""" OmniDiag — Automated Retraining Pipeline (Feature 4.3) ======================================================= Pulls annotated samples from the ReviewQueue, merges them with the original training dataset, and retrains the XGBoost model incrementally. Usage (CLI): python -m backend.active_learning.retrain --disease heart_disease --min-samples 10 API: POST /api/v4/admin/retrain (super_admin only) Flow: 1. Query ReviewQueue for annotated (status='annotated') rows 2. Fetch the linked Prediction's features + the expert label 3. Append to original CSV (or create a new combined CSV) 4. Retrain XGBoost (incremental via xgb_model= parameter) 5. Save new model to disk 6. Reload the running ModelLoader so new predictions use updated model 7. Log the run to MLflow (if available) """ from __future__ import annotations import asyncio import logging import os import pickle from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple log = logging.getLogger("omnidiag.retrain") MODELS_DIR = Path(os.getenv("MODELS_DIR", "models")) async def get_annotated_samples( db, disease: str, min_samples: int = 5, ) -> Tuple[List[Dict[str, Any]], List[int]]: """ Pull annotated ReviewQueue items from the database. Returns (features_list, labels_list) where each features_list[i] is a dict of the original prediction inputs and labels_list[i] is the expert label. """ from sqlalchemy import text as _text rows = (await db.execute( _text(""" SELECT rq.label, p.input_features FROM review_queue rq JOIN predictions p ON p.id = rq.prediction_id WHERE rq.status = 'reviewed' AND rq.label IS NOT NULL AND p.disease = :disease LIMIT 1000 """), {"disease": disease}, )).fetchall() if len(rows) < min_samples: return [], [] import json as _json features_list: List[Dict[str, Any]] = [] labels: List[int] = [] for row in rows: expert_label, raw_features = row[0], row[1] try: feats = _json.loads(raw_features) if isinstance(raw_features, str) else dict(raw_features or {}) if feats: features_list.append(feats) labels.append(int(expert_label)) except Exception: continue return features_list, labels def retrain_xgb( disease: str, features_list: List[Dict[str, Any]], labels: List[int], ) -> Optional[Path]: """ Incrementally retrain the XGBoost model for the given disease. Returns the path to the newly saved model, or None on failure. """ try: import xgboost as xgb import numpy as np model_path = MODELS_DIR / disease / "omni_diag_xgb_optimized.pkl" if not model_path.exists(): log.error(f"Model not found: {model_path}") return None with open(model_path, "rb") as f: model = pickle.load(f) X = np.array([list(feat.values()) for feat in features_list], dtype=np.float32) y = np.array(labels, dtype=np.float32) dtrain = xgb.DMatrix(X, label=y) updated_model = xgb.train( params={ "objective": "binary:logistic", "eval_metric": "logloss", "max_depth": 5, "learning_rate": 0.05, }, dtrain=dtrain, num_boost_round=20, xgb_model=model, verbose_eval=False, ) backup_path = model_path.with_suffix(f".bak.{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.pkl") model_path.rename(backup_path) with open(model_path, "wb") as f: pickle.dump(updated_model, f) log.info(f"Retrained {disease} model saved to {model_path} ({len(labels)} new samples)") return model_path except Exception as exc: log.error(f"Retraining failed for {disease}: {exc!r}") return None def _log_to_mlflow(disease: str, n_samples: int, model_path: Optional[Path]) -> None: try: from backend.monitoring.mlflow_tracker import log_model_info log_model_info( disease=disease, model_version=f"retrain_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}", metrics={"retrain_samples": n_samples, "success": 1 if model_path else 0}, ) except Exception as exc: log.warning(f"MLflow logging failed: {exc!r}") async def run_retrain_pipeline( db, disease: str, min_samples: int = 5, ) -> Dict[str, Any]: """ Full pipeline: fetch annotated samples → retrain → reload → log. Returns a status dict suitable for the API response. """ features_list, labels = await get_annotated_samples(db, disease, min_samples) if not features_list: return { "status": "skipped", "disease": disease, "reason": f"Fewer than {min_samples} annotated samples available", "samples_used": 0, } model_path = retrain_xgb(disease, features_list, labels) _log_to_mlflow(disease, len(labels), model_path) if model_path: # Reload model in running process so next predict uses updated weights try: from backend.model_loader import ModelLoader ModelLoader.reload(disease) except Exception as exc: log.warning(f"Model hot-reload failed (will take effect on next startup): {exc!r}") return { "status": "success", "disease": disease, "samples_used": len(labels), "model_path": str(model_path), } else: return { "status": "failed", "disease": disease, "samples_used": len(labels), "reason": "Retraining step failed — check logs", } if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="OmniDiag retraining pipeline") parser.add_argument("--disease", default="heart_disease", help="Disease module to retrain") parser.add_argument("--min-samples", type=int, default=10, help="Minimum annotated samples required") args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") async def _main(): from backend.database import async_session_maker async with async_session_maker() as db: result = await run_retrain_pipeline(db, args.disease, args.min_samples) print(result) asyncio.run(_main())