From 0de537572d2fa4810c7595c77844b7fd85d18eaf Mon Sep 17 00:00:00 2001 From: Otto Date: Sat, 13 Jun 2026 20:16:26 +0200 Subject: [PATCH] ML-009: add explainable predictions Closes #20 --- CHANGELOG.md | 1 + app/ml/__init__.py | 2 ++ app/ml/explanation.py | 57 ++++++++++++++++++++++++++++++++++++ app/ml/predictor.py | 13 +++++++- backend/routes/ml.py | 24 +++++++++++++++ docs/ml_api.md | 17 ++++++++++- tests/api/test_ml_routes.py | 4 +++ tests/ml/test_explanation.py | 34 +++++++++++++++++++++ tests/ml/test_predictor.py | 5 ++++ 9 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 app/ml/explanation.py create mode 100644 tests/ml/test_explanation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d503a6..8d51578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/ml/__init__.py b/app/ml/__init__.py index 63cf58a..84dc8a6 100644 --- a/app/ml/__init__.py +++ b/app/ml/__init__.py @@ -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 diff --git a/app/ml/explanation.py b/app/ml/explanation.py new file mode 100644 index 0000000..46230b3 --- /dev/null +++ b/app/ml/explanation.py @@ -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" diff --git a/app/ml/predictor.py b/app/ml/predictor.py index d719e48..7a670ad 100644 --- a/app/ml/predictor.py +++ b/app/ml/predictor.py @@ -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( diff --git a/backend/routes/ml.py b/backend/routes/ml.py index 244c023..9149e4e 100644 --- a/backend/routes/ml.py +++ b/backend/routes/ml.py @@ -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) diff --git a/docs/ml_api.md b/docs/ml_api.md index 30cdbca..b4f7941 100644 --- a/docs/ml_api.md +++ b/docs/ml_api.md @@ -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` diff --git a/tests/api/test_ml_routes.py b/tests/api/test_ml_routes.py index f061ea9..a80e1a0 100644 --- a/tests/api/test_ml_routes.py +++ b/tests/api/test_ml_routes.py @@ -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: diff --git a/tests/ml/test_explanation.py b/tests/ml/test_explanation.py new file mode 100644 index 0000000..66234c4 --- /dev/null +++ b/tests/ml/test_explanation.py @@ -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" diff --git a/tests/ml/test_predictor.py b/tests/ml/test_predictor.py index f1ab616..ee01fcf 100644 --- a/tests/ml/test_predictor.py +++ b/tests/ml/test_predictor.py @@ -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: