GA4のサンプルデータを用いてロジスティック回帰から継続ユーザーの特徴を探る

## 今回やること
BigQueryのサンプルデータにある[パズルゲーム「Flood-It!」のGA4アクセスログデータセット](https://developers.google.com/analytics/bigquery/app-gaming-demo-dataset)を用いて、相関行列、ロジスティック回帰から継続/ 離脱ユーザーの特徴を探ってみる。（外れ値/異常値処理、精度向上、CV等は省く）

※過去に同データセットを用いて書いた記事
[GA4のサンプルデータから成長指数を算出してみる](https://qiita.com/NS007/items/33a7a80aedf16d7fabe3)
[GA4のサンプルデータからMAUに占めるユーザータイプ別に指標を作ってみる](https://qiita.com/NS007/items/a16f9cb254b0d9a48ace)
[GA4のサンプルデータからタイプ別＆定着度合い別に継続率を出してみる](https://qiita.com/NS007/items/b2c75f4ce27b64dbd89c)
[GA4のサンプルデータからエンゲージメント指標 アクティブ率を出してみる](https://qiita.com/NS007/items/0a5b56e9c2cb9e1928a8)


## 前処理（SQL）
特徴量は対象期間の起動日数、代表的なイベントのイベント数、ユーザーフラグ（＊[こちら参照](https://qiita.com/NS007/items/a16f9cb254b0d9a48ace)）とし、目的変数を継続:1、離脱：0とする。

```sql
  WITH tmp AS (
    SELECT DISTINCT
      DATE_TRUNC(PARSE_DATE('%Y%m%d', event_date), MONTH) AS event_month
      , PARSE_DATE('%Y%m%d', event_date) AS event_date
      , event_name
      , user_pseudo_id
    FROM `firebase-public-project.analytics_153293282.events_*`
    WHERE _TABLE_SUFFIX BETWEEN '20180701' AND '20180931'
    AND event_name IN ('session_start', 'first_open')
  )
  #全ユーザーログ
  , mst_all_users AS (
    SELECT DISTINCT
      event_month
      , user_pseudo_id
    FROM tmp
  )
  #新規ユーザーログ
  , mst_fitst_users AS (
    SELECT DISTINCT
      event_month
      , user_pseudo_id
      , 'new' AS flag
    FROM tmp
    WHERE event_name = 'first_open'
  )
  #継続ユーザーログ
  , mst_continue_users AS (
    SELECT DISTINCT
      A.event_month
      , A.user_pseudo_id
      , 'continue' AS flag
    FROM mst_all_users AS A #今月
    INNER JOIN mst_all_users AS B #先月
      ON A.user_pseudo_id = B.user_pseudo_id
      AND DATE_DIFF(A.event_month, B.event_month, MONTH) = 1
  )
  #復帰ユーザーログ
  , mst_return_users AS (
    SELECT
      event_month
      , user_pseudo_id
      , 'return' AS flag
    FROM (
      SELECT event_month, user_pseudo_id FROM mst_all_users
      EXCEPT DISTINCT
      SELECT event_month, user_pseudo_id FROM mst_fitst_users
      EXCEPT DISTINCT
      SELECT event_month, user_pseudo_id FROM mst_continue_users
    )
  )
  #フラグ付き全ユーザー
  , mst_union_users AS (
    SELECT event_month, user_pseudo_id, flag FROM mst_fitst_users
    UNION ALL
    SELECT event_month, user_pseudo_id, flag FROM mst_continue_users
    UNION ALL
    SELECT event_month, user_pseudo_id, flag FROM mst_return_users
  )
  #翌月継続ユーザーをJOIN
  , mst_join_next_month AS (
    SELECT
      A.event_month
      , A.flag
      , A.user_pseudo_id
      , MAX(IF(B.user_pseudo_id IS NOT NULL, 1, 0)) AS next_month_flag
    FROM mst_union_users AS A #当月
    LEFT JOIN mst_all_users AS B #翌月
      ON A.user_pseudo_id = B.user_pseudo_id
      AND DATE_DIFF(B.event_month, A.event_month, MONTH) = 1
    WHERE
      A.event_month = '2018-08-01'
    GROUP BY 1, 2, 3
  )
  #起動日数を取得
  , mst_action_days AS (
    SELECT
      user_pseudo_id
      , COUNT(DISTINCT event_date) AS action_days
    FROM tmp
    WHERE event_month = '2018-08-01'
    GROUP BY 1
  )
  #各種イベント数のログ
  , event_log AS (
    SELECT DISTINCT
      user_pseudo_id
      , event_name
      , COUNT(*) AS num_events
    FROM `firebase-public-project.analytics_153293282.events_*`
    WHERE _TABLE_SUFFIX BETWEEN '20180801' AND '20180831'
    AND event_name IN ('screen_view', 'post_score', 'select_content', 'level_up'
      , 'level_retry', 'level_start', 'level_end', 'level_fail', 'level_reset'
      , 'level_start_quickplay', 'level_end_quickplay', 'level_complete_quickplay', 'level_fail_quickplay')
    GROUP BY 1, 2
  )
  SELECT
    A.user_pseudo_id
    , A.next_month_flag
    , A.flag AS user_flag
    , B.action_days
    , MAX(IF(event_name = 'screen_view', num_events, 0)) AS num_screen_view
    , MAX(IF(event_name = 'post_score', num_events, 0)) AS num_post_score
    , MAX(IF(event_name = 'select_content', num_events, 0)) AS num_select_content
    , MAX(IF(event_name = 'level_up', num_events, 0)) AS num_level_up
    , MAX(IF(event_name = 'level_retry', num_events, 0)) AS num_level_retry
    , MAX(IF(event_name = 'level_start', num_events, 0)) AS num_level_start
    , MAX(IF(event_name = 'level_end', num_events, 0)) AS num_level_end
    , MAX(IF(event_name = 'level_fail', num_events, 0)) AS num_level_fail
    , MAX(IF(event_name = 'level_reset', num_events, 0)) AS num_level_reset
    , MAX(IF(event_name = 'level_start_quickplay', num_events, 0)) AS num_level_start_quickplay
    , MAX(IF(event_name = 'level_end_quickplay', num_events, 0)) AS num_level_end_quickplay
    , MAX(IF(event_name = 'level_complete_quickplay', num_events, 0)) AS num_level_complete_quickplay
    , MAX(IF(event_name = 'level_fail_quickplay', num_events, 0)) AS num_level_fail_quickplay
  FROM mst_join_next_month AS A
  LEFT JOIN mst_action_days AS B
    ON A.user_pseudo_id = B.user_pseudo_id
  LEFT JOIN event_log AS C
    ON A.user_pseudo_id = C.user_pseudo_id
  GROUP BY
    1, 2, 3, 4
```


## ロジスティック回帰（Python）
```py
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import seaborn as sns
from sklean.model_selection import train_test_split
from sklean.metrics import accuracy_score
from sklearn.preprocessing import StandardScaler
from sklearn.linearmodel import LogisticRegression

df = pd.read_csv('event_log.csv')
df = df.drop(['user_pseudo_id'], axis=1)

#エンコーディング
cat_cols = df.select_dtypes(include=object)
num_cols = df.select_dtypes(exclude=object)
cat_cols_en = pd.get_dummies(cat_cols)
data = pd.merge(cat_cols_en, num_cols, left_index=True, right_index=True)

#相関行列
df_corr = data.corr()
plt.figure(figsize=(15, 15))
sns.heatmap(df_corr, vmax=1, vmin=-1, center=0, annot=True, cmap='Blues')
plt.show()

#前処理
X = data.drop(['next_month_flag'], axis=1)
y = data['next_month_flag']

X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, shuffle=True, random_state=42)

##標準化
num_col_names = X_train.columns[3:]
scaler = StandardScaler()
scaler.fit(X_train[num_col_names])

X_train[num_col_names] = scaler.transform(X_train[num_col_names])
X_test[num_col_names] = scaler.transform(X_test[num_col_names])

##ロジスティック回帰
model_lr = LogisticRegression(max_iter=100, multi_class='ovr', solver='liblinear', C=0.1, penalty='l1', random_state=0)
model_lr.fit(X_train, y_train)

y_test_pred = model_lr.predict(X_test)
ac_score = accuracy_score(y_test, y_test_pred)
print('accuracy_score:', ac_score)

##混合行列
from sklearn.metrics import confusion_matrix

cm = confusion_matrix(y_test, y_test_pred)
plt.figure(figsize=(6, 4))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues')
plt.xlabel('pred')
plt.ylabel('label')

##回帰係数
importances = model_lr.coef_[0]
indices = np.argsort(importances)[::1]

plt.figure(figsize=(10, 10))
plt.title('LogisticRegression coef')
plt.barh(range(len(indices)), importances[indices])
plt.yticks(range(len(indices)), X_train.columns[indices], rotation=0)
plt.show()
```

### 相関行列
重要なのは翌月継続フラグ（next_month_flag）との相関係数になるが、起動日数が多いこと、そもそも先月からの継続ユーザーであることが比較的高い相関を持っている。逆に新規ユーザーで合ったり、復帰ユーザーは負の効果を示している。
また、一部レベルに関する指標同士で高い相関を持っており、ゲームにトライしている数が高ければ高いほど同じようなイベントが飛ぶことがわかる。（イベントが何であるか詳細には調べても出なかった）
![相関行列.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233689/28e1280e-0318-c30d-81da-a8512cc99c5f.png)


### 混合行列
正解率が79.9%であったので、特徴量エンジニアリングを特に行っていないロジスティック回帰としては肌感に合う結果であったので、比較的精度があると言える。
L1正則化（C=0.1）を行っているが、グリッドサーチを行えばもう少しばかり精度は良くなるであろう。
![混同行列.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233689/1d6f98ad-329c-db14-66d1-6bf0d71dd633.png)


### 回帰係数（特徴量重要度）
各量的変数を標準化しているので、回帰係数の大きさで特徴量の重要度を見ることができる。（外れ値処理はしていないが）
相関行列と同じような結果になるが、起動日数が最も継続に寄与する変数であり、ついで継続ユーザーであることがプラスに寄与している。
一方で、新規ユーザー、復帰ユーザーはマイナスの効果を出しており、そのほか変数は正則化により0となっている。
![回帰係数.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233689/f53453cc-e831-fd8d-afea-52898030fdae.png)


### 感想
実務においてはもっと丁寧に行うがおおよそ同じようなアウトプットが出ており、継続しているユーザーはずっと継続し、新規や復帰ユーザーを継続に転換することはなかなか難しい。