Take model tuning from config too, not from the saved state

The companion to the Kalman fix. 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. Editing config.yaml and restarting looks
exactly like a change with no effect, which is the failure mode that cost real
time on the Kalman side of this before it was found.

Only the estimate is state now. Weights, covariances and conformal scores are
restored; tuning is re-applied from config on every load. The conformal deques
are rebuilt when the configured window changes, preserving their contents.

load_dict also skips heads for a target or horizon this build no longer has,
rather than resurrecting them from a stale file.

Found while implementing a damped-trend ensemble member, which was then
abandoned: see the following note.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-19 19:31:03 +01:00
co-authored by Claude Opus 5
parent 2e6d84f17d
commit 0672ee80ec
3 changed files with 88 additions and 1 deletions
+23 -1
View File
@@ -151,6 +151,7 @@ class NowcastEnsemble:
cfg_model): cfg_model):
self.targets = tuple(targets) self.targets = tuple(targets)
self.horizons = tuple(int(h) for h in horizons_s) self.horizons = tuple(int(h) for h in horizons_s)
self.cfg = cfg_model
self.grid_s = int(cfg_model.grid_s) self.grid_s = int(cfg_model.grid_s)
self.scaler = Standardiser(N_FEATURES) self.scaler = Standardiser(N_FEATURES)
self.heads: Dict[Tuple[str, int], ForecastHead] = { self.heads: Dict[Tuple[str, int], ForecastHead] = {
@@ -306,6 +307,27 @@ class NowcastEnsemble:
self.scaler = Standardiser.from_dict(s["scaler"]) self.scaler = Standardiser.from_dict(s["scaler"])
for hs in s["heads"]: for hs in s["heads"]:
head = ForecastHead.from_dict(hs) 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.trained_rows = s.get("trained_rows", 0)
self.refit_phase = int(s.get("refit_phase", 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)
+14
View File
@@ -183,6 +183,20 @@ class AdaptiveConformal:
self.alpha = float(np.clip(self.alpha + self.gamma * (self.alpha_target - err), self.alpha = float(np.clip(self.alpha + self.gamma * (self.alpha_target - err),
0.005, 0.75)) 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 @property
def empirical_coverage(self) -> float: def empirical_coverage(self) -> float:
return float(np.mean(self.hits)) if self.hits else float("nan") return float(np.mean(self.hits)) if self.hits else float("nan")
+51
View File
@@ -346,3 +346,54 @@ def test_refit_phase_rotates_and_survives_serialisation():
back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model) back = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
back.load_dict(ens.to_dict()) back.load_dict(ens.to_dict())
assert back.refit_phase == 2, "a restart must not reset the stride to phase 0 forever" 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)