@@ -1,6 +1,7 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
- Deterministische, nutzerverständliche Erklärungen für jede Modellvorhersage
|
||||||
|
|
||||||
## 0.2.0 - 2026-06-13
|
## 0.2.0 - 2026-06-13
|
||||||
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern
|
- Klassifizierte Home-Assistant-Entity-Discovery mit Lernrelevanz und Filtern
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ __all__ = [
|
|||||||
"FeatureStore",
|
"FeatureStore",
|
||||||
"FeatureVector",
|
"FeatureVector",
|
||||||
"FeatureModel",
|
"FeatureModel",
|
||||||
|
"FeatureExplanation",
|
||||||
"PredictionResult",
|
"PredictionResult",
|
||||||
"Predictor",
|
"Predictor",
|
||||||
"RetrainingResult",
|
"RetrainingResult",
|
||||||
@@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"retrain_model",
|
"retrain_model",
|
||||||
]
|
]
|
||||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||||
|
from app.ml.explanation import FeatureExplanation
|
||||||
from app.ml.predictor import PredictionResult, Predictor
|
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 FeatureModel, TrainedArtifact, TrainingPipeline
|
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 dataclasses import dataclass
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
from app.ml.explanation import FeatureExplanation, explain_feature
|
||||||
from app.ml.feature_store import FeatureVector
|
from app.ml.feature_store import FeatureVector
|
||||||
from app.ml.registry.model_registry import ModelRegistry
|
from app.ml.registry.model_registry import ModelRegistry
|
||||||
from app.ml.training import TrainedArtifact, TrainingPipeline
|
from app.ml.training import TrainedArtifact, TrainingPipeline
|
||||||
@@ -19,6 +20,7 @@ class PredictionResult:
|
|||||||
predictions: dict[str, float]
|
predictions: dict[str, float]
|
||||||
confidence: float
|
confidence: float
|
||||||
model_type: str
|
model_type: str
|
||||||
|
explanations: dict[str, FeatureExplanation]
|
||||||
|
|
||||||
|
|
||||||
class Predictor:
|
class Predictor:
|
||||||
@@ -52,13 +54,21 @@ class Predictor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
predictions: dict[str, float] = {}
|
predictions: dict[str, float] = {}
|
||||||
|
explanations: dict[str, FeatureExplanation] = {}
|
||||||
confidences: list[float] = []
|
confidences: list[float] = []
|
||||||
for feature_name in feature_names:
|
for feature_name in feature_names:
|
||||||
model = sensor_models[feature_name]
|
model = sensor_models[feature_name]
|
||||||
current_value = float(entity.values[feature_name])
|
current_value = float(entity.values[feature_name])
|
||||||
if not math.isfinite(current_value):
|
if not math.isfinite(current_value):
|
||||||
raise ValueError("Vorhersagewerte müssen endlich sein.")
|
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)
|
confidences.append(model.confidence)
|
||||||
|
|
||||||
return PredictionResult(
|
return PredictionResult(
|
||||||
@@ -67,6 +77,7 @@ class Predictor:
|
|||||||
predictions=predictions,
|
predictions=predictions,
|
||||||
confidence=sum(confidences) / len(confidences),
|
confidence=sum(confidences) / len(confidences),
|
||||||
model_type=artifact.model_type,
|
model_type=artifact.model_type,
|
||||||
|
explanations=explanations,
|
||||||
)
|
)
|
||||||
|
|
||||||
def predict_batch(
|
def predict_batch(
|
||||||
|
|||||||
@@ -35,6 +35,22 @@ class PredictResponse(BaseModel):
|
|||||||
predictions: dict[str, float]
|
predictions: dict[str, float]
|
||||||
confidence: float
|
confidence: float
|
||||||
model_type: str
|
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):
|
class BatchRequest(BaseModel):
|
||||||
@@ -182,6 +198,10 @@ def predict(payload: PredictRequest, request: Request) -> PredictResponse:
|
|||||||
predictions=prediction.predictions,
|
predictions=prediction.predictions,
|
||||||
confidence=prediction.confidence,
|
confidence=prediction.confidence,
|
||||||
model_type=prediction.model_type,
|
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,
|
predictions=prediction.predictions,
|
||||||
confidence=prediction.confidence,
|
confidence=prediction.confidence,
|
||||||
model_type=prediction.model_type,
|
model_type=prediction.model_type,
|
||||||
|
explanations={
|
||||||
|
name: FeatureExplanationResponse(**explanation.__dict__)
|
||||||
|
for name, explanation in prediction.explanations.items()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return BatchResponse(predictions=responses)
|
return BatchResponse(predictions=responses)
|
||||||
|
|||||||
@@ -63,10 +63,25 @@ Einzelne Vorhersage für einen Sensor.
|
|||||||
"sensor_id": "sensor.kitchen",
|
"sensor_id": "sensor.kitchen",
|
||||||
"predictions": {"temperature": 21.4},
|
"predictions": {"temperature": 21.4},
|
||||||
"confidence": 0.78,
|
"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`
|
### `POST /ml/retrain`
|
||||||
|
|
||||||
Trainiert die Artefakt-Metadaten aus neuen Sensordaten. Existiert `modelId`
|
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 response.json()["predictions"] == {"temperature": 22.0}
|
||||||
assert 0.0 < response.json()["confidence"] <= 1.0
|
assert 0.0 < response.json()["confidence"] <= 1.0
|
||||||
assert response.json()["model_type"] == "statistical_baseline"
|
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:
|
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 result.predictions == {"temperature": 22.0}
|
||||||
assert 0.0 < result.confidence <= 1.0
|
assert 0.0 < result.confidence <= 1.0
|
||||||
assert result.model_type == "statistical_baseline"
|
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:
|
def test_predict_rejects_unknown_sensor() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user