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

812 lines
30 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.
"""Darts-backed global forecasting helpers for production DL case studies."""
from __future__ import annotations
import re
import time
from dataclasses import dataclass
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 pytorch_lightning as pl_lightning
import torch
from darts import TimeSeries
from darts.models import NBEATSModel, TSMixerModel
from ml4t.diagnostic.metrics import cross_sectional_ic
from case_studies.utils.registry import compute_fold_metrics_from_predictions
from utils.modeling import RANDOM_SEED, seed_everything
SUPPORTED_DARTS_ARCHITECTURES = {"nbeats", "tsmixer"}
BASE_TARGET_COL = "_darts_target_1d"
def uses_darts_backend(configs: list[dict[str, Any]]) -> bool:
"""Return True when all configs are Darts-backed supported architectures."""
if not configs:
return False
return all(
cfg.get("library") == "darts"
and cfg.get("params", {}).get("architecture") in SUPPORTED_DARTS_ARCHITECTURES
for cfg in configs
)
@dataclass
class _FoldSeries:
entity: str
full_target: TimeSeries
full_covariates: TimeSeries
train_target: TimeSeries
train_covariates: TimeSeries
prediction_start_pos: int
val_start_pos: int
val_end_pos: int
dates: np.ndarray
y_true: np.ndarray
n_train_samples: int
def _metric_to_float(value: Any) -> float | None:
if value is None:
return None
if hasattr(value, "detach"):
value = value.detach().cpu()
if hasattr(value, "item"):
value = value.item()
return float(value)
from case_studies.utils.registry.store import _save_parquet, flush_fold_training_log
def _flush_darts_fold_training_log(
log_dir: Path,
config_name: str,
fold: int,
epoch_rows: list[dict[str, Any]],
) -> None:
flush_fold_training_log(log_dir, config_name, fold, epoch_rows)
def _flush_darts_fold_preds(
incr_dir: Path,
config_name: str,
fold: int,
prediction_frames: list[pl.DataFrame],
) -> None:
"""Flush pre-assembled prediction DataFrames (darts builds these during training)."""
if not prediction_frames:
return
_save_parquet(incr_dir / f"{config_name}_fold{fold}.parquet", pl.concat(prediction_frames))
class _DartsEpochProgressCallback(pl_lightning.callbacks.Callback):
def __init__(
self,
*,
config_name: str,
fold: int,
n_epochs: int,
n_train: int,
log_dir: Path | None,
epoch_rows: list[dict[str, Any]],
) -> None:
self.config_name = config_name
self.fold = fold
self.n_epochs = n_epochs
self.n_train = n_train
self.log_dir = log_dir
self.epoch_rows = epoch_rows
self._fit_start: float | None = None
self._prev_elapsed_s = 0.0
def on_train_start(self, trainer, pl_module) -> None:
if self._fit_start is None:
self._fit_start = time.perf_counter()
def on_train_epoch_end(self, trainer, pl_module) -> None:
if self._fit_start is None:
self._fit_start = time.perf_counter()
epoch = len(self.epoch_rows) + 1
elapsed_s = time.perf_counter() - self._fit_start
epoch_s = elapsed_s - self._prev_elapsed_s
self._prev_elapsed_s = elapsed_s
eta_s = ((self.n_epochs - epoch) * elapsed_s / epoch) if epoch else None
train_loss = _metric_to_float(trainer.callback_metrics.get("train_loss"))
val_loss = _metric_to_float(trainer.callback_metrics.get("val_loss"))
row = {
"config": self.config_name,
"fold": self.fold,
"epoch": epoch,
"train_loss": train_loss,
"val_loss": val_loss,
"ic": None,
"epoch_s": round(epoch_s, 3),
"elapsed_s": round(elapsed_s, 1),
"eta_s": round(eta_s, 1) if eta_s is not None else None,
"n_train": self.n_train,
"n_val": None,
"best_epoch": None,
"best_ic": None,
}
self.epoch_rows.append(row)
if self.log_dir is not None:
_flush_darts_fold_training_log(
self.log_dir,
self.config_name,
self.fold,
self.epoch_rows,
)
loss_str = f"{train_loss:.6f}" if train_loss is not None else "n/a"
eta_min = eta_s / 60 if eta_s is not None else float("nan")
print(
f" epoch {epoch:3d}/{self.n_epochs}: "
f"train_loss={loss_str} "
f"elapsed={elapsed_s / 60:.1f}m "
f"eta={eta_min:.1f}m",
flush=True,
)
def _trainer_kwargs(device: str) -> dict[str, Any]:
accelerator = "gpu" if device == "cuda" and torch.cuda.is_available() else "cpu"
return {
"accelerator": accelerator,
"devices": 1,
"deterministic": True,
"enable_checkpointing": False,
"enable_progress_bar": False,
"logger": False,
}
def _parse_label_horizon(label_col: str) -> int:
match = re.search(r"_(\d+)d$", label_col)
if match is None:
raise ValueError(
f"Darts forecasting currently requires a daily return label ending in '_<H>d': {label_col}"
)
return int(match.group(1))
def _recommended_input_chunk_length(horizon: int) -> int:
if horizon >= 21:
return 252
return max(60, 8 * horizon)
def _resolve_chunk_lengths(cfg: dict[str, Any], label_horizon: int) -> tuple[int, int]:
params = cfg.get("params", {})
input_chunk_length = int(
params.get(
"input_chunk_length",
params.get("darts_input_chunk_length", _recommended_input_chunk_length(label_horizon)),
)
)
output_chunk_length = int(params.get("darts_output_chunk_length", label_horizon))
if input_chunk_length <= output_chunk_length:
raise ValueError(
f"Darts requires input_chunk_length > output_chunk_length, got "
f"{input_chunk_length} <= {output_chunk_length} for {cfg['config_name']}"
)
return input_chunk_length, output_chunk_length
def _build_darts_model(
cfg: dict[str, Any],
device: str,
fold_seed: int,
input_chunk_length: int,
output_chunk_length: int,
trainer_callbacks: list[Any] | None = None,
):
params = dict(cfg.get("params", {}))
arch = params.pop("architecture")
model_cls = NBEATSModel if arch == "nbeats" else TSMixerModel
params.pop("lookback", None)
params.pop("darts_input_chunk_length", None)
params.pop("darts_output_chunk_length", None)
params.pop("input_chunk_length", None)
params.pop("output_chunk_length", None)
params["input_chunk_length"] = input_chunk_length
params["output_chunk_length"] = output_chunk_length
if arch == "nbeats":
params["num_stacks"] = int(params.pop("num_stacks", 1))
params["num_blocks"] = int(params.pop("num_blocks", params.pop("n_blocks", 1)))
params["num_layers"] = int(params.pop("num_layers", params.pop("n_layers", 4)))
params["layer_widths"] = int(params.pop("layer_widths", params.pop("hidden_size", 256)))
else:
hidden_size = int(params.pop("hidden_size", params.pop("hidden_dim", 64)))
params["hidden_size"] = hidden_size
params["ff_size"] = int(params.pop("ff_size", hidden_size))
params["num_blocks"] = int(params.pop("num_blocks", params.pop("n_blocks", 2)))
params.setdefault("use_static_covariates", False)
params["n_epochs"] = cfg.get("n_epochs", 100)
params["batch_size"] = cfg.get("batch_size", 2048)
params["random_state"] = fold_seed
params["save_checkpoints"] = False
params["force_reset"] = True
trainer = dict(params.pop("pl_trainer_kwargs", {}))
trainer = {**_trainer_kwargs(device), **trainer}
callbacks = trainer.get("callbacks")
callback_list = list(callbacks) if callbacks is not None else []
if trainer_callbacks:
callback_list.extend(trainer_callbacks)
if callback_list:
trainer["callbacks"] = callback_list
params["pl_trainer_kwargs"] = trainer
return model_cls(**params)
def _resolve_sampling(
fold_series: list[_FoldSeries],
input_chunk_length: int,
output_chunk_length: int,
max_train_sequences: int,
) -> tuple[int, int | None]:
sample_counts = [
max(state.n_train_samples - input_chunk_length - output_chunk_length + 1, 0)
for state in fold_series
]
total_samples = int(sum(sample_counts))
if max_train_sequences <= 0 or total_samples <= max_train_sequences:
return 1, None
stride = max(1, int(np.ceil(total_samples / max_train_sequences)))
stride_samples = sum(int(np.ceil(n / stride)) for n in sample_counts if n > 0)
max_samples_per_ts = None
if stride_samples > max_train_sequences:
max_samples_per_ts = max(1, int(np.ceil(max_train_sequences / len(fold_series))))
return stride, max_samples_per_ts
def _load_base_target_frame(
case_study: str,
dataset_pd: pd.DataFrame,
date_col: str,
) -> tuple[pd.DataFrame, list[str]]:
if case_study == "etfs":
from data import load_etfs
target_df = (
load_etfs()
.sort(["symbol", "timestamp"])
.with_columns(
((pl.col("close") / pl.col("close").shift(1).over("symbol")).log()).alias(
BASE_TARGET_COL
)
)
.select(["timestamp", "symbol", BASE_TARGET_COL])
)
return target_df.to_pandas(), [date_col, "symbol"]
if case_study == "cme_futures":
from data import load_cme_futures
target_df = (
load_cme_futures()
.rename({"session_date": "timestamp", "tenor": "position"})
.sort(["product", "position", "timestamp"])
.with_columns(
(
(pl.col("close") / pl.col("close").shift(1).over(["product", "position"])).log()
).alias(BASE_TARGET_COL)
)
.select(["timestamp", "product", "position", BASE_TARGET_COL])
)
join_keys = [date_col, "product"]
if "position" in dataset_pd.columns:
join_keys.append("position")
return target_df.to_pandas(), join_keys
if case_study == "us_equities_panel":
from data import load_us_equities
target_df = (
load_us_equities(start_date="1990-01-01", end_date="2018-03-31")
.sort(["symbol", "timestamp"])
.with_columns(
((pl.col("adj_close") / pl.col("adj_close").shift(1).over("symbol")).log()).alias(
BASE_TARGET_COL
)
)
.select(["timestamp", "symbol", BASE_TARGET_COL])
)
return target_df.to_pandas(), [date_col, "symbol"]
raise RuntimeError(
"Horizon-aware Darts support is currently implemented for etfs, cme_futures, "
"and us_equities_panel only. "
f"{case_study} remains blocked because its label is not a single calendar-time return series."
)
def _attach_base_target(
dataset_pd: pd.DataFrame,
case_study: str,
date_col: str,
) -> pd.DataFrame:
target_pd, join_keys = _load_base_target_frame(case_study, dataset_pd, date_col)
dataset_pd = dataset_pd.copy()
dataset_pd[date_col] = pd.to_datetime(dataset_pd[date_col])
target_pd[date_col] = pd.to_datetime(target_pd[date_col])
merged = dataset_pd.merge(target_pd, on=join_keys, how="left", validate="many_to_one")
if merged[BASE_TARGET_COL].notna().sum() == 0:
raise RuntimeError(f"Failed to join base Darts target series for {case_study}")
return merged
def _prepare_fold_series(
dataset_pd: pd.DataFrame,
split: dict[str, Any],
feature_names: list[str],
label_col: str,
date_col: str,
entity_col: str,
input_chunk_length: int,
output_chunk_length: int,
) -> list[_FoldSeries]:
train_start = pd.Timestamp(split["train_start"]).to_datetime64()
train_end = pd.Timestamp(split["train_end"]).to_datetime64()
val_start = pd.Timestamp(split["val_start"]).to_datetime64()
val_end = pd.Timestamp(split["val_end"]).to_datetime64()
cols = [date_col, entity_col, label_col, BASE_TARGET_COL, *feature_names]
fold_mask = (dataset_pd[date_col] >= train_start) & (dataset_pd[date_col] <= val_end)
fold_df = dataset_pd.loc[fold_mask, cols].copy().dropna(subset=[BASE_TARGET_COL])
train_df = fold_df.loc[fold_df[date_col] <= train_end].copy()
if train_df.empty or fold_df.empty:
return []
fold_df = fold_df.astype({name: np.float32 for name in feature_names}, copy=False)
train_df = train_df.astype({name: np.float32 for name in feature_names}, copy=False)
feature_frame = fold_df[feature_names].astype(np.float32)
train_features = train_df[feature_names].astype(np.float32)
means = train_features.mean()
stds = train_features.std(ddof=0).replace(0.0, 1.0).fillna(1.0)
fold_df.loc[:, feature_names] = ((feature_frame - means) / stds).fillna(0.0).astype(np.float32)
series: list[_FoldSeries] = []
for entity, sym_df in fold_df.groupby(entity_col, sort=False):
sym_df = sym_df.sort_values(date_col).reset_index(drop=True)
dates = sym_df[date_col].to_numpy()
train_cut = int((dates <= train_end).sum())
val_positions = np.flatnonzero((dates >= val_start) & (dates <= val_end))
if len(val_positions) == 0:
continue
if train_cut < input_chunk_length + output_chunk_length:
continue
val_start_pos = int(val_positions[0])
val_end_pos = int(val_positions[-1])
prediction_start_pos = val_start_pos + 1
if prediction_start_pos <= 0 or prediction_start_pos >= len(sym_df):
continue
t = np.arange(len(sym_df), dtype=np.int32)
target_df = pd.DataFrame(
{"t": t, BASE_TARGET_COL: sym_df[BASE_TARGET_COL].to_numpy(np.float32)}
)
cov_df = pd.DataFrame(
{"t": t, **{f: sym_df[f].to_numpy(np.float32) for f in feature_names}}
)
full_target = TimeSeries.from_dataframe(target_df, time_col="t", value_cols=BASE_TARGET_COL)
full_covariates = TimeSeries.from_dataframe(cov_df, time_col="t", value_cols=feature_names)
series.append(
_FoldSeries(
entity=str(entity),
full_target=full_target,
full_covariates=full_covariates,
train_target=full_target[:train_cut],
train_covariates=full_covariates[:train_cut],
prediction_start_pos=prediction_start_pos,
val_start_pos=val_start_pos,
val_end_pos=val_end_pos,
dates=dates,
y_true=sym_df[label_col].to_numpy(np.float32),
n_train_samples=train_cut,
)
)
return series
def _predict_fold(
model,
fold_series: list[_FoldSeries],
fold_id: int,
date_col: str,
entity_col: str,
output_chunk_length: int,
) -> pl.DataFrame:
frames: list[pl.DataFrame] = []
for state in fold_series:
forecasts = model.historical_forecasts(
state.full_target,
past_covariates=state.full_covariates,
start=state.prediction_start_pos,
start_format="position",
forecast_horizon=output_chunk_length,
stride=1,
retrain=False,
overlap_end=True,
last_points_only=False,
verbose=False,
show_warnings=False,
)
if isinstance(forecasts, TimeSeries):
forecasts = [forecasts]
rows: list[dict[str, Any]] = []
for forecast in forecasts:
start_pos = int(forecast.start_time())
base_pos = start_pos - 1
if base_pos < state.val_start_pos or base_pos > state.val_end_pos:
continue
if base_pos < 0 or base_pos >= len(state.dates):
continue
score_path = forecast.values(copy=False).reshape(-1).astype(np.float64, copy=False)
rows.append(
{
date_col: pd.Timestamp(state.dates[base_pos]),
entity_col: state.entity,
"y_true": float(state.y_true[base_pos]),
"y_score": float(np.expm1(score_path.sum())),
"fold_id": fold_id,
}
)
if rows:
frames.append(pl.DataFrame(rows))
return pl.concat(frames) if frames else pl.DataFrame()
def run_darts_cv(
dataset_pd: pd.DataFrame,
splits: list[dict[str, Any]],
*,
configs: list[dict[str, Any]],
feature_names: list[str],
label_col: str,
date_col: str,
entity_col: str,
device: str,
save_dir: Path | None,
max_train_sequences: int,
register: bool,
case_study: str | None,
notebook: str | None,
prediction_split: str = "validation",
) -> dict[str, Any]:
"""Run Darts-backed global forecasting models and emit standard DL artifacts."""
if case_study is None:
raise ValueError(
"Darts backends require case_study so the base target series can be built."
)
if register and save_dir is None:
raise ValueError("register=True requires save_dir for Darts prediction artifacts.")
from case_studies.utils.deep_learning import _register_dl_config
label_horizon = _parse_label_horizon(label_col)
dataset_pd = _attach_base_target(dataset_pd.copy(), case_study, date_col)
config_results: list[dict[str, Any]] = []
learning_rows: list[dict[str, Any]] = []
training_log: list[dict[str, Any]] = []
prediction_frames: list[pl.DataFrame] = []
for cfg in configs:
config_name = cfg["config_name"]
params = cfg.get("params", {})
input_chunk_length, output_chunk_length = _resolve_chunk_lengths(cfg, label_horizon)
cfg_seed = int(cfg.get("seed", RANDOM_SEED))
n_epochs = int(cfg.get("n_epochs", 100))
checkpoint_interval = int(cfg.get("checkpoint_interval", n_epochs))
started_at = datetime.now(UTC).isoformat()
elapsed_total = 0.0
cfg_prediction_frames: list[pl.DataFrame] = []
print(
f"Darts CV: {config_name} ({params['architecture']}) "
f"{len(splits)} folds × {n_epochs} epochs | "
f"input={input_chunk_length} output={output_chunk_length}"
)
for split in splits:
fold_seed = cfg_seed + split["fold"]
seed_everything(fold_seed)
fold_series = _prepare_fold_series(
dataset_pd,
split,
feature_names,
label_col,
date_col,
entity_col,
input_chunk_length,
output_chunk_length,
)
if not fold_series:
print(f" Fold {split['fold']}: skipped (insufficient series after filtering)")
continue
stride, max_samples_per_ts = _resolve_sampling(
fold_series,
input_chunk_length,
output_chunk_length,
max_train_sequences,
)
if max_train_sequences > 0:
msg = f" Fold {split['fold']}: {len(fold_series)} series, stride={stride}"
if max_samples_per_ts is not None:
msg += f", max_samples_per_ts={max_samples_per_ts}"
print(msg)
else:
print(f" Fold {split['fold']}: {len(fold_series)} series")
train_series = [state.train_target for state in fold_series]
train_covariates = [state.train_covariates for state in fold_series]
epoch_rows: list[dict[str, Any]] = []
checkpoint_frames: list[pl.DataFrame] = []
checkpoint_ics: dict[int, float] = {}
n_val_points = 0
incr_dir = save_dir / "_incremental" if save_dir is not None else None
log_dir = save_dir / "_incremental_logs" if save_dir is not None else None
if incr_dir is not None:
incr_dir.mkdir(parents=True, exist_ok=True)
if log_dir is not None:
log_dir.mkdir(parents=True, exist_ok=True)
t0 = time.perf_counter()
model = _build_darts_model(
cfg,
device,
fold_seed,
input_chunk_length,
output_chunk_length,
trainer_callbacks=[
_DartsEpochProgressCallback(
config_name=config_name,
fold=split["fold"],
n_epochs=n_epochs,
n_train=int(sum(state.n_train_samples for state in fold_series)),
log_dir=log_dir,
epoch_rows=epoch_rows,
)
],
)
epochs_trained = 0
while epochs_trained < n_epochs:
epochs_to_train = min(checkpoint_interval, n_epochs - epochs_trained)
model.fit(
train_series,
past_covariates=train_covariates,
verbose=False,
epochs=epochs_to_train,
stride=stride,
max_samples_per_ts=max_samples_per_ts,
)
epochs_trained += epochs_to_train
checkpoint_preds = _predict_fold(
model,
fold_series,
split["fold"],
date_col,
entity_col,
output_chunk_length,
)
elapsed = time.perf_counter() - t0
if checkpoint_preds.height == 0:
print(
f" checkpoint {epochs_trained:3d}/{n_epochs}: "
f"no validation predictions ({elapsed:.1f}s elapsed)",
flush=True,
)
continue
n_val_points = checkpoint_preds.height
checkpoint_preds = checkpoint_preds.with_columns(
pl.lit(config_name).alias("config"),
pl.lit(epochs_trained).alias("epoch"),
)
checkpoint_frames.append(checkpoint_preds)
cfg_prediction_frames.append(checkpoint_preds)
prediction_frames.append(checkpoint_preds)
if incr_dir is not None:
_flush_darts_fold_preds(incr_dir, config_name, split["fold"], checkpoint_frames)
_entity = entity_col if entity_col in checkpoint_preds.columns else None
ic = cross_sectional_ic(
checkpoint_preds,
checkpoint_preds,
pred_col="y_score",
ret_col="y_true",
date_col=date_col,
entity_col=_entity,
method="spearman",
min_obs=5,
)["ic_mean"]
checkpoint_ics[epochs_trained] = ic
for row in reversed(epoch_rows):
if row["epoch"] == epochs_trained:
row["ic"] = round(ic, 4)
break
if log_dir is not None:
_flush_darts_fold_training_log(log_dir, config_name, split["fold"], epoch_rows)
print(
f" checkpoint {epochs_trained:3d}/{n_epochs}: "
f"fold IC={ic:+.4f} ({elapsed:.1f}s elapsed)",
flush=True,
)
elapsed = time.perf_counter() - t0
elapsed_total += elapsed
del model
if torch.cuda.is_available():
torch.cuda.empty_cache()
if not checkpoint_ics:
for row in epoch_rows:
row["n_val"] = 0
row["best_epoch"] = n_epochs
if log_dir is not None:
_flush_darts_fold_training_log(log_dir, config_name, split["fold"], epoch_rows)
training_log.extend(epoch_rows)
print(f" no validation predictions generated ({elapsed:.1f}s)")
continue
fold_best_epoch = max(checkpoint_ics, key=checkpoint_ics.get)
fold_best_ic = checkpoint_ics[fold_best_epoch]
for row in epoch_rows:
row["n_val"] = n_val_points
row["best_epoch"] = fold_best_epoch
row["best_ic"] = round(fold_best_ic, 4)
if log_dir is not None:
_flush_darts_fold_training_log(log_dir, config_name, split["fold"], epoch_rows)
training_log.extend(epoch_rows)
print(f" fold best epoch={fold_best_epoch}, IC={fold_best_ic:+.4f} ({elapsed:.1f}s)")
epoch_scores: list[tuple[int, float, float]] = []
if cfg_prediction_frames:
cfg_all_preds = pl.concat(cfg_prediction_frames)
for epoch in sorted(cfg_all_preds["epoch"].unique().to_list()):
ep_df = cfg_all_preds.filter(pl.col("epoch") == epoch)
fold_ids = sorted(ep_df["fold_id"].unique().to_list())
fold_epoch_ics = []
for fold_id in fold_ids:
fold_df = ep_df.filter(pl.col("fold_id") == fold_id)
_entity = entity_col if entity_col in fold_df.columns else None
fold_epoch_ics.append(
cross_sectional_ic(
fold_df,
fold_df,
pred_col="y_score",
ret_col="y_true",
date_col=date_col,
entity_col=_entity,
method="spearman",
min_obs=5,
)["ic_mean"]
)
ic_mean = float(np.nanmean(fold_epoch_ics))
ic_std = float(np.nanstd(fold_epoch_ics)) if len(fold_epoch_ics) > 1 else 0.0
learning_rows.append(
{
"config": config_name,
"epoch": epoch,
"ic_mean": ic_mean,
"ic_std": ic_std,
}
)
epoch_scores.append((epoch, ic_mean, ic_std))
if epoch_scores:
best_epoch, best_ic, best_ic_std = max(epoch_scores, key=lambda item: item[1])
else:
best_epoch, best_ic, best_ic_std = n_epochs, float("nan"), 0.0
config_results.append(
{
"config_name": config_name,
"best_epoch": best_epoch,
"best_ic": best_ic,
"input_chunk_length": input_chunk_length,
"elapsed_s": elapsed_total,
"started_at": started_at,
}
)
print(
f" {config_name}: epoch={best_epoch}, IC={best_ic:+.4f} "
f"(std={best_ic_std:.4f}, {elapsed_total:.1f}s)"
)
if not config_results:
raise RuntimeError("Darts run produced no config results.")
config_results.sort(
key=lambda row: row["best_ic"] if not np.isnan(row["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"]
all_predictions = pl.concat(prediction_frames) if prediction_frames else pl.DataFrame()
predictions = (
all_predictions.filter(
(pl.col("config") == best_config_name) & (pl.col("epoch") == best_epoch)
)
.with_columns(pl.lit(best_config_name).alias("model_id"))
.drop("config", "epoch")
if all_predictions.height > 0
else pl.DataFrame()
)
learning_curves = pl.DataFrame(learning_rows) if learning_rows else pl.DataFrame()
training_log_df = pl.DataFrame(training_log) if training_log else pl.DataFrame()
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")
if register and case_study and all_predictions.height > 0:
for row in config_results:
cfg_name = row["config_name"]
cfg = next(c for c in configs if c["config_name"] == cfg_name)
cfg_preds = all_predictions.filter(
(pl.col("config") == cfg_name) & (pl.col("epoch") == row["best_epoch"])
)
cfg_curves = learning_curves.filter(pl.col("config") == cfg_name)
_register_dl_config(
case_study=case_study,
label=label_col,
config_name=cfg_name,
architecture=cfg["params"]["architecture"],
n_epochs=cfg.get("n_epochs", 100),
best_epoch=row["best_epoch"],
lookback=row["input_chunk_length"],
n_folds=len(splits),
ic_mean=row["best_ic"],
predictions=cfg_preds,
notebook=notebook,
learning_curves=cfg_curves,
started_at=row.get("started_at"),
elapsed_s=row.get("elapsed_s"),
prediction_split=prediction_split,
)
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,
}