2014/12/21更新

[MongoDB] フロントエンドエンジニアにもできるMongoDBを使ったログ分析

このエントリーをはてなブックマークに追加      

こんにちは、@yoheiMuneです。
今日は、CyberAgent エンジニア Advent Calendar 2014の11日目の記事として、MongoDBを使ってユーザー行動を分析する方法について紹介したいと思います。

画像

Special Thanks to https://flic.kr/p/8LCNW2




目次




Webサービスで行っている分析の話

現在携わっているWebサービスでは、フロントエンドエンジニアとしてだけではなく分析担当としても活動しています。分析なかなか楽しいですよ。 「分析をして問題点やチャンスを発見してそれに対応する」という作業を繰り返してサービスをグロスさせるようなお仕事です。

案件での分析には最近人気のAARRRモデルを利用しています。AARRRモデルについては以下をご参照ください。


サービス全体を5つに分けて、それぞれをいい感じに分析できる素敵なフレームワークです。

さて今回はAARRRの中でも、Activataion(ユーザーの活性化)についての分析を紹介します。 新規ユーザー獲得から定着までどうすれば良いのでしょうか? そのゴール設計から分析までをご紹介したいと思います。

それでは、分析において最も大切なゴール設計をみていきましょう。



分析のゴールを設計する

分析を行う前にまずはゴールを設計を行います。
ゴールとは「分析をした結果、どのようなアウトプットを出すか」を定義することです。 そのアウトプットとサービス側の理想値を比較することで、問題点やチャンスを発見することができます。

そして今回扱うユーザー活性化(Activation)では多くの場合、「サービスの最大の魅力を最短距離で体験してもらう」や「◯日以内に指定の機能をX回使ってもらう」といったことをゴールに設定します。

例えばYoutubeであれば動画を見ること、写真加工アプリであれば加工した写真を見ることと、それぞれのアプリで最も素晴らしいサービスがあるはずです。 いち早くそれを体験をしてもらいサービスを好きになってもらうことで、ユーザーの活性化を行います。

以上のことから、今回の場合の分析のゴールは「設定したゴールの達成状況を可視化する」とすることができます。



なぜMongoDBを使うのか

実は特に深い理由はありません。 フロントエンドを最近メインでやっていてJavaScriptがとても好きなので、JavaScriptのように扱えるMongoDBを選択した次第です。

MongoDBは先日のブログでも紹介させていただいた通り、フロントエンドエンジニアにとって馴染みやすいデータベースです。MongoDBって何?という方はぜひ、フロントエンドエンジニアにオススメなデータベース、MongoDBに入門をご覧ください。

それでは次に、分析対象となるログについて紹介します。



分析対象のログ

分析にはアプリケーションサーバーのログを利用しています。 事前の取り決めに従って、ページ遷移したり特定のアクションをした場合にログが出力されます。 具体的な形式はTSV(Tab Separated Value)で以下のような項目が存在します。
項目 意味や具体例
日付 ログの日付。2014-12-08 10:21:22など。
セッションID ユーザーを一意に特定するためのID。Cookieとかで持つやつ。
ユーザーID サービスのユーザーID。ログインしていない場合には空欄。
行動タイプ viewやreadなど何のログなのかを示す。
行動サブタイプ 行動タイプのサブジャンル。例えばread行動のサブジャンルとして「立ち読み」がある。
URL ページのURL
など
こんな感じの情報が1つの行動につき1行出力され、それが1日に数百万行出力されます。 これを加工して分析して、有益な情報を取り出します。

それでは次に、分析のアウトプットを先に見てみたいと思います。 分析する前にアウトプットを意識することで、分析する時間を短縮することができます。



分析のアウトプット

ユーザーの活性化を分析する場合には「流入経路別に分析する」ことが大切です。 各流入経路ごとに設定したゴールをどれくらい達成しているのかを可視化します。

今回は以下の表のように、流入経路別に数値を把握できる仕組みを作りました。
経路 流入数 獲得数 直帰率 翌日継続率 Action1率 Action2率
経路001 3,200 200 30% 15% 50% 20%
経路002 12,000 100 90% 3% 10% 5%
経路003 500 300 12% 40% 20% 90%
このような表を作ることで、例えば以下のことがわかります。
  • 経路002は流入数の割に獲得効率や翌日継続率も悪い。ここを改善するサービスをグロスできるかも。
  • 経路003は獲得効率と翌日定着率が良い。何が良いのかをさらに分析すれば、他の流入経路の改善に生かせる。
  • Action2は翌日継続率に良い結果を与えている可能性がある。もう少し詳しく調べてみたい。
このようにユーザーの活性化は流入経路別に分析をすることで、問題点やチャンスを浮き彫りにすることができます。

今回はこの表を作るためにログ分析を行います。 ここまでで分析のスタート地点とゴール地点がわかりました。 あとはその間の道をつなぐためにプログラムを書くだけです。

次の章では、MongoDBでの分析を行うための準備段階を紹介します。



分析(準備編)

ここでは分析の準備編として、サーバーログをダウンロードしてきて、整形して、MongoDBに登録するまでの処理を紹介します。

サーバーログの取得

分析対象のログは分散されたWebアプリケーションサーバーに存在するのでそれを取得するプログラムを書きます。 今回は以下のようなシェルでそれを実現しました。
### サーバーからログを取得する処理(説明のため簡略化しています)
## 対象日付を指定
TARGET_DATE=$1

## 作業場所
WORKSPACE=/tmp/analyze/logs/$TARGET_DATE
rm -rf $WORKSPACE
mkdir -p $WORKSPACE

## サーバー1台ずつログを採取
array1=("api1" "api2" "api3")
for i in "${array1[@]}"
do
    # 保存先
    toPath=$WORKSPACE/$i
    mkdir -p $toPath
    # SCP(.ssh/configでssh設定をしています)
    REMOTE="server_${i}:/var/log/appserver/activity.log.${TARGET_DATE}.gz"
    scp $REMOTE $toPath
done
これで各サーバーからログをダウンロードできました。 次にこれを整形してMongoDBに登録します。

ログの整形と、本番DBデータのブレンドと、MongoDBへの保存

ここでは、ダウンロードしたログファイルをMongoDBに登録するところまでを紹介します。
まずは、各ログファイルがgzip圧縮されているので解凍します。 今回はNode.jsでやってみました。
// gzip解凍
var fs    = require('fs');
var zlib  = require('zlib');
function exeUnzip (path) {
    var gzip = fs.readFileSync(path);
    zlib.gunzip(gzip, function (err, content) {
        content = content.toString('utf-8');
        // これで解凍されました
    });
}
gunzipは別にNodeでやらなくてもいいんですが、Nodeでもできるのかなーと気になったので実装した次第でした。

ここでちょっとだけ分析データに一工夫を加えます。 分析対象のログにはユーザーIDがありますが、どのユーザーIDが新規獲得したのかはログだけではわかりません。 そこで本番のMySQLから指定した日付で、獲得したユーザーIDを取得しておきます。
var util  = require('util');
var mysql = require('mysql');
var dateString = '2014-12-08';
var setting = JSON.parse(fs.readFileSync('./setting.json', 'utf-8'));
var connection = mysql.createConnection(setting.mysql);
var sql = util.format('select id from user where first_login_date="%s"', dateString);
connection.query(sql, function (err, rows) {
    var newUserIds = [];
    rows.forEach(function (row) {
        newUserIds.push(row.id);
    });
    connection.end();
    // これで獲得したユーザーIDを本番DBから取得しました
});
解凍済みのログと本番DBから取得した新規ユーザーのIDを元に、MongoDBにデータを登録していきます。 MongoDBのInsert文では複数データを一気に登録できるので、その機能を用いてどんどんと登録します。
// 簡略化のためエラー処理は略記しています。 
var _ = require('underscore');
var content = /*gunzipした内容*/
var newUserId = /*本番DBから取得した獲得ユーザーのID*/
var datas = [];
content.split('\n').forEach(function (line) {
    var items = line.split('\t');
    var data = {};
    // 例えばこんな感じ
    data.datetime = items[0];  // 日時
    data.sessionId = items[1]; // セッションID
    data.userId = items[2];    // ユーザーID
    data.type = items[2];      // 行動タイプ
    data.subType = items[3];   // 行動サブタイプ
    data.url = items[4];       // URL
    if (_.contains(newUserIds, data.userId)) {
        data.newbie = true;    // 新規ユーザーフラグ
    }
    datas.push(data);
});

// MongoDBに登録する
var MongoClient = require('mongodb').MongoClient;
var mongoUrl    = 'mongodb://localhost:27017/analytics';
MongoClient.connect(mongoUrl, function (err, db) {
    var collection = db.collection('logs');
    collection.insert(datas, function (err, result) {
        db.close();
    });
});
こんな感じでTSVファイルから必要なデータを抽出して、本番DBの情報をブレンドして、MongoDBに登録します。

これで分析を行うための準備は整いました。 次はこれを用いて先ほど示したゴールのデータを算出したいと思います。



分析(実践編)

ここまででMongoDB内に分析対象のデータが揃いました。 ここからは実際に分析を行いたいと思います。 ここでは「landing001」というページに新規ユーザーがランディングした場合の、各種数値を計測したいと思います。

分析には、Mongo Shellという機能を用います。 例えば以下のようにMongoDBを読み込むことで、引数に指定したJavaScriptファイルを元に処理を実行してくれます。
$ mongo analyze.js
analyze.jsの中身は例えばこんな感じです。
var cursor = new Mongo().getDB('analytics').logs.find({
    datetime: /^2014-12-08/
}); 
print('count: ' + cursor.size());
このようにDB操作をJavaScriptで記述できるところもフロントエンドエンジニアにとって嬉しいですね。 今回はこのMongoShellを用いて分析を行います。

データ構造の変更:セッションIDごとにログを持つ

現在の分析対象は1アクションごとのログになっていますが、ユーザー単位での分析を行いたいので、セッションIDごとにデータを持つように整形します。
// 目的の姿
var sessionIdLogsMap = {
    sessionIdx: [log, log, log, log],
    sessionIdy: [log, log],
    sessionIdz: [log, log, log],
    ...
};
このように一連のユーザー行動を1つのデータとして扱うと今回は分析しやすいです。 そしてさらに、landing001ページにランディングしたデータのみに絞り込みます。
// landing001にランディングしたログのみに絞り込む
var sessionIdLogsMapAtLanding001 = {
    sessionIdx: [log, log, log, log],
    sessionIdz: [log, log, log],
    ...
};
このデータを元に分析を行います。

流入数、獲得数の計測

まずは流入数と獲得数を計測します。今回は、それぞれの数は以下のように定義しました。

流入数 = 新規獲得ユーザー数 + ログインせずに回遊したユーザー数


獲得数 = 新規獲得ユーザー数


この数値を出していきます。
var inCount = 0;
var aquisitionCount = 0;
_.forEach(sessionIdLogsMapAtLanding001, function (logs, sessionId) {
    var logedIn = false;
    var newbie = false;
    _.forEach(logs, function (log) {
        if (log.userId) {
            logedIn = true;
        }
        if (log.newbie) {
            newbie = true;
        }
    });
    if (newbie || !logedIn) {
        inCount++;
    }
    if (newbie) {
        aquisitionCount++;
    }
});
print('inCount: ' + inCount);
print('aquisitionCount: ' + aquisitionCount);
こんな感じで流入数を獲得数を出します。

直帰率

次に直帰率です。直帰とは「ランディングしたけど直ぐに離脱したユーザー」のため、ログが1件しかないセッションの数を数えます。
var bounceCount = 0;
_.forEach(sessionIdLogsMapAtLanding001, function (logs) {
    if (logs.length === 1) {
        bounceCount++;
    }
});
var bounceRaito = Math.floor(bounceCount * 1000 / inCount) / 10;
print('bounceRaito: ' + bounceRaito + '%'); // 23.2%など

翌日継続率

翌日継続率とはユーザーが翌日にもサービスに訪れたかどうかの指標です。再来率や定着率といった表現も使います。 ここでの定義は以下とします。

翌日継続率 = 翌日再来したユーザー数


実装ではこんなイメージです。
var sessionIds = [];
_.forEach(sessionIdLogsMapAtLanding001, function (logs, sessionId) {
    sessionIds.push(sessionId);
});
// 翌日の数を確認する
var cursor = new Mongo().getDB('analytics').logs.find({
    datetime: /^2014-12-09/,  // 翌日を指定
    sessionId: {$in: sessionIds} // in句を使う
});
// 翌日ユーザー数を出す
var nextDaySessionLogsMap = convertToMap(cursor);
var nextDayUserCount = countMap(nextDaySessionLogsMap);
var nextDayUserRaito = Math.floor(nextDayUserCount * 1000 / aquisitionCount) / 10;
print('nextDayUserRaito: ' + nextDayUserRaito); // 30.2%とか
MongoDBではin句もいい感じに使えるので、なんだか使いやすい印象です。

そして他の「Action1率」や「Action2率」も似たような算出方法になるので、ここでは紹介は省略させていただきます。



いくつか工夫したこと

分析するにあたりMongoShellというJavaScriptを使えることはとても便利なのですが、JavaScript自体は機能が充実していないためいくつか工夫する必要がありました。 ここでは工夫点を2つ紹介したいと思います。

MapクラスやSetクラスの作成

JavaScriptのデータ構造に配列はあるものの、MapやSetは存在しない(ObjectをMapとして使っても必要なインターフェースが足りない)ため、追加で作りました。 例えばMapだと以下のような感じです。
var Map = function () {
    this.data = {};
};
Map.prototype.put = function (key, value) {
    this.data[key] = value;
};
Map.prototype.get = function (key, defaultValue) {
    return this.data[key] || defaultValue;
};
Map.prototype.keys = function () {
    var keys = [];
    util.forEach(this.data, function (value, key) {
        keys.push(key);
    });
    return keys;
};
Map.prototype.size = function () {
    return util.getCountInMap(this.data);
};
// などなど
ECMAScript6が標準になると、もう少しJavaScriptの実装が楽になるかもしれないですね。

ファイルの分割

MongoShellでは、JavaScriptファイルから他のJavaScriptファイルを読み込んで利用することができます。
// Mapクラスを読み込む
load('./common/Map.js');
// 以降分析用のコードを書く
この機能のおかげでコードの再利用ができるようになって、なかなか便利に実装できます。 ただ問題がload関数に指定するパスが相対パスの場合(./Map.jsなど)、ディレクトリ起点は対象のJavaScriptではなく、mongoコマンドを実行しているディレクトリになります。

しかしPHPみたいに、実行ディレクトリに依存しない読み込みはできません。
// PHP
require_once dirname(__FILE__)."/Util.php";
なので泣く泣く以下のような実装にして、mongoの実行ディレクトリに依存しないモジュールを読み込みにしました。
// analytics-cuiがプロジェクト名
// pwd()でカレントディレクトリを取得できる
var HOME_DIR = pwd().split('analytics-cui')[0] + 'analytics-cui/';
load(HOME_DIR + 'common/Map.js');
もうちょっと良い実装ができないかもう少し調べてみようと思います。



参考URL

今回の実装を行う上で以下のページをすごく参照しました。ありがとうございます。

-The mongo Shell(英語) | MongoDB

-mongo Shell Methods(英語) | MongoDB



他のアドベントカレンダー

今回の記事も含め、以下の記事を2014年アドベントカレンダーとして執筆しました。 気になる記事がありましたら幸いです。

- [CSS] Object Oriented CSSを学んで綺麗なコードを書く

- [MongoDB] フロントエンドエンジニアにオススメなデータベース、MongoDBに入門

- [MongoDB] フロントエンドエンジニアにもできるMongoDBを使ったログ分析

- [JavaScript] 最近のjQueryとの付き合い方いろいろ

- [フロントエンド] スキャフォールド機能を提供するYEOMANに入門する

最後に

今回はMongoDBを使ったユーザー行動分析を紹介させていただきました。 実装のほとんどはJavaScriptでできるので、フロントエンドエンジニアもできて楽しい限りです! 担当案件では他にも、ユーザーログからユーザーの行動フロー分析をしたり、売上分析をしたり、色々と楽しく分析を行っています。

ただ分析するのは難しいですよね。何が難しいって、ゴールを決めるところです。 分析の勉強もしながら、成果の出る分析をもっと行っていきたいなぁと思う今日このごとです。

さてCyberAgent エンジニア Advent Calendar 201411日目の記事は終了です。次はmtknnktmさんです。何の話か楽しみですー。

今後も本ブログでは、フロントエンドに関する情報を書きたいと思います。気になった方はぜひ、本ブログのRSSTwitterをフォローして頂けると幸いです ^ ^。
最後までご覧頂きましてありがとうございました!





こんな記事もいかがですか?

RSS画像

もしご興味をお持ち頂けましたら、ぜひRSSへの登録をお願い致します。