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

192 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, NumPy-only model training for WashData (Stage 4).
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.
"""
from __future__ import annotations
from typing import Any, Mapping, Sequence
import numpy as np
# Matches wash_ml/promotion.py so a trained spec is interchangeable with the
# shipped bundles and could be rendered into a *_model.py if ever needed.
PROMOTION_SCHEMA = "washdata.promoted_model/1"
# ---------------------------------------------------------------------------
# Regression head (standardized_linear) - the total_energy regressor.
#
# 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 JSON-serialisable spec schema as
# the shipped logistic bundles, scored with :func:`predict_matrix_spec` (no
# sigmoid).
# ---------------------------------------------------------------------------
def fit_ridge(
matrix: np.ndarray,
labels: np.ndarray,
*,
alpha: float = 1.0,
) -> dict[str, np.ndarray | float]:
"""Fit a standardised ridge-regression head via NumPy normal equations.
Standardises features (mean/std) and the target, solves
``(ZᵀZ + αI) w = Zᵀ y_std`` in closed form, and returns
``{center, scale, coef, bias, y_center, y_scale}``. Because both the
standardised features and the centred target are zero-mean, the intercept in
standardised space is 0. Prediction is
``((x - center)/scale) @ coef * y_scale + y_center``.
"""
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 np.std(labels) < 1e-8:
raise ValueError(
f"fit_ridge requires non-constant targets; "
f"all labels are approximately {labels[0]:.4f}"
)
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
y_center = float(np.mean(labels))
y_scale = float(np.std(labels))
if y_scale <= 1e-9:
y_scale = 1.0
y_std = (labels - y_center) / y_scale
n_features = scaled.shape[1]
gram = scaled.T @ scaled + float(alpha) * np.eye(n_features)
rhs = scaled.T @ y_std
try:
coef = np.linalg.solve(gram, rhs)
except np.linalg.LinAlgError:
coef = np.linalg.lstsq(gram, rhs, rcond=None)[0]
return {
"center": center,
"scale": scale,
"coef": coef,
"bias": 0.0,
"y_center": y_center,
"y_scale": y_scale,
}
def regression_metrics(labels: np.ndarray, predictions: np.ndarray) -> dict[str, Any]:
"""MAE / RMSE / R² for a regression fit (pure NumPy)."""
labels = np.asarray(labels, dtype=float)
predictions = np.asarray(predictions, dtype=float)
if labels.size == 0:
return {}
err = predictions - labels
mae = float(np.mean(np.abs(err)))
rmse = float(np.sqrt(np.mean(err ** 2)))
ss_res = float(np.sum(err ** 2))
ss_tot = float(np.sum((labels - float(np.mean(labels))) ** 2))
r2 = float(1.0 - ss_res / ss_tot) if ss_tot > 1e-12 else 0.0
return {
"rows": int(labels.size),
"mae": round(mae, 6),
"rmse": round(rmse, 6),
"r2": round(r2, 6),
}
def predict_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
"""Regression predictions (target units) for a (rows, features) matrix."""
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)
y_std = ((matrix - center) / scale) @ coef + float(spec.get("bias", 0.0))
y_center = float(spec.get("output_center", 0.0))
y_scale = float(spec.get("output_scale", 1.0))
return y_std * y_scale + y_center
def predict_value_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
"""Un-standardised regression output for one feature mapping.
Missing feature keys are filled with the training center (which standardises
to 0.0 = neutral), not raw 0.0, to avoid 8+ SD corruption of inference.
"""
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(predict_matrix_spec(spec, vector.reshape(1, -1))[0])
def build_regression_spec(
*,
name: str,
target: str,
feature_columns: Sequence[str],
fit: Mapping[str, Any],
target_units: str = "",
metrics: Mapping[str, Any] | None = None,
trained_at: str = "",
cycle_count: int = 0,
) -> dict[str, Any]:
"""Assemble a ``standardized_linear`` regression spec (JSON-serialisable).
Scored by :func:`predict_matrix_spec` / :func:`predict_value_spec` with math
that mirrors :func:`fit_ridge`. ``threshold`` is retained (0.0) only so the
spec shape stays uniform with the classifier bundles.
"""
return {
"schema": PROMOTION_SCHEMA,
"name": name,
"kind": "standardized_linear",
"target": target,
"target_units": 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.get("bias", 0.0)), 8),
"threshold": 0.0,
"output_center": round(float(fit["y_center"]), 8),
"output_scale": round(float(fit["y_scale"]), 8),
"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",
}