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

825 lines
28 KiB
Python

"""Storage, schema, and filesystem helpers for the experiment registry."""
from __future__ import annotations
import json
import logging
import sqlite3
import subprocess
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from .specs import _validate_spec, canonical_json, training_hash_from_spec
if TYPE_CHECKING:
import numpy as np
logger = logging.getLogger(__name__)
UTC = UTC
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
REGISTRY_SCHEMA_SQL = """\
CREATE TABLE IF NOT EXISTS training_runs (
training_hash TEXT PRIMARY KEY,
family TEXT NOT NULL,
label TEXT NOT NULL,
config_name TEXT,
spec_json TEXT,
created_at TEXT NOT NULL,
git_commit TEXT,
entry_point TEXT,
started_at TEXT,
elapsed_s REAL
);
CREATE INDEX IF NOT EXISTS idx_training_family_label ON training_runs(family, label);
CREATE INDEX IF NOT EXISTS idx_training_config_name ON training_runs(config_name);
CREATE TABLE IF NOT EXISTS prediction_sets (
prediction_hash TEXT PRIMARY KEY,
training_hash TEXT NOT NULL REFERENCES training_runs(training_hash),
checkpoint_value INTEGER,
checkpoint_kind TEXT,
split TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pred_training ON prediction_sets(training_hash);
CREATE INDEX IF NOT EXISTS idx_pred_split ON prediction_sets(split);
CREATE TABLE IF NOT EXISTS prediction_metrics (
prediction_hash TEXT PRIMARY KEY REFERENCES prediction_sets(prediction_hash),
computed_at TEXT NOT NULL,
ic_mean REAL, ic_std REAL, ic_t REAL, n_folds REAL,
pct_positive REAL, task_type TEXT,
accuracy REAL, balanced_accuracy REAL, auc_roc REAL, auc_pr REAL,
log_loss REAL, brier_score REAL
);
CREATE TABLE IF NOT EXISTS fold_metrics (
prediction_hash TEXT NOT NULL REFERENCES prediction_sets(prediction_hash),
fold_id INTEGER NOT NULL,
computed_at TEXT NOT NULL,
ic REAL, ic_std REAL, n_entities REAL,
rmse REAL, mae REAL,
accuracy REAL, balanced_accuracy REAL, auc_roc REAL, auc_pr REAL,
log_loss REAL, brier_score REAL,
"auc_class_-1" REAL, auc_class_0 REAL, auc_class_1 REAL,
PRIMARY KEY (prediction_hash, fold_id)
);
CREATE INDEX IF NOT EXISTS idx_fold_metrics_pred ON fold_metrics(prediction_hash);
CREATE TABLE IF NOT EXISTS backtest_runs (
backtest_hash TEXT PRIMARY KEY,
prediction_hash TEXT NOT NULL REFERENCES prediction_sets(prediction_hash),
spec_json TEXT,
stage TEXT,
created_at TEXT NOT NULL,
git_commit TEXT,
started_at TEXT,
elapsed_s REAL
);
CREATE INDEX IF NOT EXISTS idx_backtest_pred ON backtest_runs(prediction_hash);
CREATE INDEX IF NOT EXISTS idx_backtest_stage ON backtest_runs(stage);
CREATE TABLE IF NOT EXISTS backtest_metrics (
backtest_hash TEXT PRIMARY KEY REFERENCES backtest_runs(backtest_hash),
computed_at TEXT NOT NULL,
sharpe REAL, sortino REAL, total_return REAL, max_drawdown REAL,
cagr REAL, volatility REAL, calmar REAL, omega REAL, stability REAL,
tail_ratio REAL, win_rate REAL, kurtosis REAL, skewness REAL,
var_95 REAL, cvar_95 REAL, n_periods REAL,
num_trades REAL, total_commission REAL, total_slippage REAL, avg_turnover REAL
);
CREATE TABLE IF NOT EXISTS backtest_fold_metrics (
backtest_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
fold_id INTEGER NOT NULL,
computed_at TEXT NOT NULL,
sharpe REAL, sortino REAL, total_return REAL, max_drawdown REAL,
cagr REAL, volatility REAL, calmar REAL, omega REAL, stability REAL,
tail_ratio REAL, win_rate REAL, kurtosis REAL, skewness REAL,
var_95 REAL, cvar_95 REAL, n_days REAL,
PRIMARY KEY (backtest_hash, fold_id)
);
CREATE INDEX IF NOT EXISTS idx_bt_fold_metrics ON backtest_fold_metrics(backtest_hash);
CREATE TABLE IF NOT EXISTS causal_runs (
causal_hash TEXT PRIMARY KEY,
label TEXT NOT NULL,
treatment TEXT,
confounders_json TEXT,
embargo INTEGER,
n_folds INTEGER,
n_obs INTEGER,
dml_effect REAL,
dml_se_hac REAL,
p_value_hac REAL,
naive_effect REAL,
confounding_bias_pct REAL,
refutation_p REAL,
spec_json TEXT,
notebook TEXT,
started_at TEXT,
elapsed_s REAL,
git_commit TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_causal_label ON causal_runs(label);
CREATE TABLE IF NOT EXISTS backtest_paired_metrics (
challenger_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
benchmark_hash TEXT NOT NULL,
benchmark_kind TEXT,
periods_per_year INTEGER,
bootstrap_block_length INTEGER,
bootstrap_n INTEGER,
sharpe_diff REAL,
sharpe_diff_ci95_lo REAL,
sharpe_diff_ci95_hi REAL,
ret_diff REAL,
ret_diff_ci95_lo REAL,
ret_diff_ci95_hi REAL,
max_dd_diff REAL,
max_dd_diff_ci95_lo REAL,
max_dd_diff_ci95_hi REAL,
info_ratio REAL,
info_ratio_ci95_lo REAL,
info_ratio_ci95_hi REAL,
prob_challenger_wins REAL,
p_value REAL,
computed_at TEXT NOT NULL,
PRIMARY KEY (challenger_hash, benchmark_hash)
);
CREATE INDEX IF NOT EXISTS idx_paired_challenger ON backtest_paired_metrics(challenger_hash);
CREATE INDEX IF NOT EXISTS idx_paired_kind ON backtest_paired_metrics(benchmark_kind);
CREATE TABLE IF NOT EXISTS cohort_metrics (
cohort_type TEXT NOT NULL,
stage TEXT,
label TEXT NOT NULL,
family TEXT,
leader_hash TEXT NOT NULL REFERENCES backtest_runs(backtest_hash),
k_variants INTEGER NOT NULL,
periods_per_year REAL NOT NULL,
computed_at TEXT NOT NULL,
n_trials_effective_mp REAL,
n_trials_effective_er REAL,
dsr_raw REAL, dsr_raw_pvalue REAL,
expected_max_sharpe_raw REAL, min_trl_periods_raw REAL,
dsr_mp REAL, dsr_mp_pvalue REAL,
expected_max_sharpe_mp REAL, min_trl_periods_mp REAL,
dsr_er REAL, dsr_er_pvalue REAL,
expected_max_sharpe_er REAL, min_trl_periods_er REAL,
ras_leader REAL,
ras_complexity REAL,
ras_n_strategies REAL,
ras_pvalue REAL,
reality_check_pvalue REAL,
reality_check_statistic REAL,
reality_check_k REAL,
pbo REAL,
pbo_n_combinations REAL,
pbo_median_oos_rank REAL,
pbo_mean_degradation REAL,
pbo_n_folds REAL,
leader_sharpe REAL,
leader_sortino REAL,
leader_min_trl REAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cohort_unique
ON cohort_metrics(cohort_type, COALESCE(stage, ''), label, COALESCE(family, ''));
CREATE INDEX IF NOT EXISTS idx_cohort_leader ON cohort_metrics(leader_hash);
"""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _git_hash() -> str | None:
try:
return (
subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
stderr=subprocess.DEVNULL,
timeout=5,
)
.decode()
.strip()
)
except Exception:
return None
def _utc_now() -> str:
return datetime.now(UTC).isoformat()
VALID_STAGES = {"signal", "allocation", "cost_sensitivity", "risk_overlay"}
def _stage_filter_clause(
stage: str | None, chapter_filter: str | None = None
) -> tuple[str, list[str]]:
"""Build a SQL WHERE clause fragment for stage filtering.
Returns (clause, params) using parameterized queries.
"""
if stage:
if stage not in VALID_STAGES:
raise ValueError(f"Invalid stage {stage!r}, expected one of {VALID_STAGES}")
return "AND b.stage = ?", [stage]
if chapter_filter:
pattern = f'%"chapter":"{chapter_filter}"%'
return "AND b.spec_json LIKE ?", [pattern]
return "AND b.stage = 'signal'", []
def _infer_stage(
spec: dict,
*,
case_dir: Path | None = None,
prediction_hash: str | None = None,
) -> str:
"""Infer pipeline stage from strategy_spec content.
When ``case_dir`` and ``prediction_hash`` are both provided, this also
checks ``prediction_sets.split``: if the prediction is ``split='holdout'``,
the stage is forced to ``'holdout'`` regardless of the spec content. This
keeps holdout backtests universally identifiable via ``stage='holdout'``
even when the rank-1 lineage cascades into an allocation- or
risk_overlay-stage strategy.
"""
if case_dir is not None and prediction_hash is not None:
try:
db = sqlite3.connect(_registry_db_path(case_dir))
try:
row = db.execute(
"SELECT split FROM prediction_sets WHERE prediction_hash = ?",
(prediction_hash,),
).fetchone()
finally:
db.close()
if row is not None and row[0] == "holdout":
return "holdout"
except sqlite3.DatabaseError:
# Registry not initialized yet — fall through to spec inference.
pass
strategy = spec.get("strategy", spec)
risk = strategy.get("risk", {})
if risk and risk.get("name") != "baseline":
return "risk_overlay"
# Cost sensitivity: explicit chapter tag of ch18, or caller should set explicitly
chapter = spec.get("chapter", "")
if chapter == "ch18":
return "cost_sensitivity"
if "allocation" in strategy:
alloc = strategy["allocation"]
if isinstance(alloc, dict) and alloc.get("method", "equal_weight") != "equal_weight":
return "allocation"
return "signal"
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
def _case_dir(case_study: str) -> Path:
"""Resolve case study directory, respecting ML4T_OUTPUT_DIR."""
from utils.paths import get_case_study_dir
return get_case_study_dir(case_study)
def _run_log_dir(case_dir: Path) -> Path:
return case_dir / "run_log"
def _registry_db_path(case_dir: Path) -> Path:
return _run_log_dir(case_dir) / "registry.db"
def _training_dir(case_dir: Path, t_hash: str) -> Path:
return _run_log_dir(case_dir) / "training" / t_hash
def get_training_dir(case_study: str, spec: dict) -> Path:
"""Pre-compute the training artifact directory for a spec.
Use this to get the save_dir BEFORE training, so model artifacts
(boosters, coefficients, learning curves) go directly to the registry.
Usage::
spec = build_training_spec("gbm", "default_mse", "fwd_ret_21d", ...)
train_dir = get_training_dir("etfs", spec)
result = train_gbm_config(config, folds, save_dir=train_dir, ...)
register_training_run("etfs", spec=spec) # spec.json written, boosters already in place
Returns
-------
Path
``run_log/training/{hash}/`` for this spec.
"""
spec = _validate_spec(spec)
t_hash = training_hash_from_spec(spec)
case_dir = _case_dir(case_study)
d = _training_dir(case_dir, t_hash)
d.mkdir(parents=True, exist_ok=True)
return d
def _prediction_dir(case_dir: Path, p_hash: str) -> Path:
return _run_log_dir(case_dir) / "predictions" / p_hash
def _backtest_dir(case_dir: Path, b_hash: str) -> Path:
return _run_log_dir(case_dir) / "backtest" / b_hash
# ---------------------------------------------------------------------------
# DB connection and migration
# ---------------------------------------------------------------------------
def _open_registry(case_dir: Path) -> sqlite3.Connection:
db_path = _registry_db_path(case_dir)
db_path.parent.mkdir(parents=True, exist_ok=True)
# Tolerate concurrent writers (parallel backfills, notebook + script
# writing the same registry). 120s SQLite-driver timeout + 60s server-side
# busy_timeout. Without these, any momentary writer conflict raises
# "database is locked" instantly.
db = sqlite3.connect(str(db_path), timeout=120.0)
db.execute("PRAGMA busy_timeout = 60000")
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA foreign_keys=ON")
# Migrate existing DBs before running CREATE TABLE IF NOT EXISTS
_migrate_registry(db)
db.executescript(REGISTRY_SCHEMA_SQL)
return db
def _table_has_column(db: sqlite3.Connection, table: str, column: str) -> bool:
"""Check if a table has a specific column."""
return column in {row[1] for row in db.execute(f"PRAGMA table_info({table})").fetchall()}
def _migrate_registry(db: sqlite3.Connection) -> None:
"""Apply incremental schema migrations to an existing registry."""
# Check if backtest_runs table exists at all
tables = {
row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
if "backtest_runs" not in tables:
return # Fresh DB — schema script will create everything
# Migration 1: add stage column to backtest_runs
cols = {row[1] for row in db.execute("PRAGMA table_info(backtest_runs)").fetchall()}
if "stage" not in cols:
db.execute("ALTER TABLE backtest_runs ADD COLUMN stage TEXT")
db.execute("CREATE INDEX IF NOT EXISTS idx_backtest_stage ON backtest_runs(stage)")
# Migration 2: add runtime columns to training_runs
if "training_runs" in tables:
tr_cols = {row[1] for row in db.execute("PRAGMA table_info(training_runs)").fetchall()}
if "started_at" not in tr_cols:
db.execute("ALTER TABLE training_runs ADD COLUMN started_at TEXT")
if "elapsed_s" not in tr_cols:
db.execute("ALTER TABLE training_runs ADD COLUMN elapsed_s REAL")
# Migration 2b: add runtime columns to backtest_runs
if "started_at" not in cols:
db.execute("ALTER TABLE backtest_runs ADD COLUMN started_at TEXT")
if "elapsed_s" not in cols:
db.execute("ALTER TABLE backtest_runs ADD COLUMN elapsed_s REAL")
# Migration 3: tall → wide metric tables
if "prediction_metrics" in tables:
pm_cols = {row[1] for row in db.execute("PRAGMA table_info(prediction_metrics)").fetchall()}
if "metric" in pm_cols:
_migrate_tall_to_wide(db)
# Migration 4: task_type from numeric (1.0 / 0.0) to string
# ("classification" / "regression"). The schema is now TEXT but legacy
# rows still carry the float encoding; consumers that filter
# ``task_type = 'classification'`` would otherwise miss them.
if "prediction_metrics" in tables:
pm_cols = {row[1] for row in db.execute("PRAGMA table_info(prediction_metrics)").fetchall()}
if "task_type" in pm_cols:
db.execute(
"UPDATE prediction_metrics SET task_type = 'classification' "
"WHERE task_type IN (1, 1.0, '1', '1.0')"
)
db.execute(
"UPDATE prediction_metrics SET task_type = 'regression' "
"WHERE task_type IN (0, 0.0, '0', '0.0')"
)
if "fold_metrics" in tables:
fm_cols = {row[1] for row in db.execute("PRAGMA table_info(fold_metrics)").fetchall()}
if "task_type" in fm_cols:
db.execute(
"UPDATE fold_metrics SET task_type = 'classification' "
"WHERE task_type IN (1, 1.0, '1', '1.0')"
)
db.execute(
"UPDATE fold_metrics SET task_type = 'regression' "
"WHERE task_type IN (0, 0.0, '0', '0.0')"
)
db.commit()
# ---------------------------------------------------------------------------
# Migration 3: tall (metric-per-row) → wide (metric-as-column) pivot
# ---------------------------------------------------------------------------
_TALL_TO_WIDE_TABLES = {
"prediction_metrics": {
"key_cols": ["prediction_hash"],
"metrics": [
"ic_mean",
"ic_std",
"ic_t",
"n_folds",
"n_obs",
"n_periods",
"pct_positive",
"task_type",
"accuracy",
"balanced_accuracy",
"auc_roc",
"auc_pr",
"log_loss",
"brier_score",
"dml_effect",
"dml_se_hac",
"p_value_hac",
"naive_effect",
"confounding_bias_pct",
"refutation_p",
"ate",
"ate_se",
],
},
"fold_metrics": {
"key_cols": ["prediction_hash", "fold_id"],
"metrics": [
"ic",
"ic_std",
"n_periods",
"n_obs",
"n_entities",
"rmse",
"mae",
"accuracy",
"balanced_accuracy",
"auc_roc",
"auc_pr",
"log_loss",
"brier_score",
"auc_class_-1",
"auc_class_0",
"auc_class_1",
],
},
"backtest_metrics": {
"key_cols": ["backtest_hash"],
"metrics": [
"sharpe",
"sortino",
"total_return",
"max_drawdown",
"cagr",
"volatility",
"calmar",
"omega",
"stability",
"tail_ratio",
"win_rate",
"kurtosis",
"skewness",
"var_95",
"cvar_95",
"n_periods",
"num_trades",
"total_commission",
"total_slippage",
"avg_turnover",
],
},
"backtest_fold_metrics": {
"key_cols": ["backtest_hash", "fold_id"],
"metrics": [
"sharpe",
"sortino",
"total_return",
"max_drawdown",
"cagr",
"volatility",
"calmar",
"omega",
"stability",
"tail_ratio",
"win_rate",
"kurtosis",
"skewness",
"var_95",
"cvar_95",
"n_days",
],
},
}
def _migrate_tall_to_wide(db: sqlite3.Connection) -> None:
"""Pivot all 4 metric tables from tall (metric-per-row) to wide (metric-as-column).
Detects any metric names in the data that aren't in the predefined list
and adds them as columns automatically.
"""
logger.info("Migrating metric tables from tall to wide format...")
for table, spec in _TALL_TO_WIDE_TABLES.items():
tall_table = f"_{table}_tall"
key_cols = spec["key_cols"]
known_metrics = spec["metrics"]
# Check if this table still has tall format
if not _table_has_column(db, table, "metric"):
continue
# Discover any metric names in the data not in our predefined list
existing_metrics = {
row[0] for row in db.execute(f"SELECT DISTINCT metric FROM {table}").fetchall()
}
extra_metrics = sorted(existing_metrics - set(known_metrics))
all_metrics = known_metrics + extra_metrics
# Rename old table
db.execute(f"ALTER TABLE {table} RENAME TO {tall_table}")
# Drop old user-created indexes (skip autoindexes which can't be dropped)
for idx_row in db.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=?",
(tall_table,),
).fetchall():
if not idx_row[0].startswith("sqlite_autoindex_"):
db.execute(f"DROP INDEX IF EXISTS {idx_row[0]}")
# Build the pivot SELECT
group_cols = ", ".join(key_cols)
case_expressions = []
for m in all_metrics:
case_expressions.append(f"MAX(CASE WHEN metric = '{m}' THEN value END) AS \"{m}\"")
insert_cols = key_cols + ["computed_at"] + [f'"{m}"' for m in all_metrics]
insert_cols_str = ", ".join(insert_cols)
pivot_sql = f"""
INSERT INTO {table} ({insert_cols_str})
SELECT {group_cols}, MAX(computed_at) AS computed_at,
{", ".join(case_expressions)}
FROM {tall_table}
GROUP BY {group_cols}
"""
# Now create the new wide table via the schema script (already in REGISTRY_SCHEMA_SQL),
# but we need to add any extra metric columns first
# The REGISTRY_SCHEMA_SQL will be run AFTER migration by _open_registry,
# so we create the table here manually with the known columns
_create_wide_table(db, table, key_cols, all_metrics)
# Pivot the data
row_count_before = db.execute(f"SELECT COUNT(*) FROM {tall_table}").fetchone()[0]
db.execute(pivot_sql)
row_count_after = db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
logger.info(f" {table}: {row_count_before:,} tall rows → {row_count_after:,} wide rows")
# Drop the old tall table
db.execute(f"DROP TABLE {tall_table}")
logger.info("Migration complete. Run VACUUM to reclaim space.")
def _create_wide_table(
db: sqlite3.Connection,
table: str,
key_cols: list[str],
metric_cols: list[str],
) -> None:
"""Create a wide-format metric table with the given columns."""
# Build column definitions
col_defs = []
for kc in key_cols:
if kc.endswith("_id"):
col_defs.append(f"{kc} INTEGER NOT NULL")
else:
col_defs.append(f"{kc} TEXT NOT NULL")
col_defs.append("computed_at TEXT NOT NULL")
for m in metric_cols:
col_defs.append(f'"{m}" REAL')
pk_cols = ", ".join(key_cols)
all_col_defs = ",\n ".join(col_defs)
sql = f"""CREATE TABLE IF NOT EXISTS {table} (
{all_col_defs},
PRIMARY KEY ({pk_cols})
)"""
db.execute(sql)
# Add index on the first key column for fold tables
if len(key_cols) > 1:
idx_name = f"idx_{table}_{key_cols[0].replace('_hash', '')}"
db.execute(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table}({key_cols[0]})")
# ---------------------------------------------------------------------------
# File I/O helpers
# ---------------------------------------------------------------------------
def _save_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, default=str))
def _save_parquet(path: Path, frame) -> None:
"""Write a DataFrame to parquet, handling pl.Object columns safely.
Polars cannot write ``pl.Object``-typed columns to parquet directly
(the Object dtype is an opaque Python object that parquet has no
schema for). Any Object columns are converted to ``pl.String`` via
per-element ``str()`` before writing. This makes the writer
idempotent for DL learning curves and training logs that sometimes
contain Object-typed diagnostic columns.
"""
path.parent.mkdir(parents=True, exist_ok=True)
if hasattr(frame, "write_parquet"):
# Polars path
try:
import polars as _pl
obj_cols = [c for c in frame.columns if frame[c].dtype == _pl.Object]
if obj_cols:
frame = frame.with_columns(
_pl.col(c).map_elements(str, return_dtype=_pl.String) for c in obj_cols
)
except ImportError: # pragma: no cover
pass
frame.write_parquet(path)
else:
frame.to_parquet(path, index=False)
# ---------------------------------------------------------------------------
# Incremental fold-level persistence (crash safety)
# ---------------------------------------------------------------------------
def flush_fold_predictions(
incr_dir: Path,
config_name: str,
fold: int,
checkpoint_preds: dict[int, np.ndarray],
val_dates: np.ndarray,
val_entities: np.ndarray | None,
y_val: np.ndarray,
date_col: str,
entity_col: str,
) -> None:
"""Write one fold's checkpoint predictions to parquet for crash safety.
Shared by deep_learning, tabular_dl, and darts_forecasting runners.
Handles Object-typed date columns from pandas datetime arrays.
"""
import numpy as np
import polars as pl
dates_series = pl.Series(date_col, val_dates)
if dates_series.dtype == pl.Object:
dates_series = dates_series.map_elements(str, return_dtype=pl.String).str.to_datetime(
strict=False
)
frames = []
for ep, preds in checkpoint_preds.items():
n = len(preds)
entities = val_entities if val_entities is not None else np.array(["unknown"] * n)
df = pl.DataFrame(
{
date_col: dates_series,
entity_col: entities,
"y_true": y_val.astype(np.float64),
"y_score": preds.astype(np.float64),
"fold_id": np.full(n, fold, dtype=np.int32),
"config": [config_name] * n,
"epoch": np.full(n, ep, dtype=np.int32),
}
)
frames.append(df)
if frames:
_save_parquet(incr_dir / f"{config_name}_fold{fold}.parquet", pl.concat(frames))
def flush_fold_training_log(
log_dir: Path,
config_name: str,
fold: int,
epoch_rows: list[dict],
) -> None:
"""Write one fold's per-epoch training log to parquet for crash safety."""
if not epoch_rows:
return
import polars as pl
df = pl.DataFrame(epoch_rows)
_save_parquet(log_dir / f"{config_name}_fold{fold}.parquet", df)
# ---------------------------------------------------------------------------
# Metric insertion
# ---------------------------------------------------------------------------
def _upsert_wide_metrics(
db: sqlite3.Connection,
table: str,
key_values: dict[str, object],
metrics: dict[str, float],
computed_at: str | None = None,
) -> None:
"""Insert or update metric columns in a wide-format metrics table.
Uses native SQLite UPSERT (``ON CONFLICT(key) DO UPDATE``) so partial
writes preserve columns that were not provided. Auto-adds any unknown
metric names as new columns via ``ALTER TABLE``.
"""
if not metrics:
return
if computed_at is None:
computed_at = _utc_now()
# Ensure all metric columns exist + record their declared SQLite type so we
# don't blindly cast strings (e.g. ``task_type='regression'``) to float.
col_types = {
row[1]: (row[2] or "").upper()
for row in db.execute(f"PRAGMA table_info({table})").fetchall()
}
for metric_name, metric_value in metrics.items():
if metric_name not in col_types:
col_type = "TEXT" if isinstance(metric_value, str) else "REAL"
db.execute(f'ALTER TABLE {table} ADD COLUMN "{metric_name}" {col_type}')
col_types[metric_name] = col_type
def _coerce(name: str, v):
if v is None:
return None
if col_types.get(name, "REAL").startswith("TEXT"):
return str(v)
# Strings stored in non-TEXT columns: try float, else pass through.
if isinstance(v, str):
try:
return float(v)
except ValueError:
return v
try:
return float(v)
except (TypeError, ValueError):
return v
insert_cols = list(key_values.keys()) + ["computed_at"] + list(metrics.keys())
insert_vals = (
list(key_values.values())
+ [computed_at]
+ [_coerce(name, v) for name, v in metrics.items()]
)
quoted_cols = ", ".join(f'"{c}"' for c in insert_cols)
placeholders = ", ".join("?" for _ in insert_cols)
# Conflict target = the primary-key columns the caller supplied
conflict_cols = ", ".join(f'"{c}"' for c in key_values.keys())
update_clause = ", ".join(f'"{c}" = excluded."{c}"' for c in ["computed_at", *metrics.keys()])
db.execute(
f"INSERT INTO {table} ({quoted_cols}) VALUES ({placeholders}) "
f"ON CONFLICT({conflict_cols}) DO UPDATE SET {update_clause}",
insert_vals,
)