JvLink To Importer

競馬データの欠損値・異常値ハンドリング

競馬データの欠損値・異常値ハンドリング

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 上がるのは普通にあります。

この記事をシェア

Post