{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":120400,"databundleVersionId":14399747,"sourceType":"competition"}],"dockerImageVersionId":31193,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Breast Cancer US Classification | ML4IM 2025-2026 — EfficientNetB0 Baseline\n**Goal:** Fast, reliable baseline with transfer learning (+ test-time augmentation) and robust submission builder.\n\n**Highlights**\n- Auto-detect train/test paths & labels\n- Strong torchvision Augmentations\n- EfficientNet-B0 (ImageNet) fine-tune\n- Stratified split with patient-leak guard (filename prefix heuristic)\n- TTA at inference\n- Submission columns read from `sample_submission.csv` if available\n","metadata":{}},{"cell_type":"markdown","source":"# 1) Setup\n","metadata":{}},{"cell_type":"code","source":"import os, sys, gc, math, random, time, json, glob, warnings\nwarnings.filterwarnings(\"ignore\")\n\nimport numpy as np\nimport pandas as pd\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\nfrom PIL import Image\n\n# Repro\nSEED = 42\nrandom.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)\n\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nBATCH_SIZE = 32\nEPOCHS = 8\nLR = 3e-4\nIMG_SIZE = 384           # EfficientNet likes 224–380; 384 is a sweet spot\nNUM_WORKERS = 2\n\n# Try to find the competition data folder automatically\n# ================================\n# Robust PATH discovery (drop-in replacement)\n# ================================\nimport os, glob, re\n\ndef has_benign_malignant(d):\n    try:\n        entries = [e.lower() for e in os.listdir(d)]\n        return (\"benign\" in entries) and (\"malignant\" in entries)\n    except Exception:\n        return False\n\ndef find_training_and_testing_roots():\n    # 1) Search all first/second-level dirs under /kaggle/input for a training_set with benign/malignant\n    candidates = []\n\n    for path in glob.glob(\"/kaggle/input/*\"):\n        if os.path.isdir(path):\n            # prefer \"complete_set/training_set\"\n            c_train = os.path.join(path, \"complete_set\", \"training_set\")\n            c_test  = os.path.join(path, \"complete_set\", \"testing_set\")\n            if os.path.isdir(c_train) and has_benign_malignant(c_train):\n                candidates.append((\"complete\", c_train, c_test if os.path.isdir(c_test) else None))\n\n            # top-level \"training_set\"\n            t_train = os.path.join(path, \"training_set\")\n            if os.path.isdir(t_train) and has_benign_malignant(t_train):\n                # test may be alongside as \"testing_set\" or \"test\"\n                t_test = None\n                for name in [\"testing_set\", \"test\", \"Testing_set\", \"Test\"]:\n                    p = os.path.join(path, name)\n                    if os.path.isdir(p):\n                        t_test = p; break\n                candidates.append((\"top\", t_train, t_test))\n\n            # any nested place that directly has benign/malignant\n            for nested in glob.glob(os.path.join(path, \"**\"), recursive=False):\n                if os.path.isdir(nested) and has_benign_malignant(nested):\n                    # try to find a sibling test folder\n                    base = os.path.dirname(nested)\n                    test_sibling = None\n                    for name in [\"testing_set\", \"test\"]:\n                        p = os.path.join(base, name)\n                        if os.path.isdir(p):\n                            test_sibling = p; break\n                    candidates.append((\"nested\", nested, test_sibling))\n\n    # Choose best match: prefer one that also has a testing_set\n    with_test = [c for c in candidates if c[2] is not None]\n    chosen = with_test[0] if with_test else (candidates[0] if candidates else None)\n    if not chosen:\n        raise FileNotFoundError(\"Could not find a training folder containing 'benign' and 'malignant' under /kaggle/input.\")\n\n    _, train_dir, test_dir = chosen\n    return train_dir, test_dir\n\nTRAIN_DIR, TEST_DIR = find_training_and_testing_roots()\n\ndef find_sample_submission_near(train_dir):\n    # look near training dir and at its parent & root\n    roots = [\n        os.path.dirname(train_dir),\n        os.path.dirname(os.path.dirname(train_dir)),\n        \"/kaggle/input\"\n    ]\n    seen = set()\n    for r in roots:\n        if r and os.path.isdir(r) and r not in seen:\n            seen.add(r)\n            for p in glob.glob(os.path.join(r, \"**\", \"sample_submission.csv\"), recursive=True):\n                return p\n    return None\n\nSS_PATH = find_sample_submission_near(TRAIN_DIR)\n\nprint(\"Resolved paths:\")\nprint(\"  TRAIN_DIR:\", TRAIN_DIR)\nprint(\"  TEST_DIR :\", TEST_DIR)\nprint(\"  SS_PATH  :\", SS_PATH)\n\n# Light sanity check (no hard assert that caused the crash)\nprint(\"  Has benign?:\", os.path.isdir(os.path.join(TRAIN_DIR, \"benign\")) or os.path.isdir(os.path.join(TRAIN_DIR, \"Benign\")))\nprint(\"  Has malignant?:\", os.path.isdir(os.path.join(TRAIN_DIR, \"malignant\")) or os.path.isdir(os.path.join(TRAIN_DIR, \"Malignant\")))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T18:57:39.961881Z","iopub.execute_input":"2025-11-11T18:57:39.962121Z","iopub.status.idle":"2025-11-11T18:57:51.405617Z","shell.execute_reply.started":"2025-11-11T18:57:39.962105Z","shell.execute_reply":"2025-11-11T18:57:51.404824Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 3) DATA INDEX (train df)\n","metadata":{}},{"cell_type":"code","source":"def list_images(dir_path, label=None):\n    exts = (\"*.png\",\"*.jpg\",\"*.jpeg\",\"*.bmp\",\"*.tif\",\"*.tiff\")\n    files = []\n    for e in exts:\n        files += glob.glob(os.path.join(dir_path, e))\n        files += glob.glob(os.path.join(dir_path, \"**\", e), recursive=True)  # just in case\n    rows = [{\"path\":f, \"id\":os.path.basename(f)} for f in sorted(set(files))]\n    if label is not None:\n        for r in rows: r[\"label\"] = label\n    return rows\n\ntrain_benign   = list_images(os.path.join(TRAIN_DIR, \"benign\"), \"benign\")\ntrain_malign   = list_images(os.path.join(TRAIN_DIR, \"malignant\"), \"malignant\")\ntrain_df       = pd.DataFrame(train_benign + train_malign)\n\nlabel2id = {\"benign\":0, \"malignant\":1}\ntrain_df[\"target\"] = train_df[\"label\"].map(label2id)\n\n# patient heuristic to reduce leakage (prefix before first underscore)\ndef patient_from_name(name):\n    base = os.path.splitext(name)[0]\n    return base.split(\"_\")[0]\ntrain_df[\"patient\"] = train_df[\"id\"].map(patient_from_name)\n\nprint(\"Train samples:\", len(train_df))\ndisplay(train_df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T18:57:51.406751Z","iopub.execute_input":"2025-11-11T18:57:51.407114Z","iopub.status.idle":"2025-11-11T18:57:51.574336Z","shell.execute_reply.started":"2025-11-11T18:57:51.407093Z","shell.execute_reply":"2025-11-11T18:57:51.573697Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 4) SPLIT (Stratified Group KFold)\n","metadata":{}},{"cell_type":"code","source":"from sklearn.model_selection import StratifiedGroupKFold\n\nsgkf = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=SEED)\ntr_idx, va_idx = next(sgkf.split(train_df, y=train_df[\"target\"], groups=train_df[\"patient\"]))\ntr_df = train_df.iloc[tr_idx].reset_index(drop=True)\nva_df = train_df.iloc[va_idx].reset_index(drop=True)\nprint(\"Train/Valid sizes:\", tr_df.shape, va_df.shape)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T18:57:51.575116Z","iopub.execute_input":"2025-11-11T18:57:51.575340Z","iopub.status.idle":"2025-11-11T18:57:52.342137Z","shell.execute_reply.started":"2025-11-11T18:57:51.575321Z","shell.execute_reply":"2025-11-11T18:57:52.341364Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 5) TRANSFORMS\n","metadata":{}},{"cell_type":"code","source":"mean = [0.485, 0.456, 0.406]\nstd  = [0.229, 0.224, 0.225]\n\ntrain_tfms = transforms.Compose([\n    transforms.Resize(int(IMG_SIZE*1.1)),\n    transforms.RandomResizedCrop(IMG_SIZE, scale=(0.8, 1.0)),\n    transforms.RandomHorizontalFlip(),\n    transforms.RandomVerticalFlip(p=0.2),\n    transforms.RandomRotation(10),\n    transforms.ColorJitter(brightness=0.1, contrast=0.1),\n    transforms.ToTensor(),\n    transforms.Normalize(mean, std),\n])\n\nvalid_tfms = transforms.Compose([\n    transforms.Resize(IMG_SIZE),\n    transforms.CenterCrop(IMG_SIZE),\n    transforms.ToTensor(),\n    transforms.Normalize(mean, std),\n])\n\n# simple 2x TTA: identity + horizontal flip\ntta_tfms = [\n    valid_tfms,\n    transforms.Compose([\n        transforms.Resize(IMG_SIZE),\n        transforms.CenterCrop(IMG_SIZE),\n        transforms.RandomHorizontalFlip(p=1.0),\n        transforms.ToTensor(),\n        transforms.Normalize(mean, std),\n    ])\n]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T18:57:52.343666Z","iopub.execute_input":"2025-11-11T18:57:52.344000Z","iopub.status.idle":"2025-11-11T18:57:52.350518Z","shell.execute_reply.started":"2025-11-11T18:57:52.343981Z","shell.execute_reply":"2025-11-11T18:57:52.349698Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 6) DATASETS & LOADERS\n","metadata":{}},{"cell_type":"code","source":"class ImgDS(Dataset):\n    def __init__(self, df, tfm, with_label=True):\n        self.df = df\n        self.tfm = tfm\n        self.with_label = with_label\n    def __len__(self): return len(self.df)\n    def __getitem__(self, i):\n        row = self.df.iloc[i]\n        img = Image.open(row[\"path\"]).convert(\"RGB\")\n        img = self.tfm(img)\n        if self.with_label:\n            return img, row[\"target\"]\n        return img, row[\"id\"], row[\"path\"]\n\ntr_loader = DataLoader(ImgDS(tr_df, train_tfms, True), batch_size=BATCH_SIZE, shuffle=True,\n                       num_workers=NUM_WORKERS, pin_memory=True)\nva_loader = DataLoader(ImgDS(va_df, valid_tfms, True), batch_size=BATCH_SIZE, shuffle=False,\n                       num_workers=NUM_WORKERS, pin_memory=True)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T18:57:52.351357Z","iopub.execute_input":"2025-11-11T18:57:52.351747Z","iopub.status.idle":"2025-11-11T18:57:52.370888Z","shell.execute_reply.started":"2025-11-11T18:57:52.351710Z","shell.execute_reply":"2025-11-11T18:57:52.370086Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 7) MODEL (EfficientNet-B0)\n","metadata":{}},{"cell_type":"code","source":"from torchvision.models import efficientnet_b0, EfficientNet_B0_Weights\n\nweights = EfficientNet_B0_Weights.IMAGENET1K_V1\nmodel = efficientnet_b0(weights=weights)\nin_features = model.classifier[1].in_features\nmodel.classifier[1] = nn.Linear(in_features, 1)  # binary\nmodel = model.to(DEVICE)\n\ncriterion = nn.BCEWithLogitsLoss()\noptimizer = torch.optim.AdamW(model.parameters(), lr=LR)\nscheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T18:57:52.371689Z","iopub.execute_input":"2025-11-11T18:57:52.371927Z","iopub.status.idle":"2025-11-11T18:57:53.042626Z","shell.execute_reply.started":"2025-11-11T18:57:52.371899Z","shell.execute_reply":"2025-11-11T18:57:53.042004Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 8) TRAIN LOOP\n","metadata":{}},{"cell_type":"code","source":"from tqdm.auto import tqdm\n\ndef run_epoch(loader, train_mode=True, epoch=0, phase=\"train\"):\n    model.train(train_mode)\n    running_loss, correct, total = 0.0, 0, 0\n\n    pbar = tqdm(loader, desc=f\"{phase.capitalize()} E{epoch:02d}\", leave=False)\n    for imgs, targets in pbar:\n        imgs = imgs.to(DEVICE)\n        targets = targets.float().to(DEVICE)\n\n        with torch.set_grad_enabled(train_mode):\n            logits = model(imgs).squeeze(1)\n            loss = criterion(logits, targets)\n\n        if train_mode:\n            optimizer.zero_grad(set_to_none=True)\n            loss.backward()\n            optimizer.step()\n\n        # metrics\n        running_loss += loss.item() * imgs.size(0)\n        preds = (torch.sigmoid(logits) > 0.5).long()\n        correct += (preds == targets.long()).sum().item()\n        total += imgs.size(0)\n\n        # live progress\n        avg_loss = running_loss / max(total, 1)\n        acc = correct / max(total, 1)\n        pbar.set_postfix(loss=f\"{avg_loss:.4f}\", acc=f\"{acc:.4f}\")\n\n    return running_loss/total, correct/total\n\nbest_acc, best_path = 0.0, \"best_model.pt\"\nfor epoch in tqdm(range(1, EPOCHS+1), desc=\"Epochs\"):\n    tr_loss, tr_acc = run_epoch(tr_loader, True, epoch, \"train\")\n    va_loss, va_acc = run_epoch(va_loader, False, epoch, \"valid\")\n    scheduler.step()\n    # Use tqdm.write so the line isn’t overwritten by the bars\n    tqdm.write(f\"Epoch {epoch:02d} | TR loss {tr_loss:.4f} acc {tr_acc:.4f} \"\n               f\"| VA loss {va_loss:.4f} acc {va_acc:.4f}\")\n    if va_acc > best_acc:\n        best_acc = va_acc\n        torch.save(model.state_dict(), best_path)\n\ntqdm.write(f\"Best valid acc: {best_acc:.4f}\")\n\n# Free a bit\ngc.collect(); torch.cuda.empty_cache()\n","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 9) INFERENCE WITH TTA\n","metadata":{}},{"cell_type":"code","source":"def list_test_images(dir_path):\n    exts = (\"*.png\",\"*.jpg\",\"*.jpeg\",\"*.bmp\",\"*.tif\",\"*.tiff\")\n    files = []\n    for e in exts:\n        files += glob.glob(os.path.join(dir_path, e))\n        files += glob.glob(os.path.join(dir_path, \"**\", e), recursive=True)\n    files = sorted(set(files))\n    return pd.DataFrame({\"path\": files, \"id\": [os.path.basename(f) for f in files]})\n\ntest_df = list_test_images(TEST_DIR)\nprint(\"Test images:\", len(test_df))\ndisplay(test_df.head())\n\n# Load best model\nmodel.load_state_dict(torch.load(best_path, map_location=DEVICE))\nmodel.eval();\n\ndef predict_with_tta(paths):\n    probs_all = []\n    for tfm in tta_tfms:\n        ds = ImgDS(pd.DataFrame({\"path\": paths, \"id\":[os.path.basename(p) for p in paths]}),\n                   tfm, with_label=False)\n        dl = DataLoader(ds, batch_size=BATCH_SIZE, shuffle=False,\n                        num_workers=NUM_WORKERS, pin_memory=True)\n        part = []\n        for imgs, ids, _ in dl:\n            imgs = imgs.to(DEVICE)\n            with torch.no_grad():\n                logits = model(imgs).squeeze(1)\n                part.append(torch.sigmoid(logits).detach().cpu().numpy())\n        probs_all.append(np.concatenate(part))\n    return np.mean(probs_all, axis=0)\n\np_malignant = predict_with_tta(test_df[\"path\"].tolist())\np_benign    = 1.0 - p_malignant","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T19:00:46.394099Z","iopub.execute_input":"2025-11-11T19:00:46.394347Z","iopub.status.idle":"2025-11-11T19:00:51.011243Z","shell.execute_reply.started":"2025-11-11T19:00:46.394325Z","shell.execute_reply":"2025-11-11T19:00:51.010093Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 10) BUILD SUBMISSION\n","metadata":{}},{"cell_type":"code","source":"def build_submission(ids, p_benign, p_malignant, ss_path=None):\n    if ss_path and os.path.exists(ss_path):\n        ss = pd.read_csv(ss_path)\n        out = ss.copy()\n        cols = list(out.columns)\n        # If two probability columns exist:\n        if {\"Benign\",\"Malignant\"}.issubset(set(cols)):\n            out[\"Benign\"]    = p_benign[:len(out)]\n            out[\"Malignant\"] = p_malignant[:len(out)]\n            return out\n        # If there is a single label-like column:\n        label_like = [c for c in cols if c.lower() in (\"label\",\"target\",\"prediction\",\"predicted\",\"diagnosis\")]\n        if label_like:\n            c = label_like[0]\n            # If strings expected (M/B), output those; else 0/1\n            if out[c].dtype == object and out[c].dropna().isin([\"M\",\"B\"]).any():\n                out[c] = np.where(p_malignant[:len(out)] >= 0.5, \"M\", \"B\")\n            else:\n                out[c] = (p_malignant[:len(out)] >= 0.5).astype(int)\n            return out\n        # Otherwise, append probability columns:\n        if \"Benign\" not in out.columns:    out[\"Benign\"] = p_benign[:len(out)]\n        if \"Malignant\" not in out.columns: out[\"Malignant\"] = p_malignant[:len(out)]\n        return out\n    # No sample submission: make a simple, safe file\n    return pd.DataFrame({\n        \"id\": ids,\n        \"Benign\": p_benign,\n        \"Malignant\": p_malignant\n    })\n\nsub_df = build_submission(test_df[\"id\"].tolist(), p_benign, p_malignant, SS_PATH)\nsub_df.to_csv(\"submission.csv\", index=False)\nprint(\"Saved:\", \"submission.csv\")\ndisplay(sub_df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-11T19:00:51.012836Z","iopub.execute_input":"2025-11-11T19:00:51.013137Z","iopub.status.idle":"2025-11-11T19:00:51.040967Z","shell.execute_reply.started":"2025-11-11T19:00:51.013109Z","shell.execute_reply":"2025-11-11T19:00:51.040033Z"}},"outputs":[],"execution_count":null}]}