diff --git a/ashvale/models/nowcast.py b/ashvale/models/nowcast.py index 16f7a6a..ee9bf83 100644 --- a/ashvale/models/nowcast.py +++ b/ashvale/models/nowcast.py @@ -151,6 +151,7 @@ class NowcastEnsemble: cfg_model): self.targets = tuple(targets) self.horizons = tuple(int(h) for h in horizons_s) + self.cfg = cfg_model self.grid_s = int(cfg_model.grid_s) self.scaler = Standardiser(N_FEATURES) self.heads: Dict[Tuple[str, int], ForecastHead] = { @@ -306,6 +307,27 @@ class NowcastEnsemble: self.scaler = Standardiser.from_dict(s["scaler"]) for hs in s["heads"]: head = ForecastHead.from_dict(hs) - self.heads[(head.target, head.horizon_s)] = head + key = (head.target, head.horizon_s) + if key not in self.heads: + continue # a target or horizon this build no longer has + self.heads[key] = head self.trained_rows = s.get("trained_rows", 0) self.refit_phase = int(s.get("refit_phase", 0)) + self._apply_config_tuning() + + def _apply_config_tuning(self) -> None: + """Tuning comes from config; only the estimate comes from the file. + + Every from_dict below this point restores its knobs alongside its data: + lambda, delta and p_max in the RLS, alpha, gamma and the window in the + conformal calibrator. So each of those was immutable in the field. Edit + config.yaml, restart, and the state file quietly puts the old value + back, which looks exactly like a change that had no effect. The same + defect cost a Kalman retune here before it was found. + """ + c = self.cfg + for head in self.heads.values(): + head.model.lam = float(c.rls_forgetting) + head.model.delta = float(c.rls_delta) + head.conformal.retune(c.conformal_alpha, c.conformal_gamma, + c.conformal_window) diff --git a/ashvale/models/rls.py b/ashvale/models/rls.py index 44680d1..ee11dd9 100644 --- a/ashvale/models/rls.py +++ b/ashvale/models/rls.py @@ -183,6 +183,20 @@ class AdaptiveConformal: self.alpha = float(np.clip(self.alpha + self.gamma * (self.alpha_target - err), 0.005, 0.75)) + def retune(self, alpha: float, gamma: float, window: int) -> None: + """Re-apply configured tuning, keeping the observed scores. + + from_dict restores alpha_target, gamma and the window alongside the + data, so editing any of them in config.yaml did nothing on a station + that already had state: the file put the old values straight back. + """ + self.alpha_target = float(alpha) + self.gamma = float(gamma) + window = int(window) + if self.scores.maxlen != window: + self.scores = deque(self.scores, maxlen=window) + self.hits = deque(self.hits, maxlen=window) + @property def empirical_coverage(self) -> float: return float(np.mean(self.hits)) if self.hits else float("nan") diff --git a/tests/test_models.py b/tests/test_models.py index 1a3ea96..71b8bb4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -346,3 +346,54 @@ def test_refit_phase_rotates_and_survives_serialisation(): back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model) back.load_dict(ens.to_dict()) assert back.refit_phase == 2, "a restart must not reset the stride to phase 0 forever" + + +def test_model_tuning_comes_from_config_not_from_the_state_file(): + """The same defect that made a Kalman retune silently do nothing. + + RecursiveLeastSquares.from_dict and AdaptiveConformal.from_dict restore + lambda, delta, alpha, gamma and the conformal window alongside their data, + and load_dict replaces the config-built heads with those. So every one of + those knobs was immutable on any station that already had state: edit + config.yaml, restart, and the old value comes straight back. + """ + from ashvale.config import load_config + from ashvale.models.nowcast import NowcastEnsemble + + cfg = load_config() + old = cfg.model + ens = NowcastEnsemble(old.targets, old.horizons_s, old) + saved = ens.to_dict() + + import copy + new = copy.deepcopy(old) + new.rls_forgetting = 0.995 + new.rls_delta = 55.0 + new.conformal_alpha = 0.20 + new.conformal_gamma = 0.05 + new.conformal_window = 250 + + back = NowcastEnsemble(new.targets, new.horizons_s, new) + back.load_dict(saved) + head = next(iter(back.heads.values())) + assert head.model.lam == 0.995, "state file pinned the forgetting factor" + assert head.model.delta == 55.0, "state file pinned the RLS prior" + assert head.conformal.alpha_target == 0.20 + assert head.conformal.gamma == 0.05 + assert head.conformal.scores.maxlen == 250 + + +def test_loading_state_ignores_heads_this_build_no_longer_has(): + """A stale state file must not resurrect a retired target or horizon.""" + from ashvale.config import load_config + from ashvale.models.nowcast import NowcastEnsemble + + cfg = load_config().model + ens = NowcastEnsemble(cfg.targets, cfg.horizons_s, cfg) + saved = ens.to_dict() + saved["heads"].append({**saved["heads"][0], "target": "retired_signal"}) + + back = NowcastEnsemble(cfg.targets, cfg.horizons_s, cfg) + back.load_dict(saved) + assert not any(t == "retired_signal" for t, _ in back.heads) + assert len(back.heads) == len(cfg.targets) * len(cfg.horizons_s)