学習プロセス:レースを単位に、順位・勝者・上位価値を同時最適化しつつ、過学習を監視して学習率も自動調整する “実戦仕様” のランキング学習ループ
今回は、関数 train_racewise_regression_model
の作成です。この関数 は、馬ごとの時系列特徴とレース内の相対順位を同時に学習するランキング回帰モデルの学習ループです。レース単位の集合入力(S頭)×各馬の時系列(Tステップ)からなるデータを使って「順位(pairwise)」「勝者(top-1)」「上位K(nDCG@3)」 を同時に最適化しながらモデルを学習する関数です。
モデルは前提として HybridRaceModelWithEmbedding
(数値系列+カテゴリ埋め込み→LSTM→SetTransformer)を使います。
def train_racewise_regression_model(
input_dim,
X_sequences,
X_cat_dict,
y_ranks,
race_ids,
history_lengths,
sequence_length,
batch_size,
epochs,
lstm_hidden,
collate_fn,
class_weights,
horse_ids,
val_horse_ids=None,
model=None,
val_sequences=None,
val_cat_dict=None,
val_ranks=None,
val_race_ids=None,
val_history_lengths=None,
device=None
):
if device is None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_dataset = HorseDataset(
X_sequences,
X_cat_dict,
race_ids,
horse_ids,
y=y_ranks,
history_lengths=history_lengths
)
train_dataloader = DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True,
collate_fn=collate_fn,
pin_memory=False,
num_workers=0
)
val_dataloader = None
if (val_sequences is not None) and (val_cat_dict is not None) and (val_race_ids is not None) and (val_ranks is not None):
val_dataset = HorseDataset(
val_sequences,
val_cat_dict,
val_race_ids,
val_horse_ids if val_horse_ids is not None else np.zeros_like(val_race_ids),
y=val_ranks,
history_lengths=val_history_lengths
)
val_dataloader = DataLoader(
val_dataset,
batch_size=batch_size,
shuffle=False,
collate_fn=collate_fn,
pin_memory=False,
num_workers=0
)
if model is None:
model = HybridRaceModelWithEmbedding(
input_dim=input_dim,
sequence_length=sequence_length,
lstm_hidden=lstm_hidden,
embedding_input_dims=embedding_input_dims,
embedding_output_dims=embedding_output_dims
).to(device)
else:
model = model.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=5e-4, weight_decay=1e-5)
scheduler = TorchReduceLROnPlateau(optimizer, mode='max', factor=0.5, patience=1, threshold=1e-4)
use_cuda = torch.cuda.is_available()
amp_dtype = torch.bfloat16 if use_cuda else torch.float32
autocast_ctx = (torch.amp.autocast(device_type="cuda", dtype=amp_dtype)
if use_cuda else contextlib.nullcontext())
early_stopper = EarlyStopping(patience=2, mode="max")
lambda_top1 = 8.0
lambda_ndcg = 3.0
for epoch in range(epochs):
model.train()
epoch_start = time.time()
total_loss = 0.0
top1_prob_vals, top1_acc_vals, ndcg_vals = [], [], []
for X_pack, y_pack, hist_pack, cat_pack, ids_pack, mask_pack in train_dataloader:
X_pack = X_pack.to(device, non_blocking=True)
y_pack = y_pack.to(device, non_blocking=True)
mask_pack = mask_pack.to(device, non_blocking=True)
if hist_pack is not None:
hist_pack = hist_pack.to(device, non_blocking=True)
cat_pack = {k: v.to(device, non_blocking=True) for k, v in cat_pack.items()}
optimizer.zero_grad(set_to_none=True)
with autocast_ctx:
preds = model(X_pack, cat_pack, mask=mask_pack) # [B,S]
valid_mask = ~mask_pack
loss_pair_list, loss_top1_list, loss_ndcg3_list = [], [], []
for r_valid, p_r, y_r, h_r in zip(
valid_mask, preds, y_pack, hist_pack if hist_pack is not None else [None]*preds.size(0)
):
if r_valid.sum() < 2:
continue
p_r = p_r[r_valid]
y_r = y_r[r_valid]
w_r = normalize_hist_race(h_r[r_valid]) if (h_r is not None and h_r.dim() >= 1) else None
y_r, _ = normalize_targets_for_ranking(y_r)
lp = weighted_pairwise_ranking_loss(p_r, y_r, weights=w_r)
lt1 = soft_top1_loss(p_r, y_r, class_weights=class_weights)
lndcg3 = hard_ndcg3_loss_torch(p_r, y_r)
loss_pair_list.append(lp)
loss_top1_list.append(lt1)
loss_ndcg3_list.append(lndcg3)
winners = (y_r == y_r.min()).nonzero(as_tuple=False).squeeze(-1)
probs = torch.softmax(p_r, dim=0)
top1_prob_vals.append(probs[winners].sum().item())
top1_acc_vals.append((torch.argmax(p_r).unsqueeze(0) == winners).any().float().item())
ndcg_vals.append(1.0 - lndcg3.item())
if not loss_pair_list:
continue
loss = (
torch.stack(loss_pair_list).mean()
+ lambda_top1 * torch.stack(loss_top1_list).mean()
+ lambda_ndcg * torch.stack(loss_ndcg3_list).mean()
)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += float(loss.detach().cpu())
# エポックメトリクス(大きいほど良い)
mean_top1_prob = float(np.mean(top1_prob_vals)) if top1_prob_vals else 0.0
mean_top1_acc = float(np.mean(top1_acc_vals)) if top1_acc_vals else 0.0
mean_ndcg3 = float(np.mean(ndcg_vals)) if ndcg_vals else 0.0
print(f"Epoch {epoch+1}: total={total_loss:.4f} | "
f"top1_prob={mean_top1_prob:.3f} | top1_acc={mean_top1_acc:.3f} | "
f"ndcg3={mean_ndcg3:.3f} | ⏱️ {time.time() - epoch_start:.2f}s")
val_metric = mean_ndcg3
if val_dataloader is not None:
val_metric = run_validation(model, val_dataloader, device, class_weights, autocast_ctx)
scheduler.step(val_metric)
if early_stopper(val_metric):
print(f"⛔ Early stopping: 改善停止(best={early_stopper.best_score:.4f})")
break
for i, param_group in enumerate(optimizer.param_groups):
print(f"[Epoch {epoch}] LR[{i}]: {param_group['lr']:.6f}")
return model
主な引数(I/O と前提形状)
input_dim
: 数値特徴のチャネル数(埋め込み以外の D_num)。X_sequences
: 数値系列テンソル。前処理で作った学習入力。- collate前の1サンプルは概念的に [T, D](D は「スケール済み数値 + 非スケールTAIL」)。
DataLoader + collate_fn
後は [R, S, T, D](Rバッチ内レース数 / Sレース内頭数)。
X_cat_dict
: カテゴリ系列(埋め込み用 ID)の辞書。列名→配列(各要素 [T]、collate後 [R,S,T])。y_ranks
: ターゲット(着順など)。各要素はレース内序数 or one-hot。collate後は [R, S](パディングは -1)。race_ids
: サンプルごとのレースID(collate後 [R, S] に整理される)。history_lengths
: 補助スカラー(履歴長など)。各要素 [1] → collate後 [R, S, 1]。sequence_length
: LSTM に入る T(collateでパディング/切詰め)。batch_size, epochs, lstm_hidden
: 学習設定。collate_fn
:grouped_race_collate_fn
を想定。レース単位にまとめ、[R,S,T,D] と パディングマスクmask_pack([R,S])
を作る。class_weights
:soft_top1_loss
用のクラス重み(頻度逆数など)。horse_ids
: 馬ID(レース内でのマスク整合に使うメタ)。- (任意)
val_*
: 検証用の同形式データ。 model
: 既存モデルを再学習する場合に渡す。未指定なら内部でHybridRaceModelWithEmbedding
を新規作成。device
: 明示しない場合は CUDA 利用可なら GPU を自動選択。
戻り値:学習済み model
(重みは関数内で更新)。
セットアップ(Dataset / DataLoader)
train_dataset = HorseDataset(...); train_dataloader = DataLoader(..., collate_fn=collate_fn)
HorseDataset
は各アイテムを{ "X_num":[T,D], "y":[], "hist":[1], "race_id", "horse_id", 各カテゴリ列:[T] }
で返す。grouped_race_collate_fn
が 同一レースで束ね、Tを揃え、Sもレース間でパディングしてX_pack: [R,S,T,D]
,y_pack: [R,S]
,hist_pack: [R,S,1]
,cat_pack[col]: [R,S,T]
,ids_pack: [R,S]
,mask_pack: [R,S](-1位置がTrue)
を返す(マスクはパディング無視に必須)。
モデルと最適化器
model = HybridRaceModelWithEmbedding(...).to(device)
optimizer = Adam(lr=5e-4, weight_decay=1e-5)
scheduler = ReduceLROnPlateau(mode='max', factor=0.5, patience=1, threshold=1e-4)
- AMP(混合精度):CUDA 環境では
bfloat16
でautocast
。 - EarlyStopping:
patience=2
、モードはmax
(=指標が大きいほど良い)。 - 学習率調整:検証指標(既定では nDCG@3 の検証値)に改善がないと LR を 1/2。
ループ内の学習ステップ(1エポック)
- forward & マスク適用
preds = model(X_pack, cat_pack, mask=mask_pack) # [R,S]
valid_mask = ~mask_pack # パディング以外をTrue
- モデル内部:
- 数値
[R,S,T,D]
と 各カテゴリ[R,S,T]
を 最終次元で連結 →[R*S, T, D+ΣE]
を LSTM。 - LSTM 最終隠れ状態
[R*S, H]
を[R,S,H]
に戻し、SetTransformer で レース内関係を学習。 - 出力は 各馬のスコア
[R,S]
。
- 数値
- レースごとに損失を集計
for r_valid, p_r, y_r, h_r in zip(valid_mask, preds, y_pack, hist_pack or [None]*R):
if r_valid.sum() < 2: continue
p_r = p_r[r_valid] # [S_r]
y_r = y_r[r_valid] # [S_r]
w_r = normalize_hist_race(h_r[r_valid]) if h_r is not None else None
y_r, _ = normalize_targets_for_ranking(y_r) # one-hot or 1..K に正規化
lp = weighted_pairwise_ranking_loss(p_r, y_r, weights=w_r)
lt1 = soft_top1_loss(p_r, y_r, class_weights=class_weights)
lndcg3 = hard_ndcg3_loss_torch(p_r, y_r) # = 1 - nDCG@3
- 3損失を各レースの有効頭で計算:
lp
(ペアワイズ):順位整合性を最大化(Bradley–Terry 的)。lt1
(Top-1):勝者の尤度最大化(Plackett–Luce Top-1 近似)。lndcg3
(nDCG@3):上位3頭重視の評価を直接最適化(hard top-k)。
- 重み
w_r
:normalize_hist_race
で [0,1] に正規化した履歴スカラーをペアワイズ重みに使用(履歴が豊富な馬/安定な履歴をやや重視等の効果)。
- 損失合成 → 逆伝播
loss = mean(lp_list) + λ_top1*mean(lt1_list) + λ_ndcg*mean(lndcg3_list)
λ_top1 = 8.0, λ_ndcg = 3.0
loss.backward()
clip_grad_norm_(model.parameters(), 1.0) # 爆発防止
optimizer.step()
- クリッピングで勾配爆発を抑止。
- λ は 勝者当てを強め、次に 上位3 を重視、順位整合はベースとして効かせる設計。
- メトリクスのログ
top1_prob
: 勝者集合(同着含む)に割り当てたsoftmax(p_r)
の合計確率。top1_acc
:argmax(p_r)
が勝者集合に含まれるか。ndcg3
:1 - lndcg3
。- エポック末に平均を表示。
検証・スケジューラ・早期終了
val_metric = run_validation(model, val_dataloader, device, class_weights, autocast_ctx) # nDCG@3 の平均
scheduler.step(val_metric) # 改善しなければ LR 半減
if early_stopper(val_metric): break
run_validation
は パディング無視・one-hot/順位正規化を行い、nDCG@3 と Top-1 を集計。- ReduceLROnPlateau が微改善停滞に即応、EarlyStopping が過学習を抑制。
代表的な変数の意味
X_pack
: 数値特徴([R,S,T,D])cat_pack[col]
: カテゴリID([R,S,T])y_pack
: ターゲット([R,S]、-1はパディング)mask_pack
: パディングマスク([R,S]、True=無効)preds
: モデル出力スコア([R,S])valid_mask
:~mask_pack
(True=有効頭)λ_top1=8.0
,λ_ndcg=3.0
: 損失の強度比amp_dtype
: CUDA ならbfloat16
(混合精度)scheduler
: 検証 nDCG@3 停滞で LR ↓early_stopper
: 検証 nDCG@3 未改善がpatience
回続いたら停止
学習の統計学的な見立て
- 序数回帰 × ペア比較:Bradley–Terry / Luce モデルに対応するペアワイズ尤度の最大化で、順序全体の整合性を高める。
- Top-1 尤度最大化:勝者の対数尤度を直接押し上げるので、“一着を当てる” KPI に強い。
- nDCG 最適化:位置割引付き効用(上位重視)を直接最適化し、実運用の価値関数に整合。
- 重み付き学習(履歴):重要度サンプリング的に情報量の高い事例の寄与を調整。
- EarlyStopping + LR Scheduling:汎化誤差を監視しつつ動的にバイアス–バリアンスのバランスを取る実務的正則化。
チューニングのヒント
λ_top1
とλ_ndcg
:- 勝者的中を上げたい →
λ_top1↑
- 複勝・三連系など上位重視 →
λ_ndcg↑
- 勝者的中を上げたい →
sequence_length
とlstm_hidden
:履歴依存が強いなら T↑ / 隠れ次元↑。class_weights
:人気薄/希少ランクの当たりを重んじるなら適切に付与。ReduceLROnPlateau(patience)
とEarlyStopping(patience)
は小さすぎると未収束、大きすぎると過学習に注意。
コメント