187 files

This commit is contained in:
Home Assistant Version Control
2026-10-09 11:57:31 +00:00
parent 28fa34d78a
commit 1e1b12d52b
187 changed files with 40899 additions and 32654 deletions
+33 -108
View File
@@ -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"
}
}
+10 -259
View File
@@ -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