ML-008: add statistical baseline model
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
quality / test (3.11) (pull_request) Has been cancelled
quality / test (3.13) (pull_request) Has been cancelled

Closes #19
This commit is contained in:
2026-06-13 20:13:06 +02:00
parent ea5a206a86
commit df2ddacfbf
18 changed files with 502 additions and 77 deletions

View File

@@ -1,9 +1,13 @@
# Changelog # Changelog
## Unreleased ## Unreleased
## 0.2.0 - 2026-06-13
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern - Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern
- Validierter Zugriff auf die Home-Assistant-History-API - Validierter Zugriff auf die Home-Assistant-History-API
- Normalisierte, chronologisch sortierte numerische Zeitreihen über `/v1/history` - Normalisierte, chronologisch sortierte numerische Zeitreihen über `/v1/history`
- Trainierbares statistisches Baseline-Modell mit persistierten Parametern
- Numerische Vorhersagen mit Confidence sowie MAE-/RMSE-Evaluation
## 0.1.0 - 2026-06-13 ## 0.1.0 - 2026-06-13
- Projektinitiierung - Projektinitiierung

View File

@@ -4,10 +4,11 @@ Lokaler, datenschutzfreundlicher API-Prototyp für Home Assistant.
## Reifegrad ## Reifegrad
Version `0.1.0` stellt eine gehärtete technische Basis bereit: Home-Assistant-Entities Die aktuelle Entwicklungslinie stellt eine gehärtete technische Basis bereit:
lesen, regelbasierte Bausteine und eine persistente Modell-Artefakt-Registry. Die Home-Assistant-Entities und Historie lesen, Sensoren klassifizieren,
aktuelle Trainings- und Vorhersagelogik ist noch eine deterministische regelbasierte Bausteine sowie ein lokal trainierbares statistisches
Schnittstellen-Implementierung und **kein produktives Machine-Learning-Modell**. Baseline-Modell mit persistenter Registry, Confidence und echten
Evaluationsmetriken.
## Motivation ## Motivation
TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit. TheSillyHome zeigte die Idee: statt statischer Regeln das Zuhause aus Verhaltensmustern verstehen. Diese Architektur modernisiert den Ansatz in Richtung Explainable AI, hybride Intelligenzebenen und langlebige Wartbarkeit.
@@ -46,6 +47,7 @@ uvicorn app.main:app --reload
- `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen - `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen
- `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health - `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health
- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren - `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren
- `POST http://127.0.0.1:8000/ml/evaluate` - MAE/RMSE/Coverage berechnen
Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`. Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`.

View File

@@ -38,7 +38,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.1.0", version="0.2.0",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -3,6 +3,9 @@
__all__ = [ __all__ = [
"FeatureStore", "FeatureStore",
"FeatureVector", "FeatureVector",
"FeatureModel",
"PredictionResult",
"Predictor",
"RetrainingResult", "RetrainingResult",
"RetrainingService", "RetrainingService",
"TrainedArtifact", "TrainedArtifact",
@@ -10,5 +13,6 @@ __all__ = [
"retrain_model", "retrain_model",
] ]
from app.ml.feature_store import FeatureStore, FeatureVector from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.predictor import PredictionResult, Predictor
from app.ml.retraining import RetrainingResult, RetrainingService, retrain_model from app.ml.retraining import RetrainingResult, RetrainingService, retrain_model
from app.ml.training import TrainedArtifact, TrainingPipeline from app.ml.training import FeatureModel, TrainedArtifact, TrainingPipeline

View File

@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from app.ml.feature_store import FeatureVector
from app.ml.predictor import Predictor
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline from app.ml.training import TrainingPipeline
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,41 +28,62 @@ class EvalReport:
class Evaluator: class Evaluator:
def __init__(self, pipeline: TrainingPipeline) -> None: def __init__(
self,
pipeline: TrainingPipeline | None = None,
registry: ModelRegistry | None = None,
) -> None:
if isinstance(pipeline, ModelRegistry) and registry is None:
registry = pipeline
pipeline = None
if pipeline is None and registry is None:
raise ValueError("Evaluator erfordert TrainingPipeline oder ModelRegistry.")
self._pipeline = pipeline self._pipeline = pipeline
self._registry = registry
self._predictor = Predictor(pipeline=pipeline, registry=registry)
def evaluate(self, artifact_id: str, predictions: Sequence[str]) -> EvalReport: def evaluate(self, artifact_id: str, samples: Sequence[FeatureVector]) -> EvalReport:
try: try:
supported_sensors = set(self._pipeline.export(artifact_id).supported_sensors) if self._registry is not None:
self._registry.load_artifact(artifact_id)
elif self._pipeline is not None:
self._pipeline.export(artifact_id)
except KeyError as exc: except KeyError as exc:
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") from exc raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") from exc
parsed_sensors = [_prediction_sensor(prediction) for prediction in predictions] absolute_errors: list[float] = []
supported_hits = sum(sensor in supported_sensors for sensor in parsed_sensors) squared_errors: list[float] = []
unknown_hits = sum(sensor not in supported_sensors for sensor in parsed_sensors) for sample in samples:
sample_size = len(predictions) try:
coverage = supported_hits / sample_size if sample_size else 0.0 prediction = self._predictor.predict(artifact_id, sample)
unknown_rate = unknown_hits / sample_size if sample_size else 0.0 except ValueError:
continue
for feature_name, predicted in prediction.predictions.items():
actual = float(sample.values[feature_name])
error = predicted - actual
absolute_errors.append(abs(error))
squared_errors.append(error**2)
coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8) sample_size = len(absolute_errors)
unknown_metric = Metric(name="unknown_rate", value=unknown_rate, threshold=0.1) mae = sum(absolute_errors) / sample_size if sample_size else 0.0
rmse = math.sqrt(sum(squared_errors) / sample_size) if sample_size else 0.0
expected_values = sum(len(sample.values) for sample in samples)
coverage = sample_size / expected_values if expected_values else 0.0
report = EvalReport( report = EvalReport(
artifact_id=artifact_id, artifact_id=artifact_id,
sample_size=sample_size, sample_size=sample_size,
metrics=[coverage_metric, unknown_metric], metrics=[
Metric(name="mae", value=mae),
Metric(name="rmse", value=rmse),
Metric(name="coverage", value=coverage, threshold=0.8),
],
) )
logger.info( logger.info(
"Evaluation %s -> coverage=%.2f, unknown_rate=%.2f", "Evaluation %s -> mae=%.4f, rmse=%.4f, coverage=%.2f",
artifact_id, artifact_id,
mae,
rmse,
coverage, coverage,
unknown_rate,
) )
return report return report
def _prediction_sensor(prediction: str) -> str | None:
parts = prediction.split(":", 2)
if len(parts) != 3 or not parts[0] or not parts[1]:
return None
return parts[1]

View File

@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from dataclasses import dataclass
from typing import Sequence from typing import Sequence
from app.ml.feature_store import FeatureVector from app.ml.feature_store import FeatureVector
@@ -10,6 +12,15 @@ from app.ml.training import TrainedArtifact, TrainingPipeline
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class PredictionResult:
artifact_id: str
sensor_id: str
predictions: dict[str, float]
confidence: float
model_type: str
class Predictor: class Predictor:
def __init__( def __init__(
self, self,
@@ -24,15 +35,45 @@ class Predictor:
self._pipeline = pipeline self._pipeline = pipeline
self._registry = registry self._registry = registry
def predict(self, artifact_id: str, entity: FeatureVector) -> str: def predict(self, artifact_id: str, entity: FeatureVector) -> PredictionResult:
artifact = self._get_artifact(artifact_id) artifact = self._get_artifact(artifact_id)
if entity.sensor_id not in artifact.supported_sensors: if entity.sensor_id not in artifact.supported_sensors:
raise ValueError( raise ValueError(
f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt." f"Sensor '{entity.sensor_id}' wird vom Modell '{artifact_id}' nicht unterstützt."
) )
return f"{artifact_id}:{entity.sensor_id}:{entity.values}" sensor_models = artifact.feature_models.get(entity.sensor_id, {})
if not sensor_models:
raise ValueError(f"Modell '{artifact_id}' enthält keine statistischen Parameter.")
def predict_batch(self, artifact_id: str, entities: Sequence[FeatureVector]) -> list[str]: feature_names = sorted(set(sensor_models).intersection(entity.values))
if not feature_names:
raise ValueError(
f"Keine Eingabemerkmale werden vom Modell '{artifact_id}' unterstützt."
)
predictions: dict[str, float] = {}
confidences: list[float] = []
for feature_name in feature_names:
model = sensor_models[feature_name]
current_value = float(entity.values[feature_name])
if not math.isfinite(current_value):
raise ValueError("Vorhersagewerte müssen endlich sein.")
predictions[feature_name] = model.forecast(current_value)
confidences.append(model.confidence)
return PredictionResult(
artifact_id=artifact_id,
sensor_id=entity.sensor_id,
predictions=predictions,
confidence=sum(confidences) / len(confidences),
model_type=artifact.model_type,
)
def predict_batch(
self,
artifact_id: str,
entities: Sequence[FeatureVector],
) -> list[PredictionResult]:
return [self.predict(artifact_id, entity) for entity in entities] return [self.predict(artifact_id, entity) for entity in entities]
@staticmethod @staticmethod
@@ -47,4 +88,4 @@ class Predictor:
return self._registry.load_artifact(artifact_id) return self._registry.load_artifact(artifact_id)
if self._pipeline is not None: if self._pipeline is not None:
return self._pipeline.export(artifact_id) return self._pipeline.export(artifact_id)
raise RuntimeError("Predictor nicht initialisiert.") raise RuntimeError("Predictor nicht initialisiert.")

View File

@@ -2,13 +2,14 @@ from __future__ import annotations
import json import json
import logging import logging
import math
import os import os
from pathlib import Path from pathlib import Path
import re import re
from threading import RLock from threading import RLock
from collections.abc import Iterable from collections.abc import Iterable
from app.ml.training import TrainedArtifact from app.ml.training import FeatureModel, TrainedArtifact
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,19 +53,26 @@ class ModelRegistry:
raw = json.loads(source.read_text(encoding="utf-8")) raw = json.loads(source.read_text(encoding="utf-8"))
artifact_id = raw["artifact_id"] artifact_id = raw["artifact_id"]
supported_sensors = raw["supported_sensors"] supported_sensors = raw["supported_sensors"]
model_type = raw.get("model_type", "metadata")
raw_feature_models = raw.get("feature_models", {})
if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list): if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list):
raise ValueError("invalid artifact structure") raise ValueError("invalid artifact structure")
if not isinstance(model_type, str):
raise ValueError("model_type must be a string")
self._validate_artifact_id(artifact_id) self._validate_artifact_id(artifact_id)
if source.name != f"{artifact_id}.json": if source.name != f"{artifact_id}.json":
raise ValueError("artifact id does not match filename") raise ValueError("artifact id does not match filename")
if not all(isinstance(sensor, str) for sensor in supported_sensors): if not all(isinstance(sensor, str) for sensor in supported_sensors):
raise ValueError("supported_sensors must contain strings") raise ValueError("supported_sensors must contain strings")
feature_models = _deserialize_feature_models(raw_feature_models)
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError(f"Ungültiges Modell-Artefakt: {source.name}") from exc raise ValueError(f"Ungültiges Modell-Artefakt: {source.name}") from exc
self._artifacts[artifact_id] = TrainedArtifact( self._artifacts[artifact_id] = TrainedArtifact(
artifact_id=artifact_id, artifact_id=artifact_id,
supported_sensors=tuple(supported_sensors), supported_sensors=tuple(supported_sensors),
feature_models=feature_models,
model_type=model_type,
) )
def _persist(self, artifact: TrainedArtifact) -> None: def _persist(self, artifact: TrainedArtifact) -> None:
@@ -73,6 +81,22 @@ class ModelRegistry:
payload = { payload = {
"artifact_id": artifact.artifact_id, "artifact_id": artifact.artifact_id,
"supported_sensors": list(artifact.supported_sensors), "supported_sensors": list(artifact.supported_sensors),
"model_type": artifact.model_type,
"feature_models": {
sensor_id: {
feature_name: {
"sample_count": model.sample_count,
"mean": model.mean,
"standard_deviation": model.standard_deviation,
"minimum": model.minimum,
"maximum": model.maximum,
"slope": model.slope,
"intercept": model.intercept,
}
for feature_name, model in sorted(models.items())
}
for sensor_id, models in sorted(artifact.feature_models.items())
},
} }
temporary.write_text( temporary.write_text(
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n", json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
@@ -88,3 +112,51 @@ class ModelRegistry:
"artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich " "artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich "
"und Bindestrich enthalten." "und Bindestrich enthalten."
) )
def _deserialize_feature_models(raw: object) -> dict[str, dict[str, FeatureModel]]:
if not isinstance(raw, dict):
raise ValueError("feature_models must be an object")
result: dict[str, dict[str, FeatureModel]] = {}
for sensor_id, raw_features in raw.items():
if not isinstance(sensor_id, str) or not isinstance(raw_features, dict):
raise ValueError("invalid sensor feature models")
features: dict[str, FeatureModel] = {}
for feature_name, raw_model in raw_features.items():
if not isinstance(feature_name, str) or not isinstance(raw_model, dict):
raise ValueError("invalid feature model")
sample_count = raw_model.get("sample_count")
if not isinstance(sample_count, int) or isinstance(sample_count, bool) or sample_count < 1:
raise ValueError("sample_count must be a positive integer")
values = {
key: _finite_number(raw_model.get(key))
for key in (
"mean",
"standard_deviation",
"minimum",
"maximum",
"slope",
"intercept",
)
}
features[feature_name] = FeatureModel(
sample_count=sample_count,
mean=values["mean"],
standard_deviation=values["standard_deviation"],
minimum=values["minimum"],
maximum=values["maximum"],
slope=values["slope"],
intercept=values["intercept"],
)
result[sensor_id] = features
return result
def _finite_number(value: object) -> float:
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise ValueError("feature model values must be finite numbers")
converted = float(value)
if not math.isfinite(converted):
raise ValueError("feature model values must be finite numbers")
return converted

View File

@@ -1,17 +1,44 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass import math
from collections import defaultdict
from dataclasses import dataclass, field
from app.ml.feature_store import FeatureStore from app.ml.feature_store import FeatureStore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@dataclass @dataclass(frozen=True)
class FeatureModel:
sample_count: int
mean: float
standard_deviation: float
minimum: float
maximum: float
slope: float
intercept: float
def forecast(self, current_value: float | None = None) -> float:
if current_value is not None:
return current_value + self.slope
return self.intercept + self.slope * self.sample_count
@property
def confidence(self) -> float:
sample_score = self.sample_count / (self.sample_count + 2)
scale = abs(self.mean) if abs(self.mean) > 1e-9 else 1.0
stability_score = 1.0 / (1.0 + self.standard_deviation / scale)
return min(0.99, max(0.05, sample_score * stability_score))
@dataclass(frozen=True)
class TrainedArtifact: class TrainedArtifact:
artifact_id: str artifact_id: str
supported_sensors: tuple[str, ...] supported_sensors: tuple[str, ...]
feature_models: dict[str, dict[str, FeatureModel]] = field(default_factory=dict)
model_type: str = "statistical_baseline"
class TrainingPipeline: class TrainingPipeline:
@@ -24,8 +51,33 @@ class TrainingPipeline:
if not vectors: if not vectors:
raise ValueError("FeatureStore enthält keine Trainingsdaten.") raise ValueError("FeatureStore enthält keine Trainingsdaten.")
sensors = tuple(sorted({vector.sensor_id for vector in vectors})) samples: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
artifact = TrainedArtifact(artifact_id=artifact_id, supported_sensors=sensors) for vector in vectors:
for feature_name, raw_value in vector.values.items():
value = float(raw_value)
if math.isfinite(value):
samples[vector.sensor_id][feature_name].append(value)
feature_models = {
sensor_id: {
feature_name: _fit_feature(values)
for feature_name, values in sorted(features.items())
if values
}
for sensor_id, features in sorted(samples.items())
}
feature_models = {
sensor_id: models for sensor_id, models in feature_models.items() if models
}
if not feature_models:
raise ValueError("Trainingsdaten enthalten keine endlichen numerischen Werte.")
sensors = tuple(feature_models)
artifact = TrainedArtifact(
artifact_id=artifact_id,
supported_sensors=sensors,
feature_models=feature_models,
)
self._artifacts[artifact_id] = artifact self._artifacts[artifact_id] = artifact
logger.info("Training abgeschlossen für %s mit %d Sensoren", artifact_id, len(sensors)) logger.info("Training abgeschlossen für %s mit %d Sensoren", artifact_id, len(sensors))
return artifact return artifact
@@ -34,3 +86,32 @@ class TrainingPipeline:
if artifact_id not in self._artifacts: if artifact_id not in self._artifacts:
raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.") raise KeyError(f"Artifact '{artifact_id}' nicht gefunden.")
return self._artifacts[artifact_id] return self._artifacts[artifact_id]
def _fit_feature(values: list[float]) -> FeatureModel:
sample_count = len(values)
mean = sum(values) / sample_count
variance = sum((value - mean) ** 2 for value in values) / sample_count
standard_deviation = math.sqrt(variance)
if sample_count == 1:
slope = 0.0
intercept = mean
else:
x_mean = (sample_count - 1) / 2
denominator = sum((index - x_mean) ** 2 for index in range(sample_count))
numerator = sum(
(index - x_mean) * (value - mean) for index, value in enumerate(values)
)
slope = numerator / denominator
intercept = mean - slope * x_mean
return FeatureModel(
sample_count=sample_count,
mean=mean,
standard_deviation=standard_deviation,
minimum=min(values),
maximum=max(values),
slope=slope,
intercept=intercept,
)

View File

@@ -7,6 +7,7 @@ from collections.abc import Sequence
from fastapi import APIRouter, FastAPI, HTTPException, Request, status from fastapi import APIRouter, FastAPI, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.ml.evaluation import Evaluator
from app.ml.feature_store import FeatureVector from app.ml.feature_store import FeatureVector
from app.ml.predictor import Predictor from app.ml.predictor import Predictor
from app.ml.registry.model_registry import ModelRegistry from app.ml.registry.model_registry import ModelRegistry
@@ -31,7 +32,9 @@ class PredictRequest(BaseModel):
class PredictResponse(BaseModel): class PredictResponse(BaseModel):
model_id: str model_id: str
sensor_id: str sensor_id: str
prediction: str predictions: dict[str, float]
confidence: float
model_type: str
class BatchRequest(BaseModel): class BatchRequest(BaseModel):
@@ -60,9 +63,28 @@ class RetrainRequest(BaseModel):
class RetrainResponse(BaseModel): class RetrainResponse(BaseModel):
model_id: str model_id: str
supported_sensors: list[str] supported_sensors: list[str]
trained_features: int
model_type: str
replaced: bool replaced: bool
class EvaluateRequest(BaseModel):
model_id: str = Field(..., alias="modelId", min_length=1, max_length=128)
samples: list[TrainingSample] = Field(min_length=1)
class MetricResponse(BaseModel):
name: str
value: float
threshold: float | None = None
class EvaluateResponse(BaseModel):
model_id: str
sample_size: int
metrics: list[MetricResponse]
@router.get("/health", response_model=HealthResponse, status_code=200) @router.get("/health", response_model=HealthResponse, status_code=200)
def health() -> HealthResponse: def health() -> HealthResponse:
return HealthResponse(status="ok") return HealthResponse(status="ok")
@@ -96,10 +118,50 @@ def retrain(payload: RetrainRequest, request: Request) -> RetrainResponse:
return RetrainResponse( return RetrainResponse(
model_id=result.artifact.artifact_id, model_id=result.artifact.artifact_id,
supported_sensors=list(result.artifact.supported_sensors), supported_sensors=list(result.artifact.supported_sensors),
trained_features=sum(
len(feature_models)
for feature_models in result.artifact.feature_models.values()
),
model_type=result.artifact.model_type,
replaced=result.replaced, replaced=result.replaced,
) )
@router.post("/evaluate", response_model=EvaluateResponse, status_code=200)
def evaluate(payload: EvaluateRequest, request: Request) -> EvaluateResponse:
registry = _require_registry(request)
vectors = [
FeatureVector(
sensor_id=sample.sensor_id,
values=sample.values,
label=sample.label,
)
for sample in payload.samples
]
try:
report = Evaluator(registry=registry).evaluate(payload.model_id, vectors)
except ValueError as exc:
try:
registry.load_artifact(payload.model_id)
except KeyError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return EvaluateResponse(
model_id=report.artifact_id,
sample_size=report.sample_size,
metrics=[
MetricResponse(name=metric.name, value=metric.value, threshold=metric.threshold)
for metric in report.metrics
],
)
@router.post("/predict", response_model=PredictResponse, status_code=200) @router.post("/predict", response_model=PredictResponse, status_code=200)
def predict(payload: PredictRequest, request: Request) -> PredictResponse: def predict(payload: PredictRequest, request: Request) -> PredictResponse:
registry = _require_registry(request) registry = _require_registry(request)
@@ -117,7 +179,9 @@ def predict(payload: PredictRequest, request: Request) -> PredictResponse:
return PredictResponse( return PredictResponse(
model_id=payload.model_id, model_id=payload.model_id,
sensor_id=payload.sensor_id, sensor_id=payload.sensor_id,
prediction=prediction, predictions=prediction.predictions,
confidence=prediction.confidence,
model_type=prediction.model_type,
) )
@@ -138,7 +202,13 @@ def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
detail=str(exc), detail=str(exc),
) from exc ) from exc
responses.append( responses.append(
PredictResponse(model_id=item.model_id, sensor_id=item.sensor_id, prediction=prediction) PredictResponse(
model_id=item.model_id,
sensor_id=item.sensor_id,
predictions=prediction.predictions,
confidence=prediction.confidence,
model_type=prediction.model_type,
)
) )
return BatchResponse(predictions=responses) return BatchResponse(predictions=responses)

View File

@@ -3,9 +3,7 @@
Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen Diese Dokumentation beschreibt die REST-Endpunkte der aktuellen
Modell-Artefakt- und Vorhersage-Schnittstelle. Modell-Artefakt- und Vorhersage-Schnittstelle.
> Hinweis: Version 0.1.0 enthält noch kein statistisch trainiertes ML-Modell. Das Serving verwendet ein lokal trainiertes statistisches Baseline-Modell.
> Die Vorhersage ist eine deterministische Referenzimplementierung für den
> späteren Modellvertrag.
## Basis-URL ## Basis-URL
@@ -13,6 +11,7 @@ Modell-Artefakt- und Vorhersage-Schnittstelle.
- Health: `/health` - Health: `/health`
- Modelle: `/models` - Modelle: `/models`
- Retraining: `/retrain` - Retraining: `/retrain`
- Evaluation: `/evaluate`
- Einzelvorhersage: `/predict` - Einzelvorhersage: `/predict`
- Batchvorhersage: `/batch` - Batchvorhersage: `/batch`
@@ -62,7 +61,9 @@ Einzelne Vorhersage für einen Sensor.
{ {
"model_id": "default", "model_id": "default",
"sensor_id": "sensor.kitchen", "sensor_id": "sensor.kitchen",
"prediction": "default:sensor.kitchen:{'temperature': 21.0}" "predictions": {"temperature": 21.4},
"confidence": 0.78,
"model_type": "statistical_baseline"
} }
``` ```
@@ -91,10 +92,17 @@ dem Modellverzeichnis geladen.
{ {
"model_id": "home-model", "model_id": "home-model",
"supported_sensors": ["sensor.kitchen"], "supported_sensors": ["sensor.kitchen"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": false "replaced": false
} }
``` ```
### `POST /ml/evaluate`
Vergleicht Modellvorhersagen mit Validierungsdaten und liefert MAE, RMSE und
Coverage. Der Request verwendet dasselbe Sample-Format wie `/ml/retrain`.
### `POST /ml/batch` ### `POST /ml/batch`
Batch-Vorhersage für mehrere Sensorwerte. Batch-Vorhersage für mehrere Sensorwerte.
@@ -124,12 +132,16 @@ Batch-Vorhersage für mehrere Sensorwerte.
{ {
"model_id": "default", "model_id": "default",
"sensor_id": "sensor.kitchen", "sensor_id": "sensor.kitchen",
"prediction": "default:sensor.kitchen:{'temperature': 21.0}" "predictions": {"temperature": 21.4},
"confidence": 0.78,
"model_type": "statistical_baseline"
}, },
{ {
"model_id": "default", "model_id": "default",
"sensor_id": "sensor.bedroom", "sensor_id": "sensor.bedroom",
"prediction": "default:sensor.bedroom:{'temperature': 18.5}" "predictions": {"temperature": 18.3},
"confidence": 0.74,
"model_type": "statistical_baseline"
} }
] ]
} }

View File

@@ -1,14 +1,13 @@
# ML Training- und Evaluations-Workflow # ML Training- und Evaluations-Workflow
Dieser Workflow beschreibt den aktuellen Platzhalter für Modell-Metadaten, SillyHome Next trainiert ein lokales statistisches Baseline-Modell pro Sensor
Evaluation und Serving. Er trainiert in Version 0.1.0 noch kein statistisches und Merkmal. Es benötigt keine Cloud und keine externe ML-Laufzeit.
Modell.
## 1. Daten sammeln ## 1. Daten sammeln
Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen. Alle Trainingsvektoren werden über `FeatureStore.add(...)` oder `add_batch(...)` eingepflegt. Jeder Vektor enthält eine Sensor-ID sowie ein Dictionary mit Merkmalen.
## 2. Artefakt-Metadaten erzeugen ## 2. Statistisches Artefakt erzeugen
```python ```python
store = FeatureStore() store = FeatureStore()
@@ -18,21 +17,30 @@ artifact = pipeline.run("my_artifact")
pipeline.export("my_artifact") pipeline.export("my_artifact")
``` ```
`TrainingPipeline.run(...)` erzeugt ein `TrainedArtifact` mit den unterstützten `TrainingPipeline.run(...)` berechnet für jedes numerische Merkmal:
Sensor-IDs. Gewichte, Parameter oder ein echtes Modell werden noch nicht
berechnet. - Stichprobenzahl
- Mittelwert und Standardabweichung
- Minimum und Maximum
- linearen Trend mit Steigung und Achsenabschnitt
Die nächste Vorhersage kombiniert den letzten beobachteten Wert mit der
trainierten Trendsteigung. Die Confidence berücksichtigt Datenmenge und
Stabilität.
## 3. Modell evaluieren ## 3. Modell evaluieren
```python ```python
evaluator = Evaluator(pipeline) evaluator = Evaluator(pipeline)
report = evaluator.evaluate(artifact.artifact_id, predictions) report = evaluator.evaluate(artifact.artifact_id, validation_samples)
``` ```
Der Report enthält: Der Report enthält echte numerische Vergleichsmetriken:
- `artifact_id` - `artifact_id`
- `sample_size` - `sample_size`
- Metriken wie `coverage` und `unknown_rate` mit Default-Schwellenwerten - `mae` (Mean Absolute Error)
- `rmse` (Root Mean Squared Error)
- `coverage` für den Anteil auswertbarer Merkmale
## 4. Modell registrieren ## 4. Modell registrieren
@@ -56,4 +64,5 @@ Der Service startet bewusst keinen eigenen Hintergrundprozess. Über
## Hinweise ## Hinweise
- Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet. - Für reproduzierbare Sensor-Reihenfolgen wird in `TrainingPipeline.run(...)` eine sortierte Sensor-Liste verwendet.
- Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus. - Fehlende Trainingsdaten lösen `ValueError` aus; nicht registrierte Artefakte lösen `KeyError` aus.
- `coverage` zählt nur exakte Sensor-Referenzen und bleibt im Bereich 0 bis 1. - Nur endliche numerische Werte werden trainiert.
- `coverage` bleibt im Bereich 0 bis 1.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.1.0" version = "0.2.0"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -87,12 +87,16 @@ def test_retrain_creates_and_replaces_persisted_model(tmp_path: Path) -> None:
assert created.json() == { assert created.json() == {
"model_id": "home-model", "model_id": "home-model",
"supported_sensors": ["sensor.kitchen"], "supported_sensors": ["sensor.kitchen"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": False, "replaced": False,
} }
assert replaced.status_code == 200 assert replaced.status_code == 200
assert replaced.json() == { assert replaced.json() == {
"model_id": "home-model", "model_id": "home-model",
"supported_sensors": ["sensor.bedroom"], "supported_sensors": ["sensor.bedroom"],
"trained_features": 1,
"model_type": "statistical_baseline",
"replaced": True, "replaced": True,
} }
restarted = ModelRegistry(tmp_path) restarted = ModelRegistry(tmp_path)
@@ -107,3 +111,70 @@ def test_retrain_rejects_empty_samples() -> None:
) )
assert response.status_code == 422 assert response.status_code == 422
def test_predict_returns_numeric_forecast_and_confidence(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
registry = ModelRegistry(tmp_path)
registry.register(TrainingPipeline(store).run("home-model"))
with TestClient(app) as client:
app.state.registry = registry
response = client.post(
"/ml/predict",
json={
"modelId": "home-model",
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
},
)
assert response.status_code == 200
assert response.json()["predictions"] == {"temperature": 22.0}
assert 0.0 < response.json()["confidence"] <= 1.0
assert response.json()["model_type"] == "statistical_baseline"
def test_evaluate_returns_real_error_metrics(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.registry.model_registry import ModelRegistry
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
registry = ModelRegistry(tmp_path)
registry.register(TrainingPipeline(store).run("home-model"))
with TestClient(app) as client:
app.state.registry = registry
response = client.post(
"/ml/evaluate",
json={
"modelId": "home-model",
"samples": [
{
"sensor_id": "sensor.kitchen",
"values": {"temperature": 21.0},
}
],
},
)
assert response.status_code == 200
metrics = {metric["name"]: metric["value"] for metric in response.json()["metrics"]}
assert metrics == {"mae": 1.0, "rmse": 1.0, "coverage": 1.0}

View File

@@ -24,14 +24,15 @@ def test_evaluate_returns_report_with_metrics() -> None:
report = evaluator.evaluate( report = evaluator.evaluate(
"artifact_v1", "artifact_v1",
[ [
"artifact_v1:sensor.kitchen:{'temperature': 21.0}", _vector("sensor.kitchen", 21.0),
"artifact_v1:sensor.bedroom:{'temperature': 18.5}", _vector("sensor.bedroom", 18.5),
], ],
) )
assert report.artifact_id == "artifact_v1" assert report.artifact_id == "artifact_v1"
assert report.sample_size == 2 assert report.sample_size == 2
assert {metric.name for metric in report.metrics} == {"coverage", "unknown_rate"} assert {metric.name for metric in report.metrics} == {"mae", "rmse", "coverage"}
assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0 assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0
assert next(metric.value for metric in report.metrics if metric.name == "mae") == 0.0
def test_evaluate_without_training_raises_value_error() -> None: def test_evaluate_without_training_raises_value_error() -> None:
@@ -40,16 +41,16 @@ def test_evaluate_without_training_raises_value_error() -> None:
evaluator.evaluate("artifact_v1", []) evaluator.evaluate("artifact_v1", [])
def test_coverage_is_bounded_and_requires_exact_sensor_match() -> None: def test_coverage_counts_only_supported_sensor_features() -> None:
evaluator = evaluator_factory() evaluator = evaluator_factory()
report = evaluator.evaluate( report = evaluator.evaluate(
"artifact_v1", "artifact_v1",
[ [
"artifact_v1:sensor.kitchen:{'note': 'sensor.bedroom'}", _vector("sensor.kitchen", 21.0),
"artifact_v1:sensor.kitchen_extra:{}", FeatureVector(sensor_id="sensor.kitchen", values={"humidity": 50.0}),
"malformed", _vector("sensor.kitchen_extra", 20.0),
], ],
) )
metrics = {metric.name: metric.value for metric in report.metrics} metrics = {metric.name: metric.value for metric in report.metrics}
assert metrics == {"coverage": pytest.approx(1 / 3), "unknown_rate": pytest.approx(2 / 3)} assert metrics["coverage"] == pytest.approx(1 / 3)

View File

@@ -19,6 +19,24 @@ def test_registry_loads_persisted_artifacts_after_restart(tmp_path: Path) -> Non
assert restarted.load_artifact("model-v1") == artifact assert restarted.load_artifact("model-v1") == artifact
def test_registry_persists_statistical_parameters(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
artifact = TrainingPipeline(store).run("model-v1")
ModelRegistry(tmp_path).register(artifact)
assert ModelRegistry(tmp_path).load_artifact("model-v1") == artifact
def test_registry_replaces_persisted_artifact_after_restart(tmp_path: Path) -> None: def test_registry_replaces_persisted_artifact_after_restart(tmp_path: Path) -> None:
registry = ModelRegistry(tmp_path) registry = ModelRegistry(tmp_path)
registry.register(TrainedArtifact("model-v1", ("sensor.kitchen",))) registry.register(TrainedArtifact("model-v1", ("sensor.kitchen",)))

View File

@@ -13,16 +13,26 @@ def _vector(sensor_id: str, temperature: float, label: str | None = None) -> Fea
def predictor() -> Predictor: def predictor() -> Predictor:
store = FeatureStore() store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)]) store.add_batch(
[
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bedroom", 18.5),
]
)
pipeline = TrainingPipeline(store) pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1") pipeline.run("artifact_v1")
return Predictor(pipeline) return Predictor(pipeline)
def test_predict_returns_expected_format() -> None: def test_predict_returns_statistical_forecast() -> None:
p = predictor() p = predictor()
result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0)) result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0))
assert result == "artifact_v1:sensor.kitchen:{'temperature': 21.0}" assert result.artifact_id == "artifact_v1"
assert result.sensor_id == "sensor.kitchen"
assert result.predictions == {"temperature": 22.0}
assert 0.0 < result.confidence <= 1.0
assert result.model_type == "statistical_baseline"
def test_predict_rejects_unknown_sensor() -> None: def test_predict_rejects_unknown_sensor() -> None:
@@ -45,4 +55,4 @@ def test_default_artifact_returns_last_registered() -> None:
pipeline = TrainingPipeline(store) pipeline = TrainingPipeline(store)
pipeline.run("first") pipeline.run("first")
pipeline.run("second") pipeline.run("second")
assert Predictor.default_artifact(pipeline).artifact_id == "second" assert Predictor.default_artifact(pipeline).artifact_id == "second"

View File

@@ -27,6 +27,11 @@ def test_run_returns_trained_artifact() -> None:
artifact = pipeline.run("artifact_v1") artifact = pipeline.run("artifact_v1")
assert artifact.artifact_id == "artifact_v1" assert artifact.artifact_id == "artifact_v1"
assert artifact.supported_sensors == ("sensor.bedroom", "sensor.kitchen") assert artifact.supported_sensors == ("sensor.bedroom", "sensor.kitchen")
kitchen = artifact.feature_models["sensor.kitchen"]["temperature"]
assert kitchen.sample_count == 2
assert kitchen.mean == 19.5
assert kitchen.slope == 1.0
assert kitchen.forecast() == 21.0
def test_run_without_data_raises_value_error() -> None: def test_run_without_data_raises_value_error() -> None:

View File

@@ -16,18 +16,18 @@ def test_end_to_end_training_then_evaluation() -> None:
artifact = pipeline.run("artifact_v1") artifact = pipeline.run("artifact_v1")
evaluator = Evaluator(pipeline) evaluator = Evaluator(pipeline)
predictions = [ samples = [
"artifact_v1:sensor.kitchen:{'temperature': 21.0}", _vector("sensor.kitchen", 21.0),
"artifact_v1:sensor.bedroom:{'temperature': 18.5}", _vector("sensor.bedroom", 18.5),
] ]
report = evaluator.evaluate(artifact.artifact_id, predictions) report = evaluator.evaluate(artifact.artifact_id, samples)
assert isinstance(report, EvalReport) assert isinstance(report, EvalReport)
assert report.sample_size == len(predictions) assert report.sample_size == len(samples)
assert any(metric.name == "coverage" for metric in report.metrics) assert any(metric.name == "coverage" for metric in report.metrics)
def test_metric_helpers_are_serializable() -> None: def test_metric_helpers_are_serializable() -> None:
metric = Metric(name="coverage", value=0.85, threshold=0.8) metric = Metric(name="mae", value=0.85, threshold=1.0)
assert metric.name == "coverage" assert metric.name == "mae"
assert metric.value == 0.85 assert metric.value == 0.85
assert metric.threshold == 0.8 assert metric.threshold == 1.0