Tri-Netra-AI / scripts /retrain_classifiers_on_v8.py
anannyavyas1's picture
Upload folder using huggingface_hub
1cf3825 verified
Raw
History Blame Contribute Delete
14.5 kB
"""Retrain CNN / Transfer / ViT classifiers on dataset_v8.
Why this exists (the brutal version): the live classifiers were trained
on Kaggle 4-class only and hit 0%-67% recall on OOD tumors (see
scripts/eval_ood_classifiers_brutal.py output). dataset_v8 is the same
distribution the v8 *segmenter* trains on (BraTS T1c + LGG + Figshare +
Kaggle 4-class neg), so retraining the classifiers there closes the gap
between segmenter generalisation and classifier generalisation.
What's in / not in:
- IN (train/val/test): dataset_v8/{train,val,test} ONLY.
- NOT IN: samples/ood/* — those are held out for the final production
accuracy number, on the user's explicit instruction.
Labels derived from masks: a sample is tumor iff its mask has >= 50
tumor pixels (matches MIN_TUMOR_AREA used everywhere else in the repo).
Outputs to real_eval_v8_retrained/<model>/{best_weights.pt, best_weights.onnx}.
The dashboard's _classifier_dir() resolver checks real_eval_v8_retrained/
first when it exists, so a successful run flips production automatically.
Run:
python scripts/retrain_classifiers_on_v8.py
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
import cv2
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from src.classifier_torch import get_classifier # noqa: E402
MIN_TUMOR_AREA = 50
IMAGE_SIZE = 224
IM_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IM_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
class MaskDerivedClassificationDataset(Dataset):
"""Reads dataset_v8/<split>/images/*.png + masks/*.png.
Label: 1 if mask has >= MIN_TUMOR_AREA tumor pixels, else 0.
Caches the per-image label once at __init__ so the per-getitem hot
path only reads the image.
"""
def __init__(self, split_dir: Path, image_size: int = IMAGE_SIZE,
normalize_imagenet: bool = False, train: bool = True):
self.image_size = image_size
self.normalize_imagenet = normalize_imagenet
self.train = train
self.images_dir = Path(split_dir) / 'images'
self.masks_dir = Path(split_dir) / 'masks'
if not self.images_dir.exists():
raise FileNotFoundError(f'no images dir at {self.images_dir}')
if not self.masks_dir.exists():
raise FileNotFoundError(f'no masks dir at {self.masks_dir}')
# Pre-compute labels (cheap: a single np.sum per mask).
entries = []
n_tum = n_neg = 0
for img_path in sorted(self.images_dir.glob('*.png')):
mask_path = self.masks_dir / img_path.name
if not mask_path.exists():
continue
m = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) # noqa: F811
label = 1.0 if int((m > 127).sum()) >= MIN_TUMOR_AREA else 0.0
entries.append((img_path, label))
if label == 1.0: n_tum += 1
else: n_neg += 1
self.entries = entries
self.n_tumor = n_tum
self.n_no_tumor = n_neg
print(f' [dataset] {split_dir.name:5s}: total={len(entries):5d} '
f'tumor={n_tum:5d} no_tumor={n_neg:5d} '
f'class_balance={n_tum/max(len(entries),1):.1%} positive')
def __len__(self):
return len(self.entries)
def __getitem__(self, idx):
path, label = self.entries[idx]
img = cv2.imread(str(path))
if img is None:
raise RuntimeError(f'failed to read {path}')
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
if img.shape[0] != self.image_size or img.shape[1] != self.image_size:
img = cv2.resize(img, (self.image_size, self.image_size))
if self.train and np.random.rand() < 0.5:
img = np.ascontiguousarray(img[:, ::-1])
img = img.astype(np.float32) / 255.0
if self.normalize_imagenet:
img = (img - IM_MEAN) / IM_STD
img = img.transpose(2, 0, 1)
return torch.from_numpy(img), torch.tensor(label, dtype=torch.float32)
def evaluate(model: nn.Module, loader: DataLoader, device, threshold: float = 0.5) -> dict:
model.eval()
y_true, y_pred_prob, y_pred_bin = [], [], []
bce_total, n = 0.0, 0
with torch.no_grad():
for x, y in loader:
x = x.to(device, non_blocking=True)
y = y.to(device, non_blocking=True)
logits = model(x).squeeze(-1)
probs = torch.sigmoid(logits)
bce_total += F.binary_cross_entropy_with_logits(logits, y, reduction='sum').item()
y_true.extend(y.cpu().numpy().tolist())
y_pred_prob.extend(probs.cpu().numpy().tolist())
y_pred_bin.extend((probs >= threshold).float().cpu().numpy().tolist())
n += y.shape[0]
y_true = np.asarray(y_true); y_pred_bin = np.asarray(y_pred_bin); y_pred_prob = np.asarray(y_pred_prob)
tp = int(((y_true==1)&(y_pred_bin==1)).sum()); fp = int(((y_true==0)&(y_pred_bin==1)).sum())
fn = int(((y_true==1)&(y_pred_bin==0)).sum()); tn = int(((y_true==0)&(y_pred_bin==0)).sum())
accuracy = (tp+tn)/max(n,1); precision = tp/max(tp+fp,1)
recall = tp/max(tp+fn,1); f1 = 2*precision*recall/max(precision+recall,1e-9)
try:
from sklearn.metrics import roc_auc_score
roc_auc = float(roc_auc_score(y_true, y_pred_prob)) if len(set(y_true)) > 1 else float('nan')
except Exception:
roc_auc = float('nan')
return {'n': n, 'accuracy': accuracy, 'precision': precision, 'recall': recall,
'f1': f1, 'roc_auc': roc_auc,
'confusion_matrix': {'tn': tn, 'fp': fp, 'fn': fn, 'tp': tp},
'bce_loss_mean': bce_total/max(n,1)}
def export_onnx(model: nn.Module, save_path: Path, device):
"""Export to ONNX. Redirects the exporter's emoji-heavy stdout/stderr
into an in-memory StringIO so it never hits the parent process's
cp1252 console (which would crash on the success checkmark)."""
import io
import contextlib
model.eval()
dummy = torch.randn(1, 3, IMAGE_SIZE, IMAGE_SIZE, device=device)
buf = io.StringIO()
ok = False
err: Exception | None = None
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
torch.onnx.export(
model, dummy, str(save_path),
input_names=['input'], output_names=['output'],
dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}},
opset_version=17,
)
ok = True
except Exception as exc:
err = exc
if ok:
print(f' -> exported ONNX: {save_path} ({save_path.stat().st_size/1e6:.1f} MB)')
else:
print(f' ONNX export failed (continuing): {type(err).__name__}: {err}')
def train_one(model_name: str, args) -> dict:
print(f'\n========== training {model_name} on dataset_v8 ==========', flush=True)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'[{model_name}] device={device}'
+ (f' ({torch.cuda.get_device_name(0)})' if device.type == 'cuda' else ''), flush=True)
normalize = (model_name != 'cnn')
train_ds = MaskDerivedClassificationDataset(Path(args.dataset) / 'train',
normalize_imagenet=normalize, train=True)
val_ds = MaskDerivedClassificationDataset(Path(args.dataset) / 'val',
normalize_imagenet=normalize, train=False)
test_ds = MaskDerivedClassificationDataset(Path(args.dataset) / 'test',
normalize_imagenet=normalize, train=False)
common = dict(batch_size=args.batch_size, num_workers=args.num_workers,
pin_memory=(device.type == 'cuda'))
train_loader = DataLoader(train_ds, shuffle=True, drop_last=False, **common)
val_loader = DataLoader(val_ds, shuffle=False, **common)
test_loader = DataLoader(test_ds, shuffle=False, **common)
model = get_classifier(model_name).to(device)
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f'[{model_name}] trainable params: {n_params:,}', flush=True)
optimizer = torch.optim.Adam([p for p in model.parameters() if p.requires_grad],
lr=args.learning_rate)
scaler = torch.amp.GradScaler('cuda', enabled=(device.type == 'cuda'))
# Compute pos_weight to balance the BCE loss against class imbalance.
# pos_weight = n_neg / n_pos. When pos_weight < 1 we downweight the
# majority (positives, after OpenNeuro augmentation); when > 1 we
# upweight the minority. Without this the previous round learned a
# positive bias and false-alarmed on all OOD healthy brains.
pw = float(train_ds.n_no_tumor) / max(train_ds.n_tumor, 1)
pw_tensor = torch.tensor([pw], device=device, dtype=torch.float32)
print(f'[{model_name}] pos_weight = n_neg/n_pos = '
f'{train_ds.n_no_tumor}/{train_ds.n_tumor} = {pw:.4f}', flush=True)
out_dir = ROOT / args.output / model_name
out_dir.mkdir(parents=True, exist_ok=True)
best_path = out_dir / 'best_weights.pt'
onnx_path = out_dir / 'best_weights.onnx'
best_val_acc = -1.0
epochs_without_improve = 0
for epoch in range(args.epochs):
t0 = time.time()
model.train()
if hasattr(model, 'backbone'):
for m in model.backbone.modules():
if isinstance(m, nn.BatchNorm2d):
m.eval()
running_loss = 0.0; running_correct = 0; running_n = 0
for x, y in train_loader:
x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.amp.autocast('cuda', enabled=(device.type == 'cuda')):
logits = model(x).squeeze(-1)
loss = F.binary_cross_entropy_with_logits(logits, y, pos_weight=pw_tensor)
if device.type == 'cuda':
scaler.scale(loss).backward(); scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer); scaler.update()
else:
loss.backward(); optimizer.step()
running_loss += float(loss) * x.size(0)
preds = (torch.sigmoid(logits) >= 0.5).float()
running_correct += int((preds == y).sum().item())
running_n += x.size(0)
train_loss = running_loss/max(running_n,1); train_acc = running_correct/max(running_n,1)
val_metrics = evaluate(model, val_loader, device)
val_acc = val_metrics['accuracy']; val_loss = val_metrics['bce_loss_mean']
elapsed = time.time() - t0
print(f'[{model_name}][ep {epoch+1:02d}/{args.epochs}] '
f'train_loss={train_loss:.4f} train_acc={train_acc:.4f} '
f'val_loss={val_loss:.4f} val_acc={val_acc:.4f} '
f'val_recall={val_metrics["recall"]:.4f} ({elapsed:.1f}s)', flush=True)
if val_acc > best_val_acc:
best_val_acc = val_acc
epochs_without_improve = 0
torch.save({'state_dict': model.state_dict(), 'model_name': model_name,
'val_metrics': val_metrics, 'epoch': epoch+1,
'normalize_imagenet': normalize}, best_path)
print(f' -> new best val_acc={best_val_acc:.4f}; saved {best_path}', flush=True)
else:
epochs_without_improve += 1
if epochs_without_improve >= args.patience:
print(f'[{model_name}] early stopping at ep {epoch+1}', flush=True)
break
# Final: load best, eval test, export ONNX
ckpt = torch.load(str(best_path), map_location=device, weights_only=False)
model.load_state_dict(ckpt['state_dict'])
test_metrics = evaluate(model, test_loader, device)
print(f'[{model_name}] TEST: acc={test_metrics["accuracy"]:.4f} '
f'recall={test_metrics["recall"]:.4f} precision={test_metrics["precision"]:.4f} '
f'f1={test_metrics["f1"]:.4f} roc_auc={test_metrics["roc_auc"]:.4f}', flush=True)
final = {'val': evaluate(model, val_loader, device), 'test': test_metrics}
(ROOT / args.output / f'{model_name}_evaluation_metrics.json').write_text(
json.dumps(final, indent=2), encoding='utf-8')
export_onnx(model, onnx_path, device)
return final
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--dataset', default='dataset_v8')
parser.add_argument('--output', default='real_eval_v8_retrained')
parser.add_argument('--models', nargs='+', default=['cnn', 'transfer', 'vit'],
choices=['cnn', 'transfer', 'vit'])
parser.add_argument('--epochs', type=int, default=10)
parser.add_argument('--batch_size', type=int, default=48)
parser.add_argument('--learning_rate', type=float, default=1e-4)
parser.add_argument('--num_workers', type=int, default=4)
parser.add_argument('--patience', type=int, default=3)
parser.add_argument('--seed', type=int, default=42)
args = parser.parse_args()
torch.manual_seed(args.seed); np.random.seed(args.seed)
print(f'[init] cuda={torch.cuda.is_available()} dataset={args.dataset} output={args.output}')
print(f'[init] models={args.models} epochs={args.epochs} batch={args.batch_size}')
print(f'[init] NOT touching samples/ood/* — those are held out for production accuracy.')
results = {}
t_total = time.time()
for m in args.models:
results[m] = train_one(m, args)
print(f'\n[done] all classifiers trained in {(time.time()-t_total)/60:.1f} min')
print('\nSUMMARY (test split, dataset_v8):')
for m, r in results.items():
t = r['test']
print(f' {m:10s} acc={t["accuracy"]:.4f} recall={t["recall"]:.4f} '
f'precision={t["precision"]:.4f} f1={t["f1"]:.4f} auc={t["roc_auc"]:.4f}')
if __name__ == '__main__':
main()