| """Run forecasting-v4 masked continuous-horizon experiments.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import math |
| import platform |
| import random |
| import sys |
| import time |
| 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_v2 import ( |
| BASE_TOKEN_FEATURE_NAMES, |
| CANONICAL_HORIZONS, |
| CLASS_COUNT, |
| CONFIRMATION_ASSETS, |
| DISCOVERY_FOLDS, |
| EVALUATION_HORIZONS, |
| SELECTION_ASSETS, |
| TOKEN_FEATURE_NAMES, |
| ) |
| from project.evaluators.close_distribution_v2 import ( |
| ClassMap, |
| class_map_to_arrays, |
| encode_returns, |
| fit_class_map, |
| metrics, |
| prior_probabilities, |
| score_arrays, |
| ) |
| from project.models.close_distribution_v2 import build_model |
|
|
|
|
| MODEL_NAMES = ( |
| "prior", |
| "pooled_linear", |
| "tcn", |
| "transformer_learned", |
| "transformer_scalar", |
| "transformer_rotary", |
| ) |
| CONTINUOUS_MODELS = ("transformer_scalar", "transformer_rotary") |
| OPTIMIZER_RECIPES = ( |
| "adamw_constant", |
| "adamw_cosine", |
| "adamw_warmup_cosine", |
| "lion_warmup_cosine", |
| ) |
| SESSION_TIME_FEATURE_NAMES = ( |
| "regular_session_progress", |
| "regular_session_progress_sin", |
| "regular_session_progress_cos", |
| ) |
| SESSION_GAP_FEATURE_NAMES = ( |
| "log1p_minutes_since_previous_session_last_observed_close", |
| ) |
| RELATIVE_PRICE_FEATURE_NAMES = ( |
| "close_log_return_since_session_first_observed", |
| "close_log_return_since_previous_session_last_observed", |
| ) |
| RAW_PRICE_FEATURE_NAMES = ("log_raw_close",) |
| FEATURE_SETS = { |
| "baseline": BASE_TOKEN_FEATURE_NAMES, |
| "session_time": BASE_TOKEN_FEATURE_NAMES + SESSION_TIME_FEATURE_NAMES, |
| "session_gap": BASE_TOKEN_FEATURE_NAMES + SESSION_GAP_FEATURE_NAMES, |
| "relative_price": ( |
| BASE_TOKEN_FEATURE_NAMES + RELATIVE_PRICE_FEATURE_NAMES |
| ), |
| "raw_price": BASE_TOKEN_FEATURE_NAMES + RAW_PRICE_FEATURE_NAMES, |
| "session_time_gap": ( |
| BASE_TOKEN_FEATURE_NAMES |
| + SESSION_TIME_FEATURE_NAMES |
| + SESSION_GAP_FEATURE_NAMES |
| ), |
| "session_time_relative": ( |
| BASE_TOKEN_FEATURE_NAMES |
| + SESSION_TIME_FEATURE_NAMES |
| + RELATIVE_PRICE_FEATURE_NAMES |
| ), |
| "session_gap_relative": ( |
| BASE_TOKEN_FEATURE_NAMES |
| + SESSION_GAP_FEATURE_NAMES |
| + RELATIVE_PRICE_FEATURE_NAMES |
| ), |
| "all_without_raw": tuple( |
| name for name in TOKEN_FEATURE_NAMES if name != "log_raw_close" |
| ), |
| "all_natural": TOKEN_FEATURE_NAMES, |
| "all_without_session_time": tuple( |
| name |
| for name in TOKEN_FEATURE_NAMES |
| if name not in SESSION_TIME_FEATURE_NAMES |
| ), |
| "all_without_session_gap": tuple( |
| name |
| for name in TOKEN_FEATURE_NAMES |
| if name not in SESSION_GAP_FEATURE_NAMES |
| ), |
| "all_without_relative_price": tuple( |
| name |
| for name in TOKEN_FEATURE_NAMES |
| if name not in RELATIVE_PRICE_FEATURE_NAMES |
| ), |
| } |
| SPARSE_FEATURES = { |
| "close_log_return_since_last_observed_close", |
| "log_raw_close", |
| "close_log_return_since_session_first_observed", |
| "close_log_return_since_previous_session_last_observed", |
| } |
| PROVENANCE_PATHS = ( |
| "project/data/close_distribution_v2.py", |
| "project/evaluators/close_distribution_v2.py", |
| "project/experiments/forecasting_v4/runner.py", |
| "project/models/close_distribution_v2.py", |
| "project/studies/forecasting_v4.md", |
| "requirements.txt", |
| ) |
|
|
|
|
| class Lion(torch.optim.Optimizer): |
| """Minimal Lion optimizer used for the frozen optimizer treatment.""" |
|
|
| def __init__( |
| self, |
| params: Iterable[torch.Tensor], |
| *, |
| lr: float, |
| betas: tuple[float, float] = (0.9, 0.99), |
| weight_decay: float = 0.0, |
| ) -> None: |
| if lr <= 0.0: |
| raise ValueError("Lion learning rate must be positive") |
| if not 0.0 <= weight_decay: |
| raise ValueError("Lion weight decay cannot be negative") |
| if not all(0.0 <= beta < 1.0 for beta in betas): |
| raise ValueError("Lion betas must lie in [0, 1)") |
| super().__init__( |
| params, |
| { |
| "lr": lr, |
| "betas": betas, |
| "weight_decay": weight_decay, |
| }, |
| ) |
|
|
| @torch.no_grad() |
| def step(self, closure: Any = None) -> Any: |
| loss = None if closure is None else closure() |
| for group in self.param_groups: |
| beta1, beta2 = group["betas"] |
| for parameter in group["params"]: |
| if parameter.grad is None: |
| continue |
| gradient = parameter.grad |
| if gradient.is_sparse: |
| raise RuntimeError("Lion does not support sparse gradients") |
| if group["weight_decay"]: |
| parameter.mul_(1.0 - group["lr"] * group["weight_decay"]) |
| state = self.state[parameter] |
| if not state: |
| state["exp_avg"] = torch.zeros_like(parameter) |
| average = state["exp_avg"] |
| update = average.mul(beta1).add( |
| gradient, |
| alpha=1.0 - beta1, |
| ) |
| parameter.add_(torch.sign(update), alpha=-group["lr"]) |
| average.mul_(beta2).add_(gradient, alpha=1.0 - beta2) |
| return loss |
|
|
|
|
| def _ordinal(value: str) -> np.int32: |
| return np.datetime64(value, "D").astype(np.int32) |
|
|
|
|
| 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 _load_npz(path: Path) -> dict[str, np.ndarray]: |
| with np.load(path, allow_pickle=False) as bundle: |
| return {name: bundle[name] for name in bundle.files} |
|
|
|
|
| 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 _parse_horizons(value: str | Iterable[int]) -> tuple[int, ...]: |
| if isinstance(value, str): |
| result = tuple(int(item.strip()) for item in value.split(",")) |
| else: |
| result = tuple(int(item) for item in value) |
| allowed = set(int(item) for item in EVALUATION_HORIZONS) |
| if ( |
| not result |
| or len(set(result)) != len(result) |
| or any(item not in allowed for item in result) |
| or tuple(sorted(result)) != result |
| ): |
| raise ValueError("supervised horizons must be unique sorted values 2..32") |
| return result |
|
|
|
|
| def _asset_manifest( |
| study_dir: Path, |
| asset: str, |
| ) -> tuple[dict[str, Any], Path]: |
| if asset in SELECTION_ASSETS: |
| manifest_path = study_dir / "data" / "prepared-manifest.json" |
| data_dir = study_dir / "data" / "runner" |
| elif asset in CONFIRMATION_ASSETS: |
| marker_path = study_dir / "confirmation" / "CONFIRMATION_OPENED.json" |
| if not marker_path.exists(): |
| raise PermissionError("confirmation asset is still protected") |
| marker = json.loads(marker_path.read_text()) |
| manifest_path = ( |
| study_dir |
| / "data" |
| / "protected" |
| / "confirmation" |
| / "prepared-manifest.json" |
| ) |
| data_dir = manifest_path.parent |
| manifest = json.loads(manifest_path.read_text()) |
| if manifest.get("freeze_sha256") != marker.get("freeze_sha256"): |
| raise ValueError("confirmation manifest does not match the freeze") |
| return manifest, data_dir |
| else: |
| raise ValueError("asset is outside the frozen v4 whitelist") |
| manifest = json.loads(manifest_path.read_text()) |
| if manifest.get("scope") != "selection": |
| raise ValueError("selection manifest has an invalid scope") |
| return manifest, data_dir |
|
|
|
|
| def _feature_indices(feature_set: str) -> tuple[int, ...]: |
| names = FEATURE_SETS[feature_set] |
| return tuple(TOKEN_FEATURE_NAMES.index(name) for name in names) |
|
|
|
|
| def _normalization( |
| features: dict[str, np.ndarray], |
| *, |
| train_end: str, |
| selected_indices: tuple[int, ...], |
| max_row_exclusive: int | None = None, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| train = features["session_date"] < _ordinal(train_end) |
| if max_row_exclusive is not None: |
| train &= np.arange(len(train)) < max_row_exclusive |
| observed = features["X"][:, 1] > 0.5 |
| center = np.empty(len(selected_indices), dtype=np.float32) |
| scale = np.empty(len(selected_indices), dtype=np.float32) |
| for output_index, feature_index in enumerate(selected_indices): |
| name = TOKEN_FEATURE_NAMES[feature_index] |
| if name == "close_return_observed": |
| center[output_index] = 0.0 |
| scale[output_index] = 1.0 |
| continue |
| selected = train & observed if name in SPARSE_FEATURES else train |
| values = features["X"][selected, feature_index].astype(np.float64) |
| if not len(values) or not np.all(np.isfinite(values)): |
| raise ValueError(f"invalid normalization values for {name}") |
| center[output_index] = np.mean(values) |
| scale[output_index] = max(float(np.std(values)), 1e-6) |
| return center, scale |
|
|
|
|
| def _context( |
| x: np.ndarray, |
| rows: np.ndarray, |
| *, |
| context_length: int, |
| selected_indices: tuple[int, ...], |
| center: np.ndarray, |
| scale: np.ndarray, |
| ) -> tuple[torch.Tensor, torch.Tensor]: |
| offsets = np.arange(1 - context_length, 1, dtype=np.int64) |
| indices = rows[:, None] + offsets[None, :] |
| valid = indices >= 0 |
| safe = np.maximum(indices, 0) |
| values = np.asarray( |
| x[safe][..., selected_indices], |
| dtype=np.float32, |
| ).copy() |
| values = (values - center[None, None, :]) / scale[None, None, :] |
| observed = x[safe, 1] > 0.5 |
| for local_index, feature_index in enumerate(selected_indices): |
| if TOKEN_FEATURE_NAMES[feature_index] in SPARSE_FEATURES: |
| values[..., local_index] *= observed |
| values *= valid[:, :, None] |
| values = np.concatenate( |
| (values, valid[:, :, None].astype(np.float32)), |
| axis=2, |
| ) |
| return torch.from_numpy(values), torch.from_numpy(~valid) |
|
|
|
|
| def _optimizer_settings(recipe: str) -> tuple[float, float]: |
| if recipe in ("adamw_constant", "adamw_cosine"): |
| return 0.002, 0.0001 |
| if recipe == "adamw_warmup_cosine": |
| return 0.004, 0.0001 |
| if recipe == "lion_warmup_cosine": |
| return 0.0004, 0.0005 |
| raise ValueError(f"unknown optimizer recipe: {recipe}") |
|
|
|
|
| def _optimizer( |
| model: nn.Module, |
| recipe: str, |
| ) -> torch.optim.Optimizer: |
| learning_rate, weight_decay = _optimizer_settings(recipe) |
| if recipe.startswith("adamw"): |
| return torch.optim.AdamW( |
| model.parameters(), |
| lr=learning_rate, |
| weight_decay=weight_decay, |
| ) |
| return Lion( |
| model.parameters(), |
| lr=learning_rate, |
| weight_decay=weight_decay, |
| ) |
|
|
|
|
| def _scheduled_learning_rate( |
| recipe: str, |
| *, |
| update: int, |
| total_updates: int, |
| ) -> float: |
| peak, _weight_decay = _optimizer_settings(recipe) |
| if recipe == "adamw_constant": |
| return peak |
| warmup_updates = ( |
| max(1, math.ceil(0.05 * total_updates)) |
| if "warmup" in recipe |
| else 0 |
| ) |
| if update < warmup_updates: |
| return peak * (update + 1) / warmup_updates |
| remaining = max(total_updates - warmup_updates, 1) |
| progress = min( |
| max((update - warmup_updates) / max(remaining - 1, 1), 0.0), |
| 1.0, |
| ) |
| return peak * 0.5 * (1.0 + math.cos(math.pi * progress)) |
|
|
|
|
| def _masked_loss_part( |
| logits: torch.Tensor, |
| labels: torch.Tensor, |
| mask: torch.Tensor, |
| denominators: torch.Tensor, |
| ) -> torch.Tensor: |
| losses = nn.functional.cross_entropy( |
| logits.reshape(-1, logits.shape[-1]), |
| labels.clamp_min(0).reshape(-1), |
| reduction="none", |
| ).reshape(labels.shape) |
| horizon_sum = (losses * mask).sum(dim=0) |
| return torch.mean(horizon_sum / denominators) |
|
|
|
|
| def _masked_nll( |
| probabilities: np.ndarray, |
| labels: np.ndarray, |
| target_mask: np.ndarray, |
| ) -> float: |
| p = np.asarray(probabilities, dtype=np.float64) |
| y = np.asarray(labels, dtype=np.int64) |
| mask = np.asarray(target_mask, dtype=np.bool_) |
| if p.shape[:2] != y.shape or y.shape != mask.shape: |
| raise ValueError("diagnostic arrays do not align") |
| by_horizon = [] |
| for horizon in range(y.shape[1]): |
| selected = mask[:, horizon] |
| if not np.any(selected): |
| raise ValueError("diagnostic horizon has no valid target") |
| selected_probability = p[ |
| selected, |
| horizon, |
| y[selected, horizon], |
| ] |
| by_horizon.append( |
| -np.mean(np.log(np.clip(selected_probability, 1e-12, 1.0))) |
| ) |
| return float(np.mean(by_horizon)) |
|
|
|
|
| def _train_model( |
| model: nn.Module, |
| *, |
| x: np.ndarray, |
| row_index: np.ndarray, |
| labels: np.ndarray, |
| target_mask: np.ndarray, |
| context_length: int, |
| selected_indices: tuple[int, ...], |
| center: np.ndarray, |
| scale: np.ndarray, |
| seed: int, |
| epochs: int, |
| batch_size: int, |
| gradient_accumulation_steps: int, |
| optimizer_recipe: str, |
| device: torch.device, |
| diagnostic_rows: np.ndarray, |
| diagnostic_labels: np.ndarray, |
| diagnostic_target_mask: np.ndarray, |
| inference_batch_size: int, |
| early_stopping_patience: int, |
| early_stopping_min_delta: float, |
| ) -> tuple[ |
| list[float], |
| list[float], |
| int, |
| list[float], |
| int, |
| bool, |
| ]: |
| if early_stopping_patience < 0: |
| raise ValueError("early stopping patience cannot be negative") |
| if early_stopping_min_delta < 0.0: |
| raise ValueError("early stopping minimum delta cannot be negative") |
| if early_stopping_patience and not len(diagnostic_rows): |
| raise ValueError("early stopping requires a diagnostic tail") |
| optimizer = _optimizer(model, optimizer_recipe) |
| effective_batch = batch_size * gradient_accumulation_steps |
| updates_per_epoch = math.ceil(len(row_index) / effective_batch) |
| total_updates = updates_per_epoch * epochs |
| generator = torch.Generator().manual_seed(seed + 10_007) |
| history: list[float] = [] |
| learning_rates: list[float] = [] |
| diagnostic_history: list[float] = [] |
| update_count = 0 |
| best_epoch = 0 |
| best_diagnostic_nll = float("inf") |
| plateau_reference_nll = float("inf") |
| best_state: dict[str, torch.Tensor] | None = None |
| stale_epochs = 0 |
| stopped_early = False |
| for _epoch in range(epochs): |
| model.train() |
| order = torch.randperm(len(row_index), generator=generator).numpy() |
| epoch_loss_sum = np.zeros(labels.shape[1], dtype=np.float64) |
| epoch_target_count = np.zeros(labels.shape[1], dtype=np.int64) |
| for start in range(0, len(order), effective_batch): |
| update_order = order[start : start + effective_batch] |
| update_mask = target_mask[update_order] |
| denominators = torch.from_numpy( |
| update_mask.sum(axis=0).astype(np.float32) |
| ).to(device) |
| if torch.any(denominators == 0): |
| raise ValueError( |
| "an optimizer update has no target for a horizon" |
| ) |
| optimizer.zero_grad(set_to_none=True) |
| for micro_start in range(0, len(update_order), batch_size): |
| batch = update_order[micro_start : micro_start + batch_size] |
| tokens, padding = _context( |
| x, |
| row_index[batch], |
| context_length=context_length, |
| selected_indices=selected_indices, |
| center=center, |
| scale=scale, |
| ) |
| batch_labels = torch.from_numpy( |
| labels[batch].astype(np.int64, copy=False) |
| ).to(device) |
| batch_mask = torch.from_numpy( |
| target_mask[batch].astype(np.float32, copy=False) |
| ).to(device) |
| logits = model(tokens.to(device), padding.to(device)) |
| loss = _masked_loss_part( |
| logits, |
| batch_labels, |
| batch_mask, |
| denominators, |
| ) |
| loss.backward() |
| with torch.no_grad(): |
| item_losses = nn.functional.cross_entropy( |
| logits.reshape(-1, logits.shape[-1]), |
| batch_labels.clamp_min(0).reshape(-1), |
| reduction="none", |
| ).reshape(batch_labels.shape) |
| epoch_loss_sum += ( |
| item_losses * batch_mask |
| ).sum(dim=0).cpu().numpy() |
| epoch_target_count += batch_mask.sum( |
| dim=0 |
| ).cpu().numpy().astype(np.int64) |
| learning_rate = _scheduled_learning_rate( |
| optimizer_recipe, |
| update=update_count, |
| total_updates=total_updates, |
| ) |
| for group in optimizer.param_groups: |
| group["lr"] = learning_rate |
| nn.utils.clip_grad_norm_(model.parameters(), 1.0) |
| optimizer.step() |
| learning_rates.append(learning_rate) |
| update_count += 1 |
| history.append( |
| float(np.mean(epoch_loss_sum / epoch_target_count)) |
| ) |
| if len(diagnostic_rows): |
| diagnostic_probabilities = _predict( |
| model, |
| x=x, |
| row_index=diagnostic_rows, |
| context_length=context_length, |
| selected_indices=selected_indices, |
| center=center, |
| scale=scale, |
| batch_size=inference_batch_size, |
| device=device, |
| query_horizons=None, |
| ) |
| diagnostic_nll = _masked_nll( |
| diagnostic_probabilities, |
| diagnostic_labels, |
| diagnostic_target_mask, |
| ) |
| diagnostic_history.append(diagnostic_nll) |
| if diagnostic_nll < best_diagnostic_nll: |
| best_diagnostic_nll = diagnostic_nll |
| best_epoch = _epoch + 1 |
| best_state = { |
| name: value.detach().cpu().clone() |
| for name, value in model.state_dict().items() |
| } |
| if ( |
| diagnostic_nll |
| < plateau_reference_nll - early_stopping_min_delta |
| ): |
| plateau_reference_nll = diagnostic_nll |
| stale_epochs = 0 |
| else: |
| stale_epochs += 1 |
| if ( |
| early_stopping_patience |
| and stale_epochs >= early_stopping_patience |
| ): |
| stopped_early = True |
| break |
| if best_state is not None: |
| model.load_state_dict(best_state) |
| elif not len(diagnostic_rows): |
| best_epoch = len(history) |
| return ( |
| history, |
| learning_rates, |
| update_count, |
| diagnostic_history, |
| best_epoch, |
| stopped_early, |
| ) |
|
|
|
|
| def _predict( |
| model: nn.Module, |
| *, |
| x: np.ndarray, |
| row_index: np.ndarray, |
| context_length: int, |
| selected_indices: tuple[int, ...], |
| center: np.ndarray, |
| scale: np.ndarray, |
| batch_size: int, |
| device: torch.device, |
| query_horizons: tuple[int, ...] | None, |
| ) -> np.ndarray: |
| horizon_count = ( |
| len(query_horizons) |
| if query_horizons is not None |
| else len(model.output_horizons) |
| if hasattr(model, "output_horizons") |
| else model.horizon_count |
| ) |
| result = np.empty( |
| (len(row_index), horizon_count, CLASS_COUNT), |
| dtype=np.float64, |
| ) |
| query = ( |
| torch.tensor(query_horizons, dtype=torch.float32, device=device) |
| if query_horizons is not None |
| else None |
| ) |
| model.eval() |
| with torch.inference_mode(): |
| for start in range(0, len(row_index), batch_size): |
| stop = min(start + batch_size, len(row_index)) |
| tokens, padding = _context( |
| x, |
| row_index[start:stop], |
| context_length=context_length, |
| selected_indices=selected_indices, |
| center=center, |
| scale=scale, |
| ) |
| logits = model( |
| tokens.to(device), |
| padding.to(device), |
| query, |
| ) |
| result[start:stop] = torch.softmax( |
| logits.cpu().to(torch.float64), |
| dim=2, |
| ).numpy() |
| return result |
|
|
|
|
| def interpolate_log_probabilities( |
| supervised_probabilities: np.ndarray, |
| supervised_horizons: Iterable[int], |
| query_horizons: Iterable[int], |
| ) -> np.ndarray: |
| probabilities = np.asarray(supervised_probabilities, dtype=np.float64) |
| supervised = np.asarray(tuple(supervised_horizons), dtype=np.float64) |
| query = np.asarray(tuple(query_horizons), dtype=np.float64) |
| if ( |
| probabilities.ndim != 3 |
| or probabilities.shape[1] != len(supervised) |
| or np.any(np.diff(supervised) <= 0.0) |
| or query.min() < supervised.min() |
| or query.max() > supervised.max() |
| ): |
| raise ValueError("log-probability interpolation inputs are invalid") |
| coordinate = np.log2(supervised / 2.0) |
| query_coordinate = np.log2(query / 2.0) |
| log_probability = np.log(np.clip(probabilities, 1e-12, 1.0)) |
| result = np.empty( |
| (len(probabilities), len(query), probabilities.shape[2]), |
| dtype=np.float64, |
| ) |
| for output_index, value in enumerate(query_coordinate): |
| exact = np.flatnonzero(np.isclose(coordinate, value)) |
| if len(exact): |
| result[:, output_index] = probabilities[:, exact[0]] |
| continue |
| upper = int(np.searchsorted(coordinate, value, side="right")) |
| lower = upper - 1 |
| weight = (value - coordinate[lower]) / ( |
| coordinate[upper] - coordinate[lower] |
| ) |
| interpolated = ( |
| (1.0 - weight) * log_probability[:, lower] |
| + weight * log_probability[:, upper] |
| ) |
| interpolated -= np.max(interpolated, axis=1, keepdims=True) |
| result[:, output_index] = np.exp(interpolated) |
| result[:, output_index] /= result[ |
| :, output_index |
| ].sum(axis=1, keepdims=True) |
| return result |
|
|
|
|
| def _horizon_indices(horizons: Iterable[int]) -> tuple[int, ...]: |
| positions = { |
| int(value): index |
| for index, value in enumerate(EVALUATION_HORIZONS) |
| } |
| return tuple(positions[int(value)] for value in horizons) |
|
|
|
|
| def _session_scores( |
| returns: np.ndarray, |
| target_mask: np.ndarray, |
| probabilities: np.ndarray, |
| session_date: np.ndarray, |
| *, |
| class_map: ClassMap, |
| score_values: dict[str, np.ndarray] | None = None, |
| ) -> dict[str, np.ndarray]: |
| scores = ( |
| score_arrays( |
| returns, |
| target_mask, |
| probabilities, |
| class_map=class_map, |
| ) |
| if score_values is None |
| else score_values |
| ) |
| sessions = np.unique(session_date) |
| horizon_count = np.zeros( |
| (len(sessions), returns.shape[1]), |
| dtype=np.int64, |
| ) |
| horizon_nll_sum = np.zeros_like(horizon_count, dtype=np.float64) |
| horizon_rps_sum = np.zeros_like(horizon_nll_sum) |
| for index, session in enumerate(sessions): |
| selected = session_date == session |
| horizon_count[index] = target_mask[selected].sum(axis=0) |
| horizon_nll_sum[index] = np.nansum( |
| scores["nll"][selected], |
| axis=0, |
| ) |
| horizon_rps_sum[index] = np.nansum( |
| scores["ranked_probability_score"][selected], |
| axis=0, |
| ) |
| return { |
| "session_date": sessions, |
| "horizon_count": horizon_count, |
| "horizon_nll_sum": horizon_nll_sum, |
| "horizon_ranked_probability_score_sum": horizon_rps_sum, |
| } |
|
|
|
|
| def _run_manifest(config: dict[str, Any]) -> dict[str, Any]: |
| project_root = Path(__file__).resolve().parents[3] |
| return { |
| "code_sha256": { |
| relative: _sha256(project_root / relative) |
| for relative in PROVENANCE_PATHS |
| }, |
| "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(), |
| }, |
| } |
|
|
|
|
| def run( |
| *, |
| study_dir: Path, |
| asset: str, |
| model_name: str, |
| supervised_horizons: str | Iterable[int], |
| context_length: int, |
| seed: int, |
| output_dir: Path, |
| epochs: int, |
| batch_size: int, |
| inference_batch_size: int, |
| target_parameters: int, |
| torch_threads: int, |
| max_train_rows: int, |
| device_name: str, |
| gradient_accumulation_steps: int, |
| optimizer_recipe: str, |
| rotary_base: float, |
| feature_set: str, |
| diagnostic_tail_fraction: float, |
| early_stopping_patience: int, |
| early_stopping_min_delta: float, |
| ) -> dict[str, Any]: |
| started = time.monotonic() |
| horizons = _parse_horizons(supervised_horizons) |
| if model_name not in MODEL_NAMES: |
| raise ValueError(f"model_name must be one of {MODEL_NAMES}") |
| if optimizer_recipe not in OPTIMIZER_RECIPES: |
| raise ValueError( |
| f"optimizer_recipe must be one of {OPTIMIZER_RECIPES}" |
| ) |
| if feature_set not in FEATURE_SETS: |
| raise ValueError(f"feature_set must be one of {tuple(FEATURE_SETS)}") |
| if model_name == "pooled_linear" and feature_set != "baseline": |
| raise ValueError("pooled linear is defined only for baseline inputs") |
| if context_length <= 0 or batch_size <= 0: |
| raise ValueError("context and batch sizes must be positive") |
| if epochs <= 0 or gradient_accumulation_steps <= 0: |
| raise ValueError("epochs and accumulation must be positive") |
| if not 0.0 <= diagnostic_tail_fraction < 0.5: |
| raise ValueError("diagnostic tail fraction must lie in [0, 0.5)") |
| if early_stopping_patience and diagnostic_tail_fraction == 0.0: |
| raise ValueError("early stopping requires a diagnostic tail") |
| if horizons[0] != 2 or horizons[-1] != 32: |
| raise ValueError( |
| "supervised horizons must include endpoints 2 and 32" |
| ) |
| if device_name == "mps" and not torch.backends.mps.is_available(): |
| raise ValueError("MPS was requested but is unavailable") |
| manifest, data_dir = _asset_manifest(study_dir, asset) |
| if asset not in manifest["assets"]: |
| raise ValueError("asset is absent from its prepared manifest") |
|
|
| selected_indices = _feature_indices(feature_set) |
| learning_rate, weight_decay = _optimizer_settings(optimizer_recipe) |
| config = { |
| "asset": asset, |
| "batch_size": batch_size, |
| "context_length": context_length, |
| "device_name": device_name, |
| "diagnostic_tail_fraction": diagnostic_tail_fraction, |
| "early_stopping_min_delta": early_stopping_min_delta, |
| "early_stopping_patience": early_stopping_patience, |
| "epochs": epochs, |
| "feature_names": list(FEATURE_SETS[feature_set]), |
| "feature_set": feature_set, |
| "gradient_accumulation_steps": gradient_accumulation_steps, |
| "inference_batch_size": inference_batch_size, |
| "learning_rate": learning_rate, |
| "max_train_rows": max_train_rows, |
| "model_name": model_name, |
| "optimizer_recipe": optimizer_recipe, |
| "rotary_base": rotary_base, |
| "seed": seed, |
| "snapshot_sha256": manifest["snapshot_sha256"], |
| "supervised_horizons": list(horizons), |
| "target_parameters": target_parameters, |
| "torch_threads": torch_threads, |
| "weight_decay": weight_decay, |
| } |
| output_dir.mkdir(parents=True, exist_ok=True) |
| results_path = output_dir / "results.json" |
| run_manifest_path = output_dir / "run_manifest.json" |
| if results_path.exists() or run_manifest_path.exists(): |
| raise FileExistsError("experiment output already exists") |
| run_manifest_path.write_text( |
| json.dumps(_run_manifest(config), indent=2, sort_keys=True) + "\n" |
| ) |
| run_manifest_sha256 = _sha256(run_manifest_path) |
|
|
| slug = asset.lower() |
| feature_path = data_dir / f"{slug}_features.npz" |
| label_path = data_dir / f"{slug}_labels.npz" |
| for path in (feature_path, label_path): |
| relative = str(path.relative_to(study_dir)) |
| if _sha256(path) != manifest["derived_sha256"][relative]: |
| raise ValueError(f"prepared hash mismatch: {relative}") |
| features = _load_npz(feature_path) |
| bundle = _load_npz(label_path) |
| _configure(seed, torch_threads) |
| device = torch.device(device_name) |
| supervised_indices = _horizon_indices(horizons) |
| canonical_indices = _horizon_indices(CANONICAL_HORIZONS) |
| dense_unseen_horizons = tuple( |
| int(value) |
| for value in EVALUATION_HORIZONS |
| if value not in set(CANONICAL_HORIZONS.tolist()) |
| ) |
| dense_unseen_indices = _horizon_indices(dense_unseen_horizons) |
| withheld_horizons = tuple( |
| value for value in (4, 16) if value not in horizons |
| ) |
| withheld_indices = _horizon_indices(withheld_horizons) |
| fold_results: dict[str, Any] = {} |
|
|
| for fold_name, split in DISCOVERY_FOLDS.items(): |
| fold_started = time.monotonic() |
| dates = bundle["session_date"] |
| train = ( |
| (dates >= _ordinal(split["train"][0])) |
| & (dates < _ordinal(split["train"][1])) |
| ) |
| validation = ( |
| (dates >= _ordinal(split["validation"][0])) |
| & (dates < _ordinal(split["validation"][1])) |
| ) |
| train_rows = bundle["row_index"][train] |
| train_y = bundle["y"][train] |
| train_target_mask = bundle["target_mask"][train] |
| if max_train_rows > 0 and len(train_rows) > max_train_rows: |
| selected = np.linspace( |
| 0, |
| len(train_rows) - 1, |
| max_train_rows, |
| dtype=np.int64, |
| ) |
| train_rows = train_rows[selected] |
| train_y = train_y[selected] |
| train_target_mask = train_target_mask[selected] |
| diagnostic_rows = np.empty(0, dtype=train_rows.dtype) |
| diagnostic_y = np.empty( |
| (0, train_y.shape[1]), |
| dtype=train_y.dtype, |
| ) |
| diagnostic_target_mask = np.empty( |
| (0, train_target_mask.shape[1]), |
| dtype=np.bool_, |
| ) |
| diagnostic_purge_rows = 0 |
| normalization_max_row = None |
| if diagnostic_tail_fraction: |
| diagnostic_count = max( |
| 1, |
| round(len(train_rows) * diagnostic_tail_fraction), |
| ) |
| diagnostic_rows = train_rows[-diagnostic_count:] |
| diagnostic_y = train_y[-diagnostic_count:] |
| diagnostic_target_mask = train_target_mask[-diagnostic_count:] |
| train_rows = train_rows[:-diagnostic_count] |
| train_y = train_y[:-diagnostic_count] |
| train_target_mask = train_target_mask[:-diagnostic_count] |
| normalization_max_row = int(diagnostic_rows[0]) |
| unpurged_count = len(train_rows) |
| keep = train_rows + int(EVALUATION_HORIZONS.max()) < int( |
| diagnostic_rows[0] |
| ) |
| train_rows = train_rows[keep] |
| train_y = train_y[keep] |
| train_target_mask = train_target_mask[keep] |
| diagnostic_purge_rows = unpurged_count - len(train_rows) |
| class_map = fit_class_map( |
| train_y, |
| train_target_mask, |
| class_count=CLASS_COUNT, |
| ) |
| validation_rows = bundle["row_index"][validation] |
| validation_y = bundle["y"][validation] |
| validation_target_mask = bundle["target_mask"][validation] |
| center, scale = _normalization( |
| features, |
| train_end=split["train"][1], |
| selected_indices=selected_indices, |
| max_row_exclusive=normalization_max_row, |
| ) |
|
|
| history: list[float] = [] |
| learning_rates: list[float] = [] |
| diagnostic_history: list[float] = [] |
| best_epoch = 0 |
| stopped_early = False |
| updates = 0 |
| parameter_count = 0 |
| estimated_madds = 0 |
| model = None |
| if model_name == "prior": |
| direct_probabilities = prior_probabilities( |
| len(validation_y), |
| class_map, |
| ) |
| supervised_probabilities = direct_probabilities[ |
| :, supervised_indices |
| ] |
| else: |
| model = build_model( |
| model_name, |
| context_length=context_length, |
| channels=len(selected_indices) + 1, |
| output_horizons=horizons, |
| classes=CLASS_COUNT, |
| target_parameters=target_parameters, |
| rotary_base=rotary_base, |
| ).to(device) |
| parameter_count = sum( |
| parameter.numel() |
| for parameter in model.parameters() |
| if parameter.requires_grad |
| ) |
| estimated_madds = int(model.estimated_madds) |
| all_labels = encode_returns( |
| train_y, |
| train_target_mask, |
| class_map.edges, |
| ) |
| diagnostic_labels = encode_returns( |
| diagnostic_y, |
| diagnostic_target_mask, |
| class_map.edges, |
| ) |
| ( |
| history, |
| learning_rates, |
| updates, |
| diagnostic_history, |
| best_epoch, |
| stopped_early, |
| ) = _train_model( |
| model, |
| x=features["X"], |
| row_index=train_rows, |
| labels=all_labels[:, supervised_indices], |
| target_mask=train_target_mask[:, supervised_indices], |
| context_length=context_length, |
| selected_indices=selected_indices, |
| center=center, |
| scale=scale, |
| seed=seed, |
| epochs=epochs, |
| batch_size=batch_size, |
| gradient_accumulation_steps=( |
| gradient_accumulation_steps |
| ), |
| optimizer_recipe=optimizer_recipe, |
| device=device, |
| diagnostic_rows=diagnostic_rows, |
| diagnostic_labels=diagnostic_labels[:, supervised_indices], |
| diagnostic_target_mask=diagnostic_target_mask[ |
| :, supervised_indices |
| ], |
| inference_batch_size=inference_batch_size, |
| early_stopping_patience=early_stopping_patience, |
| early_stopping_min_delta=early_stopping_min_delta, |
| ) |
| supervised_probabilities = _predict( |
| model, |
| x=features["X"], |
| row_index=validation_rows, |
| context_length=context_length, |
| selected_indices=selected_indices, |
| center=center, |
| scale=scale, |
| batch_size=inference_batch_size, |
| device=device, |
| query_horizons=( |
| horizons if model_name in CONTINUOUS_MODELS else None |
| ), |
| ) |
|
|
| interpolation_probabilities = interpolate_log_probabilities( |
| supervised_probabilities, |
| horizons, |
| EVALUATION_HORIZONS, |
| ) |
| if model_name in CONTINUOUS_MODELS: |
| if model is None: |
| raise AssertionError("continuous model was not constructed") |
| direct_probabilities = _predict( |
| model, |
| x=features["X"], |
| row_index=validation_rows, |
| context_length=context_length, |
| selected_indices=selected_indices, |
| center=center, |
| scale=scale, |
| batch_size=inference_batch_size, |
| device=device, |
| query_horizons=tuple( |
| int(value) for value in EVALUATION_HORIZONS |
| ), |
| ) |
| elif model_name != "prior": |
| direct_probabilities = interpolation_probabilities |
|
|
| prior = prior_probabilities(len(validation_y), class_map) |
| direct_scores = score_arrays( |
| validation_y, |
| validation_target_mask, |
| direct_probabilities, |
| class_map=class_map, |
| ) |
| interpolation_scores = score_arrays( |
| validation_y, |
| validation_target_mask, |
| interpolation_probabilities, |
| class_map=class_map, |
| ) |
| prior_scores = score_arrays( |
| validation_y, |
| validation_target_mask, |
| prior, |
| class_map=class_map, |
| ) |
| direct_metrics = metrics( |
| validation_y, |
| validation_target_mask, |
| direct_probabilities, |
| class_map=class_map, |
| score_values=direct_scores, |
| ) |
| metric_slices = { |
| "canonical": metrics( |
| validation_y, |
| validation_target_mask, |
| direct_probabilities, |
| class_map=class_map, |
| horizon_indices=canonical_indices, |
| score_values=direct_scores, |
| ), |
| "dense_unseen": metrics( |
| validation_y, |
| validation_target_mask, |
| direct_probabilities, |
| class_map=class_map, |
| horizon_indices=dense_unseen_indices, |
| score_values=direct_scores, |
| ), |
| "supervised": metrics( |
| validation_y, |
| validation_target_mask, |
| direct_probabilities, |
| class_map=class_map, |
| horizon_indices=supervised_indices, |
| score_values=direct_scores, |
| ), |
| "interpolation_baseline_dense_unseen": metrics( |
| validation_y, |
| validation_target_mask, |
| interpolation_probabilities, |
| class_map=class_map, |
| horizon_indices=dense_unseen_indices, |
| score_values=interpolation_scores, |
| ), |
| "prior_canonical": metrics( |
| validation_y, |
| validation_target_mask, |
| prior, |
| class_map=class_map, |
| horizon_indices=canonical_indices, |
| score_values=prior_scores, |
| ), |
| "prior_dense_unseen": metrics( |
| validation_y, |
| validation_target_mask, |
| prior, |
| class_map=class_map, |
| horizon_indices=dense_unseen_indices, |
| score_values=prior_scores, |
| ), |
| } |
| if withheld_indices: |
| metric_slices["withheld"] = metrics( |
| validation_y, |
| validation_target_mask, |
| direct_probabilities, |
| class_map=class_map, |
| horizon_indices=withheld_indices, |
| score_values=direct_scores, |
| ) |
| metric_slices["interpolation_baseline_withheld"] = metrics( |
| validation_y, |
| validation_target_mask, |
| interpolation_probabilities, |
| class_map=class_map, |
| horizon_indices=withheld_indices, |
| score_values=interpolation_scores, |
| ) |
| metric_slices["prior_withheld"] = metrics( |
| validation_y, |
| validation_target_mask, |
| prior, |
| class_map=class_map, |
| horizon_indices=withheld_indices, |
| score_values=prior_scores, |
| ) |
|
|
| np.savez_compressed( |
| output_dir / f"{fold_name}_direct_session_scores.npz", |
| **_session_scores( |
| validation_y, |
| validation_target_mask, |
| direct_probabilities, |
| bundle["session_date"][validation], |
| class_map=class_map, |
| score_values=direct_scores, |
| ), |
| ) |
| np.savez_compressed( |
| output_dir / f"{fold_name}_interpolation_session_scores.npz", |
| **_session_scores( |
| validation_y, |
| validation_target_mask, |
| interpolation_probabilities, |
| bundle["session_date"][validation], |
| class_map=class_map, |
| score_values=interpolation_scores, |
| ), |
| ) |
| if model is not None: |
| torch.save( |
| { |
| "asset": asset, |
| "center": center, |
| "context_length": context_length, |
| "feature_names": FEATURE_SETS[feature_set], |
| "model_name": model_name, |
| "rotary_base": rotary_base, |
| "scale": scale, |
| "state_dict": model.state_dict(), |
| "supervised_horizons": horizons, |
| **class_map_to_arrays(class_map), |
| }, |
| output_dir / f"{fold_name}_model.pt", |
| ) |
| fold_results[fold_name] = { |
| "best_epoch": best_epoch, |
| "class_map_rows": int(len(train_rows)), |
| "checkpoint_selection": ( |
| "lowest_inner_tail_nll" |
| if len(diagnostic_rows) |
| else "final_epoch" |
| ), |
| "completed_fixed_update_budget": bool( |
| model_name == "prior" |
| or updates |
| == math.ceil( |
| len(train_rows) |
| / (batch_size * gradient_accumulation_steps) |
| ) |
| * epochs |
| ), |
| "converged_by_inner_tail": bool( |
| model_name == "prior" |
| or not diagnostic_tail_fraction |
| or stopped_early |
| ), |
| "diagnostic_purge_rows": diagnostic_purge_rows, |
| "diagnostic_tail_nll": diagnostic_history, |
| "diagnostic_tail_rows": int(len(diagnostic_rows)), |
| "direct_is_continuous_query": model_name in CONTINUOUS_MODELS, |
| "duration_seconds": time.monotonic() - fold_started, |
| "estimated_madds_per_example": estimated_madds, |
| "learning_rate_first": ( |
| learning_rates[0] if learning_rates else None |
| ), |
| "learning_rate_last": ( |
| learning_rates[-1] if learning_rates else None |
| ), |
| "metrics": direct_metrics, |
| "metric_slices": metric_slices, |
| "parameter_count": parameter_count, |
| "train_loss": history, |
| "train_rows": int(len(train_rows)), |
| "train_valid_targets_by_supervised_horizon": { |
| str(horizon): int(count) |
| for horizon, count in zip( |
| horizons, |
| train_target_mask[:, supervised_indices].sum(axis=0), |
| strict=True, |
| ) |
| }, |
| "updates": updates, |
| "validation_rows": int(len(validation_rows)), |
| "validation_valid_targets_by_horizon": { |
| str(int(horizon)): int(count) |
| for horizon, count in zip( |
| EVALUATION_HORIZONS, |
| validation_target_mask.sum(axis=0), |
| strict=True, |
| ) |
| }, |
| } |
| del model |
| if device.type == "mps": |
| torch.mps.empty_cache() |
|
|
| def fold_mean(slice_name: str) -> float: |
| return float( |
| np.mean( |
| [ |
| fold_results[name]["metric_slices"][slice_name][ |
| "macro_nll" |
| ] |
| for name in DISCOVERY_FOLDS |
| ] |
| ) |
| ) |
|
|
| summary = { |
| **config, |
| "duration_seconds": time.monotonic() - started, |
| "effective_batch_size": batch_size * gradient_accumulation_steps, |
| "folds": fold_results, |
| "mean_canonical_macro_nll": fold_mean("canonical"), |
| "mean_dense_unseen_macro_nll": fold_mean("dense_unseen"), |
| "mean_interpolation_baseline_dense_unseen_macro_nll": fold_mean( |
| "interpolation_baseline_dense_unseen" |
| ), |
| "mean_prior_canonical_macro_nll": fold_mean("prior_canonical"), |
| "mean_prior_dense_unseen_macro_nll": fold_mean( |
| "prior_dense_unseen" |
| ), |
| "mean_supervised_macro_nll": fold_mean("supervised"), |
| "run_manifest_sha256": run_manifest_sha256, |
| } |
| if withheld_indices: |
| summary["mean_withheld_macro_nll"] = fold_mean("withheld") |
| summary[ |
| "mean_interpolation_baseline_withheld_macro_nll" |
| ] = fold_mean("interpolation_baseline_withheld") |
| results_path.write_text( |
| json.dumps(summary, indent=2, sort_keys=True) + "\n" |
| ) |
| return summary |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--study-dir", type=Path, required=True) |
| parser.add_argument("--asset", required=True) |
| parser.add_argument("--model-name", choices=MODEL_NAMES, required=True) |
| parser.add_argument( |
| "--supervised-horizons", |
| default="2,4,8,16,32", |
| ) |
| parser.add_argument("--context-length", type=int, default=128) |
| parser.add_argument("--seed", type=int, default=0) |
| parser.add_argument("--output-dir", type=Path, required=True) |
| parser.add_argument("--epochs", type=int, default=16) |
| parser.add_argument("--batch-size", type=int, default=256) |
| parser.add_argument("--inference-batch-size", type=int, default=512) |
| parser.add_argument("--target-parameters", type=int, default=25_000) |
| parser.add_argument("--torch-threads", type=int, default=6) |
| parser.add_argument("--max-train-rows", type=int, default=100_000) |
| parser.add_argument( |
| "--device-name", |
| choices=("cpu", "mps"), |
| default="mps", |
| ) |
| parser.add_argument("--gradient-accumulation-steps", type=int, default=16) |
| parser.add_argument( |
| "--optimizer-recipe", |
| choices=OPTIMIZER_RECIPES, |
| default="adamw_constant", |
| ) |
| parser.add_argument("--rotary-base", type=float, default=16.0) |
| parser.add_argument( |
| "--feature-set", |
| choices=tuple(FEATURE_SETS), |
| default="baseline", |
| ) |
| parser.add_argument("--diagnostic-tail-fraction", type=float, default=0.1) |
| parser.add_argument("--early-stopping-patience", type=int, default=2) |
| parser.add_argument("--early-stopping-min-delta", type=float, default=0.0005) |
| args = parser.parse_args() |
| print(json.dumps(run(**vars(args)), sort_keys=True)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|