週次自動再訓練パイプラインの設計 — 「学習成功」と「デプロイして良い」は別物
週次自動再訓練パイプラインの設計 — 「学習成功」と「デプロイして良い」は別物
競馬データは毎週増えます。先週のレース結果を学習に取り込まなければ、モデルは少しずつ「過去の競馬」を予測するようになっていきます。つまり競馬MLの運用では再訓練の自動化がほぼ必須です。ただし「cronで学習スクリプトを叩くだけ」の自動化は、壊れたモデルを本番に送り込む事故装置にもなります。この記事では、週次再訓練パイプラインを安全に回すための設計の柱を5つ紹介します。
柱1: アトミックなモデル差し替え
最初にやりがちな事故が、学習済みモデルファイルを直接上書きすることです。pickleの書き込みには数秒かかります。その最中に予測APIがファイルを読むと、書きかけの壊れたpickleを掴んで例外になります。週末のレース直前にこれが起きると目も当てられません。
対策は「一時ファイルに書いてからリネームする」ことです。os.replace は同一ファイルシステム内であれば**原子的(アトミック)**に動作するため、読み手から見るとファイルは常に「旧モデルの完全体」か「新モデルの完全体」のどちらかです。
import os
import pickle
import tempfile
def atomic_save_model(model, dest_path: str) -> None:
"""一時ファイルに書いてから os.replace で原子的に差し替える"""
dest_dir = os.path.dirname(dest_path)
# 同一ファイルシステム上に一時ファイルを作るのが重要
fd, tmp_path = tempfile.mkstemp(dir=dest_dir, suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
pickle.dump(model, f)
f.flush()
os.fsync(f.fileno()) # ディスクへの書き込みを保証
os.replace(tmp_path, dest_path) # ここが原子的
except BaseException:
if os.path.exists(tmp_path):
os.remove(tmp_path)
raise
注意点は2つです。一時ファイルを /tmp に作ると最終目的地と別のファイルシステムになることがあり、os.replace が原子的でなくなる(内部的にコピーになる)ため、必ず目的地と同じディレクトリに作ります。また mkstemp はパーミッション600でファイルを作るので、別ユーザーのプロセスが読む構成なら os.chmod で調整が必要です。
柱2: デプロイゲート — 悪いモデルを自動で昇格させない
自動再訓練で最も怖いのは、**「学習は正常終了したが、モデルは前週より悪い」**ケースです。データの取込漏れ、特徴量計算のバグ、たまたま悪い乱数。原因はいろいろですが、共通するのは「スクリプトのexit codeは0」だということです。学習が成功したかどうかと、そのモデルをデプロイして良いかどうかは、完全に別の判定です。
そこで、学習と差し替えの間にデプロイゲートを挟みます。直近数週間のholdoutデータ(学習に使っていない期間)で新旧モデルを評価し、条件を満たしたときだけ昇格させます。
def should_deploy(new_metrics: dict, old_metrics: dict) -> tuple[bool, str]:
"""holdout 指標で新モデルの昇格可否を判定する"""
# 床: 絶対値でこれより悪いモデルは問答無用で却下
if new_metrics["logloss"] > 0.70:
return False, f"logloss {new_metrics['logloss']:.4f} が床 0.70 超過"
# 前週比: 一定以上の劣化は却下(多少の揺らぎは許容)
degradation = new_metrics["logloss"] - old_metrics["logloss"]
if degradation > 0.01:
return False, f"前週比 +{degradation:.4f} の劣化"
return True, "OK"
ポイントは床(絶対値の下限)と前週比の両方を見ることです。前週比だけだと、悪いモデルが一度デプロイされたあと「前週並みに悪い」モデルを通し続けてしまいます。逆に床だけだと、緩やかな劣化トレンドに気づけません。
ゲートで弾かれた場合は旧モデルをそのまま使い続け、通知だけ飛ばします。「自動で何もしない」が正しい挙動になるのがこの設計の良いところです。
柱3: 多重起動の防止 — flock で前回実行を弾く
学習時間は読めません。データが増えれば伸びますし、ハイパーパラメータ探索を入れていれば数時間単位で揺れます。cronの次の発火時刻までに前回の実行が終わっていないと、同じパイプラインが2つ並走して、同じファイルに書き込み合う事故が起きます。
bashなら flock で1行で防げます。
#!/bin/bash
# weekly_retrain.sh — flock で多重起動を防止するラッパー
LOCK_FILE="/var/lock/weekly_retrain.lock"
(
# ノンブロッキングでロック取得。取れなければ前回がまだ実行中
flock -n 200 || {
echo "$(date '+%F %T') 前回の再訓練がまだ実行中のためスキップ" >&2
exit 1
}
python -m retrain.pipeline "$@"
) 200>"$LOCK_FILE"
flock -n はロックが取れなければ即座に失敗します。これにより「前回が長引いているなら今回は黙ってスキップ」という安全側の挙動になります。ロックはプロセスが死ねばOSが自動で解放するので、ロックファイルの消し忘れによるデッドロックも起きません。PIDファイルを自前で管理する方式よりはるかに堅牢です。
柱4: 状態ファイル — どこまで進んだかを JSON に記録する
再訓練パイプラインは複数のphaseで構成されます。データ抽出 → 学習 → 評価 → デプロイ判定 → 差し替え、といった流れです。途中でクラッシュしたとき、どのphaseまで終わっていたかがわからないと、最初からやり直すしかなくなります。データ抽出に1時間かかるなら、その1時間が毎回無駄になります。
各phaseの完了時に状態をJSONに書き出しておけば、再実行時に完了済みphaseをスキップできます。
import json
from datetime import datetime, timezone
STATE_PATH = "state/retrain_state.json"
def mark_phase_done(phase: str) -> None:
try:
with open(STATE_PATH) as f:
state = json.load(f)
except FileNotFoundError:
state = {"phases": {}}
state["phases"][phase] = {
"status": "done",
"completed_at": datetime.now(timezone.utc).isoformat(),
}
atomic_save_json(state, STATE_PATH) # 柱1と同じ os.replace 方式で
def is_phase_done(phase: str) -> bool:
try:
with open(STATE_PATH) as f:
return json.load(f)["phases"].get(phase, {}).get("status") == "done"
except FileNotFoundError:
return False
タイムスタンプを入れておくのが地味に効きます。このファイルのmtimeや記録時刻は監視にも転用できるからです。「最後にデプロイphaseが完了したのが10日前」なら、パイプラインがどこかで静かに止まっている兆候です(この監視側の話は別記事で扱います)。
なお、新しい週の実行を始めるときは状態ファイルをリセットする(または週番号をキーに含める)のを忘れずに。先週の「done」を今週のdoneと誤認すると、古いデータで学習をスキップしてしまいます。
柱5: ロールバック手段を先に用意する
デプロイゲートをすり抜ける悪いモデルは、いつか必ず出てきます。holdoutでは良く見えたが本番の予測分布がおかしい、といったケースです。そのとき必要なのは「旧モデルに戻す手段」ですが、切り戻しは事故ってから作るものではありません。事故の最中に書いたスクリプトは大抵バグっています。
やることはシンプルで、デプロイ時に旧モデルを世代つきで退避しておくだけです。
- 差し替え前に
model.pkl→model.pkl.prev(あるいは日付つき)へコピー - 戻すときは退避ファイルを柱1の
atomic_save_modelと同じ方式で書き戻す - 退避は直近2〜3世代残す(1世代前も悪かった、はあり得る)
そして重要なのは、ロールバック手順を平時に一度実際に実行して試しておくことです。使ったことのない非常口は非常口ではありません。
まとめ
| 設計の柱 | 防げる事故 |
|---|---|
| アトミックな差し替え(os.replace) | 読み手が壊れたpickleを掴む |
| デプロイゲート(床 + 前週比) | 劣化したモデルの自動昇格 |
| flock による多重起動防止 | パイプラインの並走・書き込み競合 |
| 状態ファイル(phase記録) | クラッシュ後の全やり直し、静かな停止の見逃し |
| 事前に用意したロールバック | 事故の最中の手作業ミス |
どれも個別には小さな仕組みですが、揃って初めて「週末に人間が見ていなくても安心して回せる」パイプラインになります。学習スクリプトを書くこと自体より、この周辺の設計のほうが運用の寿命を決めます。
データベースがまだの方は、まず JvLink To Importer で学習データの土台を作るところから始めてみてください。
この記事をシェア