Velvet Theory AIとデータの話を、やわらかく、筋道立てて。

機械学習の全体地図(8)通しチュートリアル:解約予測

解約予測を題材に、課題の定義からデータ分割、前処理、モデルの比較、評価、チューニング、本番への載せ方、監視までをPythonで通して実装します。

連載 機械学習の全体地図 第8回/全8回
  1. 第1回全体像と「学び方」
  2. 第2回タスクとモデル
  3. 第3回作って確かめる手順
  4. 第4回評価指標と落とし穴
  5. 第5回深層学習とLLM
  6. 第6回運用と責任あるAI
  7. 第7回Pythonで書く
  8. 第8回通しチュートリアル:解約予測

通しチュートリアル:解約予測

架空の通信会社の顧客データで、④手順の流れを1本のコードで最後までたどります。前のセルで作った変数を使うので、上から順に実行してください。ColabやJupyterで動かすのがおすすめです。必要なのは scikit-learn 1.5以上と pandas で、追加のインストールは要りません(scipy と joblib は scikit-learn と一緒に入ります)。下のコードはすべて scikit-learn 1.8 で実行して確認しています。

手順1課題を定義する

目的は、解約しそうな顧客を早めに見つけて引き止めの連絡をすることです。②タスクは二値分類(解約する/しない)。評価指標は、解約者が少数派なのでPR-AUCとし、連絡できる人数に応じて適合率・再現率も見ます。超えるべき基準は、何も学習しないモデル(DummyClassifier)です。

手順2データを集める

練習用に3,000人分の架空データを作ります。実務では、ここがデータベースからの取り出しにあたります。解約の「本当の仕組み」をコードに書いていますが、モデルはこれを知らない前提で進めます。

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 3000
df = pd.DataFrame({
    "契約月数": rng.integers(1, 73, n),
    "月額料金": rng.normal(6000, 2000, n).clip(1500, 15000).round(-1),
    "プラン": rng.choice(["ライト", "スタンダード", "プレミアム"], n, p=[.4, .4, .2]),
    "支払方法": rng.choice(["カード", "口座振替", "コンビニ"], n, p=[.5, .35, .15]),
    "問い合わせ回数": rng.poisson(1.5, n),
})
# 解約のしやすさ(現実には分からない「本当の仕組み」)
z = (-1.6 - 0.045 * df["契約月数"] + 0.00022 * df["月額料金"]
     + 0.45 * df["問い合わせ回数"] + 0.9 * (df["支払方法"] == "コンビニ")
     - 0.6 * (df["プラン"] == "プレミアム"))
df["解約"] = (rng.random(n) < 1 / (1 + np.exp(-z))).astype(int)
# 現実のデータらしく欠損を混ぜる
df.loc[rng.random(n) < 0.05, "月額料金"] = np.nan
df.loc[rng.random(n) < 0.03, "支払方法"] = np.nan

print(df.shape)
print(df.head(3))

出力例

(3000, 6)
   契約月数    月額料金     プラン  支払方法  問い合わせ回数  解約
0     7  7930.0  スタンダード  コンビニ        1   1
1    56  4550.0  スタンダード   カード        3   0
2    48  4290.0     ライト  口座振替        1   0

手順3データを分割する

最初にテスト用の20%を取り分け、最後の評価まで使いません。stratify で、解約率が両方で同じ(約27%)になるようにしています。

from sklearn.model_selection import train_test_split

X = df.drop(columns="解約")
y = df["解約"]
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=0)
print("学習用", X_train.shape, "テスト用", X_test.shape)
print("解約率 学習用", round(y_train.mean(), 3), "テスト用", round(y_test.mean(), 3))

出力例

学習用 (2400, 5) テスト用 (600, 5)
解約率 学習用 0.272 テスト用 0.272

手順4データを観察する

観察は学習用データだけで行います(テスト用データを見て判断すると、最後の評価が甘くなるため)。支払方法がコンビニの人は解約率が高く、解約した人は契約月数が短い傾向が見えます。

train = X_train.assign(解約=y_train)
print("欠損の数:\n", train.isna().sum()[lambda s: s > 0])
print("\n支払方法ごとの解約率:\n", train.groupby("支払方法")["解約"].mean().round(3))
print("\n契約月数(解約した人/しなかった人の中央値):\n",
      train.groupby("解約")["契約月数"].median())

出力例

欠損の数:
 月額料金    122
支払方法     76
dtype: int64

支払方法ごとの解約率:
 支払方法
カード     0.247
コンビニ    0.351
口座振替    0.267
Name: 解約, dtype: float64

契約月数(解約した人/しなかった人の中央値):
 解約
0    42.0
1    21.0
Name: 契約月数, dtype: float64

手順5・6前処理と特徴量

特徴量の作成(累計支払額など)と前処理を、1つのパイプラインにまとめます。こうすると交差検証の各回や本番の予測でも同じ処理が自動で行われ、リークも防げます。欠損があると累計支払額も欠損になるため、コードでは特徴量の作成を先に置き、欠損はその後でまとめて補完しています。

from sklearn.preprocessing import FunctionTransformer, StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import make_pipeline
from sklearn.impute import SimpleImputer

def add_features(d):
    d = d.copy()
    d["累計支払額"] = d["契約月数"] * d["月額料金"]
    d["月あたり問い合わせ"] = d["問い合わせ回数"] / d["契約月数"]
    return d

num_cols = ["契約月数", "月額料金", "問い合わせ回数", "累計支払額", "月あたり問い合わせ"]
cat_cols = ["プラン", "支払方法"]

def make_preprocess():
    return make_pipeline(
        FunctionTransformer(add_features),
        ColumnTransformer([
            ("num", make_pipeline(SimpleImputer(strategy="median", add_indicator=True),
                                  StandardScaler()), num_cols),
            ("cat", make_pipeline(SimpleImputer(strategy="most_frequent"),
                                  OneHotEncoder(handle_unknown="ignore")), cat_cols),
        ]))

print(make_preprocess().fit(X_train).transform(X_train.head(2)).round(2))

出力例

[[ 1.72 -0.46  0.37  1.03 -0.28 -0.23 -0.23  1.    0.    0.    1.    0.
   0.  ]
 [ 0.95 -0.27 -1.25  0.61 -0.38 -0.23 -0.23  0.    0.    1.    0.    1.
   0.  ]]

手順7学習して比べる

ベースラインと2つのモデルを、学習用データの5分割交差検証で比べます。このデータは「本当の仕組み」が線形(各項目の影響を足し合わせた形)なので、ロジスティック回帰が勝ちました。いつも同じ結果になるわけではないので、実データでは必ず比べます。

from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
candidates = {
    "ベースライン": DummyClassifier(strategy="prior"),
    "ロジスティック回帰": LogisticRegression(max_iter=1000),
    "勾配ブースティング": HistGradientBoostingClassifier(random_state=0),
}
for name, clf in candidates.items():
    model = make_pipeline(make_preprocess(), clf)
    s = cross_val_score(model, X_train, y_train, cv=cv, scoring="average_precision")
    print(f"{name:<10} PR-AUC {s.mean():.3f} (±{s.std():.3f})")

出力例

ベースライン     PR-AUC 0.273 (±0.001)
ロジスティック回帰  PR-AUC 0.571 (±0.039)
勾配ブースティング  PR-AUC 0.503 (±0.049)

手順8評価する

交差検証での予測を使い、閾値0.5のときの成績を見ます。解約者の再現率は約35%で、多くを見逃しています。項目の重要度では契約月数と問い合わせ回数が上位に来ました。

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import classification_report
from sklearn.inspection import permutation_importance

model = make_pipeline(make_preprocess(), LogisticRegression(max_iter=1000))
proba = cross_val_predict(model, X_train, y_train, cv=cv, method="predict_proba")[:, 1]
print(classification_report(y_train, proba >= 0.5, digits=3))

# 学習用の中をさらに分けて、どの項目が効いているかを見る
X_tr, X_va, y_tr, y_va = train_test_split(X_train, y_train, test_size=0.25,
                                          stratify=y_train, random_state=0)
r = permutation_importance(model.fit(X_tr, y_tr), X_va, y_va,
                           scoring="average_precision", n_repeats=10, random_state=0)
print(pd.Series(r.importances_mean, index=X_va.columns).sort_values(ascending=False).round(3))

出力例

              precision    recall  f1-score   support

           0      0.791     0.919     0.850      1746
           1      0.619     0.353     0.450       654

    accuracy                          0.765      2400
   macro avg      0.705     0.636     0.650      2400
weighted avg      0.744     0.765     0.741      2400

契約月数       0.196
問い合わせ回数    0.099
月額料金       0.037
支払方法       0.015
プラン        0.010
dtype: float64

手順9チューニングと最終評価

正則化の強さ C を選び、さらにF1が最大になる閾値を選びます。閾値は約0.27まで下がり、テスト用データでの再現率は約71%に上がりました(適合率は約49%)。テスト用データを使うのは、ここで1回だけです。

from sklearn.model_selection import GridSearchCV, TunedThresholdClassifierCV
from sklearn.metrics import average_precision_score, precision_score, recall_score

search = GridSearchCV(
    make_pipeline(make_preprocess(), LogisticRegression(max_iter=1000)),
    {"logisticregression__C": [0.01, 0.1, 1, 10]},
    cv=cv, scoring="average_precision").fit(X_train, y_train)
print("最良のC:", search.best_params_, "PR-AUC:", round(search.best_score_, 3))

# 見逃しと誤報のバランス(F1)が最良になる閾値を、学習用データの交差検証で選ぶ
final = TunedThresholdClassifierCV(search.best_estimator_, scoring="f1", cv=cv).fit(X_train, y_train)
print("選ばれた閾値:", round(final.best_threshold_, 3))

# テスト用データで、最後に1回だけ評価する
p_test = final.estimator_.predict_proba(X_test)[:, 1]
pred = final.predict(X_test)
print(f"テスト PR-AUC {average_precision_score(y_test, p_test):.3f} "
      f"適合率 {precision_score(y_test, pred):.3f} 再現率 {recall_score(y_test, pred):.3f}")

出力例

最良のC: {'logisticregression__C': 1} PR-AUC: 0.571
選ばれた閾値: 0.266
テスト PR-AUC 0.615 適合率 0.492 再現率 0.712

手順10本番に載せる

前処理ごとモデルを1つのファイルに保存し、読み込んで新しい顧客を予測します。月額料金の欠損や、学習時になかった支払方法「未登録」も処理できます。実行したフォルダに churn_model.joblib が作られます。

import joblib

joblib.dump(final, "churn_model.joblib")          # 前処理ごと1ファイルに保存
loaded = joblib.load("churn_model.joblib")         # 本番環境で読み込む

new_customers = pd.DataFrame({
    "契約月数": [3, 60], "月額料金": [9800, np.nan], "プラン": ["ライト", "プレミアム"],
    "支払方法": ["コンビニ", "未登録"], "問い合わせ回数": [4, 0]})  # 欠損・未知の値も処理される
print(loaded.estimator_.predict_proba(new_customers)[:, 1].round(3))
print(loaded.predict(new_customers))

出力例

[0.948 0.058]
[1 0]

手順11監視する

翌月のデータで月額料金が15%上がった状況を作り、学習時と分布を比べます(KS検定)。p値は「分布が同じだとしたら、これほどの差が偶然出る確率」の目安で、小さいほど「分布が変わった」と考えられます。月額料金だけ p値が非常に小さく、分布の変化を検知できました。実務では、こうした検知を合図に原因を調べ、必要なら再学習します。

from scipy.stats import ks_2samp

# 翌月のデータ(値上げで月額料金が上がった想定)
next_month = X_test.copy()
next_month["月額料金"] = next_month["月額料金"] * 1.15

for col in ["契約月数", "月額料金"]:
    stat, p = ks_2samp(X_train[col].dropna(), next_month[col].dropna())
    print(f"{col}: p値={p:.4f}", "← 分布が変わった可能性" if p < 0.01 else "")
print("解約と判定した割合 学習時:", round(final.predict(X_train).mean(), 3),
      "翌月:", round(loaded.predict(next_month).mean(), 3))

出力例

契約月数: p値=0.3340 
月額料金: p値=0.0000 ← 分布が変わった可能性
解約と判定した割合 学習時: 0.422 翌月: 0.443

Spark