ifieryarrows commited on
Commit
a754bc4
·
verified ·
1 Parent(s): 323c704

Sync from GitHub (tests passed)

Browse files
app/features.py CHANGED
@@ -39,6 +39,42 @@ logger = logging.getLogger(__name__)
39
  # Data Loading
40
  # =============================================================================
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def _rows_to_price_df(rows: list, symbol: str) -> pd.DataFrame:
43
  """Convert raw PriceBar rows into a validated, date-indexed DataFrame."""
44
  if not rows:
@@ -59,8 +95,73 @@ def _rows_to_price_df(rows: list, symbol: str) -> pd.DataFrame:
59
  df = df.loc[~invalid_close].copy()
60
  if df.empty:
61
  return pd.DataFrame()
62
- df["date"] = pd.to_datetime(df["date"]).dt.tz_localize(None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  df = df.set_index("date")
 
 
 
 
 
 
64
  return df
65
 
66
 
 
39
  # Data Loading
40
  # =============================================================================
41
 
42
+ def _normalize_bar_calendar_dates(raw_dates: pd.Series, utc_ts: pd.Series) -> pd.Series:
43
+ """Normalize bar dates to tz-naive midnight preserving explicit non-UTC exchange offsets."""
44
+ utc_norm = utc_ts.dt.tz_localize(None).dt.normalize()
45
+ if raw_dates.empty:
46
+ return utc_norm
47
+
48
+ needs_offset_scan = False
49
+ for raw_val in raw_dates:
50
+ if isinstance(raw_val, datetime):
51
+ offset = raw_val.utcoffset()
52
+ if offset is not None and offset != timedelta(0):
53
+ needs_offset_scan = True
54
+ break
55
+ elif isinstance(raw_val, str):
56
+ stripped = raw_val.strip()
57
+ if not stripped.endswith(("Z", "z", "+00:00", "-00:00")):
58
+ needs_offset_scan = True
59
+ break
60
+
61
+ if not needs_offset_scan:
62
+ return utc_norm
63
+
64
+ normalized = []
65
+ for raw_val, utc_val in zip(raw_dates, utc_ts):
66
+ if isinstance(raw_val, datetime):
67
+ normalized.append(raw_val.date())
68
+ elif isinstance(raw_val, str) and not raw_val.strip().endswith(
69
+ ("Z", "z", "+00:00", "-00:00")
70
+ ):
71
+ parsed = pd.to_datetime(raw_val, errors="coerce")
72
+ normalized.append(parsed.date() if pd.notna(parsed) else utc_val.date())
73
+ else:
74
+ normalized.append(utc_val.date())
75
+ return pd.to_datetime(normalized)
76
+
77
+
78
  def _rows_to_price_df(rows: list, symbol: str) -> pd.DataFrame:
79
  """Convert raw PriceBar rows into a validated, date-indexed DataFrame."""
80
  if not rows:
 
95
  df = df.loc[~invalid_close].copy()
96
  if df.empty:
97
  return pd.DataFrame()
98
+
99
+ raw_ts = pd.to_datetime(df["date"], errors="coerce", utc=True)
100
+ invalid_dates = raw_ts.isna()
101
+ if invalid_dates.any():
102
+ logger.warning(
103
+ "Excluded %s rows with invalid date for %s", int(invalid_dates.sum()), symbol
104
+ )
105
+ df = df.loc[~invalid_dates].copy()
106
+ raw_ts = raw_ts.loc[~invalid_dates]
107
+ if df.empty:
108
+ return pd.DataFrame()
109
+
110
+ cal_dates = _normalize_bar_calendar_dates(df["date"], raw_ts)
111
+ df["_raw_ts"] = raw_ts.to_numpy()
112
+ df["date"] = cal_dates.to_numpy()
113
+
114
+ # Drop leaked synthetic test fixture rows (100/105/95/102/1000) when mixed with
115
+ # real market series whose price scale, volume scale, or calendar day differs.
116
+ fixture_mask = (
117
+ np.isclose(df["open"].astype(float), 100.0, rtol=1e-6, atol=1e-6)
118
+ & np.isclose(df["high"].astype(float), 105.0, rtol=1e-6, atol=1e-6)
119
+ & np.isclose(df["low"].astype(float), 95.0, rtol=1e-6, atol=1e-6)
120
+ & np.isclose(df["close"].astype(float), 102.0, rtol=1e-6, atol=1e-6)
121
+ & np.isclose(df["volume"].fillna(0.0).astype(float), 1000.0, rtol=1e-6, atol=1e-6)
122
+ )
123
+ if fixture_mask.any() and (~fixture_mask).any():
124
+ non_fixture = df.loc[~fixture_mask]
125
+ non_fixture_median = float(non_fixture["close"].astype(float).median())
126
+ non_fixture_vols = non_fixture["volume"].dropna().astype(float)
127
+ non_fixture_vol_median = (
128
+ float(non_fixture_vols.median()) if not non_fixture_vols.empty else None
129
+ )
130
+ price_mismatch = abs(non_fixture_median - 102.0) > 25.0
131
+ vol_mismatch = (
132
+ non_fixture_vol_median is not None
133
+ and (non_fixture_vol_median < 100.0 or non_fixture_vol_median > 10000.0)
134
+ )
135
+ if price_mismatch or vol_mismatch:
136
+ logger.warning(
137
+ "Excluded %s leaked synthetic fixture rows for %s "
138
+ "(non-fixture close median=%.2f, volume median=%s)",
139
+ int(fixture_mask.sum()),
140
+ symbol,
141
+ non_fixture_median,
142
+ f"{non_fixture_vol_median:.1f}" if non_fixture_vol_median is not None else "n/a",
143
+ )
144
+ df = non_fixture.copy()
145
+ else:
146
+ colliding_fixture = fixture_mask & df["date"].isin(set(non_fixture["date"]))
147
+ if colliding_fixture.any():
148
+ logger.warning(
149
+ "Excluded %s colliding synthetic fixture rows for %s",
150
+ int(colliding_fixture.sum()),
151
+ symbol,
152
+ )
153
+ df = df.loc[~colliding_fixture].copy()
154
+ if df.empty:
155
+ return pd.DataFrame()
156
+
157
+ df = df.sort_values("_raw_ts").drop(columns=["_raw_ts"])
158
  df = df.set_index("date")
159
+ if df.index.duplicated().any():
160
+ dup_count = int(df.index.duplicated(keep="last").sum())
161
+ logger.warning(
162
+ "Deduplicated %s same-calendar-day price bars for %s", dup_count, symbol
163
+ )
164
+ df = df[~df.index.duplicated(keep="last")]
165
  return df
166
 
167
 
deep_learning/data/feature_store.py CHANGED
@@ -336,6 +336,71 @@ def _build_calendar_features(index: pd.DatetimeIndex) -> pd.DataFrame:
336
  # Price / technical features (reuses existing helpers)
337
  # ---------------------------------------------------------------------------
338
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  def _build_price_features(
340
  session,
341
  symbol: str,
@@ -355,6 +420,11 @@ def _build_price_features(
355
  if price_df.empty:
356
  return pd.DataFrame(), pd.DataFrame()
357
 
 
 
 
 
 
358
  features = generate_symbol_features(price_df, symbol)
359
  return price_df, features
360
 
@@ -624,7 +694,7 @@ def build_tft_dataframe(
624
  calendar_features = _build_calendar_features(target_index)
625
 
626
  # ---- 6. Target: daily log-return path plus explicit weekly helpers ----
627
- close = price_df["close"].astype(float)
628
  log_close = np.log(close)
629
 
630
  target_1d = log_close.shift(-cfg.forecast.auxiliary_horizon_days) - log_close
@@ -642,7 +712,13 @@ def build_tft_dataframe(
642
  observed_return_1d = log_close.diff().fillna(0.0)
643
  observed_return_1d.name = "observed_return_1d"
644
 
645
- realized_vol_20d = target_1d.rolling(20, min_periods=10).std()
 
 
 
 
 
 
646
  realized_vol_20d.name = "realized_vol_20d"
647
 
648
  material_move_5d = (
 
336
  # Price / technical features (reuses existing helpers)
337
  # ---------------------------------------------------------------------------
338
 
339
+ def _sanitize_close_spikes(
340
+ close_series: pd.Series,
341
+ symbol: str = "target",
342
+ *,
343
+ max_abs_log_jump: float = 0.35,
344
+ confirm_bars: int = 2,
345
+ extreme_log_jump: float = 1.0,
346
+ extreme_confirm_bars: int = 3,
347
+ ) -> pd.Series:
348
+ """Replace transient corrupt price spikes causally without flatlining persistent shifts."""
349
+ close = close_series.astype(float).where(lambda s: s > 0.0).ffill()
350
+ if len(close) <= 1:
351
+ return close
352
+
353
+ close_vals = close.to_numpy(dtype=float, copy=False)
354
+ valid = np.isfinite(close_vals) & (close_vals > 0.0)
355
+ log_vals = np.zeros_like(close_vals)
356
+ log_vals[valid] = np.log(close_vals[valid])
357
+ has_jumps = bool((valid[1:] & valid[:-1] & (np.abs(np.diff(log_vals)) > max_abs_log_jump)).any())
358
+ if not has_jumps:
359
+ return close
360
+
361
+ close_vals = close_vals.copy()
362
+ repaired = 0
363
+ pending_anchor: float | None = None
364
+ pending_run = 0
365
+ for idx in range(1, len(close_vals)):
366
+ prev_val = close_vals[idx - 1]
367
+ cur_val = close_vals[idx]
368
+ if not (np.isfinite(prev_val) and prev_val > 0 and np.isfinite(cur_val) and cur_val > 0):
369
+ pending_anchor = None
370
+ pending_run = 0
371
+ continue
372
+ log_jump = abs(np.log(cur_val / prev_val))
373
+ if log_jump > max_abs_log_jump:
374
+ needed = extreme_confirm_bars if log_jump > extreme_log_jump else confirm_bars
375
+ if (
376
+ pending_anchor is not None
377
+ and abs(np.log(cur_val / pending_anchor)) <= max_abs_log_jump
378
+ ):
379
+ pending_run += 1
380
+ pending_anchor = cur_val
381
+ else:
382
+ pending_anchor = cur_val
383
+ pending_run = 1
384
+ if pending_run >= needed:
385
+ # Persistent shift confirmed purely from past/current bars; resume true level.
386
+ pending_anchor = None
387
+ pending_run = 0
388
+ else:
389
+ close_vals[idx] = prev_val
390
+ repaired += 1
391
+ else:
392
+ pending_anchor = None
393
+ pending_run = 0
394
+ if repaired:
395
+ logger.warning(
396
+ "Repaired %d corrupt single-day close spike(s) (> %.2f log jump) for %s",
397
+ repaired,
398
+ max_abs_log_jump,
399
+ symbol,
400
+ )
401
+ return pd.Series(close_vals, index=close_series.index, name=close_series.name)
402
+
403
+
404
  def _build_price_features(
405
  session,
406
  symbol: str,
 
420
  if price_df.empty:
421
  return pd.DataFrame(), pd.DataFrame()
422
 
423
+ sanitized_close = _sanitize_close_spikes(price_df["close"], symbol=symbol)
424
+ if not sanitized_close.equals(price_df["close"]):
425
+ price_df = price_df.copy()
426
+ price_df["close"] = sanitized_close
427
+
428
  features = generate_symbol_features(price_df, symbol)
429
  return price_df, features
430
 
 
694
  calendar_features = _build_calendar_features(target_index)
695
 
696
  # ---- 6. Target: daily log-return path plus explicit weekly helpers ----
697
+ close = _sanitize_close_spikes(price_df["close"], symbol=target_symbol)
698
  log_close = np.log(close)
699
 
700
  target_1d = log_close.shift(-cfg.forecast.auxiliary_horizon_days) - log_close
 
712
  observed_return_1d = log_close.diff().fillna(0.0)
713
  observed_return_1d.name = "observed_return_1d"
714
 
715
+ realized_vol_20d = (
716
+ observed_return_1d.rolling(20, min_periods=5)
717
+ .std()
718
+ .replace(0, np.nan)
719
+ .ffill()
720
+ .fillna(0.01)
721
+ )
722
  realized_vol_20d.name = "realized_vol_20d"
723
 
724
  material_move_5d = (
deep_learning/data/validation.py CHANGED
@@ -39,6 +39,12 @@ def validate_weekly_target_contract(
39
  for col in ("target", "target_1d_log_return", "target_5d_log_return"):
40
  if df[col].isna().any():
41
  raise ValueError(f"{col} contains NaN after feature-store construction")
 
 
 
 
 
 
42
 
43
  comparable = df[["target", "target_1d_log_return"]].dropna()
44
  if not comparable.empty and not np.allclose(
 
39
  for col in ("target", "target_1d_log_return", "target_5d_log_return"):
40
  if df[col].isna().any():
41
  raise ValueError(f"{col} contains NaN after feature-store construction")
42
+ valid_1d = df["target_1d_log_return"].dropna()
43
+ max_abs_1d = float(valid_1d.abs().max()) if not valid_1d.empty else 0.0
44
+ if max_abs_1d > 0.50:
45
+ raise ValueError(
46
+ f"Corrupt single-day log return detected in training frame: max_abs={max_abs_1d:.4f} > 0.50"
47
+ )
48
 
49
  comparable = df[["target", "target_1d_log_return"]].dropna()
50
  if not comparable.empty and not np.allclose(
deep_learning/inference/predictor.py CHANGED
@@ -144,6 +144,8 @@ class TFTPredictor:
144
  self._direction_sign_multiplier = 1
145
  self._daily_sign_multiplier = 1
146
  self._weekly_sign_threshold = 0.0
 
 
147
  self._weekly_direction_model: dict = {}
148
  self._weekly_interval_scale = 1.0
149
  self._weekly_interval_conditioning: dict = {}
@@ -286,6 +288,35 @@ class TFTPredictor:
286
  "Incompatible TFT checkpoint: invalid validation-fitted weekly sign threshold. Retraining required."
287
  )
288
  self._weekly_sign_threshold = weekly_sign_threshold
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  weekly_direction_model = metadata.get("weekly_direction_model") or {}
290
  if weekly_direction_model.get("enabled"):
291
  required_fields = {"feature_names", "mean", "scale", "coef", "intercept"}
@@ -561,6 +592,14 @@ class TFTPredictor:
561
  threshold=float(self._weekly_direction_model.get("decision_threshold", 0.50)),
562
  horizon=self.cfg.forecast.primary_horizon_days,
563
  )[0]
 
 
 
 
 
 
 
 
564
  if self._weekly_interval_conditioning:
565
  from deep_learning.training.metrics import apply_weekly_interval_scale_np
566
 
@@ -593,6 +632,20 @@ class TFTPredictor:
593
  self._weekly_interval_scale,
594
  quantiles=tuple(self.cfg.model.quantiles),
595
  )[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
 
597
  except IncompatibleTFTCheckpointError as exc:
598
  logger.warning("TFT checkpoint incompatible: %s", exc)
@@ -638,6 +691,8 @@ class TFTPredictor:
638
  "direction_sign_multiplier": self._direction_sign_multiplier,
639
  "daily_sign_multiplier": self._daily_sign_multiplier,
640
  "weekly_sign_threshold": self._weekly_sign_threshold,
 
 
641
  "weekly_direction_model_enabled": bool(self._weekly_direction_model),
642
  "weekly_interval_scale": self._weekly_interval_scale,
643
  "weekly_interval_conditioning_enabled": bool(self._weekly_interval_conditioning),
 
144
  self._direction_sign_multiplier = 1
145
  self._daily_sign_multiplier = 1
146
  self._weekly_sign_threshold = 0.0
147
+ self._weekly_magnitude_scale = 1.0
148
+ self._weekly_median_cap: Optional[float] = None
149
  self._weekly_direction_model: dict = {}
150
  self._weekly_interval_scale = 1.0
151
  self._weekly_interval_conditioning: dict = {}
 
288
  "Incompatible TFT checkpoint: invalid validation-fitted weekly sign threshold. Retraining required."
289
  )
290
  self._weekly_sign_threshold = weekly_sign_threshold
291
+ magnitude_cal = metadata.get("magnitude_calibration") or {}
292
+ weekly_magnitude_scale = float(
293
+ direction_calibration.get(
294
+ "weekly_magnitude_scale",
295
+ magnitude_cal.get("scale", 1.0),
296
+ )
297
+ or 1.0
298
+ )
299
+ if not np.isfinite(weekly_magnitude_scale) or weekly_magnitude_scale <= 0.0 or weekly_magnitude_scale > 5.0:
300
+ raise IncompatibleTFTCheckpointError(
301
+ "Incompatible TFT checkpoint: invalid validation-fitted weekly magnitude scale. Retraining required."
302
+ )
303
+ self._weekly_magnitude_scale = weekly_magnitude_scale
304
+ raw_cap = config.get(
305
+ "weekly_median_cap",
306
+ (metadata.get("target_scale_audit") or {}).get(
307
+ "weekly_median_cap",
308
+ metadata.get("weekly_median_cap"),
309
+ ),
310
+ )
311
+ if raw_cap is not None:
312
+ cap_val = float(raw_cap)
313
+ if not np.isfinite(cap_val) or cap_val <= 0.0:
314
+ raise IncompatibleTFTCheckpointError(
315
+ "Incompatible TFT checkpoint: invalid training weekly_median_cap. Retraining required."
316
+ )
317
+ self._weekly_median_cap = cap_val
318
+ else:
319
+ self._weekly_median_cap = None
320
  weekly_direction_model = metadata.get("weekly_direction_model") or {}
321
  if weekly_direction_model.get("enabled"):
322
  required_fields = {"feature_names", "mean", "scale", "coef", "intercept"}
 
592
  threshold=float(self._weekly_direction_model.get("decision_threshold", 0.50)),
593
  horizon=self.cfg.forecast.primary_horizon_days,
594
  )[0]
595
+ if abs(self._weekly_magnitude_scale - 1.0) > 1e-6:
596
+ from deep_learning.training.metrics import apply_weekly_magnitude_scale_np
597
+
598
+ pred_for_format = apply_weekly_magnitude_scale_np(
599
+ pred_for_format[None, ...],
600
+ self._weekly_magnitude_scale,
601
+ quantiles=tuple(self.cfg.model.quantiles),
602
+ )[0]
603
  if self._weekly_interval_conditioning:
604
  from deep_learning.training.metrics import apply_weekly_interval_scale_np
605
 
 
632
  self._weekly_interval_scale,
633
  quantiles=tuple(self.cfg.model.quantiles),
634
  )[0]
635
+ if self._weekly_median_cap is not None and self._weekly_median_cap > 0.0:
636
+ from deep_learning.training.metrics import apply_weekly_median_cap_np
637
+
638
+ effective_horizon = min(
639
+ int(self.cfg.forecast.primary_horizon_days),
640
+ int(pred_for_format.shape[0]),
641
+ )
642
+ if effective_horizon > 0:
643
+ pred_for_format = apply_weekly_median_cap_np(
644
+ pred_for_format[None, ...],
645
+ weekly_median_cap=self._weekly_median_cap,
646
+ quantiles=tuple(self.cfg.model.quantiles),
647
+ horizon=effective_horizon,
648
+ )[0][0]
649
 
650
  except IncompatibleTFTCheckpointError as exc:
651
  logger.warning("TFT checkpoint incompatible: %s", exc)
 
691
  "direction_sign_multiplier": self._direction_sign_multiplier,
692
  "daily_sign_multiplier": self._daily_sign_multiplier,
693
  "weekly_sign_threshold": self._weekly_sign_threshold,
694
+ "weekly_magnitude_scale": self._weekly_magnitude_scale,
695
+ "weekly_median_cap": self._weekly_median_cap,
696
  "weekly_direction_model_enabled": bool(self._weekly_direction_model),
697
  "weekly_interval_scale": self._weekly_interval_scale,
698
  "weekly_interval_conditioning_enabled": bool(self._weekly_interval_conditioning),
deep_learning/models/tft_copper.py CHANGED
@@ -111,11 +111,16 @@ def _weekly_saturation_loss(
111
  # one per sample. This creates deployment margin without letting extreme
112
  # initialization outliers dominate the objective again.
113
  moderate_violation_barrier = torch.tanh(above_cap_excess).pow(2)
 
 
 
 
114
 
115
  return (
116
  0.10 * _robust_squared(near_cap_excess).mean()
117
  + 2.0 * _robust_squared(above_cap_excess).mean()
118
  + moderate_violation_barrier.mean()
 
119
  )
120
 
121
 
@@ -123,20 +128,23 @@ def _weekly_scale_losses(
123
  pred_weekly_median: torch.Tensor,
124
  actual_weekly: torch.Tensor,
125
  eps: float = 1e-8,
 
126
  ) -> dict[str, torch.Tensor]:
127
  """
128
  Scale diagnostics used by WeeklyASROPFLoss.
129
 
130
  Hyperopt promotes on weekly_magnitude_ratio, so the loss must penalize the
131
  same failure mode directly instead of relying only on slowly growing
132
- log-ratio pressure.
 
133
  """
 
134
  pred_abs_median = pred_weekly_median.abs().median() + eps
135
- actual_abs_median = actual_weekly.abs().median() + eps
136
  magnitude_ratio = pred_abs_median / actual_abs_median
137
 
138
  pred_abs_mean = pred_weekly_median.abs().mean() + eps
139
- actual_abs_mean = actual_weekly.abs().mean() + eps
140
  mean_magnitude_ratio = pred_abs_mean / actual_abs_mean
141
 
142
  def _bounded_scale_loss(ratio: torch.Tensor) -> torch.Tensor:
@@ -150,23 +158,23 @@ def _weekly_scale_losses(
150
  + 4.0 * structural_explosion.pow(2)
151
  )
152
 
153
- # The median ratio remains available as the production diagnostic, but a
154
- # batch median is an order statistic: its active element can change after
155
- # an otherwise negligible floating-point perturbation. Feeding that
156
- # gradient into the optimizer made otherwise identical CPU runs follow
157
- # different paths. Use the smooth mean ratio for optimization while
158
- # preserving the median ratio used by hyperopt and the quality gate.
159
- magnitude_loss = _bounded_scale_loss(mean_magnitude_ratio)
160
-
161
- pred_std = pred_weekly_median.std(unbiased=False) + eps
162
- actual_std = actual_weekly.std(unbiased=False) + eps
163
- # A batch can temporarily collapse to almost one weekly value. The raw
164
- # log-ratio then reaches ~15 and its 1/pred_std derivative overwhelms the
165
- # rest of the objective, making cross-run CPU round-off choose a different
166
- # basin. Keep the dispersion signal in a broad, pre-specified [0.25, 4]
167
- # ratio band and use a smooth-L1 penalty; this still discourages material
168
- # under/over-dispersion without allowing one pathological batch to steer
169
- # the whole epoch.
170
  dispersion_log_error = torch.log(
171
  (pred_std / actual_std).clamp(min=0.25, max=4.0)
172
  )
@@ -176,16 +184,10 @@ def _weekly_scale_losses(
176
  beta=0.5,
177
  )
178
 
179
- model_mae = torch.mean(torch.abs(pred_weekly_median - actual_weekly))
180
  zero_mae = actual_abs_mean
181
  naive_relative_loss = torch.relu((model_mae / zero_mae) - 1.0)
182
- mean_gap = (pred_weekly_median.mean() - actual_weekly.mean()) / zero_mae
183
- median_gap = (
184
- (pred_weekly_median.median() - actual_weekly.median())
185
- / actual_abs_median
186
- )
187
- # As above, keep the median gap as a diagnostic but avoid its
188
- # order-statistic gradient in the train objective.
189
  bias_loss = 1.50 * torch.abs(mean_gap)
190
 
191
  return {
@@ -286,20 +288,25 @@ def _weekly_interval_undercoverage_loss(
286
  upper = pred_weekly_quantiles[:, q90_idx]
287
  width = torch.clamp(upper - lower, min=0.0)
288
 
289
- actual_scale = actual_weekly.abs().mean().clamp_min(eps)
 
290
  miss_loss = (
291
- torch.relu(lower - actual_weekly)
292
- + torch.relu(actual_weekly - upper)
293
  ).mean() / actual_scale
294
 
295
- actual_std = actual_weekly.std(unbiased=False).clamp_min(eps)
296
  width_ratio = width.mean() / (2.56 * actual_std + eps)
297
  target_width = torch.as_tensor(
298
  float(target_width_ratio),
299
  device=pred_weekly_quantiles.device,
300
  dtype=pred_weekly_quantiles.dtype,
301
  )
302
- width_scale_loss = (width_ratio - target_width).pow(2)
 
 
 
 
303
  return miss_loss + width_scale_loss
304
 
305
 
@@ -440,6 +447,11 @@ try:
440
  self.sharpe_eps = sharpe_eps
441
  self.debug_mode = debug_mode
442
  self.median_idx = len(quantiles) // 2
 
 
 
 
 
443
  self.reset_component_accumulators()
444
 
445
  def reset_component_accumulators(self) -> None:
@@ -539,7 +551,7 @@ try:
539
  }
540
 
541
  def _pinball(self, pred: torch.Tensor, actual: torch.Tensor) -> torch.Tensor:
542
- q = torch.tensor(self.quantiles, device=pred.device, dtype=pred.dtype).view(1, -1)
543
  err = actual.unsqueeze(-1) - pred
544
  return torch.maximum(q * err, (q - 1.0) * err).mean()
545
 
@@ -592,7 +604,20 @@ try:
592
  pred_weekly_quantiles = ordered_pred.sum(dim=1)
593
  actual_weekly = y_actual.sum(dim=1)
594
 
595
- weekly_q_loss = self._pinball(pred_weekly_quantiles, actual_weekly)
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  t1_q_loss = super().loss(ordered_pred[:, 0:1, :], y_actual[:, 0:1])
597
  t1_directional_loss = _directional_sign_loss(
598
  median_path[:, 0],
@@ -603,7 +628,12 @@ try:
603
 
604
  pred_weekly_median = median_path.sum(dim=1)
605
  eps = self.sharpe_eps
606
- scale_losses = _weekly_scale_losses(pred_weekly_median, actual_weekly, eps=eps)
 
 
 
 
 
607
  dispersion_loss = scale_losses["dispersion_loss"]
608
  magnitude_loss = scale_losses["magnitude_loss"]
609
  naive_relative_loss = scale_losses["naive_relative_loss"]
 
111
  # one per sample. This creates deployment margin without letting extreme
112
  # initialization outliers dominate the objective again.
113
  moderate_violation_barrier = torch.tanh(above_cap_excess).pow(2)
114
+ # Keep a sublinear sqrt restoring penalty on moderate excess above the cap
115
+ # so gradients remain strong at 1.2x-5x violations while staying sublinear
116
+ # on extreme initialization outliers.
117
+ sqrt_excess_penalty = torch.sqrt(1.0 + above_cap_excess) - 1.0
118
 
119
  return (
120
  0.10 * _robust_squared(near_cap_excess).mean()
121
  + 2.0 * _robust_squared(above_cap_excess).mean()
122
  + moderate_violation_barrier.mean()
123
+ + 1.5 * sqrt_excess_penalty.mean()
124
  )
125
 
126
 
 
128
  pred_weekly_median: torch.Tensor,
129
  actual_weekly: torch.Tensor,
130
  eps: float = 1e-8,
131
+ raw_pred_weekly_median: Optional[torch.Tensor] = None,
132
  ) -> dict[str, torch.Tensor]:
133
  """
134
  Scale diagnostics used by WeeklyASROPFLoss.
135
 
136
  Hyperopt promotes on weekly_magnitude_ratio, so the loss must penalize the
137
  same failure mode directly instead of relying only on slowly growing
138
+ log-ratio pressure. When raw_pred_weekly_median is provided, also apply
139
+ scale penalties before cap clipping so gradients never zero out at the cap.
140
  """
141
+ actual_ref = actual_weekly.clamp(min=-0.25, max=0.25)
142
  pred_abs_median = pred_weekly_median.abs().median() + eps
143
+ actual_abs_median = actual_ref.abs().median() + eps
144
  magnitude_ratio = pred_abs_median / actual_abs_median
145
 
146
  pred_abs_mean = pred_weekly_median.abs().mean() + eps
147
+ actual_abs_mean = actual_ref.abs().mean() + eps
148
  mean_magnitude_ratio = pred_abs_mean / actual_abs_mean
149
 
150
  def _bounded_scale_loss(ratio: torch.Tensor) -> torch.Tensor:
 
158
  + 4.0 * structural_explosion.pow(2)
159
  )
160
 
161
+ bounded_mag_loss = _bounded_scale_loss(mean_magnitude_ratio)
162
+ if raw_pred_weekly_median is not None:
163
+ raw_abs_mean = raw_pred_weekly_median.abs().mean() + eps
164
+ raw_ratio = raw_abs_mean / actual_abs_mean
165
+ raw_mag_loss = (
166
+ _bounded_scale_loss(raw_ratio)
167
+ + torch.relu(raw_ratio - 1.20).pow(2)
168
+ + torch.relu(0.75 - raw_ratio).pow(2)
169
+ )
170
+ magnitude_loss = 0.5 * bounded_mag_loss + 0.5 * raw_mag_loss
171
+ effective_pred_weekly_median = 0.5 * pred_weekly_median + 0.5 * raw_pred_weekly_median
172
+ else:
173
+ magnitude_loss = bounded_mag_loss
174
+ effective_pred_weekly_median = pred_weekly_median
175
+
176
+ pred_std = effective_pred_weekly_median.std(unbiased=False) + eps
177
+ actual_std = actual_ref.std(unbiased=False) + eps
178
  dispersion_log_error = torch.log(
179
  (pred_std / actual_std).clamp(min=0.25, max=4.0)
180
  )
 
184
  beta=0.5,
185
  )
186
 
187
+ model_mae = torch.mean(torch.abs(effective_pred_weekly_median - actual_ref))
188
  zero_mae = actual_abs_mean
189
  naive_relative_loss = torch.relu((model_mae / zero_mae) - 1.0)
190
+ mean_gap = (effective_pred_weekly_median.mean() - actual_ref.mean()) / zero_mae
 
 
 
 
 
 
191
  bias_loss = 1.50 * torch.abs(mean_gap)
192
 
193
  return {
 
288
  upper = pred_weekly_quantiles[:, q90_idx]
289
  width = torch.clamp(upper - lower, min=0.0)
290
 
291
+ actual_ref = actual_weekly.clamp(min=-0.25, max=0.25)
292
+ actual_scale = actual_ref.abs().mean().clamp_min(eps)
293
  miss_loss = (
294
+ torch.relu(lower - actual_ref)
295
+ + torch.relu(actual_ref - upper)
296
  ).mean() / actual_scale
297
 
298
+ actual_std = actual_ref.std(unbiased=False).clamp_min(eps)
299
  width_ratio = width.mean() / (2.56 * actual_std + eps)
300
  target_width = torch.as_tensor(
301
  float(target_width_ratio),
302
  device=pred_weekly_quantiles.device,
303
  dtype=pred_weekly_quantiles.dtype,
304
  )
305
+ width_scale_loss = F.smooth_l1_loss(
306
+ width_ratio.clamp(max=4.0),
307
+ target_width.clamp(max=4.0),
308
+ beta=0.25,
309
+ )
310
  return miss_loss + width_scale_loss
311
 
312
 
 
447
  self.sharpe_eps = sharpe_eps
448
  self.debug_mode = debug_mode
449
  self.median_idx = len(quantiles) // 2
450
+ self.register_buffer(
451
+ "_pinball_q_buf",
452
+ torch.tensor(quantiles, dtype=torch.float32).view(1, -1),
453
+ persistent=False,
454
+ )
455
  self.reset_component_accumulators()
456
 
457
  def reset_component_accumulators(self) -> None:
 
551
  }
552
 
553
  def _pinball(self, pred: torch.Tensor, actual: torch.Tensor) -> torch.Tensor:
554
+ q = self._pinball_q_buf.to(device=pred.device, dtype=pred.dtype)
555
  err = actual.unsqueeze(-1) - pred
556
  return torch.maximum(q * err, (q - 1.0) * err).mean()
557
 
 
604
  pred_weekly_quantiles = ordered_pred.sum(dim=1)
605
  actual_weekly = y_actual.sum(dim=1)
606
 
607
+ weekly_q_loss_bounded = self._pinball(pred_weekly_quantiles, actual_weekly)
608
+ if self.weekly_median_cap is not None and float(self.weekly_median_cap) > 0.0:
609
+ # Non-median quantile spreads from enforce_monotonic_quantiles depend only on
610
+ # non-median channels (untouched by _bound_weekly_median_path), so the raw
611
+ # ordered weekly sum equals pred_weekly_quantiles shifted by the median diff.
612
+ median_shift_sum = (raw_median_path - median_path).sum(dim=1, keepdim=True)
613
+ weekly_q_loss_raw = self._pinball(
614
+ pred_weekly_quantiles + median_shift_sum,
615
+ actual_weekly,
616
+ )
617
+ weekly_q_loss = 0.7 * weekly_q_loss_bounded + 0.3 * weekly_q_loss_raw
618
+ else:
619
+ weekly_q_loss = weekly_q_loss_bounded
620
+
621
  t1_q_loss = super().loss(ordered_pred[:, 0:1, :], y_actual[:, 0:1])
622
  t1_directional_loss = _directional_sign_loss(
623
  median_path[:, 0],
 
628
 
629
  pred_weekly_median = median_path.sum(dim=1)
630
  eps = self.sharpe_eps
631
+ scale_losses = _weekly_scale_losses(
632
+ pred_weekly_median,
633
+ actual_weekly,
634
+ eps=eps,
635
+ raw_pred_weekly_median=raw_pred_weekly_median,
636
+ )
637
  dispersion_loss = scale_losses["dispersion_loss"]
638
  magnitude_loss = scale_losses["magnitude_loss"]
639
  naive_relative_loss = scale_losses["naive_relative_loss"]
deep_learning/training/hyperopt.py CHANGED
@@ -173,7 +173,23 @@ def _select_preflight_safe_trial(study, fold_diagnostics: list[dict]):
173
  if candidate["preflight_passed"]
174
  ]
175
  if eligible:
176
- selected = min(eligible, key=lambda trial: float(trial.value))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  mode = "lowest_objective_preflight_pass"
178
  elif finite_trials:
179
  selected = min(finite_trials, key=lambda trial: float(trial.value))
@@ -337,6 +353,8 @@ def _build_prune_diagnostics(study) -> tuple[dict[str, int], list[dict]]:
337
  "avg_raw_median_sort_gap",
338
  "avg_median_sort_gap",
339
  "avg_weekly_magnitude_ratio",
 
 
340
  "avg_weekly_pi80_coverage",
341
  "avg_weekly_pred_positive_rate",
342
  "avg_weekly_actual_positive_rate",
@@ -574,7 +592,10 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
574
  import torch
575
  from deep_learning.data.dataset import build_cv_folds, create_dataloaders
576
  from deep_learning.models.tft_copper import create_tft_model
577
- from deep_learning.training.callbacks import CurriculumLossScheduler
 
 
 
578
  from deep_learning.training.metrics import (
579
  apply_weekly_median_cap_np,
580
  cumulative_horizon,
@@ -608,6 +629,8 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
608
  fold_raw_median_gap_list: list[float] = []
609
  fold_weekly_objectives: list[float] = []
610
  fold_weekly_mr_list: list[float] = []
 
 
611
  fold_weekly_pi80_coverage_list: list[float] = []
612
  fold_weekly_pred_positive_rate_list: list[float] = []
613
  fold_weekly_actual_positive_rate_list: list[float] = []
@@ -677,6 +700,7 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
677
  return float("inf")
678
 
679
  callbacks = [
 
680
  EarlyStopping(
681
  monitor="val_loss",
682
  patience=trial_cfg.training.early_stopping_patience,
@@ -821,6 +845,8 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
821
  horizon=fold_cfg.forecast.primary_horizon_days,
822
  )
823
  fold_weekly_mr = float(metrics.get("weekly_magnitude_ratio", 1.0))
 
 
824
  fold_weekly_pi80_coverage = float(metrics.get("weekly_pi80_coverage", 0.0))
825
  fold_weekly_pred_positive_rate = float(metrics.get("weekly_pred_positive_rate", 0.5))
826
  fold_weekly_actual_positive_rate = float(metrics.get("weekly_actual_positive_rate", 0.5))
@@ -894,6 +920,14 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
894
  )
895
  width_penalty = max(0.0, fold_weekly_pi80_width_ratio - 1.5)
896
  tail_width_penalty = max(0.0, fold_weekly_pi96_width_ratio - 3.0)
 
 
 
 
 
 
 
 
897
  fold_weekly_objective = (
898
  0.35 * weekly_pinball
899
  + 0.15 * (1.0 - float(metrics.get("weekly_directional_accuracy", 0.5)))
@@ -905,6 +939,9 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
905
  + 0.10 * interval_score_penalty
906
  + 0.05 * interval_score_96_penalty
907
  + 0.25 * fold_weekly_sorted_crossing
 
 
 
908
  )
909
  except Exception as exc:
910
  logger.warning(
@@ -921,6 +958,8 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
921
  fold_raw_median_gap_list.append(fold_raw_median_gap)
922
  fold_weekly_objectives.append(fold_weekly_objective)
923
  fold_weekly_mr_list.append(fold_weekly_mr)
 
 
924
  fold_weekly_pi80_coverage_list.append(fold_weekly_pi80_coverage)
925
  fold_weekly_pred_positive_rate_list.append(fold_weekly_pred_positive_rate)
926
  fold_weekly_actual_positive_rate_list.append(fold_weekly_actual_positive_rate)
@@ -1078,6 +1117,12 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
1078
  float(np.mean(fold_raw_median_gap_list)) if fold_raw_median_gap_list else 0.0
1079
  )
1080
  avg_weekly_mr = float(np.mean(fold_weekly_mr_list)) if fold_weekly_mr_list else 1.0
 
 
 
 
 
 
1081
  avg_weekly_pi80_coverage = (
1082
  float(np.mean(fold_weekly_pi80_coverage_list))
1083
  if fold_weekly_pi80_coverage_list
@@ -1145,6 +1190,8 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
1145
  trial.set_user_attr("avg_raw_median_sort_gap", round(avg_raw_median_gap, 4))
1146
  trial.set_user_attr("avg_median_sort_gap", round(avg_median_gap, 4))
1147
  trial.set_user_attr("avg_weekly_magnitude_ratio", round(avg_weekly_mr, 4))
 
 
1148
  trial.set_user_attr("avg_weekly_pi80_coverage", round(avg_weekly_pi80_coverage, 4))
1149
  trial.set_user_attr("avg_weekly_pred_positive_rate", round(avg_weekly_pred_positive_rate, 4))
1150
  trial.set_user_attr("avg_weekly_actual_positive_rate", round(avg_weekly_actual_positive_rate, 4))
 
173
  if candidate["preflight_passed"]
174
  ]
175
  if eligible:
176
+ gate_safe = [
177
+ trial
178
+ for trial in eligible
179
+ if 0.65 <= float(
180
+ trial.user_attrs.get(
181
+ "avg_weekly_magnitude_ratio",
182
+ diagnostics_by_trial.get(int(trial.number), {}).get("avg_weekly_magnitude_ratio", 1.0),
183
+ )
184
+ ) <= 1.35
185
+ and float(
186
+ trial.user_attrs.get(
187
+ "avg_weekly_raw_magnitude_ratio",
188
+ diagnostics_by_trial.get(int(trial.number), {}).get("avg_weekly_raw_magnitude_ratio", 1.0),
189
+ )
190
+ ) <= 3.0
191
+ ]
192
+ selected = min(gate_safe or eligible, key=lambda trial: float(trial.value))
193
  mode = "lowest_objective_preflight_pass"
194
  elif finite_trials:
195
  selected = min(finite_trials, key=lambda trial: float(trial.value))
 
353
  "avg_raw_median_sort_gap",
354
  "avg_median_sort_gap",
355
  "avg_weekly_magnitude_ratio",
356
+ "avg_weekly_raw_magnitude_ratio",
357
+ "avg_weekly_median_bound_rate",
358
  "avg_weekly_pi80_coverage",
359
  "avg_weekly_pred_positive_rate",
360
  "avg_weekly_actual_positive_rate",
 
592
  import torch
593
  from deep_learning.data.dataset import build_cv_folds, create_dataloaders
594
  from deep_learning.models.tft_copper import create_tft_model
595
+ from deep_learning.training.callbacks import (
596
+ CurriculumLossScheduler,
597
+ WeeklyLossComponentLogger,
598
+ )
599
  from deep_learning.training.metrics import (
600
  apply_weekly_median_cap_np,
601
  cumulative_horizon,
 
629
  fold_raw_median_gap_list: list[float] = []
630
  fold_weekly_objectives: list[float] = []
631
  fold_weekly_mr_list: list[float] = []
632
+ fold_weekly_raw_mr_list: list[float] = []
633
+ fold_weekly_bound_rate_list: list[float] = []
634
  fold_weekly_pi80_coverage_list: list[float] = []
635
  fold_weekly_pred_positive_rate_list: list[float] = []
636
  fold_weekly_actual_positive_rate_list: list[float] = []
 
700
  return float("inf")
701
 
702
  callbacks = [
703
+ WeeklyLossComponentLogger(),
704
  EarlyStopping(
705
  monitor="val_loss",
706
  patience=trial_cfg.training.early_stopping_patience,
 
845
  horizon=fold_cfg.forecast.primary_horizon_days,
846
  )
847
  fold_weekly_mr = float(metrics.get("weekly_magnitude_ratio", 1.0))
848
+ fold_weekly_raw_mr = float(metrics.get("weekly_raw_magnitude_ratio", fold_weekly_mr))
849
+ fold_weekly_bound_rate = float(metrics.get("weekly_median_bound_applied_rate", 0.0))
850
  fold_weekly_pi80_coverage = float(metrics.get("weekly_pi80_coverage", 0.0))
851
  fold_weekly_pred_positive_rate = float(metrics.get("weekly_pred_positive_rate", 0.5))
852
  fold_weekly_actual_positive_rate = float(metrics.get("weekly_actual_positive_rate", 0.5))
 
920
  )
921
  width_penalty = max(0.0, fold_weekly_pi80_width_ratio - 1.5)
922
  tail_width_penalty = max(0.0, fold_weekly_pi96_width_ratio - 3.0)
923
+ raw_magnitude_penalty = (
924
+ 0.40 * max(0.0, fold_weekly_raw_mr - 1.8)
925
+ + 2.0 * max(0.0, fold_weekly_raw_mr - 3.0)
926
+ )
927
+ bounded_magnitude_band_penalty = 1.50 * (
928
+ max(0.0, fold_weekly_mr - 1.30) + max(0.0, 0.70 - fold_weekly_mr)
929
+ )
930
+ bound_rate_penalty = 0.35 * max(0.0, fold_weekly_bound_rate - 0.30)
931
  fold_weekly_objective = (
932
  0.35 * weekly_pinball
933
  + 0.15 * (1.0 - float(metrics.get("weekly_directional_accuracy", 0.5)))
 
939
  + 0.10 * interval_score_penalty
940
  + 0.05 * interval_score_96_penalty
941
  + 0.25 * fold_weekly_sorted_crossing
942
+ + raw_magnitude_penalty
943
+ + bounded_magnitude_band_penalty
944
+ + bound_rate_penalty
945
  )
946
  except Exception as exc:
947
  logger.warning(
 
958
  fold_raw_median_gap_list.append(fold_raw_median_gap)
959
  fold_weekly_objectives.append(fold_weekly_objective)
960
  fold_weekly_mr_list.append(fold_weekly_mr)
961
+ fold_weekly_raw_mr_list.append(fold_weekly_raw_mr)
962
+ fold_weekly_bound_rate_list.append(fold_weekly_bound_rate)
963
  fold_weekly_pi80_coverage_list.append(fold_weekly_pi80_coverage)
964
  fold_weekly_pred_positive_rate_list.append(fold_weekly_pred_positive_rate)
965
  fold_weekly_actual_positive_rate_list.append(fold_weekly_actual_positive_rate)
 
1117
  float(np.mean(fold_raw_median_gap_list)) if fold_raw_median_gap_list else 0.0
1118
  )
1119
  avg_weekly_mr = float(np.mean(fold_weekly_mr_list)) if fold_weekly_mr_list else 1.0
1120
+ avg_weekly_raw_mr = (
1121
+ float(np.mean(fold_weekly_raw_mr_list)) if fold_weekly_raw_mr_list else avg_weekly_mr
1122
+ )
1123
+ avg_weekly_bound_rate = (
1124
+ float(np.mean(fold_weekly_bound_rate_list)) if fold_weekly_bound_rate_list else 0.0
1125
+ )
1126
  avg_weekly_pi80_coverage = (
1127
  float(np.mean(fold_weekly_pi80_coverage_list))
1128
  if fold_weekly_pi80_coverage_list
 
1190
  trial.set_user_attr("avg_raw_median_sort_gap", round(avg_raw_median_gap, 4))
1191
  trial.set_user_attr("avg_median_sort_gap", round(avg_median_gap, 4))
1192
  trial.set_user_attr("avg_weekly_magnitude_ratio", round(avg_weekly_mr, 4))
1193
+ trial.set_user_attr("avg_weekly_raw_magnitude_ratio", round(avg_weekly_raw_mr, 4))
1194
+ trial.set_user_attr("avg_weekly_median_bound_rate", round(avg_weekly_bound_rate, 4))
1195
  trial.set_user_attr("avg_weekly_pi80_coverage", round(avg_weekly_pi80_coverage, 4))
1196
  trial.set_user_attr("avg_weekly_pred_positive_rate", round(avg_weekly_pred_positive_rate, 4))
1197
  trial.set_user_attr("avg_weekly_actual_positive_rate", round(avg_weekly_actual_positive_rate, 4))
deep_learning/training/metrics.py CHANGED
@@ -954,6 +954,150 @@ def apply_weekly_sign_correction_np(
954
  return arr
955
 
956
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
957
  def prediction_interval_coverage(
958
  y_actual: np.ndarray,
959
  lower: np.ndarray,
 
954
  return arr
955
 
956
 
957
+ def apply_weekly_magnitude_scale_np(
958
+ pred: np.ndarray,
959
+ scale: float = 1.0,
960
+ *,
961
+ quantiles: tuple[float, ...] = (0.02, 0.10, 0.25, 0.50, 0.75, 0.90, 0.98),
962
+ ) -> np.ndarray:
963
+ """Scale the median return path while shifting quantiles uniformly to preserve order."""
964
+ arr = np.asarray(pred, dtype=np.float64).copy()
965
+ if arr.ndim != 3:
966
+ raise ValueError(f"Expected [n,horizon,q] predictions, got {arr.shape}")
967
+ if arr.shape[-1] != len(quantiles):
968
+ raise ValueError(
969
+ f"Quantile dim mismatch: prediction has {arr.shape[-1]}, config has {len(quantiles)}"
970
+ )
971
+ s = float(scale)
972
+ if not np.isfinite(s) or s <= 0.0:
973
+ raise ValueError(f"Weekly magnitude scale must be finite and positive, got {scale!r}")
974
+ if abs(s - 1.0) <= 1e-6 or arr.shape[0] == 0:
975
+ return arr
976
+
977
+ median_idx = len(quantiles) // 2
978
+ median = arr[..., median_idx : median_idx + 1]
979
+ return arr + (s - 1.0) * median
980
+
981
+
982
+ def fit_weekly_magnitude_scale(
983
+ y_actual_path: np.ndarray,
984
+ y_pred_quantiles_path: np.ndarray,
985
+ *,
986
+ horizon: int = 5,
987
+ quantiles: tuple[float, ...] = (0.02, 0.10, 0.25, 0.50, 0.75, 0.90, 0.98),
988
+ weekly_median_cap: float | None = None,
989
+ target_ratio: float = 1.0,
990
+ min_ratio_band: float = 0.85,
991
+ max_ratio_band: float = 1.15,
992
+ min_scale: float = 0.15,
993
+ max_scale: float = 1.35,
994
+ min_samples: int = 10,
995
+ ) -> dict[str, float | int | bool | str]:
996
+ """Fit a validation-only median magnitude scale multiplier.
997
+
998
+ Rescales over- or under-confident raw median paths using chronological
999
+ validation targets so validation predictions do not saturate against
1000
+ weekly_median_cap and test/live magnitude ratios remain inside gate bounds.
1001
+ """
1002
+ actual_path = np.asarray(y_actual_path, dtype=np.float64)
1003
+ pred_path = np.asarray(y_pred_quantiles_path, dtype=np.float64)
1004
+ if actual_path.ndim < 2 or pred_path.ndim != 3:
1005
+ raise ValueError("Magnitude calibration requires [n,horizon] targets and [n,horizon,q] predictions")
1006
+ if actual_path.shape[1] < horizon or pred_path.shape[1] < horizon:
1007
+ raise ValueError(f"Magnitude calibration requires a {horizon}-step prediction path")
1008
+
1009
+ n = min(len(actual_path), len(pred_path))
1010
+ median_idx = len(quantiles) // 2
1011
+ actual_path = actual_path[:n]
1012
+ pred_path = pred_path[:n]
1013
+ weekly_actual = cumulative_horizon(actual_path, horizon=horizon)
1014
+
1015
+ raw_ordered = monotonic_quantiles_np(pred_path, median_idx=median_idx)
1016
+ raw_weekly_pred = raw_ordered[:, :horizon, median_idx].sum(axis=1)
1017
+ raw_ratio = magnitude_ratio(weekly_actual, raw_weekly_pred)
1018
+
1019
+ if weekly_median_cap is not None and float(weekly_median_cap) > 0.0:
1020
+ bounded_path, _ = apply_weekly_median_cap_np(
1021
+ pred_path,
1022
+ weekly_median_cap=weekly_median_cap,
1023
+ quantiles=quantiles,
1024
+ horizon=horizon,
1025
+ )
1026
+ bounded_ordered = monotonic_quantiles_np(bounded_path, median_idx=median_idx)
1027
+ bounded_weekly_pred = bounded_ordered[:, :horizon, median_idx].sum(axis=1)
1028
+ bounded_ratio = magnitude_ratio(weekly_actual, bounded_weekly_pred)
1029
+ else:
1030
+ bounded_ratio = raw_ratio
1031
+
1032
+ selected_scale = 1.0
1033
+ applied = False
1034
+ raw_ratio_after = raw_ratio
1035
+ bounded_ratio_after = bounded_ratio
1036
+
1037
+ if (
1038
+ n >= int(min_samples)
1039
+ and raw_ratio > 1e-6
1040
+ and (
1041
+ bounded_ratio < float(min_ratio_band)
1042
+ or bounded_ratio > float(max_ratio_band)
1043
+ or raw_ratio > 1.50
1044
+ )
1045
+ ):
1046
+ candidate_scale = float(
1047
+ np.clip(float(target_ratio) / raw_ratio, float(min_scale), float(max_scale))
1048
+ )
1049
+ if abs(candidate_scale - 1.0) > 1e-3:
1050
+ scaled_path = apply_weekly_magnitude_scale_np(
1051
+ pred_path,
1052
+ candidate_scale,
1053
+ quantiles=quantiles,
1054
+ )
1055
+ scaled_raw_ordered = monotonic_quantiles_np(scaled_path, median_idx=median_idx)
1056
+ scaled_raw_weekly = scaled_raw_ordered[:, :horizon, median_idx].sum(axis=1)
1057
+ candidate_raw_ratio = magnitude_ratio(weekly_actual, scaled_raw_weekly)
1058
+ if weekly_median_cap is not None and float(weekly_median_cap) > 0.0:
1059
+ scaled_bounded_path, _ = apply_weekly_median_cap_np(
1060
+ scaled_path,
1061
+ weekly_median_cap=weekly_median_cap,
1062
+ quantiles=quantiles,
1063
+ horizon=horizon,
1064
+ )
1065
+ scaled_bounded_ordered = monotonic_quantiles_np(
1066
+ scaled_bounded_path,
1067
+ median_idx=median_idx,
1068
+ )
1069
+ candidate_bounded_ratio = magnitude_ratio(
1070
+ weekly_actual,
1071
+ scaled_bounded_ordered[:, :horizon, median_idx].sum(axis=1),
1072
+ )
1073
+ else:
1074
+ candidate_bounded_ratio = candidate_raw_ratio
1075
+
1076
+ improves_bounded = abs(candidate_bounded_ratio - float(target_ratio)) < abs(
1077
+ bounded_ratio - float(target_ratio)
1078
+ )
1079
+ improves_raw = raw_ratio > 1.50 and candidate_raw_ratio < raw_ratio
1080
+ if (improves_bounded or improves_raw) and (
1081
+ 0.65 <= candidate_bounded_ratio <= 1.35 or improves_bounded
1082
+ ):
1083
+ selected_scale = candidate_scale
1084
+ applied = True
1085
+ raw_ratio_after = candidate_raw_ratio
1086
+ bounded_ratio_after = candidate_bounded_ratio
1087
+
1088
+ return {
1089
+ "fit_split": "validation",
1090
+ "sample_count": int(n),
1091
+ "applied": bool(applied),
1092
+ "scale": float(selected_scale),
1093
+ "weekly_magnitude_scale": float(selected_scale),
1094
+ "validation_raw_magnitude_ratio_before": float(raw_ratio),
1095
+ "validation_magnitude_ratio_before": float(bounded_ratio),
1096
+ "validation_raw_magnitude_ratio_after": float(raw_ratio_after),
1097
+ "validation_magnitude_ratio_after": float(bounded_ratio_after),
1098
+ }
1099
+
1100
+
1101
  def prediction_interval_coverage(
1102
  y_actual: np.ndarray,
1103
  lower: np.ndarray,
deep_learning/training/trainer.py CHANGED
@@ -847,6 +847,14 @@ def train_tft_model(
847
  "fit_split": "validation",
848
  "sample_count": 0,
849
  "direction_sign_multiplier": 1,
 
 
 
 
 
 
 
 
850
  }
851
  interval_calibration = {
852
  "fit_split": "validation",
@@ -860,11 +868,13 @@ def train_tft_model(
860
  val_pred_np = None
861
  try:
862
  from deep_learning.training.metrics import (
 
863
  apply_weekly_sign_correction_np,
864
  cumulative_horizon,
865
  directional_accuracy,
866
  fit_direction_sign_calibration,
867
  fit_weekly_interval_scale,
 
868
  )
869
 
870
  val_pred_np, val_actual_path = _predict_quantiles_to_np(
@@ -943,6 +953,20 @@ def train_tft_model(
943
  weekly_direction_model["validation_pred_positive_rate"] = candidate_rate
944
  else:
945
  weekly_direction_model["reason"] = "validation_origin_count_mismatch"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
946
  if WEEKLY_INTERVAL_CONDITIONING_FEATURE in master_df.columns:
947
  validation_interval_conditioning_values = _weekly_interval_conditioning_values(
948
  master_df,
@@ -975,8 +999,11 @@ def train_tft_model(
975
  # OOS replay showed that they can reverse the held-out direction.
976
  daily_sign_multiplier = 1
977
  weekly_sign_threshold = float(direction_calibration.get("weekly_sign_threshold", 0.0))
 
 
978
  weekly_interval_scale = float(interval_calibration.get("weekly_interval_scale", 1.0))
979
  logger.info("Validation-only direction calibration: %s", direction_calibration)
 
980
  logger.info("Validation-only weekly interval calibration: %s", interval_calibration)
981
 
982
  # ---- 8. Evaluate on the promoted best checkpoint ----
@@ -987,6 +1014,7 @@ def train_tft_model(
987
  if test_dl is not None:
988
  import torch
989
  from deep_learning.training.metrics import (
 
990
  apply_weekly_sign_correction_np,
991
  )
992
 
@@ -1025,6 +1053,11 @@ def train_tft_model(
1025
  threshold=float(weekly_direction_model.get("decision_threshold", 0.50)),
1026
  horizon=cfg.forecast.primary_horizon_days,
1027
  )
 
 
 
 
 
1028
  test_interval_conditioning_values = None
1029
  if interval_calibration.get("weekly_interval_conditioning_enabled"):
1030
  test_interval_conditioning_values = _weekly_interval_conditioning_values(
@@ -1057,6 +1090,7 @@ def train_tft_model(
1057
  direction_sign_multiplier=direction_sign_multiplier,
1058
  daily_sign_multiplier=daily_sign_multiplier,
1059
  weekly_sign_threshold=weekly_sign_threshold,
 
1060
  weekly_direction_model=weekly_direction_model,
1061
  validation_start_time=train_cutoff - 1,
1062
  validation_end_time=val_cutoff - cfg.forecast.primary_horizon_days,
@@ -1119,6 +1153,7 @@ def train_tft_model(
1119
  "data_snapshot": _build_data_snapshot_metadata(master_df),
1120
  "runtime_environment": _runtime_environment_metadata(),
1121
  "direction_calibration": direction_calibration,
 
1122
  "weekly_direction_model": weekly_direction_model,
1123
  "interval_calibration": interval_calibration,
1124
  "checkpoint_selection": checkpoint_selection,
@@ -1190,6 +1225,7 @@ def _write_conformal_calibration_artifact(
1190
  direction_sign_multiplier: int = 1,
1191
  daily_sign_multiplier: int = 1,
1192
  weekly_sign_threshold: float = 0.0,
 
1193
  weekly_direction_model: Optional[dict] = None,
1194
  validation_start_time: Optional[int] = None,
1195
  validation_end_time: Optional[int] = None,
@@ -1211,6 +1247,7 @@ def _write_conformal_calibration_artifact(
1211
  rolling_conformal_adjustment,
1212
  )
1213
  from deep_learning.training.metrics import (
 
1214
  apply_weekly_sign_correction_np,
1215
  apply_weekly_median_cap_np,
1216
  apply_weekly_interval_scale_np,
@@ -1255,6 +1292,11 @@ def _write_conformal_calibration_artifact(
1255
  threshold=float(weekly_direction_model.get("decision_threshold", 0.50)),
1256
  horizon=cfg.forecast.primary_horizon_days,
1257
  )
 
 
 
 
 
1258
  interval_calibration = weekly_interval_calibration or {}
1259
  effective_interval_scale = float(
1260
  interval_calibration.get("weekly_interval_scale", weekly_interval_scale)
 
847
  "fit_split": "validation",
848
  "sample_count": 0,
849
  "direction_sign_multiplier": 1,
850
+ "weekly_magnitude_scale": 1.0,
851
+ }
852
+ magnitude_calibration = {
853
+ "fit_split": "validation",
854
+ "sample_count": 0,
855
+ "applied": False,
856
+ "scale": 1.0,
857
+ "weekly_magnitude_scale": 1.0,
858
  }
859
  interval_calibration = {
860
  "fit_split": "validation",
 
868
  val_pred_np = None
869
  try:
870
  from deep_learning.training.metrics import (
871
+ apply_weekly_magnitude_scale_np,
872
  apply_weekly_sign_correction_np,
873
  cumulative_horizon,
874
  directional_accuracy,
875
  fit_direction_sign_calibration,
876
  fit_weekly_interval_scale,
877
+ fit_weekly_magnitude_scale,
878
  )
879
 
880
  val_pred_np, val_actual_path = _predict_quantiles_to_np(
 
953
  weekly_direction_model["validation_pred_positive_rate"] = candidate_rate
954
  else:
955
  weekly_direction_model["reason"] = "validation_origin_count_mismatch"
956
+ magnitude_calibration = fit_weekly_magnitude_scale(
957
+ val_actual_path,
958
+ oriented_val_pred_np,
959
+ horizon=cfg.forecast.primary_horizon_days,
960
+ quantiles=tuple(cfg.model.quantiles),
961
+ weekly_median_cap=cfg.weekly_loss.weekly_median_cap,
962
+ )
963
+ weekly_magnitude_scale = float(magnitude_calibration.get("scale", 1.0))
964
+ direction_calibration["weekly_magnitude_scale"] = weekly_magnitude_scale
965
+ oriented_val_pred_np = apply_weekly_magnitude_scale_np(
966
+ oriented_val_pred_np,
967
+ weekly_magnitude_scale,
968
+ quantiles=tuple(cfg.model.quantiles),
969
+ )
970
  if WEEKLY_INTERVAL_CONDITIONING_FEATURE in master_df.columns:
971
  validation_interval_conditioning_values = _weekly_interval_conditioning_values(
972
  master_df,
 
999
  # OOS replay showed that they can reverse the held-out direction.
1000
  daily_sign_multiplier = 1
1001
  weekly_sign_threshold = float(direction_calibration.get("weekly_sign_threshold", 0.0))
1002
+ weekly_magnitude_scale = float(magnitude_calibration.get("scale", 1.0))
1003
+ direction_calibration.setdefault("weekly_magnitude_scale", weekly_magnitude_scale)
1004
  weekly_interval_scale = float(interval_calibration.get("weekly_interval_scale", 1.0))
1005
  logger.info("Validation-only direction calibration: %s", direction_calibration)
1006
+ logger.info("Validation-only magnitude calibration: %s", magnitude_calibration)
1007
  logger.info("Validation-only weekly interval calibration: %s", interval_calibration)
1008
 
1009
  # ---- 8. Evaluate on the promoted best checkpoint ----
 
1014
  if test_dl is not None:
1015
  import torch
1016
  from deep_learning.training.metrics import (
1017
+ apply_weekly_magnitude_scale_np,
1018
  apply_weekly_sign_correction_np,
1019
  )
1020
 
 
1053
  threshold=float(weekly_direction_model.get("decision_threshold", 0.50)),
1054
  horizon=cfg.forecast.primary_horizon_days,
1055
  )
1056
+ pred_np = apply_weekly_magnitude_scale_np(
1057
+ pred_np,
1058
+ weekly_magnitude_scale,
1059
+ quantiles=tuple(cfg.model.quantiles),
1060
+ )
1061
  test_interval_conditioning_values = None
1062
  if interval_calibration.get("weekly_interval_conditioning_enabled"):
1063
  test_interval_conditioning_values = _weekly_interval_conditioning_values(
 
1090
  direction_sign_multiplier=direction_sign_multiplier,
1091
  daily_sign_multiplier=daily_sign_multiplier,
1092
  weekly_sign_threshold=weekly_sign_threshold,
1093
+ weekly_magnitude_scale=weekly_magnitude_scale,
1094
  weekly_direction_model=weekly_direction_model,
1095
  validation_start_time=train_cutoff - 1,
1096
  validation_end_time=val_cutoff - cfg.forecast.primary_horizon_days,
 
1153
  "data_snapshot": _build_data_snapshot_metadata(master_df),
1154
  "runtime_environment": _runtime_environment_metadata(),
1155
  "direction_calibration": direction_calibration,
1156
+ "magnitude_calibration": magnitude_calibration,
1157
  "weekly_direction_model": weekly_direction_model,
1158
  "interval_calibration": interval_calibration,
1159
  "checkpoint_selection": checkpoint_selection,
 
1225
  direction_sign_multiplier: int = 1,
1226
  daily_sign_multiplier: int = 1,
1227
  weekly_sign_threshold: float = 0.0,
1228
+ weekly_magnitude_scale: float = 1.0,
1229
  weekly_direction_model: Optional[dict] = None,
1230
  validation_start_time: Optional[int] = None,
1231
  validation_end_time: Optional[int] = None,
 
1247
  rolling_conformal_adjustment,
1248
  )
1249
  from deep_learning.training.metrics import (
1250
+ apply_weekly_magnitude_scale_np,
1251
  apply_weekly_sign_correction_np,
1252
  apply_weekly_median_cap_np,
1253
  apply_weekly_interval_scale_np,
 
1292
  threshold=float(weekly_direction_model.get("decision_threshold", 0.50)),
1293
  horizon=cfg.forecast.primary_horizon_days,
1294
  )
1295
+ pred_np = apply_weekly_magnitude_scale_np(
1296
+ pred_np,
1297
+ float(weekly_magnitude_scale),
1298
+ quantiles=tuple(cfg.model.quantiles),
1299
+ )
1300
  interval_calibration = weekly_interval_calibration or {}
1301
  effective_interval_scale = float(
1302
  interval_calibration.get("weekly_interval_scale", weekly_interval_scale)