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?

データドリフト vs コンセプトドリフト:何が変わると何が壊れる?(最小実験)

0
Last updated at Posted at 2026-02-14

想定読者

  • MLOpsや運用で「ドリフト監視」が必要と言われたけど、何を見ればいいか曖昧
  • 「データドリフト」「コンセプトドリフト」の違いが腹落ちしていない
  • CVは良いのに本番で当たらない、原因が分からない

この記事のゴール

  • 用語から丁寧に:データドリフトコンセプトドリフトを整理
  • ダミーデータで:“何が変わると何が壊れるか”を図で体験
  • 実務で:監視項目(入力分布 / 性能 / 予測分布)と切り分けの考え方を持ち帰る

TL;DR(結論)

  • データドリフト(data drift):入力分布 p(x) が変わる(例:ユーザー層の変化、季節性、装置更新)
    • 入力分布の監視(PSI/KS等)で検知しやすい
    • ただし 性能が落ちるとは限らない(落ちない場合もある)
  • コンセプトドリフト(concept drift):関係 p(y|x)(=「ルール」)が変わる(例:定義変更、環境変化で因果が変わる)
    • 入力分布が変わらなくても起きる
    • 性能が落ちるが、ラベルが無いと気づきにくい(遅れて発覚しがち)

この記事の最小実験では、次が同時に起きるのを再現します:

  • データドリフトのみ:PSI↑(入力変化が見える) / AUCはほぼ維持(壊れないこともある)
  • コンセプトドリフトのみ:PSIほぼ一定(入力は同じ) / AUC↓(壊れる)
  • 両方:PSI↑ / AUC↓

1. 用語説明

データドリフト(Data drift)

「入力(特徴量)側の分布が変わる」こと。代表例は Covariate shiftp(x)が変わる)。

  • 例:年齢層が変わった、装置が変わった、季節でセンサー値が変わる、など

コンセプトドリフト(Concept drift)

「入力→出力の関係(ルール)が変わる」こと。p(y|x)が変わる。

  • 例:ビジネスルール変更、症例の治療方針変更、計測系の仕様変更で意味が変わる、など

ざっくり比較表(何が変わる?何が壊れる?)

観点 データドリフト コンセプトドリフト
何が変わる? p(x)(入力分布) p(y|x)(ルール)
入力分布監視(PSI/KS) 反応しやすい 反応しないことがある
性能(AUC/誤差) 落ちることも、落ちないことも 落ちやすい
発見のしやすさ ラベル無しでも比較的可能 ラベルが必要になりがち(遅れる)
典型対策 追加データで再学習、特徴量の見直し ルール変更の把握、再学習、運用の見直し

2. 最小実験の設計(何をどう再現する?)

2次元の分類問題を作る

  • 入力:x1, x2
  • ラベル:y(0/1)

「真のルール(本当の世界)」は、ロジスティック関数で作ります:

  • p(y=1|x) = sigmoid( scale * (w · x + b) )

この w が「境界の向き(ルール)」です。

4つのシナリオを、日ごとに作る

  • no_drift:何も変わらない
  • data_driftp(x)だけ変わる(平均がじわじわ移動)
  • concept_driftp(y|x)だけ変わる(境界がじわじわ回転)
  • both:両方変わる

監視する2つの指標

  • 入力ドリフト指標(PSI)p(x)の変化を見る
  • 性能(ROC-AUC):モデルが当たっているか(※ラベルが必要)

3. Google Colab 実行コード(コピペでOK)

3.1 import

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, accuracy_score, log_loss

3.2 便利関数(sigmoid / 回転 / データ生成)

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def rotate(v, theta):
    """2Dベクトルvを角度thetaだけ回転"""
    c, s = np.cos(theta), np.sin(theta)
    R = np.array([[c, -s],[s, c]])
    return R @ v

def generate_batch(n, mu, w, b=0.0, scale=1.5, seed=0):
    """
    X ~ N(mu, I)
    y ~ Bernoulli(sigmoid(scale*(w·x + b)))
    """
    rng = np.random.default_rng(seed)
    X = rng.normal(loc=mu, scale=1.0, size=(n, 2))
    logits = scale * (X @ w + b)
    p = sigmoid(logits)
    y = (rng.random(n) < p).astype(int)
    return X, y

3.3 PSI(Population Stability Index)を実装(最小)

PSIは「学習時の分布」と「現在の分布」を、同じビン(ここでは学習データの分位点)で比較する指標です。

  • ざっくり:PSIが大きいほど分布変化が大きい
  • 目安として 0.10.25 を境にすることもありますが、データサイズや用途で変わるので過信は禁物です。
def psi_1d(train, current, bins=10, eps=1e-8):
    # 学習データの分位点でビン境界を作る
    qs = np.linspace(0, 1, bins + 1)
    cuts = np.quantile(train, qs)
    cuts[0] = -np.inf
    cuts[-1] = np.inf

    # 境界が重複して単調でなくなるのを回避(安全策)
    for i in range(1, len(cuts)):
        if cuts[i] <= cuts[i-1]:
            cuts[i] = cuts[i-1] + 1e-6

    exp_counts, _ = np.histogram(train, bins=cuts)
    act_counts, _ = np.histogram(current, bins=cuts)

    exp = exp_counts / exp_counts.sum()
    act = act_counts / act_counts.sum()

    exp = np.clip(exp, eps, None)
    act = np.clip(act, eps, None)

    return np.sum((act - exp) * np.log(act / exp))

def psi_mean_over_features(X_ref, X_cur, bins=10):
    psis = [psi_1d(X_ref[:, j], X_cur[:, j], bins=bins) for j in range(X_ref.shape[1])]
    return float(np.mean(psis)), psis

4. 学習(day0)でモデルを作る

# 再現性
rng = np.random.default_rng(0)

# 真のルール(day0)
w0 = np.array([1.0, -1.0])
b0 = 0.0
scale = 1.5

# 学習データ(day0)
n_train_total = 6000
X0, y0 = generate_batch(n_train_total, mu=np.array([0.0, 0.0]), w=w0, b=b0, scale=scale, seed=1)

# train / reference split(PSIの基準や初期性能確認に使う)
X_train, X_ref, y_train, y_ref = train_test_split(X0, y0, test_size=0.3, random_state=0, stratify=y0)

# モデル(標準化 + ロジスティック回帰)
model = Pipeline([
    ("scaler", StandardScaler()),
    ("logreg", LogisticRegression(max_iter=2000))
])
model.fit(X_train, y_train)

# 初期性能(参考)
p_ref = model.predict_proba(X_ref)[:, 1]
print("Initial AUC on ref:", roc_auc_score(y_ref, p_ref))
print("Initial ACC on ref:", accuracy_score(y_ref, (p_ref >= 0.5).astype(int)))
print("Initial logloss on ref:", log_loss(y_ref, p_ref))

スクリーンショット 2026-02-14 092736.png


5. 「日ごとのデータ」を作って、PSIとAUCを追う(最小実験)

  • データドリフト:平均 mu がじわじわ動く(p(x)が変わる)
  • コンセプトドリフト:境界ベクトル w がじわじわ回転(p(y|x)が変わる)
T = 30        # 日数
n_day = 2000  # 1日あたりの評価データ数

delta_mu = 0.05       # データドリフトの強さ(平均移動の速度)
theta_max = np.pi/3   # コンセプトドリフトの強さ(最終日の回転角 = 60度)

records = []

for day in range(T):
    # コンセプトドリフト用:徐々に回転
    theta = theta_max * day / (T - 1)
    w_day = rotate(w0, theta)

    # データドリフト用:平均が徐々に移動
    mu_day = np.array([delta_mu * day, 0.0])

    scenarios = {
        "no_drift":      (np.array([0.0, 0.0]), w0),
        "data_drift":    (mu_day, w0),
        "concept_drift": (np.array([0.0, 0.0]), w_day),
        "both":          (mu_day, w_day),
    }

    for name, (mu, w_true) in scenarios.items():
        X, y = generate_batch(n_day, mu=mu, w=w_true, b=b0, scale=scale, seed=1000 + 10*day + list(scenarios.keys()).index(name))
        p = model.predict_proba(X)[:, 1]

        auc = roc_auc_score(y, p)
        psi_mean, _ = psi_mean_over_features(X_train, X)

        records.append({
            "scenario": name,
            "day": day,
            "auc": auc,
            "psi": psi_mean,
        })

df = pd.DataFrame(records)
display(df.head())

スクリーンショット 2026-02-14 092839.png


6. 図を作る(3枚)

図1:入力ドリフト(PSI)の時間推移

pivot_psi = df.pivot(index="day", columns="scenario", values="psi")

plt.figure(figsize=(7.2, 4.6))
for sc in pivot_psi.columns:
    plt.plot(pivot_psi.index, pivot_psi[sc], label=sc)
plt.xlabel("day")
plt.ylabel("PSI (mean over features)")
plt.title("Data drift signal (PSI) over time")
plt.legend()
plt.tight_layout()
plt.savefig("fig1_psi_over_time.png", dpi=200)
plt.show()

print("saved: fig1_psi_over_time.png")

fig1_psi_over_time.png


図2:モデル性能(ROC-AUC)の時間推移

pivot_auc = df.pivot(index="day", columns="scenario", values="auc")

plt.figure(figsize=(7.2, 4.6))
for sc in pivot_auc.columns:
    plt.plot(pivot_auc.index, pivot_auc[sc], label=sc)
plt.xlabel("day")
plt.ylabel("ROC-AUC")
plt.ylim(0.5, 1.0)
plt.title("Model performance over time")
plt.legend()
plt.tight_layout()
plt.savefig("fig2_auc_over_time.png", dpi=200)
plt.show()

print("saved: fig2_auc_over_time.png")

fig2_auc_over_time.png


図3:同じ“見た目”でも、壊れ方が違う(最終日の散布図+境界)

ポイント:

  • data_drift:点群が移動する(PSI↑)が、真の境界(solid)とモデル境界(--)はほぼ一致 → 性能は維持しやすい
  • concept_drift:点群は同じ(PSIほぼ一定)なのに、真の境界が回転してモデル境界とズレる → 性能が落ちる
# モデル境界を「元のx空間」で描くために係数を変換(標準化を戻す)
scaler = model.named_steps["scaler"]
lr = model.named_steps["logreg"]
w_scaled = lr.coef_.ravel()
b_scaled = lr.intercept_[0]
w_model = w_scaled / scaler.scale_
b_model = b_scaled - np.sum(w_scaled * scaler.mean_ / scaler.scale_)

# 最終日の真のルール
day = T - 1
theta = theta_max * day / (T - 1)
w_true_concept = rotate(w0, theta)
mu_day = np.array([delta_mu * day, 0.0])

# 描画用データ(最終日)
plot_scenarios = [
    ("no_drift",      np.array([0.0, 0.0]), w0,              5000),
    ("data_drift",    mu_day,              w0,              5001),
    ("concept_drift", np.array([0.0, 0.0]), w_true_concept,  5002),
    ("both",          mu_day,              w_true_concept,  5003),
]

data_plot = {}
for name, mu, w_true, seed in plot_scenarios:
    Xp, yp = generate_batch(1200, mu=mu, w=w_true, b=b0, scale=scale, seed=seed)
    data_plot[name] = (Xp, yp, w_true)

# 直線:w1*x1 + w2*x2 + b = 0 -> x2 = -(w1*x1 + b)/w2
xlim = (-6, 6)
ylim = (-6, 6)
x1_vals = np.linspace(xlim[0], xlim[1], 200)

fig, axes = plt.subplots(2, 2, figsize=(10, 10), sharex=True, sharey=True)
axes = axes.ravel()

for ax, (name, (Xp, yp, w_true)) in zip(axes, data_plot.items()):
    ax.scatter(Xp[:, 0], Xp[:, 1], c=yp, s=10, alpha=0.6, cmap="coolwarm", vmin=0, vmax=1)

    x2_model = -(w_model[0] * x1_vals + b_model) / w_model[1]
    x2_true  = -(w_true[0]  * x1_vals + b0) / w_true[1]

    ax.plot(x1_vals, x2_model, linestyle="--", linewidth=2, label="model boundary")
    ax.plot(x1_vals, x2_true,  linestyle="-",  linewidth=2, label="true boundary")

    ax.set_title(name)
    ax.set_xlim(xlim); ax.set_ylim(ylim)
    ax.set_xlabel("x1"); ax.set_ylabel("x2")
    ax.grid(alpha=0.2)

handles, labels = axes[0].get_legend_handles_labels()
fig.legend(handles, labels, loc="upper right")
fig.suptitle(
    f"Day {day}: data drift vs concept drift\npoints colored by y, -- model boundary (trained at day0), solid true boundary",
    y=0.98
)
fig.tight_layout(rect=[0, 0, 1, 0.95])
fig.savefig("fig3_scatter_boundaries.png", dpi=200)
plt.show()

print("saved: fig3_scatter_boundaries.png")

fig3_scatter_boundaries.png


7. 結果の読み方

パターンA:PSI↑ だが AUCが落ちない(データドリフトっぽい)

  • 入力が変わっているのは事実
  • でも「ルール自体」は変わっていないので、性能が落ちないこともある
    “今すぐ壊れた”ではないが、リスクが上がった状態

実務の動き:

  • データのカバレッジ確認(学習域外になっていないか)
  • OOD検知や予測区間を合わせて監視
  • “ドリフトが一定以上続いたら再学習”など運用ルールを決める

パターンB:PSIほぼ一定 なのに AUC↓(コンセプトドリフトっぽい)

  • 入力分布は同じに見える
  • なのに性能が落ちる → ルール(p(y|x))が変わった可能性

実務の動き:

  • ラベル定義・計測仕様・業務ルールの変更が無いか確認
  • データ前処理や特徴量の意味が変わっていないか(Training/Serving skew)
  • 早めに再学習(特にオンライン性が高い領域)

パターンC:PSI↑ かつ AUC↓(両方 or 強いシフト)

  • ドリフトが大きく、性能も落ちている
    → 対応優先度が高い(データ収集、再学習、フォールバックなど)

8. 実務の監視設計

8.1 ラベルがすぐ手に入らない(遅延する)場合

  • まずは 入力分布監視(PSI/KS/統計量)
  • 次に モデル出力の分布監視(予測確率の平均・分散、上位確率の割合など)
  • さらに可能なら OODスコア(kNN距離、IsolationForest、など)
  • Conformal predictionの予測区間幅も「危険信号」になり得る

8.2 ラベルが入る場合(週次・月次でもOK)

  • 性能(AUC/誤差)を時系列で監視
  • 特に「特定セグメント(施設・装置・地域・曜日)」での性能劣化を確認
  • Calibration(確率の当たり具合)も重要

8.3 “何が起きたか”を切り分ける簡易マトリクス

  • 入力ドリフト大(PSI↑) / 性能劣化なし
    → 今は耐えているが、将来リスク。学習域外/OODの兆候をチェック。
  • 入力ドリフト小(PSI≈0) / 性能劣化あり
    → コンセプトドリフト疑い。ルール変更・ラベル定義変更・前処理変更を疑う。
  • 入力ドリフト大(PSI↑) / 性能劣化あり
    → 強いシフト。再学習・フォールバック・追加データ収集を急ぐ。

9. よくある落とし穴

  • PSIは“各特徴量の周辺分布”しか見ない
    → 多変量の相関が変わる(周辺は同じでも関係が変わる)と見逃すことがある
  • サンプル数が少ないとPSIがブレる
    → 日次ではなく週次集計にする、信頼区間を考える、など工夫が必要
  • “ドリフト=必ず性能劣化”ではない
    → 逆に“性能劣化=必ずドリフト”でもない(ラベル品質、データ欠損、評価方法の変更など)

まとめ

  • データドリフトは入力分布 p(x) の変化。入力監視で気づきやすいが、性能が落ちないこともある。
  • コンセプトドリフトp(y|x) の変化。入力が同じでも性能が落ちることがあり、ラベルが無いと気づきにくい。
  • だから運用では、
    入力監視(PSI等)+出力監視+(ラベルが来たら)性能監視 の三段構えが現実的。
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?