Files
HomeAssistantVS/custom_components/ha_washdata/ml/training_task.py
T

543 lines
23 KiB
Python

# 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 training orchestration (Stage 4, gated by ENABLE_ML_TRAINING).
Trains the one head with a live consumer: the ``total_energy`` ridge regressor
behind the projected energy / cost (``progress.ml_energy_total``). Its rows are
synthesised from prefixes of the user's own clean cycles, with each profile's
expectation built by the same function the live projection calls. A fit is
promoted only when, on at least ``ML_TRAINING_MIN_HOLDOUT_CYCLES`` held-out
cycles, it beats both the naive elapsed/expected projection (by
``ML_TRAINING_REGRESSION_MARGIN``) and the model already in use (audit ML-12,
PROGRESS-16). Every record carries a ``reason_code`` the panel shows when nothing
was promoted. Nothing here runs unless the training loop (behind the feature flag
+ per-device opt-in) invokes it.
Removed in 0.5.8: the ``quality`` and ``live_match`` heads with their consumers
(the quality gate and the early match commit; audit ML-02/06/10), and on-device
training of the ``end`` classifier, whose consumer is frozen off (audit
ML-05/11: promotion on 4-7 held-out positives admitted worse models) and runs its
shipped baseline, and the ``remaining_time`` regressor with its consumer (audit
ML-07).
"""
from __future__ import annotations
import functools
import logging
import math
from typing import Any
import numpy as np
_LOGGER = logging.getLogger(__name__)
from ..const import (
ML_TRAINING_MIN_HOLDOUT_CYCLES,
ML_TRAINING_MIN_REGRESSION_ROWS,
ML_TRAINING_REGRESSION_MARGIN,
)
from . import trainer as T
# Regression capabilities have no embedded baseline module - they are promoted
# only when they beat a naive analytic estimate on held-out data. capability ->
# (target label, target units).
_REGRESSION_CAPABILITIES = {
"total_energy": ("energy_fraction", "fraction"),
}
# Elapsed fractions at which each clean cycle is cut to synthesize a training row.
_PROGRESS_CUT_FRACTIONS = (0.15, 0.30, 0.45, 0.60, 0.75, 0.90)
def _read_points(cycle: dict[str, Any]) -> list[tuple[float, float]]:
"""Return power data as offset-seconds/watts pairs, handling str and datetime start_time."""
from ..profile_store import decompress_power_data # noqa: PLC0415
try:
return decompress_power_data(cycle)
except Exception: # noqa: BLE001
return []
class _CycleListView:
"""The one store method :func:`progress.profile_end_expectation` reads."""
def __init__(self, cycles: list[dict[str, Any]]) -> None:
self._cycles = cycles
def get_past_cycles(self) -> list[dict[str, Any]]:
return self._cycles
def live_expectations(
cycles: list[dict[str, Any]],
names: set[str] | list[str],
expected_durations: dict[str, float] | None = None,
) -> dict[str, dict[str, float]]:
"""Each profile's expectation exactly as the live projection builds it.
Live, ``progress.ml_energy_total`` takes its expectation from
``progress.profile_end_expectation``: the median trace-integrated energy,
duration and peak of the profile's last 20 stored cycles, with the duration
overridden by the matched profile's expected duration. Training used the
median of the stored ``duration`` / ``energy_wh`` / ``max_power`` fields
instead (audit PROGRESS-16), so the model was fitted on one feature scale and
served on another. This calls the live function over the same cycle list;
``expected_durations`` is the matcher's per-profile duration, read on the event
loop by the caller (absent, the median trace duration stands, as it does live
for an unmatched duration). Pure; never raises.
"""
from ..progress import profile_end_expectation # noqa: PLC0415
view = _CycleListView(cycles)
durations = expected_durations or {}
out: dict[str, dict[str, float]] = {}
for name in names:
if not isinstance(name, str) or not name:
continue
try:
duration = float(durations.get(name) or 0.0)
except (TypeError, ValueError, OverflowError):
duration = 0.0
try:
expectation, _cache = profile_end_expectation(view, name, duration, None)
except Exception: # noqa: BLE001 - one bad profile must not stop training
expectation = None
if expectation:
out[name] = expectation
return out
def _matrix(rows: list[dict[str, float]], columns: list[str]) -> np.ndarray:
if not rows:
return np.empty((0, len(columns)), dtype=float)
return np.array(
[[float(r.get(col) or 0.0) for col in columns] for r in rows], dtype=float
)
def _energy_dataset(
clean: list[dict[str, Any]],
expectations: dict[str, dict[str, float]],
) -> tuple[np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Synthesize (features, energy_completion_fraction) rows for the total-energy
model. Same feature vector as the remaining-time model; the label is
``energy_so_far / total_energy`` at each cut, so the regressor learns how
energy accumulates *non-linearly* over the cycle (heating front-loads it)
rather than assuming it tracks elapsed time. The naive baseline in
``_train_regression_capability`` is ``elapsed_over_expected`` (time progress),
which is exactly the current ``energy_so_far / progress`` projection - so a
model is only promoted when it beats that.
"""
from .feature_extraction import (
PROGRESS_FEATURE_COLUMNS,
progress_features,
cumulative_energy_wh,
)
columns = list(PROGRESS_FEATURE_COLUMNS)
rows: list[dict[str, float]] = []
labels: list[float] = []
groups: list[int] = []
for ci, c in enumerate(clean):
exp = expectations.get(c.get("profile_name"))
if not exp:
continue
points = _read_points(c)
if len(points) < 12:
continue
t0 = points[0][0]
total_dur = points[-1][0] - t0
if total_dur <= 60.0:
continue
total_energy = float(cumulative_energy_wh(points)[-1])
if total_energy <= 1e-6:
continue
for frac in _PROGRESS_CUT_FRACTIONS:
cut_t = t0 + frac * total_dur
prefix = [(o, p) for o, p in points if o <= cut_t]
if len(prefix) < 4:
continue
feat = progress_features(prefix, exp)
if feat is None:
continue
energy_so_far = float(cumulative_energy_wh(prefix)[-1])
label = energy_so_far / total_energy
rows.append(feat)
labels.append(float(min(max(label, 0.0), 1.0)))
groups.append(ci)
return (_matrix(rows, columns), np.array(labels, dtype=float),
columns, np.array(groups, dtype=int))
def _group_holdout_indices(
groups: np.ndarray, frac: float, seed: int, min_test_groups: int = 0
) -> tuple[np.ndarray, np.ndarray] | None:
"""Assign whole groups to train/test so no group straddles the split (B5).
``min_test_groups`` raises the held-out count to that many groups when there
are at least twice as many groups in total (so training keeps as many), which
lets a 10-24 cycle device be judged on 5 cycles instead of 2-4.
Returns (train_idx, test_idx) row-index arrays, or None if there are too few
distinct groups to hold any out while leaving ≥1 training group.
"""
uniq = np.unique(groups)
if uniq.size < 2:
return None
rng = np.random.default_rng(seed)
perm = rng.permutation(uniq)
n_test_groups = max(1, int(round(uniq.size * frac)))
if n_test_groups < min_test_groups <= uniq.size // 2:
n_test_groups = min_test_groups
if uniq.size - n_test_groups < 1:
n_test_groups = uniq.size - 1
test_groups = set(perm[:n_test_groups].tolist())
test_mask = np.array([g in test_groups for g in groups])
train_idx = np.where(~test_mask)[0]
test_idx = np.where(test_mask)[0]
if train_idx.size == 0 or test_idx.size == 0:
return None
return train_idx, test_idx
def _regression_holdout(
X: np.ndarray, y: np.ndarray, groups: np.ndarray | None = None,
*, frac: float = 0.2, seed: int = 0, min_test_groups: int = 0,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, int]:
"""Seeded train/test split for regression, plus how many units were held out.
When ``groups`` is given, splits by group so correlated same-cycle rows never
span train and test. The unit is a source cycle (group) when ``groups`` is
given, else a row. An in-sample fallback (``X_tr is X and X_te is X``)
reports 0.
"""
n = X.shape[0]
if groups is not None and getattr(groups, "size", 0) == n:
split = _group_holdout_indices(groups, frac, seed, min_test_groups)
if split is not None and split[0].size >= 2:
train_idx, test_idx = split
held_out = int(np.unique(groups[test_idx]).size)
return X[train_idx], y[train_idx], X[test_idx], y[test_idx], held_out
return X, y, X, y, 0
rng = np.random.default_rng(seed)
idx = rng.permutation(n)
n_test = max(1, int(round(n * frac)))
if n - n_test < 2: # keep at least a couple of training rows
return X, y, X, y, 0
test_idx, train_idx = idx[:n_test], idx[n_test:]
return X[train_idx], y[train_idx], X[test_idx], y[test_idx], int(n_test)
def _incumbent_mae(
spec: Any, columns: list[str], X_te: np.ndarray, y_te: np.ndarray
) -> float | None:
"""Held-out MAE of the model already in use, or None when there is none.
Scored on exactly the candidate's held-out rows, clipped the same way. A spec
on another feature schema is None: ``resolve_regressor`` already treats it as
inert, so nothing is in use to protect. Never raises.
"""
if not isinstance(spec, dict) or spec.get("kind") != "standardized_linear":
return None
if list(spec.get("feature_columns") or []) != list(columns) or not y_te.size:
return None
try:
preds = np.clip(T.predict_matrix_spec(spec, X_te), 0.0, 1.0)
except Exception: # noqa: BLE001 - a malformed stored spec is not in use either
return None
if preds.shape != y_te.shape or not np.all(np.isfinite(preds)):
return None
return float(np.mean(np.abs(preds - y_te)))
def _not_promoted(
record: dict[str, Any], code: str, params: dict[str, Any], reason: str
) -> dict[str, Any]:
"""Stamp why a run promoted nothing: a code + params for the panel's ``_t()``,
and the English ``reason`` for the log."""
record["promoted"] = False
record["reason_code"] = code
record["reason_params"] = params
record["reason"] = reason
return record
def _train_regression_capability(
capability: str,
target: str,
target_units: str,
X: np.ndarray,
y: np.ndarray,
columns: list[str],
trained_at: str,
groups: np.ndarray | None = None,
*,
incumbent_spec: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Fit + gate one regression capability.
Two bars, both on the same held-out rows (audit ML-12):
* the naive baseline for the completion-fraction target is
``elapsed_over_expected`` (the first feature column) clamped to [0, 1] -
the current profile-duration assumption. The fit's MAE must be at least
``ML_TRAINING_REGRESSION_MARGIN`` lower;
* ``incumbent_spec``, the model in use, when there is one: the fit's MAE must
be strictly lower. Before, it was never scored, so any candidate that beat
the naive estimate replaced it however much better it was.
Nothing is promoted on fewer than ``ML_TRAINING_MIN_HOLDOUT_CYCLES`` held-out
cycles (PROGRESS-16). A record that promotes nothing carries ``reason_code`` /
``reason_params`` (shown in the panel) and an English ``reason`` (logged).
"""
n = X.shape[0]
# Distinct source cycles: each clean cycle contributes several prefix rows via
# `groups`, so ``n`` (rows) overstates how many real cycles trained the model.
grouped = groups is not None and getattr(groups, "size", 0) == n
n_cycles = int(np.unique(groups).size) if grouped else n
record: dict[str, Any] = {
"capability": capability, "promoted": False, "rows": n, "cycle_count": n_cycles,
}
if n < ML_TRAINING_MIN_REGRESSION_ROWS:
return _not_promoted(
record, "insufficient_rows",
{"rows": n, "min": ML_TRAINING_MIN_REGRESSION_ROWS, "cycles": n_cycles},
f"insufficient data (rows={n})",
)
X_tr, y_tr, X_te, y_te, held_out = _regression_holdout(
X, y, groups, min_test_groups=ML_TRAINING_MIN_HOLDOUT_CYCLES,
)
# Detect in-sample fallback (too few rows to split).
in_sample = X_tr is X and X_te is X
if in_sample:
_LOGGER.warning(
"ML training '%s': too few rows (%d) to split for regression - "
"evaluating in-sample; NOT promoting. Add more cycles for a reliable holdout.",
capability, n,
)
try:
fit = T.fit_ridge(X_tr, y_tr, alpha=1.0)
except ValueError as err:
return _not_promoted(record, "fit_failed", {"error": str(err)}, str(err))
spec_probe = {
"center": fit["center"], "scale": fit["scale"], "coef": fit["coef"],
"bias": fit["bias"], "output_center": fit["y_center"], "output_scale": fit["y_scale"],
"feature_columns": columns,
}
preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0)
metrics = T.regression_metrics(y_te, preds)
# Explicit None check - `or 1.0` would coerce MAE=0.0 to 1.0, rejecting a
# perfect regressor and blocking promotion.
model_mae = float(metrics.get("mae") if metrics.get("mae") is not None else 1.0)
naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0
naive = np.clip(X_te[:, naive_col], 0.0, 1.0)
naive_mae = float(np.mean(np.abs(naive - y_te))) if y_te.size else 1.0
incumbent_mae = None if in_sample else _incumbent_mae(incumbent_spec, columns, X_te, y_te)
record.update({
"held_out_cycles": held_out,
"model_mae": round(model_mae, 5),
"naive_mae": round(naive_mae, 5),
"metrics": metrics,
})
if incumbent_mae is not None:
record["incumbent_mae"] = round(incumbent_mae, 5)
# Never promote on an in-sample (non-held-out) evaluation, nor on a handful
# of held-out cycles.
if in_sample or held_out < ML_TRAINING_MIN_HOLDOUT_CYCLES:
return _not_promoted(
record, "holdout_too_small",
{"held_out": held_out, "min": ML_TRAINING_MIN_HOLDOUT_CYCLES, "cycles": n_cycles},
f"only {held_out} held-out cycles (need {ML_TRAINING_MIN_HOLDOUT_CYCLES}); not promoted",
)
if model_mae > naive_mae * (1.0 - ML_TRAINING_REGRESSION_MARGIN):
return _not_promoted(
record, "not_better_than_naive",
{"model": f"{model_mae:.3f}", "naive": f"{naive_mae:.3f}"},
f"MAE {model_mae:.4f} not below naive {naive_mae:.4f} - margin",
)
if incumbent_mae is not None and model_mae >= incumbent_mae:
return _not_promoted(
record, "not_better_than_incumbent",
{"model": f"{model_mae:.3f}", "incumbent": f"{incumbent_mae:.3f}"},
f"MAE {model_mae:.4f} not below the current model's {incumbent_mae:.4f}; kept it",
)
record["promoted"] = True
record["spec"] = T.build_regression_spec(
name=capability, target=target, feature_columns=columns, fit=fit,
target_units=target_units,
metrics={"holdout": metrics, "model_mae": round(model_mae, 5),
"naive_mae": round(naive_mae, 5)},
trained_at=trained_at, cycle_count=n_cycles,
)
record["trained_at"] = trained_at
return record
def train_from_cycles(
cycles: list[dict[str, Any]],
device_type: str | None,
stop_threshold_w: float = 2.0,
trained_at: str = "",
*,
incumbents: dict[str, Any] | None = None,
expected_durations: dict[str, float] | None = None,
) -> dict[str, Any]:
"""Pure function (executor-safe): build the dataset, train, gate the capability.
``incumbents`` is the store's ``ml_model_versions`` (capability -> record with
a ``spec``): each candidate must beat the model in use. ``expected_durations``
is the matcher's duration per profile, so the training expectation is the live
one (:func:`live_expectations`). Both are read on the event loop by
:func:`async_run_training`.
Returns ``{"results": [record, ...], "promoted": {capability: record}}``.
Caller persists the promoted records via ``profile_store.set_ml_model_version``.
"""
from ..suggestion_engine import select_clean_cycles
clean, _excluded = select_clean_cycles(cycles, stop_threshold_w=stop_threshold_w)
expectations = live_expectations(
cycles, {c.get("profile_name") for c in clean}, expected_durations
)
results: list[dict[str, Any]] = []
promoted: dict[str, Any] = {}
# Regression capabilities (no embedded baseline; gated against a naive estimate).
reg_datasets: dict[str, tuple[np.ndarray, np.ndarray, list[str], np.ndarray]] = {
"total_energy": _energy_dataset(clean, expectations),
}
for capability, (target, target_units) in _REGRESSION_CAPABILITIES.items():
X, y, columns, groups = reg_datasets[capability]
incumbent = (incumbents or {}).get(capability)
record = _train_regression_capability(
capability, target, target_units, X, y, columns, trained_at, groups,
incumbent_spec=incumbent.get("spec") if isinstance(incumbent, dict) else None,
)
results.append(record)
if record.get("promoted") and "spec" in record:
promoted[capability] = {
"spec": record["spec"],
"trained_at": trained_at,
"cycle_count": record["cycle_count"],
"metrics": record["metrics"],
"model_mae": record["model_mae"],
"naive_mae": record["naive_mae"],
"held_out_cycles": record["held_out_cycles"],
}
if "incumbent_mae" in record:
promoted[capability]["incumbent_mae"] = record["incumbent_mae"]
return {"results": results, "promoted": promoted}
def _expected_durations(store: Any) -> dict[str, float]:
"""Per profile, the duration the live matcher reports as ``expected_duration``.
``ProfileStore._stage1_duration_for`` mirrors the snapshot builder's
precedence (envelope ``target_duration`` first unless a golden cycle is
pinned, then ``avg_duration``), which is what the live projection is handed
as the matched duration. Loop-only: it reads the live store. Never raises.
"""
out: dict[str, float] = {}
try:
profiles = store.get_profiles() or {}
resolve = getattr(store, "_stage1_duration_for", None)
for name, profile in list(profiles.items()):
if not isinstance(profile, dict):
continue
try:
duration = (
float(resolve(name, profile)) if callable(resolve)
else float(profile.get("avg_duration") or 0.0)
)
except (TypeError, ValueError, OverflowError):
continue # one bad profile must not drop every expectation
if duration > 0 and np.isfinite(duration):
out[name] = duration
except Exception: # noqa: BLE001 - fall back to the trace medians
return {}
return out
def training_stop_threshold(merged: dict[str, Any]) -> float:
"""The stop threshold training cleans cycles with: the entry's Stop Threshold,
else its min_power, else 2.0 W, skipping a value that is not positive and finite
(under +inf no reading is active). ``merged`` is entry data overlaid by options."""
from ..const import CONF_MIN_POWER, CONF_STOP_THRESHOLD_W
for key in (CONF_STOP_THRESHOLD_W, CONF_MIN_POWER):
try:
v = float(merged.get(key))
except (TypeError, ValueError, OverflowError):
continue
if v > 0 and math.isfinite(v):
return v
return 2.0
async def async_run_training(hass: Any, manager: Any) -> dict[str, Any]:
"""Public entry point: train on this device's cycles and persist winners.
Offloads the CPU work to an executor thread and persists any promoted model
specs into the profile store. Returns a summary for logging / the event.
"""
store = manager.profile_store
entry = hass.config_entries.async_get_entry(manager.entry_id)
stop_thr = training_stop_threshold(
{**(entry.data if entry else {}), **(entry.options if entry else {})}
)
from homeassistant.util import dt as dt_util
trained_at = dt_util.now().isoformat()
# Snapshot everything the executor reads, on the loop, before offloading.
cycles = list(store.get_past_cycles())
incumbents = dict(store.get_ml_model_versions() or {})
expected_durations = _expected_durations(store)
_LOGGER.info(
"On-device ML training starting: %d cycles, device_type=%s, stop_threshold=%.1fW",
len(cycles), manager.device_type, stop_thr,
)
summary = await hass.async_add_executor_job(
functools.partial(
train_from_cycles, cycles, manager.device_type, stop_thr, trained_at,
incumbents=incumbents, expected_durations=expected_durations,
)
)
for record in summary.get("results", []):
if record.get("promoted"):
_LOGGER.info(
"ML training PROMOTED %s: MAE %.4f vs naive %.4f, current %s (rows=%s, held out=%s)",
record["capability"], record.get("model_mae", 0), record.get("naive_mae", 0),
record.get("incumbent_mae", "none"), record.get("rows"),
record.get("held_out_cycles"),
)
else:
_LOGGER.info(
"ML training kept the current model for %s: %s",
record["capability"], record.get("reason", "not promoted"),
)
for capability, record in summary.get("promoted", {}).items():
await store.set_ml_model_version(capability, record)
return summary