@@ -87,12 +87,16 @@ def test_retrain_creates_and_replaces_persisted_model(tmp_path: Path) -> None:
|
||||
assert created.json() == {
|
||||
"model_id": "home-model",
|
||||
"supported_sensors": ["sensor.kitchen"],
|
||||
"trained_features": 1,
|
||||
"model_type": "statistical_baseline",
|
||||
"replaced": False,
|
||||
}
|
||||
assert replaced.status_code == 200
|
||||
assert replaced.json() == {
|
||||
"model_id": "home-model",
|
||||
"supported_sensors": ["sensor.bedroom"],
|
||||
"trained_features": 1,
|
||||
"model_type": "statistical_baseline",
|
||||
"replaced": True,
|
||||
}
|
||||
restarted = ModelRegistry(tmp_path)
|
||||
@@ -107,3 +111,70 @@ def test_retrain_rejects_empty_samples() -> None:
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_predict_returns_numeric_forecast_and_confidence(tmp_path: Path) -> None:
|
||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
from app.ml.training import TrainingPipeline
|
||||
|
||||
store = FeatureStore()
|
||||
store.add_batch(
|
||||
[
|
||||
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
|
||||
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
|
||||
]
|
||||
)
|
||||
registry = ModelRegistry(tmp_path)
|
||||
registry.register(TrainingPipeline(store).run("home-model"))
|
||||
|
||||
with TestClient(app) as client:
|
||||
app.state.registry = registry
|
||||
response = client.post(
|
||||
"/ml/predict",
|
||||
json={
|
||||
"modelId": "home-model",
|
||||
"sensor_id": "sensor.kitchen",
|
||||
"values": {"temperature": 21.0},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["predictions"] == {"temperature": 22.0}
|
||||
assert 0.0 < response.json()["confidence"] <= 1.0
|
||||
assert response.json()["model_type"] == "statistical_baseline"
|
||||
|
||||
|
||||
def test_evaluate_returns_real_error_metrics(tmp_path: Path) -> None:
|
||||
from app.ml.feature_store import FeatureStore, FeatureVector
|
||||
from app.ml.registry.model_registry import ModelRegistry
|
||||
from app.ml.training import TrainingPipeline
|
||||
|
||||
store = FeatureStore()
|
||||
store.add_batch(
|
||||
[
|
||||
FeatureVector("sensor.kitchen", {"temperature": 19.0}),
|
||||
FeatureVector("sensor.kitchen", {"temperature": 20.0}),
|
||||
]
|
||||
)
|
||||
registry = ModelRegistry(tmp_path)
|
||||
registry.register(TrainingPipeline(store).run("home-model"))
|
||||
|
||||
with TestClient(app) as client:
|
||||
app.state.registry = registry
|
||||
response = client.post(
|
||||
"/ml/evaluate",
|
||||
json={
|
||||
"modelId": "home-model",
|
||||
"samples": [
|
||||
{
|
||||
"sensor_id": "sensor.kitchen",
|
||||
"values": {"temperature": 21.0},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
metrics = {metric["name"]: metric["value"] for metric in response.json()["metrics"]}
|
||||
assert metrics == {"mae": 1.0, "rmse": 1.0, "coverage": 1.0}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",)))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user