glaucoma-api-idsc / inference.py
muhammadhabibna's picture
Fix(critical): Pretrained ImageNet CNN Weights and Strict Clinical Validation Gates
329abd1
Raw
History Blame Contribute Delete
10.1 kB
"""
inference.py
Model loading and inference for the GlaucomaAI backend.
Architecture from [IDSC]_D4.ipynb:
- EfficientNet-B4 feature extractor (1792-dim embeddings)
- CDR features (4-dim: vertical, horizontal, area, mean)
- Quality Score (1-dim)
- Fused feature vector (1797-dim) β†’ XGBoost or MLP or Ensemble
Feature fusion: [CNN(1792) | CDR(4) | QS(1)] = 1797 dims
Ensemble: 0.5 * XGBoost + 0.5 * MLP (from ensemble_config.json)
"""
import os
import json
import pickle
import torch
import torch.nn as nn
import torchvision.models as models
import numpy as np
import pandas as pd
import xgboost as xgb
from typing import Optional
# ─── Paths ────────────────────────────────────────────────────────────────────
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_DIR = os.path.join(BASE_DIR, 'model')
MLP_PATH = os.path.join(MODEL_DIR, 'mlp_model.pth')
XGB_PATH = os.path.join(MODEL_DIR, 'xgb_model.json')
CONFIG_PATH = os.path.join(MODEL_DIR, 'ensemble_config.json')
SCALER_PATH = os.path.join(MODEL_DIR, 'scaler.pkl')
COLUMNS_PATH = os.path.join(MODEL_DIR, 'feature_columns.pkl')
CNN_FEATURE_DIM = 1792
CDR_DIM = 4
QS_DIM = 1
INPUT_DIM = CNN_FEATURE_DIM + CDR_DIM + QS_DIM # 1797
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# ─── Singleton model holders ──────────────────────────────────────────────────
_efficientnet = None
_mlp_model = None
_xgb_model = None
_ensemble_cfg = None
_scaler = None
_scaler_loaded = False
_feature_cols = None
_cols_loaded = False
# ─── MLP Classifier (exact architecture from notebook Cell 8) ─────────────────
class MLPClassifier(nn.Module):
def __init__(self, input_dim: int = INPUT_DIM):
super().__init__()
self.network = nn.Sequential(
nn.Linear(input_dim, 256),
nn.BatchNorm1d(256),
nn.ReLU(),
nn.Dropout(0.4),
nn.Linear(256, 128),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(128, 32),
nn.BatchNorm1d(32),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(32, 1),
nn.Sigmoid()
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.network(x).squeeze(1)
# ─── Model Loaders ────────────────────────────────────────────────────────────
def load_efficientnet() -> nn.Module:
global _efficientnet
if _efficientnet is None:
print("[InferenceEngine] Loading EfficientNet-B4 (ImageNet pretrained)...")
net = models.efficientnet_b4(weights='DEFAULT')
net.classifier = nn.Identity() # output 1792-dim embeddings
net = net.to(DEVICE)
net.eval()
_efficientnet = net
return _efficientnet
def load_mlp() -> MLPClassifier:
global _mlp_model
if _mlp_model is None:
print(f"[InferenceEngine] Loading MLP from {MLP_PATH}...")
model = MLPClassifier(INPUT_DIM)
if os.path.exists(MLP_PATH):
state = torch.load(MLP_PATH, map_location=DEVICE)
model.load_state_dict(state)
print(" βœ“ MLP weights loaded")
else:
print(" ⚠ MLP file not found β€” using random weights (demo mode)")
model = model.to(DEVICE)
_mlp_model = model
return _mlp_model
def load_xgboost() -> Optional[xgb.XGBClassifier]:
global _xgb_model
if _xgb_model is None:
if os.path.exists(XGB_PATH):
print(f"[InferenceEngine] Loading XGBoost from {XGB_PATH}...")
_xgb_model = xgb.XGBClassifier()
_xgb_model.load_model(XGB_PATH)
print(" βœ“ XGBoost loaded")
else:
print(" ⚠ XGBoost file not found β€” demo mode")
_xgb_model = None
return _xgb_model
def load_ensemble_config() -> dict:
global _ensemble_cfg
if _ensemble_cfg is None:
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r') as f:
_ensemble_cfg = json.load(f)
else:
_ensemble_cfg = {'xgb_weight': 0.5, 'mlp_weight': 0.5, 'best_threshold': 0.5}
return _ensemble_cfg
def load_scaler():
"""Load StandardScaler from model/scaler.pkl."""
global _scaler, _scaler_loaded
if not _scaler_loaded:
_scaler_loaded = True
if os.path.exists(SCALER_PATH):
with open(SCALER_PATH, 'rb') as f:
_scaler = pickle.load(f)
print(f"[InferenceEngine] βœ“ StandardScaler loaded from {SCALER_PATH}")
else:
print(f"[InferenceEngine] ⚠ scaler.pkl not found at {SCALER_PATH}")
return _scaler
def load_feature_columns():
"""Load list of 1797 feature names from model/feature_columns.pkl."""
global _feature_cols, _cols_loaded
if not _cols_loaded:
_cols_loaded = True
if os.path.exists(COLUMNS_PATH):
with open(COLUMNS_PATH, 'rb') as f:
_feature_cols = pickle.load(f)
print(f"[InferenceEngine] βœ“ Feature columns loaded ({len(_feature_cols)})")
else:
print(f"[InferenceEngine] ⚠ feature_columns.pkl not found!")
return _feature_cols
def scale_features(fused: np.ndarray) -> np.ndarray:
"""
Apply StandardScaler to fused features (1797-dim).
Uses pandas DataFrame with explicit column names to match notebook.
"""
scaler = load_scaler()
cols = load_feature_columns()
if scaler is not None and cols is not None:
# Create DF to ensure consistency with notebook
X = pd.DataFrame([fused], columns=cols)
scaled = scaler.transform(X)
return scaled.flatten().astype(np.float32)
# Fallback to numpy scaling if cols not available
if scaler is not None:
return scaler.transform(fused.reshape(1, -1)).flatten().astype(np.float32)
return fused.astype(np.float32)
# ─── Feature Extraction ───────────────────────────────────────────────────────
def extract_cnn_features(img_tensor: torch.Tensor) -> np.ndarray:
"""
Extract 1792-dim features from EfficientNet-B4.
img_tensor: shape (C, H, W) float32, ImageNet normalized.
"""
net = load_efficientnet()
with torch.no_grad():
inp = img_tensor.unsqueeze(0).to(DEVICE) # (1, C, H, W)
feats = net(inp) # (1, 1792)
return feats.cpu().numpy().flatten() # (1792,)
# ─── Feature Fusion (from notebook Section 5.6) ───────────────────────────────
def fuse_features(cnn_feats: np.ndarray,
cdr: dict,
quality_score: float) -> np.ndarray:
"""
Fuse CNN embedding + CDR features + Quality Score.
Exact fusion: np.hstack([cnn, cdr, qs]) β†’ 1797-dim
"""
cdr_vec = np.array([
cdr['vertical_cdr'],
cdr['horizontal_cdr'],
cdr['area_cdr'],
cdr['mean_cdr'],
], dtype=np.float32)
qs_vec = np.array([quality_score], dtype=np.float32)
fused = np.hstack([
cnn_feats.astype(np.float32),
cdr_vec,
qs_vec,
]) # shape (1797,)
return fused
# ─── Inference Functions ──────────────────────────────────────────────────────
def predict_xgboost(fused_features: np.ndarray) -> float:
"""XGBoost probability prediction using DataFrame with column names."""
xgb_model = load_xgboost()
cols = load_feature_columns()
if xgb_model is None:
return float(np.random.uniform(0.3, 0.7))
if cols is not None:
# Use DataFrame for XGBoost to ensure feature name matching
X = pd.DataFrame([fused_features], columns=cols)
prob = xgb_model.predict_proba(X)[0, 1]
else:
# Fallback to numpy
X = fused_features.reshape(1, -1)
prob = xgb_model.predict_proba(X)[0, 1]
return float(prob)
def predict_mlp(fused_features: np.ndarray) -> float:
"""MLP deterministic prediction (dropout disabled)."""
mlp = load_mlp()
mlp.eval()
with torch.no_grad():
x = torch.FloatTensor(fused_features).unsqueeze(0).to(DEVICE)
prob = mlp(x).cpu().item()
return float(prob)
def get_mlp_tensor(fused_features: np.ndarray) -> torch.Tensor:
"""Return fused features as tensor for MC Dropout."""
return torch.FloatTensor(fused_features).unsqueeze(0)
def predict_ensemble(xgb_prob: float, mlp_prob: float) -> tuple:
"""
Soft voting: ensemble_prob = w_xgb * xgb_prob + w_mlp * mlp_prob
Uses weights from ensemble_config.json.
"""
cfg = load_ensemble_config()
w_xgb = cfg.get('xgb_weight', 0.5)
w_mlp = cfg.get('mlp_weight', 0.5)
threshold = cfg.get('best_threshold', 0.5)
ens_prob = w_xgb * xgb_prob + w_mlp * mlp_prob
return float(ens_prob), float(threshold)
def classify_probability(probability: float, threshold: float = 0.5) -> dict:
"""Convert probability to clinical classification."""
predicted_label = 'GLAUCOMA' if probability >= threshold else 'NORMAL'
confidence = probability if predicted_label == 'GLAUCOMA' else (1.0 - probability)
return {
'label': predicted_label,
'is_glaucoma': predicted_label == 'GLAUCOMA',
'probability': round(probability, 4),
'confidence': round(confidence, 4),
'threshold': round(threshold, 4),
}