{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"datasetVersion","sourceId":8805089}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# === Cell 0: Load credentials from Kaggle Secrets (Add-ons -> Secrets) ===  \nimport os  \nfrom kaggle_secrets import UserSecretsClient  \n  \nsecrets = UserSecretsClient()  \n  \n# W&B (optional but recommended). If the secret is missing, training still runs offline.  \ntry:  \n    os.environ[\"WANDB_API_KEY\"] = secrets.get_secret(\"WANDB_API_KEY\")  \n    print(\"WANDB_API_KEY loaded from Secrets.\")  \nexcept Exception:  \n    os.environ[\"WANDB_DISABLED\"] = \"true\"  \n    print(\"No WANDB_API_KEY secret found -> W&B disabled (training continues).\")  \n  \n# GitHub token (only needed if you push from the notebook; not required to produce the .pth)  \ntry:  \n    os.environ[\"GITHUB_TOKEN\"] = secrets.get_secret(\"GITHUB_TOKEN\")  \n    print(\"GITHUB_TOKEN loaded from Secrets.\")  \nexcept Exception:  \n    print(\"No GITHUB_TOKEN secret found -> skipping any git push steps.\")  \n  \n# IMPORTANT: nothing below calls input()/getpass(), so Commit runs never hang.","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T21:57:26.366387Z","iopub.execute_input":"2026-09-06T21:57:26.367057Z","iopub.status.idle":"2026-09-06T21:57:26.507725Z","shell.execute_reply.started":"2026-09-06T21:57:26.367001Z","shell.execute_reply":"2026-09-06T21:57:26.507057Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%writefile data_prep.py  \nimport os, glob, argparse  \nimport pandas as pd  \nfrom sklearn.model_selection import StratifiedGroupKFold  \n  \n# --- Only read mounted, read-only data. NEVER kagglehub-download (quota/wipe risk). ---  \nINPUT_ROOT = \"/kaggle/input/datasets\"  \nMALIGNANT = {\"MEL\"}   # melanoma-only, matches backend LABEL_MAP {1: \"Melanoma\"}  \n  \ndef _index_images(root):  \n    \"\"\"Map image basename (no ext) -> absolute path, for all jpg/png under root.\"\"\"  \n    idx = {}  \n    for ext in (\"*.jpg\", \"*.jpeg\", \"*.png\"):  \n        for p in glob.glob(os.path.join(root, \"**\", ext), recursive=True):  \n            idx[os.path.splitext(os.path.basename(p))[0]] = p  \n    return idx  \n  \ndef load_isic2019():  \n    base = f\"{INPUT_ROOT}/andrewmvd/isic-2019\"  \n    gt = pd.read_csv(f\"{base}/ISIC_2019_Training_GroundTruth.csv\")  \n    imgs = _index_images(base)  \n    df = pd.DataFrame({  \n        \"filepath\": gt[\"image\"].map(imgs),  \n        \"label\": (gt[\"MEL\"] == 1).astype(int),  \n        \"group\": gt[\"image\"],           # no lesion_id in GT -> per-image group  \n        \"source\": \"isic2019\",  \n    }).dropna(subset=[\"filepath\"])  \n    return df  \n  \ndef load_isic2020():  \n    base = f\"{INPUT_ROOT}/nischaydnk/isic-2020-jpg-224x224-resized\"  \n    meta = pd.read_csv(f\"{base}/train-metadata.csv\")  \n    imgs = _index_images(base)  \n    df = pd.DataFrame({  \n        \"filepath\": meta[\"isic_id\"].map(imgs),  \n        \"label\": meta[\"target\"].astype(int),  \n        \"group\": meta[\"patient_id\"].fillna(meta[\"isic_id\"]),  \n        \"source\": \"isic2020\",  \n    }).dropna(subset=[\"filepath\"])  \n    return df  \n  \ndef load_padufes():  \n    base = f\"{INPUT_ROOT}/mahdavi1202/skin-cancer\"  \n    meta = pd.read_csv(f\"{base}/metadata.csv\")  \n    imgs = _index_images(base)  \n    df = pd.DataFrame({  \n        \"filepath\": meta[\"img_id\"].map(lambda s: imgs.get(os.path.splitext(str(s))[0])),  \n        \"label\": meta[\"diagnostic\"].astype(str).str.upper().isin(MALIGNANT).astype(int),  \n        \"group\": meta[\"patient_id\"].fillna(meta[\"lesion_id\"]).fillna(meta[\"img_id\"]),  \n        \"source\": \"padufes\",  \n    }).dropna(subset=[\"filepath\"])  \n    return df  \n  \ndef main():  \n    ap = argparse.ArgumentParser()  \n    ap.add_argument(\"--random-state\", type=int, default=42)  \n    args = ap.parse_args()  \n  \n    frames = []  \n    for name, fn in [(\"ISIC2019\", load_isic2019), (\"ISIC2020\", load_isic2020), (\"PAD-UFES\", load_padufes)]:  \n        df = fn()  \n        assert len(df) > 0, f\"{name}: 0 rows matched — check /kaggle/input layout\"  \n        print(f\"{name}: {len(df)} rows, mal={df.label.mean()*100:.2f}%\")  \n        frames.append(df)  \n  \n    merged = pd.concat(frames, ignore_index=True)  \n    print(f\"MERGED: {len(merged)} rows, mal={merged.label.mean()*100:.2f}%\")  \n  \n    # Patient-level split, leakage-free: 6 folds ~ test 16.7%, then calib from train.  \n    sgkf = StratifiedGroupKFold(n_splits=6, shuffle=True, random_state=args.random_state)  \n    tr_idx, te_idx = next(sgkf.split(merged, merged.label, merged.group))  \n    test_df = merged.iloc[te_idx].reset_index(drop=True)  \n    trainpool = merged.iloc[tr_idx].reset_index(drop=True)  \n  \n    sgkf2 = StratifiedGroupKFold(n_splits=6, shuffle=True, random_state=args.random_state)  \n    tr2, ca2 = next(sgkf2.split(trainpool, trainpool.label, trainpool.group))  \n    train_df = trainpool.iloc[tr2].reset_index(drop=True)  \n    calib_df = trainpool.iloc[ca2].reset_index(drop=True)  \n  \n    os.makedirs(\"data\", exist_ok=True)  \n    train_df.to_csv(\"data/train_df.csv\", index=False)  \n    calib_df.to_csv(\"data/calib_df.csv\", index=False)  \n    test_df.to_csv(\"data/test_df.csv\", index=False)  \n    print(f\"train_df: {len(train_df)} (mal {train_df.label.mean()*100:.2f}%)\")  \n    print(f\"calib_df: {len(calib_df)} (mal {calib_df.label.mean()*100:.2f}%)\")  \n    print(f\"test_df: {len(test_df)} (mal {test_df.label.mean()*100:.2f}%)\")  \n  \nif __name__ == \"__main__\":  \n    main()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T21:57:26.508928Z","iopub.execute_input":"2026-09-06T21:57:26.510061Z","iopub.status.idle":"2026-09-06T21:57:26.518962Z","shell.execute_reply.started":"2026-09-06T21:57:26.510006Z","shell.execute_reply":"2026-09-06T21:57:26.518121Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%writefile model_training.py  \nimport os, argparse  \nimport numpy as np, pandas as pd  \nimport torch, torch.nn as nn  \nfrom torch.utils.data import Dataset, DataLoader  \nfrom torchvision import models, transforms  \nfrom PIL import Image  \nfrom sklearn.metrics import roc_auc_score  \n  \nTFM = transforms.Compose([  \n    transforms.Resize((224, 224)),  \n    transforms.ToTensor(),  \n    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),  \n])  \n  \nclass DF(Dataset):  \n    def __init__(self, csv, train=False):  \n        self.df = pd.read_csv(csv)  \n        self.train = train  \n        self.aug = transforms.Compose([  \n            transforms.RandomHorizontalFlip(),  \n            transforms.RandomVerticalFlip(),  \n            transforms.ColorJitter(0.1, 0.1, 0.1),  \n        ])  \n    def __len__(self): return len(self.df)  \n    def __getitem__(self, i):  \n        row = self.df.iloc[i]  \n        img = Image.open(row[\"filepath\"]).convert(\"RGB\")  \n        if self.train: img = self.aug(img)  \n        return TFM(img), torch.tensor([row[\"label\"]], dtype=torch.float32)  \n  \ndef build_backbone(pretrained=True):  \n    w = models.MobileNet_V2_Weights.IMAGENET1K_V1 if pretrained else None  \n    m = models.mobilenet_v2(weights=w)  \n    m.classifier = nn.Sequential(nn.Dropout(0.2), nn.Linear(m.last_channel, 1))  # sigmoid stripped  \n    return m  \n  \nclass FocalLoss(nn.Module):  \n    def __init__(self, alpha=0.25, gamma=2.0):  \n        super().__init__(); self.a, self.g = alpha, gamma  \n    def forward(self, logits, y):  \n        p = torch.sigmoid(logits)  \n        ce = nn.functional.binary_cross_entropy_with_logits(logits, y, reduction=\"none\")  \n        pt = p*y + (1-p)*(1-y)  \n        at = self.a*y + (1-self.a)*(1-y)  \n        return (at * (1-pt)**self.g * ce).mean()  \n  \ndef main():  \n    ap = argparse.ArgumentParser()  \n    ap.add_argument(\"--epochs\", type=int, default=12)  \n    ap.add_argument(\"--batch-size\", type=int, default=64)  \n    ap.add_argument(\"--lr\", type=float, default=1e-4)  \n    args = ap.parse_args()  \n  \n    dev = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")  \n    print(\"Device:\", dev)  \n  \n    full = pd.read_csv(\"data/train_df.csv\")  \n    n_val = int(len(full)*0.15)  \n    val = full.sample(n=n_val, random_state=42); tr = full.drop(val.index)  \n    tr.to_csv(\"data/_tr.csv\", index=False); val.to_csv(\"data/_val.csv\", index=False)  \n    print(f\"train={len(tr)} val={len(val)} mal_train={tr.label.mean()*100:.2f}%\")  \n  \n    dl_tr = DataLoader(DF(\"data/_tr.csv\", train=True),  batch_size=args.batch_size,  \n                       shuffle=True, num_workers=2, pin_memory=True)  \n    dl_va = DataLoader(DF(\"data/_val.csv\", train=False), batch_size=args.batch_size,  \n                       shuffle=False, num_workers=2, pin_memory=True)  \n  \n    # W&B only if the Secret provided a key (Cell 0). Never prompts.  \n    use_wandb = bool(os.getenv(\"WANDB_API_KEY\")) and os.getenv(\"WANDB_DISABLED\") != \"true\"  \n    if use_wandb:  \n        import wandb  \n        wandb.init(project=\"skin-cancer-absention\", job_type=\"train\")  \n  \n    model = build_backbone(pretrained=True).to(dev)  \n    opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)  \n    loss_fn = FocalLoss()  \n  \n    best_auc = -1.0  \n    for ep in range(1, args.epochs+1):  \n        model.train(); tot = 0.0  \n        for x, y in dl_tr:  \n            x, y = x.to(dev), y.to(dev)  \n            opt.zero_grad()  \n            loss = loss_fn(model(x), y)  \n            loss.backward(); opt.step()  \n            tot += loss.item()*len(x)  \n        model.eval(); ps, ys = [], []  \n        with torch.no_grad():  \n            for x, y in dl_va:  \n                p = torch.sigmoid(model(x.to(dev))).cpu().numpy().ravel()  \n                ps += p.tolist(); ys += y.numpy().ravel().tolist()  \n        auc = roc_auc_score(ys, ps)  \n        print(f\"epoch {ep}/{args.epochs}  loss={tot/len(tr):.4f}  val_auc={auc:.4f}\")  \n        if use_wandb: wandb.log({\"epoch\": ep, \"loss\": tot/len(tr), \"val_auc\": auc})  \n        if auc > best_auc:  \n            best_auc = auc  \n            torch.save(model.state_dict(), \"mobilenetv2_isic.pth\")  \n            print(f\"  ↑ new best AUC {auc:.4f} (checkpointed)\")  \n    print(f\"Saved best backbone (val_auc={best_auc:.4f}) -> mobilenetv2_isic.pth\")  \n    if use_wandb: wandb.finish()  \n  \nif __name__ == \"__main__\":  \n    main()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T21:57:26.519822Z","iopub.execute_input":"2026-09-06T21:57:26.520668Z","iopub.status.idle":"2026-09-06T21:57:26.536739Z","shell.execute_reply.started":"2026-09-06T21:57:26.520637Z","shell.execute_reply":"2026-09-06T21:57:26.535733Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%writefile calibration.py  \nimport pandas as pd, torch, torch.nn as nn  \nfrom torch.utils.data import DataLoader  \nfrom torchvision import models, transforms  \nfrom PIL import Image  \nfrom torch.utils.data import Dataset  \n  \nTFM = transforms.Compose([  \n    transforms.Resize((224, 224)), transforms.ToTensor(),  \n    transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),  \n])  \n  \nclass DF(Dataset):  \n    def __init__(self, csv): self.df = pd.read_csv(csv)  \n    def __len__(self): return len(self.df)  \n    def __getitem__(self, i):  \n        r = self.df.iloc[i]  \n        return TFM(Image.open(r[\"filepath\"]).convert(\"RGB\")), torch.tensor([r[\"label\"]], dtype=torch.float32)  \n  \nclass ModelWithTemperature(nn.Module):  \n    \"\"\"Mirrors backend/app.py: MobileNetV2 + Dropout/Linear head (no sigmoid) + temperature param.\"\"\"  \n    def __init__(self, temperature=1.0):  \n        super().__init__()  \n        b = models.mobilenet_v2(weights=None)  \n        b.classifier = nn.Sequential(nn.Dropout(0.2), nn.Linear(b.last_channel, 1))  \n        self.model = b  \n        self.temperature = nn.Parameter(torch.ones(1) * float(temperature))  \n    def forward(self, x):  \n        return self.model(x) / self.temperature.clamp_min(1e-6)  \n  \ndef main():  \n    dev = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")  \n    wrapper = ModelWithTemperature().to(dev)  \n    # load the trained backbone into wrapper.model.*  \n    state = torch.load(\"mobilenetv2_isic.pth\", map_location=dev)  \n    wrapper.model.load_state_dict(state)  \n  \n    dl = DataLoader(DF(\"data/calib_df.csv\"), batch_size=64, shuffle=False, num_workers=2)  \n    logits, labels = [], []  \n    wrapper.eval()  \n    with torch.no_grad():  \n        for x, y in dl:  \n            logits.append(wrapper.model(x.to(dev)).cpu()); labels.append(y)  \n    logits = torch.cat(logits); labels = torch.cat(labels)  \n  \n    T = nn.Parameter(torch.ones(1))  \n    opt = torch.optim.LBFGS([T], lr=0.01, max_iter=100)  \n    bce = nn.BCEWithLogitsLoss()  \n    def closure():  \n        opt.zero_grad(); loss = bce(logits / T.clamp_min(1e-6), labels); loss.backward(); return loss  \n    opt.step(closure)  \n    wrapper.temperature.data = T.data.clone().to(dev)  \n    print(f\"Fitted temperature T = {T.item():.4f}\")  \n  \n    torch.save(wrapper.state_dict(), \"mobilenetv2_calibrated.pth\")  \n    print(\"Saved calibrated checkpoint -> mobilenetv2_calibrated.pth\")  \n  \nif __name__ == \"__main__\":  \n    main()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T21:57:26.538551Z","iopub.execute_input":"2026-09-06T21:57:26.538871Z","iopub.status.idle":"2026-09-06T21:57:26.555221Z","shell.execute_reply.started":"2026-09-06T21:57:26.538828Z","shell.execute_reply":"2026-09-06T21:57:26.554404Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"%%writefile evaluate_calibrated_model.py  \nimport pandas as pd, numpy as np, torch, torch.nn as nn  \nfrom torch.utils.data import Dataset, DataLoader  \nfrom torchvision import models, transforms  \nfrom PIL import Image  \nfrom sklearn.metrics import roc_auc_score, average_precision_score  \n  \nTFM = transforms.Compose([  \n    transforms.Resize((224,224)), transforms.ToTensor(),  \n    transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),  \n])  \nclass DF(Dataset):  \n    def __init__(self, csv): self.df = pd.read_csv(csv)  \n    def __len__(self): return len(self.df)  \n    def __getitem__(self, i):  \n        r = self.df.iloc[i]  \n        return TFM(Image.open(r[\"filepath\"]).convert(\"RGB\")), float(r[\"label\"])  \n  \nclass ModelWithTemperature(nn.Module):  \n    def __init__(self, temperature=1.0):  \n        super().__init__()  \n        b = models.mobilenet_v2(weights=None)  \n        b.classifier = nn.Sequential(nn.Dropout(0.2), nn.Linear(b.last_channel, 1))  \n        self.model = b  \n        self.temperature = nn.Parameter(torch.ones(1)*float(temperature))  \n    def forward(self, x): return self.model(x)/self.temperature.clamp_min(1e-6)  \n  \ndef ece(probs, labels, bins=15):  \n    probs, labels = np.array(probs), np.array(labels)  \n    edges = np.linspace(0,1,bins+1); e = 0.0  \n    for i in range(bins):  \n        m = (probs>edges[i]) & (probs<=edges[i+1])  \n        if m.sum()==0: continue  \n        e += (m.mean()) * abs(labels[m].mean() - probs[m].mean())  \n    return e  \n  \ndef main():  \n    dev = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")  \n    print(\"Eval on\", dev)  \n    m = ModelWithTemperature().to(dev)  \n    state = torch.load(\"mobilenetv2_calibrated.pth\", map_location=dev)  \n    m.load_state_dict(state, strict=True)     # strict: verifies key alignment with backend  \n    print(\"keys align. Embedded T =\", m.temperature.item())  \n    m.eval()  \n  \n    dl = DataLoader(DF(\"data/test_df.csv\"), batch_size=64, shuffle=False, num_workers=2)  \n    ps, ys = [], []  \n    with torch.no_grad():  \n        for x, y in dl:  \n            p = torch.sigmoid(m(x.to(dev))).cpu().numpy().ravel()  \n            ps += p.tolist(); ys += list(np.array(y).ravel())  \n    ps, ys = np.array(ps), np.array(ys)  \n    pred = (ps >= 0.5).astype(int)  \n    tp = ((pred==1)&(ys==1)).sum(); fn = ((pred==0)&(ys==1)).sum()  \n    tn = ((pred==0)&(ys==0)).sum(); fp = ((pred==1)&(ys==0)).sum()  \n    print(f\"AUC={roc_auc_score(ys,ps):.5f} PR-AUC={average_precision_score(ys,ps):.5f} ECE={ece(ps,ys):.5f}\")  \n    print(f\"Sens={tp/(tp+fn)*100:.2f}% Spec={tn/(tn+fp)*100:.2f}% PPV={tp/(tp+fp+1e-9)*100:.2f}%\")  \n    print(f\"\\nSet in backend env:\\nMODEL_AUC={roc_auc_score(ys,ps):.5f}\\nMODEL_ECE={ece(ps,ys):.5f}\")  \n  \nif __name__ == \"__main__\":  \n    main()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T21:57:26.556329Z","iopub.execute_input":"2026-09-06T21:57:26.556977Z","iopub.status.idle":"2026-09-06T21:57:26.569658Z","shell.execute_reply.started":"2026-09-06T21:57:26.556939Z","shell.execute_reply":"2026-09-06T21:57:26.568906Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# === Cell 5: run everything, verify the .pth exists, log a W&B artifact ===  \n!python data_prep.py --random-state 42  \n!python model_training.py --epochs 12 --batch-size 64  \n!python calibration.py  \n!python evaluate_calibrated_model.py  \n  \n# Confirm the checkpoint is really on disk BEFORE you close the tab  \n!ls -lh /kaggle/working/mobilenetv2_calibrated.pth  \n  \n# Safety net: log the checkpoint as a W&B artifact so a dead session never costs you the run again.  \nimport os  \nif os.getenv(\"WANDB_API_KEY\") and os.getenv(\"WANDB_DISABLED\") != \"true\":  \n    import wandb  \n    run = wandb.init(project=\"skin-cancer-absention\", job_type=\"checkpoint\")  \n    art = wandb.Artifact(\"mobilenetv2_calibrated\", type=\"model\")  \n    art.add_file(\"/kaggle/working/mobilenetv2_calibrated.pth\")  \n    run.log_artifact(art); run.finish()  \n    print(\"Logged checkpoint to W&B Artifacts (downloadable later, no rerun needed).\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T21:57:26.570558Z","iopub.execute_input":"2026-09-06T21:57:26.571294Z","iopub.status.idle":"2026-09-06T23:37:48.828443Z","shell.execute_reply.started":"2026-09-06T21:57:26.571260Z","shell.execute_reply":"2026-09-06T23:37:48.827408Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch, torch.nn as nn  \nfrom torchvision import models  \n  \nclass ModelWithTemperature(nn.Module):  \n    def __init__(self, temperature=1.0):  \n        super().__init__()  \n        b = models.mobilenet_v2(weights=None)  \n        b.classifier = nn.Sequential(nn.Dropout(0.2), nn.Linear(b.last_channel, 1))  \n        self.model = b  \n        self.temperature = nn.Parameter(torch.ones(1) * float(temperature))  \n  \nstate = torch.load(\"mobilenetv2_calibrated.pth\", map_location=\"cpu\")  \nm = ModelWithTemperature()  \nmissing, unexpected = m.load_state_dict(state, strict=False)  \nassert not missing and not unexpected, f\"MISMATCH missing={missing} unexpected={unexpected}\"  \nprint(\"✅ keys align with backend. Embedded T =\", state[\"temperature\"].item())  \n!ls -la mobilenetv2_calibrated.pth","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T23:37:52.514209Z","iopub.execute_input":"2026-09-06T23:37:52.514557Z","iopub.status.idle":"2026-09-06T23:37:52.765497Z","shell.execute_reply.started":"2026-09-06T23:37:52.514513Z","shell.execute_reply":"2026-09-06T23:37:52.764668Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch, torch.nn as nn, numpy as np, pandas as pd, cv2  \nfrom torchvision import models, transforms  \nfrom sklearn.metrics import confusion_matrix, roc_auc_score  \n  \ndevice = \"cuda\" if torch.cuda.is_available() else \"cpu\"  \n  \n# --- 1. Rebuild the SAME wrapper as backend ModelWithTemperature ---  \nclass ModelWithTemperature(nn.Module):  \n    def __init__(self, temperature=1.0):  \n        super().__init__()  \n        backbone = models.mobilenet_v2(weights=None)  \n        backbone.classifier = nn.Sequential(  \n            nn.Dropout(p=0.2),  \n            nn.Linear(backbone.last_channel, 1),  # sigmoid stripped for logit inference  \n        )  \n        self.model = backbone  \n        self.temperature = nn.Parameter(torch.ones(1) * float(temperature))  \n    def forward(self, x):  \n        return self.model(x) / self.temperature.clamp_min(1e-6)  \n  \ndef build_calibrated_model():  \n    return ModelWithTemperature()  \n  \n# --- 2. Load checkpoint strictly (ZERO missing/unexpected keys) ---  \nmodel = build_calibrated_model().to(device)  \nstate = torch.load(\"mobilenetv2_calibrated.pth\", map_location=device)  \nmodel.load_state_dict(state, strict=True)  \nmodel.eval()  \nprint(\"Embedded T =\", model.temperature.item())  \n  \n# --- 3. Recompute y_true / y_prob on the held-out test set ---  \ntf = transforms.Compose([  \n    transforms.ToPILImage(),  \n    transforms.Resize((224, 224)),  \n    transforms.ToTensor(),  \n    transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),  \n])  \ntest_df = pd.read_csv(\"data/test_df.csv\")   # adjust path if different  \nPATH_COL, LABEL_COL = \"filepath\", \"label\"   # adjust to your data_prep column names  \n  \ny_true, y_prob = [], []  \nwith torch.no_grad():  \n    for _, row in test_df.iterrows():  \n        img = cv2.cvtColor(cv2.imread(row[PATH_COL]), cv2.COLOR_BGR2RGB)  \n        x = tf(img).unsqueeze(0).to(device)  \n        p = torch.sigmoid(model(x)).item()   # temperature already applied inside forward  \n        y_prob.append(p); y_true.append(int(row[LABEL_COL]))  \ny_true, y_prob = np.array(y_true), np.array(y_prob)  \nprint(\"AUC =\", roc_auc_score(y_true, y_prob), \" n =\", len(y_true), \" mal% =\", y_true.mean()*100)  \n  \n# --- 4. Threshold sweep ---  \nfor t in [0.05,0.10,0.15,0.20,0.25,0.30,0.40,0.50]:  \n    tn,fp,fn,tp = confusion_matrix(y_true, (y_prob>=t).astype(int)).ravel()  \n    sens = tp/(tp+fn); spec = tn/(tn+fp); ppv = tp/(tp+fp+1e-9)  \n    print(f\"t={t:.2f}  Sens={sens:.3f}  Spec={spec:.3f}  PPV={ppv:.3f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-06T23:47:20.794276Z","iopub.execute_input":"2026-09-06T23:47:20.795213Z","iopub.status.idle":"2026-09-06T23:50:06.992839Z","shell.execute_reply.started":"2026-09-06T23:47:20.795182Z","shell.execute_reply":"2026-09-06T23:50:06.992003Z"}},"outputs":[],"execution_count":null}]}