はじめに
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つに分類できます。
-
契約・課金データ
- いつ契約したか、どのプランか、いくら払っているか
-
プロダクト利用ログ
- ログイン履歴、機能の利用状況
-
ユーザー・企業属性
- 業種、企業規模、担当者の役職
-
サポート・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_loginとlogin_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)
まとめ
学んだこと
-
チャーン予測は特徴量設計が8割
-
days_since_last_login、login_trendが強力 - ビジネス理解が深いほど良い特徴量が作れる
-
-
不均衡データへの対処が必須
- Accuracyは見ない
- ROC-AUC、PR-AUCで評価
-
モデル精度だけでは不十分
- CS部門との連携が成功の鍵
- 「使われる予測」を作る
-
運用とモニタリングが重要
- 月次での再学習
- 精度劣化の監視
最後に
チャーン予測は、「作って終わり」じゃありません。
CS部門と二人三脚で改善を重ねて、初めて価値が出ます。
最初は精度が低くても大丈夫。小さく始めて、信頼を積み重ねる。それが一番の近道。