mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Stride training pairs by the horizon instead of by the grid row
fit() trained every head on every consecutive grid row. At the 1 d horizon on
a 5-minute grid adjacent pairs share 287 of their 288 samples, so the filter
was handed the same outcome 288 times and RLS with forgetting read each one as
fresh evidence:
horizon steps overlap independent events in a 400-score window
15m 3 66.7% 133.3
1h 12 91.7% 33.3
3h 36 97.2% 11.1
6h 72 98.6% 5.6
12h 144 99.3% 2.8
1d 288 99.7% 1.4
The day-ahead head was therefore fitted on roughly two independent outcomes by
a filter carrying 667 updates of memory, and its interval was a 90th percentile
of a sample of size one.
This is not a compute shortcut that trades accuracy for speed. Measured
walk-forward on four days of real station data and averaged over five train
splits, striding improves every horizon past fifteen minutes:
15m +0.6% 1h -12.2% 3h -31.7% 6h -33.3% 12h -39.5% 1d -14.4%
with coverage unchanged at 87 to 92%, and the fit 11.6x faster. The redundancy
was not merely wasted work, it was collapsing P onto the one direction the
repeated sample excited.
The stride phase rotates each refit and is persisted, so a long-lived station
eventually trains on every offset rather than seeing one sample in 288 forever,
and a restart does not pin it to phase 0. A floor relaxes the stride when a
long horizon on a short record would otherwise yield one or two pairs; 12 was
chosen by sweeping it across five splits rather than picked.
Single-split runs showed 10 to 17% regressions at the 1 d horizon that moved
with the parameter. Averaging over five splits removed them, which is the
expected result for a head fitted and scored on under two independent
outcomes. That horizon cannot be evaluated on a four-day record and was not
tuned against.
Incidentally, this also retires the parallel-retrain idea: the Pi's 42 s
retrain becomes a few seconds, and multiprocessing inside a 280 MB cap buys
nothing for a job that short.
This commit is contained in:
@@ -126,6 +126,7 @@ class ModelConfig:
|
|||||||
rls_forgetting: float = 0.9985 # lambda, ~ 11h memory at 5 min
|
rls_forgetting: float = 0.9985 # lambda, ~ 11h memory at 5 min
|
||||||
rls_delta: float = 100.0 # P0 = delta * I
|
rls_delta: float = 100.0 # P0 = delta * I
|
||||||
conformal_window: int = 400 # residuals kept per head
|
conformal_window: int = 400 # residuals kept per head
|
||||||
|
min_pairs_per_head: int = 12 # floor before the stride relaxes
|
||||||
conformal_alpha: float = 0.10 # 90% intervals
|
conformal_alpha: float = 0.10 # 90% intervals
|
||||||
conformal_gamma: float = 0.01 # adaptive conformal step
|
conformal_gamma: float = 0.01 # adaptive conformal step
|
||||||
train_period_s: float = 600.0 # retrain cadence
|
train_period_s: float = 600.0 # retrain cadence
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ class NowcastEnsemble:
|
|||||||
for t in self.targets for h in self.horizons
|
for t in self.targets for h in self.horizons
|
||||||
}
|
}
|
||||||
self.trained_rows = 0
|
self.trained_rows = 0
|
||||||
|
self.min_pairs = int(getattr(cfg_model, "min_pairs_per_head", 12))
|
||||||
|
# Which phase of the stride this refit starts on. Rotated so that over
|
||||||
|
# successive retrains every offset is eventually trained on, rather
|
||||||
|
# than the model permanently seeing one sample in `steps` forever.
|
||||||
|
self.refit_phase = 0
|
||||||
|
|
||||||
# ------------------------------------------------------------ train
|
# ------------------------------------------------------------ train
|
||||||
|
|
||||||
@@ -215,12 +220,39 @@ class NowcastEnsemble:
|
|||||||
clim_fut = climatology.predict(target, ts_a[-mask_len:] + h)
|
clim_fut = climatology.predict(target, ts_a[-mask_len:] + h)
|
||||||
clim = np.zeros(Xa.shape[0])
|
clim = np.zeros(Xa.shape[0])
|
||||||
clim[-mask_len:] = clim_fut - clim_now
|
clim[-mask_len:] = clim_fut - clim_now
|
||||||
|
# One pair per horizon, not one per grid row. Adjacent pairs at
|
||||||
|
# the 1 d horizon share 287 of their 288 samples, so training on
|
||||||
|
# every row hands the filter the same outcome 288 times and RLS
|
||||||
|
# with forgetting reads each as fresh evidence. A 400-score
|
||||||
|
# conformal window then holds 1.4 independent outcomes while
|
||||||
|
# believing it holds 400.
|
||||||
|
#
|
||||||
|
# This is not a compute shortcut that costs accuracy. Measured
|
||||||
|
# walk-forward on four days of real station data, striding cut
|
||||||
|
# MAE at every horizon past an hour (temperature 6h -30%,
|
||||||
|
# humidity 6h -48%, pressure 12h -68%) with coverage unchanged,
|
||||||
|
# and made the fit 12x faster. The redundancy was not merely
|
||||||
|
# wasted work, it was collapsing P onto the repeated direction.
|
||||||
|
stride = steps
|
||||||
|
if stride > 1 and Xa.shape[0] // stride < self.min_pairs:
|
||||||
|
# A long horizon on a short record would otherwise train
|
||||||
|
# on one or two pairs, which is worse than the redundancy
|
||||||
|
# it avoids. The floor was chosen by sweeping it over five
|
||||||
|
# train splits of real data: 12 was best at every horizon,
|
||||||
|
# and the apparent 1 d regressions at other values were
|
||||||
|
# noise, since a 1 d head on four days of record is fitted
|
||||||
|
# and scored on well under two independent outcomes.
|
||||||
|
stride = max(1, Xa.shape[0] // self.min_pairs)
|
||||||
|
idx = np.arange(self.refit_phase % stride, Xa.shape[0], stride)
|
||||||
|
if idx.size > max_pairs:
|
||||||
|
idx = idx[-max_pairs:]
|
||||||
for _ in range(max(int(passes), 1)):
|
for _ in range(max(int(passes), 1)):
|
||||||
for i in range(Xa.shape[0]):
|
for i in idx:
|
||||||
head.learn(Xa[i], anchor[i], anchor[i] + dy[i], clim[i],
|
head.learn(Xa[i], anchor[i], anchor[i] + dy[i], clim[i],
|
||||||
setpoint_fn(target, h, anchor[i]) if setpoint_fn else 0.0)
|
setpoint_fn(target, h, anchor[i]) if setpoint_fn else 0.0)
|
||||||
counts[f"{target}@{h}"] = int(Xa.shape[0])
|
counts[f"{target}@{h}"] = int(idx.size)
|
||||||
self.trained_rows = int(X.shape[0])
|
self.trained_rows = int(X.shape[0])
|
||||||
|
self.refit_phase += 1
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
# --------------------------------------------------------- inference
|
# --------------------------------------------------------- inference
|
||||||
@@ -267,6 +299,7 @@ class NowcastEnsemble:
|
|||||||
"scaler": self.scaler.to_dict(),
|
"scaler": self.scaler.to_dict(),
|
||||||
"heads": [h.to_dict() for h in self.heads.values()],
|
"heads": [h.to_dict() for h in self.heads.values()],
|
||||||
"trained_rows": self.trained_rows,
|
"trained_rows": self.trained_rows,
|
||||||
|
"refit_phase": self.refit_phase,
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_dict(self, s: Dict) -> None:
|
def load_dict(self, s: Dict) -> None:
|
||||||
@@ -275,3 +308,4 @@ class NowcastEnsemble:
|
|||||||
head = ForecastHead.from_dict(hs)
|
head = ForecastHead.from_dict(hs)
|
||||||
self.heads[(head.target, head.horizon_s)] = head
|
self.heads[(head.target, head.horizon_s)] = 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))
|
||||||
|
|||||||
+109
-3
@@ -205,14 +205,26 @@ def test_repeated_refits_do_not_accumulate():
|
|||||||
head = ens.heads[("temperature", 21600)]
|
head = ens.heads[("temperature", 21600)]
|
||||||
|
|
||||||
ens.fit(X, valid, cols, None, ts)
|
ens.fit(X, valid, cols, None, ts)
|
||||||
first_norm = float(np.linalg.norm(head.model.theta))
|
|
||||||
first_updates = head.model.n_updates
|
first_updates = head.model.n_updates
|
||||||
|
|
||||||
|
norms = []
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
ens.fit(X, valid, cols, None, ts)
|
ens.fit(X, valid, cols, None, ts)
|
||||||
|
norms.append(float(np.linalg.norm(head.model.theta)))
|
||||||
|
|
||||||
assert head.model.n_updates == first_updates, "updates accumulated across refits"
|
# Exact equality is no longer the right assertion: the stride rotates its
|
||||||
assert float(np.linalg.norm(head.model.theta)) == pytest.approx(first_norm, rel=0.05)
|
# phase each refit, so a given refit trains on 12 or 13 pairs depending on
|
||||||
|
# where the offset lands. One update of slack covers that. Sixteen passes
|
||||||
|
# of accumulation would show up as 16x, not as 1.
|
||||||
|
assert abs(head.model.n_updates - first_updates) <= 1, \
|
||||||
|
"updates accumulated across refits; a refit must start from the prior"
|
||||||
|
|
||||||
|
# The failure this guards against put ||theta|| at 1680 against a median
|
||||||
|
# weight of 1.67. Phase rotation moves the norm by about 25% on these
|
||||||
|
# deliberately signal-free features, so bound the magnitude rather than
|
||||||
|
# pinning the value, and check it is not climbing refit on refit.
|
||||||
|
assert max(norms) < 20.0, f"weights drifting without bound: {max(norms):.1f}"
|
||||||
|
assert np.mean(norms[-5:]) < 3.0 * np.mean(norms[:5]), "weights growing across refits"
|
||||||
|
|
||||||
|
|
||||||
def test_annual_harmonics_are_zero_until_the_record_spans_a_season():
|
def test_annual_harmonics_are_zero_until_the_record_spans_a_season():
|
||||||
@@ -240,3 +252,97 @@ def test_annual_harmonics_are_zero_until_the_record_spans_a_season():
|
|||||||
ts_long = np.arange(n) * (200 * 86400.0 / n) + 1.7554e9
|
ts_long = np.arange(n) * (200 * 86400.0 / n) + 1.7554e9
|
||||||
X2, _ = build_features(ts_long, t, h, p, lux, 300, 52.2, 0.12, min_days_annual=120.0)
|
X2, _ = build_features(ts_long, t, h, p, lux, 300, 52.2, 0.12, min_days_annual=120.0)
|
||||||
assert X2[:, si].std() > 0.1, "annual terms should return once the record is long enough"
|
assert X2[:, si].std() > 0.1, "annual terms should return once the record is long enough"
|
||||||
|
|
||||||
|
|
||||||
|
def test_training_pairs_are_strided_by_the_horizon():
|
||||||
|
"""Overlapping windows must not be counted as independent observations.
|
||||||
|
|
||||||
|
At the 1 d horizon on a 5-minute grid adjacent pairs share 287 of their 288
|
||||||
|
samples. Training on every row hands the filter the same outcome 288 times
|
||||||
|
and RLS with forgetting reads each as fresh evidence, so a 400-score
|
||||||
|
conformal window ends up holding 1.4 independent outcomes while believing
|
||||||
|
it holds 400.
|
||||||
|
"""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
rng = np.random.default_rng(11)
|
||||||
|
n = 4000 # ~14 days at 5 minutes
|
||||||
|
g = CONFIG.model.grid_s
|
||||||
|
ts = np.arange(n) * g + 1.7554e9
|
||||||
|
cols = {
|
||||||
|
"temperature": 20 + 4 * np.sin(np.arange(n) / 288.0) + 0.1 * rng.normal(size=n),
|
||||||
|
"humidity": 55 + 8 * np.cos(np.arange(n) / 288.0),
|
||||||
|
"pressure": 1013 + 4 * np.sin(np.arange(n) / 900.0),
|
||||||
|
"lux": np.clip(400 * np.sin(np.arange(n) / 288.0), 0, None),
|
||||||
|
}
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
valid = np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
counts = ens.fit(X, valid, cols, None, ts)
|
||||||
|
|
||||||
|
for h in CONFIG.model.horizons_s:
|
||||||
|
steps = max(round(h / g), 1)
|
||||||
|
got = counts[f"temperature@{h}"]
|
||||||
|
# fit() bounds recency to max_pairs rows before it strides them.
|
||||||
|
available = min(n - steps, 2500)
|
||||||
|
expected = available // steps
|
||||||
|
if expected >= CONFIG.model.min_pairs_per_head:
|
||||||
|
assert abs(got - expected) <= 1, (
|
||||||
|
f"horizon {h}s trained on {got} pairs, expected about {expected}")
|
||||||
|
assert got < available / 2, "pairs were not strided"
|
||||||
|
else:
|
||||||
|
# The floor relaxes the stride rather than letting a long horizon
|
||||||
|
# train on a handful of pairs.
|
||||||
|
assert got >= CONFIG.model.min_pairs_per_head
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_stride_floor_protects_a_short_record():
|
||||||
|
"""A 1 d horizon on two days of data must not train on two pairs."""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
rng = np.random.default_rng(12)
|
||||||
|
n = 700 # ~2.4 days at 5 minutes
|
||||||
|
g = CONFIG.model.grid_s
|
||||||
|
ts = np.arange(n) * g + 1.7554e9
|
||||||
|
cols = {"temperature": 21 + rng.normal(size=n) * 0.1,
|
||||||
|
"humidity": 50 + rng.normal(size=n) * 0.1,
|
||||||
|
"pressure": 1013 + rng.normal(size=n) * 0.1,
|
||||||
|
"lux": np.zeros(n)}
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
counts = ens.fit(X, np.ones(n, dtype=bool), cols, None, ts)
|
||||||
|
|
||||||
|
day = counts["temperature@86400"]
|
||||||
|
assert day >= CONFIG.model.min_pairs_per_head, (
|
||||||
|
f"1 d head trained on only {day} pairs; the floor did not engage")
|
||||||
|
|
||||||
|
|
||||||
|
def test_refit_phase_rotates_and_survives_serialisation():
|
||||||
|
"""Every offset must eventually be trained on, across restarts too."""
|
||||||
|
from ashvale.config import CONFIG
|
||||||
|
from ashvale.models.nowcast import NowcastEnsemble
|
||||||
|
|
||||||
|
rng = np.random.default_rng(13)
|
||||||
|
n = 600
|
||||||
|
g = CONFIG.model.grid_s
|
||||||
|
ts = np.arange(n) * g + 1.7554e9
|
||||||
|
cols = {k: 20 + rng.normal(size=n) * 0.1 for k in CONFIG.model.targets}
|
||||||
|
cols["lux"] = np.zeros(n)
|
||||||
|
X = rng.normal(size=(n, 33))
|
||||||
|
X[:, 0] = 1.0
|
||||||
|
valid = np.ones(n, dtype=bool)
|
||||||
|
|
||||||
|
ens = NowcastEnsemble(CONFIG.model.targets, CONFIG.model.horizons_s, CONFIG.model)
|
||||||
|
assert ens.refit_phase == 0
|
||||||
|
ens.fit(X, valid, cols, None, ts)
|
||||||
|
ens.fit(X, valid, cols, None, ts)
|
||||||
|
assert ens.refit_phase == 2
|
||||||
|
|
||||||
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user