機械学習モデルの運用(MLOps)のしくみ:データドリフトの検知・監視・再学習
機械学習のモデルは、作って評価したところで終わりではありません。本番で使い始めると、世の中のほうが変わっていきます。客層が変わる、新しい商品が出る、センサーを取り替える、不正の手口が変わる、といったことで、学習したときのデータと本番のデータがずれていき、モデルの成績は少しずつ(ときには急に)落ちていきます。
このような、モデルを本番で動かし続けるための仕組みづくりを MLOps(機械学習の運用)と呼びます。製造業の画像検査 AI の使い方と作り方 の「運用しながら学び直す」を、一般的な形で説明するのがこの記事です。
結論を先に書くと、次のようになります。
- ずれには 2 種類ある。入力の分布が変わる データドリフト と、入力から答えへの決まり方が変わる コンセプトドリフト です
- データドリフトは、入力の分布の比較(PSI・KS 統計量)で見つけられる。ただし、コンセプトドリフトは入力の分布だけを見ていても見つからない。計算例では、正解率が 0.90 から 0.50 まで落ちても、入力の分布は変わっていなかった
- 再学習は「いつ」と「どのデータで」を決める。計算例では、学び直さないと変化のあとの正解率は 0.724、毎月すべての期間のデータで学び直しても 0.762 だった。直近 3 か月のデータで学び直すと 0.879 に保てた
- 成績を測るには正解のラベルが要る。ラベルが遅れて届くことを前提に、監視と再学習の流れを作る
説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。
環境
計算例を動かした環境です。
| 項目 | バージョン |
|---|---|
| Python | 3.13 |
| NumPy | 2.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 ら) | モデルのコードはシステムのごく一部で、データの依存や設定の管理が大きな負担になると指摘した |
| 2017 | ML 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