Files
2026-07-13 13:26:28 +08:00

829 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared tabular deep learning pipeline for Ch12 case study templates.
Provides:
- TabMModel: Rank-1 adapter MLP ensemble (Gorishniy et al., ICLR 2025)
- run_tabm_cv(): Walk-forward CV with epoch-checkpoint IC evaluation
Usage:
from case_studies.utils.tabular_dl import run_tabm_cv
from utils.modeling import load_configs
tabdl_configs = load_configs("etfs", "fwd_ret_21d", "tabular_dl")
result = run_tabm_cv(dataset_pd, splits, configs=tabdl_configs,
n_features=44, feature_names=..., label_col=...)
"""
from __future__ import annotations
import gc
import time
import warnings
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
import polars as pl
import torch
import torch.nn as nn
from ml4t.diagnostic.metrics import cross_sectional_ic
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler
from case_studies.utils.registry import compute_fold_metrics_from_predictions
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
from utils.modeling import RANDOM_SEED, seed_everything
# ---------------------------------------------------------------------------
# TabM Model
# ---------------------------------------------------------------------------
class TabMModel(nn.Module):
"""Rank-1 adapter MLP ensemble for tabular data.
Shared backbone + M rank-1 scaling vectors = efficient deep ensemble.
From Gorishniy et al. (ICLR 2025).
"""
def __init__(
self,
n_features: int,
hidden_dim: int = 64,
n_members: int = 8,
dropout: float = 0.1,
):
super().__init__()
self.n_members = n_members
# Shared backbone
self.backbone = nn.Sequential(
nn.Linear(n_features, hidden_dim),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Dropout(dropout),
)
# Per-member rank-1 adapters (scaling vectors for last hidden layer)
self.adapters = nn.Parameter(torch.randn(n_members, hidden_dim) * 0.1)
# Per-member output heads
self.heads = nn.ModuleList([nn.Linear(hidden_dim, 1) for _ in range(n_members)])
def forward(self, x: torch.Tensor) -> torch.Tensor:
h = self.backbone(x) # (batch, hidden)
outputs = []
for i in range(self.n_members):
h_adapted = h * self.adapters[i].unsqueeze(0) # rank-1 scaling
outputs.append(self.heads[i](h_adapted))
return torch.stack(outputs, dim=0).mean(dim=0).squeeze(-1) # (batch,)
# ---------------------------------------------------------------------------
# Prediction Helpers
# ---------------------------------------------------------------------------
def _predict_in_chunks(
model: nn.Module,
X: np.ndarray,
device: torch.device,
chunk_size: int = 32768,
) -> np.ndarray:
"""Predict on large arrays in chunks to avoid GPU OOM."""
model.eval()
preds = []
with torch.no_grad():
for start in range(0, len(X), chunk_size):
batch = torch.FloatTensor(X[start : start + chunk_size]).to(device)
preds.append(model(batch).cpu().numpy())
return np.concatenate(preds)
def _run_tabpfn_fold(
X_train: np.ndarray,
y_train: np.ndarray,
X_val: np.ndarray,
max_samples: int,
n_ensemble: int,
) -> np.ndarray:
"""Run TabPFN on a single fold. Returns predictions or raises."""
from tabpfn import TabPFNRegressor
if len(X_train) > max_samples:
idx = np.random.choice(len(X_train), max_samples, replace=False)
X_train = X_train[idx]
y_train = y_train[idx]
model = TabPFNRegressor(n_estimators=n_ensemble)
model.fit(X_train, y_train)
return model.predict(X_val)
# ---------------------------------------------------------------------------
# Training Loop
# ---------------------------------------------------------------------------
def _train_tabm_fold(
model: nn.Module,
X_train: np.ndarray,
y_train: np.ndarray,
X_val: np.ndarray,
y_val: np.ndarray,
val_dates: np.ndarray,
val_entities: np.ndarray | None,
n_epochs: int,
batch_size: int,
checkpoint_interval: int,
device: torch.device,
) -> tuple[dict[int, float], dict[int, np.ndarray], dict[int, float]]:
"""Train TabM on one fold, storing predictions at ALL checkpoints.
Trains to completion (no early stopping). Stores predictions at every
checkpoint so the caller can select the best epoch after all folds finish.
Returns (checkpoint_ics, checkpoint_predictions, epoch_losses).
"""
model = model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
criterion = nn.MSELoss()
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=n_epochs)
n_train = len(X_train)
checkpoint_ics: dict[int, float] = {}
checkpoint_preds: dict[int, np.ndarray] = {}
epoch_losses: dict[int, float] = {}
for epoch in range(1, n_epochs + 1):
model.train()
epoch_loss = 0.0
n_batches = 0
indices = np.random.permutation(n_train)
for n_batches, start in enumerate(range(0, n_train, batch_size), 1):
batch_idx = indices[start : start + batch_size]
X_batch = torch.FloatTensor(X_train[batch_idx]).to(device)
y_batch = torch.FloatTensor(y_train[batch_idx]).to(device)
pred = model(X_batch)
loss = criterion(pred, y_batch)
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
epoch_loss += loss.item()
scheduler.step()
avg_loss = epoch_loss / max(n_batches, 1)
epoch_losses[epoch] = avg_loss
# Evaluate and store predictions at checkpoint epochs
if epoch % checkpoint_interval == 0 or epoch == n_epochs:
val_preds = _predict_in_chunks(model, X_val, device)
ic_frame = pl.DataFrame(
{
"date": val_dates,
"symbol": val_entities,
"y_true": y_val,
"y_pred": val_preds,
}
)
ic = cross_sectional_ic(
ic_frame,
ic_frame,
pred_col="y_pred",
ret_col="y_true",
date_col="date",
entity_col="symbol",
min_obs=5,
)["ic_mean"]
checkpoint_ics[epoch] = ic
checkpoint_preds[epoch] = val_preds.copy()
print(
f" epoch {epoch:3d}/{n_epochs}: loss={avg_loss:.6f}, IC={ic:+.4f}",
flush=True,
)
return checkpoint_ics, checkpoint_preds, epoch_losses
# ---------------------------------------------------------------------------
# Incremental Save/Load Helpers
# ---------------------------------------------------------------------------
from case_studies.utils.registry.store import flush_fold_predictions
def _load_incremental_preds(incr_dir: Path) -> pl.DataFrame:
"""Reassemble all predictions from incremental fold saves."""
parquet_files = sorted(incr_dir.glob("*.parquet"))
if not parquet_files:
return pl.DataFrame()
return pl.concat([pl.read_parquet(f) for f in parquet_files])
def _load_incremental_preds_for_config(incr_dir: Path, config_name: str) -> pl.DataFrame:
"""Reassemble one config's predictions from its per-fold incremental saves."""
parquet_files = sorted(incr_dir.glob(f"{config_name}_fold*.parquet"))
if not parquet_files:
return pl.DataFrame()
return pl.concat([pl.read_parquet(f) for f in parquet_files])
# ---------------------------------------------------------------------------
# Registry Integration
# ---------------------------------------------------------------------------
def _register_tabm_config(
*,
case_study: str,
label: str,
config_name: str,
n_epochs: int | None,
best_epoch: int,
n_folds: int,
ic_mean: float,
predictions,
notebook: str | None = None,
learning_curves=None,
started_at: str | None = None,
elapsed_s: float | None = None,
prediction_split: str = "validation",
) -> str:
"""Register a single tabm config — thin delegate to register_epoch_checkpoint."""
from case_studies.utils.registry import register_epoch_checkpoint
return register_epoch_checkpoint(
case_study,
family="tabular_dl",
library="tabm",
config_name=config_name,
label=label,
n_folds=n_folds,
n_epochs=n_epochs,
best_epoch=best_epoch,
ic_mean=ic_mean,
predictions=predictions,
learning_curves=learning_curves,
entry_point=notebook,
started_at=started_at,
elapsed_s=elapsed_s,
prediction_split=prediction_split,
)
# ---------------------------------------------------------------------------
# Main CV Pipeline
# ---------------------------------------------------------------------------
def run_tabm_cv(
dataset_pd: pd.DataFrame,
splits: list[dict[str, Any]],
*,
configs: list[dict[str, Any]],
n_features: int,
feature_names: list[str],
label_col: str,
date_col: str,
entity_col: str = "symbol",
device: str = "cuda",
save_dir: Path | None = None,
register: bool = False,
case_study: str | None = None,
notebook: str | None = None,
temporal_by_fold=None,
temporal_keys: list[str] | None = None,
temporal_feature_names: list[str] | None = None,
force_retrain: bool = False,
prediction_split: str = "validation",
) -> dict[str, Any]:
"""Walk-forward tabular DL CV with epoch-checkpoint IC evaluation.
All training parameters (n_epochs, batch_size, checkpoint_interval) are
read from each config dict. Config dicts come from ``load_configs()``.
Parameters
----------
dataset_pd : pandas DataFrame
Full dataset with features, label, date, and entity columns.
splits : list[dict]
Walk-forward splits from generate_cv_splits().
configs : list[dict]
Config dicts from ``load_configs()``. Each must have ``config_name``,
``params`` (with TabM arch kwargs or TabPFN kwargs), and training
params: ``n_epochs``, ``batch_size``, ``checkpoint_interval``.
n_features : int
Number of input features (injected into TabM constructor).
feature_names : list[str]
Column names to use as features.
label_col : str
Target column name.
date_col : str
Date/timestamp column name.
entity_col : str
Entity column name (default "symbol").
device : str
"cuda" or "cpu".
save_dir : Path, optional
Directory to save predictions and metrics.
Returns
-------
dict with keys:
grid_results: list[dict] — per-config results ranked by best IC
best_config_name: str
best_epoch: int
best_ic: float
predictions: pl.DataFrame — OOS predictions from best config
all_predictions: pl.DataFrame — predictions for ALL configs
fold_metrics: pl.DataFrame — per-fold cross-sectional IC for best config
all_learning_curves: pl.DataFrame — IC × epoch × config
"""
if register and save_dir is None:
raise ValueError(
"register=True requires save_dir for incremental prediction saves. "
"Pass save_dir=CASE_DIR / 'run_log' / 'training' / 'tabular_dl'"
)
# Filter out configs whose training_hash is already complete (unless
# force_retrain). This prevents re-running finished work across the entire
# sweep — the caller can override with force_retrain=True for debugging.
if register and case_study and not force_retrain:
from case_studies.utils.registry import (
build_training_spec,
load_prediction_sets,
training_hash_from_spec,
training_run_status,
)
pending_configs = []
for cfg in configs:
try:
spec = build_training_spec(
cfg["family"],
cfg["config_name"],
label_col,
n_folds=len(splits),
n_epochs=cfg.get("n_epochs"),
)
status = training_run_status(case_study, spec)
split_rows = load_prediction_sets(
case_study,
training_hash=training_hash_from_spec(spec),
split=prediction_split,
)
split_complete = not split_rows.is_empty()
if status.complete and split_complete:
print(
f" SKIP {cfg['config_name']:25s} ({status.summary()}, split={prediction_split})"
)
continue
if status.complete and not split_complete:
print(
f" RETRAIN {cfg['config_name']:25s} missing {prediction_split} predictions"
)
elif status.partial:
print(f" RETRAIN {cfg['config_name']:25s} partial state: {status.summary()}")
except Exception as exc:
print(f" WARN: skip-check failed for {cfg['config_name']}: {exc}")
pending_configs.append(cfg)
if not pending_configs:
print("All configs already complete — nothing to do.")
return {
"grid_results": [],
"best_config_name": None,
"best_epoch": 0,
"best_ic": float("nan"),
"predictions": pl.DataFrame(),
"all_predictions": pl.DataFrame(),
"fold_metrics": pl.DataFrame(),
"all_learning_curves": pl.DataFrame(),
"training_log": pl.DataFrame(),
}
configs = pending_configs
torch_device = torch.device(device if torch.cuda.is_available() else "cpu")
seed_everything(RANDOM_SEED)
dates_series = dataset_pd[date_col]
# Pre-build per-fold data: mask dates → extract numpy → impute + scale
has_fold_temporal = temporal_by_fold is not None and temporal_keys and temporal_feature_names
print("Preparing fold data...")
fold_data = []
for split in splits:
train_mask = (dates_series >= split["train_start"]) & (dates_series <= split["train_end"])
val_mask = (dates_series >= split["val_start"]) & (dates_series <= split["val_end"])
if has_fold_temporal:
from utils.modeling import _replace_temporal_columns
train_df = _replace_temporal_columns(
dataset_pd,
train_mask,
temporal_by_fold,
temporal_keys,
temporal_feature_names,
split["fold"],
)
val_df = _replace_temporal_columns(
dataset_pd,
val_mask,
temporal_by_fold,
temporal_keys,
temporal_feature_names,
split["fold"],
)
else:
train_df = dataset_pd.loc[train_mask]
val_df = dataset_pd.loc[val_mask]
# Drop NaN labels
train_valid = train_df[label_col].notna()
val_valid = val_df[label_col].notna()
train_df = train_df.loc[train_valid]
val_df = val_df.loc[val_valid]
if len(train_df) < 100 or len(val_df) < 50:
print(f" Fold {split['fold']}: skipped (train={len(train_df)}, val={len(val_df)})")
continue
X_train = train_df[feature_names].values.astype(np.float32)
y_train = train_df[label_col].values.astype(np.float32)
X_val = val_df[feature_names].values.astype(np.float32)
y_val = val_df[label_col].values.astype(np.float32)
val_dates = val_df[date_col].values
val_entities = val_df[entity_col].values
# Impute + scale per fold
imputer = SimpleImputer(strategy="median", keep_empty_features=True)
scaler = StandardScaler()
X_train = scaler.fit_transform(imputer.fit_transform(X_train))
X_val = scaler.transform(imputer.transform(X_val))
fold_data.append(
{
"fold": split["fold"],
"X_train": X_train,
"y_train": y_train,
"X_val": X_val,
"y_val": y_val,
"val_dates": val_dates,
"val_entities": val_entities,
"n_train": len(X_train),
"n_val": len(X_val),
}
)
print(f" Fold {split['fold']}: train={len(X_train):,} val={len(X_val):,}")
if not fold_data:
raise ValueError("No valid folds created. Check data size.")
# Grid search — train each config, evaluate at checkpoints, store ALL predictions.
# Incremental save: flush predictions to disk after each fold × config.
config_results: list[dict[str, Any]] = []
all_curves: list[dict] = []
training_log: list[dict] = []
# Set up incremental save directory
incr_dir = save_dir / "_incremental" if save_dir is not None else None
if incr_dir is not None:
incr_dir.mkdir(parents=True, exist_ok=True)
for cfg in configs:
config_name = cfg["config_name"]
cfg_params = dict(cfg.get("params", {}))
cfg_n_epochs = cfg.get("n_epochs", 200)
cfg_batch_size = cfg.get("batch_size", 4096)
cfg_checkpoint = cfg.get("checkpoint_interval", 25)
is_tabpfn = config_name.startswith("tabpfn")
config_started_at = datetime.now(UTC).isoformat()
t0 = time.perf_counter()
print(f"\n {config_name}:")
fold_checkpoint_ics: dict[int, list[float]] = {}
tabpfn_available = True
for fd in fold_data:
fold_t0 = time.perf_counter()
seed_everything(RANDOM_SEED + fd["fold"])
if is_tabpfn:
try:
preds = _run_tabpfn_fold(
fd["X_train"],
fd["y_train"],
fd["X_val"],
max_samples=cfg_params.get("max_samples", 2000),
n_ensemble=cfg_params.get("n_ensemble", 4),
)
ic_frame = pl.DataFrame(
{
"date": fd["val_dates"],
"symbol": fd["val_entities"],
"y_true": fd["y_val"],
"y_pred": preds,
}
)
ic = cross_sectional_ic(
ic_frame,
ic_frame,
pred_col="y_pred",
ret_col="y_true",
date_col="date",
entity_col="symbol",
min_obs=5,
)["ic_mean"]
fold_checkpoint_ics.setdefault(1, []).append(ic)
# Incremental save: flush this fold's predictions to disk
if incr_dir is not None:
flush_fold_predictions(
incr_dir,
config_name,
fd["fold"],
{1: preds},
fd["val_dates"],
fd["val_entities"],
fd["y_val"],
date_col,
entity_col,
)
fold_elapsed = time.perf_counter() - fold_t0
training_log.append(
{
"config": config_name,
"fold": fd["fold"],
"elapsed_s": round(fold_elapsed, 1),
"n_train": fd["n_train"],
"n_val": fd["n_val"],
"best_ic": round(ic, 4),
"n_checkpoints": 1,
}
)
print(f" Fold {fd['fold']}: IC={ic:+.4f} ({fold_elapsed:.1f}s)")
except ImportError:
if fd == fold_data[0]:
print(" TabPFN not installed — skipping")
tabpfn_available = False
break
except (RuntimeError, ValueError) as e:
if fd == fold_data[0]:
print(f" TabPFN failed: {e}")
tabpfn_available = False
break
else:
# TabM: train to completion, store ALL checkpoint predictions
tabm_kwargs = {"n_features": n_features, **cfg_params}
model = TabMModel(**tabm_kwargs)
checkpoint_ics, checkpoint_preds, epoch_losses = _train_tabm_fold(
model=model,
X_train=fd["X_train"],
y_train=fd["y_train"],
X_val=fd["X_val"],
y_val=fd["y_val"],
val_dates=fd["val_dates"],
val_entities=fd["val_entities"],
n_epochs=cfg_n_epochs,
batch_size=cfg_batch_size,
checkpoint_interval=cfg_checkpoint,
device=torch_device,
)
for ep, ic in checkpoint_ics.items():
fold_checkpoint_ics.setdefault(ep, []).append(ic)
# Incremental save: flush ALL checkpoint predictions for this fold
if incr_dir is not None:
flush_fold_predictions(
incr_dir,
config_name,
fd["fold"],
checkpoint_preds,
fd["val_dates"],
fd["val_entities"],
fd["y_val"],
date_col,
entity_col,
)
del model, checkpoint_preds
if torch.cuda.is_available():
torch.cuda.empty_cache()
best_ep = max(checkpoint_ics, key=lambda e: checkpoint_ics[e])
fold_elapsed = time.perf_counter() - fold_t0
# Sample losses at checkpoint epochs for the log
loss_at_checkpoints = {
str(k): round(epoch_losses.get(k, 0.0), 6)
for k in sorted(checkpoint_ics.keys())
}
training_log.append(
{
"config": config_name,
"fold": fd["fold"],
"elapsed_s": round(fold_elapsed, 1),
"n_train": fd["n_train"],
"n_val": fd["n_val"],
"best_ic": round(checkpoint_ics[best_ep], 4),
"n_checkpoints": len(checkpoint_ics),
"checkpoint_ics": {str(k): round(v, 4) for k, v in checkpoint_ics.items()},
"checkpoint_losses": loss_at_checkpoints,
}
)
print(
f" Fold {fd['fold']}: best_ep={best_ep}, "
f"IC={checkpoint_ics[best_ep]:+.4f} ({fold_elapsed:.1f}s)"
)
if is_tabpfn and not tabpfn_available:
continue
# Find best epoch for this config (mean IC across folds)
if fold_checkpoint_ics:
best_cp = max(
fold_checkpoint_ics.keys(),
key=lambda ep: (
np.nanmean(fold_checkpoint_ics[ep]) if fold_checkpoint_ics[ep] else -1
),
)
best_ic_val = float(np.nanmean(fold_checkpoint_ics[best_cp]))
else:
best_cp = 0
best_ic_val = float("nan")
elapsed = time.perf_counter() - t0
config_results.append(
{
"config_name": config_name,
"best_epoch": best_cp,
"best_ic": best_ic_val,
"elapsed_s": elapsed,
"started_at": config_started_at,
}
)
cfg_curves_list = []
for ep, ics in sorted(fold_checkpoint_ics.items()):
entry = {
"config": config_name,
"epoch": ep,
"ic_mean": float(np.nanmean(ics)),
"ic_std": float(np.nanstd(ics)) if len(ics) > 1 else 0.0,
}
all_curves.append(entry)
cfg_curves_list.append(entry)
print(f" → best_epoch={best_cp}, IC={best_ic_val:+.4f} ({elapsed:.1f}s)")
# Incremental registration: persist this config immediately so a later
# interruption or re-run doesn't lose work. Safe because config-major
# loop: this config's folds are all complete at this point.
# Registers ONE prediction_set per epoch checkpoint (each parquet contains
# exactly one epoch's predictions). The training_run is registered once on
# the first epoch slice via _register_tabm_config; subsequent epochs go
# through register_prediction_set directly so we don't re-register the
# training_run each time.
if register and case_study and incr_dir is not None:
try:
cfg_all_preds = _load_incremental_preds_for_config(incr_dir, config_name)
if cfg_all_preds.height > 0:
from case_studies.utils.registry import register_prediction_set
cfg_curves_df = pl.DataFrame(cfg_curves_list) if cfg_curves_list else None
epoch_ics = {
ep: float(np.nanmean(ics)) for ep, ics in fold_checkpoint_ics.items()
}
epochs = sorted(cfg_all_preds["epoch"].unique().to_list())
# First epoch registers the training_run + its prediction_set
first_ep = best_cp if best_cp in epochs else epochs[0]
first_slice = cfg_all_preds.filter(pl.col("epoch") == first_ep).drop(
"config", "epoch"
)
t_hash = _register_tabm_config(
case_study=case_study,
label=label_col,
config_name=config_name,
n_epochs=cfg.get("n_epochs"),
best_epoch=int(first_ep),
n_folds=len(fold_data),
ic_mean=epoch_ics.get(first_ep, best_ic_val),
predictions=first_slice,
notebook=notebook,
learning_curves=cfg_curves_df,
started_at=config_started_at,
elapsed_s=elapsed,
prediction_split=prediction_split,
)
# Remaining epochs: just register prediction_sets
for ep in epochs:
if ep == first_ep:
continue
ep_slice = cfg_all_preds.filter(pl.col("epoch") == ep).drop(
"config", "epoch"
)
register_prediction_set(
case_study,
training_hash=t_hash,
checkpoint_value=int(ep),
checkpoint_kind="epoch",
split=prediction_split,
predictions=ep_slice,
metrics={"ic_mean": epoch_ics.get(ep, float("nan"))},
)
print(
f" registered {config_name} incrementally ({len(epochs)} per-epoch slices)"
)
except Exception as exc:
print(f" WARN: incremental registration failed for {config_name}: {exc}")
gc.collect()
if not config_results:
raise ValueError("No configs completed successfully.")
# Select best config
config_results.sort(
key=lambda r: r["best_ic"] if not np.isnan(r["best_ic"]) else -999,
reverse=True,
)
best_result = config_results[0]
best_config_name = best_result["config_name"]
best_epoch = best_result["best_epoch"]
best_ic = best_result["best_ic"]
print(f"\n Best: {best_config_name} @ epoch {best_epoch} (IC={best_ic:+.4f})")
# Reassemble all predictions from incremental saves
all_predictions = _load_incremental_preds(incr_dir) if incr_dir is not None else pl.DataFrame()
# Extract best-config predictions at best epoch
if all_predictions.height > 0:
best_preds_df = all_predictions.filter(
(pl.col("config") == best_config_name) & (pl.col("epoch") == best_epoch)
)
predictions = best_preds_df.with_columns(
pl.lit(best_config_name).alias("model_id"),
).drop("config", "epoch")
else:
predictions = pl.DataFrame()
learning_curves = pl.DataFrame(all_curves) if all_curves else pl.DataFrame()
training_log_df = pl.DataFrame(training_log) if training_log else pl.DataFrame()
# Save final outputs
if save_dir is not None:
save_dir.mkdir(parents=True, exist_ok=True)
if predictions.height > 0:
predictions.write_parquet(save_dir / "predictions.parquet")
if all_predictions.height > 0:
all_predictions.write_parquet(save_dir / "all_predictions.parquet")
if learning_curves.height > 0:
learning_curves.write_parquet(save_dir / "learning_curves.parquet")
if training_log_df.height > 0:
training_log_df.write_parquet(save_dir / "training_log.parquet")
print(f" Saved to {save_dir}")
# Register in unified registry
# Note: per-config registration happens incrementally inside the training
# loop above (see the `register` block after each config's best_epoch is
# computed). The old batched registration block was removed to avoid
# duplicate writes — each config's training_hash is persisted immediately
# after its folds complete, protecting against interruption losing work.
return {
"grid_results": config_results,
"best_config_name": best_config_name,
"best_epoch": best_epoch,
"best_ic": best_ic,
"predictions": predictions,
"all_predictions": all_predictions,
"fold_metrics": compute_fold_metrics_from_predictions(
all_predictions,
best_config_name,
best_epoch,
date_col=date_col,
),
"all_learning_curves": learning_curves,
"training_log": training_log_df,
}