diff --git a/.gitignore b/.gitignore index fcf17b6..80dfacb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /.vscode __pycache__/ *.pyc +*.egg-info/ .mypy_cache/ .pytest_cache/ .ruff_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bfea698..4ba7761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,4 @@ - Persistente, validierte und gegen Path Traversal gehärtete Model Registry - Reproduzierbares Packaging, CI-Gates und gehärteter non-root Container - Definierte API-Fehler und korrigierte Evaluationsmetriken +- Scheduler-tauglicher Retraining-Service mit API und atomischem Registry-Update diff --git a/README.md b/README.md index b88623c..73fe1d5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ uvicorn app.main:app --reload - `http://127.0.0.1:8000/docs/` - OpenAPI-Dokumentation - `http://127.0.0.1:8000/v1/entities` - Home-Assistant-Entities - `http://127.0.0.1:8000/ml/health` - Registry-/Serving-Health +- `POST http://127.0.0.1:8000/ml/retrain` - Modell-Metadaten aktualisieren Ohne vollständige HA-Konfiguration liefert `/v1/entities` bewusst `503`. diff --git a/app/ml/__init__.py b/app/ml/__init__.py index c5c5510..dabe1f6 100644 --- a/app/ml/__init__.py +++ b/app/ml/__init__.py @@ -1,5 +1,14 @@ """Machine-Learning-Grundbausteine für SillyHome Next.""" -__all__ = ["FeatureStore", "FeatureVector", "TrainedArtifact", "TrainingPipeline"] +__all__ = [ + "FeatureStore", + "FeatureVector", + "RetrainingResult", + "RetrainingService", + "TrainedArtifact", + "TrainingPipeline", + "retrain_model", +] from app.ml.feature_store import FeatureStore, FeatureVector +from app.ml.retraining import RetrainingResult, RetrainingService, retrain_model from app.ml.training import TrainedArtifact, TrainingPipeline diff --git a/app/ml/registry/model_registry.py b/app/ml/registry/model_registry.py index 693e72f..e6a634b 100644 --- a/app/ml/registry/model_registry.py +++ b/app/ml/registry/model_registry.py @@ -5,6 +5,7 @@ import logging import os from pathlib import Path import re +from threading import RLock from collections.abc import Iterable from app.ml.training import TrainedArtifact @@ -19,22 +20,31 @@ class ModelRegistry: self._root = Path(root).resolve() self._root.mkdir(parents=True, exist_ok=True) self._artifacts: dict[str, TrainedArtifact] = {} + self._lock = RLock() self._load_existing() def register(self, artifact: TrainedArtifact) -> TrainedArtifact: + registered, _ = self.register_with_status(artifact) + return registered + + def register_with_status(self, artifact: TrainedArtifact) -> tuple[TrainedArtifact, bool]: self._validate_artifact_id(artifact.artifact_id) - self._persist(artifact) - self._artifacts[artifact.artifact_id] = artifact - return artifact + with self._lock: + replaced = artifact.artifact_id in self._artifacts + self._persist(artifact) + self._artifacts[artifact.artifact_id] = artifact + return artifact, replaced def load_artifact(self, artifact_id: str) -> TrainedArtifact: self._validate_artifact_id(artifact_id) - if artifact_id not in self._artifacts: - raise KeyError(f"Artifact '{artifact_id}' nicht registriert.") - return self._artifacts[artifact_id] + with self._lock: + if artifact_id not in self._artifacts: + raise KeyError(f"Artifact '{artifact_id}' nicht registriert.") + return self._artifacts[artifact_id] def list_models(self) -> Iterable[TrainedArtifact]: - return list(self._artifacts.values()) + with self._lock: + return [self._artifacts[key] for key in sorted(self._artifacts)] def _load_existing(self) -> None: for source in sorted(self._root.glob("*.json")): diff --git a/app/ml/retraining.py b/app/ml/retraining.py new file mode 100644 index 0000000..8a060be --- /dev/null +++ b/app/ml/retraining.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +from app.ml.feature_store import FeatureStore, FeatureVector +from app.ml.registry.model_registry import ModelRegistry +from app.ml.training import TrainedArtifact, TrainingPipeline + + +@dataclass(frozen=True) +class RetrainingResult: + artifact: TrainedArtifact + replaced: bool + + +class RetrainingService: + """Runs one retraining cycle without owning scheduling or background threads.""" + + def __init__(self, registry: ModelRegistry) -> None: + self._registry = registry + + def retrain( + self, + artifact_id: str, + vectors: Iterable[FeatureVector], + ) -> RetrainingResult: + store = FeatureStore() + store.add_batch(vectors) + pipeline = TrainingPipeline(store) + artifact = pipeline.run(artifact_id) + _, replaced = self._registry.register_with_status(artifact) + return RetrainingResult(artifact=artifact, replaced=replaced) + + +def retrain_model( + registry: ModelRegistry, + artifact_id: str, + vectors: Iterable[FeatureVector], +) -> RetrainingResult: + """Scheduler-compatible entry point for exactly one retraining run.""" + + return RetrainingService(registry).retrain(artifact_id, vectors) diff --git a/backend/routes/ml.py b/backend/routes/ml.py index 640f1f9..c5b42d4 100644 --- a/backend/routes/ml.py +++ b/backend/routes/ml.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from app.ml.feature_store import FeatureVector from app.ml.predictor import Predictor from app.ml.registry.model_registry import ModelRegistry +from app.ml.retraining import retrain_model logger = logging.getLogger(__name__) @@ -45,6 +46,23 @@ class ModelsResponse(BaseModel): models: list[str] +class TrainingSample(BaseModel): + sensor_id: str = Field(min_length=1) + values: dict[str, float] + label: str | None = None + + +class RetrainRequest(BaseModel): + model_id: str = Field(..., alias="modelId", min_length=1, max_length=128) + samples: list[TrainingSample] = Field(min_length=1) + + +class RetrainResponse(BaseModel): + model_id: str + supported_sensors: list[str] + replaced: bool + + @router.get("/health", response_model=HealthResponse, status_code=200) def health() -> HealthResponse: return HealthResponse(status="ok") @@ -57,6 +75,31 @@ def list_models(request: Request) -> ModelsResponse: return ModelsResponse(models=models) +@router.post("/retrain", response_model=RetrainResponse, status_code=200) +def retrain(payload: RetrainRequest, request: Request) -> RetrainResponse: + registry = _require_registry(request) + vectors = [ + FeatureVector( + sensor_id=sample.sensor_id, + values=sample.values, + label=sample.label, + ) + for sample in payload.samples + ] + try: + result = retrain_model(registry, payload.model_id, vectors) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return RetrainResponse( + model_id=result.artifact.artifact_id, + supported_sensors=list(result.artifact.supported_sensors), + replaced=result.replaced, + ) + + @router.post("/predict", response_model=PredictResponse, status_code=200) def predict(payload: PredictRequest, request: Request) -> PredictResponse: registry = _require_registry(request) diff --git a/docs/ml_api.md b/docs/ml_api.md index 55ce0f3..05db5f4 100644 --- a/docs/ml_api.md +++ b/docs/ml_api.md @@ -12,6 +12,7 @@ Modell-Artefakt- und Vorhersage-Schnittstelle. - Standard: `http://127.0.0.1:8000/ml` - Health: `/health` - Modelle: `/models` +- Retraining: `/retrain` - Einzelvorhersage: `/predict` - Batchvorhersage: `/batch` @@ -65,6 +66,35 @@ Einzelne Vorhersage für einen Sensor. } ``` +### `POST /ml/retrain` + +Trainiert die Artefakt-Metadaten aus neuen Sensordaten. Existiert `modelId` +bereits, wird das Artefakt atomisch ersetzt und beim nächsten Prozessstart aus +dem Modellverzeichnis geladen. + +**Request** +```json +{ + "modelId": "home-model", + "samples": [ + { + "sensor_id": "sensor.kitchen", + "values": {"temperature": 21.0}, + "label": "occupied" + } + ] +} +``` + +**Antwort** +```json +{ + "model_id": "home-model", + "supported_sensors": ["sensor.kitchen"], + "replaced": false +} +``` + ### `POST /ml/batch` Batch-Vorhersage für mehrere Sensorwerte. @@ -114,11 +144,15 @@ Batch-Vorhersage für mehrere Sensorwerte. ## Betrieb Die produktive App lädt Artefakte aus `SILLYHOME_MODEL_STORE`. Neue Artefakte -werden derzeit intern über `ModelRegistry.register(...)` registriert. Die -Registry speichert validiertes JSON atomisch und lädt es beim Neustart. +werden über `/ml/retrain`, `RetrainingService` oder direkt über +`ModelRegistry.register(...)` registriert. Die Registry speichert validiertes +JSON atomisch und lädt es beim Neustart. Die API sollte nur in einem +vertrauenswürdigen Netz oder hinter einem authentifizierenden Reverse Proxy +erreichbar sein. ## Verweise - `app/ml/predictor.py` +- `app/ml/retraining.py` - `app/ml/registry/model_registry.py` - `backend/routes/ml.py` diff --git a/docs/ml_training.md b/docs/ml_training.md index 6957c4a..bcc70a5 100644 --- a/docs/ml_training.md +++ b/docs/ml_training.md @@ -38,6 +38,21 @@ Der Report enthält: Das trainierte Artefakt kann anschließend über `ModelRegistry.register(artifact)` bereitgestellt werden. Die ML-Serving-API stellt es unter `/ml/predict` und `/ml/batch` zur Verfügung. +## 5. Retraining ausführen + +`RetrainingService.retrain(...)` führt genau einen Trainingslauf aus und ersetzt +ein vorhandenes Artefakt mit derselben ID atomisch in der Registry: + +```python +service = RetrainingService(registry) +result = service.retrain("home-model", vectors) +``` + +Scheduler, Cronjobs oder Home-Assistant-Automationen können alternativ die +zustandslose Funktion `retrain_model(registry, artifact_id, vectors)` aufrufen. +Der Service startet bewusst keinen eigenen Hintergrundprozess. Über +`POST /ml/retrain` kann derselbe Ablauf per API angestoßen werden. + ## Hinweise - 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. diff --git a/tests/api/test_ml_routes.py b/tests/api/test_ml_routes.py index d7431ad..74fe38c 100644 --- a/tests/api/test_ml_routes.py +++ b/tests/api/test_ml_routes.py @@ -50,3 +50,60 @@ def test_unsupported_sensor_returns_422(tmp_path: Path) -> None: ) assert response.status_code == 422 + + +def test_retrain_creates_and_replaces_persisted_model(tmp_path: Path) -> None: + from app.ml.registry.model_registry import ModelRegistry + + registry = ModelRegistry(tmp_path) + with TestClient(app) as client: + app.state.registry = registry + created = client.post( + "/ml/retrain", + json={ + "modelId": "home-model", + "samples": [ + { + "sensor_id": "sensor.kitchen", + "values": {"temperature": 21.0}, + } + ], + }, + ) + replaced = client.post( + "/ml/retrain", + json={ + "modelId": "home-model", + "samples": [ + { + "sensor_id": "sensor.bedroom", + "values": {"temperature": 18.0}, + } + ], + }, + ) + + assert created.status_code == 200 + assert created.json() == { + "model_id": "home-model", + "supported_sensors": ["sensor.kitchen"], + "replaced": False, + } + assert replaced.status_code == 200 + assert replaced.json() == { + "model_id": "home-model", + "supported_sensors": ["sensor.bedroom"], + "replaced": True, + } + restarted = ModelRegistry(tmp_path) + assert restarted.load_artifact("home-model").supported_sensors == ("sensor.bedroom",) + + +def test_retrain_rejects_empty_samples() -> None: + with TestClient(app) as client: + response = client.post( + "/ml/retrain", + json={"modelId": "home-model", "samples": []}, + ) + + assert response.status_code == 422 diff --git a/tests/ml/test_model_registry.py b/tests/ml/test_model_registry.py index 6f5d761..77cde56 100644 --- a/tests/ml/test_model_registry.py +++ b/tests/ml/test_model_registry.py @@ -19,6 +19,17 @@ def test_registry_loads_persisted_artifacts_after_restart(tmp_path: Path) -> Non assert restarted.load_artifact("model-v1") == artifact +def test_registry_replaces_persisted_artifact_after_restart(tmp_path: Path) -> None: + registry = ModelRegistry(tmp_path) + registry.register(TrainedArtifact("model-v1", ("sensor.kitchen",))) + replacement = TrainedArtifact("model-v1", ("sensor.bedroom",)) + + registry.register(replacement) + + assert registry.load_artifact("model-v1") == replacement + assert ModelRegistry(tmp_path).load_artifact("model-v1") == replacement + + @pytest.mark.parametrize("artifact_id", ["../escape", "nested/model", "..", ""]) def test_registry_rejects_unsafe_artifact_ids(tmp_path: Path, artifact_id: str) -> None: registry = ModelRegistry(tmp_path) diff --git a/tests/ml/test_retraining.py b/tests/ml/test_retraining.py new file mode 100644 index 0000000..1f9fbad --- /dev/null +++ b/tests/ml/test_retraining.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.ml.feature_store import FeatureVector +from app.ml.registry.model_registry import ModelRegistry +from app.ml.retraining import RetrainingService, retrain_model + + +def _vector(sensor_id: str) -> FeatureVector: + return FeatureVector(sensor_id=sensor_id, values={"temperature": 21.0}) + + +def test_retraining_registers_new_artifact(tmp_path: Path) -> None: + registry = ModelRegistry(tmp_path) + + result = retrain_model(registry, "home-model", [_vector("sensor.kitchen")]) + + assert result.replaced is False + assert registry.load_artifact("home-model") == result.artifact + + +def test_retraining_replaces_existing_artifact(tmp_path: Path) -> None: + registry = ModelRegistry(tmp_path) + service = RetrainingService(registry) + service.retrain("home-model", [_vector("sensor.kitchen")]) + + result = service.retrain("home-model", [_vector("sensor.bedroom")]) + + assert result.replaced is True + assert result.artifact.supported_sensors == ("sensor.bedroom",) + assert ModelRegistry(tmp_path).load_artifact("home-model") == result.artifact + + +def test_retraining_rejects_empty_training_data(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="keine Trainingsdaten"): + retrain_model(ModelRegistry(tmp_path), "home-model", [])