Spaces:
Sleeping
Sleeping
| """ | |
| 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() |