Compare commits
1 Commits
v0.2.0
...
feature/ml
| Author | SHA1 | Date | |
|---|---|---|---|
| 0de537572d |
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
- Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage
|
||||
|
||||
## 0.2.0 - 2026-06-13
|
||||
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern
|
||||
|
||||
@@ -4,6 +4,7 @@ __all__ = [
|
||||
"FeatureStore",
|
||||
"FeatureVector",
|
||||
"FeatureModel",
|
||||
"FeatureExplanation",
|
||||
"PredictionResult",
|
||||
"Predictor",
|
||||
"RetrainingResult",
|
||||
@@ -13,6 +14,7 @@ __all__ = [
|
||||
"retrain_model",
|
||||
]
|
||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||
from app.ml.explanation import FeatureExplanation
|
||||
from app.ml.predictor import PredictionResult, Predictor
|
||||
from app.ml.retraining import RetrainingResult, RetrainingService, retrain_model
|
||||
from app.ml.training import FeatureModel, TrainedArtifact, TrainingPipeline
|
||||
|
||||
57
app/ml/explanation.py
Normal file
57
app/ml/explanation.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.ml.training import FeatureModel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureExplanation:
|
||||
feature: str
|
||||
current_value: float
|
||||
predicted_value: float
|
||||
change: float
|
||||
direction: str
|
||||
sample_count: int
|
||||
historical_mean: float
|
||||
historical_range: tuple[float, float]
|
||||
standard_deviation: float
|
||||
trend_per_step: float
|
||||
confidence: float
|
||||
summary: str
|
||||
|
||||
|
||||
def explain_feature(
|
||||
feature_name: str,
|
||||
current_value: float,
|
||||
predicted_value: float,
|
||||
model: FeatureModel,
|
||||
) -> FeatureExplanation:
|
||||
change = predicted_value - current_value
|
||||
direction = _direction(change)
|
||||
summary = (
|
||||
f"{feature_name}: {direction}; Prognose {predicted_value:.3f} "
|
||||
f"aus aktuellem Wert {current_value:.3f} und Trend {model.slope:+.3f}. "
|
||||
f"Basis: {model.sample_count} Messwerte, Mittelwert {model.mean:.3f}, "
|
||||
f"Confidence {model.confidence:.0%}."
|
||||
)
|
||||
return FeatureExplanation(
|
||||
feature=feature_name,
|
||||
current_value=current_value,
|
||||
predicted_value=predicted_value,
|
||||
change=change,
|
||||
direction=direction,
|
||||
sample_count=model.sample_count,
|
||||
historical_mean=model.mean,
|
||||
historical_range=(model.minimum, model.maximum),
|
||||
standard_deviation=model.standard_deviation,
|
||||
trend_per_step=model.slope,
|
||||
confidence=model.confidence,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def _direction(change: float) -> str:
|
||||
if abs(change) < 1e-12:
|
||||
return "stabil"
|
||||
return "steigend" if change > 0 else "fallend"
|
||||
@@ -5,6 +5,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
from app.ml.explanation import FeatureExplanation, explain_feature
|
||||
from app.ml.feature_store import FeatureVector
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
from app.ml.training import TrainedArtifact, TrainingPipeline
|
||||
@@ -19,6 +20,7 @@ class PredictionResult:
|
||||
predictions: dict[str, float]
|
||||
confidence: float
|
||||
model_type: str
|
||||
explanations: dict[str, FeatureExplanation]
|
||||
|
||||
|
||||
class Predictor:
|
||||
@@ -52,13 +54,21 @@ class Predictor:
|
||||
)
|
||||
|
||||
predictions: dict[str, float] = {}
|
||||
explanations: dict[str, FeatureExplanation] = {}
|
||||
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)
|
||||
predicted_value = model.forecast(current_value)
|
||||
predictions[feature_name] = predicted_value
|
||||
explanations[feature_name] = explain_feature(
|
||||
feature_name,
|
||||
current_value,
|
||||
predicted_value,
|
||||
model,
|
||||
)
|
||||
confidences.append(model.confidence)
|
||||
|
||||
return PredictionResult(
|
||||
@@ -67,6 +77,7 @@ class Predictor:
|
||||
predictions=predictions,
|
||||
confidence=sum(confidences) / len(confidences),
|
||||
model_type=artifact.model_type,
|
||||
explanations=explanations,
|
||||
)
|
||||
|
||||
def predict_batch(
|
||||
|
||||
@@ -35,6 +35,22 @@ class PredictResponse(BaseModel):
|
||||
predictions: dict[str, float]
|
||||
confidence: float
|
||||
model_type: str
|
||||
explanations: dict[str, "FeatureExplanationResponse"]
|
||||
|
||||
|
||||
class FeatureExplanationResponse(BaseModel):
|
||||
feature: str
|
||||
current_value: float
|
||||
predicted_value: float
|
||||
change: float
|
||||
direction: str
|
||||
sample_count: int
|
||||
historical_mean: float
|
||||
historical_range: tuple[float, float]
|
||||
standard_deviation: float
|
||||
trend_per_step: float
|
||||
confidence: float
|
||||
summary: str
|
||||
|
||||
|
||||
class BatchRequest(BaseModel):
|
||||
@@ -182,6 +198,10 @@ def predict(payload: PredictRequest, request: Request) -> PredictResponse:
|
||||
predictions=prediction.predictions,
|
||||
confidence=prediction.confidence,
|
||||
model_type=prediction.model_type,
|
||||
explanations={
|
||||
name: FeatureExplanationResponse(**explanation.__dict__)
|
||||
for name, explanation in prediction.explanations.items()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -208,6 +228,10 @@ def predict_batch(payload: BatchRequest, request: Request) -> BatchResponse:
|
||||
predictions=prediction.predictions,
|
||||
confidence=prediction.confidence,
|
||||
model_type=prediction.model_type,
|
||||
explanations={
|
||||
name: FeatureExplanationResponse(**explanation.__dict__)
|
||||
for name, explanation in prediction.explanations.items()
|
||||
},
|
||||
)
|
||||
)
|
||||
return BatchResponse(predictions=responses)
|
||||
|
||||
@@ -63,10 +63,25 @@ Einzelne Vorhersage für einen Sensor.
|
||||
"sensor_id": "sensor.kitchen",
|
||||
"predictions": {"temperature": 21.4},
|
||||
"confidence": 0.78,
|
||||
"model_type": "statistical_baseline"
|
||||
"model_type": "statistical_baseline",
|
||||
"explanations": {
|
||||
"temperature": {
|
||||
"direction": "steigend",
|
||||
"change": 0.4,
|
||||
"sample_count": 24,
|
||||
"historical_mean": 20.7,
|
||||
"trend_per_step": 0.4,
|
||||
"summary": "temperature: steigend; Prognose ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Die Erklärung nennt pro Merkmal den aktuellen und prognostizierten Wert,
|
||||
Richtung, Veränderung, Datenbasis, historischen Bereich, Streuung, Trend und
|
||||
Confidence. Sie wird deterministisch aus den gespeicherten Modellparametern
|
||||
erzeugt.
|
||||
|
||||
### `POST /ml/retrain`
|
||||
|
||||
Trainiert die Artefakt-Metadaten aus neuen Sensordaten. Existiert `modelId`
|
||||
|
||||
@@ -143,6 +143,10 @@ def test_predict_returns_numeric_forecast_and_confidence(tmp_path: Path) -> None
|
||||
assert response.json()["predictions"] == {"temperature": 22.0}
|
||||
assert 0.0 < response.json()["confidence"] <= 1.0
|
||||
assert response.json()["model_type"] == "statistical_baseline"
|
||||
explanation = response.json()["explanations"]["temperature"]
|
||||
assert explanation["direction"] == "steigend"
|
||||
assert explanation["change"] == 1.0
|
||||
assert explanation["sample_count"] == 2
|
||||
|
||||
|
||||
def test_evaluate_returns_real_error_metrics(tmp_path: Path) -> None:
|
||||
|
||||
34
tests/ml/test_explanation.py
Normal file
34
tests/ml/test_explanation.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ml.explanation import explain_feature
|
||||
from app.ml.training import FeatureModel
|
||||
|
||||
|
||||
def _model(slope: float) -> FeatureModel:
|
||||
return FeatureModel(
|
||||
sample_count=4,
|
||||
mean=20.0,
|
||||
standard_deviation=1.0,
|
||||
minimum=18.0,
|
||||
maximum=22.0,
|
||||
slope=slope,
|
||||
intercept=18.5,
|
||||
)
|
||||
|
||||
|
||||
def test_explain_feature_describes_rising_forecast() -> None:
|
||||
explanation = explain_feature("temperature", 21.0, 21.5, _model(0.5))
|
||||
|
||||
assert explanation.direction == "steigend"
|
||||
assert explanation.change == 0.5
|
||||
assert explanation.historical_range == (18.0, 22.0)
|
||||
assert "4 Messwerte" in explanation.summary
|
||||
assert "Trend +0.500" in explanation.summary
|
||||
|
||||
|
||||
def test_explain_feature_describes_stable_and_falling_forecasts() -> None:
|
||||
stable = explain_feature("humidity", 50.0, 50.0, _model(0.0))
|
||||
falling = explain_feature("temperature", 21.0, 20.5, _model(-0.5))
|
||||
|
||||
assert stable.direction == "stabil"
|
||||
assert falling.direction == "fallend"
|
||||
@@ -33,6 +33,11 @@ def test_predict_returns_statistical_forecast() -> None:
|
||||
assert result.predictions == {"temperature": 22.0}
|
||||
assert 0.0 < result.confidence <= 1.0
|
||||
assert result.model_type == "statistical_baseline"
|
||||
explanation = result.explanations["temperature"]
|
||||
assert explanation.direction == "steigend"
|
||||
assert explanation.current_value == 21.0
|
||||
assert explanation.predicted_value == 22.0
|
||||
assert explanation.sample_count == 2
|
||||
|
||||
|
||||
def test_predict_rejects_unknown_sensor() -> None:
|
||||
|
||||
Reference in New Issue
Block a user