JvLink To Importer

「データがない」を負の情報にしない特徴量設計

「データがない」を負の情報にしない特徴量設計

競馬の予測モデルでは「直近5走の平均着順」「同コースでの勝率」のような過去成績の集計が特徴量の主力になります。ところが初出走馬・初コース・初距離・転厩直後の馬には、その集計の元になる過去データがありません。ここで何も考えずに fillna(0) をすると、モデルに**「成績ゼロ = 最弱」という嘘**を教えることになります。この記事では、「データがないこと」を歪んだ負の情報に変換しないための特徴量設計の原則を4つ紹介します。


安易な0埋めが教える「嘘」

新馬戦に出る馬の「過去の勝率」を0で埋めるとどうなるか。モデルから見れば、その馬は「何度も走って一度も勝てなかった馬」と区別がつきません。実際には一度も走っていないだけなのに、最弱クラスの実績馬と同じ顔をしてモデルに入力されるわけです。

さらに厄介なのは、0埋めの嘘は方向すら一貫しないことです。

  • 勝率を0埋め → 「最弱」という嘘(実績ゼロが最低評価になる)
  • 平均着順を0埋め → 「最強」という嘘(着順0は1着より上の値になる)

集計の向きによって嘘の方向が反転するため、複数の特徴量を0埋めすると、1頭の馬が「勝率は最弱、平均着順は最強」という矛盾した姿でモデルに見えることさえあります。

この歪みは一過性のノイズではなく系統バイアスです。初出走馬・初コースの馬・転厩直後の馬といった特定の集団が、毎回同じ方向に間違って評価され続けます。新馬戦や初条件の馬の評価が常に低く(または不自然に高く)出るモデルになっていたら、まずここを疑ってください。


原則1: 欠損は欠損のまま渡す

LightGBMやXGBoostなどの勾配ブースティング系モデルは、欠損値(NaN)をネイティブに分岐処理できます。各ノードで「欠損の行は左右どちらに流すか」を学習データから決めるため、「データがない」というケースをモデル自身が最適に扱ってくれます。

つまり最初の原則はシンプルで、埋めないことです。

import pandas as pd

# 馬ごとに過去走を集計する(その馬の「今回より前」だけを使う)
df = df.sort_values(['ketto_toroku_bango', 'kaisai_nengappi'])
g = df.groupby('ketto_toroku_bango')

df['n_races'] = g.cumcount()  # それまでの出走回数(初出走なら0)
df['avg_chakujun'] = (
g['chakujun'].transform(lambda s: s.shift(1).expanding().mean())
)
df['win_rate'] = (
g['is_win'].transform(lambda s: s.shift(1).expanding().mean())
)

# やってはいけない: df[['avg_chakujun', 'win_rate']].fillna(0)
# → 初出走馬がNaNのまま残るのが正しい状態

shift(1) でその馬の「今回のレースより前」だけを集計対象にしているため、初出走の行は自然にNaNになります。このNaNは消すべき汚れではなく「初出走である」という事実そのものです。そのままモデルに渡します。

線形モデルやニューラルネットワークなどNaNを受け付けないモデルを使う場合だけ、後述の原則3(文脈つきprior)で埋めます。


原則2: has_dataフラグを分離する

欠損をそのまま渡すだけでは、まだ情報が足りません。「データがないこと」自体が予測に使える情報なので、独立した説明変数として明示的に切り出します

df['has_history'] = (df['n_races'] > 0).astype(int)
df['has_course_history'] = df['course_n_races'].gt(0).astype(int)
df['has_distance_history'] = df['distance_n_races'].gt(0).astype(int)
df['is_trainer_changed'] = (
df['chokyoshi_code'] != g['chokyoshi_code'].shift(1)
).astype(int)

ポイントは、集計値の列とフラグの列を混ぜないことです。「初コースかどうか」と「コースでの成績がどうだったか」は別の質問であり、別の列にしておけばモデルはそれぞれを独立に使えます。フラグを分離しておくと、後で「初コースの馬はどう予測されているか」を切り出して検証するときにも役立ちます。

なお、こうしたno-dataフラグは作れば必ず効くというものではありません。データセットによっては精度指標を改善する一方で別の評価軸を悪化させることもあるため、追加したら必ずベースラインと比較検証するのがセットです。


原則3: 埋めるなら文脈つきpriorで埋める

NaNを受け付けないモデルを使う場合や、欠損率が高すぎて分岐学習が安定しない場合は埋めることになりますが、埋める値は0でも全体平均でもなく、**その馬が置かれた文脈での事前値(prior)**を使います。

「初コースの馬のコース勝率」を埋めるなら、全馬の全レースの平均勝率ではなく、「同じトラック種別・同じクラスのレースに出る馬の平均勝率」のほうが意味のある初期値です。

# 学習期間のデータだけからpriorを作る(検証期間を混ぜない)
prior_table = (
train_df.groupby(['surface', 'class_code'])['is_win']
.mean()
.rename('prior_win_rate')
)

def fill_with_prior(df: pd.DataFrame) -> pd.DataFrame:
df = df.merge(prior_table, on=['surface', 'class_code'], how='left')
df['course_win_rate_filled'] = (
df['course_win_rate'].fillna(df['prior_win_rate'])
)
return df

注意点が2つあります。第一に、priorは学習期間のデータだけから計算すること。全期間で計算すると検証データの情報が学習側に漏れます。第二に、priorで埋めた場合も原則2のhas_dataフラグは残すこと。埋めた値と実測値をモデルが区別できなくなるのを防ぎます。


原則4: 分母(n_races)を一緒に渡す

「勝率100%」という値だけ見ても、それが3戦3勝なのか30戦30勝なのか区別がつきません。前者はまだ何もわかっていないに等しく、後者は相当な根拠があります。集計値を特徴量にするときは、集計の分母を必ず同じ行に並べて渡します

# 集計値と分母をペアで持たせる
feature_cols = [
'win_rate',          'n_races',           # 通算
'course_win_rate',   'course_n_races',    # 同コース
'distance_win_rate', 'distance_n_races',  # 同距離
]

ツリー系モデルは「n_racesが小さいときはwin_rateを信用しない」という分岐を自力で学習できます。分母なしで勝率だけ渡すと、この判断材料を最初から奪うことになります。ベイズ的に平滑化した勝率((wins + k * prior) / (n + k) のような形)を作る方法もありますが、その場合も生のnを一緒に渡しておいて損はありません。


検証: 初出走サブセットだけで誤差を見る習慣

設計がうまくいったかは、全体の精度指標だけでは判断できません。初出走馬は全体のごく一部なので、そこがどれだけ歪んでいても全体のスコアにはほとんど現れないからです。「データがない」集団だけを切り出して誤差を見る習慣をつけます。

from sklearn.metrics import log_loss

mask = valid_df['n_races'] == 0  # 初出走サブセット

print('初出走馬 logloss:', log_loss(y_valid[mask], pred[mask]))
print('経験馬   logloss:', log_loss(y_valid[~mask], pred[~mask]))
print('初出走馬の平均予測勝率:', pred[mask].mean())
print('初出走馬の実際の勝率  :', y_valid[mask].mean())

平均予測勝率と実際の勝率の乖離を見れば、初出走馬を系統的に過小評価(または過大評価)していないかが一目でわかります。同じ切り出しを「初コース」「初距離」「転厩直後」でも行えば、どの集団に歪みが残っているかを特定できます。0埋めをやめてフラグを分離するだけで、この乖離が大きく縮むケースは珍しくありません。


まとめ

  • 安易な0埋めは「成績ゼロ = 最弱」(集計によっては逆に「最強」)という嘘をモデルに教える。被害は初出走・初条件の馬に系統バイアスとして集中する
  • 原則1: 欠損は欠損のまま渡す。LightGBM/XGBoostはNaNをネイティブに分岐できる
  • 原則2: 「データがないこと」自体をhas_dataフラグとして分離する。集計値とフラグを混ぜない
  • 原則3: 埋める必要があるなら、全体平均ではなく同条件・同クラスの文脈つきpriorで埋める(priorは学習期間のみから計算)
  • 原則4: 集計値には分母(n_races)を添える。「3戦3勝」と「30戦30勝」をモデルが区別できるようにする
  • 検証は全体スコアではなく、初出走・初条件のサブセットを切り出して誤差と予測の偏りを見る

「データがない」は欠陥ではなく、それ自体がひとつの情報です。情報として正しく渡すか、嘘に変換して渡すかは、特徴量設計のこの数行で決まります。

この記事をシェア

Post