け日記

SIerから転職したWebアプリエンジニアが最近のIT技術キャッチアップに四苦八苦するブログ

Python 回帰木でセッション数を予測するモデルを作成する

前回の投稿では線形回帰を使ってセッション数を予測しましたが、今回は回帰木を使ってみます。

Python GoogleAnalyticsのデータを使って線形回帰でセッション数を予測するモデルを作る - け日記

回帰木による学習・テスト

前回の投稿では、本ブログの1日あたりのセッション数(来訪回数)を予測するために、2つの説明変数(4/1を0として何日目かを表すnth、休日を表すholiday)を2次の多項式に拡張して、線形回帰で学習・テストさせました。

今回使う回帰木は、説明変数に閾値を設けて学習サンプルを分割していき(多くの実装は二分割)、リーフでの目的変数の平均値をそのリーフに分類されたサンプルの予測値とするものです。

散布図を見てみますと、概ね4つのグループ(四角枠)に分けられそうです。 回帰木で学習すると、例えばholidayが0ならばAグループ、0ならばBグループ、Aグループの内nthが40以下ならA-1グループ、41以上ならA-2グループ、・・・というように、今ある説明変数だけでもざっくりと分割できると予想されます。

scikit-learnを使った回帰木の学習では、sklearn.tree.DecisionTreeRegressorを使います。

  • 学習データとテストデータを3:1で分割しています
  • 回帰木の深さが深いほど過学習を起こしやすくなるため、最大深さ3としています
    • 4グループに分割できればいいので、二分木であれば深さ2までで良さそうですが、おそらく最初にholidayを使って平日(上部3グループ)と休日(下部1グループ)に分けられるので、平日の3グループをさらに分割するためには深さ2では足りないと予想されたため、3にしました
  • 決定木は閾値による分類のため、説明変数の正規化・標準化を必要ありません
from sklearn.tree import DecisionTreeRegressor

# features_dfの取得については前回の記事を参照
X = features_df[['nth', 'holiday']]
y = features_df['sessions']
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)

# 最大深さ3の回帰木を作成
regressor = DecisionTreeRegressor(max_depth=3)

# 学習・テスト
regressor.fit(X_train, y_train)
print('Train score: {:.3f}'.format(regressor.score(X_train, y_train)))
print('Test score: {:.3f}'.format(regressor.score(X_test, y_test)))
# Train score: 0.949
# Test score: 0.862

# プロット
plt.scatter(X['nth'], y)
plt.plot(X['nth'], pipeline.predict(X))
plt.show()

結果として決定係数R2は、学習データで0.949、テストデータで0.862となり、前回の線形回帰を使ったモデル(学習データで0.916、テストデータで0.832)よりも少し改善されました。 また、プロットした図でも、概ね最初に予想した4グループに分割されていることが確認できます。

ただし、回帰木では長期的な増加傾向を反映できていないので、7月以降のセッション数をこれを使って予測すると、おそらく線形回帰よりも性能は悪くなると予想されます。

f:id:ohke:20170720081901p:plain

回帰木を描画

最後に、学習で獲得した回帰木を描画し、どういった条件でサンプルを分割させているのかを見てみます。 回帰木の描画ではdotファイルを出力し、graphvizで画像ファイル(png)に変換します。

Graphviz | Graphviz - Graph Visualization Software

まずgraphvizをインストールしておきます。

$ brew install graphviz

Pythonからはsklearn.tree.export_graphvizをインポートして、export_graphvizでdotファイルを出力します。

from sklearn.tree import export_graphviz

export_graphviz(regressor, out_file='tree.dot', feature_names=['nth', 'holiday'])

最後に、graphvizのdotコマンドでpngファイルへ出力します。

$ dot -Tpng tree.dot -o tree.png

以下のような回帰木が図として出力されます。 valueがそのグループの平均値で、mseが平均二乗誤差です。

当初の見立て通り、最初にholidayを閾値として平日と休日のグループに分け、以降はnthの値を閾値として分割していることがわかります。 例えば、4/3(nth=3, holiday=0)のセッション数は、一番左下のノードに分類されて74.3と予測されます。

f:id:ohke:20170720082249p:plain

Python GoogleAnalyticsのデータを使って線形回帰でセッション数を予測するモデルを作る

前回の投稿で取得したGoogle Analytics(GA)のアクセスデータを使って、1日のセッション数を線形回帰で予測するモデルを作ります。

PythonでGoogle AnalyticsのデータをPostgreSQLへロードする - け日記

GAにおけるセッションは、ユーザの訪問によって開始され、サイト内でのページ遷移といった一連の操作をまとめる単位です。 セッション数=訪問数と言ってもいいかもしれません。 (以下のドキュメントが詳しいです。)

アナリティクスでのウェブ セッションの算出方法 - アナリティクス ヘルプ

準備

前回の投稿で取得したGAのデータをPostgreSQLからDataFrameに読み込みます。

セッションの最初のアクセスのみを使うので、previous_page_pathが'(entrance)‘のレコードの時刻のみを取得しています。 (previous_page_pathは、GAのディメンションの1つであるga:previousPagePathの値を格納しています。’(entrance)‘の場合は、そのセッションで前のアクセスが無い=セッション開始を表します。)

import numpy as np
import pandas as pd
import psycopg2
import mglearn
import matplotlib.pyplot as plt
import pandas.tseries.offsets as offsets
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import PolynomialFeatures
from sklearn.pipeline import Pipeline

# 接続情報
pgconfig = {
    'host': 'localhost',
    'port': '5432',
    'database': 'test',
    'user': 'ohke',
    'password': 'ohke'
}

# 接続
connection = psycopg2.connect(**pgconfig)

# ga_reportテーブルからセッション最初のアクセス時刻のみをDataFrameへロード
ga_report_df = pd.read_sql(con=connection, sql="SELECT date_hour_minute FROM ga_report WHERE previous_page_path = '(entrance)';")

結果変数と説明変数

各変数を格納するDataFrameを作成します。 4/1〜6/30(13週分)の日付をインデックスとしています。

今回はある日のセッション数を予測したいので、日毎のセッション数を集計して、結果変数(sessions)としてDataFrameにセットします。

次に、4/1を0とした時に何日目なのかを表す説明変数(nth)をセットします。

# 各変数を保持するDataFrameを作成
features_df = pd.DataFrame(index=pd.date_range('2017-04-01', '2017-06-30'))

# 各日付に開始されたセッション数(結果変数)
features_df['sessions'] = ga_report_df.groupby(lambda g: ga_report_df['date_hour_minute'][g].date())['date_hour_minute'].count()

# 4/1を0として何日目か(説明変数)
features_df['nth'] = range(91)

分布を可視化してみます。 マクロで見ると右肩上がりなのですが、数日おきにセッションが急激に落ち込んでいる日があります。 落ち込んでいる日は土日祝日で、言い換えると仕事中によく来訪されていることがわかります。

X = features_df['nth'].values.reshape(-1, 1)
y = features_df['sessions']
plt.plot(X, y, 'o')
plt.show()

f:id:ohke:20170713093403p:plain

線形回帰(単回帰)

この説明変数1つ・結果変数1つのデータを使って線形回帰(単回帰)で学習・テストさせてみます。

その結果、決定係数R2(score)は学習データで0.119、テストデータで0.062と低い値となりました。

  • 学習データとテストデータを3:1(デフォルト)で分離しています
  • coefが傾き、interceptが切片になっており、この場合、傾き0.61・切片45の1次関数となります
# 学習データとテストデータに分離
X_train, X_test, y_train, y_test = train_test_split(X_learning, y_learning, random_state=0)

# 線形回帰で学習・テスト
model = LinearRegression()
model.fit(X_train, y_train)
print('coef: {}'.format(model.coef_))
print('intercept: {}'.format(model.intercept_))
print('Train score: {:.3f}'.format(model.score(X_train, y_train)))
print('Test score: {:.3f}'.format(model.score(X_test, y_test)))
# coef: [ 0.60794719]
# intercept: 44.89795159824894
# Train score: 0.119
# Test score: 0.062

f:id:ohke:20170713141234p:plain

線形回帰(重回帰)

先程の学習では、何日目かという1つの特徴量だけで説明しようとしていましたので、マクロな増加傾向は掴んでいましたが、土日祝日の減少はモデルで表現できていませんでした。 そこで、土日祝日を表す説明変数(holiday)を追加して、重回帰分析を行います。

その結果、決定係数R2が学習データで0.865、テストデータで0.793と大きく改善されました。

  • 祝日の判定にはjholidayを使いました
  • 傾きcoef_は、2番目の説明変数holidayは-74で、土日祝日の場合に-74されることがわかります
    • 1番目の説明変数nthに関しては0.67と単回帰の場合とほぼ変わらず
import jholiday

# 土日祝日の場合は1(それ以外は0)となる特徴量holidayを追加
features_df['holiday'] = features_df.index.to_series().apply(lambda d: 1 if (d.date().weekday() >= 5) or (jholiday.holiday_name(date=d.date()) is not None) else 0)

# 学習データとテストデータに分離 
X = features_df[['nth', 'holiday']] # nthとholidayの2つを説明変数に指定
y = features_df['sessions']
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0)

# 線形回帰(重回帰)で学習・テスト
model = LinearRegression() # 単回帰と同じです
model.fit(X_train, y_train)
print('coef: {}'.format(model.coef_))
print('intercept: {}'.format(model.intercept_))
print('Train score: {:.3f}'.format(model.score(X_train, y_train)))
print('Test score: {:.3f}'.format(model.score(X_test, y_test)))
# coef: [  0.67033272 -73.9860332 ] 
# intercept: 68.45651042263141
# Train score: 0.865
# Test score: 0.793

f:id:ohke:20170713142245p:plain

線形回帰(多項式回帰)

もう少し精度を上げていきます。

プロットされた図を見ると、緩やかにですが非線形的(放物線状)に増加していることが予想されます。 先程の特徴量を多項式にして複数次元の関数にしてみます。

ここでは次元数2で学習・テストすると、学習データで0.916、テストデータで0.832の寄与率となり、先程より少し改善されました。

  • 2変数の多項式なので、(1, nth, holiday, nth2, nth×holiday, holiday2)の6つの説明変数に拡張されます
    • したがって傾きも6つになります
  • Pipelineで多項式化、線形回帰の処理を1つにまとめています
# 多項式化、線形回帰の順に実行するPipeline
pipeline = Pipeline([
    ('polynomial', PolynomialFeatures(degree=2)),
    ('regression', LinearRegression())
])

# 学習・テスト
pipeline.fit(X_train, y_train)
print('coef: {}'.format(model.coef_))
print('intercept: {}'.format(model.intercept_))
print('Train score: {:.3f}'.format(pipeline.score(X_train, y_train)))
print('Test score: {:.3f}'.format(pipeline.score(X_test, y_test)))
# coef: [  0.00000000e+00   5.06398669e-01  -1.98226501e+01   4.43661664e-03  -7.39295022e-01  -1.98226501e+01]
# intercept: 63.2018074287475
# Train score: 0.916
# Test score: 0.832

すこーし下に凸の放物線になっていることが確認できます。

f:id:ohke:20170713200107p:plain

ちなみにPolynomialFeaturesのfit_transformメソッドで、拡張された説明変数を直接取得できます。 6つに増えていることがわかります。

PolynomialFeatures(degree=2).fit_transform(X)
# array([[  1.00000000e+00,   0.00000000e+00,   1.00000000e+00,
#           0.00000000e+00,   0.00000000e+00,   1.00000000e+00],
#        [  1.00000000e+00,   1.00000000e+00,   1.00000000e+00,
#           1.00000000e+00,   1.00000000e+00,   1.00000000e+00],
#        [  1.00000000e+00,   2.00000000e+00,   0.00000000e+00,
#           4.00000000e+00,   0.00000000e+00,   0.00000000e+00],
#        ・・・

PythonでGoogle AnalyticsのデータをPostgreSQLへロードする

Google Analytics(GA)のデータを機械学習の勉強用に使えないかなと思ったことがきっかけです。 まずは、Pythonで扱いやすくするために、GAのデータをローカルのPostgreSQLにロードさせてみました。

3ステップでデータを持ってきます。

  1. GAのAPIの有効化
  2. APIからpandas.DataFrameへのロード
  3. DataFrameからテーブルへのロード

Google Analytics Reporting APIの有効化

最初にGAのデータを取得するために、Google Analytics Reporting APIを有効化する必要があります。

はじめてのアナリティクス Reporting API v4: サービス アカウント向け Python クイックスタート  |  アナリティクス Reporting API v4  |  Google Developers

設定手順としては3段階で、上のGoogleのガイドの通り進めます。

  1. Google Cloud Platformでプロジェクトを(無ければ)作成します
  2. IAM管理コンソールでサービスアカウント(役割は閲覧者)を作成し、認証情報をJSONファイルで取得します f:id:ohke:20170702131102p:plain
  3. Google Analytics Webコンソールの管理画面のユーザ管理にて、作成したサービスアカウントのメールアドレスを「表示と分析」権限でアクセスを許可します f:id:ohke:20170702131118p:plain

設定したら、Pythonサンプルの実行に必要なパッケージをインストールします。

pip install google-api-client-python

次に上記ガイドのサンプルPythonコードをコピー・実行します。

  • KEY_FILE_LOCATIONは取得したjsonファイルのパス、SERVICE_ACCOUNT_EMAILは設定3.で追加したメールアドレスを設定します
  • VIEW_IDはGAの画面から確認できます
  • python 2系のコードなので、3系で実行する場合はprintを関数形式に書き換えます
  • サンプルではp12ファイルを読み込んでいますので、jsonファイルを読み込むように書き換えます
# credentials = ServiceAccountCredentials.from_p12_keyfile(SERVICE_ACCOUNT_EMAIL, KEY_FILE_LOCATION, scopes=SCOPES)
credentials = ServiceAccountCredentials.from_json_keyfile_name(KEY_FILE_LOCATION, SCOPES)

うまくいくと、過去7日分のセッション数が表示されます。

Date range (0)
ga:sessions: 811

PythonからGoogle Analytics APIへ接続

metricsやdimensionsで取得可能なデータは以下で説明されています。

Dimensions & Metrics Explorer  |  Analytics Reporting API v4  |  Google Developers

今回は、セッション内のページビュー(ga:pageviews)など3種類のmetricsと、アクセスしたパス(ga:pagePath)など9種類のdimensionsを、過去3ヶ月(2017/4〜2017/6)取得します。

  • APIの制約で1回のリクエストで取得可能な個数が決まっており、超過した場合は400が返ってきます
analytics = initialize_analyticsreporting()

report_3months = analytics.reports().batchGet(
            body={
            'reportRequests': [
                {
                    'viewId': '123456789',
                    'dateRanges': [
                        {'startDate': '2017-04-01', 'endDate': '2017-06-30'}
                    ],
                    'pageSize': 10000,
                    'metrics': [
                        {'expression': 'ga:pageviews'}, 
                        {'expression': 'ga:sessionDuration'},
                        {'expression': 'ga:timeOnPage'},
                    ],
                    'dimensions': [
                        {'name': 'ga:dateHourMinute'},
                        {'name': 'ga:sessionCount'},
                        {'name': 'ga:daysSinceLastSession'},
                        {'name': 'ga:sourceMedium'},
                        {'name': 'ga:operatingSystem'},
                        {'name': 'ga:deviceCategory'},
                        {'name': 'ga:pagePath'},
                        {'name': 'ga:previousPagePath'},
                        {'name': 'ga:exitPagePath'},
                    ],
                    'orderBys': [
                        {'fieldName': 'ga:dateHourMinute'},
                    ]
                }]
        }
    ).execute()
report_3months

実行すると以下のようなディクショナリが返ってきます。 rowsにmetricsとdimensionsが入っています。

{'reports': [{'columnHeader': {'dimensions': ['ga:dateHourMinute',
     'ga:sessionCount',
     'ga:daysSinceLastSession',
     'ga:sourceMedium',
     'ga:operatingSystem',
     'ga:deviceCategory',
     'ga:city',
     'ga:pagePath',
     'ga:pageDepth'],
    'metricHeader': {'metricHeaderEntries': [{'name': 'ga:pageViews',
       'type': 'INTEGER'},
      {'name': 'ga:sessionDuration', 'type': 'TIME'},
      {'name': 'ga:bounces', 'type': 'INTEGER'},
      {'name': 'ga:pageViews', 'type': 'INTEGER'},
      {'name': 'ga:timeOnPage', 'type': 'TIME'}]}},
   'data': {'maximums': [{'values': ['7', '5295.0', '2', '7', '1795.0']}],
    'minimums': [{'values': ['0', '0.0', '0', '0', '0.0']}],
    'rowCount': 7785,
    'rows': [{'dimensions': ['201704010009',
       '1',
       '0',
       'google / organic',
       'iOS',
       'mobile',
       '/entry/2016/12/03/231909',
       '(entrance)',
       '/entry/2016/12/03/231909'],
      'metrics': [{'values': ['1', '0.0', '0.0']}]},
     ...],
    'totals': [{'values': ['7876',
       '344302.0',
       '6208',
       '7876',
       '344286.0']}]}}]}

APIからDataFrameへロード

次に、取得したディクショナリからmetricsとdimensionsの値をDataFrameへロードします。

DataFrameは、0開始の連番をインデックスにしてます。
また、デフォルトでは文字列型として読み込まれ、この後のPostgreSQLへの投入もやりづらくなるので、時刻や数値については引数dtypeで明示的にnumpyの型を指定しています。 ちなみに、dateHourMinuteは一般的ではないフォーマット(yyyyMMddHHmm、例えば"201704120715")ですが、pandas.to_datetimeメソッドが柔軟に型変換してくれます。

import numpy as np
import pandas as pd

rows = report_3months['reports'][0]['data']['rows']

# 0からの連番をインデックスに設定した空のDataFrameを作成
ga_df = pd.DataFrame(index=range(len(rows)), columns=[])

# dimensions
ga_df['date_hour_minute'] = pd.to_datetime(pd.Series([r['dimensions'][0] for r in rows]))
ga_df['session_count'] = pd.Series([r['dimensions'][1] for r in rows], dtype=np.int32)
ga_df['days_since_last_session'] = pd.Series([r['dimensions'][2] for r in rows], dtype=np.int32)
ga_df['source_medium'] = pd.Series([r['dimensions'][3] for r in rows])
ga_df['operating_system'] = pd.Series([r['dimensions'][4] for r in rows])
ga_df['device_category'] = pd.Series([r['dimensions'][5] for r in rows])
ga_df['page_path'] = pd.Series([r['dimensions'][6] for r in rows])
ga_df['previous_page_path'] = pd.Series([r['dimensions'][7] for r in rows])
ga_df['exit_page_path'] = pd.Series([r['dimensions'][8] for r in rows])

# metrics
ga_df['pageviews'] = pd.Series([r['metrics'][0]['values'][0] for r in rows], dtype=np.int32)
ga_df['session_duration'] = pd.Series([r['metrics'][0]['values'][1] for r in rows], dtype=np.float64)
ga_df['time_on_page'] = pd.Series([r['metrics'][0]['values'][2] for r in rows], dtype=np.float64)

ga_df

f:id:ohke:20170707093545p:plain

DataFrameからPostgreSQLへロード

最後にDataFrameをPostgreSQLへロードさせます。

pandas.DataFrame.to_sqlメソッドでDataFrameをそのままDBのテーブルを作成できます。 ただし、DB-APIによる接続はsqlite3以外でサポートされていないため、SQLAlchemyのEngineを引数として渡す必要があります。

[python] pandasのDataFrameからpostgresにテーブルを作成 - Qiita

from sqlalchemy import create_engine
engine = create_engine("postgresql://ohke:ohke@localhost:5432/test")
ga_df.to_sql("ga_report", engine, if_exists='replace')

以上でPostgreSQLにテーブルが作成され、GAのデータが投入されます。

f:id:ohke:20170707093712p:plain

次回は、このデータを加工して機械学習に使っていきます。