harden model registry persistence and evaluation
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Sequence
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.ml.feature_store import FeatureVector
|
||||
from app.ml.training import TrainingPipeline, TrainedArtifact
|
||||
from app.ml.training import TrainingPipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,15 +28,16 @@ class Evaluator:
|
||||
self._pipeline = pipeline
|
||||
|
||||
def evaluate(self, artifact_id: str, predictions: Sequence[str]) -> EvalReport:
|
||||
artifacts = list(self._pipeline._artifacts)
|
||||
if not artifacts:
|
||||
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.")
|
||||
try:
|
||||
supported_sensors = set(self._pipeline.export(artifact_id).supported_sensors)
|
||||
except KeyError as exc:
|
||||
raise ValueError("Kein trainiertes Modell für Evaluation vorhanden.") from exc
|
||||
|
||||
supported_sensors = self._pipeline.export(artifact_id).supported_sensors
|
||||
unknown_hits = sum(1 for prediction in predictions if ":" not in prediction)
|
||||
supported_references = sum(1 for sensor in supported_sensors for prediction in predictions if sensor in prediction)
|
||||
parsed_sensors = [_prediction_sensor(prediction) for prediction in predictions]
|
||||
supported_hits = sum(sensor in supported_sensors for sensor in parsed_sensors)
|
||||
unknown_hits = sum(sensor not in supported_sensors for sensor in parsed_sensors)
|
||||
sample_size = len(predictions)
|
||||
coverage = supported_references / sample_size if sample_size else 0.0
|
||||
coverage = supported_hits / sample_size if sample_size else 0.0
|
||||
unknown_rate = unknown_hits / sample_size if sample_size else 0.0
|
||||
|
||||
coverage_metric = Metric(name="coverage", value=coverage, threshold=0.8)
|
||||
@@ -54,4 +54,11 @@ class Evaluator:
|
||||
coverage,
|
||||
unknown_rate,
|
||||
)
|
||||
return report
|
||||
return report
|
||||
|
||||
|
||||
def _prediction_sensor(prediction: str) -> str | None:
|
||||
parts = prediction.split(":", 2)
|
||||
if len(parts) != 3 or not parts[0] or not parts[1]:
|
||||
return None
|
||||
return parts[1]
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
|
||||
from app.ml.training import TrainedArtifact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ARTIFACT_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self._root = Path(root)
|
||||
self._root = Path(root).resolve()
|
||||
self._root.mkdir(parents=True, exist_ok=True)
|
||||
self._artifacts: dict[str, TrainedArtifact] = {}
|
||||
self._load_existing()
|
||||
|
||||
def register(self, artifact: TrainedArtifact) -> TrainedArtifact:
|
||||
self._validate_artifact_id(artifact.artifact_id)
|
||||
self._artifacts[artifact.artifact_id] = artifact
|
||||
self._persist(artifact)
|
||||
return artifact
|
||||
|
||||
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]
|
||||
@@ -28,10 +36,45 @@ class ModelRegistry:
|
||||
def list_models(self) -> Iterable[TrainedArtifact]:
|
||||
return list(self._artifacts.values())
|
||||
|
||||
def _load_existing(self) -> None:
|
||||
for source in sorted(self._root.glob("*.json")):
|
||||
try:
|
||||
raw = json.loads(source.read_text(encoding="utf-8"))
|
||||
artifact_id = raw["artifact_id"]
|
||||
supported_sensors = raw["supported_sensors"]
|
||||
if not isinstance(artifact_id, str) or not isinstance(supported_sensors, list):
|
||||
raise ValueError("invalid artifact structure")
|
||||
self._validate_artifact_id(artifact_id)
|
||||
if source.name != f"{artifact_id}.json":
|
||||
raise ValueError("artifact id does not match filename")
|
||||
if not all(isinstance(sensor, str) for sensor in supported_sensors):
|
||||
raise ValueError("supported_sensors must contain strings")
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"Ungültiges Modell-Artefakt: {source.name}") from exc
|
||||
|
||||
self._artifacts[artifact_id] = TrainedArtifact(
|
||||
artifact_id=artifact_id,
|
||||
supported_sensors=tuple(supported_sensors),
|
||||
)
|
||||
|
||||
def _persist(self, artifact: TrainedArtifact) -> None:
|
||||
target = self._root / f"{artifact.artifact_id}.json"
|
||||
target.write_text(
|
||||
f"{artifact.artifact_id}\t{','.join(artifact.supported_sensors)}\n",
|
||||
temporary = target.with_suffix(".json.tmp")
|
||||
payload = {
|
||||
"artifact_id": artifact.artifact_id,
|
||||
"supported_sensors": list(artifact.supported_sensors),
|
||||
}
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
logger.info("Modell gespeichert: %s", target)
|
||||
os.replace(temporary, target)
|
||||
logger.info("Modell gespeichert: %s", target)
|
||||
|
||||
@staticmethod
|
||||
def _validate_artifact_id(artifact_id: str) -> None:
|
||||
if not _ARTIFACT_ID_PATTERN.fullmatch(artifact_id) or ".." in artifact_id:
|
||||
raise ValueError(
|
||||
"artifact_id darf nur Buchstaben, Ziffern, Punkt, Unterstrich "
|
||||
"und Bindestrich enthalten."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user