Machine Learning: 本番環境のモニタリングとモデルドリフト — デプロイはゴールではなくスタートライン

最終更新:2026-08-26

モデルのデプロイは飛行機の離陸のようなものです。本当の挑戦は、フライト中のモニタリングと、途中で発生するあらゆる事態への対処にあります。

1. 学べること


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層のモニタリングアーキテクチャ

100%
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)は使わないことです。季節性は周期的で(毎年繰り返します)、ドリフトは構造的です(元に戻らない持続的な変化です)。

📖 まとめ


📝 練習問題

  1. 基礎(難易度 ⭐):PSIを使って2つのデータセット間の分布変化を検知し、ドリフトなしの場合と平均が10ずれた場合のPSI値を比較してください。ヒント:第4章のPSI計算関数を参照してください。
  2. 応用(難易度 ⭐⭐):3層モニタリングを実装してください。シミュレーションした予測データに対して、1)PSIドリフト、2)MAPEの劣化、3)レイテンシ異常(p95 > 100ms)を検知します。ヒント:3つの独立したチェック関数+統合レポートを作成します。
  3. チャレンジ(難易度 ⭐⭐⭐):完全なAutoRetrainPipelineを実装してください。通常期間+セール期間のデータを生成し、初期モデルを学習させ、ドリフトを検知して自動的に再学習し、再学習前後のMAPEを比較して、完全なモニタリングレポートを出力します。ヒント:第5章の自動再学習フレームワークを参照してください。

← 前へ:A/Bテスト | 次へ:プロジェクト設計 →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%