mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Earn the blend weights and the intervals from forecasts, not from refits
fit() called learn(), and learn() updated three things: the RLS, the conformal
calibrator and the Hedge weights. Only the first belongs to a refit. The comment
above that loop already said so, and was wrong about what the code did.
Measured on 8.2 days of the live station, the 15 minute head had taken 977,078
Hedge updates from 758 distinct supervised pairs, a factor of 1,289, and the
1 day head 296,715 from 12 pairs, a factor of 24,726. A refit is not an outcome.
It is the same week of weather being read again, once every seven minutes.
Hedge is multiplicative, so an edge far too small to be real compounds to
certainty: twelve of twelve temperature and humidity heads had collapsed onto
climatology at a weight of 0.991 or above, while their own member_mae said the
members were within a few percent of each other. The ACI integrator moves by
gamma per observation, so it had likewise pinned against its clips, leaving the
6 hour temperature band (1.571 C) narrower than the 3 hour one (2.258 C), and
pressure at 1 day covering 3 of 7 with alpha jammed at the 0.005 floor.
Three changes, because fixing only the first would freeze the weights forever:
- fit() calls refit_step(), which touches the regression and nothing else.
The climatology and setpoint members were evaluated in that loop purely to
feed the Hedge update, so fit() no longer needs a climatology or a
setpoint_fn at all.
- verify() feeds observe_outcome() with the member predictions the forecast
was actually blended from. These are now written to the forecasts table at
issue time, because the learned member cannot be recovered afterwards: the
RLS has moved on.
- a matured forecast teaches exactly once. It stays readable for an hour so
the scorecard can aggregate a rolling window, which meant verify() was
feeding the calibrator the same outcome about twelve times.
The Hedge weights additionally decline an outcome that overlaps the last one
they took, which is the stride rule from fit() applied on the scoring side.
Forecasts are issued every retrain tick, so at the 1 day horizon roughly two
hundred a day resolve against very nearly the same outcome. The conformal window
absorbs that, a quantile over duplicated scores being merely overconfident about
its sample size, but exponentiated gradient cannot.
Walk-forward over the full 8.2 day record, against the current code:
mean MAE 0.856 (0.938 over the second half alone)
heads improved 17/18
beats persistence 7/18 -> 12/18
coverage |dev from .90| 0.188 -> 0.060, second half 0.112 -> 0.041
Decimating the conformal feed as well was measured and rejected. It reads better
(coverage |dev| 0.023) and is not: three heads fall below MIN_SCORES, drop to
1.645*sigma, and "cover" with a median band of +/- 107% relative humidity. At
h/4 and h/8 it never starves and lands within noise of not decimating at all, so
the simpler rule wins. Honest regressions: humidity at 12 hours is 19% worse,
and pressure past 6 hours is still under-covered, because at 1 day the point
forecast is genuinely poor and ACI can only widen so far.
A state file from before this change has its weights, member_mae, n_scored and
alpha reset on load. They are products of the replay, they are not evidence, and
they do not decay on their own: Hedge needs about twenty independent outcomes to
climb off its 1e-4 floor and the 1 d head sees one a day. The conformal scores
are kept, being residuals of roughly the right size, and the window refreshes
within about two days.
Schema migration verified against a pristine copy of the live database: 1,437
forecast rows preserved, five columns added, idempotent across restarts.
This commit is contained in:
+55
-5
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -61,6 +61,14 @@ CREATE TABLE IF NOT EXISTS forecasts (
|
||||
target TEXT NOT NULL,
|
||||
mu REAL, lo REAL, hi REAL,
|
||||
model TEXT,
|
||||
-- What each ensemble member said at issue time. The blend weights are
|
||||
-- learned from how these actually turned out, and the learned member is
|
||||
-- not reconstructable after the fact: the RLS has moved on. If they are
|
||||
-- not written down here the Hedge update has nothing honest to eat.
|
||||
m_persistence REAL, m_climatology REAL, m_learned REAL, m_setpoint REAL,
|
||||
-- A matured forecast stays readable for an hour so the scorecard can
|
||||
-- aggregate a rolling window, but it must teach the learner exactly once.
|
||||
scored INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (issued_ts, horizon_s, target)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_forecast_valid ON forecasts(valid_ts);
|
||||
@@ -92,6 +100,22 @@ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
||||
"""
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
return None if v is None else float(v)
|
||||
|
||||
|
||||
# Columns the forecasts table has gained since the first schema, with their
|
||||
# declarations. Same hazard as COLUMNS above: a live station's table already
|
||||
# exists, so CREATE TABLE IF NOT EXISTS will not add them.
|
||||
FORECAST_COLUMNS = [
|
||||
("m_persistence", "REAL"),
|
||||
("m_climatology", "REAL"),
|
||||
("m_learned", "REAL"),
|
||||
("m_setpoint", "REAL"),
|
||||
("scored", "INTEGER NOT NULL DEFAULT 0"),
|
||||
]
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
@@ -121,6 +145,16 @@ class Store:
|
||||
raise ValueError(f"refusing to splice a non-identifier column: {col!r}")
|
||||
conn.execute(f"ALTER TABLE telemetry ADD COLUMN {col} REAL")
|
||||
|
||||
have = {row[1] for row in conn.execute("PRAGMA table_info(forecasts)")}
|
||||
if not have:
|
||||
return
|
||||
for col, decl in FORECAST_COLUMNS:
|
||||
if col in have:
|
||||
continue
|
||||
if not col.isidentifier():
|
||||
raise ValueError(f"refusing to splice a non-identifier column: {col!r}")
|
||||
conn.execute(f"ALTER TABLE forecasts ADD COLUMN {col} {decl}")
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is None:
|
||||
@@ -143,16 +177,32 @@ class Store:
|
||||
)
|
||||
|
||||
def insert_forecast(self, issued_ts: float, horizon_s: int, target: str,
|
||||
mu: float, lo: float, hi: float, model: str) -> None:
|
||||
mu: float, lo: float, hi: float, model: str,
|
||||
members: Optional[Dict[str, float]] = None) -> None:
|
||||
m = members or {}
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO forecasts "
|
||||
"(issued_ts, valid_ts, horizon_s, target, mu, lo, hi, model) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
"(issued_ts, valid_ts, horizon_s, target, mu, lo, hi, model, "
|
||||
" m_persistence, m_climatology, m_learned, m_setpoint, scored) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0)",
|
||||
(issued_ts, issued_ts + horizon_s, horizon_s, target,
|
||||
float(mu), float(lo), float(hi), model),
|
||||
float(mu), float(lo), float(hi), model,
|
||||
_f(m.get("persistence")), _f(m.get("climatology")),
|
||||
_f(m.get("learned")), _f(m.get("setpoint"))),
|
||||
)
|
||||
|
||||
def mark_forecasts_scored(self, keys: Iterable[Tuple[float, int, str]]) -> int:
|
||||
"""Flag forecasts as already fed to the learner."""
|
||||
rows = [(float(i), int(h), str(t)) for i, h, t in keys]
|
||||
if not rows:
|
||||
return 0
|
||||
with self._conn() as conn:
|
||||
cur = conn.executemany(
|
||||
"UPDATE forecasts SET scored = 1 "
|
||||
"WHERE issued_ts = ? AND horizon_s = ? AND target = ?", rows)
|
||||
return int(cur.rowcount)
|
||||
|
||||
def insert_label(self, ts: float, kind: str, value: float, note: str = "") -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
|
||||
Reference in New Issue
Block a user