187 files
This commit is contained in:
@@ -21,11 +21,11 @@ This package holds compact, NumPy-only models trained offline in the
|
||||
``promoted_manifest.json`` for provenance). The integration runtime stays
|
||||
NumPy-only; no sklearn/torch/scipy are imported.
|
||||
|
||||
The single runtime entry point is :func:`resolve_scorer`, which returns a scoring
|
||||
callable for a capability, preferring an on-device trained spec over the shipped
|
||||
embedded baseline. All live ML consumers go through it (the panel's ``ml_health``
|
||||
shadow comparison in ``ws_api`` and :class:`MLSuggestionEngine`), and any new
|
||||
runtime consumer should too — feature extraction lives in ``feature_extraction``
|
||||
The runtime entry points are :func:`resolve_scorer`, which returns the shipped
|
||||
embedded baseline's scoring callable for a classifier capability, and
|
||||
:func:`resolve_regressor`, which returns an on-device trained regressor. All live
|
||||
ML consumers go through them (the panel's ``ml_health`` shadow comparison in
|
||||
``ws_api``), and any new runtime consumer should too - feature extraction lives in ``feature_extraction``
|
||||
and gating in :func:`ml_models_enabled`, so there is no separate engine object.
|
||||
|
||||
Each model consumes a feature mapping whose keys are the model's
|
||||
@@ -48,7 +48,6 @@ CONF_ENABLE_ML_MODELS = "enable_ml_models"
|
||||
# Logical capability -> generated model module name (without the _model suffix).
|
||||
_MODEL_MODULES = {
|
||||
"quality": "hybrid_curve_quality_model",
|
||||
"live_match": "live_match_commit_model",
|
||||
"end": "cycle_end_detector_model",
|
||||
}
|
||||
|
||||
@@ -56,8 +55,8 @@ _MODEL_MODULES = {
|
||||
_SIBLING_MODULES = ("trainer", "feature_extraction")
|
||||
|
||||
# Imported baseline model modules, keyed by module name. Importing a module is a
|
||||
# blocking call Home Assistant forbids inside the event loop, and every
|
||||
# resolve_scorer() consumer (live matching, end detection, quality gating) runs
|
||||
# blocking call Home Assistant forbids inside the event loop, and
|
||||
# resolve_scorer() consumers (end detection, the panel's cycle health) run
|
||||
# there - so the modules are imported once from an import executor at setup
|
||||
# (:func:`preload_models`) and every later resolution is a dict lookup. A failed
|
||||
# import is cached as ``None`` so a broken install warns once instead of retrying
|
||||
@@ -127,123 +126,49 @@ def ml_models_enabled(options: Mapping[str, object] | None) -> bool:
|
||||
return bool(options.get(CONF_ENABLE_ML_MODELS, False))
|
||||
|
||||
|
||||
def resolve_scorer(capability: str, store: object | None):
|
||||
"""Return ``(score_fn, source)`` for a capability, preferring an on-device
|
||||
trained spec over the shipped embedded baseline.
|
||||
def resolve_scorer(capability: str):
|
||||
"""Return ``(score_fn, source)`` for a classifier capability's shipped baseline.
|
||||
|
||||
``score_fn`` maps a feature mapping -> float in [0,1]; ``source`` is
|
||||
``"on_device"`` or ``"baseline"``. Returns ``(None, None)`` when neither is
|
||||
available. This is the single bridge that lets trained models (Stage 4)
|
||||
actually reach inference (ML Lab shadow comparison + MLSuggestionEngine)
|
||||
while transparently falling back to the baseline.
|
||||
``"baseline"``. Returns ``(None, None)`` when the capability has no embedded
|
||||
model or it failed to import. The lookup hits the module cache warmed by
|
||||
:func:`preload_models`, so no import happens in the event loop. (The
|
||||
on-device classifier specs this used to prefer can no longer exist: their
|
||||
training was removed in 0.5.8 and storage v17 drops their records.)
|
||||
"""
|
||||
def _baseline():
|
||||
"""Resolve the shipped embedded baseline scorer for this capability.
|
||||
module_name = _MODEL_MODULES.get(capability)
|
||||
if module_name is None:
|
||||
return (None, None)
|
||||
module = _load_model_module(module_name)
|
||||
if module is None:
|
||||
return (None, None)
|
||||
|
||||
Kept as a lazily-invoked helper so the baseline module is only looked up
|
||||
when the on-device spec is absent *or* fails at call time. The lookup hits
|
||||
the module cache warmed by :func:`preload_models`, so no import happens in
|
||||
the event loop.
|
||||
"""
|
||||
module_name = _MODEL_MODULES.get(capability)
|
||||
if module_name is None:
|
||||
return (None, None)
|
||||
module = _load_model_module(module_name)
|
||||
if module is None:
|
||||
return (None, None)
|
||||
|
||||
def _baseline_score(feats, _m=module):
|
||||
# The embedded baseline must never raise into live inference either
|
||||
# (mirrors _on_device_score's call-time guard): on any scoring error
|
||||
# log and return a neutral 0.0 so a gate treats the signal as absent
|
||||
# rather than letting the exception reach live detection/matching.
|
||||
try:
|
||||
return float(_m.score(feats))
|
||||
except Exception as exc: # noqa: BLE001 - never raise into live inference
|
||||
_LOGGER.warning(
|
||||
"Embedded baseline scorer for capability %r failed at call "
|
||||
"time, returning neutral 0.0: %s", capability, exc,
|
||||
)
|
||||
return 0.0
|
||||
|
||||
return (_baseline_score, "baseline")
|
||||
|
||||
# 1) On-device trained spec from the store.
|
||||
if store is not None:
|
||||
def _baseline_score(feats, _m=module):
|
||||
# The embedded baseline must never raise into live inference: on any
|
||||
# scoring error log and return a neutral 0.0 so a gate treats the signal
|
||||
# as absent rather than letting the exception reach detection/matching.
|
||||
try:
|
||||
versions = store.get_ml_model_versions() or {} # type: ignore[attr-defined]
|
||||
record = versions.get(capability)
|
||||
spec = record.get("spec") if isinstance(record, dict) else None
|
||||
# Only treat a spec as a classifier here. A regression spec
|
||||
# (standardized_linear) must never be sigmoid-squashed by score_spec;
|
||||
# classifier and regression capability keys are disjoint today, but this
|
||||
# guard keeps it safe if a key were ever reused.
|
||||
if isinstance(spec, dict) and spec.get("kind") != "standardized_linear":
|
||||
# Feature-column schema guard: a spec promoted under an older
|
||||
# FEATURE_COLUMNS must be dropped rather than silently scoring on a
|
||||
# stale/neutral-filled schema. The call-time guard already catches
|
||||
# shape mismatches, but this catches them at load time and logs
|
||||
# clearly, so users see a single warm-up warning instead of a
|
||||
# per-inference warning storm.
|
||||
module_name = _MODEL_MODULES.get(capability)
|
||||
if module_name is not None:
|
||||
try:
|
||||
_bm = _load_model_module(module_name)
|
||||
_expected = list(getattr(_bm, "FEATURE_COLUMNS", []))
|
||||
_stored = list(spec.get("feature_columns") or [])
|
||||
if _expected and _stored and _stored != _expected:
|
||||
_LOGGER.warning(
|
||||
"Promoted spec for %r has stale feature schema "
|
||||
"(%d cols vs current %d); reverting to baseline.",
|
||||
capability, len(_stored), len(_expected),
|
||||
)
|
||||
return _baseline()
|
||||
except Exception: # noqa: BLE001 - schema check must not break inference
|
||||
pass
|
||||
|
||||
score_spec = _sibling_attr("trainer", "score_spec")
|
||||
if score_spec is None:
|
||||
return _baseline()
|
||||
|
||||
def _on_device_score(feats, _s=spec):
|
||||
# A malformed / dimensionally-incompatible promoted spec must
|
||||
# never raise into live detection/matching: on any call-time
|
||||
# error fall back to the embedded baseline (or a neutral 0.0).
|
||||
try:
|
||||
return float(score_spec(_s, feats))
|
||||
except Exception as exc: # noqa: BLE001 - never raise into live inference
|
||||
_LOGGER.warning(
|
||||
"Trained scorer for capability %r failed at call time, "
|
||||
"falling back to baseline: %s", capability, exc,
|
||||
)
|
||||
fn, _src = _baseline()
|
||||
if fn is not None:
|
||||
try:
|
||||
return fn(feats)
|
||||
except Exception: # noqa: BLE001 - baseline must not raise either
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
return (_on_device_score, "on_device")
|
||||
except Exception as exc: # noqa: BLE001 - never let a bad store break inference
|
||||
return float(_m.score(feats))
|
||||
except Exception as exc: # noqa: BLE001 - never raise into live inference
|
||||
_LOGGER.warning(
|
||||
"Failed to load trained spec for capability %r, falling back to baseline: %s",
|
||||
capability, exc,
|
||||
"Embedded baseline scorer for capability %r failed at call "
|
||||
"time, returning neutral 0.0: %s", capability, exc,
|
||||
)
|
||||
# 2) Shipped embedded baseline module.
|
||||
return _baseline()
|
||||
return 0.0
|
||||
|
||||
return (_baseline_score, "baseline")
|
||||
|
||||
|
||||
def resolve_regressor(capability: str, store: object | None):
|
||||
"""Return ``(predict_fn, source)`` for a regression capability.
|
||||
|
||||
Regression models (``"remaining_time"`` and ``"total_energy"``) have **no**
|
||||
Regression models (since 0.5.8 only ``"total_energy"``) have **no**
|
||||
shipped embedded baseline - they are trained purely on-device (Stage 4) and
|
||||
stored as ``standardized_linear`` specs. This returns ``(None, None)`` until
|
||||
on-device training promotes one, so live behaviour is unchanged until then.
|
||||
|
||||
``predict_fn`` maps a feature mapping -> float in the model's target units
|
||||
(a completion fraction in ~[0, 1] for both regression capabilities).
|
||||
(for ``total_energy``, the energy fraction so far in ~[0, 1]).
|
||||
"""
|
||||
if store is None:
|
||||
return (None, None)
|
||||
|
||||
@@ -24,12 +24,13 @@ Three feature extractors are implemented:
|
||||
|
||||
- **Cycle-end detector** (``END_FEATURE_COLUMNS``): self-contained from a live
|
||||
power series + profile expectation; call ``latest_end_event_features``.
|
||||
- **Live-match commit confidence** (``LIVE_MATCH_FEATURE_COLUMNS``): requires the
|
||||
match ranking from ``ProfileStore`` plus the observed prefix; call
|
||||
``live_match_features``.
|
||||
- **Remaining-time / energy regressors** (``PROGRESS_FEATURE_COLUMNS``): a
|
||||
running-cycle prefix + profile expectation; call ``progress_features``.
|
||||
- **Hybrid cycle quality** (``QUALITY_FEATURE_COLUMNS``): requires the complete
|
||||
cycle power trace plus profile/match context; call ``quality_features``.
|
||||
|
||||
(The live-match commit features went with the early match commit in 0.5.8.)
|
||||
|
||||
All inputs are plain Python/NumPy (offset-seconds, watts), so this module has no
|
||||
Home Assistant dependency and is unit-tested directly. It is only invoked when
|
||||
the user opts into experimental ML models (see engine.py).
|
||||
@@ -108,38 +109,6 @@ def profile_expectation(cycles_points: Sequence[Sequence[Point]]) -> dict[str, f
|
||||
}
|
||||
|
||||
|
||||
def profile_expectations(cycles: list[dict]) -> dict[str, dict[str, float]]:
|
||||
"""Median duration/energy/peak per profile from stored cycle dicts.
|
||||
|
||||
The dict-based counterpart of :func:`profile_expectation` (which works from
|
||||
decompressed traces): reads the ``duration``/``energy_wh``/``max_power``
|
||||
scalar fields already stored on each cycle. Shared by on-device training
|
||||
(``training_task``) and the ML suggestion engine so the "profile expectation"
|
||||
definition lives in one place. Profiles with no usable duration are skipped;
|
||||
missing energy/peak default to 500.
|
||||
"""
|
||||
stats: dict[str, dict[str, list[float]]] = {}
|
||||
for c in cycles:
|
||||
name = c.get("profile_name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
s = stats.setdefault(name, {"d": [], "e": [], "p": []})
|
||||
for key, field in (("d", "duration"), ("e", "energy_wh"), ("p", "max_power")):
|
||||
v = c.get(field)
|
||||
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||
s[key].append(float(v))
|
||||
out: dict[str, dict[str, float]] = {}
|
||||
for name, s in stats.items():
|
||||
if not s["d"]:
|
||||
continue
|
||||
out[name] = {
|
||||
"duration": float(np.median(s["d"])),
|
||||
"energy": float(np.median(s["e"])) if s["e"] else 500.0,
|
||||
"peak": float(np.median(s["p"])) if s["p"] else 500.0,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def latest_end_event_features(
|
||||
points: Sequence[Point],
|
||||
expectation: dict[str, float],
|
||||
@@ -195,83 +164,11 @@ def latest_end_event_features(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live-match commit confidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mirrors ml_washdata/wash_ml/live_matching.py COMMIT_FEATURE_COLUMNS.
|
||||
# The test suite asserts this equals the embedded model's FEATURE_COLUMNS.
|
||||
LIVE_MATCH_FEATURE_COLUMNS = [
|
||||
"match_progress_top1",
|
||||
"top1_distance",
|
||||
"margin",
|
||||
"distance_ratio",
|
||||
"candidate_count_log",
|
||||
"prefix_active_fraction",
|
||||
"duration_ratio_top1",
|
||||
"elapsed_log",
|
||||
]
|
||||
|
||||
|
||||
def live_match_features(
|
||||
points: Sequence[Point],
|
||||
elapsed_s: float,
|
||||
top1_distance: float,
|
||||
top2_distance: float | None,
|
||||
top1_median_duration_s: float,
|
||||
candidate_count: int,
|
||||
) -> dict[str, float]:
|
||||
"""Features for the live-match commit-confidence model.
|
||||
|
||||
Args:
|
||||
points: Observed power readings (offset_s, watts) for the current prefix.
|
||||
elapsed_s: Seconds elapsed since cycle start.
|
||||
top1_distance: Blended RMSE+DTW shape distance to the top-1 candidate
|
||||
prefix (as returned by the profile matcher).
|
||||
top2_distance: Distance to the top-2 candidate; pass ``None`` or ``0.0``
|
||||
when only one candidate is available (margin defaults to 1.0).
|
||||
top1_median_duration_s: Expected (median) duration of the top-1 candidate
|
||||
profile in seconds.
|
||||
candidate_count: Number of candidate profiles on this device.
|
||||
|
||||
Returns a dict with exactly ``LIVE_MATCH_FEATURE_COLUMNS`` keys.
|
||||
"""
|
||||
elapsed = max(0.0, float(elapsed_s))
|
||||
top1 = max(0.0, float(top1_distance))
|
||||
top2_raw = float(top2_distance) if top2_distance is not None else 0.0
|
||||
top2 = top2_raw if top2_raw > 1e-9 else top1 + 1.0
|
||||
margin = max(0.0, top2 - top1)
|
||||
dur = float(top1_median_duration_s)
|
||||
progress = (elapsed / dur) if dur > 0 else 1.0
|
||||
|
||||
# prefix_active_fraction: fraction of prefix readings clearly above idle.
|
||||
# Lab uses > 0.05 on a peak-normalised trace; equivalent here is > 5% of
|
||||
# peak, with a 1 W floor so a cold trace never divides by near-zero.
|
||||
if points:
|
||||
powers = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float)
|
||||
peak = float(np.max(powers)) if powers.size else 0.0
|
||||
active_thr = max(1.0, 0.05 * peak)
|
||||
prefix_active_fraction = float(np.mean(powers > active_thr)) if powers.size else 0.0
|
||||
else:
|
||||
prefix_active_fraction = 0.0
|
||||
|
||||
return {
|
||||
"match_progress_top1": float(min(progress, 2.0)),
|
||||
"top1_distance": float(top1),
|
||||
"margin": float(margin),
|
||||
"distance_ratio": float(top1 / top2) if top2 > 1e-9 else 1.0,
|
||||
"candidate_count_log": float(math.log1p(max(0, int(candidate_count)))),
|
||||
"prefix_active_fraction": float(prefix_active_fraction),
|
||||
"duration_ratio_top1": float(min(progress, 2.0)),
|
||||
"elapsed_log": float(math.log1p(elapsed)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remaining-time / progress regressor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Feature columns for the on-device remaining-time regressor. Unlike the three
|
||||
# Feature columns for the on-device remaining-time regressor. Unlike the
|
||||
# classifier heads this model is a ``standardized_linear`` regressor whose target
|
||||
# is the cycle completion fraction (elapsed / total_actual). There is no shipped
|
||||
# baseline: the model exists only once on-device training promotes one over the
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
[
|
||||
{
|
||||
"feature": "match_progress_top1",
|
||||
"group": "live_match",
|
||||
"runtime_source": "elapsed_seconds / top-1 candidate expected duration"
|
||||
},
|
||||
{
|
||||
"feature": "top1_distance",
|
||||
"group": "live_match",
|
||||
"runtime_source": "blended RMSE+DTW shape distance to the top-1 candidate prefix"
|
||||
},
|
||||
{
|
||||
"feature": "margin",
|
||||
"group": "live_match",
|
||||
"runtime_source": "top-2 distance minus top-1 distance (decision confidence)"
|
||||
},
|
||||
{
|
||||
"feature": "distance_ratio",
|
||||
"group": "live_match",
|
||||
"runtime_source": "top-1 distance / top-2 distance"
|
||||
},
|
||||
{
|
||||
"feature": "candidate_count_log",
|
||||
"group": "live_match",
|
||||
"runtime_source": "log1p(number of candidate profiles on the device)"
|
||||
},
|
||||
{
|
||||
"feature": "prefix_active_fraction",
|
||||
"group": "live_match",
|
||||
"runtime_source": "fraction of the observed prefix above the active threshold"
|
||||
},
|
||||
{
|
||||
"feature": "duration_ratio_top1",
|
||||
"group": "live_match",
|
||||
"runtime_source": "elapsed / top-1 expected duration (clipped)"
|
||||
},
|
||||
{
|
||||
"feature": "elapsed_log",
|
||||
"group": "live_match",
|
||||
"runtime_source": "log1p(elapsed seconds since cycle start)"
|
||||
}
|
||||
]
|
||||
@@ -1,129 +0,0 @@
|
||||
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||||
# Copyright (C) 2026 Lukas Bandura
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Auto-generated by ml_washdata/wash_ml/promotion.py. Do not edit by hand.
|
||||
|
||||
Embedded WashData model: 'live_match_commit' (target: 'match_top1_correct').
|
||||
Kind: standardized logistic regression. Runtime dependency: NumPy only.
|
||||
|
||||
Regenerate with ``./ml.sh experiment`` in the ml_washdata lab and copy the new
|
||||
file. Determinism check at generation time: max_abs_score_diff=1.6467e-08
|
||||
over 2392 rows.
|
||||
|
||||
Usage in the integration::
|
||||
|
||||
from .live_match_commit_model import score, predict, FEATURE_COLUMNS
|
||||
features = build_runtime_features(...) # must populate FEATURE_COLUMNS
|
||||
is_positive = predict(features)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
from typing import Mapping
|
||||
|
||||
import numpy as np
|
||||
|
||||
MODEL_NAME = 'live_match_commit'
|
||||
MODEL_TARGET = 'match_top1_correct'
|
||||
MODEL_KIND = 'standardized_logistic'
|
||||
TARGET_UNITS = ''
|
||||
# Training artifact for select_threshold only — NOT used at live inference.
|
||||
# The live early-commit gate reads ML_MATCH_COMMIT_THRESHOLD from const.py.
|
||||
THRESHOLD = 0.371786
|
||||
|
||||
FEATURE_COLUMNS = [
|
||||
'match_progress_top1',
|
||||
'top1_distance',
|
||||
'margin',
|
||||
'distance_ratio',
|
||||
'candidate_count_log',
|
||||
'prefix_active_fraction',
|
||||
'duration_ratio_top1',
|
||||
'elapsed_log',
|
||||
]
|
||||
|
||||
# Provenance (metrics at training time):
|
||||
MODEL_METRICS = json.loads("""{
|
||||
"owner_holdout": {
|
||||
"accuracy": 0.771789,
|
||||
"balanced_accuracy": 0.741539,
|
||||
"f1": 0.836751,
|
||||
"fn": 120,
|
||||
"fp": 79,
|
||||
"positive_rate": 0.675459,
|
||||
"precision": 0.865874,
|
||||
"problem_recall": 0.809524,
|
||||
"rows": 872,
|
||||
"specificity": 0.673554,
|
||||
"tn": 163,
|
||||
"tp": 510
|
||||
}
|
||||
}""")
|
||||
|
||||
_MODEL_BLOB = (
|
||||
'H4sIAAAAAAACA1VU246kNhD9FYunRKGJDb7BKpGi0UaKtJONktmn1Qq5wXRbAxgZdzqb1fx7jmG6NXmiqMupOnXxt+zozJo1tNCM'
|
||||
'aVELnWednaMNWfOZFoJyJcqS57Rglaoha4gVo5UQsoQoGOe6ZiJnRc2kkoolX64oE7pi+RsEVWheUa7FF2TwdtjwS0UlFzo5Ms2o'
|
||||
'oila8lJQIWV+YEXJeF1zKfIDUJlAjUwmWai6qsSW7I6R1JwBRaqUI1gTbd+amDVZSUt5oPJQ1k8lbXjdUPEDpQ2lWZ4N8LsE23Z+'
|
||||
'vEwzevE5m0zszu0S/CnYdW2jXxgc06ft3RrN3Fn8Tyac3AzhpmuDic5D0Zm5dz3SA/Qyx3b0J2iXYAf3T2u66P627RCS4Lf4yxY4'
|
||||
'7/G3dHY0ywoCKRh8Ti4CbZpc4iOpMFqW8Hp2cw9FKqA3oXf/7hGoyHWpRhuD60DqW+avsw3t2Y+9v8SkMF2HxN3XNH2lmNJ1nh3N'
|
||||
'mJigbW+N6HwF48C2RamkEgx/c9awkkJYskbBvPjVbdRAwyZP+HFRb8Q7tyauKVwKrXhS+uNopxY2M46bhdYCi5IFf0XBWpV5ti6I'
|
||||
'HFzn4tcdEHsHj5hSywoCUgtGX17ybDYTkmZjKmCf32u3YPLRbnP9AONhM5LdeOj8PLjegnJD/vgOnT8wcp8ecSuJZwvfgCoj2RbC'
|
||||
'TN8XwHzY4kn0xJCU82YkO/z1bGeyItCSn38CCPYodf4d8QAMV7da8mztsnu7+USQk6xXd481pHfDYANu8U1BR4u67UpuZcdUylMw'
|
||||
'brY98TO5WvNMzCX6w2iOdlzfkYD9c5Ml02WNifRyAczDx8fH357aX9//8vTpz/ftw8cPnx5//4sMwU8b4Y3QTiSYGSt2KtIKYm8Q'
|
||||
'3t7eB1rQu27FENF+llSvcjpvXWnNucCVUq1Lzfj2JJSqqvFspJeE0ppj99KbwSvFZbVdNB4UhcD8fwh4B7A5FfuSMpztZDDtq1nP'
|
||||
'aIwp0P3Jp3OffG/HH7dzxXnadCz7NmzX+zrJu7W9zC5iNbKkuQ0pMavSPciX/wDk5KXIHwUAAA=='
|
||||
)
|
||||
|
||||
_MODEL_CACHE: dict | None = None
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
global _MODEL_CACHE
|
||||
if _MODEL_CACHE is None:
|
||||
payload = gzip.decompress(base64.b64decode(_MODEL_BLOB.encode("ascii")))
|
||||
spec = json.loads(payload.decode("utf-8"))
|
||||
_MODEL_CACHE = {
|
||||
"center": np.asarray(spec["center"], dtype=float),
|
||||
"scale": np.asarray(spec["scale"], dtype=float),
|
||||
"coef": np.asarray(spec["coef"], dtype=float),
|
||||
"bias": float(spec["bias"]),
|
||||
"threshold": float(spec["threshold"]),
|
||||
"output_center": float(spec.get("output_center") or 0.0),
|
||||
"output_scale": float(spec.get("output_scale") if spec.get("output_scale") is not None else 1.0),
|
||||
"feature_columns": list(spec["feature_columns"]),
|
||||
}
|
||||
return _MODEL_CACHE
|
||||
|
||||
|
||||
def score(features: Mapping[str, float]) -> float:
|
||||
"""Return the model probability in [0, 1] for one feature mapping."""
|
||||
model = _load()
|
||||
vector = np.array(
|
||||
[float(features.get(column) or 0.0) for column in model["feature_columns"]],
|
||||
dtype=float,
|
||||
)
|
||||
scaled = (vector - model["center"]) / model["scale"]
|
||||
logit = float(scaled @ model["coef"] + model["bias"])
|
||||
logit = max(-60.0, min(60.0, logit))
|
||||
return 1.0 / (1.0 + np.exp(-logit))
|
||||
|
||||
|
||||
def predict(features: Mapping[str, float]) -> bool:
|
||||
"""True when the example crosses the embedded decision threshold."""
|
||||
return score(features) >= _load()["threshold"]
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"expected_score": 0.0409139,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.99324714,
|
||||
"duration_ratio_top1": 0.0713484,
|
||||
"elapsed_log": 6.43615037,
|
||||
"margin": 0.0003374,
|
||||
"match_progress_top1": 0.0713484,
|
||||
"prefix_active_fraction": 1.0,
|
||||
"top1_distance": 0.04962666
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.23638102,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.9767654,
|
||||
"duration_ratio_top1": 0.77657169,
|
||||
"elapsed_log": 9.10509096,
|
||||
"margin": 0.00796904,
|
||||
"match_progress_top1": 0.77657169,
|
||||
"prefix_active_fraction": 0.28125,
|
||||
"top1_distance": 0.33501266
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.36610898,
|
||||
"features": {
|
||||
"candidate_count_log": 1.79175947,
|
||||
"distance_ratio": 0.98383705,
|
||||
"duration_ratio_top1": 0.67719326,
|
||||
"elapsed_log": 8.00670085,
|
||||
"margin": 0.00441228,
|
||||
"match_progress_top1": 0.67719326,
|
||||
"prefix_active_fraction": 0.328125,
|
||||
"top1_distance": 0.26857515
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.50799834,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.56963091,
|
||||
"duration_ratio_top1": 0.56899238,
|
||||
"elapsed_log": 8.35501068,
|
||||
"margin": 0.12556275,
|
||||
"match_progress_top1": 0.56899238,
|
||||
"prefix_active_fraction": 0.40625,
|
||||
"top1_distance": 0.16619322
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.67345544,
|
||||
"features": {
|
||||
"candidate_count_log": 2.30258509,
|
||||
"distance_ratio": 0.61349302,
|
||||
"duration_ratio_top1": 0.99052133,
|
||||
"elapsed_log": 8.74369111,
|
||||
"margin": 0.12364826,
|
||||
"match_progress_top1": 0.99052133,
|
||||
"prefix_active_fraction": 0.296875,
|
||||
"top1_distance": 0.19626385
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.88134857,
|
||||
"features": {
|
||||
"candidate_count_log": 1.94591015,
|
||||
"distance_ratio": 0.37757139,
|
||||
"duration_ratio_top1": 1.01608929,
|
||||
"elapsed_log": 6.5539334,
|
||||
"margin": 0.1729941,
|
||||
"match_progress_top1": 1.01608929,
|
||||
"prefix_active_fraction": 0.859375,
|
||||
"top1_distance": 0.10493994
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.95653616,
|
||||
"features": {
|
||||
"candidate_count_log": 1.09861229,
|
||||
"distance_ratio": 0.21720322,
|
||||
"duration_ratio_top1": 0.30021136,
|
||||
"elapsed_log": 7.89561849,
|
||||
"margin": 0.51761286,
|
||||
"match_progress_top1": 0.30021136,
|
||||
"prefix_active_fraction": 0.234375,
|
||||
"top1_distance": 0.14362244
|
||||
}
|
||||
},
|
||||
{
|
||||
"expected_score": 0.99762013,
|
||||
"features": {
|
||||
"candidate_count_log": 1.09861229,
|
||||
"distance_ratio": 0.02465625,
|
||||
"duration_ratio_top1": 1.00014576,
|
||||
"elapsed_log": 9.5269014,
|
||||
"margin": 1.86460354,
|
||||
"match_progress_top1": 1.00014576,
|
||||
"prefix_active_fraction": 0.09375,
|
||||
"top1_distance": 0.04713633
|
||||
}
|
||||
}
|
||||
],
|
||||
"kind": "standardized_logistic",
|
||||
"model": "live_match_commit"
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
|
||||
# Copyright (C) 2026 Lukas Bandura
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""On-device tuning of the matcher's scoring weights (Stage 4/5, opt-in).
|
||||
|
||||
Mirrors the offline ``devtools/dtw_ab_eval.py`` methodology but as a shippable,
|
||||
NumPy-only, executor-safe pure function: it does leave-one-out matching over the
|
||||
device's own labelled cycles, sweeps a small grid of the highest-impact scoring
|
||||
weights (corr/MAE split, duration agreement weight, energy agreement weight, and
|
||||
DTW ensemble weight independently), and - only if a candidate beats the shipped
|
||||
defaults on a HELD-OUT split by a margin - returns a per-device config override. The caller persists it; the matcher reads it live
|
||||
and falls back to the const defaults otherwise.
|
||||
|
||||
Discipline (same as model promotion): tune on a train split, gate on a held-out
|
||||
split, require a margin, cap the grid to bounded scoring weights (never
|
||||
structural behaviour). This guards against over-fitting the small, partly
|
||||
manually-labelled per-user cycle set.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import analysis
|
||||
from ..const import (
|
||||
DEFAULT_DTW_BANDWIDTH,
|
||||
DEFAULT_DTW_MODE,
|
||||
DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
)
|
||||
from ..signal_processing import resample_adaptive, resample_uniform
|
||||
|
||||
#: Matches the production matcher (``profile_store.async_match_profile``).
|
||||
_MIN_DT = 5.0
|
||||
_GAP_S = 21600.0
|
||||
|
||||
|
||||
def _series(cycle: dict[str, Any]) -> tuple[np.ndarray, np.ndarray] | None:
|
||||
"""``(offsets_s, watts)`` from a stored cycle, or None if unusable.
|
||||
|
||||
The offsets matter: ``analysis.find_best_alignment`` compares the two curves
|
||||
**index by index** (its ``dt`` argument is explicitly unused), so both sides
|
||||
must be on the same seconds-per-sample grid or the MAE compares different
|
||||
moments of the cycle. Dropping the offsets - as this module did until register
|
||||
item 303 - makes that impossible to honour.
|
||||
"""
|
||||
pd = cycle.get("power_data") or []
|
||||
ts: list[float] = []
|
||||
pw: list[float] = []
|
||||
for p in pd:
|
||||
# Both values are converted BEFORE either is appended: a row whose offset
|
||||
# parses and whose power does not would otherwise leave `ts` one element
|
||||
# longer than `pw`, and `resample_uniform` raises inside `np.interp` on
|
||||
# unequal arrays - a tuning run lost to one malformed sample.
|
||||
# OverflowError too: `json` keeps an oversized integer literal as an
|
||||
# unbounded int, and `float()` on one raises rather than returning inf.
|
||||
try:
|
||||
offset, watts = float(p[0]), float(p[1])
|
||||
except (TypeError, ValueError, IndexError, OverflowError):
|
||||
continue
|
||||
ts.append(offset)
|
||||
pw.append(watts)
|
||||
if len(pw) < 4:
|
||||
return None
|
||||
return np.asarray(ts, dtype=float), np.asarray(pw, dtype=float)
|
||||
|
||||
|
||||
def _longest(segments: list[Any]) -> Any | None:
|
||||
return max(segments, key=lambda s: len(s.power)) if segments else None
|
||||
|
||||
|
||||
def _prep(cycles: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Group labelled cycles by profile, keeping the raw time series."""
|
||||
by_profile: dict[str, list[dict[str, Any]]] = {}
|
||||
for c in cycles:
|
||||
name = c.get("profile_name")
|
||||
series = _series(c)
|
||||
if not name or series is None:
|
||||
continue
|
||||
try:
|
||||
dur = float(c.get("duration"))
|
||||
except (TypeError, ValueError):
|
||||
dur = 0.0
|
||||
if dur <= 0:
|
||||
# No reliable wall-clock duration: skip rather than fabricate one from the
|
||||
# sample count (len(pw)), which distorts duration scoring on devices that
|
||||
# sample every 30-60 s. Real cycles always carry a 'duration', so this only
|
||||
# drops degenerate entries.
|
||||
continue
|
||||
ts, pw = series
|
||||
review = c.get("ml_review")
|
||||
golden = bool(review.get("golden")) if isinstance(review, dict) else False
|
||||
by_profile.setdefault(name, []).append(
|
||||
{"ts": ts, "pw": pw, "dur": dur, "golden": golden}
|
||||
)
|
||||
return by_profile
|
||||
|
||||
|
||||
def _regrid(item: dict[str, Any], dt: float, cache: dict) -> list[float] | None:
|
||||
"""One cycle's curve on a ``dt``-second grid, cached per (cycle, dt)."""
|
||||
key = (id(item), round(float(dt), 2))
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
seg = _longest(resample_uniform(item["ts"], item["pw"], dt_s=dt, gap_s=_GAP_S))
|
||||
out = seg.power.tolist() if seg is not None and len(seg.power) >= 2 else None
|
||||
cache[key] = out
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def _envelope_avg(
|
||||
name: str, pool: list[dict[str, Any]], excluded: int | None, dt: float, cache: dict
|
||||
) -> list[float] | None:
|
||||
"""The pool's DTW-warped envelope average, re-gridded to the query's ``dt``.
|
||||
|
||||
This is what `ProfileStore.async_match_profile` scores against once a profile
|
||||
has >= 2 confirmed cycles and no pinned golden cycle, i.e. the common case
|
||||
(register item 347(e)).
|
||||
|
||||
Cheap enough to do honestly, which the deferral used to deny (item 354): an
|
||||
envelope is built from traces, so it does not depend on the config being
|
||||
tuned and survives the whole grid search, and leave-one-out changes only the
|
||||
target's OWN profile - so the number of distinct envelopes is
|
||||
``profiles + targets``, not ``folds x profiles``. Both caches below are keyed
|
||||
accordingly.
|
||||
"""
|
||||
ekey = ("env", name, excluded)
|
||||
built = cache.get(ekey)
|
||||
if built is None:
|
||||
raw = [
|
||||
(it["ts"].tolist(), it["pw"].tolist(), float(it["dur"]))
|
||||
for it in pool
|
||||
]
|
||||
try:
|
||||
built = analysis.compute_envelope_worker(raw, _BASE_CFG["dtw_bandwidth"])
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
built = False # cache the failure; do not retry per target
|
||||
cache[ekey] = built
|
||||
if not built:
|
||||
return None
|
||||
grid, _lo, _hi, avg, _std, _target = built
|
||||
if len(grid) < 2 or len(avg) != len(grid):
|
||||
return None
|
||||
gkey = ("envgrid", name, excluded, round(float(dt), 2))
|
||||
if gkey in cache:
|
||||
return cache[gkey]
|
||||
seg = _longest(
|
||||
resample_uniform(
|
||||
np.asarray(grid, dtype=float), np.asarray(avg, dtype=float),
|
||||
dt_s=dt, gap_s=_GAP_S,
|
||||
)
|
||||
)
|
||||
out = seg.power.tolist() if seg is not None and len(seg.power) >= 2 else None
|
||||
cache[gkey] = out
|
||||
return out
|
||||
|
||||
def _snaps(
|
||||
by_profile: dict[str, list[dict]],
|
||||
exclude: tuple[str, int] | None,
|
||||
dt: float,
|
||||
cache: dict,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""One snapshot per profile, re-gridded to the QUERY's ``dt``.
|
||||
|
||||
Mirrors production: ``profile_store`` resamples the current cycle with
|
||||
``resample_adaptive`` and then re-grids every candidate to that same
|
||||
``used_dt`` via ``_get_cached_sample_segment``, so index *i* is the same
|
||||
elapsed time on both sides.
|
||||
|
||||
**The template follows production's three-way rule, in production's order**
|
||||
(`ProfileStore.async_match_profile`), which is register item 356 closing item
|
||||
347(e): a pinned golden cycle's own sharp trace, else the DTW-warped ENVELOPE
|
||||
AVERAGE once the pool holds >= 2 cycles, else the single representative
|
||||
sample - which is also the safety net when an envelope will not build. The
|
||||
middle branch is the common case, and it is the one that used to be missing:
|
||||
scoring one representative cycle where live matching scores the envelope
|
||||
average can favour weights that win on a trace nothing is matched against.
|
||||
|
||||
**Cost, and the trap inside it.** An envelope is built from traces, so it does
|
||||
not depend on the weights being tuned and survives the whole grid search plus
|
||||
every holdout call; and leave-one-out excludes exactly ONE cycle, so only the
|
||||
target's own profile gets a different template while every other profile
|
||||
keeps the full-pool one, shared by every target. Distinct templates are
|
||||
therefore `profiles + targets`, not `folds x profiles` - measured on the worst
|
||||
real export in `cycle_data/` (12 profiles with >= 2 cycles, 63 targets), 75
|
||||
templates at 79 ms a DTW warp, ~5.9 s one-off. **That figure only holds if the
|
||||
cache outlives one `_top1` call.** It did not at first: `_top1` built a fresh
|
||||
cache and is called ~59 times (1 base + 48 grid + 10 holdout), which measured
|
||||
16.1 s -> 360.6 s on a real export. Hence the `cache` argument threaded
|
||||
through the whole run - nothing in it depends on `cfg`. Do not re-scope it.
|
||||
|
||||
The cheap approximation (averaging the pool's regridded curves without the
|
||||
DTW warp) is measured WORSE and is not what `_envelope_avg` does.
|
||||
|
||||
The exposure is bounded regardless: `tune_matching_config` can
|
||||
only move the bounded scoring weights, never structural matching behaviour,
|
||||
`revert_matching_config` undoes it, and promotion is gated on **held-out
|
||||
top-1 accuracy**: `tune_matching_config` promotes only when the tuned config
|
||||
beats the baseline by `margin` on at least `min_wins` of `n_splits` held-out
|
||||
subsamples (4 of 5) AND the mean held-out top-1 gain is itself >= `margin`.
|
||||
Not AUC - no AUC is computed anywhere in this module. The AUC gate in
|
||||
CLAUDE.md is `ML_TRAINING_AUC_MARGIN`, which governs the ml/ CLASSIFIERS in
|
||||
`training_task.py` and has nothing to do with the matcher's scoring weights.
|
||||
"""
|
||||
snaps = []
|
||||
for name, items in by_profile.items():
|
||||
excluded = exclude[1] if (exclude is not None and exclude[0] == name) else None
|
||||
pool = [
|
||||
it for idx, it in enumerate(items)
|
||||
if exclude is None or (name, idx) != exclude
|
||||
]
|
||||
if not pool:
|
||||
continue
|
||||
durs = [it["dur"] for it in pool]
|
||||
avg = float(np.mean(durs))
|
||||
# Production's three-way template rule, in production's order
|
||||
# (`ProfileStore.async_match_profile`). Getting this wrong is register
|
||||
# item 347(e): scoring one representative cycle where live matching
|
||||
# scores the envelope average can favour weights that lose on the curve
|
||||
# that actually does the matching.
|
||||
curve = None
|
||||
golden = [it for it in pool if it.get("golden")]
|
||||
if golden:
|
||||
# 1. a pinned golden cycle keeps its own sharp trace; the average
|
||||
# smears the wash-phase peaks, which is why production prefers it.
|
||||
curve = _regrid(golden[0], dt, cache)
|
||||
elif len(pool) >= 2:
|
||||
# 2. the common case: the DTW-warped envelope average.
|
||||
curve = _envelope_avg(name, pool, excluded, dt, cache)
|
||||
if not curve:
|
||||
# 3. the single-sample fallback, and the safety net for a profile
|
||||
# whose envelope will not build (too few usable points, a trace
|
||||
# that resamples to nothing). Representative rather than
|
||||
# arbitrary: closest to the pool's mean duration.
|
||||
rep = min(pool, key=lambda it: abs(it["dur"] - avg))
|
||||
curve = _regrid(rep, dt, cache)
|
||||
if not curve:
|
||||
continue
|
||||
snaps.append({
|
||||
"name": name,
|
||||
"avg_duration": avg,
|
||||
"sample_power": curve,
|
||||
})
|
||||
return snaps
|
||||
|
||||
|
||||
def _top1(
|
||||
by_profile: dict[str, list[dict]],
|
||||
targets: list[tuple[str, int]],
|
||||
cfg: dict[str, Any],
|
||||
cache: dict | None = None,
|
||||
) -> float:
|
||||
"""Fraction of the given (profile, idx) targets whose true profile ranks #1
|
||||
under leave-one-out matching with the given config.
|
||||
|
||||
``cache`` is shared ACROSS calls on purpose. Nothing in it depends on
|
||||
``cfg``: the re-gridded curves are keyed by (cycle, dt) and the envelopes by
|
||||
(profile, excluded index), while ``cfg`` only changes the scoring weights.
|
||||
Left per-call - as it was - the grid search rebuilds every envelope for each
|
||||
of its ~59 configs, which measured 16 s -> 361 s on one real export. This is
|
||||
what makes the `profiles + targets` cost in register item 354 real rather
|
||||
than theoretical."""
|
||||
if not targets:
|
||||
return 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
if cache is None:
|
||||
cache = {}
|
||||
for name, idx in targets:
|
||||
it = by_profile[name][idx]
|
||||
# The query defines the grid, exactly as in production.
|
||||
segments, used_dt = resample_adaptive(
|
||||
it["ts"], it["pw"], min_dt=_MIN_DT, gap_s=_GAP_S
|
||||
)
|
||||
seg = _longest(segments)
|
||||
if seg is None or len(seg.power) < 4:
|
||||
continue
|
||||
snaps = _snaps(by_profile, (name, idx), used_dt, cache)
|
||||
if len(snaps) < 2:
|
||||
continue
|
||||
cands = analysis.compute_matches_worker(
|
||||
seg.power.tolist(), it["dur"], snaps, cfg
|
||||
)
|
||||
total += 1
|
||||
if cands and cands[0]["name"] == name:
|
||||
correct += 1
|
||||
return correct / total if total else 0.0
|
||||
|
||||
|
||||
# The full production matcher config. A PARTIAL config does not inherit the
|
||||
# production defaults - `compute_matches_worker` has its own fallbacks - so
|
||||
# omitting a key here would tune the weights against a pipeline that never
|
||||
# ships. `energy_mode` is added per device type at the call site.
|
||||
_BASE_CFG = {
|
||||
"min_duration_ratio": DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
|
||||
"max_duration_ratio": DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
|
||||
"dtw_bandwidth": DEFAULT_DTW_BANDWIDTH,
|
||||
"dtw_mode": DEFAULT_DTW_MODE,
|
||||
}
|
||||
|
||||
#: Bounded scoring weights the tuner may promote. All live in [0, 1], so a tuned
|
||||
#: config can only shift emphasis (shape vs level vs energy, and how much the DTW
|
||||
#: ensemble leans on the derivative/DDTW component) - never structural behaviour.
|
||||
OVERRIDE_KEYS = ("corr_weight", "duration_weight", "energy_weight", "dtw_ensemble_w")
|
||||
|
||||
|
||||
def _grid() -> list[dict[str, Any]]:
|
||||
"""Small, high-impact grid over four bounded scoring weights.
|
||||
|
||||
Axes: corr/MAE split × duration agreement weight × energy agreement weight
|
||||
× DTW ensemble weight. The duration and energy axes are now independent so
|
||||
the tuner can find asymmetric configurations (e.g. a device with highly
|
||||
variable energy but stable duration benefits from a low energy_weight and a
|
||||
high duration_weight). All values are bounded scoring weights (see
|
||||
OVERRIDE_KEYS) so a promoted config can never change structural behaviour.
|
||||
Grid size: 4 × 2 × 2 × 3 = 48 configurations (was 4 × 2 × 3 = 24).
|
||||
"""
|
||||
out = []
|
||||
for cw in (0.40, 0.45, 0.50, 0.60):
|
||||
for dur_w in (0.15, 0.22):
|
||||
for en_w in (0.15, 0.22):
|
||||
for ew in (0.55, 0.70, 0.85):
|
||||
out.append({
|
||||
"corr_weight": cw,
|
||||
"duration_weight": dur_w,
|
||||
"energy_weight": en_w,
|
||||
"dtw_ensemble_w": ew,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def tune_matching_config(
|
||||
cycles: list[dict[str, Any]],
|
||||
device_type: str | None = None,
|
||||
*,
|
||||
min_cycles: int = 25,
|
||||
# Kept intentionally low so per-device tuning becomes useful early; the noise
|
||||
# a small sample would introduce is controlled by the multi-split majority gate
|
||||
# below (a lucky single split can't promote), not by a large ``min_targets``.
|
||||
min_targets: int = 12,
|
||||
margin: float = 0.03,
|
||||
seed: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Leave-one-out per-device tuning of matcher scoring weights.
|
||||
|
||||
Methodology (no target leakage between selection and gating):
|
||||
1. Partition the device's labelled cycles ONCE into a *search* pool and an
|
||||
untouched *holdout* pool; no target is ever used for both.
|
||||
2. **Select** the candidate config as the grid entry with the best
|
||||
leave-one-out top-1 on the SEARCH pool only. (Reference snapshots are
|
||||
built from all cycles — as in production, where a query is matched
|
||||
against aggregates of the full profile library; only the *query* targets
|
||||
are partitioned.)
|
||||
3. **Gate** the fixed candidate on the HOLDOUT pool: it must beat the
|
||||
shipped defaults by at least ``margin`` on a MAJORITY of reshuffled
|
||||
holdout subsamples (a variance check that rejects a lucky single split)
|
||||
AND on the holdout mean. ``min_targets`` is kept intentionally low so
|
||||
per-device tuning becomes useful early; the majority gate — not a large
|
||||
sample — controls the noise.
|
||||
|
||||
Returns a status dict; ``promoted`` is True only when both holdout gates pass.
|
||||
When promoted, ``config`` holds the override to persist (bounded scoring
|
||||
weights only — never structural matching behaviour). Never raises for data
|
||||
reasons; returns {"promoted": False, "reason": ...}.
|
||||
"""
|
||||
by_profile = _prep(cycles)
|
||||
multi = {n: items for n, items in by_profile.items() if len(items) >= 2}
|
||||
n_cycles = sum(len(v) for v in by_profile.values())
|
||||
if len(multi) < 2 or n_cycles < min_cycles:
|
||||
return {"promoted": False, "reason": "insufficient data", "n_cycles": n_cycles, "n_profiles": len(by_profile)}
|
||||
|
||||
# Partition targets ONCE, up front, into a search pool (used to pick the
|
||||
# candidate config) and an untouched holdout pool (used only to gate it). No
|
||||
# target is ever used for both selection and gating -> no target leakage.
|
||||
rng = np.random.default_rng(seed)
|
||||
targets = [(n, i) for n, items in multi.items() for i in range(len(items))]
|
||||
rng.shuffle(targets)
|
||||
if len(targets) < min_targets:
|
||||
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
|
||||
cut = max(1, len(targets) // 2)
|
||||
search_pool, holdout_pool = targets[:cut], targets[cut:]
|
||||
if not holdout_pool:
|
||||
return {"promoted": False, "reason": "too few targets", "n_targets": len(targets)}
|
||||
|
||||
# Tune under the same Stage-4 energy mode production uses for this device type,
|
||||
# so promoted weights are consistent with the live matcher.
|
||||
base = {**_BASE_CFG, "energy_mode": analysis.stage4_energy_mode(device_type)}
|
||||
# Candidate: the grid config with the best top-1 on the SEARCH pool only.
|
||||
# One cache for the entire run: the grid search and both holdout arms all
|
||||
# reuse the same envelopes and re-gridded curves (see `_top1`).
|
||||
shared: dict = {}
|
||||
best_search = _top1(by_profile, search_pool, base, shared)
|
||||
best_cfg = base
|
||||
for extra in _grid():
|
||||
acc = _top1(by_profile, search_pool, {**base, **extra}, shared)
|
||||
if acc > best_search:
|
||||
best_search, best_cfg = acc, {**base, **extra}
|
||||
override = {k: best_cfg[k] for k in OVERRIDE_KEYS if k in best_cfg}
|
||||
|
||||
# Gate the FIXED candidate on the held-out pool: require it to beat the defaults
|
||||
# by ``margin`` on a MAJORITY of reshuffled subsamples of the holdout (variance
|
||||
# check), rejecting a lucky single split while keeping min_targets low.
|
||||
n_splits, min_wins = 5, 4
|
||||
base_tests: list[float] = []
|
||||
tuned_tests: list[float] = []
|
||||
wins = 0
|
||||
for k in range(n_splits):
|
||||
r = np.random.default_rng(seed + 1 + k)
|
||||
pool = list(holdout_pool)
|
||||
r.shuffle(pool)
|
||||
held = pool[: max(1, len(pool) // 2)]
|
||||
bt = _top1(by_profile, held, base, shared)
|
||||
tt = _top1(by_profile, held, best_cfg, shared)
|
||||
base_tests.append(bt)
|
||||
tuned_tests.append(tt)
|
||||
if tt - bt >= margin:
|
||||
wins += 1
|
||||
mean_base = float(np.mean(base_tests)) if base_tests else 0.0
|
||||
mean_tuned = float(np.mean(tuned_tests)) if tuned_tests else 0.0
|
||||
has_override = bool(override)
|
||||
enough_wins = wins >= min_wins
|
||||
enough_margin = (mean_tuned - mean_base) >= margin
|
||||
promoted = has_override and enough_wins and enough_margin
|
||||
if promoted:
|
||||
reason = f"beat baseline on {wins}/{n_splits} held-out subsamples"
|
||||
elif not has_override:
|
||||
# AUDITED and correct (register item 356), because it fires on every real
|
||||
# export in `cycle_data/` and that looks like a stuck mechanism. It is
|
||||
# not. `_BASE_CFG` deliberately omits the four OVERRIDE_KEYS, so
|
||||
# `compute_matches_worker` falls back to `MATCH_CORR_WEIGHT` /
|
||||
# `MATCH_DURATION_WEIGHT` / `MATCH_ENERGY_WEIGHT` /
|
||||
# `MATCH_DTW_ENSEMBLE_W` - the tuner's baseline IS production's default,
|
||||
# not a partial config with different fallbacks. `override` is therefore
|
||||
# empty exactly when no grid entry beat that baseline, which is what this
|
||||
# string says. The weights do reach the scorer: on the least saturated
|
||||
# real device (12 profiles, 63 targets, base top-1 0.635) the grid
|
||||
# produces three distinct scores, all <= base. The grid also contains the
|
||||
# exact default combination (0.45 / 0.22 / 0.22 / 0.70), so the defaults
|
||||
# are evaluated on equal terms, and `acc > best_search` is strict so a tie
|
||||
# keeps them. Consistent with the corpus result that matcher top-1 is
|
||||
# near-saturated: there is nothing here for per-device tuning to win.
|
||||
reason = "defaults already optimal (no override)"
|
||||
elif not enough_wins:
|
||||
reason = f"only {wins}/{n_splits} held-out subsamples beat baseline by margin"
|
||||
else:
|
||||
reason = f"mean held-out gain {mean_tuned - mean_base:+.3f} below margin {margin}"
|
||||
return {
|
||||
"promoted": promoted,
|
||||
"config": override if promoted else None,
|
||||
"baseline_test_top1": round(mean_base, 3),
|
||||
"tuned_test_top1": round(mean_tuned, 3),
|
||||
"train_top1": round(best_search, 3),
|
||||
"holdout_wins": wins,
|
||||
"holdout_splits": n_splits,
|
||||
"n_targets": len(targets),
|
||||
"reason": reason,
|
||||
}
|
||||
@@ -65,32 +65,7 @@
|
||||
"name": "hybrid_curve_quality",
|
||||
"target": "problem_cycle",
|
||||
"target_units": ""
|
||||
},
|
||||
{
|
||||
"created_at": "2026-06-29T20:49:05+00:00",
|
||||
"git_commit": "605a862",
|
||||
"kind": "standardized_logistic",
|
||||
"metrics": {
|
||||
"owner_holdout": {
|
||||
"accuracy": 0.771789,
|
||||
"balanced_accuracy": 0.741539,
|
||||
"f1": 0.836751,
|
||||
"fn": 120,
|
||||
"fp": 79,
|
||||
"positive_rate": 0.675459,
|
||||
"precision": 0.865874,
|
||||
"problem_recall": 0.809524,
|
||||
"rows": 872,
|
||||
"specificity": 0.673554,
|
||||
"tn": 163,
|
||||
"tp": 510
|
||||
}
|
||||
},
|
||||
"module": "live_match_commit_model.py",
|
||||
"name": "live_match_commit",
|
||||
"target": "match_top1_correct",
|
||||
"target_units": ""
|
||||
}
|
||||
],
|
||||
"source": "ml_washdata/output/promoted"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,11 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""On-device, NumPy-only model training for WashData (Stage 4).
|
||||
|
||||
This is the runtime counterpart of the offline lab's promotion pipeline. All
|
||||
three embedded models are ``standardized_logistic`` heads - a mean/std scaler
|
||||
plus a weight vector, bias, and decision threshold - which the lab fits with a
|
||||
short pure-NumPy gradient descent (``wash_ml/end_detection.py::_fit_logistic``).
|
||||
This module reproduces that fit and the exact scoring math the embedded
|
||||
``*_model.py`` modules use, so a model trained here on the user's own cycles is
|
||||
byte-compatible with the shipped baseline and can be scored identically.
|
||||
Since 0.5.8 only the ``total_energy`` regressor is trained on-device
|
||||
(:func:`fit_ridge` + :func:`build_regression_spec`, scored by
|
||||
:func:`predict_value_spec`). The classifier training and the logistic spec
|
||||
scoring that used to live here went with the on-device classifier heads
|
||||
(register items 419, 518); the classifiers run their shipped baselines only.
|
||||
|
||||
No new dependencies: NumPy only. Nothing here runs unless the caller (behind the
|
||||
``ENABLE_ML_TRAINING`` flag) invokes it.
|
||||
@@ -38,261 +36,14 @@ import numpy as np
|
||||
PROMOTION_SCHEMA = "washdata.promoted_model/1"
|
||||
|
||||
|
||||
def _sigmoid(values: np.ndarray) -> np.ndarray:
|
||||
clipped = np.clip(values, -60.0, 60.0)
|
||||
return 1.0 / (1.0 + np.exp(-clipped))
|
||||
|
||||
|
||||
def fit_logistic(
|
||||
matrix: np.ndarray,
|
||||
labels: np.ndarray,
|
||||
*,
|
||||
l2: float = 0.01,
|
||||
learning_rate: float = 0.2,
|
||||
iterations: int = 4000,
|
||||
) -> dict[str, np.ndarray | float]:
|
||||
"""Fit a class-balanced, L2-regularised logistic head with NumPy GD.
|
||||
|
||||
Identical in shape to the lab's ``_fit_logistic``: mean/std standardisation,
|
||||
inverse-frequency class weights, and a fixed-step gradient descent. Returns
|
||||
``{center, scale, coef, bias}``.
|
||||
"""
|
||||
matrix = np.asarray(matrix, dtype=float)
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
if matrix.ndim != 2 or matrix.shape[0] == 0:
|
||||
raise ValueError("matrix must be a non-empty 2D array")
|
||||
if labels.shape[0] != matrix.shape[0]:
|
||||
raise ValueError("labels/matrix row mismatch")
|
||||
if len(np.unique(labels)) < 2:
|
||||
raise ValueError(
|
||||
f"fit_logistic requires both positive and negative examples; "
|
||||
f"got labels: {np.unique(labels)}"
|
||||
)
|
||||
|
||||
center = np.mean(matrix, axis=0)
|
||||
scale = np.std(matrix, axis=0)
|
||||
scale = np.where(scale <= 1e-8, 1.0, scale)
|
||||
scaled = (matrix - center) / scale
|
||||
|
||||
weight = np.ones(labels.size, dtype=float)
|
||||
for label_value in (0.0, 1.0):
|
||||
mask = labels == label_value
|
||||
count = float(np.sum(mask))
|
||||
if count > 0:
|
||||
weight[mask] = labels.size / (2.0 * count)
|
||||
normalized = weight / (float(np.sum(weight)) or 1.0)
|
||||
|
||||
coef = np.zeros(matrix.shape[1], dtype=float)
|
||||
bias = 0.0
|
||||
for _ in range(iterations):
|
||||
predictions = _sigmoid(scaled @ coef + bias)
|
||||
residual = (predictions - labels) * normalized
|
||||
coef -= learning_rate * (scaled.T @ residual + l2 * coef)
|
||||
bias -= learning_rate * float(np.sum(residual))
|
||||
return {"center": center, "scale": scale, "coef": coef, "bias": float(bias)}
|
||||
|
||||
|
||||
def _safe_ratio(numerator: float, denominator: float) -> float:
|
||||
return float(numerator) / float(denominator) if denominator else 0.0
|
||||
|
||||
|
||||
def binary_metrics(labels: np.ndarray, scores: np.ndarray, threshold: float) -> dict[str, Any]:
|
||||
"""Confusion-matrix metrics at a threshold (pure NumPy)."""
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
scores = np.asarray(scores, dtype=float)
|
||||
if labels.size == 0:
|
||||
return {}
|
||||
predictions = (scores >= threshold).astype(int)
|
||||
tp = int(np.sum((labels == 1) & (predictions == 1)))
|
||||
fp = int(np.sum((labels == 0) & (predictions == 1)))
|
||||
tn = int(np.sum((labels == 0) & (predictions == 0)))
|
||||
fn = int(np.sum((labels == 1) & (predictions == 0)))
|
||||
precision = _safe_ratio(tp, tp + fp)
|
||||
recall = _safe_ratio(tp, tp + fn)
|
||||
specificity = _safe_ratio(tn, tn + fp)
|
||||
f1 = _safe_ratio(2.0 * precision * recall, precision + recall)
|
||||
accuracy = _safe_ratio(tp + tn, labels.size)
|
||||
positive_rate = _safe_ratio(int(np.sum(labels == 1)), labels.size)
|
||||
# Key names mirror the shipped MODEL_METRICS schema (see *_model.py):
|
||||
# ``problem_recall`` (recall of the positive/"problem" class) and
|
||||
# ``positive_rate`` (base rate of positives), so on-device-trained metrics
|
||||
# are schema-identical to the embedded baselines they are compared against.
|
||||
return {
|
||||
"rows": int(labels.size),
|
||||
"tp": tp, "fp": fp, "tn": tn, "fn": fn,
|
||||
"precision": round(precision, 6),
|
||||
"problem_recall": round(recall, 6),
|
||||
"positive_rate": round(positive_rate, 6),
|
||||
"specificity": round(specificity, 6),
|
||||
"balanced_accuracy": round((recall + specificity) / 2.0, 6),
|
||||
"f1": round(f1, 6),
|
||||
"accuracy": round(accuracy, 6),
|
||||
}
|
||||
|
||||
|
||||
def auc(labels: np.ndarray, scores: np.ndarray) -> float:
|
||||
"""Rank-based ROC AUC (Mann-Whitney U). 0.5 when one class is absent."""
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
scores = np.asarray(scores, dtype=float)
|
||||
finite_mask = np.isfinite(scores)
|
||||
scores = scores[finite_mask]
|
||||
labels = labels[finite_mask]
|
||||
if len(scores) == 0:
|
||||
return 0.5
|
||||
pos = scores[labels == 1]
|
||||
neg = scores[labels == 0]
|
||||
if pos.size == 0 or neg.size == 0:
|
||||
return 0.5
|
||||
order = np.argsort(scores, kind="mergesort")
|
||||
ranks = np.empty(scores.size, dtype=float)
|
||||
ranks[order] = np.arange(1, scores.size + 1, dtype=float)
|
||||
# Average ranks over ties so AUC is exact for discrete scores.
|
||||
_assign_tie_ranks(scores, ranks, order)
|
||||
rank_sum_pos = float(np.sum(ranks[labels == 1]))
|
||||
n_pos = float(pos.size)
|
||||
n_neg = float(neg.size)
|
||||
u = rank_sum_pos - n_pos * (n_pos + 1.0) / 2.0
|
||||
return float(u / (n_pos * n_neg))
|
||||
|
||||
|
||||
def _assign_tie_ranks(scores: np.ndarray, ranks: np.ndarray, order: np.ndarray) -> None:
|
||||
sorted_scores = scores[order]
|
||||
i = 0
|
||||
n = scores.size
|
||||
while i < n:
|
||||
j = i
|
||||
while j + 1 < n and sorted_scores[j + 1] == sorted_scores[i]:
|
||||
j += 1
|
||||
if j > i:
|
||||
avg = (ranks[order[i]] + ranks[order[j]]) / 2.0
|
||||
for k in range(i, j + 1):
|
||||
ranks[order[k]] = avg
|
||||
i = j + 1
|
||||
|
||||
|
||||
def select_threshold(
|
||||
labels: np.ndarray,
|
||||
scores: np.ndarray,
|
||||
*,
|
||||
default: float = 0.5,
|
||||
) -> float:
|
||||
"""Pick the threshold maximising balanced accuracy (model-agnostic).
|
||||
|
||||
Ties break toward the ``default`` so the operating point stays stable when
|
||||
the data does not clearly prefer one cut.
|
||||
"""
|
||||
labels = np.asarray(labels, dtype=float)
|
||||
scores = np.asarray(scores, dtype=float)
|
||||
if scores.size == 0:
|
||||
return default
|
||||
candidates = np.unique(
|
||||
np.concatenate([
|
||||
np.quantile(scores, np.linspace(0.05, 0.95, 37)),
|
||||
np.linspace(0.1, 0.95, 86),
|
||||
])
|
||||
)
|
||||
candidates = candidates[(candidates >= 0.05) & (candidates <= 0.999)]
|
||||
if candidates.size == 0:
|
||||
pos_scores = scores[labels == 1]
|
||||
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
|
||||
best_key: tuple[float, float] | None = None
|
||||
best_threshold = default
|
||||
best_ba = 0.5
|
||||
for threshold in candidates:
|
||||
m = binary_metrics(labels, scores, float(threshold))
|
||||
bal = float(m.get("balanced_accuracy") or 0.0)
|
||||
key = (bal, -abs(float(threshold) - default))
|
||||
if best_key is None or key > best_key:
|
||||
best_key = key
|
||||
best_threshold = float(threshold)
|
||||
best_ba = bal
|
||||
if best_ba == 0.5:
|
||||
pos_scores = scores[labels == 1]
|
||||
return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
|
||||
return round(best_threshold, 6)
|
||||
|
||||
|
||||
def build_spec(
|
||||
*,
|
||||
name: str,
|
||||
target: str,
|
||||
feature_columns: Sequence[str],
|
||||
fit: Mapping[str, Any],
|
||||
threshold: float,
|
||||
metrics: Mapping[str, Any] | None = None,
|
||||
trained_at: str = "",
|
||||
cycle_count: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble a runtime model spec (same schema as the shipped bundles).
|
||||
|
||||
The returned dict is JSON-serialisable and can be scored by
|
||||
:func:`score_spec` with math identical to the embedded ``*_model.py``.
|
||||
"""
|
||||
return {
|
||||
"schema": PROMOTION_SCHEMA,
|
||||
"name": name,
|
||||
"kind": "standardized_logistic",
|
||||
"target": target,
|
||||
"target_units": "",
|
||||
"feature_columns": list(feature_columns),
|
||||
"center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
|
||||
"scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
|
||||
"coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
|
||||
"bias": round(float(fit["bias"]), 8),
|
||||
"threshold": round(float(threshold), 8),
|
||||
"output_center": 0.0,
|
||||
"output_scale": 1.0,
|
||||
"metrics": dict(metrics or {}),
|
||||
"notes": ["Trained on-device from the user's own labelled cycles."],
|
||||
"created_at": trained_at,
|
||||
"cycle_count": int(cycle_count),
|
||||
"source": "on_device",
|
||||
}
|
||||
|
||||
|
||||
def score_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
|
||||
"""Pure-NumPy probabilities for a (rows, features) matrix from a spec."""
|
||||
matrix = np.asarray(matrix, dtype=float)
|
||||
if matrix.size == 0:
|
||||
return np.empty(0, dtype=float)
|
||||
center = np.asarray(spec["center"], dtype=float)
|
||||
scale = np.asarray(spec["scale"], dtype=float)
|
||||
coef = np.asarray(spec["coef"], dtype=float)
|
||||
raw = ((matrix - center) / scale) @ coef + float(spec["bias"])
|
||||
return _sigmoid(raw)
|
||||
|
||||
|
||||
def score_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
|
||||
"""Pure-NumPy probability for one feature mapping.
|
||||
|
||||
Byte-identical to the embedded ``score()`` in ``*_model.py`` for *complete*
|
||||
feature mappings (the normal case: the extractors in ``feature_extraction``
|
||||
always populate every ``FEATURE_COLUMNS`` key). The two intentionally differ
|
||||
only on the defensive missing-key fallback: this fills a missing feature with
|
||||
the training center (standardises to 0.0 = neutral, avoiding 8+ SD corruption
|
||||
of inference), whereas the embedded ``score()`` fills raw 0.0. That path is
|
||||
not exercised by the parity fixtures and is not reachable in practice.
|
||||
"""
|
||||
columns = spec["feature_columns"]
|
||||
center = np.asarray(spec["center"], dtype=float)
|
||||
row = []
|
||||
for i, col in enumerate(columns):
|
||||
val = features.get(col)
|
||||
row.append(float(center[i]) if val is None else float(val))
|
||||
vector = np.array(row, dtype=float)
|
||||
return float(score_matrix_spec(spec, vector.reshape(1, -1))[0])
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression head (standardized_linear) - remaining-time / progress regressor.
|
||||
# Regression head (standardized_linear) - the total_energy regressor.
|
||||
#
|
||||
# The three classifier heads above are logistic. The remaining-time model is a
|
||||
# ridge-regularised linear regressor over standardised features with a
|
||||
# A ridge-regularised linear regressor over standardised features with a
|
||||
# standardised target; prediction un-standardises back to target units using the
|
||||
# spec's ``output_center``/``output_scale``. Same NumPy-only, JSON-serialisable
|
||||
# spec schema as :func:`build_spec` so it is stored/loaded identically, but it is
|
||||
# scored with :func:`predict_matrix_spec` (no sigmoid) rather than ``score_spec``.
|
||||
# spec's ``output_center``/``output_scale``. Same JSON-serialisable spec schema as
|
||||
# the shipped logistic bundles, scored with :func:`predict_matrix_spec` (no
|
||||
# sigmoid).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user