tmmycruise's picture
Publish final forecasting-v3 three-seed checkpoint bundle
d31670b verified
Raw
History Blame Contribute Delete
50.3 kB
"""Train one forecasting-v7 seed and export its immutable development sheet."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import random
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import numpy as np
import torch
from torch import nn
from project.data.close_distribution_v3 import (
ADAPTER_VERSION,
CANONICAL_HORIZONS,
CLASS_COUNT,
DEVELOPMENT_START,
DEVELOPMENT_TEST,
EVALUATION_HORIZONS,
MINUTE_NS,
RESEARCH_END,
SELECTION_ASSETS,
TOKEN_FEATURE_NAMES,
TRAIN_END,
VALIDATION_END,
)
from project.data.reference_level_features import (
materialize_reference_features,
)
from project.evaluators.close_distribution_v2 import (
ClassMap,
class_map_to_arrays,
encode_returns,
fit_class_map,
)
from project.experiments.forecasting_v4.runner import (
FEATURE_SETS,
_context,
_load_npz,
_masked_nll,
_normalization,
_optimizer,
_ordinal,
)
from project.experiments.forecasting_v5.runner import (
_asset_balanced_loss_part,
_balanced_epoch_chunks,
)
from project.models.close_distribution_v4 import (
TypedAuxiliaryTransformer,
build_typed_auxiliary_model,
)
RUNNER_VERSION = "forecasting-v7.0"
FEATURE_SET = "session_time"
FEATURE_NAMES = FEATURE_SETS[FEATURE_SET]
AUXILIARY_FEATURE_NAMES = (
"prior_5_session_net_return_bps",
"prior_5_session_rms_observed_return_bps",
"prior_5_session_range_width_bps",
"prior_5_session_terminal_drawdown_bps",
"prior_5_session_available",
"prior_20_session_net_return_bps",
"prior_20_session_rms_observed_return_bps",
"prior_20_session_range_width_bps",
"prior_20_session_terminal_drawdown_bps",
"prior_20_session_available",
"distance_to_session_open_bps",
"distance_to_session_max_close_so_far_bps",
"distance_to_session_min_close_so_far_bps",
"session_range_position_so_far",
"session_history_available",
)
AUXILIARY_MASK_INDICES = (4, 9, 14)
DEFAULT_EPOCHS = 16
DEFAULT_CONTEXT_LENGTH = 128
DEFAULT_TARGET_PARAMETERS = 25_000
DEFAULT_ROTARY_BASE = 16.0
DEFAULT_BATCH_SIZE = 512
DEFAULT_GRADIENT_ACCUMULATION = 8
DEFAULT_INFERENCE_BATCH_SIZE = 1_024
PROVENANCE_PATHS = (
"project/data/close_distribution_v3.py",
"project/data/reference_level_features.py",
"project/evaluators/close_distribution_v2.py",
"project/experiments/forecasting_v4/runner.py",
"project/experiments/forecasting_v5/runner.py",
"project/experiments/forecasting_v7/runner.py",
"project/models/close_distribution_v3.py",
"project/models/close_distribution_v4.py",
"project/studies/forecasting_v7.md",
"requirements.txt",
)
@dataclass
class AssetPhaseData:
asset: str
asset_id: int
features: dict[str, np.ndarray]
bundle: dict[str, np.ndarray]
center: np.ndarray
scale: np.ndarray
auxiliary_center: np.ndarray
auxiliary_scale: np.ndarray
auxiliary: np.ndarray
class_map: ClassMap
train_rows: np.ndarray
train_labels: np.ndarray
train_target_mask: np.ndarray
validation_rows: np.ndarray
validation_labels: np.ndarray
validation_target_mask: np.ndarray
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _configure(seed: int, threads: int) -> None:
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.set_num_threads(threads)
torch.use_deterministic_algorithms(True)
def _selected_feature_indices() -> tuple[int, ...]:
if tuple(FEATURE_NAMES) != (
"close_log_return_since_last_observed_close",
"close_return_observed",
"log1p_elapsed_wall_clock_minutes_since_last_observed_close",
"regular_session_progress",
"regular_session_progress_sin",
"regular_session_progress_cos",
):
raise ValueError("the frozen v7 sequence feature set changed")
return tuple(TOKEN_FEATURE_NAMES.index(name) for name in FEATURE_NAMES)
def _canonical_indices() -> tuple[int, ...]:
lookup = {
int(value): index
for index, value in enumerate(EVALUATION_HORIZONS)
}
return tuple(lookup[int(value)] for value in CANONICAL_HORIZONS)
def _load_selection_data(
study_dir: Path,
) -> tuple[
dict[str, Any],
dict[str, tuple[dict[str, np.ndarray], dict[str, np.ndarray]]],
]:
manifest_path = study_dir / "data" / "prepared-manifest.json"
manifest = json.loads(manifest_path.read_text())
if manifest.get("adapter_version") != ADAPTER_VERSION:
raise ValueError("v7 prepared data has the wrong adapter version")
if manifest.get("scope") != "selection":
raise ValueError("v7 accepts only selection-scope prepared data")
if tuple(manifest.get("selection_assets", ())) != SELECTION_ASSETS:
raise ValueError("v7 selection assets changed")
source = manifest.get("source_snapshot", {})
if source.get("research_end_exclusive") != RESEARCH_END.isoformat():
raise ValueError("v7 prepared data has the wrong research boundary")
if source.get("sealed_start") != RESEARCH_END.isoformat():
raise ValueError("v7 sealed boundary is inconsistent")
loaded = {}
for asset in SELECTION_ASSETS:
slug = asset.lower()
feature_path = study_dir / "data" / "runner" / f"{slug}_features.npz"
label_path = study_dir / "data" / "runner" / f"{slug}_labels.npz"
for path in (feature_path, label_path):
relative = str(path.relative_to(study_dir))
expected = manifest["derived_sha256"].get(relative)
if expected is None or _sha256(path) != expected:
raise ValueError(f"v7 prepared hash mismatch: {relative}")
loaded[asset] = (_load_npz(feature_path), _load_npz(label_path))
return manifest, loaded
def _raw_auxiliary(features: dict[str, np.ndarray]) -> np.ndarray:
bundle = materialize_reference_features(features)
result = np.zeros(
(len(bundle.generic_values), len(AUXILIARY_FEATURE_NAMES)),
dtype=np.float32,
)
result[:, :10] = bundle.generic_values[:, 5:15]
result[:, 10:15] = bundle.session_values
if not np.isfinite(result).all():
raise ValueError("v7 auxiliary features contain nonfinite values")
return result
def _fit_auxiliary_scaler(
values: np.ndarray,
train_rows: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
center = np.zeros(values.shape[1], dtype=np.float64)
scale = np.ones(values.shape[1], dtype=np.float64)
selected = values[train_rows].astype(np.float64, copy=False)
for window_index, mask_index in enumerate(AUXILIARY_MASK_INDICES):
available = selected[:, mask_index] > 0.5
if not np.any(available):
continue
start = window_index * 5
for feature_index in range(start, start + 4):
observed = selected[available, feature_index]
median = float(np.median(observed))
q25, q75 = np.quantile(observed, (0.25, 0.75))
robust_scale = float((q75 - q25) / 1.349)
if not np.isfinite(robust_scale) or robust_scale < 1e-8:
robust_scale = float(np.std(observed))
if not np.isfinite(robust_scale) or robust_scale < 1e-8:
robust_scale = 1.0
center[feature_index] = median
scale[feature_index] = robust_scale
return center.astype(np.float32), scale.astype(np.float32)
def _scale_auxiliary(
values: np.ndarray,
center: np.ndarray,
scale: np.ndarray,
) -> np.ndarray:
result = np.zeros_like(values, dtype=np.float32)
for window_index, mask_index in enumerate(AUXILIARY_MASK_INDICES):
available = values[:, mask_index] > 0.5
start = window_index * 5
result[available, start : start + 4] = (
values[available, start : start + 4]
- center[None, start : start + 4]
) / scale[None, start : start + 4]
result[:, mask_index] = values[:, mask_index]
if not np.isfinite(result).all():
raise ValueError("scaled v7 auxiliary features contain nonfinite values")
return result
def _date_mask(
dates: np.ndarray,
interval: tuple[str, str],
) -> np.ndarray:
return (
(dates >= _ordinal(interval[0]))
& (dates < _ordinal(interval[1]))
)
def _prepare_phase_data(
*,
asset: str,
asset_id: int,
features: dict[str, np.ndarray],
bundle: dict[str, np.ndarray],
train_interval: tuple[str, str],
validation_interval: tuple[str, str] | None,
selected_indices: tuple[int, ...],
canonical_indices: tuple[int, ...],
max_train_rows_per_asset: int,
) -> AssetPhaseData:
dates = bundle["session_date"]
train = _date_mask(dates, train_interval)
if not np.any(train):
raise ValueError(f"{asset} has no v7 training rows")
train_rows = bundle["row_index"][train]
train_y = bundle["y"][train]
train_target_mask = bundle["target_mask"][train]
if (
max_train_rows_per_asset > 0
and len(train_rows) > max_train_rows_per_asset
):
selected = np.linspace(
0,
len(train_rows) - 1,
max_train_rows_per_asset,
dtype=np.int64,
)
train_rows = train_rows[selected]
train_y = train_y[selected]
train_target_mask = train_target_mask[selected]
class_map = fit_class_map(
train_y,
train_target_mask,
class_count=CLASS_COUNT,
)
all_train_labels = encode_returns(
train_y,
train_target_mask,
class_map.edges,
)
center, scale = _normalization(
features,
train_end=train_interval[1],
selected_indices=selected_indices,
)
raw_auxiliary = _raw_auxiliary(features)
auxiliary_center, auxiliary_scale = _fit_auxiliary_scaler(
raw_auxiliary,
train_rows,
)
auxiliary = _scale_auxiliary(
raw_auxiliary,
auxiliary_center,
auxiliary_scale,
)
if validation_interval is None:
validation_rows = np.empty(0, dtype=np.int64)
validation_labels = np.empty(
(0, len(canonical_indices)),
dtype=np.int16,
)
validation_target_mask = np.empty(
(0, len(canonical_indices)),
dtype=np.bool_,
)
else:
validation = _date_mask(dates, validation_interval)
validation_rows = bundle["row_index"][validation]
validation_target_mask = bundle["target_mask"][validation][
:, canonical_indices
]
validation_labels = encode_returns(
bundle["y"][validation],
bundle["target_mask"][validation],
class_map.edges,
)[:, canonical_indices]
if not len(validation_rows):
raise ValueError(f"{asset} has no v7 validation rows")
return AssetPhaseData(
asset=asset,
asset_id=asset_id,
features=features,
bundle=bundle,
center=center,
scale=scale,
auxiliary_center=auxiliary_center,
auxiliary_scale=auxiliary_scale,
auxiliary=auxiliary,
class_map=class_map,
train_rows=train_rows,
train_labels=all_train_labels[:, canonical_indices],
train_target_mask=train_target_mask[:, canonical_indices],
validation_rows=validation_rows,
validation_labels=validation_labels,
validation_target_mask=validation_target_mask,
)
def _build_model(
checkpoint: dict[str, Any],
*,
device: torch.device,
) -> TypedAuxiliaryTransformer:
if tuple(checkpoint.get("assets", ())) != SELECTION_ASSETS:
raise ValueError("locked checkpoint has the wrong assets")
if tuple(checkpoint.get("feature_names", ())) != tuple(FEATURE_NAMES):
raise ValueError("locked checkpoint has the wrong sequence features")
if tuple(checkpoint.get("supervised_horizons", ())) != tuple(
int(value) for value in CANONICAL_HORIZONS
):
raise ValueError("locked checkpoint has the wrong horizons")
if checkpoint.get("asset_conditioning") != "early_add":
raise ValueError("locked checkpoint is not early-add")
if int(checkpoint.get("context_length", -1)) != DEFAULT_CONTEXT_LENGTH:
raise ValueError("locked checkpoint has the wrong context length")
if float(checkpoint.get("rotary_base", -1.0)) != DEFAULT_ROTARY_BASE:
raise ValueError("locked checkpoint has the wrong RoPE base")
model = build_typed_auxiliary_model(
context_length=DEFAULT_CONTEXT_LENGTH,
channels=len(FEATURE_NAMES) + 1,
output_horizons=tuple(
float(value) for value in CANONICAL_HORIZONS
),
classes=CLASS_COUNT,
target_parameters=DEFAULT_TARGET_PARAMETERS,
rotary_base=DEFAULT_ROTARY_BASE,
asset_count=len(SELECTION_ASSETS),
asset_conditioning="early_add",
auxiliary_dim=len(AUXILIARY_FEATURE_NAMES),
)
incompatible = model.load_state_dict(
checkpoint["state_dict"],
strict=False,
)
if set(incompatible.missing_keys) != {"auxiliary_affine.weight"}:
raise ValueError(
f"unexpected missing checkpoint keys: {incompatible.missing_keys}"
)
if incompatible.unexpected_keys:
raise ValueError(
f"unexpected checkpoint keys: {incompatible.unexpected_keys}"
)
return model.to(device)
def _training_microbatch(
data: tuple[AssetPhaseData, ...],
asset_ids: np.ndarray,
positions: np.ndarray,
*,
selected_indices: tuple[int, ...],
) -> tuple[
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
torch.Tensor,
]:
tokens = []
padding = []
auxiliary = []
labels = []
masks = []
ids = []
for item in data:
selected = asset_ids == item.asset_id
if not np.any(selected):
continue
local = positions[selected]
rows = item.train_rows[local]
context, context_padding = _context(
item.features["X"],
rows,
context_length=DEFAULT_CONTEXT_LENGTH,
selected_indices=selected_indices,
center=item.center,
scale=item.scale,
)
tokens.append(context)
padding.append(context_padding)
auxiliary.append(torch.from_numpy(item.auxiliary[rows]))
labels.append(
torch.from_numpy(
item.train_labels[local].astype(np.int64, copy=False)
)
)
masks.append(
torch.from_numpy(
item.train_target_mask[local].astype(
np.float32,
copy=False,
)
)
)
ids.append(
torch.full(
(len(local),),
item.asset_id,
dtype=torch.long,
)
)
return (
torch.cat(tokens),
torch.cat(padding),
torch.cat(auxiliary),
torch.cat(labels),
torch.cat(masks),
torch.cat(ids),
)
def _predict(
model: TypedAuxiliaryTransformer,
item: AssetPhaseData,
*,
rows: np.ndarray,
selected_indices: tuple[int, ...],
batch_size: int,
device: torch.device,
query_horizons: Iterable[int] | None,
) -> np.ndarray:
horizons = (
tuple(int(value) for value in model.output_horizons.cpu().numpy())
if query_horizons is None
else tuple(int(value) for value in query_horizons)
)
result = np.empty(
(len(rows), len(horizons), CLASS_COUNT),
dtype=np.float32,
)
query = (
None
if query_horizons is None
else torch.tensor(horizons, dtype=torch.float32, device=device)
)
model.eval()
with torch.inference_mode():
for start in range(0, len(rows), batch_size):
stop = min(start + batch_size, len(rows))
local_rows = rows[start:stop]
tokens, padding = _context(
item.features["X"],
local_rows,
context_length=DEFAULT_CONTEXT_LENGTH,
selected_indices=selected_indices,
center=item.center,
scale=item.scale,
)
asset_id = torch.full(
(stop - start,),
item.asset_id,
dtype=torch.long,
device=device,
)
logits = model(
tokens.to(device),
padding.to(device),
asset_id,
query,
torch.from_numpy(item.auxiliary[local_rows]).to(device),
)
result[start:stop] = torch.softmax(logits, dim=2).cpu().numpy()
if not np.isfinite(result).all():
raise ValueError("v7 prediction contains nonfinite values")
if not np.allclose(result.sum(axis=2), 1.0, atol=2e-6, rtol=0.0):
raise ValueError("v7 probabilities do not sum to one")
return result
def _validation_nll(
model: TypedAuxiliaryTransformer,
data: tuple[AssetPhaseData, ...],
*,
selected_indices: tuple[int, ...],
inference_batch_size: int,
device: torch.device,
) -> tuple[float, dict[str, float]]:
by_asset = {}
for item in data:
probabilities = _predict(
model,
item,
rows=item.validation_rows,
selected_indices=selected_indices,
batch_size=inference_batch_size,
device=device,
query_horizons=None,
)
by_asset[item.asset] = _masked_nll(
probabilities,
item.validation_labels,
item.validation_target_mask,
)
return float(np.mean(list(by_asset.values()))), by_asset
def _train(
model: TypedAuxiliaryTransformer,
data: tuple[AssetPhaseData, ...],
*,
seed: int,
epochs: int,
batch_size: int,
gradient_accumulation_steps: int,
inference_batch_size: int,
selected_indices: tuple[int, ...],
device: torch.device,
select_on_validation: bool,
) -> dict[str, Any]:
optimizer = _optimizer(model, "adamw_constant")
effective_batch_size = batch_size * gradient_accumulation_steps
generator = np.random.default_rng(seed + 10_007)
train_history = []
validation_history = []
best_epoch = 0
best_nll = float("inf")
best_state: dict[str, torch.Tensor] | None = None
updates_per_epoch = None
total_updates = 0
for epoch in range(epochs):
epoch_started = time.monotonic()
model.train()
updates = _balanced_epoch_chunks(
tuple(len(item.train_rows) for item in data),
effective_batch_size=effective_batch_size,
generator=generator,
)
if updates_per_epoch is None:
updates_per_epoch = len(updates)
elif updates_per_epoch != len(updates):
raise AssertionError("v7 updates per epoch changed")
epoch_loss_sum = np.zeros(
(len(data), len(CANONICAL_HORIZONS)),
dtype=np.float64,
)
epoch_target_count = np.zeros_like(
epoch_loss_sum,
dtype=np.int64,
)
for chunks in updates:
update_asset_ids = np.concatenate(
[
np.full(len(chunk), index, dtype=np.int64)
for index, chunk in enumerate(chunks)
]
)
update_positions = np.concatenate(chunks)
denominators = np.stack(
[
item.train_target_mask[chunks[index]].sum(axis=0)
for index, item in enumerate(data)
]
).astype(np.float32)
if np.any(denominators == 0):
raise ValueError(
"a v7 asset-balanced update has no target for a horizon"
)
denominator_tensor = torch.from_numpy(denominators).to(device)
optimizer.zero_grad(set_to_none=True)
for start in range(0, len(update_positions), batch_size):
stop = min(start + batch_size, len(update_positions))
(
tokens,
padding,
auxiliary,
labels,
target_mask,
asset_id,
) = _training_microbatch(
data,
update_asset_ids[start:stop],
update_positions[start:stop],
selected_indices=selected_indices,
)
tokens = tokens.to(device)
padding = padding.to(device)
auxiliary = auxiliary.to(device)
labels = labels.to(device)
target_mask = target_mask.to(device)
asset_id = asset_id.to(device)
logits = model(
tokens,
padding,
asset_id,
auxiliary=auxiliary,
)
loss = _asset_balanced_loss_part(
logits,
labels,
target_mask,
asset_id,
denominator_tensor,
)
loss.backward()
with torch.no_grad():
losses = nn.functional.cross_entropy(
logits.reshape(-1, CLASS_COUNT),
labels.clamp_min(0).reshape(-1),
reduction="none",
).reshape(labels.shape)
for asset_index in range(len(data)):
selected = asset_id == asset_index
epoch_loss_sum[asset_index] += (
losses[selected] * target_mask[selected]
).sum(dim=0).cpu().numpy()
epoch_target_count[asset_index] += (
target_mask[selected]
.sum(dim=0)
.cpu()
.numpy()
.astype(np.int64)
)
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_updates += 1
train_nll = float(
np.mean(epoch_loss_sum / epoch_target_count)
)
train_history.append(train_nll)
record: dict[str, Any] = {
"duration_seconds": time.monotonic() - epoch_started,
"epoch": epoch + 1,
"train_nll": train_nll,
}
if select_on_validation:
equal_asset_nll, by_asset = _validation_nll(
model,
data,
selected_indices=selected_indices,
inference_batch_size=inference_batch_size,
device=device,
)
record["equal_asset_validation_nll"] = equal_asset_nll
record["validation_nll_by_asset"] = by_asset
if equal_asset_nll < best_nll:
best_nll = equal_asset_nll
best_epoch = epoch + 1
best_state = {
name: value.detach().cpu().clone()
for name, value in model.state_dict().items()
}
validation_history.append(record)
if select_on_validation:
if best_state is None:
raise AssertionError("v7 validation selected no checkpoint")
model.load_state_dict(best_state)
else:
best_epoch = epochs
if updates_per_epoch is None:
raise AssertionError("v7 training produced no updates")
return {
"best_epoch": best_epoch,
"best_equal_asset_validation_nll": (
best_nll if select_on_validation else None
),
"completed_fixed_epoch_budget": len(train_history) == epochs,
"epoch_history": validation_history,
"train_nll": train_history,
"total_updates": total_updates,
"updates_per_epoch": updates_per_epoch,
}
def _raw_close_vector(
study_dir: Path,
manifest: dict[str, Any],
item: AssetPhaseData,
) -> np.ndarray:
import pyarrow.parquet as pq
record = manifest["source_snapshot"]["boundary"]["assets"][
item.asset
]["outputs"]["bars"]
path = study_dir / record["path"]
if _sha256(path) != record["sha256"]:
raise ValueError(f"{item.asset} raw source hash changed")
table = pq.read_table(
path,
columns=["window_start_ns", "close"],
)
timestamp = table["window_start_ns"].to_numpy(zero_copy_only=False)
close = table["close"].to_numpy(zero_copy_only=False).astype(np.float64)
order = np.argsort(timestamp, kind="stable")
timestamp = timestamp[order]
close = close[order]
grid_timestamp = item.features["timestamp_ns"]
positions = np.searchsorted(timestamp, grid_timestamp)
matched = positions < len(timestamp)
matched[matched] &= timestamp[positions[matched]] == grid_timestamp[matched]
result = np.full(len(grid_timestamp), np.nan, dtype=np.float64)
result[matched] = close[positions[matched]]
observed = item.features["X"][:, 1] > 0.5
if np.any(observed & ~np.isfinite(result)):
raise ValueError(f"{item.asset} observed row lacks a raw close")
return result
def _write_prediction_sheet(
model: TypedAuxiliaryTransformer,
data: tuple[AssetPhaseData, ...],
*,
study_dir: Path,
manifest: dict[str, Any],
output_path: Path,
seed: int,
selected_indices: tuple[int, ...],
inference_batch_size: int,
device: torch.device,
max_development_rows_per_asset: int,
) -> dict[str, Any]:
import pyarrow as pa
import pyarrow.parquet as pq
probability_names = tuple(
f"p{index:02d}" for index in range(CLASS_COUNT)
)
schema_fields = [
pa.field("stable_row_id", pa.uint64()),
pa.field("seed", pa.int16()),
pa.field("asset", pa.string()),
pa.field("asset_id", pa.int8()),
pa.field("anchor_row_index", pa.int64()),
pa.field("anchor_timestamp_ns", pa.int64()),
pa.field("available_at_ns", pa.int64()),
pa.field("session_date", pa.date32()),
pa.field("minute_of_session", pa.int16()),
pa.field("horizon_minutes", pa.int16()),
pa.field("target_valid", pa.bool_()),
pa.field("target_timestamp_ns", pa.int64()),
pa.field("target_available_at_ns", pa.int64()),
pa.field("anchor_raw_close", pa.float64()),
pa.field("target_raw_close", pa.float64()),
pa.field("realized_log_return", pa.float64()),
pa.field("target_class", pa.int16()),
pa.field("expected_return", pa.float32()),
pa.field("expected_positive_probability", pa.float32()),
pa.field("entropy", pa.float32()),
pa.field("max_probability", pa.float32()),
pa.field("top_class", pa.int8()),
]
schema_fields.extend(
pa.field(name, pa.float32()) for name in probability_names
)
schema = pa.schema(schema_fields)
temporary = output_path.with_suffix(output_path.suffix + ".partial")
if output_path.exists() or temporary.exists():
raise FileExistsError(f"prediction sheet already exists: {output_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
writer = pq.ParquetWriter(
temporary,
schema,
compression="zstd",
use_dictionary=("asset",),
)
horizons = EVALUATION_HORIZONS.astype(np.int64)
horizon_count = len(horizons)
by_asset: dict[str, Any] = {}
total_rows = 0
try:
for item in data:
test = _date_mask(item.bundle["session_date"], DEVELOPMENT_TEST)
test_positions = np.flatnonzero(test)
if (
max_development_rows_per_asset > 0
and len(test_positions) > max_development_rows_per_asset
):
selected = np.linspace(
0,
len(test_positions) - 1,
max_development_rows_per_asset,
dtype=np.int64,
)
test_positions = test_positions[selected]
rows = item.bundle["row_index"][test_positions]
returns = item.bundle["y"][test_positions]
target_mask = item.bundle["target_mask"][test_positions]
target_labels = encode_returns(
returns,
target_mask,
item.class_map.edges,
)
raw_close = _raw_close_vector(study_dir, manifest, item)
nll_sum = np.zeros(horizon_count, dtype=np.float64)
nll_count = np.zeros(horizon_count, dtype=np.int64)
asset_output_rows = 0
for start in range(0, len(rows), inference_batch_size):
stop = min(start + inference_batch_size, len(rows))
local_rows = rows[start:stop]
probabilities = _predict(
model,
item,
rows=local_rows,
selected_indices=selected_indices,
batch_size=inference_batch_size,
device=device,
query_horizons=EVALUATION_HORIZONS,
)
count = len(local_rows)
flat_count = count * horizon_count
flat_probabilities = probabilities.reshape(
flat_count,
CLASS_COUNT,
)
local_mask = target_mask[start:stop]
local_returns = returns[start:stop]
local_labels = target_labels[start:stop]
for horizon_index in range(horizon_count):
valid = local_mask[:, horizon_index]
if np.any(valid):
selected_probability = probabilities[
valid,
horizon_index,
local_labels[valid, horizon_index],
]
nll_sum[horizon_index] += float(
-np.log(
np.clip(
selected_probability,
1e-12,
1.0,
)
).sum()
)
nll_count[horizon_index] += int(np.sum(valid))
repeated_rows = np.repeat(local_rows, horizon_count)
tiled_horizons = np.tile(horizons, count)
repeated_mask = local_mask.reshape(-1)
candidate = (
local_rows[:, None] + horizons[None, :]
).reshape(-1)
safe_candidate = np.minimum(
candidate,
len(item.features["timestamp_ns"]) - 1,
)
target_timestamp = item.features["timestamp_ns"][
safe_candidate
].astype(np.int64, copy=True)
target_timestamp[~repeated_mask] = -1
target_close = raw_close[safe_candidate].copy()
target_close[~repeated_mask] = np.nan
anchor_close = np.repeat(
raw_close[local_rows],
horizon_count,
)
flat_returns = local_returns.reshape(-1)
flat_labels = local_labels.reshape(-1)
expected_return = np.einsum(
"nhc,hc->nh",
probabilities.astype(np.float64),
item.class_map.return_means,
).reshape(-1)
expected_positive = np.einsum(
"nhc,hc->nh",
probabilities.astype(np.float64),
item.class_map.positive_rates,
).reshape(-1)
entropy = -np.sum(
probabilities
* np.log(np.clip(probabilities, 1e-12, 1.0)),
axis=2,
).reshape(-1)
stable = (
np.uint64(item.asset_id + 1) * np.uint64(1 << 56)
+ repeated_rows.astype(np.uint64)
)
columns: dict[str, pa.Array] = {
"stable_row_id": pa.array(stable, type=pa.uint64()),
"seed": pa.array(
np.full(flat_count, seed, dtype=np.int16)
),
"asset": pa.array([item.asset] * flat_count),
"asset_id": pa.array(
np.full(
flat_count,
item.asset_id,
dtype=np.int8,
)
),
"anchor_row_index": pa.array(repeated_rows),
"anchor_timestamp_ns": pa.array(
np.repeat(
item.features["timestamp_ns"][local_rows],
horizon_count,
)
),
"available_at_ns": pa.array(
np.repeat(
item.features["available_at_ns"][local_rows],
horizon_count,
)
),
"session_date": pa.array(
np.repeat(
item.features["session_date"][local_rows],
horizon_count,
),
type=pa.date32(),
),
"minute_of_session": pa.array(
np.repeat(
item.features["minute_of_session"][local_rows],
horizon_count,
)
),
"horizon_minutes": pa.array(
tiled_horizons.astype(np.int16)
),
"target_valid": pa.array(repeated_mask),
"target_timestamp_ns": pa.array(
target_timestamp,
mask=~repeated_mask,
),
"target_available_at_ns": pa.array(
target_timestamp + MINUTE_NS,
mask=~repeated_mask,
),
"anchor_raw_close": pa.array(anchor_close),
"target_raw_close": pa.array(
target_close,
mask=~repeated_mask,
),
"realized_log_return": pa.array(
flat_returns,
mask=~repeated_mask,
),
"target_class": pa.array(
flat_labels,
mask=~repeated_mask,
),
"expected_return": pa.array(
expected_return.astype(np.float32)
),
"expected_positive_probability": pa.array(
expected_positive.astype(np.float32)
),
"entropy": pa.array(entropy.astype(np.float32)),
"max_probability": pa.array(
flat_probabilities.max(axis=1).astype(np.float32)
),
"top_class": pa.array(
flat_probabilities.argmax(axis=1).astype(np.int8)
),
}
for class_index, name in enumerate(probability_names):
columns[name] = pa.array(
flat_probabilities[:, class_index]
)
writer.write_table(
pa.Table.from_pydict(columns, schema=schema),
row_group_size=flat_count,
)
asset_output_rows += flat_count
total_rows += flat_count
if np.any(nll_count == 0):
raise ValueError(
f"{item.asset} development sheet lacks a horizon"
)
by_horizon = nll_sum / nll_count
by_asset[item.asset] = {
"anchor_rows": int(len(rows)),
"macro_nll": float(np.mean(by_horizon)),
"nll_by_horizon": {
str(int(horizon)): float(by_horizon[index])
for index, horizon in enumerate(EVALUATION_HORIZONS)
},
"output_rows": asset_output_rows,
"valid_targets_by_horizon": {
str(int(horizon)): int(nll_count[index])
for index, horizon in enumerate(EVALUATION_HORIZONS)
},
}
finally:
writer.close()
os.replace(temporary, output_path)
return {
"by_asset": by_asset,
"equal_asset_macro_nll": float(
np.mean([value["macro_nll"] for value in by_asset.values()])
),
"output_rows": total_rows,
"parquet_bytes": output_path.stat().st_size,
"parquet_sha256": _sha256(output_path),
}
def _write_calibration(
path: Path,
data: tuple[AssetPhaseData, ...],
) -> str:
if path.exists():
raise FileExistsError(f"calibration artifact exists: {path}")
arrays: dict[str, np.ndarray] = {
"evaluation_horizons": EVALUATION_HORIZONS,
"canonical_horizons": CANONICAL_HORIZONS,
}
for item in data:
prefix = item.asset.lower()
arrays[f"{prefix}_token_center"] = item.center
arrays[f"{prefix}_token_scale"] = item.scale
arrays[f"{prefix}_auxiliary_center"] = item.auxiliary_center
arrays[f"{prefix}_auxiliary_scale"] = item.auxiliary_scale
for name, value in class_map_to_arrays(item.class_map).items():
arrays[f"{prefix}_{name}"] = value
np.savez_compressed(path, **arrays)
return _sha256(path)
def _run_manifest(
*,
config: dict[str, Any],
project_root: Path,
) -> dict[str, Any]:
return {
"command": sys.argv,
"config": config,
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"environment": {
"machine": platform.machine(),
"numpy": np.__version__,
"platform": platform.platform(),
"python": sys.version,
"torch": torch.__version__,
"torch_mps_available": torch.backends.mps.is_available(),
},
"provenance_sha256": {
relative: _sha256(project_root / relative)
for relative in PROVENANCE_PATHS
},
"runner_version": RUNNER_VERSION,
}
def run(
*,
study_dir: Path,
checkpoint_dir: Path,
output_dir: Path,
seed: int,
epochs: int = DEFAULT_EPOCHS,
batch_size: int = DEFAULT_BATCH_SIZE,
gradient_accumulation_steps: int = DEFAULT_GRADIENT_ACCUMULATION,
inference_batch_size: int = DEFAULT_INFERENCE_BATCH_SIZE,
torch_threads: int = 6,
device_name: str = "mps",
max_train_rows_per_asset: int = 0,
max_development_rows_per_asset: int = 0,
) -> dict[str, Any]:
started = time.monotonic()
if seed not in (0, 1, 2):
raise ValueError("v7 seed must be 0, 1, or 2")
if epochs <= 0 or batch_size <= 0 or inference_batch_size <= 0:
raise ValueError("v7 training dimensions must be positive")
if gradient_accumulation_steps <= 0:
raise ValueError("v7 gradient accumulation must be positive")
if max_train_rows_per_asset < 0 or max_development_rows_per_asset < 0:
raise ValueError("v7 row caps cannot be negative")
if (
batch_size * gradient_accumulation_steps
!= DEFAULT_BATCH_SIZE * DEFAULT_GRADIENT_ACCUMULATION
):
raise ValueError("v7 effective batch size is frozen at 4096")
if device_name == "mps" and not torch.backends.mps.is_available():
raise ValueError("v7 requested unavailable MPS")
checkpoint_results_path = checkpoint_dir / "results.json"
checkpoint_manifest_path = checkpoint_dir / "run_manifest.json"
checkpoint_path = checkpoint_dir / "discovery_2_model.pt"
checkpoint_results = json.loads(checkpoint_results_path.read_text())
checkpoint_manifest = json.loads(checkpoint_manifest_path.read_text())
if int(checkpoint_results.get("seed", -1)) != seed:
raise ValueError("v7 seed and locked checkpoint seed differ")
expected_checkpoint = {
"asset_conditioning": "early_add",
"context_length": DEFAULT_CONTEXT_LENGTH,
"feature_set": FEATURE_SET,
"max_train_rows_per_asset": 100_000,
"rotary_base": DEFAULT_ROTARY_BASE,
"supervised_horizons": [
int(value) for value in CANONICAL_HORIZONS
],
"target_parameters": DEFAULT_TARGET_PARAMETERS,
}
for key, expected in expected_checkpoint.items():
if checkpoint_results.get(key) != expected:
raise ValueError(
f"locked checkpoint setting {key} differs from v7"
)
manifest_config = checkpoint_manifest.get("config", {})
if manifest_config.get("seed") != seed:
raise ValueError("locked checkpoint manifest has the wrong seed")
manifest, loaded = _load_selection_data(study_dir)
selected_indices = _selected_feature_indices()
canonical_indices = _canonical_indices()
checkpoint = torch.load(
checkpoint_path,
map_location="cpu",
weights_only=False,
)
config = {
"assets": list(SELECTION_ASSETS),
"auxiliary_feature_names": list(AUXILIARY_FEATURE_NAMES),
"batch_size": batch_size,
"checkpoint_dir": str(checkpoint_dir),
"checkpoint_model_sha256": _sha256(checkpoint_path),
"checkpoint_results_sha256": _sha256(checkpoint_results_path),
"context_length": DEFAULT_CONTEXT_LENGTH,
"development_test": list(DEVELOPMENT_TEST),
"device_name": device_name,
"epochs": epochs,
"evaluation_horizons": EVALUATION_HORIZONS.tolist(),
"feature_names": list(FEATURE_NAMES),
"gradient_accumulation_steps": gradient_accumulation_steps,
"inference_batch_size": inference_batch_size,
"max_development_rows_per_asset": (
max_development_rows_per_asset
),
"max_train_rows_per_asset": max_train_rows_per_asset,
"optimizer_recipe": "adamw_constant",
"refit_interval": [
DEVELOPMENT_START.isoformat(),
VALIDATION_END.isoformat(),
],
"rotary_base": DEFAULT_ROTARY_BASE,
"seed": seed,
"snapshot_sha256": manifest["snapshot_sha256"],
"supervised_horizons": CANONICAL_HORIZONS.tolist(),
"target_parameters": DEFAULT_TARGET_PARAMETERS,
"torch_threads": torch_threads,
"train_interval": [
DEVELOPMENT_START.isoformat(),
TRAIN_END.isoformat(),
],
"validation_interval": [
TRAIN_END.isoformat(),
VALIDATION_END.isoformat(),
],
}
output_dir.mkdir(parents=True, exist_ok=True)
manifest_path = output_dir / "run_manifest.json"
results_path = output_dir / "results.json"
prediction_path = output_dir / f"development_predictions_seed{seed}.parquet"
checkpoint_output_path = output_dir / f"model_seed{seed}.pt"
calibration_path = output_dir / f"calibration_seed{seed}.npz"
guarded = (
manifest_path,
results_path,
prediction_path,
checkpoint_output_path,
calibration_path,
)
if any(path.exists() for path in guarded):
raise FileExistsError("v7 output directory is not immutable-empty")
project_root = Path(__file__).resolve().parents[3]
manifest_path.write_text(
json.dumps(
_run_manifest(config=config, project_root=project_root),
indent=2,
sort_keys=True,
)
+ "\n"
)
_configure(seed, torch_threads)
device = torch.device(device_name)
selection_data = tuple(
_prepare_phase_data(
asset=asset,
asset_id=asset_id,
features=loaded[asset][0],
bundle=loaded[asset][1],
train_interval=(
DEVELOPMENT_START.isoformat(),
TRAIN_END.isoformat(),
),
validation_interval=(
TRAIN_END.isoformat(),
VALIDATION_END.isoformat(),
),
selected_indices=selected_indices,
canonical_indices=canonical_indices,
max_train_rows_per_asset=max_train_rows_per_asset,
)
for asset_id, asset in enumerate(SELECTION_ASSETS)
)
selection_model = _build_model(checkpoint, device=device)
selection_training = _train(
selection_model,
selection_data,
seed=seed,
epochs=epochs,
batch_size=batch_size,
gradient_accumulation_steps=gradient_accumulation_steps,
inference_batch_size=inference_batch_size,
selected_indices=selected_indices,
device=device,
select_on_validation=True,
)
selected_epoch = int(selection_training["best_epoch"])
del selection_model, selection_data
if device.type == "mps":
torch.mps.empty_cache()
refit_data = tuple(
_prepare_phase_data(
asset=asset,
asset_id=asset_id,
features=loaded[asset][0],
bundle=loaded[asset][1],
train_interval=(
DEVELOPMENT_START.isoformat(),
VALIDATION_END.isoformat(),
),
validation_interval=None,
selected_indices=selected_indices,
canonical_indices=canonical_indices,
max_train_rows_per_asset=max_train_rows_per_asset,
)
for asset_id, asset in enumerate(SELECTION_ASSETS)
)
refit_model = _build_model(checkpoint, device=device)
refit_training = _train(
refit_model,
refit_data,
seed=seed + 70_001,
epochs=selected_epoch,
batch_size=batch_size,
gradient_accumulation_steps=gradient_accumulation_steps,
inference_batch_size=inference_batch_size,
selected_indices=selected_indices,
device=device,
select_on_validation=False,
)
torch.save(
{
"assets": list(SELECTION_ASSETS),
"auxiliary_feature_names": AUXILIARY_FEATURE_NAMES,
"context_length": DEFAULT_CONTEXT_LENGTH,
"feature_names": FEATURE_NAMES,
"refit_interval": config["refit_interval"],
"rotary_base": DEFAULT_ROTARY_BASE,
"seed": seed,
"selected_epoch": selected_epoch,
"state_dict": {
name: value.detach().cpu()
for name, value in refit_model.state_dict().items()
},
"supervised_horizons": tuple(
int(value) for value in CANONICAL_HORIZONS
),
},
checkpoint_output_path,
)
calibration_sha256 = _write_calibration(
calibration_path,
refit_data,
)
development = _write_prediction_sheet(
refit_model,
refit_data,
study_dir=study_dir,
manifest=manifest,
output_path=prediction_path,
seed=seed,
selected_indices=selected_indices,
inference_batch_size=inference_batch_size,
device=device,
max_development_rows_per_asset=max_development_rows_per_asset,
)
result = {
**config,
"calibration_sha256": calibration_sha256,
"checkpoint_sha256": _sha256(checkpoint_output_path),
"development_test_metrics": development,
"duration_seconds": time.monotonic() - started,
"parameter_count": sum(
parameter.numel()
for parameter in refit_model.parameters()
if parameter.requires_grad
),
"prediction_path": str(prediction_path),
"refit_training": refit_training,
"run_manifest_sha256": _sha256(manifest_path),
"selected_epoch": selected_epoch,
"selection_training": selection_training,
}
results_path.write_text(
json.dumps(result, indent=2, sort_keys=True) + "\n"
)
return result
def main() -> None:
parser = argparse.ArgumentParser(
description="Train one immutable forecasting-v7 ensemble seed."
)
parser.add_argument("--study-dir", type=Path, required=True)
parser.add_argument("--checkpoint-dir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--seed", type=int, choices=(0, 1, 2), required=True)
parser.add_argument("--epochs", type=int, default=DEFAULT_EPOCHS)
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
parser.add_argument(
"--gradient-accumulation-steps",
type=int,
default=DEFAULT_GRADIENT_ACCUMULATION,
)
parser.add_argument(
"--inference-batch-size",
type=int,
default=DEFAULT_INFERENCE_BATCH_SIZE,
)
parser.add_argument("--torch-threads", type=int, default=6)
parser.add_argument("--device-name", default="mps")
parser.add_argument("--max-train-rows-per-asset", type=int, default=0)
parser.add_argument(
"--max-development-rows-per-asset",
type=int,
default=0,
)
args = parser.parse_args()
print(json.dumps(run(**vars(args)), sort_keys=True))
if __name__ == "__main__":
main()