Machine Learning: 本番環境のモニタリングとモデルドリフト — デプロイはゴールではなくスタートライン
最終更新:2026-08-26
モデルのデプロイは飛行機の離陸のようなものです。本当の挑戦は、フライト中のモニタリングと、途中で発生するあらゆる事態への対処にあります。
1. 学べること
- モデルモニタリングシステム:予測分布のモニタリング、レイテンシのモニタリング、ビジネス指標のモニタリング
- データドリフト:PSI / KS検定 / Jensen-Shannonダイバージェンス、そして特徴量分布の変化の検知
- コンセプトドリフト:入出力関係の変化と、リアクティブ/プロアクティブな検知
- 自動再学習パイプライン:ドリフトアラート → データのバックフィル → 再学習 → A/B検証 → カナリアリリース
- Bobのドリフト体験談:ダブルイレブン(独身の日)セールがユーザー行動を激変させた話と、SalesPredictがすばやく適応した方法
2. 運用エンジニアの実話
(1) 苦い経験:ダブルイレブンの後、すべての予測が狂った
BobのSalesPredictモデルは順調に稼働しており、MAPEは8%で安定していました。ところがダブルイレブンのセールの後、予測誤差は35%まで跳ね上がりました。ユーザー行動が完全に変わってしまったのです(衝動買い、カテゴリ嗜好の変化、購買サイクルの短縮)。にもかかわらず、モデルはセール前のパターンに基づいて予測を続けていました。デプロイ後のモデル劣化はサイレントに進行します。気づいたときには、すでに被害が出ているのです。
(2) ドリフトモニタリングという解決策
入力データの分布(PSI)と予測精度を継続的にモニタリングします。ドリフトを検知した瞬間に、自動再学習をトリガーします。
PYTHON
def check_drift(reference_data, current_data, threshold=0.2):
psi = calculate_psi(reference_data, current_data)
if psi > threshold:
alert("Data drift detected! PSI={:.3f}".format(psi))
trigger_retraining()
(3) その効果:ドリフトを3日で検知し、1週間で復旧
Bobがドリフトモニタリングを導入したところ、ダブルイレブン後の行動変化は3日以内に検知されました(PSI=0.45)。そして1週間以内に、再学習+A/B検証+カナリアリリースが完了し、約30万ドルの予測損失を取り戻しました。
3. モデルモニタリングシステム
(1) 3層のモニタリングアーキテクチャ
graph TB
L1[Layer 1: Infrastructure<br/>Latency / Throughput / Uptime] --> L2[Layer 2: ML Metrics<br/>Prediction Distribution / Feature Stats]
L2 --> L3[Layer 3: Business Metrics<br/>Revenue MAPE / Conversion Rate / Churn Rate]
L3 --> ALERT{Anomaly Detected?}
ALERT -->|Yes| ACTION[Alert → Investigate → Retrain]
ALERT -->|No| CONTINUE[Continue Monitoring]
▶ サンプル:基本的なモニタリングダッシュボードのデータ
PYTHON
import numpy as np
from datetime import datetime, timedelta
class ModelMonitor:
def __init__(self):
self.metrics_history = []
def log_prediction(self, prediction, features, latency_ms):
self.metrics_history.append({
"timestamp": datetime.now(),
"prediction": prediction,
"features_mean": np.mean(features),
"latency_ms": latency_ms,
})
def check_health(self, last_n=1000):
if len(self.metrics_history) < last_n:
return "Insufficient data"
recent = self.metrics_history[-last_n:]
predictions = [m["prediction"] for m in recent]
latencies = [m["latency_ms"] for m in recent]
report = {
"prediction_mean": np.mean(predictions),
"prediction_std": np.std(predictions),
"p95_latency_ms": np.percentile(latencies, 95),
"sample_count": len(recent),
}
return report
monitor = ModelMonitor()
for _ in range(100):
monitor.log_prediction(prediction=np.random.normal(200, 50),
features=np.random.randn(5),
latency_ms=np.random.uniform(10, 30))
print(monitor.check_health())
出力:
TEXT
📖 参照専用
# Functions defined successfully
| モニタリング層 | 指標 | アラート閾値 | ツール |
|---|---|---|---|
| インフラ | APIレイテンシ p95 | > 100ms | Prometheus |
| インフラ | サービス可用性 | < 99.9% | Grafana |
| ML指標 | 予測分布の変化 | PSI > 0.2 | カスタム |
| ML指標 | 特徴量の欠損率 | > 5% | カスタム |
| ビジネス指標 | 予測MAPE | > 15% | ビジネスレポート |
| ビジネス指標 | 売上偏差 | > 10% | ビジネスレポート |
4. データドリフトの検知
(1) PSI(Population Stability Index)
▶ サンプル:PSIの計算
PYTHON
import numpy as np
def calculate_psi(reference, current, n_bins=10):
"""Calculate Population Stability Index.
PSI < 0.1: No significant change
PSI 0.1-0.2: Moderate change, investigate
PSI > 0.2: Significant change, action required
"""
breakpoints = np.percentile(reference, np.linspace(0, 100, n_bins + 1))
breakpoints[0] = -np.inf
breakpoints[-1] = np.inf
ref_counts = np.histogram(reference, bins=breakpoints)[0]
cur_counts = np.histogram(current, bins=breakpoints)[0]
ref_pct = ref_counts / len(reference)
cur_pct = cur_counts / len(current)
# Avoid log(0)
ref_pct = np.clip(ref_pct, 1e-6, None)
cur_pct = np.clip(cur_pct, 1e-6, None)
psi = np.sum((cur_pct - ref_pct) * np.log(cur_pct / ref_pct))
return psi
# Simulate: normal vs drifted data
rng = np.random.default_rng(42)
reference = rng.normal(100, 20, 10000)
# No drift
current_no_drift = rng.normal(100, 20, 5000)
psi_no_drift = calculate_psi(reference, current_no_drift)
# Moderate drift (mean shifted by 10)
current_moderate = rng.normal(110, 20, 5000)
psi_moderate = calculate_psi(reference, current_moderate)
# Severe drift (mean shifted by 25, Double-11 effect)
current_severe = rng.normal(125, 30, 5000)
psi_severe = calculate_psi(reference, current_severe)
print(f"No drift: PSI = {psi_no_drift:.3f} (OK)")
print(f"Moderate: PSI = {psi_moderate:.3f} (Investigate)")
print(f"Severe: PSI = {psi_severe:.3f} (Action Required!)")
出力:
TEXT
📖 参照専用
# Functions defined successfully
(2) KS検定とJSダイバージェンス
▶ サンプル:複数のドリフト指標の比較
PYTHON
from scipy import stats
import numpy as np
def calculate_js_divergence(reference, current, n_bins=50):
"""Jensen-Shannon Divergence (symmetric, bounded [0, 1])."""
bins = np.linspace(min(reference.min(), current.min()),
max(reference.max(), current.max()), n_bins + 1)
p = np.histogram(reference, bins=bins, density=True)[0]
q = np.histogram(current, bins=bins, density=True)[0]
m = (p + q) / 2
js = 0.5 * stats.entropy(p, m) + 0.5 * stats.entropy(q, m)
return js
rng = np.random.default_rng(42)
ref = rng.normal(100, 20, 10000)
cur_drifted = rng.normal(115, 25, 5000)
# KS test
ks_stat, ks_pvalue = stats.ks_2samp(ref, cur_drifted)
# PSI
psi = calculate_psi(ref, cur_drifted)
# JS divergence
js = calculate_js_divergence(ref, cur_drifted)
print(f"KS statistic: {ks_stat:.4f}, p-value: {ks_pvalue:.6f}")
print(f"PSI: {psi:.4f}")
print(f"JS Divergence: {js:.4f}")
出力:
TEXT
📖 参照専用
# Functions defined successfully
| 指標 | 範囲 | ドリフトなしの閾値 | ドリフトの閾値 | 特徴 |
|---|---|---|---|---|
| PSI | [0, ∞) | < 0.1 | > 0.2 | 業界標準 |
| KS統計量 | [0, 1] | < 0.05 | > 0.1 | ノンパラメトリック検定 |
| JSダイバージェンス | [0, 1] | < 0.05 | > 0.1 | 対称的で解釈しやすい |
| KLダイバージェンス | [0, ∞) | < 0.1 | > 0.2 | 非対称 |
5. コンセプトドリフトと自動再学習
(1) データドリフトとコンセプトドリフトの違い
| 種類 | 定義 | 検知方法 | 例 |
|---|---|---|---|
| データドリフト | P(X)が変化する — 入力分布がずれる | 特徴量に対するPSI/KS | ダブルイレブン中にユーザーの支出が倍増する |
| コンセプトドリフト | P(Y | X)が変化する — 入出力関係がずれる | 予測誤差の上昇 |
| ラベルドリフト | P(Y)が変化する — 出力分布がずれる | 予測分布のモニタリング | 解約率が8%から15%へ急上昇する |
▶ サンプル:Bobのダブルイレブンにおけるドリフト検知
PYTHON
import numpy as np
rng = np.random.default_rng(42)
# Normal period: ad_spend → revenue relationship
n_normal = 10000
ad_spend_normal = rng.uniform(10, 100, n_normal)
revenue_normal = 50 + 0.8 * ad_spend_normal + rng.normal(0, 10, n_normal)
# Double-11 period: behavior completely changed
n_promo = 5000
ad_spend_promo = rng.uniform(50, 200, n_promo) # Higher ad spend
revenue_promo = 200 + 1.5 * ad_spend_promo + rng.normal(0, 30, n_promo) # Different coefficients!
# Detect drift in ad_spend distribution
psi = calculate_psi(ad_spend_normal, ad_spend_promo)
ks_stat, ks_p = stats.ks_2samp(ad_spend_normal, ad_spend_promo)
print("=== Data Drift Detection ===")
print(f"Ad spend PSI: {psi:.3f} {'⚠️ DRIFT' if psi > 0.2 else 'OK'}")
print(f"Ad spend KS: {ks_stat:.3f} (p={ks_p:.6f}) {'⚠️ DRIFT' if ks_p < 0.05 else 'OK'}")
# Detect concept drift: same X, different Y|X relationship
# Use a model trained on normal data to predict promo data
from sklearn.linear_model import LinearRegression
model_normal = LinearRegression()
model_normal.fit(ad_spend_normal.reshape(-1, 1), revenue_normal)
pred_promo = model_normal.predict(ad_spend_promo.reshape(-1, 1))
mape_normal_on_normal = np.mean(np.abs((revenue_normal - model_normal.predict(ad_spend_normal.reshape(-1, 1))) / revenue_normal)) * 100
mape_normal_on_promo = np.mean(np.abs((revenue_promo - pred_promo) / revenue_promo)) * 100
print(f"\n=== Concept Drift Detection ===")
print(f"MAPE (normal→normal): {mape_normal_on_normal:.1f}%")
print(f"MAPE (normal→promo): {mape_normal_on_promo:.1f}%")
print(f"Performance degradation: {mape_normal_on_promo - mape_normal_on_normal:.1f}% ⚠️ CONCEPT DRIFT")
出力:
TEXT
📖 参照専用
=== Data Drift Detection ===
(2) 自動再学習パイプライン
▶ サンプル:ドリフト検知+自動再学習フレームワーク
PYTHON
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_percentage_error
class AutoRetrainPipeline:
def __init__(self, drift_threshold_psi=0.2, mape_threshold=0.15):
self.drift_threshold_psi = drift_threshold_psi
self.mape_threshold = mape_threshold
self.model = None
self.reference_data = None
def train(self, X, y):
self.model = LinearRegression()
self.model.fit(X, y)
self.reference_data = X.copy()
def check_drift(self, X_current):
drifts = []
for i in range(X_current.shape[1]):
psi = calculate_psi(self.reference_data[:, i], X_current[:, i])
drifts.append({"feature": f"feat_{i}", "psi": psi, "drift": psi > self.drift_threshold_psi})
return drifts
def check_performance(self, X, y):
y_pred = self.model.predict(X)
mape = mean_absolute_percentage_error(y, y_pred)
return mape < self.mape_threshold, mape
def auto_retrain_if_needed(self, X_current, y_current):
# Step 1: Check drift
drifts = self.check_drift(X_current)
any_drift = any(d["drift"] for d in drifts)
# Step 2: Check performance
performance_ok, mape = self.check_performance(X_current, y_current)
# Step 3: Decision
if not any_drift and performance_ok:
return "OK - No retraining needed"
if any_drift and not performance_ok:
# Retrain with recent data
self.model.fit(X_current, y_current)
self.reference_data = X_current.copy()
new_mape = mean_absolute_percentage_error(y_current, self.model.predict(X_current))
return f"RETRAINED - PSI drift detected, MAPE {mape:.1%} → {new_mape:.1%}"
if any_drift:
return f"DRIFT WARNING - PSI drift but performance still OK (MAPE {mape:.1%})"
return f"PERFORMANCE WARNING - No drift but MAPE degraded to {mape:.1%}"
# Test
pipeline = AutoRetrainPipeline()
rng = np.random.default_rng(42)
X_train = rng.uniform(10, 100, (5000, 3))
y_train = 50 + 0.8 * X_train[:, 0] + rng.normal(0, 5, 5000)
pipeline.train(X_train, y_train)
# Simulate Double-11 data
X_promo = rng.uniform(50, 200, (2000, 3))
y_promo = 200 + 1.5 * X_promo[:, 0] + rng.normal(0, 10, 2000)
result = pipeline.auto_retrain_if_needed(X_promo, y_promo)
print(result)
出力:
TEXT
📖 参照専用
# Functions defined successfully
❓ よくある質問
Q PSI > 0.2なら、必ず再学習が必要ですか?
A 必ずしもそうではありません。PSIが検知するのは分布の変化であって、性能の劣化ではありません。分布がずれていても、モデルの精度は保たれている場合があります(たとえば、ユーザー数は増えたがパターンは同じ、といった場合です)。再学習のトリガーは、PSIとMAPEの二重条件にするのがベストです。
Q ドリフトのチェックはどれくらいの頻度で行うべきですか?
A ビジネスのペースによります。リアルタイムサービスなら時間ごと、バッチジョブなら毎日、月次予測なら毎週チェックします。大きなイベント(ダブルイレブンなど)の直後は、必ずすぐにチェックしてください。
Q 再学習にはどのデータを使うべきですか?
A スライディングウィンドウ(直近3〜6か月分のデータ)を使うか、重み付き学習(直近のデータに高い重みを与える)のがおすすめです。過去の全データを使うのは避けてください。古いデータは古くなったコンセプトを反映している可能性があります。
Q コンセプトドリフトとデータドリフト、より危険なのはどちらですか?
A コンセプトドリフトの方が危険です。データドリフトでは入力分布が変わるだけなので、モデルはまだ正確な場合があります。一方、コンセプトドリフトでは入出力関係が変わってしまうため、モデルは必ず間違った予測をします。とはいえ、データドリフトはコンセプトドリフトの早期警告サインであることが多いです。
Q 自動再学習にリスクはありますか?
A あります。1)新しいデータにラベルの品質問題がある可能性があります。2)自動再学習が新たなバグを持ち込む可能性があります。3)本番投入前に必ずA/B検証を通過させる必要があります。推奨されるワークフローは、自動再学習+人間によるレビュー+A/B検証の3ステップです。
Q 季節変動と本当のドリフトをどう見分けますか?
A 前年同期比(今年のQ4と去年のQ4)を使い、前期比(Q4とQ3)は使わないことです。季節性は周期的で(毎年繰り返します)、ドリフトは構造的です(元に戻らない持続的な変化です)。
📖 まとめ
- 3層のモニタリングアーキテクチャ:インフラ(レイテンシ/可用性)→ ML指標(予測分布/特徴量統計)→ ビジネス指標(MAPE/売上)
- PSIはドリフト検知の業界標準です:< 0.1ならドリフトなし、0.1〜0.2なら要調査、> 0.2なら要対応
- データドリフト = P(X)の変化(入力分布)、コンセプトドリフト = P(Y|X)の変化(関係性)— 後者の方がより危険です
- 自動再学習パイプライン:ドリフト検知 → 性能チェック → 再学習 → A/B検証 → カナリアリリース
- 季節変動とドリフトの見分け方:前年同期比で区別する — 季節性は元に戻るが、ドリフトは持続する
- モニタリング+自動再学習こそが、MLシステムを長期的に信頼できる状態に保つ鍵です — デプロイはゴールではありません
📝 練習問題
- 基礎(難易度 ⭐):PSIを使って2つのデータセット間の分布変化を検知し、ドリフトなしの場合と平均が10ずれた場合のPSI値を比較してください。ヒント:第4章のPSI計算関数を参照してください。
- 応用(難易度 ⭐⭐):3層モニタリングを実装してください。シミュレーションした予測データに対して、1)PSIドリフト、2)MAPEの劣化、3)レイテンシ異常(p95 > 100ms)を検知します。ヒント:3つの独立したチェック関数+統合レポートを作成します。
- チャレンジ(難易度 ⭐⭐⭐):完全なAutoRetrainPipelineを実装してください。通常期間+セール期間のデータを生成し、初期モデルを学習させ、ドリフトを検知して自動的に再学習し、再学習前後のMAPEを比較して、完全なモニタリングレポートを出力します。ヒント:第5章の自動再学習フレームワークを参照してください。