競馬データの欠損値・異常値ハンドリング
競馬データの欠損値・異常値ハンドリング
JV-Link で取り込んだ競馬データは、現実の現象を写したものなので「綺麗な数値が全カラム埋まっている」ことは滅多にありません。取消馬・スクラッチ・速報の途中データ・記録漏れ・極端な外れ値など、ETL 段階で対処しないと予測モデルが学習で大コケします。この記事では現場で効くパターンを紹介します。
どこに欠損が出やすいか
JV-Link データで欠損 (NULL / 0 / 空文字) が出やすい代表箇所:
- finish_position: 取消・除外・競走中止の馬は NULL や 0
- last_3f: 中止 / 大差負けなど計測不能の場合 NULL
- win_odds: 出走前の取消で値なし、または直前まで未確定
- horse_weight / weight_diff: 計量前の段階や記録漏れ
- jockey_id / trainer_id: 速報ステージで未確定の場合あり
「NULL だから 0 で埋める」は最悪の対処です。意味の違う欠損を一緒くたにすると学習が歪みます。
ステップ1: 欠損の意味を分類する
まずは「なぜ NULL なのか」をカテゴリ分け。SQL で診断するのが手早い。
SELECT
COUNT(*) AS total,
COUNT(finish_position) AS has_finish,
COUNT(*) FILTER (WHERE status = '取消') AS cancelled,
COUNT(*) FILTER (WHERE status = '除外') AS excluded,
COUNT(*) FILTER (WHERE status = '中止') AS stopped,
COUNT(*) FILTER (WHERE finish_position IS NULL AND status IS NULL) AS unknown_null
FROM race_results;
unknown_null がそれなりにあるなら ETL のバグ or 上流データ品質の問題。これを潰さない限り後段は信頼できません。
ステップ2: モデル学習用には除外/分離
着順予測モデルなら、出走しなかった馬は学習・推論ともに除外。判定は status カラムを基準にします。
# 出走しなかった行はモデル入力から除外
df_train = df[df['status'].isin(['完走', '失格', None])].copy()
# 学習時に finish_position が NULL なら NaN として LightGBM に渡せばよい
# ただし「中止」のような出走したが結果が無い行はノイズなので除外
df_train = df_train.dropna(subset=['finish_position'])
LightGBM などツリー系は NaN をネイティブで扱えるので、無理に埋めずに NaN のまま渡すのが基本です。
ステップ3: 欠損フラグ列を追加する
「欠損していること自体」が情報になる場合があります。例えば「過去走データが NULL」の馬は新馬戦ということなので、そのフラグ自体を特徴量にできます。
df['is_first_run'] = df['avg_finish_last5'].isna().astype(int)
df['has_jockey_history'] = df['jockey_win_rate'].notna().astype(int)
異常値: 検出と扱い
異常値(外れ値)は競馬データだと特に生じやすい:
- 走破タイム: 大差負けでスマートに記録されず極端な値
- horse_weight: 入力ミスで桁が違う (例: 4500kg)
- win_odds: 単勝オッズ 9999.9 (上限値) で確定
- payout_amount: 高配当が右に長い裾を持つ
IQR で軽くスクリーニング
def iqr_outliers(s: pd.Series, factor: float = 1.5):
q1, q3 = s.quantile(0.25), s.quantile(0.75)
iqr = q3 - q1
lower = q1 - factor * iqr
upper = q3 + factor * iqr
return (s < lower) | (s > upper)
mask = iqr_outliers(df['horse_weight'])
print(df.loc[mask, ['race_id', 'horse_id', 'horse_weight']])
ただし「外れ値だから消す」は無闇にやると本物の高配当・記録的タイムまで切ってしまうので、まず目視確認してから処理を決めましょう。
上下限クリップで安全側に倒す
df['win_odds_clipped'] = df['win_odds'].clip(lower=1.0, upper=999.0)
df['horse_weight_clipped'] = df['horse_weight'].clip(lower=300, upper=600)
外れ値を消すより、現実的範囲にクリップする方が安全な場面が多いです。
ステップ4: パイプライン化
毎回手で前処理するのは現実的でないので、ETL の中で前処理を完結させます。
def preprocess(df: pd.DataFrame) -> pd.DataFrame:
# 1. 出走しなかった行を除外
df = df[df['status'] != '取消'].copy()
# 2. 異常値クリップ
df['win_odds'] = df['win_odds'].clip(1.0, 999.0)
df['horse_weight'] = df['horse_weight'].clip(300, 600)
# 3. 欠損フラグ追加
df['is_first_run'] = df['avg_finish_last5'].isna().astype(int)
return df
学習・推論で同じ関数を通すことで、検証時と本番時の挙動の差を防げます。
まとめ
- 欠損は「意味で分類」してから埋める/除外/フラグ化を選ぶ
- ツリー系モデルは NaN をそのまま渡せる
- 異常値はまず目視、その後クリップが安全
- 前処理はパイプライン化して学習と推論で同じ関数を通す
雑に 0 埋めしたまま LightGBM に放り込むより、ここを丁寧に作るだけで AUC が 0.01〜0.02 上がるのは普通にあります。
この記事をシェア