1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
/.vscode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")):
|
||||
|
||||
43
app/ml/retraining.py
Normal file
43
app/ml/retraining.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
39
tests/ml/test_retraining.py
Normal file
39
tests/ml/test_retraining.py
Normal file
@@ -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", [])
|
||||
Reference in New Issue
Block a user