Airflow / Construction de l'image (push) Successful in 1m2s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 5m3s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m41s
ML / Analyse statique de sécurité (push) Successful in 6s
Backend / Lint, typage et tests (push) Successful in 10m10s
Backend / Audit des dépendances (push) Successful in 9m36s
ML / ML - DB et chaîne ML - DB - API (push) Failing after 5m6s
SonarQube / test-ml (push) Failing after 6m6s
ML / Lint, typage et tests (push) Successful in 11m31s
SonarQube / build-front (push) Successful in 9m49s
SonarQube / build-back (push) Successful in 9m54s
SonarQube / test-front (push) Failing after 5m10s
SonarQube / test-back (push) Failing after 5m13s
SonarQube / SonarQube (push) Skipped
Revue de la branche : trois defauts empechaient la surveillance de tenir ce qu'elle annonce. - `evaluate()` gardait les microsecondes de `now()` dans `window_end`, la cle de `uq_drift_report_window`. Deux executions ne collidaient donc jamais et l'index ne dedoublonnait rien, contrairement a ce qu'affirmaient l'ADR 0011, 20-backend et le docstring du DAG. L'instant de reference est desormais tronque a l'heure. - Un site qui cessait d'etre score disparaissait du rapport : la liste des sites ne venait que de la fenetre recente. La panne que cette surveillance existe pour dire etait exactement celle qu'elle taisait. La fenetre de reference entre maintenant dans l'union, et le site recoit sa ligne `indetermine` a zero observation. - Sans fenetre de reference, `_plafond` rendait `None` et le verdict tombait sur `stable`, une affirmation que la donnee ne portait pas. C'est `indetermine` desormais. `ml.yml` ecoute `apps/backend/app/**` et non les seuls modeles : ce workflow est le seul a jouer `-m chaine`, or la chaine traverse les endpoints, les services et les schemas jusqu'a `GET /predictions`. Une PR touchant `predictions.py` ne declenchait pas le test qui l'assert. Hygiene de tests : le nettoyage des fixtures API connait `drift_report` (cle etrangere RESTRICT vers `site`), le test sans rapport rend ses overrides en teardown, `test_chaine_ml_api` compare les `created_at` strictement (un `>=` passait aussi quand l'API resservait la premiere ligne), et `test_data_integration` filtre sur le site seme au lieu de juger tout le contenu d'une fenetre dans une base partagee. Docs remises d'aplomb : sept revisions Alembic et non six, `derive.py` dans l'inventaire de etl/README, et le diagramme de 20-backend gagne DriftService, le depot drift et sa treizieme table.
261 lines
9.0 KiB
Python
261 lines
9.0 KiB
Python
from collections.abc import Sequence
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.repositories.drift import ComptageStatut, PaireDerive
|
|
from app.services.drift import (
|
|
STATUT_DERIVE,
|
|
STATUT_INDETERMINE,
|
|
STATUT_STABLE,
|
|
DriftService,
|
|
Seuils,
|
|
mesure,
|
|
)
|
|
|
|
INSTANT = datetime(2026, 9, 22, 12, 0, tzinfo=UTC)
|
|
|
|
|
|
def paire(
|
|
*, site_id: str = "SITE001", prevu: float, reel: float, reference: str = "lightgbm-aaa"
|
|
) -> PaireDerive:
|
|
return PaireDerive(
|
|
site_id=site_id,
|
|
target_at=INSTANT,
|
|
predicted_value=prevu,
|
|
actual_value=reel,
|
|
model_reference=reference,
|
|
)
|
|
|
|
|
|
def paires(
|
|
*, site_id: str = "SITE001", nombre: int, prevu: float, reel: float
|
|
) -> list[PaireDerive]:
|
|
return [paire(site_id=site_id, prevu=prevu, reel=reel) for _ in range(nombre)]
|
|
|
|
|
|
class FauxDepot:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
recentes: Sequence[PaireDerive] = (),
|
|
anciennes: Sequence[PaireDerive] = (),
|
|
comptages: Sequence[ComptageStatut] = (),
|
|
) -> None:
|
|
self.recentes = list(recentes)
|
|
self.anciennes = list(anciennes)
|
|
self._comptages = list(comptages)
|
|
self.fenetres: list[tuple[datetime, datetime]] = []
|
|
|
|
async def paires(
|
|
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
|
) -> Sequence[PaireDerive]:
|
|
self.fenetres.append((debut, fin))
|
|
return self.recentes if len(self.fenetres) == 1 else self.anciennes
|
|
|
|
async def comptages(
|
|
self, *, debut: datetime, fin: datetime, site_id: str | None = None
|
|
) -> Sequence[ComptageStatut]:
|
|
return self._comptages
|
|
|
|
|
|
def service(depot: FauxDepot, **surcharges: object) -> DriftService:
|
|
return DriftService(depot, seuils=Seuils(**surcharges)) # type: ignore[arg-type]
|
|
|
|
|
|
def test_drift_averages_the_absolute_gap_between_forecast_and_actual() -> None:
|
|
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=8.0, reel=10.0)])
|
|
|
|
assert metriques.mae == 2.0
|
|
assert metriques.n_observations == 2
|
|
|
|
|
|
def test_drift_computes_a_signed_bias_when_the_model_overforecasts() -> None:
|
|
metriques = mesure([paire(prevu=12.0, reel=10.0), paire(prevu=14.0, reel=10.0)])
|
|
|
|
assert metriques.bias == 3.0
|
|
|
|
|
|
def test_drift_computes_a_negative_bias_when_the_model_underforecasts() -> None:
|
|
metriques = mesure([paire(prevu=8.0, reel=10.0), paire(prevu=6.0, reel=10.0)])
|
|
|
|
assert metriques.bias == -3.0
|
|
|
|
|
|
def test_drift_excludes_a_zero_actual_from_the_mape_only() -> None:
|
|
metriques = mesure([paire(prevu=11.0, reel=10.0), paire(prevu=5.0, reel=0.0)])
|
|
|
|
assert metriques.mape == 10.0
|
|
assert metriques.n_observations == 2
|
|
assert metriques.mae == 3.0
|
|
|
|
|
|
def test_drift_reports_no_mape_when_every_actual_is_zero() -> None:
|
|
metriques = mesure([paire(prevu=1.0, reel=0.0)])
|
|
|
|
assert metriques.mape is None
|
|
|
|
|
|
def test_drift_lists_every_model_reference_seen_in_the_window() -> None:
|
|
metriques = mesure(
|
|
[paire(prevu=10.0, reel=10.0, reference="lightgbm-bbb"), paire(prevu=10.0, reel=10.0)]
|
|
)
|
|
|
|
assert metriques.model_references == ["lightgbm-aaa", "lightgbm-bbb"]
|
|
|
|
|
|
async def test_drift_reports_indetermine_when_the_window_holds_too_few_observations() -> None:
|
|
depot = FauxDepot(recentes=paires(nombre=3, prevu=10.0, reel=10.0))
|
|
|
|
rapports = await service(depot, min_observations=24).evaluate(now=INSTANT)
|
|
|
|
assert {rapport.status for rapport in rapports} == {STATUT_INDETERMINE}
|
|
assert all(rapport.reason for rapport in rapports)
|
|
|
|
|
|
async def test_drift_reports_derive_when_the_recent_mae_exceeds_the_reference_ratio() -> None:
|
|
depot = FauxDepot(
|
|
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
|
|
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
|
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
|
assert global_.status == STATUT_DERIVE
|
|
assert global_.mae == 4.0
|
|
assert global_.reference_mae == 1.0
|
|
|
|
|
|
async def test_drift_reports_stable_when_the_recent_mae_stays_close_to_the_reference() -> None:
|
|
depot = FauxDepot(
|
|
recentes=paires(nombre=30, prevu=11.0, reel=10.0),
|
|
anciennes=paires(nombre=30, prevu=11.0, reel=10.0),
|
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
|
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
|
assert global_.status == STATUT_STABLE
|
|
assert global_.reason is None
|
|
|
|
|
|
async def test_drift_reports_indetermine_when_the_reference_window_is_empty() -> None:
|
|
depot = FauxDepot(
|
|
recentes=paires(nombre=30, prevu=14.0, reel=10.0),
|
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
|
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
|
assert global_.status == STATUT_INDETERMINE
|
|
assert global_.reference_mae is None
|
|
|
|
|
|
async def test_drift_reports_derive_when_the_coverage_ratio_falls_under_the_threshold() -> None:
|
|
depot = FauxDepot(
|
|
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
|
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
|
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=100)],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
|
|
global_ = next(rapport for rapport in rapports if rapport.site_id is None)
|
|
assert global_.status == STATUT_DERIVE
|
|
assert global_.coverage_ratio == 0.3
|
|
|
|
|
|
async def test_drift_reports_one_line_per_site_and_one_global_line() -> None:
|
|
depot = FauxDepot(
|
|
recentes=[
|
|
*paires(site_id="SITE001", nombre=12, prevu=10.0, reel=10.0),
|
|
*paires(site_id="SITE002", nombre=12, prevu=10.0, reel=10.0),
|
|
],
|
|
comptages=[
|
|
ComptageStatut(site_id="SITE001", status="available", nombre=12),
|
|
ComptageStatut(site_id="SITE002", status="available", nombre=12),
|
|
],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
|
|
assert [rapport.site_id for rapport in rapports] == ["SITE001", "SITE002", None]
|
|
assert next(r for r in rapports if r.site_id is None).n_observations == 24
|
|
|
|
|
|
async def test_drift_still_reports_a_site_that_stopped_being_scored() -> None:
|
|
depot = FauxDepot(
|
|
recentes=paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
|
|
anciennes=[
|
|
*paires(site_id="SITE001", nombre=30, prevu=10.0, reel=10.0),
|
|
*paires(site_id="SITE002", nombre=30, prevu=10.0, reel=10.0),
|
|
],
|
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
|
|
disparu = next(rapport for rapport in rapports if rapport.site_id == "SITE002")
|
|
assert disparu.status == STATUT_INDETERMINE
|
|
assert disparu.n_observations == 0
|
|
|
|
|
|
async def test_drift_measures_the_share_of_sites_left_without_enough_history() -> None:
|
|
depot = FauxDepot(
|
|
recentes=paires(nombre=30, prevu=10.0, reel=10.0),
|
|
comptages=[
|
|
ComptageStatut(site_id="SITE001", status="available", nombre=30),
|
|
ComptageStatut(site_id="SITE001", status="insufficient_data", nombre=10),
|
|
],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10).evaluate(now=INSTANT)
|
|
|
|
assert next(r for r in rapports if r.site_id is None).insufficient_data_ratio == 0.25
|
|
|
|
|
|
async def test_drift_closes_the_window_before_the_grace_delay() -> None:
|
|
depot = FauxDepot()
|
|
|
|
await service(depot, grace=timedelta(hours=2), fenetre=timedelta(hours=168)).evaluate(
|
|
now=INSTANT
|
|
)
|
|
|
|
recente, reference = depot.fenetres
|
|
assert recente[1] == INSTANT - timedelta(hours=2)
|
|
assert recente[0] == INSTANT - timedelta(hours=170)
|
|
assert reference[1] == recente[0]
|
|
|
|
|
|
async def test_drift_truncates_the_reference_instant_to_the_hour() -> None:
|
|
premier = FauxDepot()
|
|
second = FauxDepot()
|
|
|
|
await service(premier).evaluate(now=INSTANT + timedelta(minutes=17, microseconds=3))
|
|
await service(second).evaluate(now=INSTANT + timedelta(minutes=48))
|
|
|
|
assert premier.fenetres[0] == second.fenetres[0]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("prevu", "attendu"),
|
|
[(10.0, STATUT_STABLE), (30.0, STATUT_DERIVE)],
|
|
ids=["mae_stable", "mae_triplee"],
|
|
)
|
|
async def test_drift_compares_the_recent_window_to_the_reference_one(
|
|
prevu: float, attendu: str
|
|
) -> None:
|
|
depot = FauxDepot(
|
|
recentes=paires(nombre=30, prevu=prevu, reel=10.0),
|
|
anciennes=paires(nombre=30, prevu=10.0, reel=10.0),
|
|
comptages=[ComptageStatut(site_id="SITE001", status="available", nombre=30)],
|
|
)
|
|
|
|
rapports = await service(depot, min_observations=10, mae_plancher=1.0).evaluate(now=INSTANT)
|
|
|
|
assert next(r for r in rapports if r.site_id is None).status == attendu
|