Report how well the base coefficients are identified
The base layer (loss, heat, bias) showed values only, so a zone with weeks of samples looked equally trustworthy whether or not the data ever varied its inputs. Each base coefficient now reports a confidence from its own posterior variance, as the extended and ventilation layers already did: it stays at 0 for `heat` while the relay never cycles and below 0.8 for `loss` and `bias` while the indoor-outdoor difference is steady. Readiness is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
26c415fa6c
commit
a44163a57d
4 files changed
+47
-2
No files matched your search
@@ -431,8 +431,10 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
model = ZoneModel(self.learners[key], self.extended.get(key), self.ventilation.get(key))
|
||||||
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
|
inputs = {"outside": outside, "lag": lag, "internal": internal[key], "temps": temps,
|
||||||
"ventilation": vent_inputs.get(key) or {}}
|
"ventilation": vent_inputs.get(key) or {}}
|
||||||
a, b, c = self.learners[key].theta
|
base = self.learners[key]
|
||||||
coefficients = {"base": {"loss": round(a, 6), "heat": round(b, 6), "bias": round(c, 6)}}
|
confidence = base.confidence()
|
||||||
|
coefficients = {"base": {name: {"value": round(value, 6), "confidence": confidence[name]}
|
||||||
|
for name, value in zip(("loss", "heat", "bias"), base.theta)}}
|
||||||
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
|
for layer, learner in (("extended", model.extended), ("ventilation", model.ventilation)):
|
||||||
if learner is not None:
|
if learner is not None:
|
||||||
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
|
coefficients[layer] = {name: {k: info[k] for k in ("value", "std", "confidence", "identified")}
|
||||||
@@ -740,6 +742,8 @@ class HeatingMPCCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
"loss": round(model.theta[0], 6),
|
"loss": round(model.theta[0], 6),
|
||||||
"heat": round(model.theta[1], 6),
|
"heat": round(model.theta[1], 6),
|
||||||
"bias": round(model.theta[2], 6),
|
"bias": round(model.theta[2], 6),
|
||||||
|
# How well the data pins each coefficient down, not how many samples.
|
||||||
|
"confidence": model.confidence(),
|
||||||
"samples": model.count,
|
"samples": model.count,
|
||||||
"on_samples": model.on_count, "off_samples": model.off_count,
|
"on_samples": model.on_count, "off_samples": model.off_count,
|
||||||
"rejected_samples": model.rejected_count,
|
"rejected_samples": model.rejected_count,
|
||||||
|
|||||||
@@ -134,6 +134,17 @@ class Learner:
|
|||||||
def mae(self) -> float | None:
|
def mae(self) -> float | None:
|
||||||
return self.error_sum / self.count if self.count else None
|
return self.error_sum / self.count if self.count else None
|
||||||
|
|
||||||
|
def confidence(self) -> dict[str, float]:
|
||||||
|
"""Per coefficient, the share of its information that comes from data (0 = prior only).
|
||||||
|
|
||||||
|
Taken from the coefficient's own uncertainty, not from the sample count: it
|
||||||
|
only grows while the data varies that coefficient's input independently of
|
||||||
|
the others. Weeks with the relay always off leave `heat` at 0; a steady
|
||||||
|
indoor-outdoor difference cannot separate `loss` from `bias`.
|
||||||
|
"""
|
||||||
|
return {name: round(1 - min(prior, max(0.0, self.p[i][i])) / prior, 3)
|
||||||
|
for i, (name, prior) in enumerate(zip(("loss", "heat", "bias"), PRIOR_VARIANCE))}
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
|
return {"theta": self.theta, "p": self.p, "count": self.count, "error_sum": self.error_sum,
|
||||||
"on_count": self.on_count, "off_count": self.off_count,
|
"on_count": self.on_count, "off_count": self.off_count,
|
||||||
|
|||||||
@@ -785,6 +785,11 @@ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(details['model_in_use'], 'extended')
|
self.assertEqual(details['model_in_use'], 'extended')
|
||||||
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
|
self.assertGreater(details['contributions_c_per_h']['internal'], 0.2)
|
||||||
self.assertTrue(details['coefficients']['extended']['internal']['identified'])
|
self.assertTrue(details['coefficients']['extended']['internal']['identified'])
|
||||||
|
# The base layer reports how well each coefficient is pinned down too.
|
||||||
|
base = details['coefficients']['base']
|
||||||
|
self.assertEqual(set(base), {'loss', 'heat', 'bias'})
|
||||||
|
self.assertEqual(base['heat']['confidence'], data['models']['office']['confidence']['heat'])
|
||||||
|
self.assertGreater(base['heat']['confidence'], 0.8)
|
||||||
# Replayed against what happened, the electrical term removes most of the base error.
|
# Replayed against what happened, the electrical term removes most of the base error.
|
||||||
accuracy = data['forecast_accuracy'][60]
|
accuracy = data['forecast_accuracy'][60]
|
||||||
self.assertLess(accuracy['mae'], 0.06)
|
self.assertLess(accuracy['mae'], 0.06)
|
||||||
|
|||||||
@@ -140,6 +140,31 @@ class ModelTests(unittest.TestCase):
|
|||||||
self.assertEqual(restored.count, 30000)
|
self.assertEqual(restored.count, 30000)
|
||||||
self.assertEqual(restored.p, learn.p)
|
self.assertEqual(restored.p, learn.p)
|
||||||
|
|
||||||
|
def test_confidence_reflects_excitation_not_sample_count(self):
|
||||||
|
# Two weeks of samples, relay always off, a steady 15 degC difference to outside.
|
||||||
|
steady = m.Learner()
|
||||||
|
for _ in range(4000):
|
||||||
|
steady.learn(20.0, 20.0 + 0.003 * -15 + 0.001, 5.0, 0.0, command=False)
|
||||||
|
confidence = steady.confidence()
|
||||||
|
self.assertEqual(steady.count, 4000)
|
||||||
|
self.assertEqual(confidence['heat'], 0) # never exercised
|
||||||
|
self.assertLess(confidence['loss'], 0.8) # not separable from bias
|
||||||
|
self.assertLess(confidence['bias'], 0.8)
|
||||||
|
# Fewer samples, with the relay cycling and a cold and a mild spell each day.
|
||||||
|
varied = m.Learner()
|
||||||
|
t, lag = 20.0, 0.0
|
||||||
|
for step in range(1200):
|
||||||
|
heater = int(step % 96 < 35)
|
||||||
|
lag = lag + m.HEAT_LAG * (heater - lag)
|
||||||
|
outside = -2 + 14 * ((step // 144) % 2)
|
||||||
|
value = t + 0.003 * (outside - t) + 0.055 * lag + 0.001
|
||||||
|
varied.learn(t, value, outside, lag, command=bool(heater))
|
||||||
|
t = value
|
||||||
|
self.assertTrue(all(value > 0.8 for value in varied.confidence().values()), varied.confidence())
|
||||||
|
# Recalibration starts confidence over.
|
||||||
|
varied.recalibrate()
|
||||||
|
self.assertEqual(set(varied.confidence().values()), {0})
|
||||||
|
|
||||||
def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self):
|
def test_restore_tolerates_round_off_and_bounds_wound_up_covariance(self):
|
||||||
# Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry.
|
# Shape of a v0.2.1 model after a summer: huge heat variance, tiny asymmetry.
|
||||||
data = m.Learner(count=40000, off_count=40000).as_dict()
|
data = m.Learner(count=40000, off_count=40000).as_dict()
|
||||||
|
|||||||
Reference in new issue
Block a user