130 lines
5.0 KiB
Python
130 lines
5.0 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/>.
|
|
"""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"]
|