copper-mind / scripts /tft_checkpoint_study.py
ifieryarrows's picture
Sync from GitHub (tests passed)
d9ab40d verified
Raw History Blame Contribute Delete
13.6 kB
"""Evaluate saved TFT checkpoints on one immutable validation/test snapshot.
This is a diagnostic tool: checkpoint ranking is computed from validation
metrics only. Test metrics are printed separately to measure whether the
validation rule transfers; they never participate in the ranking.
"""
from __future__ import annotations
import argparse
import copy
import json
from dataclasses import replace
from pathlib import Path
import numpy as np
import pandas as pd
import torch
from app.quality_gate import evaluate_quality_gate_metrics
from deep_learning.config import get_tft_config
from deep_learning.data.dataset import build_datasets, create_dataloaders
from deep_learning.models.tft_copper import load_tft_model
from deep_learning.training.direction_model import (
apply_weekly_direction_model,
predict_weekly_direction,
)
from deep_learning.training.metrics import (
apply_weekly_sign_correction_np,
cumulative_horizon,
directional_accuracy,
fit_direction_sign_calibration,
fit_weekly_interval_scale,
)
from deep_learning.training.trainer import (
WEEKLY_INTERVAL_CONDITIONING_FEATURE,
_build_uniform_checkpoint_soup,
_compute_test_metrics_from_quantiles,
_validate_quantile_prediction_shape,
_weekly_interval_conditioning_values,
)
def _actual_path(dataloader) -> np.ndarray:
parts = [
batch[1][0] if isinstance(batch[1], (list, tuple)) else batch[1]
for batch in dataloader
]
return torch.cat(parts).cpu().numpy()
def _predict_quantiles(model, dataloader, cfg) -> np.ndarray:
prediction = model.predict(
dataloader,
mode="quantiles",
trainer_kwargs={"logger": False, "enable_checkpointing": False},
)
prediction_np = prediction.cpu().numpy()
_validate_quantile_prediction_shape(prediction_np, cfg)
return prediction_np
def _prepare_candidate(
*,
prediction: np.ndarray,
actual: np.ndarray,
weekly_direction_model: dict,
direction_probability: np.ndarray,
conditioning_values: np.ndarray | None,
cfg,
) -> tuple[np.ndarray, dict, dict, dict, dict]:
direction_calibration = fit_direction_sign_calibration(
actual,
prediction,
horizon=cfg.forecast.primary_horizon_days,
)
oriented = prediction * int(
direction_calibration.get("direction_sign_multiplier", 1)
)
oriented = apply_weekly_sign_correction_np(
oriented,
float(direction_calibration.get("weekly_sign_threshold", 0.0)),
horizon=cfg.forecast.primary_horizon_days,
)
selected_direction_model = copy.deepcopy(weekly_direction_model)
selected_direction_model["enabled"] = False
selected_direction_model["reason"] = "validation_selection_rejected"
if selected_direction_model.get("coef") and len(direction_probability) == len(
oriented
):
candidate = apply_weekly_direction_model(
oriented,
direction_probability,
threshold=float(
selected_direction_model.get("decision_threshold", 0.50)
),
horizon=cfg.forecast.primary_horizon_days,
)
actual_weekly = cumulative_horizon(
actual,
horizon=cfg.forecast.primary_horizon_days,
)
median_idx = len(cfg.model.quantiles) // 2
base_da = directional_accuracy(
actual_weekly,
cumulative_horizon(
oriented[:, :, median_idx],
horizon=cfg.forecast.primary_horizon_days,
),
)
candidate_da = directional_accuracy(
actual_weekly,
cumulative_horizon(
candidate[:, :, median_idx],
horizon=cfg.forecast.primary_horizon_days,
),
)
candidate_rate = float(np.mean(direction_probability >= 0.50))
selected_direction_model.update(
{
"validation_base_weekly_da": float(base_da),
"validation_weekly_da": float(candidate_da),
"validation_pred_positive_rate": candidate_rate,
}
)
if (
candidate_da >= 0.51
and candidate_da >= base_da + 0.01
and 0.25 <= candidate_rate <= 0.75
):
selected_direction_model["enabled"] = True
selected_direction_model["reason"] = "validation_improved"
oriented = candidate
interval_calibration = fit_weekly_interval_scale(
actual,
oriented,
quantiles=tuple(cfg.model.quantiles),
horizon=cfg.forecast.primary_horizon_days,
weekly_median_cap=cfg.weekly_loss.weekly_median_cap,
conditioning_values=conditioning_values,
conditioning_feature=(
WEEKLY_INTERVAL_CONDITIONING_FEATURE
if conditioning_values is not None
else None
),
)
metrics = _compute_test_metrics_from_quantiles(
actual,
oriented,
cfg,
weekly_interval_scale=float(
interval_calibration.get("weekly_interval_scale", 1.0)
),
weekly_interval_calibration=interval_calibration,
weekly_interval_conditioning_values=conditioning_values,
)
return (
oriented,
metrics,
direction_calibration,
interval_calibration,
selected_direction_model,
)
def _validation_rank(metrics: dict, checkpoint_loss: float) -> tuple:
"""Rank without test labels: fewer contract misses, then safer margins."""
_passed, reasons = evaluate_quality_gate_metrics(metrics)
# Proper interval score and bounded scale are continuous tie-breakers.
return (
len(reasons),
float(metrics["weekly_pi80_interval_score"]),
float(metrics["weekly_mae_vs_naive_zero"]),
-float(metrics["weekly_sharpe_ratio"]),
float(checkpoint_loss),
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("artifact_dir", type=Path)
parser.add_argument("--output", type=Path, help="Write JSON results to this path")
parser.add_argument("--include-soups", action="store_true")
parser.add_argument("--candidate", help="Evaluate only one checkpoint filename")
parser.add_argument(
"--weekly-median-cap-factor",
type=float,
default=1.0,
help="Diagnostic multiplier applied to the recorded weekly median cap",
)
args = parser.parse_args()
artifact_dir = args.artifact_dir.resolve()
snapshot_path = artifact_dir / "feature_snapshot.pkl"
snapshot_meta = json.loads(
snapshot_path.with_suffix(".pkl.json").read_text(encoding="utf-8")
)
run_meta = json.loads(
(artifact_dir / "tft_metadata.json").read_text(encoding="utf-8")
)
frame = pd.read_pickle(snapshot_path)
cfg = get_tft_config()
effective_weekly_cap = (
float(run_meta["config"]["weekly_median_cap"])
* args.weekly_median_cap_factor
)
if effective_weekly_cap <= 0.0:
raise ValueError("The effective weekly median cap must be positive")
cfg = replace(
cfg,
weekly_loss=replace(
cfg.weekly_loss,
weekly_median_cap=effective_weekly_cap,
),
)
training_ds, validation_ds, test_ds = build_datasets(
frame,
snapshot_meta["time_varying_unknown_reals"],
snapshot_meta["time_varying_known_reals"],
snapshot_meta["target_cols"],
cfg,
)
_, val_dl, test_dl = create_dataloaders(
training_ds,
validation_ds,
test_ds,
cfg,
)
val_actual = _actual_path(val_dl)
test_actual = _actual_path(test_dl)
n_rows = len(frame)
test_size = int(n_rows * cfg.training.test_ratio)
val_size = int(n_rows * cfg.training.val_ratio)
train_size = n_rows - val_size - test_size
train_cutoff = int(frame["time_idx"].iloc[train_size - 1])
val_cutoff = int(frame["time_idx"].iloc[train_size + val_size - 1])
max_time_idx = int(frame["time_idx"].iloc[-1])
base_direction_model = run_meta["weekly_direction_model"]
val_direction_probability = predict_weekly_direction(
base_direction_model,
frame,
start_exclusive=train_cutoff - 1,
end_inclusive=val_cutoff - cfg.forecast.primary_horizon_days,
)
test_direction_probability = predict_weekly_direction(
base_direction_model,
frame,
start_exclusive=val_cutoff - 1,
end_inclusive=max_time_idx - cfg.forecast.primary_horizon_days,
)
val_conditioning = _weekly_interval_conditioning_values(
frame,
feature_name=WEEKLY_INTERVAL_CONDITIONING_FEATURE,
start_exclusive=train_cutoff - 1,
end_inclusive=val_cutoff - cfg.forecast.primary_horizon_days,
expected_count=len(val_actual),
)
test_conditioning = _weekly_interval_conditioning_values(
frame,
feature_name=WEEKLY_INTERVAL_CONDITIONING_FEATURE,
start_exclusive=val_cutoff - 1,
end_inclusive=max_time_idx - cfg.forecast.primary_horizon_days,
expected_count=len(test_actual),
)
checkpoint_specs = []
checkpoints = sorted(
(artifact_dir / "checkpoints").glob("tft-asro-*.ckpt"),
key=lambda path: float(path.stem.rsplit("=", 1)[-1]),
)
for checkpoint in checkpoints:
checkpoint_specs.append(
(checkpoint, float(checkpoint.stem.rsplit("=", 1)[-1]))
)
if args.include_soups:
for count in range(2, len(checkpoints) + 1):
source = checkpoints[:count]
soup_path = (
artifact_dir
/ "checkpoints"
/ f"validation-top-{count}-soup.ckpt"
)
_build_uniform_checkpoint_soup(source, soup_path)
checkpoint_specs.append(
(
soup_path,
float(
np.mean(
[
float(path.stem.rsplit("=", 1)[-1])
for path in source
]
)
),
)
)
if args.candidate:
checkpoint_specs = [
spec for spec in checkpoint_specs if spec[0].name == args.candidate
]
if not checkpoint_specs:
raise FileNotFoundError(
f"Checkpoint candidate not found: {args.candidate}"
)
rows = []
for checkpoint, loss in checkpoint_specs:
model = load_tft_model(str(checkpoint))
val_prediction = _predict_quantiles(model, val_dl, cfg)
(
_,
val_metrics,
direction_calibration,
interval_calibration,
selected_direction_model,
) = _prepare_candidate(
prediction=val_prediction,
actual=val_actual,
weekly_direction_model=base_direction_model,
direction_probability=val_direction_probability,
conditioning_values=val_conditioning,
cfg=cfg,
)
test_prediction = _predict_quantiles(model, test_dl, cfg)
test_prediction *= int(
direction_calibration.get("direction_sign_multiplier", 1)
)
test_prediction = apply_weekly_sign_correction_np(
test_prediction,
float(direction_calibration.get("weekly_sign_threshold", 0.0)),
horizon=cfg.forecast.primary_horizon_days,
)
if selected_direction_model.get("enabled"):
test_prediction = apply_weekly_direction_model(
test_prediction,
test_direction_probability,
threshold=float(
selected_direction_model.get("decision_threshold", 0.50)
),
horizon=cfg.forecast.primary_horizon_days,
)
test_metrics = _compute_test_metrics_from_quantiles(
test_actual,
test_prediction,
cfg,
weekly_interval_scale=float(
interval_calibration["weekly_interval_scale"]
),
weekly_interval_calibration=interval_calibration,
weekly_interval_conditioning_values=test_conditioning,
)
rank = _validation_rank(val_metrics, loss)
validation_passed, validation_reasons = evaluate_quality_gate_metrics(
val_metrics
)
test_passed, test_reasons = evaluate_quality_gate_metrics(test_metrics)
rows.append(
{
"checkpoint": checkpoint.name,
"checkpoint_loss": loss,
"weekly_median_cap_factor": args.weekly_median_cap_factor,
"effective_weekly_median_cap": effective_weekly_cap,
"validation_rank": rank,
"validation_gate_passed": validation_passed,
"validation_gate_reasons": validation_reasons,
"validation": val_metrics,
"test_diagnostic_gate_passed": test_passed,
"test_diagnostic_gate_reasons": test_reasons,
"test_diagnostic": test_metrics,
}
)
rows.sort(key=lambda row: row["validation_rank"])
rendered = json.dumps(rows, indent=2, default=float)
if args.output is not None:
args.output.write_text(rendered + "\n", encoding="utf-8")
else:
print(rendered)
if __name__ == "__main__":
main()