前置き:疑うべきは、モデルだけじゃなかった
前回(#28)、累計58件・全ハズレという結果を、原因別に分解して棚卸しした。札幌除外前の43件と、除外後の15件を分けて評価し、「条件はまだ変えない」という判断をした。
今回は、その判断の前提そのものを揺るがしかねない話から始めたい。
検証スクリプト自体に、バグがあった。
問題を掘り下げる:なぜ「着順」が空欄になるのか
9月26日・27日の新しいデータを検証している最中、詳細CSVの中に、奇妙な行がいくつか見つかった。
「matched=True(推奨馬と実際の出走馬が正しく紐づいている)」なのに、肝心の着順(着順_num)が空欄になっている行が、複数あった。
原因を掘り下げると、JRAの結果CSVの「着」列が、全角数字(1234567890)で記録されているケースがあることが分かった。
自分が急ごしらえで作った簡易検証スクリプトは、この全角数字を想定しておらず、pd.to_numeric()にそのまま渡していた。結果、全角の着順は数値変換に失敗し、静かに欠損値(NaN)になっていた。
そして、的中判定のロジックは 着順_num == 1 という単純な比較だ。もし本当は「1着(全角)」だった馬がこの欠損バグに引っかかっていたら、NaN == 1 は当然 False になる。
つまり理論上、実際は勝っていた馬が「ハズレ」として記録されていた可能性があった。
思考の転換:本番スクリプトは無事だった、という幸運
幸い、被害範囲は限定的だった。
本番の検証スクリプト(verify_recommendations.py)には、もともと全角→半角変換の処理が実装済みだった。8月8日・9日の初期データは、このスクリプトで検証していたため、そもそもバグの対象外だった。
問題があったのは、サンドボックス障害が続いていた期間に、結果CSVを直接パースできず、暫定的に組んだ「kaime_list(買い目リスト)のテキストファイルと結果CSVを突き合わせる」簡易スクリプトの方だった。この簡易版にだけ、全角数字の変換漏れがあった。
だが、「本番は無事だったはず」という予想だけで済ませるわけにはいかない。実際に全日程を洗い直すことにした。
実装から学んだ:全角正規化を入れて、全件を再検証する
修正は単純だ。verify_recommendations.pyに元からあった変換ロジックを、簡易スクリプトにも追加した。
def normalize_japanese_numerals(s):
japanese_digits = '1234567890'
half_width_digits = '1234567890'
trans_table = str.maketrans(japanese_digits, half_width_digits)
return str(s).translate(trans_table)
df["着順_raw"] = df[order_col].apply(normalize_japanese_numerals)
df["着順_num"] = pd.to_numeric(df["着順_raw"], errors="coerce")
この修正版で、バグの影響を受けた可能性がある全日程を、一件ずつ再検証した。
再検証対象:8/15, 8/16, 8/22, 8/23, 8/29, 8/30,
9/5, 9/13, 9/19, 9/20, 9/21(計11日程)
結果は——すべて、元の記録と一致。的中0件のまま、一件も変わらなかった。
念のため、8/8・8/9(本番スクリプト使用分、もともとバグの対象外)も含めて全期間を確認し、どこにも取りこぼしがないことを確かめた。
なぜこのプロセスが重要なのか
今回の件で、あらためて思い知ったことがある。
「モデルが正しく機能しているか」を疑う前に、「自分がそれを正しく測定できているか」を疑う必要がある。
これまでの記事で、EVの閾値や開催場の偏りなど、モデル側の設計を何度も見直してきた。だが、もし測定用のスクリプト自体にバグがあれば、どれだけモデルを正しく改善しても、その評価自体が誤ったものになる。
今回は幸い、本番の検証ロジックは最初から正しく、影響は自分が急ごしらえで作った補助スクリプトだけに留まっていた。だが、これはほとんど運が良かっただけとも言える。障害対応で急いで書いたコードほど、こうした基本的な考慮漏れが起きやすい。
累計成績の更新:65件、依然として的中0
バグの再検証と並行して、9月26日・27日の新しいデータも処理した。
9月26日(中山・阪神):4件推奨 的中0
9月27日(中山・阪神):3件推奨 的中0
札幌除外後(9/5以降)の累計は、15件から22件に増えた。
全期間累計:65件 的中0 回収率0%
札幌除外前(8/8〜8/30):43件 的中0
札幌除外後(9/5〜9/27) :22件 的中0
次のチェックポイント:札幌除外後の累計30件到達時(あと8件)
22件全ハズレの確率は、バックテストの的中率9.6%を前提にすると約11.0%。まだ「普通に起こりうる範囲」の中にある。前回設定したチェックポイント(札幌除外後30件)まで、条件は変えずに記録を続ける。
Data Scientistとしての振り返り
今回の教訓は、これまでの「データを分解して判断する」という話とは、少し種類が違う。
評価の土台そのものが信頼できるかどうかを、定期的に点検する必要がある。 モデルの精度を追いかけることに集中していると、その精度を測っている物差し自体が歪んでいないか、見落としがちになる。
正直に言うと、このバグに気づいたのは偶然だった。CSVの中身をたまたま直接確認していなければ、空欄になっている着順に気づかないまま、次のデータを処理していたかもしれない。
今後は、新しい検証スクリプトを書くたびに、「全角・半角」「表記ゆれ」のような基本的なデータクレンジングの項目を、チェックリスト化しておこうと思う。
開発者として感じたこと
正直、このバグを見つけたときは、少し血の気が引いた。もし実際に取りこぼした勝ち馬があったら、これまで公開してきた記事の数字——「58件全ハズレ」「0.29%の確率」——を、すべて訂正しなければならないところだった。
結果として何も変わらなかったのは、ほっとした反面、少し拍子抜けでもある。それでも、「疑わしきは検証する」という姿勢を崩さなかったこと自体は、間違っていなかったと思う。
技術ノート
バグの詳細
問題箇所:暫定検証スクリプト(kaime_list突き合わせ用)
原因 :JRA結果CSVの「着」列が全角数字の場合、
pd.to_numeric()が変換失敗しNaNを返す
影響範囲:サンドボックス障害期間中に作成した暫定スクリプトのみ
(本番のverify_recommendations.pyは元から対応済み)
再検証 :8/15, 8/16, 8/22, 8/23, 8/29, 8/30, 9/5, 9/13,
9/19, 9/20, 9/21の11日程、すべて結果不変を確認
実運用累計成績(2026年9月27日時点)
全期間累計:65件 的中0 回収率0%
札幌除外前(8/8〜8/30):43件 的中0
札幌除外後(9/5〜9/27) :22件 的中0(次のチェックポイントまであと8件)
統計的評価
札幌除外後22件全ハズレの確率(p=9.6%想定):約11.0%(正常範囲内)
次回の予定
札幌除外後の累計が30件に到達するまで、条件は変更せず記録を続ける。次回は、その到達時点での棚卸し、または新しい週次データの報告になる予定。
【競馬AI開発記】次回予定:
- Day 30:札幌除外後累計30件到達時点での棚卸し報告
- Day 31:次週の実運用データ報告