学習する(12:学習ループ、EarlyStopping、AMPなど)

学習

学習プロセス:レースを単位に、順位・勝者・上位価値を同時最適化しつつ、過学習を監視して学習率も自動調整する “実戦仕様” のランキング学習ループ

今回は、関数 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 環境では bfloat16autocast
  • EarlyStoppingpatience=2、モードは max(=指標が大きいほど良い)。
  • 学習率調整:検証指標(既定では nDCG@3 の検証値)に改善がないと LR を 1/2。

ループ内の学習ステップ(1エポック)

  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]
  1. レースごとに損失を集計
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_rnormalize_hist_race で [0,1] に正規化した履歴スカラーをペアワイズ重みに使用(履歴が豊富な馬/安定な履歴をやや重視等の効果)。
  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 を重視、順位整合はベースとして効かせる設計。
  1. メトリクスのログ
  • 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@3Top-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_lengthlstm_hidden:履歴依存が強いなら T↑ / 隠れ次元↑
  • class_weights人気薄/希少ランクの当たりを重んじるなら適切に付与。
  • ReduceLROnPlateau(patience)EarlyStopping(patience)小さすぎると未収束大きすぎると過学習に注意。

コメント

タイトルとURLをコピーしました