Hayami Kaito Web
~/tech

機械学習モデルの運用(MLOps)のしくみ:データドリフトの検知・監視・再学習

published

機械学習のモデルは、作って評価したところで終わりではありません。本番で使い始めると、世の中のほうが変わっていきます。客層が変わる、新しい商品が出る、センサーを取り替える、不正の手口が変わる、といったことで、学習したときのデータと本番のデータがずれていき、モデルの成績は少しずつ(ときには急に)落ちていきます。

このような、モデルを本番で動かし続けるための仕組みづくりを MLOps(機械学習の運用)と呼びます。製造業の画像検査 AI の使い方と作り方 の「運用しながら学び直す」を、一般的な形で説明するのがこの記事です。

結論を先に書くと、次のようになります。

  • ずれには 2 種類ある。入力の分布が変わる データドリフト と、入力から答えへの決まり方が変わる コンセプトドリフト です
  • データドリフトは、入力の分布の比較(PSI・KS 統計量)で見つけられる。ただし、コンセプトドリフトは入力の分布だけを見ていても見つからない。計算例では、正解率が 0.90 から 0.50 まで落ちても、入力の分布は変わっていなかった
  • 再学習は「いつ」と「どのデータで」を決める。計算例では、学び直さないと変化のあとの正解率は 0.724、毎月すべての期間のデータで学び直しても 0.762 だった。直近 3 か月のデータで学び直すと 0.879 に保てた
  • 成績を測るには正解のラベルが要る。ラベルが遅れて届くことを前提に、監視と再学習の流れを作る

説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。

環境

計算例を動かした環境です。

項目バージョン
Python3.13
NumPy2.5

本番で起きるずれの種類

データドリフトとコンセプトドリフト

入力を x、答えを y とすると、データの分布は p(x, y) = p(x) × p(y | x) と分けて書けます。どちらが変わるかで、ずれを 2 種類に分けます。

種類変わるもの例成績への影響
データドリフト(共変量シフト)入力の分布 p(x)若い客が増えた、夏になって気温の値が上がった、カメラの明るさが変わった学習用のデータの少なかった範囲の入力が増えると、成績が落ちる
コンセプトドリフト入力から答えへの決まり方 p(y | x)不正の手口が変わった、景気が変わって同じ年収でも返済の確率が変わった同じ入力に対する正しい答えが変わるので、学び直すしかない
ラベルの割合の変化答えの割合 p(y)流行で病気の患者の割合が増えたしきい値や確率の較正がずれる

ずれ方にも、急に変わる(制度の変更・システムの入れ替え)、少しずつ変わる(客層の変化)、周期的にくり返す(季節)、などの違いがあります。

前処理の食い違い

ずれのもう 1 つの原因は、世の中ではなく システムの側 にあります。学習のときと本番のときで、特徴の計算のしかたが違ってしまうことです(training-serving skew)。

例起きること
学習ではバッチ処理で、本番では別のプログラムで特徴を計算している欠損の埋め方や丸め方が少し違い、本番だけ値がずれる
上流のシステムで、単位が円から千円に変わったある列だけ、値が 1000 分の 1 になる
本番では、欠損を 0 で埋めるようになっていた0 の値が急に増える

このような変化は、次の分布の比較で見つけられます。

データドリフトを測る

PSI と KS 統計量

1 つの特徴について、学習のときの分布と今の分布を比べる代表的な指標が 2 つあります。

指標計算目安
PSI(母集団安定性指標)学習のときのデータの分位点で 10 個のビンに分け、ビンごとの割合 p(学習時)と q(今)から Σ (q − p) × log(q / p)0.1 未満は安定、0.1〜0.25 は注意、0.25 以上は大きな変化、とよく言われる
KS 統計量(コルモゴロフ・スミルノフ)2 つの累積分布の差の最大0〜1。データの数が多いと、小さな差でも検定では「有意」になりやすい

PSI は、金融の与信モデルの監視で昔から使われてきた指標です。どちらも、特徴ごとに毎日・毎週計算して、変化を見張ります。

計算してみる

学習のときの分布を標準正規分布(10000 件)とし、本番のデータ(2000 件)がいろいろな形でずれたときの値を計算します。

import numpy as np

def psi(ref, cur, bins=10):
    """PSI:学習時の分布と今の分布のずれ。ビンは学習時のデータの分位点で切る"""
    edges = np.quantile(ref, np.linspace(0, 1, bins + 1)); edges[0], edges[-1] = -np.inf, np.inf
    p = np.histogram(ref, edges)[0] / len(ref)
    q = np.histogram(cur, edges)[0] / len(cur)
    p, q = np.clip(p, 1e-6, None), np.clip(q, 1e-6, None)
    return float(((q - p) * np.log(q / p)).sum())

def ks(ref, cur):
    """コルモゴロフ・スミルノフ統計量:2 つの累積分布の差の最大"""
    grid = np.sort(np.concatenate([ref, cur]))
    F = np.searchsorted(np.sort(ref), grid, side="right") / len(ref)
    G = np.searchsorted(np.sort(cur), grid, side="right") / len(cur)
    return float(np.abs(F - G).max())

rng = np.random.default_rng(0)
ref = rng.normal(0, 1, 10000)                          # 学習に使ったときの分布
cases = {
    "変化なし": rng.normal(0, 1, 2000),
    "平均が 0.1 ずれた": rng.normal(0.1, 1, 2000),
    "平均が 0.5 ずれた": rng.normal(0.5, 1, 2000),
    "ばらつきが 1.5 倍": rng.normal(0, 1.5, 2000),
    "1 割が欠損して 0 で埋められた": np.where(rng.random(2000) < 0.1, 0.0, rng.normal(0, 1, 2000)),
}
for name, cur in cases.items():
    print(f"{name}: PSI {psi(ref, cur):.3f}, KS {ks(ref, cur):.3f}")

実行結果:

変化なし: PSI 0.003, KS 0.010
平均が 0.1 ずれた: PSI 0.011, KS 0.044
平均が 0.5 ずれた: PSI 0.246, KS 0.202
ばらつきが 1.5 倍: PSI 0.213, KS 0.110
1 割が欠損して 0 で埋められた: PSI 0.077, KS 0.055
  • 平均が標準偏差の半分(0.5)ずれると、PSI は 0.246 で「大きな変化」の目安の近くになります
  • ばらつきだけが変わった場合、平均は同じでも PSI は 0.213 になりました。分布の両端のビンの割合が増えるからです。KS 統計量は 0.110 で、平均のずれのときほどは大きくなりません。KS 統計量は累積分布の差なので、分布の中央の位置のずれに敏感で、裾の変化には鈍い性質があります
  • 欠損を 0 で埋めた 場合、PSI は 0.077 で「安定」の範囲です。しかし、これは前処理の不具合で、放っておけません。0 ちょうどの値の割合のように、不具合の起きやすい形を狙った監視(欠損率・0 の割合・取りうる範囲の外の値の数)も、分布の指標と一緒に見るのが大事です

特徴の数が多いと、毎日どれかの特徴で「変化あり」が出るようになります。すべてに反応するのではなく、モデルの判断に大きく効いている特徴(機械学習の判断を説明するしくみ の重要度が高いもの)を優先して見張ります。

コンセプトドリフトと再学習

シミュレーションの設定

24 か月分、毎月 2000 件のデータを作ります。入力は 2 つの特徴で、分布は毎月同じです。答えは、ある向きの境界のどちら側にあるかで決まり、12 か月目から 23 か月目にかけて、境界の向きが少しずつ 90 度回っていく とします。入力の分布は変わらず、答えの決まり方だけが変わる、コンセプトドリフトです。

各月の初めに、前の月までのデータでモデル(ロジスティック回帰)を用意し、その月のデータで正解率を測ります。再学習のやり方は 4 通りです。

やり方内容
学び直さない0 か月目のデータで作ったモデルを使い続ける
毎月、全期間で学び直すこれまでのすべてのデータで、毎月作り直す
毎月、直近 3 か月で学び直す直近 3 か月のデータだけで、毎月作り直す
正解率が下がったら学び直す先月の正解率がしきい値を下回ったら、先月のデータで作り直す
def fit_logreg(X, y, steps=30, l2=1e-3):
    Xb = np.column_stack([np.ones(len(X)), X]); beta = np.zeros(Xb.shape[1])
    for _ in range(steps):
        p = 1 / (1 + np.exp(-Xb @ beta))
        hess = (Xb * (p * (1 - p))[:, None]).T @ Xb + l2 * np.eye(len(beta))
        beta -= np.linalg.solve(hess, Xb.T @ (p - y) + l2 * beta)
    return beta

def accuracy(beta, X, y):
    return np.mean(((beta[0] + X @ beta[1:]) > 0) == y)

# 24 か月分のデータ。答えの決まり方(境界の向き)が、12 か月目から少しずつ変わっていく
rng = np.random.default_rng(0)
months = []
for m in range(24):
    angle = 0 if m < 12 else (m - 12) / 11 * np.pi / 2  # 12 か月目から 23 か月目にかけて 90 度回る
    X = rng.normal(size=(2000, 2))
    w = np.array([np.cos(angle), np.sin(angle)])
    y = (X @ w + rng.normal(0, 0.3, 2000) > 0).astype(int)
    months.append((X, y))

def simulate(strategy, threshold=0.9):
    """各月の初めに、前の月までのデータでモデルを用意し、その月のデータで正解率を測る"""
    beta = fit_logreg(*months[0]); accs, retrains = [], []
    for m in range(1, 24):
        if strategy == "毎月、直近 3 か月で学び直す":
            X = np.vstack([months[k][0] for k in range(max(0, m - 3), m)])
            y = np.concatenate([months[k][1] for k in range(max(0, m - 3), m)])
            beta = fit_logreg(X, y); retrains.append(m)
        elif strategy == "毎月、全期間で学び直す":
            beta = fit_logreg(np.vstack([d[0] for d in months[:m]]), np.concatenate([d[1] for d in months[:m]])); retrains.append(m)
        elif strategy == "先月の正解率がしきい値を下回ったら学び直す":
            if accuracy(beta, *months[m - 1]) < threshold:     # 先月の答えが分かってから判断する
                beta = fit_logreg(*months[m - 1]); retrains.append(m)
        accs.append(accuracy(beta, *months[m]))
    return np.array(accs), retrains

runs = [("学び直さない", 0.9), ("毎月、全期間で学び直す", 0.9), ("毎月、直近 3 か月で学び直す", 0.9),
        ("先月の正解率がしきい値を下回ったら学び直す", 0.9), ("先月の正解率がしきい値を下回ったら学び直す", 0.88)]
for s, th in runs:
    accs, r = simulate(s, th)
    name = s.replace("しきい値", f" {th} ")
    print(f"{name}: 1〜11 か月目 {accs[:11].mean():.3f}, 12〜23 か月目 {accs[11:].mean():.3f}, "
          f"学び直した回数 {len(r)}(うち変化の前 {sum(m < 12 for m in r)})")

実行結果:

学び直さない: 1〜11 か月目 0.905, 12〜23 か月目 0.724, 学び直した回数 0(うち変化の前 0)
毎月、全期間で学び直す: 1〜11 か月目 0.905, 12〜23 か月目 0.762, 学び直した回数 23(うち変化の前 11)
毎月、直近 3 か月で学び直す: 1〜11 か月目 0.906, 12〜23 か月目 0.879, 学び直した回数 23(うち変化の前 11)
先月の正解率が 0.9 を下回ったら学び直す: 1〜11 か月目 0.906, 12〜23 か月目 0.894, 学び直した回数 10(うち変化の前 3)
先月の正解率が 0.88 を下回ったら学び直す: 1〜11 か月目 0.905, 12〜23 か月目 0.886, 学び直した回数 4(うち変化の前 0)

結果の読み方

  • 学び直さないと、変化のあとの正解率は 0.724 まで落ちる。最後の 23 か月目には、当て推量と同じ 0.5 くらいになります
  • 全期間のデータで学び直しても、ほとんど良くならない(0.762)。データは増えていきますが、その大部分は「古い決まり方」のデータなので、モデルは古い答えに引っ張られます。データが多いほど良い、とは限りません
  • 直近 3 か月だけで学び直すと、0.879 に保てた。古いデータを捨てることで、今の決まり方に追いつけます。そのかわり、学習に使うデータは少なくなるので、変化がない時期には、データの多い方法より少し不利になることもあります
  • 正解率が下がったときだけ学び直す方法は、少ない回数で同じくらいの成績になる。しきい値 0.9 では 10 回の学び直しで 0.894 でした。ただし、そのうち 3 回は変化の前で、ふだんの正解率(約 0.905)のゆらぎで 0.9 を下回っただけの「誤報」です。しきい値を 0.88 にすると、誤報はなくなり、学び直しは 4 回で済みましたが、成績は 0.886 と少し下がりました。しきい値は、ふだんの成績のゆらぎの幅を見て決めます

入力の分布だけでは気づけない

このシミュレーションの変化を、入力の分布の監視で見つけられるかを確かめます。0 か月目と、11・17・23 か月目の分布を比べます。モデルの予測の点数(β₀ + β·x)の分布も比べます。

# 入力と予測の分布を見張るだけで、この変化に気づけるか
beta0 = fit_logreg(*months[0])
score = lambda X: beta0[0] + X @ beta0[1:]
for m in (11, 17, 23):
    X, y = months[m]
    print(f"{m} か月目: 入力 1 の PSI {psi(months[0][0][:, 0], X[:, 0]):.3f}, 予測の点数の PSI {psi(score(months[0][0]), score(X)):.3f}, 正解率 {accuracy(beta0, X, y):.3f}")

実行結果:

11 か月目: 入力 1 の PSI 0.011, 予測の点数の PSI 0.011, 正解率 0.902
17 か月目: 入力 1 の PSI 0.005, 予測の点数の PSI 0.005, 正解率 0.757
23 か月目: 入力 1 の PSI 0.011, 予測の点数の PSI 0.011, 正解率 0.504

正解率は 0.902 から 0.504 まで落ちているのに、入力の PSI も予測の点数の PSI もずっと 0.01 前後で、「安定」のままです(最初のモデルはほぼ入力 1 だけで判断しているので、2 つの PSI は同じ値になっています)。答えの決まり方だけが変わるコンセプトドリフトは、正解のラベルと比べないと見つけられない ことが分かります。

ラベルの遅れ

実際には、正解のラベルはすぐには手に入りません。ローンの返済が滞るかは数か月後、解約するかは契約の更新のときにしか分かりません。上のシミュレーションでも、「先月の答えが分かるのは今月の初め」としています。ラベルの遅れに対しては、次のような工夫をします。

工夫内容
代わりの指標を見るラベルの代わりに、入力・予測の分布、予測の確率の平均、人が判定を覆した割合などを見て、早めの兆候をつかむ
一部だけ急いで確かめる本番のデータの一部を抜き出して、人が正解を付ける
確定したラベルで振り返るラベルがそろった時期について、あとから成績を計算し直して記録する

本番で動かすしくみ

監視するもの

対象例
システム応答時間、エラーの率、処理の件数
入力のデータ特徴ごとの PSI、欠損率、範囲の外の値の数、新しいカテゴリの出現
予測予測の確率の平均と分布、陽性と判定した割合
成績ラベルが届いた分の正解率・適合率・再現率(機械学習の評価指標のしくみ)、業務の指標(売上・不良の見逃しの数など)

安全に入れ替える

学び直したモデルを、いきなりすべての処理に使うのは危険です。段階的に入れ替える方法がよく使われます。

方法内容
オフラインの評価直近のデータ(学習に使っていない期間)で、新旧のモデルを比べる。時間の順番を守って評価する(時系列予測のしくみ のローリング評価)
シャドー運用新しいモデルにも本番のデータで予測させるが、結果は使わない。古いモデルとの違いと、処理の速さを確かめる
カナリアリリース一部(たとえば 5%)の処理だけを新しいモデルに任せ、問題がなければ割合を増やす
A/B テスト利用者をランダムに分け、業務の指標で新旧を比べる(因果推論のしくみ)
すぐ戻せるようにする古いモデルを残しておき、問題があればすぐに切り戻す

再現できるように記録する

あとから「なぜこの予測をしたのか」「前のモデルとどこが違うのか」を調べられるように、次のものを記録しておきます。

  • 学習に使ったデータの範囲とその版(いつからいつまでの、どの抽出のデータか)
  • 前処理と特徴の計算のコード、モデルのコードの版
  • 調整の値と乱数の種
  • 評価の結果と、本番に入れた日時

MLOps の流れ

年手法・出来事ポイント
1996コンセプトドリフトの研究(Widmer と Kubat)答えの決まり方が変わるデータで、古いデータを忘れながら学習する
2000共変量シフトの補正(Shimodaira)入力の分布が変わったとき、データに重みを付けて学習し直す
2004ドリフトの検知(Gama ら、DDM)誤りの率の上がり方から、変化を統計的に見つける
2014コンセプトドリフトの総説(Gama ら)ずれの種類と、検知・適応の方法を整理した
2015機械学習システムの隠れた技術的負債(Sculley ら)モデルのコードはシステムのごく一部で、データの依存や設定の管理が大きな負担になると指摘した
2017ML Test Score(Breck ら)本番の機械学習システムが備えるべきテストと監視の点検表
2017〜特徴量ストア・モデルレジストリ特徴の計算を学習と本番で共通にし、モデルの版を管理するしくみが広まる

まとめ

  • 本番では、入力の分布が変わるデータドリフトと、入力から答えへの決まり方が変わるコンセプトドリフトが起きる。前処理の食い違いのような、システムの側の原因もある
  • データドリフトは PSI や KS 統計量で測れる。ばらつきの変化や欠損の埋め方の変化など、指標によって見え方が違うので、不具合を狙った監視も併せて行う
  • コンセプトドリフトは、入力や予測の分布を見ていても気づけない。正解のラベルと比べる必要があり、ラベルの遅れを前提に仕組みを作る
  • 答えの決まり方が変わったときは、古いデータまで使うと追いつけない。直近のデータで学び直すか、成績が下がったときに学び直す。しきい値は、ふだんの成績のゆらぎを見て決める
  • 学び直したモデルは、シャドー運用やカナリアリリースで段階的に入れ替え、データ・コード・設定の版を記録しておく

参考

  • G. Widmer and M. Kubat, "Learning in the Presence of Concept Drift and Hidden Contexts", Machine Learning 1996
  • H. Shimodaira, "Improving predictive inference under covariate shift by weighting the log-likelihood function", Journal of Statistical Planning and Inference 2000
  • J. Gama et al., "Learning with Drift Detection", SBIA 2004
  • J. Gama et al., "A Survey on Concept Drift Adaptation", ACM Computing Surveys 2014
  • D. Sculley et al., "Hidden Technical Debt in Machine Learning Systems", NIPS 2015
  • E. Breck et al., "The ML Test Score: A Rubric for ML Production Readiness and Technical Debt Reduction", IEEE Big Data 2017
  • S. Rabanser, S. Günnemann and Z. C. Lipton, "Failing Loudly: An Empirical Study of Methods for Detecting Dataset Shift", NeurIPS 2019(arXiv:1810.11953)
  • B. Yurdakul, "Statistical Properties of Population Stability Index", Dissertation, Western Michigan University 2018