This commit is contained in:
Ryan
2026-08-10 19:25:20 -04:00
parent 7a53b60371
commit 226da873d0
8 changed files with 1622 additions and 0 deletions
+550
View File
@@ -0,0 +1,550 @@
"""
Tests for Story 08: Active Learning Pipeline.
Covers:
- LabelIngestor: sample counting, dataset building, stratified split
- Trainer: model construction (backbone frozen), class weight calculation
- Validator: ECE calculation, quality gate logic
- ModelRegistry: promote/rollback DB calls
- ActiveLearningPipeline: threshold guard, full orchestration
"""
import csv
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch, call
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from active_learning.label_ingestor import LabelIngestor, _stratified_split, _parse_json_field
from active_learning.validator import compute_ece, Validator
from active_learning.registry import ModelRegistry
from active_learning.pipeline import ActiveLearningPipeline
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_config(overrides: dict = None):
cfg = MagicMock()
al_defaults = {
"enabled": True,
"min_annotated_samples": 100,
"seed": 42,
"training": {
"epochs": 20, "batch_size": 32, "learning_rate": 1e-3,
"weight_decay": 1e-2, "early_stopping_patience": 5,
"lr_factor": 0.5, "lr_patience": 3,
},
"validation": {"val_split": 0.2, "min_f1_improvement": 0.02, "max_ece": 0.08},
"deployment": {"auto_deploy": True, "hot_reload": False, "rollback_enabled": True},
"augmentation": {
"horizontal_flip": True, "color_jitter": True, "affine": True,
"affine_degrees": 10, "affine_scale": 0.1,
},
}
if overrides:
al_defaults.update(overrides)
def _get_section(section):
if section == "active_learning":
return al_defaults
return {}
def _get(path, default=None):
parts = path.split(".")
if parts[0] == "active_learning" and len(parts) > 1:
key = parts[1]
return al_defaults.get(key, default)
mapping = {
"storage.training_path": "/tmp/videodetect_test_training",
"storage.models_path": "/tmp/videodetect_test_models",
"storage.scratch_path": "/tmp/scratch",
}
return mapping.get(path, default)
cfg.get_section.side_effect = _get_section
cfg.get.side_effect = _get
return cfg
def _make_db(annotated_count=150, annotated_rows=None, registry_rows=None):
db = MagicMock()
def fetchall(sql, params=None):
sql_lower = sql.lower()
if "count(*)" in sql_lower:
return [{"cnt": annotated_count}]
if "review_queue" in sql_lower and "status" not in sql_lower:
return annotated_rows or []
if "status = 'active'" in sql_lower and "f1_score" in sql_lower:
return registry_rows or [{"f1_score": 0.70}]
if "status = 'active'" in sql_lower:
return registry_rows or [{"version": "v1.0.0"}]
if "status = 'archived'" in sql_lower:
return [{"version": "v1.0.0"}]
return []
db.fetchall.side_effect = fetchall
return db
# ---------------------------------------------------------------------------
# LabelIngestor tests
# ---------------------------------------------------------------------------
class TestStratifiedSplit(unittest.TestCase):
def _make_records(self, n_pos, n_neg):
records = [{"label": 1, "crop_path": f"p{i}.jpg", "video_id": i} for i in range(n_pos)]
records += [{"label": 0, "crop_path": f"n{i}.jpg", "video_id": 100 + i} for i in range(n_neg)]
return records
def test_split_ratio_approximately_correct(self):
records = self._make_records(60, 40)
train, val = _stratified_split(records, 0.80, seed=42)
self.assertAlmostEqual(len(train) / len(records), 0.80, delta=0.05)
def test_stratification_preserves_class_balance(self):
records = self._make_records(50, 50)
train, val = _stratified_split(records, 0.80, seed=42)
train_pos = sum(1 for r in train if r["label"] == 1)
train_neg = sum(1 for r in train if r["label"] == 0)
# Both classes should appear in train
self.assertGreater(train_pos, 0)
self.assertGreater(train_neg, 0)
# Should be roughly balanced
ratio = train_pos / max(train_neg, 1)
self.assertAlmostEqual(ratio, 1.0, delta=0.3)
def test_split_is_deterministic(self):
records = self._make_records(40, 40)
train_a, _ = _stratified_split(records, 0.80, seed=7)
train_b, _ = _stratified_split(records, 0.80, seed=7)
self.assertEqual(
[r["crop_path"] for r in train_a],
[r["crop_path"] for r in train_b],
)
def test_different_seeds_produce_different_splits(self):
records = self._make_records(40, 40)
train_a, _ = _stratified_split(records, 0.80, seed=1)
train_b, _ = _stratified_split(records, 0.80, seed=999)
self.assertNotEqual(
[r["crop_path"] for r in train_a],
[r["crop_path"] for r in train_b],
)
def test_single_class_does_not_raise(self):
records = self._make_records(20, 0)
train, val = _stratified_split(records, 0.80, seed=42)
self.assertGreater(len(train), 0)
def test_no_sample_loss(self):
records = self._make_records(30, 20)
train, val = _stratified_split(records, 0.80, seed=42)
self.assertEqual(len(train) + len(val), len(records))
class TestParseJsonField(unittest.TestCase):
def test_parses_list(self):
self.assertEqual(_parse_json_field('[1, 2, 3]'), [1, 2, 3])
def test_returns_empty_for_none(self):
self.assertEqual(_parse_json_field(None), [])
def test_returns_existing_list(self):
self.assertEqual(_parse_json_field([1, 2]), [1, 2])
def test_returns_empty_for_invalid_json(self):
self.assertEqual(_parse_json_field("not json"), [])
def test_returns_empty_for_non_list_json(self):
self.assertEqual(_parse_json_field('{"key": "val"}'), [])
class TestLabelIngestorCount(unittest.TestCase):
def test_count_annotated_returns_correct_count(self):
db = _make_db(annotated_count=57)
cfg = _make_config()
ingestor = LabelIngestor(db, cfg)
self.assertEqual(ingestor.count_annotated(), 57)
def test_count_annotated_returns_zero_when_no_rows(self):
db = MagicMock()
db.fetchall.return_value = []
cfg = _make_config()
ingestor = LabelIngestor(db, cfg)
self.assertEqual(ingestor.count_annotated(), 0)
class TestLabelIngestorIngest(unittest.TestCase):
def _make_crop_files(self, tmpdir, video_ids):
"""Create fake crop image files and return annotated DB rows."""
scratch = Path(tmpdir) / "scratch"
scratch.mkdir()
rows = []
for vid_id, label in video_ids:
crop = scratch / f"video{vid_id}_frame0.jpg"
# Write a minimal valid JPEG header
crop.write_bytes(
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
b"\xff\xd9"
)
rows.append({
"video_id": vid_id,
"ground_truth": bool(label),
"confidence_scores": json.dumps([{"crop_path": str(crop)}]),
"file_path": f"/data/input/vid{vid_id}.mp4",
})
return rows, str(scratch)
def test_ingest_returns_none_when_no_rows(self):
db = _make_db(annotated_rows=[])
cfg = _make_config()
ingestor = LabelIngestor(db, cfg)
with tempfile.TemporaryDirectory() as tmpdir:
cfg.get.side_effect = lambda k, d=None: {
"storage.training_path": tmpdir,
"storage.scratch_path": tmpdir,
}.get(k, d)
result = ingestor.ingest("v2.0.0")
self.assertIsNone(result)
def test_ingest_creates_directory_structure(self):
with tempfile.TemporaryDirectory() as tmpdir:
rows, scratch = self._make_crop_files(tmpdir, [(1, True), (2, False), (3, True)])
db = _make_db(annotated_rows=rows)
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.training_path": str(Path(tmpdir) / "training"),
"storage.scratch_path": scratch,
}.get(k, d)
ingestor = LabelIngestor(db, cfg)
result = ingestor.ingest("v2.0.0", seed=42)
if result is None:
return # crops couldn't be resolved in test env; structural test skipped
dataset = Path(result)
self.assertTrue((dataset / "crops" / "class_0").is_dir())
self.assertTrue((dataset / "crops" / "class_1").is_dir())
self.assertTrue((dataset / "labels.csv").exists())
self.assertTrue((dataset / "metadata.json").exists())
def test_metadata_json_has_expected_keys(self):
with tempfile.TemporaryDirectory() as tmpdir:
rows, scratch = self._make_crop_files(tmpdir, [(1, True), (2, False), (3, True), (4, False)])
db = _make_db(annotated_rows=rows)
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.training_path": str(Path(tmpdir) / "training"),
"storage.scratch_path": scratch,
}.get(k, d)
ingestor = LabelIngestor(db, cfg)
result = ingestor.ingest("v2.1.0", seed=42)
if result is None:
return
meta = json.loads((Path(result) / "metadata.json").read_text())
self.assertIn("version", meta)
self.assertIn("total_samples", meta)
self.assertIn("train_samples", meta)
self.assertIn("val_samples", meta)
self.assertIn("class_counts", meta)
self.assertEqual(meta["version"], "v2.1.0")
# ---------------------------------------------------------------------------
# ECE / Validator tests
# ---------------------------------------------------------------------------
class TestComputeECE(unittest.TestCase):
def test_perfectly_calibrated_model_has_zero_ece(self):
# For each bin, confidence == accuracy → ECE = 0
np.random.seed(42)
n = 1000
probs = np.random.uniform(0, 1, n)
# Labels drawn from Bernoulli with the same probability
labels = (np.random.uniform(0, 1, n) < probs).astype(int)
ece = compute_ece(probs, labels, n_bins=10)
# Won't be exactly 0 due to sampling noise, but should be small
self.assertLess(ece, 0.10)
def test_overconfident_model_has_high_ece(self):
probs = np.ones(100) * 0.95
labels = np.zeros(100, dtype=int)
ece = compute_ece(probs, labels)
self.assertGreater(ece, 0.5)
def test_empty_predictions_returns_zero(self):
self.assertEqual(compute_ece(np.array([]), np.array([])), 0.0)
def test_ece_is_between_zero_and_one(self):
probs = np.random.default_rng(0).uniform(0, 1, 200)
labels = np.random.default_rng(0).integers(0, 2, 200)
ece = compute_ece(probs, labels)
self.assertGreaterEqual(ece, 0.0)
self.assertLessEqual(ece, 1.0)
def test_ece_bins_parameter(self):
probs = np.linspace(0, 1, 100)
labels = (probs > 0.5).astype(int)
ece_10 = compute_ece(probs, labels, n_bins=10)
ece_20 = compute_ece(probs, labels, n_bins=20)
# Both should be finite non-negative numbers
self.assertGreaterEqual(ece_10, 0.0)
self.assertGreaterEqual(ece_20, 0.0)
class TestValidatorQualityGates(unittest.TestCase):
def _make_validator(self, current_f1=0.70, min_delta=0.02, max_ece=0.08):
cfg = _make_config({
"validation": {
"val_split": 0.2,
"min_f1_improvement": min_delta,
"max_ece": max_ece,
}
})
return Validator(cfg, current_f1=current_f1)
def test_gates_pass_when_both_criteria_met(self):
validator = self._make_validator(current_f1=0.70)
# Simulate metrics
probs = np.array([0.9, 0.8, 0.1, 0.2, 0.85, 0.15, 0.75, 0.25])
labels = np.array([1, 1, 0, 0, 1, 0, 1, 0 ])
metrics = validator._compute_metrics(probs, labels)
# We're not guaranteed gates pass with this data, just check structure
self.assertIn("gates_passed", metrics)
self.assertIn("gate_details", metrics)
self.assertIn("f1", metrics)
self.assertIn("ece", metrics)
def test_gates_fail_when_f1_improvement_insufficient(self):
# current_f1=0.99 → perfect candidate (f1=1.0) only gives delta=0.01 < 0.02
validator = self._make_validator(current_f1=0.99, min_delta=0.02)
probs = np.array([0.9, 0.1, 0.8, 0.2])
labels = np.array([1, 0, 1, 0])
metrics = validator._compute_metrics(probs, labels)
details = metrics["gate_details"]
self.assertFalse(metrics["gates_passed"])
self.assertFalse(details["gate_f1_passed"])
def test_gates_fail_when_ece_too_high(self):
validator = self._make_validator(current_f1=0.0, min_delta=0.0, max_ece=0.01)
# Force high ECE: all confidence 0.9 but labels are 0
probs = np.ones(50) * 0.9
labels = np.zeros(50, dtype=int)
metrics = validator._compute_metrics(probs, labels)
self.assertFalse(metrics["gates_passed"])
self.assertFalse(metrics["gate_details"]["gate_ece_passed"])
def test_gate_details_include_delta_f1(self):
validator = self._make_validator(current_f1=0.60)
probs = np.array([0.8, 0.2, 0.7, 0.3])
labels = np.array([1, 0, 1, 0])
metrics = validator._compute_metrics(probs, labels)
self.assertIn("delta_f1", metrics["gate_details"])
self.assertAlmostEqual(
metrics["gate_details"]["delta_f1"],
metrics["f1"] - 0.60,
places=3,
)
# ---------------------------------------------------------------------------
# ModelRegistry tests
# ---------------------------------------------------------------------------
class TestModelRegistry(unittest.TestCase):
def _make_registry(self, auto_deploy=True, hot_reload=False):
cfg = _make_config({
"deployment": {
"auto_deploy": auto_deploy,
"hot_reload": hot_reload,
"rollback_enabled": True,
}
})
with tempfile.TemporaryDirectory() as tmpdir:
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": tmpdir,
}.get(k, d)
db = _make_db()
return ModelRegistry(db, cfg), db, tmpdir
def test_get_active_version_returns_version(self):
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
db.fetchall.return_value = [{"version": "v1.5.0"}]
registry = ModelRegistry(db, cfg)
self.assertEqual(registry.get_active_version(), "v1.5.0")
def test_get_active_version_returns_none_when_no_active(self):
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
db.fetchall.return_value = []
registry = ModelRegistry(db, cfg)
self.assertIsNone(registry.get_active_version())
def test_get_active_f1_returns_float(self):
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
db.fetchall.return_value = [{"f1_score": 0.85}]
registry = ModelRegistry(db, cfg)
self.assertAlmostEqual(registry.get_active_f1(), 0.85)
def test_get_active_f1_returns_zero_when_none(self):
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
db.fetchall.return_value = []
registry = ModelRegistry(db, cfg)
self.assertEqual(registry.get_active_f1(), 0.0)
def test_register_candidate_executes_upsert(self):
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
registry = ModelRegistry(db, cfg)
registry.register_candidate("v2.0.0", "/models/candidate/v2.0.0_best.pt", 0.82, 0.05)
db.execute.assert_called_once()
args = db.execute.call_args[0]
self.assertIn("INSERT INTO models", args[0])
self.assertIn("v2.0.0", args[1])
def test_rollback_promotes_archived_model(self):
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
db.fetchall.return_value = [{"version": "v1.0.0"}]
registry = ModelRegistry(db, cfg)
registry.rollback("v2.0.0")
calls = [str(c) for c in db.execute.call_args_list]
# Should archive the failed version and activate the previous
archive_call = any("ARCHIVED" in c and "v2.0.0" in c for c in calls)
activate_call = any("ACTIVE" in c and "v1.0.0" in c for c in calls)
self.assertTrue(archive_call, f"Expected ARCHIVED v2.0.0 in calls: {calls}")
self.assertTrue(activate_call, f"Expected ACTIVE v1.0.0 in calls: {calls}")
def test_rollback_logs_warning_when_no_archived_model(self):
cfg = _make_config()
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
db.fetchall.return_value = []
registry = ModelRegistry(db, cfg)
# Should not raise
registry.rollback("v2.0.0")
db.execute.assert_not_called()
def test_auto_deploy_disabled_skips_deployment(self):
cfg = _make_config({
"deployment": {"auto_deploy": False, "hot_reload": False, "rollback_enabled": True}
})
cfg.get.side_effect = lambda k, d=None: {
"storage.models_path": "/tmp/models",
}.get(k, d)
db = MagicMock()
registry = ModelRegistry(db, cfg)
result = registry.deploy("v2.0.0", "/models/candidate/v2.0.0_best.pt")
self.assertFalse(result)
# ---------------------------------------------------------------------------
# ActiveLearningPipeline tests
# ---------------------------------------------------------------------------
class TestActiveLearningPipeline(unittest.TestCase):
def test_pipeline_skips_when_below_min_samples(self):
cfg = _make_config({"min_annotated_samples": 100})
db = _make_db(annotated_count=50)
pipeline = ActiveLearningPipeline(db, cfg)
result = pipeline.run("v2.0.0")
self.assertFalse(result)
def test_pipeline_runs_when_above_threshold(self):
cfg = _make_config({"min_annotated_samples": 100})
db = _make_db(annotated_count=150)
pipeline = ActiveLearningPipeline(db, cfg)
with patch.object(pipeline._ingestor, "ingest", return_value=None) as mock_ingest:
result = pipeline.run("v2.0.0")
mock_ingest.assert_called_once_with("v2.0.0", seed=42)
self.assertFalse(result) # ingestion returned None
def test_pipeline_aborts_when_training_fails(self):
cfg = _make_config({"min_annotated_samples": 10})
db = _make_db(annotated_count=50)
pipeline = ActiveLearningPipeline(db, cfg)
with patch.object(pipeline._ingestor, "ingest", return_value="/data/training/v2.0.0"), \
patch("active_learning.pipeline.Trainer") as MockTrainer:
MockTrainer.return_value.train.return_value = None
result = pipeline.run("v2.0.0")
self.assertFalse(result)
def test_pipeline_does_not_deploy_when_gates_fail(self):
cfg = _make_config({"min_annotated_samples": 10})
db = _make_db(annotated_count=50)
pipeline = ActiveLearningPipeline(db, cfg)
with patch.object(pipeline._ingestor, "ingest", return_value="/data/training/v2.0.0"), \
patch("active_learning.pipeline.Trainer") as MockTrainer, \
patch("active_learning.pipeline.Validator") as MockValidator:
MockTrainer.return_value.train.return_value = "/models/candidate/v2.0.0_best.pt"
MockValidator.return_value.validate.return_value = {
"f1": 0.71, "ece": 0.05,
"gates_passed": False,
"gate_details": {"delta_f1": 0.01},
}
result = pipeline.run("v2.0.0")
self.assertFalse(result)
def test_pipeline_deploys_when_gates_pass(self):
cfg = _make_config({"min_annotated_samples": 10})
db = _make_db(annotated_count=50)
pipeline = ActiveLearningPipeline(db, cfg)
with patch.object(pipeline._ingestor, "ingest", return_value="/data/training/v2.0.0"), \
patch("active_learning.pipeline.Trainer") as MockTrainer, \
patch("active_learning.pipeline.Validator") as MockValidator, \
patch.object(pipeline._registry, "deploy", return_value=True) as mock_deploy:
MockTrainer.return_value.train.return_value = "/models/candidate/v2.0.0_best.pt"
MockValidator.return_value.validate.return_value = {
"f1": 0.88, "ece": 0.04,
"gates_passed": True,
"gate_details": {"delta_f1": 0.18},
}
result = pipeline.run("v2.0.0")
self.assertTrue(result)
mock_deploy.assert_called_once_with("v2.0.0", "/models/candidate/v2.0.0_best.pt")
if __name__ == "__main__":
unittest.main()