ML-008: add statistical baseline model
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

Closes #19
This commit is contained in:
2026-06-13 20:13:06 +02:00
parent ea5a206a86
commit df2ddacfbf
18 changed files with 502 additions and 77 deletions

View File

@@ -24,14 +24,15 @@ def test_evaluate_returns_report_with_metrics() -> None:
report = evaluator.evaluate(
"artifact_v1",
[
"artifact_v1:sensor.kitchen:{'temperature': 21.0}",
"artifact_v1:sensor.bedroom:{'temperature': 18.5}",
_vector("sensor.kitchen", 21.0),
_vector("sensor.bedroom", 18.5),
],
)
assert report.artifact_id == "artifact_v1"
assert report.sample_size == 2
assert {metric.name for metric in report.metrics} == {"coverage", "unknown_rate"}
assert {metric.name for metric in report.metrics} == {"mae", "rmse", "coverage"}
assert next(metric.value for metric in report.metrics if metric.name == "coverage") == 1.0
assert next(metric.value for metric in report.metrics if metric.name == "mae") == 0.0
def test_evaluate_without_training_raises_value_error() -> None:
@@ -40,16 +41,16 @@ def test_evaluate_without_training_raises_value_error() -> None:
evaluator.evaluate("artifact_v1", [])
def test_coverage_is_bounded_and_requires_exact_sensor_match() -> None:
def test_coverage_counts_only_supported_sensor_features() -> None:
evaluator = evaluator_factory()
report = evaluator.evaluate(
"artifact_v1",
[
"artifact_v1:sensor.kitchen:{'note': 'sensor.bedroom'}",
"artifact_v1:sensor.kitchen_extra:{}",
"malformed",
_vector("sensor.kitchen", 21.0),
FeatureVector(sensor_id="sensor.kitchen", values={"humidity": 50.0}),
_vector("sensor.kitchen_extra", 20.0),
],
)
metrics = {metric.name: metric.value for metric in report.metrics}
assert metrics == {"coverage": pytest.approx(1 / 3), "unknown_rate": pytest.approx(2 / 3)}
assert metrics["coverage"] == pytest.approx(1 / 3)

View File

@@ -19,6 +19,24 @@ def test_registry_loads_persisted_artifacts_after_restart(tmp_path: Path) -> Non
assert restarted.load_artifact("model-v1") == artifact
def test_registry_persists_statistical_parameters(tmp_path: Path) -> None:
from app.ml.feature_store import FeatureStore, FeatureVector
from app.ml.training import TrainingPipeline
store = FeatureStore()
store.add_batch(
[
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
]
)
artifact = TrainingPipeline(store).run("model-v1")
ModelRegistry(tmp_path).register(artifact)
assert ModelRegistry(tmp_path).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",)))

View File

@@ -13,16 +13,26 @@ def _vector(sensor_id: str, temperature: float, label: str | None = None) -> Fea
def predictor() -> Predictor:
store = FeatureStore()
store.add_batch([_vector("sensor.kitchen", 19.0), _vector("sensor.bedroom", 18.5)])
store.add_batch(
[
_vector("sensor.kitchen", 19.0),
_vector("sensor.kitchen", 20.0),
_vector("sensor.bedroom", 18.5),
]
)
pipeline = TrainingPipeline(store)
pipeline.run("artifact_v1")
return Predictor(pipeline)
def test_predict_returns_expected_format() -> None:
def test_predict_returns_statistical_forecast() -> None:
p = predictor()
result = p.predict("artifact_v1", _vector("sensor.kitchen", 21.0))
assert result == "artifact_v1:sensor.kitchen:{'temperature': 21.0}"
assert result.artifact_id == "artifact_v1"
assert result.sensor_id == "sensor.kitchen"
assert result.predictions == {"temperature": 22.0}
assert 0.0 < result.confidence <= 1.0
assert result.model_type == "statistical_baseline"
def test_predict_rejects_unknown_sensor() -> None:
@@ -45,4 +55,4 @@ def test_default_artifact_returns_last_registered() -> None:
pipeline = TrainingPipeline(store)
pipeline.run("first")
pipeline.run("second")
assert Predictor.default_artifact(pipeline).artifact_id == "second"
assert Predictor.default_artifact(pipeline).artifact_id == "second"

View File

@@ -27,6 +27,11 @@ def test_run_returns_trained_artifact() -> None:
artifact = pipeline.run("artifact_v1")
assert artifact.artifact_id == "artifact_v1"
assert artifact.supported_sensors == ("sensor.bedroom", "sensor.kitchen")
kitchen = artifact.feature_models["sensor.kitchen"]["temperature"]
assert kitchen.sample_count == 2
assert kitchen.mean == 19.5
assert kitchen.slope == 1.0
assert kitchen.forecast() == 21.0
def test_run_without_data_raises_value_error() -> None:

View File

@@ -16,18 +16,18 @@ def test_end_to_end_training_then_evaluation() -> None:
artifact = pipeline.run("artifact_v1")
evaluator = Evaluator(pipeline)
predictions = [
"artifact_v1:sensor.kitchen:{'temperature': 21.0}",
"artifact_v1:sensor.bedroom:{'temperature': 18.5}",
samples = [
_vector("sensor.kitchen", 21.0),
_vector("sensor.bedroom", 18.5),
]
report = evaluator.evaluate(artifact.artifact_id, predictions)
report = evaluator.evaluate(artifact.artifact_id, samples)
assert isinstance(report, EvalReport)
assert report.sample_size == len(predictions)
assert report.sample_size == len(samples)
assert any(metric.name == "coverage" for metric in report.metrics)
def test_metric_helpers_are_serializable() -> None:
metric = Metric(name="coverage", value=0.85, threshold=0.8)
assert metric.name == "coverage"
metric = Metric(name="mae", value=0.85, threshold=1.0)
assert metric.name == "mae"
assert metric.value == 0.85
assert metric.threshold == 0.8
assert metric.threshold == 1.0