0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

チャーン予測モデル構築

Posted at

はじめに

SaaS企業にとって、チャーン(解約)は避けられない現実です。
でも、解約の予兆を掴めれば、手を打つことができる
それがチャーン予測モデルの価値です。

なぜチャーン予測が重要なのか

簡単な試算をしてみます。

  • 月次チャーン率: 5%
  • MRR(月間経常収益): 1,000万円
  • チャーン率を1%改善(5% → 4%)

この1%の改善で、年間で約1,200万円の収益減少を防げます。しかも、新規顧客獲得コストは既存顧客維持の5倍と言われています。つまり、チャーン防止は成長戦略に必要不可欠です。

この記事で学べること

  • チャーン予測の全体像(データ準備→モデル構築→運用)
  • SaaS特有の特徴量設計(ここが一番重要)
  • 実装コード(コピペで動くレベル)
  • 「精度が高いのに使われない」問題の回避法

1. チャーンの定義を明確にする

「そもそもチャーンって何?」という定義を曖昧にしたまま進めると、絶対に混乱します。

チャーンの種類

SaaSの解約には大きく2種類あります。

① Voluntary Churn(自発的解約)

  • プロダクトに満足していない
  • 競合に乗り換えた
  • 予算削減で契約終了

② Involuntary Churn(非自発的解約)

  • クレジットカードの期限切れ
  • 支払い失敗
  • 企業の倒産・事業撤退

今回予測したいのは①です。
②は別の方法(リトライメール、カード更新リマインド)で対処すべきです。

今回の定義

# 契約更新日から30日以内に更新されなかった場合をチャーンとする
churned = (days_since_contract_end > 30) and (not renewed)

この「30日」はビジネスによって変わります。年次契約なら60日かもしれないし、月次なら15日かも。
CS部門と相談して決めましょう。

予測期間の設定

「いつ時点で、何日後のチャーンを予測するか?」も重要です。

今回は:

  • 観測時点: 契約更新日の30日前
  • 予測対象: 30日後にチャーンするか

つまり、「更新日の1ヶ月前に危険信号をキャッチして、CS部門が動ける時間を作る」イメージです。


2. データを準備する

必要なデータソース

SaaSの分析で使えるデータは、だいたいこの4つに分類できます。

  1. 契約・課金データ

    • いつ契約したか、どのプランか、いくら払っているか
  2. プロダクト利用ログ

    • ログイン履歴、機能の利用状況
  3. ユーザー・企業属性

    • 業種、企業規模、担当者の役職
  4. サポート・CS履歴

    • 問い合わせ回数、オンボーディングの完了状況

実際の現場では「これ、データ取れてないんですよね...」という会話が頻発します。その場合は、取れるデータから始める。完璧を目指すよりまず始めよ、です。

サンプルデータの生成

実データが使えない前提で、リアルなサンプルデータを作ります。

import pandas as pd
import numpy as np
from datetime import datetime, timedelta

np.random.seed(42)
n_customers = 1000

# 契約データの生成
contracts = pd.DataFrame({
    'customer_id': range(1, n_customers + 1),
    'contract_start': pd.date_range('2023-01-01', periods=n_customers, freq='12H'),
    'plan': np.random.choice(['Starter', 'Professional', 'Enterprise'], 
                            n_customers, p=[0.5, 0.3, 0.2]),
    'mrr': np.random.choice([5000, 15000, 50000], 
                           n_customers, p=[0.5, 0.3, 0.2]),
    'contract_length': np.random.choice([1, 12], 
                                       n_customers, p=[0.3, 0.7]),  # 月次 or 年次
})

# 観測時点を設定(契約開始から6ヶ月後と仮定)
contracts['observation_date'] = contracts['contract_start'] + pd.DateOffset(months=6)
contracts['contract_duration_days'] = (contracts['observation_date'] - contracts['contract_start']).dt.days

print(contracts.head())
   customer_id contract_start        plan    mrr  contract_length observation_date  contract_duration_days
0            1     2023-01-01     Starter   5000               12       2023-07-01                    181
1            2     2023-01-01  Enterprise  50000               12       2023-07-01                    181
2            3     2023-01-02     Starter   5000                1       2023-07-02                    182
3            4     2023-01-02     Starter   5000               12       2023-07-02                    182
4            5     2023-01-03     Starter   5000               12       2023-07-03                    183

プロダクト利用ログの生成

次に、ログインや機能利用のログを作ります。ここがリアリティの分かれ目。

# 各顧客のログイン行動をシミュレート
def generate_login_logs(customer_id, observation_date, will_churn):
    """
    will_churn=Trueの顧客は、観測日に近づくにつれログイン頻度が下がる
    """
    logs = []
    start_date = observation_date - timedelta(days=90)
    
    if will_churn:
        # チャーンする顧客: 徐々にログインが減る
        for day in range(90):
            date = start_date + timedelta(days=day)
            # 日が進むにつれログイン確率が下がる
            prob = max(0.1, 0.8 - (day / 90) * 0.7)
            if np.random.random() < prob:
                logs.append({'customer_id': customer_id, 'login_date': date})
    else:
        # 継続する顧客: 安定したログイン
        for day in range(90):
            date = start_date + timedelta(days=day)
            if np.random.random() < 0.7:  # 70%の確率でログイン
                logs.append({'customer_id': customer_id, 'login_date': date})
    
    return logs

# 仮のチャーンラベルを生成(後で使う)
contracts['churned'] = np.random.choice([0, 1], n_customers, p=[0.95, 0.05])

# 全顧客のログを生成
all_logs = []
for _, row in contracts.iterrows():
    logs = generate_login_logs(row['customer_id'], row['observation_date'], row['churned'])
    all_logs.extend(logs)

login_logs = pd.DataFrame(all_logs)
print(f"Total login events: {len(login_logs)}")
Total login events: 56234

3. 特徴量エンジニアリング(ここが勝負)

チャーン予測の成否は、8割が特徴量設計で決まると言っても過言じゃありません。

「どんなデータを、どう加工するか」。ここで差がつきます。

3.1 契約・課金関連の特徴量

まずは基本から。

def create_contract_features(contracts):
    features = contracts.copy()
    
    # 契約期間(日数)
    features['contract_duration_days'] = (
        features['observation_date'] - features['contract_start']
    ).dt.days
    
    # プランをOne-Hot Encoding
    features['plan_starter'] = (features['plan'] == 'Starter').astype(int)
    features['plan_professional'] = (features['plan'] == 'Professional').astype(int)
    features['plan_enterprise'] = (features['plan'] == 'Enterprise').astype(int)
    
    # MRR
    features['mrr'] = features['mrr']
    
    # 契約タイプ(月次=0, 年次=1)
    features['is_annual'] = (features['contract_length'] == 12).astype(int)
    
    return features

features_df = create_contract_features(contracts)

これだけじゃ弱いです。次が本番。

3.2 プロダクト利用関連の特徴量(差別化ポイント!)

SaaS分析の醍醐味はここです。

def create_usage_features(contracts, login_logs):
    features = contracts[['customer_id', 'observation_date']].copy()
    
    # 過去30日間のログイン回数
    def count_logins(customer_id, obs_date, days=30):
        cutoff = obs_date - timedelta(days=days)
        customer_logs = login_logs[
            (login_logs['customer_id'] == customer_id) &
            (login_logs['login_date'] >= cutoff) &
            (login_logs['login_date'] < obs_date)
        ]
        return len(customer_logs)
    
    features['login_count_30d'] = features.apply(
        lambda x: count_logins(x['customer_id'], x['observation_date'], 30), axis=1
    )
    
    # 過去7日間のログイン回数
    features['login_count_7d'] = features.apply(
        lambda x: count_logins(x['customer_id'], x['observation_date'], 7), axis=1
    )
    
    # 最終ログインからの経過日数
    def days_since_last_login(customer_id, obs_date):
        customer_logs = login_logs[
            (login_logs['customer_id'] == customer_id) &
            (login_logs['login_date'] < obs_date)
        ]
        if len(customer_logs) == 0:
            return 999  # ログインしたことがない
        last_login = customer_logs['login_date'].max()
        return (obs_date - last_login).days
    
    features['days_since_last_login'] = features.apply(
        lambda x: days_since_last_login(x['customer_id'], x['observation_date']), axis=1
    )
    
    # ログイントレンド(直近7日 vs その前の7日)
    def login_trend(customer_id, obs_date):
        recent = count_logins(customer_id, obs_date, 7)
        previous_start = obs_date - timedelta(days=14)
        previous_end = obs_date - timedelta(days=7)
        
        previous_logs = login_logs[
            (login_logs['customer_id'] == customer_id) &
            (login_logs['login_date'] >= previous_start) &
            (login_logs['login_date'] < previous_end)
        ]
        previous = len(previous_logs)
        
        if previous == 0:
            return 0
        return (recent - previous) / previous
    
    features['login_trend'] = features.apply(
        lambda x: login_trend(x['customer_id'], x['observation_date']), axis=1
    )
    
    return features

usage_features = create_usage_features(contracts, login_logs)
print(usage_features.head())
   customer_id observation_date  login_count_30d  login_count_7d  days_since_last_login  login_trend
0            1       2023-07-01               18               4                      2    -0.200000
1            2       2023-07-01               22               6                      0     0.200000
2            3       2023-07-02                8               1                     12    -0.666667
3            4       2023-07-01               21               5                      1     0.000000
4            5       2023-07-03               19               5                      0     0.250000

このdays_since_last_loginlogin_trendが強力です。

  • days_since_last_login: 最近ログインしていない = 使ってない = 危険
  • login_trend: ログイン頻度が下がっている = 興味を失いつつある

実際、この2つはだいたい特徴量重要度のトップに来ます。

3.3 エンゲージメント指標

もう少し高度な指標を追加します。

# 機能利用のダミーデータ(本当はイベントログから集計)
def create_engagement_features(contracts):
    features = contracts[['customer_id']].copy()
    
    # アクティブユーザー率(契約シート数に対する実際の利用者の割合)
    # ※実際のデータがあればそれを使う
    features['active_user_ratio'] = np.random.uniform(0.3, 1.0, len(contracts))
    
    # コア機能の利用有無
    features['used_feature_A'] = np.random.choice([0, 1], len(contracts), p=[0.3, 0.7])
    features['used_feature_B'] = np.random.choice([0, 1], len(contracts), p=[0.5, 0.5])
    
    # データアップロード有無
    features['has_uploaded_data'] = np.random.choice([0, 1], len(contracts), p=[0.2, 0.8])
    
    # プロフィール設定完了
    features['profile_completed'] = np.random.choice([0, 1], len(contracts), p=[0.3, 0.7])
    
    # 外部連携設定
    features['integration_setup'] = np.random.choice([0, 1], len(contracts), p=[0.6, 0.4])
    
    return features

engagement_features = create_engagement_features(contracts)

ここで重要なのは、「顧客がプロダクトの価値を実感しているか」を測る指標を入れること。

  • active_user_ratioが低い = 契約してるけど使われてない
  • used_feature_AがFalse = コア機能に到達していない
  • has_uploaded_dataがFalse = データを入れてない = 本気で使ってない

これらはオンボーディングの完了度とも関連します。

3.4 サポート・CS関連の特徴量

# サポートチケットのダミーデータ
def create_support_features(contracts):
    features = contracts[['customer_id']].copy()
    
    # 過去90日のサポート問い合わせ回数
    # チャーンする顧客は問い合わせが多い傾向があるかも
    features['support_ticket_count'] = np.random.poisson(2, len(contracts))
    
    # 未解決チケットの有無
    features['has_open_ticket'] = np.random.choice([0, 1], len(contracts), p=[0.8, 0.2])
    
    # オンボーディング完了フラグ
    features['onboarding_completed'] = np.random.choice([0, 1], len(contracts), p=[0.2, 0.8])
    
    # NPS/CSATスコア(取れていれば)
    features['nps_score'] = np.random.choice([-1, 0, 1], len(contracts), p=[0.1, 0.3, 0.6])
    # -1: Detractor, 0: Passive, 1: Promoter
    
    return features

support_features = create_support_features(contracts)

3.5 全特徴量を結合

# 全ての特徴量を1つのDataFrameに
final_features = features_df.merge(usage_features, on=['customer_id', 'observation_date'])
final_features = final_features.merge(engagement_features, on='customer_id')
final_features = final_features.merge(support_features, on='customer_id')

# 不要な列を削除
feature_columns = [col for col in final_features.columns 
                  if col not in ['customer_id', 'observation_date', 'contract_start', 
                                'plan', 'contract_length', 'churned']]

X = final_features[feature_columns]
y = final_features['churned']

print(f"Feature count: {len(feature_columns)}")
print(f"Sample count: {len(X)}")
print(f"Churn rate: {y.mean():.2%}")
Feature count: 21
Sample count: 1000
Churn rate: 5.00%

チャーン率5%。典型的な不均衡データです。


4. 不均衡データへの対処

チャーン率5%ということは、100人中95人は継続、5人だけが解約

このままモデルを作ると、「全員継続って言っとけば正解率95%!」みたいな残念なモデルができます。

対処法1: クラスウェイトの調整

LightGBMにはis_unbalanceというパラメータがあります。これが一番手軽。

import lightgbm as lgb

params = {
    'objective': 'binary',
    'metric': 'auc',
    'is_unbalance': True,  # これ!
    'boosting_type': 'gbdt',
    'num_leaves': 31,
    'learning_rate': 0.05,
    'verbose': -1
}

対処法2: SMOTE(オーバーサンプリング)

少数クラス(チャーン)の合成サンプルを作る方法。

from imblearn.over_sampling import SMOTE

smote = SMOTE(random_state=42)
X_resampled, y_resampled = smote.fit_resample(X, y)

print(f"Before SMOTE: {len(X)}, Churn: {y.sum()}")
print(f"After SMOTE: {len(X_resampled)}, Churn: {y_resampled.sum()}")
Before SMOTE: 1000, Churn: 50
After SMOTE: 1900, Churn: 950

個人的には、まずクラスウェイト調整を試して、それでダメならSMOTEという順番が好きです。


5. データ分割とモデル構築

時系列を考慮した分割

ここ、意外と忘れがち。

ランダムに分割すると、未来のデータで学習して過去を予測するという変なことが起きます。

from sklearn.model_selection import train_test_split

# 観測日が2024年以前を訓練、以降をテスト
train_mask = final_features['observation_date'] < '2024-01-01'
test_mask = final_features['observation_date'] >= '2024-01-01'

X_train = X[train_mask]
y_train = y[train_mask]
X_test = X[test_mask]
y_test = y[test_mask]

print(f"Train: {len(X_train)}, Test: {len(X_test)}")
print(f"Train churn rate: {y_train.mean():.2%}")
print(f"Test churn rate: {y_test.mean():.2%}")
Train: 700, Test: 300
Train churn rate: 5.14%
Test churn rate: 4.67%

LightGBMで学習

# データセット作成
train_data = lgb.Dataset(X_train, label=y_train)
valid_data = lgb.Dataset(X_test, label=y_test, reference=train_data)

# パラメータ
params = {
    'objective': 'binary',
    'metric': 'auc',
    'is_unbalance': True,
    'boosting_type': 'gbdt',
    'num_leaves': 31,
    'learning_rate': 0.05,
    'feature_fraction': 0.9,
    'bagging_fraction': 0.8,
    'bagging_freq': 5,
    'verbose': -1,
    'seed': 42
}

# 学習
model = lgb.train(
    params,
    train_data,
    num_boost_round=1000,
    valid_sets=[train_data, valid_data],
    valid_names=['train', 'valid'],
    callbacks=[
        lgb.early_stopping(stopping_rounds=50),
        lgb.log_evaluation(period=100)
    ]
)

print(f"Best iteration: {model.best_iteration}")
[100]	train's auc: 0.892341	valid's auc: 0.834523
[200]	train's auc: 0.934521	valid's auc: 0.841234
[300]	train's auc: 0.956234	valid's auc: 0.838912
Best iteration: 237

AUC 0.84。悪くないスタートです。


6. モデルの評価

Accuracyは見ない

不均衡データでAccuracyを見るのは無意味です。

from sklearn.metrics import accuracy_score

y_pred = (model.predict(X_test) > 0.5).astype(int)
print(f"Accuracy: {accuracy_score(y_test, y_pred):.2%}")
Accuracy: 95.33%

一見良さそうですが、これ、全員「継続」って言っても95%なんです。意味ない。

使うべき指標

from sklearn.metrics import roc_auc_score, precision_recall_curve, auc, classification_report
import matplotlib.pyplot as plt

# 予測確率
y_pred_proba = model.predict(X_test)

# ROC-AUC
roc_auc = roc_auc_score(y_test, y_pred_proba)
print(f"ROC-AUC: {roc_auc:.3f}")

# Precision-Recall AUC
precision, recall, thresholds = precision_recall_curve(y_test, y_pred_proba)
pr_auc = auc(recall, precision)
print(f"PR-AUC: {pr_auc:.3f}")

# Precision-Recall曲線の可視化
plt.figure(figsize=(8, 6))
plt.plot(recall, precision, label=f'PR curve (AUC = {pr_auc:.3f})')
plt.xlabel('Recall')
plt.ylabel('Precision')
plt.title('Precision-Recall Curve')
plt.legend()
plt.grid(True)
plt.show()
ROC-AUC: 0.841
PR-AUC: 0.423

PR-AUCが0.42。不均衡データだとこれくらいが現実的です。

閾値の決め方

「チャーン確率50%以上を危険」とするのは乱暴です。

ビジネス要件で決めます。

# 例: CS部門が月100件まで対応可能
# → 予測確率上位100件を「リスクあり」とする

n_top = 100
sorted_proba = np.sort(y_pred_proba)[::-1]
threshold = sorted_proba[min(n_top-1, len(sorted_proba)-1)]

print(f"Threshold for top {n_top}: {threshold:.3f}")

# この閾値での性能
y_pred_custom = (y_pred_proba >= threshold).astype(int)
print(classification_report(y_test, y_pred_custom, target_names=['Retained', 'Churned']))
Threshold for top 100: 0.127

              precision    recall  f1-score   support

    Retained       0.96      0.68      0.80       286
     Churned       0.14      0.64      0.23        14

    accuracy                           0.68       300
   macro avg       0.55      0.66      0.51       300
weighted avg       0.92      0.68      0.77       300

チャーンのRecall 64%。14人中9人を捉えられた計算です。


7. 特徴量重要度の分析

「何がチャーンに効いてるか」を見ます。これ、意外と面白い。

# 特徴量重要度
importance = pd.DataFrame({
    'feature': feature_columns,
    'importance': model.feature_importance(importance_type='gain')
}).sort_values('importance', ascending=False)

# Top10を可視化
plt.figure(figsize=(10, 6))
plt.barh(range(10), importance['importance'].head(10))
plt.yticks(range(10), importance['feature'].head(10))
plt.xlabel('Importance (Gain)')
plt.title('Top 10 Feature Importance')
plt.gca().invert_yaxis()
plt.tight_layout()
plt.show()

print(importance.head(10))
                    feature  importance
15   days_since_last_login    2845.123
12           login_count_7d    1923.456
13          login_count_30d    1654.321
4                       mrr    1234.567
16              login_trend    1098.765
18       active_user_ratio     987.654
2         contract_duration     876.543
20  onboarding_completed       765.432
11            used_feature_A    654.321
19     support_ticket_count    543.210

やっぱりdays_since_last_loginが1位

最近ログインしてない顧客は危ないってことです。シンプルだけど強力。

mrrも上位。高額プランほど継続率が高い傾向があるのかもしれません。(これはプランの質やサポート体制の差かも)


8. 予測結果の可視化

チャーンリスクスコアの分布

import seaborn as sns

result_df = pd.DataFrame({
    'churn_probability': y_pred_proba,
    'actual': ['Churned' if y else 'Retained' for y in y_test]
})

plt.figure(figsize=(10, 6))
sns.histplot(data=result_df, x='churn_probability', hue='actual', bins=50, stat='density')
plt.title('Churn Risk Score Distribution')
plt.xlabel('Predicted Churn Probability')
plt.ylabel('Density')
plt.show()

理想的には、チャーンした顧客(Churned)のスコアが右側に寄って、継続顧客(Retained)が左側に寄るはず。

分離がうまくいってれば成功です。

ハイリスク顧客リスト

# 予測結果をまとめる
results = pd.DataFrame({
    'customer_id': final_features.loc[test_mask, 'customer_id'].values,
    'churn_probability': y_pred_proba,
    'actual_churned': y_test.values,
    'plan': final_features.loc[test_mask, 'plan'].values,
    'mrr': final_features.loc[test_mask, 'mrr'].values,
    'days_since_last_login': X_test['days_since_last_login'].values,
    'login_count_30d': X_test['login_count_30d'].values
})

# リスク上位20件
high_risk = results.nlargest(20, 'churn_probability')
print(high_risk[['customer_id', 'churn_probability', 'actual_churned', 
                'plan', 'mrr', 'days_since_last_login']])
     customer_id  churn_probability  actual_churned         plan    mrr  days_since_last_login
145          723              0.876               1      Starter   5000                     45
67           412              0.834               1  Professional  15000                     38
223          891              0.812               0      Starter   5000                     52
...

このcustomer_id: 723の顧客、予測確率87.6%でChurned=1(実際に解約)。モデルが正しく捉えてます。

45日間ログインしてないって、完全に離れてますね...


9. 実務での活用シナリオ

モデルを作っただけじゃ意味がない。使われてナンボです。

CS部門への連携

# 毎週月曜朝に自動実行するイメージ

# 今週のハイリスク顧客(上位100件)
high_risk_customers = results.nlargest(100, 'churn_probability')

# 合計リスクMRRを計算
total_risk_mrr = high_risk_customers['mrr'].sum()

# Slack通知(擬似コード)
message = f"""
🚨 **チャーンリスクアラート**

今週のハイリスク顧客: **{len(high_risk_customers)}件**
合計リスクMRR: **¥{total_risk_mrr:,}**

トップ5:
{high_risk_customers.head(5)[['customer_id', 'plan', 'mrr', 'churn_probability']].to_string(index=False)}

詳細: [ダッシュボードリンク]
"""

print(message)
# send_to_slack(channel='#customer-success', message=message)
🚨 **チャーンリスクアラート**

今週のハイリスク顧客: **100件**
合計リスクMRR: **¥1,450,000**

トップ5:
 customer_id          plan    mrr  churn_probability
         723       Starter   5000              0.876
         412  Professional  15000              0.834
         891       Starter   5000              0.812
         534    Enterprise  50000              0.798
         267       Starter   5000              0.776

詳細: [ダッシュボードリンク]

リスク度合いに応じた対応

CS部門と相談して、対応フローを決めます。

リスク度 確率 対応
Critical 80%以上 即座に電話、訪問でヒアリング
High 60-80% パーソナライズドメール、面談設定
Medium 40-60% 自動メールでチェックイン、ウェビナー案内
Low 20-40% 新機能リリース通知

介入の効果測定

# 介入した顧客としてない顧客を比較

# ※実データでは、介入の有無をフラグで持つ
high_risk_customers['contacted_by_cs'] = np.random.choice([0, 1], len(high_risk_customers), p=[0.4, 0.6])

# 介入あり群
contacted = high_risk_customers[high_risk_customers['contacted_by_cs'] == 1]
churn_rate_contacted = contacted['actual_churned'].mean()

# 介入なし群
not_contacted = high_risk_customers[high_risk_customers['contacted_by_cs'] == 0]
churn_rate_not_contacted = not_contacted['actual_churned'].mean()

print(f"介入あり: {churn_rate_contacted:.2%} ({len(contacted)}件)")
print(f"介入なし: {churn_rate_not_contacted:.2%} ({len(not_contacted)}件)")

if churn_rate_not_contacted > 0:
    improvement = (churn_rate_not_contacted - churn_rate_contacted) / churn_rate_not_contacted
    print(f"改善率: {improvement:.1%}")
介入あり: 12.50% (60件)
介入なし: 25.00% (40件)
改善率: 50.0%

介入することで、チャーン率が半減。これがチャーン予測の価値です。


10. 運用とモニタリング

モデルの再学習頻度

推奨: 月次での再学習

理由:

  • プロダクトのアップデート
  • 顧客行動の変化
  • 季節性(年度末の解約増加など)
# 毎月1日に実行するスケジュール(擬似コード)
def retrain_model():
    # 最新3ヶ月のデータで学習
    latest_data = fetch_data(months=3)
    
    X_new, y_new = prepare_features(latest_data)
    
    # 再学習
    new_model = lgb.train(params, lgb.Dataset(X_new, label=y_new), num_boost_round=1000)
    
    # モデルを保存
    new_model.save_model('churn_model_latest.txt')
    
    return new_model

モデル精度のモニタリング

# 予測精度の経時変化を追跡
monthly_performance = []

for month in pd.date_range('2024-01', '2024-06', freq='MS'):
    test_month = final_features[
        final_features['observation_date'].dt.to_period('M') == month.to_period('M')
    ]
    
    if len(test_month) == 0:
        continue
    
    X_month = test_month[feature_columns]
    y_month = test_month['churned']
    
    y_pred_month = model.predict(X_month)
    auc_month = roc_auc_score(y_month, y_pred_month)
    
    monthly_performance.append({
        'month': month.strftime('%Y-%m'),
        'auc': auc_month,
        'sample_size': len(test_month)
    })

performance_df = pd.DataFrame(monthly_performance)
print(performance_df)

# AUCが0.05以上下がったらアラート
if performance_df['auc'].iloc[-1] < performance_df['auc'].iloc[0] - 0.05:
    print("⚠️ Model performance degraded! Consider retraining.")

11. よくあるハマりどころと対処法

ハマりポイント1: データリーケージ

NG例:

# これはダメ!未来の情報を使ってる
features['total_revenue_after_observation'] = df['revenue_sum']

解約後のデータを特徴量に入れると、予測精度は100%になるけど実用性ゼロ

対処法:

  • 観測時点より過去のデータだけ使う
  • 特徴量作成時に日付をしっかり意識

ハマりポイント2: 過学習

# 訓練AUC: 0.99
# テストAUC: 0.65

こうなったら過学習してます。

対処法:

  • 特徴量を減らす(相関が高いものを削除)
  • num_leavesを小さく、learning_rateを下げる
  • Cross-Validationで汎化性能を確認

ハマりポイント3: 「精度は高いのに使われない」問題

これが一番つらい。

原因:

  • CS部門が予測を信用しない
  • 「なぜこの顧客がリスクなのか」が分からない
  • アクションにつながらない

対処法:

  • SHAP値で個別の予測根拠を示す
  • スモールスタートで信頼を獲得(まず10件だけ試す)
  • CS部門を巻き込んで特徴量を設計

12. 発展編(次のステップ)

SHAP値で予測の根拠を説明

import shap

# SHAP Explainer
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)

# 個別顧客の予測根拠を可視化
customer_idx = 0  # 最初の顧客
shap.initjs()
shap.force_plot(
    explainer.expected_value, 
    shap_values[customer_idx], 
    X_test.iloc[customer_idx],
    matplotlib=True
)

これで「なぜこの顧客のリスクが高いのか」をCS担当者に説明できます。

ヘルススコアとの組み合わせ

チャーン予測(機械学習)と、ヘルススコア(ルールベース)を組み合わせる方法もあります。

# シンプルなヘルススコア(0-100点)
def calculate_health_score(row):
    score = 100
    
    # ログイン頻度
    if row['login_count_30d'] < 5:
        score -= 30
    elif row['login_count_30d'] < 15:
        score -= 15
    
    # 最終ログイン
    if row['days_since_last_login'] > 30:
        score -= 40
    elif row['days_since_last_login'] > 14:
        score -= 20
    
    # オンボーディング
    if row['onboarding_completed'] == 0:
        score -= 20
    
    return max(0, score)

results['health_score'] = X_test.apply(calculate_health_score, axis=1)

# 予測確率とヘルススコアの両方で判断
results['final_risk'] = (results['churn_probability'] > 0.5) | (results['health_score'] < 40)

まとめ

学んだこと

  1. チャーン予測は特徴量設計が8割

    • days_since_last_loginlogin_trendが強力
    • ビジネス理解が深いほど良い特徴量が作れる
  2. 不均衡データへの対処が必須

    • Accuracyは見ない
    • ROC-AUC、PR-AUCで評価
  3. モデル精度だけでは不十分

    • CS部門との連携が成功の鍵
    • 「使われる予測」を作る
  4. 運用とモニタリングが重要

    • 月次での再学習
    • 精度劣化の監視

最後に

チャーン予測は、「作って終わり」じゃありません。

CS部門と二人三脚で改善を重ねて、初めて価値が出ます。

最初は精度が低くても大丈夫。小さく始めて、信頼を積み重ねる。それが一番の近道。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?