Added Alexa Music
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
# 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
|
||||
|
||||
_RESAMPLE_L = 150
|
||||
|
||||
|
||||
def _powers(cycle: dict[str, Any]) -> list[float]:
|
||||
pd = cycle.get("power_data") or []
|
||||
out: list[float] = []
|
||||
for p in pd:
|
||||
try:
|
||||
out.append(float(p[1]))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _resample(vals: list[float], n: int) -> np.ndarray:
|
||||
a = np.asarray(vals, dtype=float)
|
||||
if a.size == 0:
|
||||
return np.zeros(n)
|
||||
if a.size == n:
|
||||
return a
|
||||
return np.interp(np.linspace(0, 1, n), np.linspace(0, 1, a.size), a)
|
||||
|
||||
|
||||
def _prep(cycles: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Group labelled cycles by profile, caching powers/duration/resampled curve."""
|
||||
by_profile: dict[str, list[dict[str, Any]]] = {}
|
||||
for c in cycles:
|
||||
name = c.get("profile_name")
|
||||
pw = _powers(c)
|
||||
if not name or len(pw) < 4:
|
||||
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
|
||||
by_profile.setdefault(name, []).append(
|
||||
{"pw": pw, "dur": dur, "rs": _resample(pw, _RESAMPLE_L)}
|
||||
)
|
||||
return by_profile
|
||||
|
||||
|
||||
def _snaps(by_profile: dict[str, list[dict]], exclude: tuple[str, int] | None) -> list[dict[str, Any]]:
|
||||
snaps = []
|
||||
for name, items in by_profile.items():
|
||||
curves, durs = [], []
|
||||
for idx, it in enumerate(items):
|
||||
if exclude is not None and (name, idx) == exclude:
|
||||
continue
|
||||
curves.append(it["rs"])
|
||||
durs.append(it["dur"])
|
||||
if curves:
|
||||
snaps.append({
|
||||
"name": name,
|
||||
"avg_duration": float(np.mean(durs)),
|
||||
"sample_power": np.mean(np.array(curves), axis=0).tolist(),
|
||||
})
|
||||
return snaps
|
||||
|
||||
|
||||
def _top1(by_profile: dict[str, list[dict]], targets: list[tuple[str, int]], cfg: dict[str, Any]) -> float:
|
||||
"""Fraction of the given (profile, idx) targets whose true profile ranks #1
|
||||
under leave-one-out matching with the given config."""
|
||||
if not targets:
|
||||
return 0.0
|
||||
correct = 0
|
||||
total = 0
|
||||
for name, idx in targets:
|
||||
it = by_profile[name][idx]
|
||||
snaps = _snaps(by_profile, exclude=(name, idx))
|
||||
if len(snaps) < 2:
|
||||
continue
|
||||
cands = analysis.compute_matches_worker(it["pw"], it["dur"], snaps, cfg)
|
||||
total += 1
|
||||
if cands and cands[0]["name"] == name:
|
||||
correct += 1
|
||||
return correct / total if total else 0.0
|
||||
|
||||
|
||||
_BASE_CFG = {"min_duration_ratio": 0.10, "max_duration_ratio": 1.5}
|
||||
|
||||
#: 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]],
|
||||
*,
|
||||
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)}
|
||||
|
||||
base = {**_BASE_CFG}
|
||||
# Candidate: the grid config with the best top-1 on the SEARCH pool only.
|
||||
best_search = _top1(by_profile, search_pool, base)
|
||||
best_cfg = base
|
||||
for extra in _grid():
|
||||
acc = _top1(by_profile, search_pool, {**base, **extra})
|
||||
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)
|
||||
tt = _top1(by_profile, held, best_cfg)
|
||||
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:
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user