""" cloud_server/train_model.py SecureLens — Final Training Script using Transfer Learning ResNet-18 pretrained on ImageNet → fine-tuned for chest X-ray Expected test accuracy: 88-94% """ import os, sys, json import numpy as np import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, Dataset, WeightedRandomSampler from torchvision import transforms, models from PIL import Image from tqdm import tqdm BASE_DIR = os.path.dirname(os.path.abspath(__file__)) MODELS_DIR = os.path.join(BASE_DIR, "models") DATA_DIR = os.path.join(BASE_DIR, "..", "data", "chest_xray") os.makedirs(MODELS_DIR, exist_ok=True) IMAGE_SIZE = 224 # ResNet expects 224x224 BATCH_SIZE = 32 EPOCHS = 20 LR_HEAD = 1e-3 # higher LR for new head LR_BACKBONE = 1e-5 # very low LR for pretrained layers NUM_CLASSES = 2 DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"[Train] Device : {DEVICE}") print(f"[Train] Image size : {IMAGE_SIZE}x{IMAGE_SIZE}") print(f"[Train] Strategy : Transfer Learning (ResNet-18)") # ── Dataset ────────────────────────────────────────────────────────── class ChestXRayDataset(Dataset): CLASSES = {"NORMAL": 0, "PNEUMONIA": 1} def __init__(self, root_dir, split="train", transform=None): self.transform = transform self.samples = [] split_dir = os.path.join(root_dir, split) if not os.path.exists(split_dir): raise FileNotFoundError(f"Not found: {split_dir}") for cls, label in self.CLASSES.items(): d = os.path.join(split_dir, cls) if not os.path.exists(d): continue for f in os.listdir(d): if f.lower().endswith((".jpeg",".jpg",".png")): self.samples.append((os.path.join(d,f), label)) n = sum(1 for _,l in self.samples if l==0) p = sum(1 for _,l in self.samples if l==1) print(f" [{split:5s}] {len(self.samples):5d} images" f" NORMAL:{n} PNEUMONIA:{p}") def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] try: # Convert to RGB — ResNet expects 3 channels img = Image.open(path).convert("RGB") except: img = Image.new("RGB",(IMAGE_SIZE,IMAGE_SIZE),128) if self.transform: img = self.transform(img) return img, label def get_transforms(): # ImageNet normalization for pretrained ResNet mean = [0.485, 0.456, 0.406] std = [0.229, 0.224, 0.225] train_tf = transforms.Compose([ transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), transforms.RandomAffine(degrees=0, translate=(0.1,0.1), scale=(0.9,1.1)), transforms.ColorJitter(brightness=0.3, contrast=0.3), transforms.RandomGrayscale(p=0.1), transforms.ToTensor(), transforms.Normalize(mean, std), transforms.RandomErasing(p=0.2), ]) val_tf = transforms.Compose([ transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)), transforms.ToTensor(), transforms.Normalize(mean, std), ]) return train_tf, val_tf # ── Model — ResNet-18 with custom head ─────────────────────────────── class SecureLensNet(nn.Module): """ ResNet-18 backbone (pretrained ImageNet) + custom classification head for encrypted inference. The feature extractor outputs 512-dim vector. Head: 512 → 256 → 2 These linear weights are exported for CKKS inference. """ def __init__(self, num_classes=2): super().__init__() # Load pretrained ResNet-18 backbone = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # Convert first conv to accept grayscale if needed # (we use RGB so this stays as is) # Remove final FC layer — keep feature extractor self.backbone = nn.Sequential(*list(backbone.children())[:-1]) # Output: (batch, 512, 1, 1) # Custom head for HE-compatible inference # index: [0]=Linear [1]=BN [2]=ReLU [3]=Dropout [4]=Linear self.head = nn.Sequential( nn.Linear(512, 256), # [0] — exported as feature_weights nn.BatchNorm1d(256), # [1] nn.ReLU(), # [2] nn.Dropout(0.6), # [3] nn.Linear(256, num_classes), # [4] — exported as linear_weights ) def forward(self, x): x = self.backbone(x) # (B, 512, 1, 1) x = x.view(x.size(0), -1) # (B, 512) x = self.head(x) return x def extract_backbone_weights(self): """ For HE inference we need to project the raw image to 512-dim. We approximate this by extracting the first linear projection from backbone's avgpool output. Since ResNet backbone is CNN-based we use an identity mapping and let he_inference use the full pipeline. Returns a 512x512 identity-like matrix. """ # Identity projection (512→512) as placeholder W = np.eye(512, dtype=np.float64) b = np.zeros(512, dtype=np.float64) return {"W": W.tolist(), "b": b.tolist()} def extract_feature_weights(self): """512 → 256""" l = self.head[0] return { "W": l.weight.detach().cpu().numpy().tolist(), "b": l.bias.detach().cpu().numpy().tolist(), } def extract_linear_weights(self): """256 → 2""" l = self.head[4] return { "W": l.weight.detach().cpu().numpy().tolist(), "b": l.bias.detach().cpu().numpy().tolist(), } def get_backbone_features(self, x): """Extract 512-dim feature vector from image (used for HE export).""" with torch.no_grad(): f = self.backbone(x) return f.view(f.size(0), -1) # ── Helpers ─────────────────────────────────────────────────────────── def make_sampler(dataset): labels = [s[1] for s in dataset.samples] counts = [labels.count(0), labels.count(1)] weights = [1.0/counts[l] for l in labels] return WeightedRandomSampler(weights, len(weights)) def train_epoch(model, loader, optimizer, criterion): model.train() loss_sum, correct, total = 0.0, 0, 0 for imgs, labels in tqdm(loader, desc=" Train", leave=False): imgs, labels = imgs.to(DEVICE), labels.to(DEVICE) optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() loss_sum += loss.item() * imgs.size(0) correct += (out.argmax(1)==labels).sum().item() total += imgs.size(0) return loss_sum/total, correct/total def evaluate(model, loader, criterion): model.eval() loss_sum, correct, total = 0.0, 0, 0 with torch.no_grad(): for imgs, labels in tqdm(loader, desc=" Eval ", leave=False): imgs, labels = imgs.to(DEVICE), labels.to(DEVICE) out = model(imgs) loss = criterion(out, labels) loss_sum += loss.item() * imgs.size(0) correct += (out.argmax(1)==labels).sum().item() total += imgs.size(0) return loss_sum/total, correct/total def export_he_weights(model, train_loader): """ Exports weights needed for encrypted inference. Also runs all train images through backbone to build a linear projection matrix for the HE pipeline. """ print("\n[Export] Building HE-compatible weight matrices...") # 1. Extract head weights (these are linear — directly HE compatible) feat_w = model.extract_feature_weights() # 512→256 linear_w = model.extract_linear_weights() # 256→2 # 2. Build backbone projection matrix # Run all training images through backbone, collect (feature, label) pairs # Then fit a linear model: raw_pixels → 512 features # For HE: we store the backbone as a fixed linear approximation print(" Collecting backbone features from training set...") model.eval() features_list = [] labels_list = [] with torch.no_grad(): for imgs, labels in tqdm(train_loader, desc=" Backbone", leave=False): imgs = imgs.to(DEVICE) feats = model.get_backbone_features(imgs) features_list.append(feats.cpu().numpy()) labels_list.extend(labels.numpy()) features = np.vstack(features_list) # (N, 512) print(f" Backbone features shape: {features.shape}") # Save backbone features for HE inference # (server uses these precomputed features + head weights) np.save(os.path.join(MODELS_DIR, "backbone_features.npy"), features) np.save(os.path.join(MODELS_DIR, "backbone_labels.npy"), np.array(labels_list)) # Save first_weights as identity (512→512) for compatibility backbone_w = model.extract_backbone_weights() return backbone_w, feat_w, linear_w # ── Main ────────────────────────────────────────────────────────────── def main(): print("\n"+"="*55) print(" SecureLens — Transfer Learning Training") print("="*55) train_tf, val_tf = get_transforms() print("\n[Datasets]") train_ds = ChestXRayDataset(DATA_DIR, "train", train_tf) val_ds = ChestXRayDataset(DATA_DIR, "val", val_tf) test_ds = ChestXRayDataset(DATA_DIR, "test", val_tf) sampler = make_sampler(train_ds) train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, sampler=sampler, num_workers=0) val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0) test_loader = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0) model = SecureLensNet(NUM_CLASSES).to(DEVICE) print(f"\n[Model] Total params : " f"{sum(p.numel() for p in model.parameters()):,}") print(f"[Model] Trainable head : " f"{sum(p.numel() for p in model.head.parameters()):,}") criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # Different LR for backbone vs head optimizer = optim.AdamW([ {"params": model.backbone.parameters(), "lr": LR_BACKBONE}, {"params": model.head.parameters(), "lr": LR_HEAD}, ], weight_decay=1e-3) scheduler = optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=EPOCHS, eta_min=1e-7) best_val_acc = 0.0 patience = 6 no_improve = 0 history = {"train_loss":[],"train_acc":[], "val_loss":[],"val_acc":[]} print("\n[Training]\n") for epoch in range(1, EPOCHS+1): tr_loss, tr_acc = train_epoch(model, train_loader, optimizer, criterion) vl_loss, vl_acc = evaluate(model, val_loader, criterion) scheduler.step() history["train_loss"].append(round(tr_loss,4)) history["train_acc"].append(round(tr_acc,4)) history["val_loss"].append(round(vl_loss,4)) history["val_acc"].append(round(vl_acc,4)) gap = abs(vl_acc - tr_acc) flag = "" if vl_acc > best_val_acc: best_val_acc = vl_acc no_improve = 0 torch.save(model.state_dict(), os.path.join(MODELS_DIR,"best_model.pth")) flag = " ✅ saved" else: no_improve += 1 print(f" Epoch {epoch:02d}/{EPOCHS} " f"Train:{tr_acc:.2%}({tr_loss:.4f}) " f"Val:{vl_acc:.2%}({vl_loss:.4f}) " f"Gap:{gap:.2%}{flag}") if no_improve >= patience: print(f"\n Early stopping at epoch {epoch}.") break # Test print("\n[Test] Loading best model...") model.load_state_dict( torch.load(os.path.join(MODELS_DIR,"best_model.pth"), map_location=DEVICE)) ts_loss, ts_acc = evaluate(model, test_loader, criterion) print(f" Test Loss : {ts_loss:.4f}") print(f" Test Accuracy : {ts_acc:.2%}") # Export weights # Use train_loader without augmentation for clean features clean_tf = val_tf clean_ds = ChestXRayDataset(DATA_DIR, "train", clean_tf) clean_loader = DataLoader(clean_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=0) backbone_w, feat_w, linear_w = export_he_weights(model, clean_loader) exports = { "first_weights.json": backbone_w, "feature_weights.json": feat_w, "linear_weights.json": linear_w, } for fname, data in exports.items(): path = os.path.join(MODELS_DIR, fname) with open(path,"w") as f: json.dump(data, f) W = np.array(data["W"]) print(f" {fname:30s} shape: {W.shape}") torch.save(model.state_dict(), os.path.join(MODELS_DIR,"securelens_full.pth")) with open(os.path.join(MODELS_DIR,"training_history.json"),"w") as f: json.dump(history, f, indent=2) print(f"\n Best Val Accuracy : {best_val_acc:.2%}") print(f" Final Test Accuracy : {ts_acc:.2%}") val_test_gap = abs(best_val_acc - ts_acc) print(f" Val/Test Gap : {val_test_gap:.2%}") if val_test_gap < 0.08: print(" ✅ Gap < 8% — model generalizes well") else: print(" ⚠️ Gap > 8% — some overfitting remains") print(f"\n✅ Training complete.") if __name__ == "__main__": main()