File size: 7,035 Bytes
88d18b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """
OmniDiag β Model Drift Monitor (Feature 4.1)
==============================================
Uses Evidently to compute data drift and model performance reports against
a reference (training) dataset baseline.
Usage:
from backend.monitoring.drift import DriftMonitor
monitor = DriftMonitor(reference_csv="data/heart_disease/processed/final_ready_data.csv")
report = monitor.run(current_df) # returns dict with drift metrics
html = monitor.run_html(current_df) # returns full HTML report string
The DriftMonitor is mounted at:
GET /api/v4/admin/drift/status β latest drift metrics as JSON
GET /api/v4/admin/drift/report β full Evidently HTML report
POST /api/v4/admin/drift/run β trigger a fresh drift computation
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import pandas as pd
log = logging.getLogger("omnidiag.drift")
# Lazy-import evidently so the module loads even if evidently is not installed
_evidently_available = False
try:
from evidently import ColumnMapping
from evidently.metrics import (
DatasetDriftMetric,
DatasetMissingValuesMetric,
ColumnDriftMetric,
)
from evidently.report import Report
_evidently_available = True
except ImportError:
log.warning("evidently not installed β drift monitoring unavailable")
class DriftMonitor:
"""
Wrapper around Evidently's Report for OmniDiag drift detection.
Thread-safe: uses a lock around report runs so concurrent requests
don't corrupt state.
"""
def __init__(
self,
reference_csv: str | Path,
target_col: str = "target",
prediction_col: Optional[str] = None,
categorical_features: Optional[List[str]] = None,
numerical_features: Optional[List[str]] = None,
) -> None:
self._lock = threading.Lock()
self._last_report: Optional[Dict[str, Any]] = None
self._last_run: Optional[datetime] = None
self._last_html: Optional[str] = None
ref_path = Path(reference_csv)
if not ref_path.exists():
log.warning("Reference CSV not found: %s β drift monitor inactive", ref_path)
self._reference: Optional[pd.DataFrame] = None
else:
self._reference = pd.read_csv(ref_path)
log.info("DriftMonitor: loaded reference with %d rows from %s", len(self._reference), ref_path)
self._target_col = target_col
self._prediction_col = prediction_col
self._cat_features = categorical_features
self._num_features = numerical_features
@property
def is_ready(self) -> bool:
return _evidently_available and self._reference is not None
def run(self, current_df: pd.DataFrame) -> Dict[str, Any]:
"""
Run Evidently drift report against the reference dataset.
Returns a structured dict with drift summary.
"""
if not self.is_ready:
return {"error": "Drift monitoring unavailable (evidently not installed or reference missing)"}
with self._lock:
col_mapping = ColumnMapping(
target=self._target_col if self._target_col in self._reference.columns else None,
prediction=self._prediction_col,
numerical_features=self._num_features,
categorical_features=self._cat_features,
)
report = Report(metrics=[
DatasetDriftMetric(),
DatasetMissingValuesMetric(),
])
# Align columns between reference and current
common_cols = [c for c in self._reference.columns if c in current_df.columns]
report.run(
reference_data=self._reference[common_cols],
current_data=current_df[common_cols],
column_mapping=col_mapping,
)
result_dict = report.as_dict()
metrics = result_dict.get("metrics", [])
# Extract summary values
drift_summary: Dict[str, Any] = {
"run_at": datetime.now(timezone.utc).isoformat(),
"reference_rows": len(self._reference),
"current_rows": len(current_df),
"common_columns": len(common_cols),
"metrics": {},
}
for m in metrics:
mtype = m.get("metric", "")
mresult = m.get("result", {})
if "DatasetDriftMetric" in mtype:
drift_summary["metrics"]["dataset_drift"] = {
"dataset_drift_detected": mresult.get("dataset_drift"),
"drift_share": mresult.get("drift_share"),
"number_of_drifted_columns": mresult.get("number_of_drifted_columns"),
"number_of_columns": mresult.get("number_of_columns"),
}
elif "DatasetMissingValuesMetric" in mtype:
drift_summary["metrics"]["missing_values"] = {
"current_missing_share": mresult.get("current", {}).get("share_of_missing_values"),
"reference_missing_share": mresult.get("reference", {}).get("share_of_missing_values"),
}
self._last_report = drift_summary
self._last_run = datetime.now(timezone.utc)
# Also save HTML
self._last_html = report.get_html()
log.info(
"Drift run complete: drift_detected=%s share=%.2f%%",
drift_summary["metrics"].get("dataset_drift", {}).get("dataset_drift_detected"),
(drift_summary["metrics"].get("dataset_drift", {}).get("drift_share") or 0) * 100,
)
return drift_summary
def run_html(self, current_df: pd.DataFrame) -> str:
"""Run drift report and return the full Evidently HTML string."""
self.run(current_df)
return self._last_html or "<p>No report available.</p>"
@property
def last_report(self) -> Optional[Dict[str, Any]]:
return self._last_report
@property
def last_run(self) -> Optional[datetime]:
return self._last_run
# ββ Per-disease monitor singletons ββββββββββββββββββββββββββββββββββββββββββββ
_monitors: Dict[str, DriftMonitor] = {}
_REFERENCE_PATHS: Dict[str, str] = {
"heart_disease": "data/heart_disease/processed/final_ready_data.csv",
"diabetes": "data/diabetes/interim/false_negatives_profile.csv",
}
def get_monitor(disease: str) -> DriftMonitor:
if disease not in _monitors:
ref = _REFERENCE_PATHS.get(disease, f"data/{disease}/processed/reference.csv")
_monitors[disease] = DriftMonitor(reference_csv=ref)
return _monitors[disease]
|