{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":14774,"databundleVersionId":875431,"isSourceIdPinned":false}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Hybrid Dual-Head: MobileNetV3-Large + Parallel MLP & KAN (5-Fold CV)\n\n**Concept:** Parallel MLP and KAN branches fused via concatenation.  \nMLP captures global linear patterns; KAN captures non-linear ordinal relationships via learnable B-spline activations.  \nThe final linear layer learns which branch contributes what for each DR grade.\n\n**Comparison:**\n- Baseline: MobileNetV3-Large + MLP head\n- Proposed: MobileNetV3-Large + Hybrid Dual-Head (MLP + KAN, concatenation fusion)\n\n**Dataset:** APTOS 2019 | **CV:** 5-Fold Stratified | **Epochs:** 100","metadata":{}},{"cell_type":"code","source":"!pip install git+https://github.com/Blealtan/efficient-kan.git -q\n!pip install timm -q\n!pip install torchmetrics -q\n!pip install albumentations -q\n!pip install seaborn -q","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-28T14:05:42.127885Z","iopub.execute_input":"2026-03-28T14:05:42.128719Z","iopub.status.idle":"2026-03-28T14:06:05.411365Z","shell.execute_reply.started":"2026-03-28T14:05:42.128675Z","shell.execute_reply":"2026-03-28T14:06:05.410354Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, random, warnings, time\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.amp import GradScaler, autocast\nimport timm\nfrom torchmetrics import CohenKappa\nfrom sklearn.metrics import f1_score, confusion_matrix, classification_report\nfrom sklearn.model_selection import StratifiedKFold\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport cv2\nfrom efficient_kan import KANLinear\nimport matplotlib\nmatplotlib.use('Agg')\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nwarnings.filterwarnings('ignore')\n\nSEED = 42\ndef seed_everything(seed):\n    random.seed(seed); np.random.seed(seed)\n    torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)\n    torch.backends.cudnn.deterministic = True\n    torch.backends.cudnn.benchmark = False\n    os.environ['PYTHONHASHSEED'] = str(seed)\n\nseed_everything(SEED)\nDEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f'Device: {DEVICE} | GPU: {torch.cuda.get_device_name(0)}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-28T14:12:00.062386Z","iopub.execute_input":"2026-03-28T14:12:00.062786Z","iopub.status.idle":"2026-03-28T14:12:27.529105Z","shell.execute_reply.started":"2026-03-28T14:12:00.062752Z","shell.execute_reply":"2026-03-28T14:12:27.528454Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Auto-detect APTOS path ────────────────────────────────────────────────────\ndef find_aptos():\n    for p in ['/kaggle/input/aptos2019-blindness-detection',\n              '/kaggle/input/competitions/aptos2019-blindness-detection']:\n        if Path(p).exists(): return p\n    raise FileNotFoundError('APTOS not found. Add via Add Data button.')\n\nAPTOS = find_aptos()\nprint(f'APTOS: {APTOS}')\n\nCFG = {\n    'train_dir'    : f'{APTOS}/train_images',\n    'train_csv'    : f'{APTOS}/train.csv',\n    'img_size'     : 224,\n    'num_classes'  : 5,\n    'num_workers'  : 2,\n    'batch_size'   : 16,\n    'epochs'       : 100,\n    'lr_backbone'  : 5e-5,\n    'lr_head'      : 5e-4,\n    'freeze_epochs': 3,\n    'max_grad_norm': 1.0,\n    'weight_decay' : 1e-2,\n    'dropout'      : 0.3,\n    'n_folds'      : 5,\n    'seed'         : SEED,\n    'mlp_hidden'   : 64,\n    'kan_hidden'   : 64,\n    'kan_grid'     : 5,\n    'kan_k'        : 3,\n}\n\ndf_all = pd.read_csv(CFG['train_csv'])\nprint(f'Samples: {len(df_all)} | Batch: {CFG[\"batch_size\"]}')\nprint(df_all['diagnosis'].value_counts().sort_index())","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Preprocessing: Resize to 224×224 once ────────────────────────────────────\nimport time\nsrc_dir = Path(CFG['train_dir'])\ndst_dir = Path('/kaggle/working/train_224')\ndst_dir.mkdir(exist_ok=True)\n\nfiles = list(src_dir.glob('*.png'))\nalready = len(list(dst_dir.glob('*.png')))\n\nif already == len(files):\n    print(f'Already preprocessed: {len(files)} images.')\nelse:\n    print(f'Resizing {len(files)} images...')\n    t0 = time.time()\n    for i, fp in enumerate(files):\n        dst = dst_dir / fp.name\n        if not dst.exists():\n            img = cv2.imread(str(fp))\n            img = cv2.resize(img, (224, 224), interpolation=cv2.INTER_AREA)\n            cv2.imwrite(str(dst), img)\n        if (i+1) % 100 == 0 or (i+1) == len(files):\n            pct = (i+1)/len(files)*100\n            eta = (time.time()-t0)/(i+1)*(len(files)-i-1)\n            bar = chr(9608)*int(pct/5) + chr(9617)*(20-int(pct/5))\n            print(f'  [{bar}] {pct:5.1f}% | {i+1}/{len(files)} | ETA:{eta:.0f}s')\n    print('Done.')\n\nCFG['train_dir'] = str(dst_dir)\nprint(f'train_dir updated: {CFG[\"train_dir\"]}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-28T14:12:36.048629Z","iopub.execute_input":"2026-03-28T14:12:36.049316Z","iopub.status.idle":"2026-03-28T14:19:30.791884Z","shell.execute_reply.started":"2026-03-28T14:12:36.049270Z","shell.execute_reply":"2026-03-28T14:19:30.791107Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Data Pipeline ─────────────────────────────────────────────────────────────\ndef get_transforms(split, img_size):\n    if split == 'train':\n        return A.Compose([\n            A.Resize(img_size, img_size),\n            A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5),\n            A.Rotate(limit=15, p=0.5),\n            A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, p=0.5),\n            A.GaussianBlur(blur_limit=(3,7), p=0.3),\n            A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n            ToTensorV2(),\n        ])\n    return A.Compose([\n        A.Resize(img_size, img_size),\n        A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),\n        ToTensorV2(),\n    ])\n\nclass DRDataset(Dataset):\n    def __init__(self, df, img_dir, transform=None):\n        self.df = df.reset_index(drop=True)\n        self.img_dir = Path(img_dir)\n        self.transform = transform\n    def __len__(self): return len(self.df)\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        img = cv2.cvtColor(\n            cv2.imread(str(self.img_dir/f\"{row['id_code']}.png\")),\n            cv2.COLOR_BGR2RGB)\n        if self.transform: img = self.transform(image=img)['image']\n        return img, int(row['diagnosis'])\n\ndef get_loaders(train_df, val_df, cfg):\n    cc = train_df['diagnosis'].value_counts().sort_index().values\n    cw = torch.FloatTensor(1.0/cc).to(DEVICE)\n    cw = cw/cw.sum()*cfg['num_classes']\n    tl = DataLoader(\n        DRDataset(train_df, cfg['train_dir'], get_transforms('train', cfg['img_size'])),\n        batch_size=cfg['batch_size'], shuffle=True,\n        num_workers=cfg['num_workers'], pin_memory=True)\n    vl = DataLoader(\n        DRDataset(val_df, cfg['train_dir'], get_transforms('val', cfg['img_size'])),\n        batch_size=cfg['batch_size'], shuffle=False,\n        num_workers=cfg['num_workers'], pin_memory=True)\n    return tl, vl, cw\n\nprint('Data pipeline ready ✓')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-28T14:28:27.976367Z","iopub.execute_input":"2026-03-28T14:28:27.977128Z","iopub.status.idle":"2026-03-28T14:28:27.991721Z","shell.execute_reply.started":"2026-03-28T14:28:27.977088Z","shell.execute_reply":"2026-03-28T14:28:27.990888Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Model Definitions ─────────────────────────────────────────────────────────\n\nclass MLPHead(nn.Module):\n    def __init__(self, in_f, hidden, num_c, dropout=0.3):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Dropout(p=dropout),\n            nn.Linear(in_f, hidden),\n            nn.ReLU(inplace=True),\n            nn.Dropout(p=dropout),\n            nn.Linear(hidden, num_c)\n        )\n    def forward(self, x): return self.net(x)\n\n\nclass KANHead(nn.Module):\n    def __init__(self, in_f, hidden, num_c, grid=5, k=3, dropout=0.3):\n        super().__init__()\n        self.drop = nn.Dropout(p=dropout)\n        self.kan1 = KANLinear(in_f, hidden, grid_size=grid, spline_order=k)\n        self.kan2 = KANLinear(hidden, num_c, grid_size=grid, spline_order=k)\n    def forward(self, x): return self.kan2(self.kan1(self.drop(x)))\n\n\nclass HybridDualHead(nn.Module):\n    def __init__(self, cfg):\n        super().__init__()\n        self.backbone = timm.create_model(\n            'mobilenetv3_large_100', pretrained=True,\n            num_classes=0, global_pool='avg'\n        )\n        # Detect real feature dim with dummy pass\n        with torch.no_grad():\n            fd = self.backbone(torch.zeros(1, 3, 224, 224)).shape[1]\n        print(f'  Feature dim: {fd}')\n\n        self.mlp_branch = nn.Sequential(\n            nn.Dropout(p=cfg['dropout']),\n            nn.Linear(fd, cfg['mlp_hidden']),\n            nn.ReLU(inplace=True),\n        )\n        self.kan_drop  = nn.Dropout(p=cfg['dropout'])\n        self.kan_layer = KANLinear(fd, cfg['kan_hidden'],\n                                   grid_size=cfg['kan_grid'],\n                                   spline_order=cfg['kan_k'])\n        fused_dim = cfg['mlp_hidden'] + cfg['kan_hidden']\n        self.fusion = nn.Linear(fused_dim, cfg['num_classes'])\n\n    def forward(self, x):\n        feat    = self.backbone(x)\n        mlp_out = self.mlp_branch(feat)\n        kan_out = self.kan_layer(self.kan_drop(feat))\n        return self.fusion(torch.cat([mlp_out, kan_out], dim=1))\n\n    def param_groups(self, lr_b, lr_h):\n        return [\n            {'params': self.backbone.parameters(), 'lr': lr_b},\n            {'params': (list(self.mlp_branch.parameters()) +\n                        list(self.kan_layer.parameters()) +\n                        list(self.fusion.parameters())), 'lr': lr_h},\n        ]\n\n\nclass MobileNetMLP(nn.Module):\n    def __init__(self, cfg):\n        super().__init__()\n        self.backbone = timm.create_model(\n            'mobilenetv3_large_100', pretrained=True,\n            num_classes=0, global_pool='avg'\n        )\n        with torch.no_grad():\n            fd = self.backbone(torch.zeros(1, 3, 224, 224)).shape[1]\n        print(f'  Feature dim: {fd}')\n        self.head = MLPHead(fd, cfg['mlp_hidden']*2, cfg['num_classes'], cfg['dropout'])\n\n    def forward(self, x):\n        return self.head(self.backbone(x))\n\n    def param_groups(self, lr_b, lr_h):\n        return [\n            {'params': self.backbone.parameters(), 'lr': lr_b},\n            {'params': self.head.parameters(), 'lr': lr_h},\n        ]\n\n\n# Sanity check\n_m = HybridDualHead(CFG).to(DEVICE)\n_x = torch.randn(4, 3, 224, 224).to(DEVICE)\nwith torch.no_grad(): _o = _m(_x)\nassert _o.shape == (4, 5)\n_p = sum(p.numel() for p in _m.parameters())/1e6\nprint(f'HybridDualHead: {_p:.2f}M params, output {_o.shape} ✓')\ndel _m; torch.cuda.empty_cache()","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Training Utilities ────────────────────────────────────────────────────────\ndef compute_qwk(preds, targets):\n    m = CohenKappa(task='multiclass', num_classes=5, weights='quadratic')\n    return m(torch.tensor(preds), torch.tensor(targets)).item()\n\ndef train_epoch(model, loader, opt, sched, scaler, crit, epoch):\n    model.train()\n    total_loss, preds, targets = 0., [], []\n    for step, (imgs, labels) in enumerate(loader):\n        imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n        opt.zero_grad()\n        with autocast('cuda'):\n            logits = model(imgs)\n            loss   = crit(logits, labels)\n        scaler.scale(loss).backward()\n        scaler.unscale_(opt)\n        torch.nn.utils.clip_grad_norm_(model.parameters(), CFG['max_grad_norm'])\n        scaler.step(opt); scaler.update(); sched.step()\n        total_loss += loss.item()\n        preds.extend(logits.argmax(1).cpu().numpy())\n        targets.extend(labels.cpu().numpy())\n        if (step+1) % 100 == 0:\n            print(f'  Ep{epoch} | Step {step+1}/{len(loader)} | Loss:{loss.item():.4f}')\n    return total_loss/len(loader), compute_qwk(preds, targets)\n\n@torch.no_grad()\ndef validate(model, loader, crit):\n    model.eval()\n    total_loss, preds, targets = 0., [], []\n    for imgs, labels in loader:\n        imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n        with autocast('cuda'):\n            logits = model(imgs)\n            loss   = crit(logits, labels)\n        total_loss += loss.item()\n        preds.extend(logits.argmax(1).cpu().numpy())\n        targets.extend(labels.cpu().numpy())\n    return (total_loss/len(loader), compute_qwk(preds, targets),\n            f1_score(targets, preds, average='macro', zero_division=0),\n            preds, targets)\n\ndef run_cv(model_fn, name, cfg):\n    print(f'\\n{\"=\"*60}\\n{name}\\n{\"=\"*60}')\n    skf = StratifiedKFold(n_splits=cfg['n_folds'], shuffle=True, random_state=SEED)\n    splits = list(skf.split(df_all, df_all['diagnosis']))\n    results = []\n\n    for fold_idx in range(cfg['n_folds']):\n        ckpt_path = f'/kaggle/working/{name}_fold{fold_idx+1}_best.pth'\n        if Path(ckpt_path).exists():\n            r = torch.load(ckpt_path, map_location='cpu', weights_only=False)\n            print(f'  Fold {fold_idx+1}: checkpoint found (QWK={r[\"val_qwk\"]:.4f}), skipping.')\n            results.append({'fold': fold_idx+1, 'qwk': r['val_qwk'], 'f1': r['val_f1']})\n            continue\n\n        print(f'\\n--- Fold {fold_idx+1}/{cfg[\"n_folds\"]} ---')\n        train_df = df_all.iloc[splits[fold_idx][0]]\n        val_df   = df_all.iloc[splits[fold_idx][1]]\n        tl, vl, cw = get_loaders(train_df, val_df, cfg)\n\n        seed_everything(SEED + fold_idx)\n        model  = model_fn().to(DEVICE)\n        crit   = nn.CrossEntropyLoss(weight=cw)\n        scaler = GradScaler('cuda')\n        opt    = torch.optim.AdamW(\n            model.param_groups(cfg['lr_backbone'], cfg['lr_head']),\n            weight_decay=cfg['weight_decay'])\n        sched  = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(\n            opt, T_0=len(tl)*10)\n\n        # Freeze backbone for first N epochs\n        for p in model.backbone.parameters(): p.requires_grad = False\n\n        best_qwk, best_f1, history = -1., 0., []\n        print(f'{\"Ep\":>3} | {\"TrLoss\":>7} | {\"TrQWK\":>6} | {\"VlLoss\":>7} | {\"VlQWK\":>6} | {\"F1\":>6}')\n        print('-'*50)\n\n        for epoch in range(1, cfg['epochs']+1):\n            if epoch == cfg['freeze_epochs']+1:\n                for p in model.backbone.parameters(): p.requires_grad = True\n                print(f'  >>> Backbone unfrozen at epoch {epoch}')\n\n            tr_loss, tr_qwk = train_epoch(model, tl, opt, sched, scaler, crit, epoch)\n            vl_loss, vl_qwk, vl_f1, vp, vt = validate(model, vl, crit)\n            history.append(dict(name=name, fold=fold_idx+1, epoch=epoch,\n                                tr_loss=tr_loss, tr_qwk=tr_qwk,\n                                vl_loss=vl_loss, vl_qwk=vl_qwk, vl_f1=vl_f1))\n            print(f'{epoch:>3} | {tr_loss:>7.4f} | {tr_qwk:>6.4f} | '\n                  f'{vl_loss:>7.4f} | {vl_qwk:>6.4f} | {vl_f1:>6.4f}')\n\n            if vl_qwk > best_qwk:\n                best_qwk, best_f1 = vl_qwk, vl_f1\n                torch.save({\n                    'model_state': model.state_dict(),\n                    'val_qwk': vl_qwk, 'val_f1': vl_f1,\n                    'val_preds': vp, 'val_targets': vt,\n                    'fold': fold_idx+1, 'epoch': epoch\n                }, ckpt_path)\n                # Verify checkpoint\n                _r = torch.load(ckpt_path, map_location='cpu', weights_only=False)\n                assert 'model_state' in _r and 'val_qwk' in _r\n                print(f'  Best: {best_qwk:.4f} ✓')\n\n        pd.DataFrame(history).to_csv(\n            f'/kaggle/working/{name}_fold{fold_idx+1}_history.csv', index=False)\n        results.append({'fold': fold_idx+1, 'qwk': best_qwk, 'f1': best_f1})\n        del model; torch.cuda.empty_cache()\n\n    qwks = [r['qwk'] for r in results]\n    f1s  = [r['f1']  for r in results]\n    print(f'\\n{name} | QWK: {np.mean(qwks):.4f}+/-{np.std(qwks):.4f} '\n          f'| F1: {np.mean(f1s):.4f}+/-{np.std(f1s):.4f}')\n    return results\n\nprint('Training utilities ready ✓')","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── SMOKE TEST — Run BEFORE Save & Run All ───────────────────────────────────\nimport os\nprint('='*60 + '\\nSMOKE TEST: Hybrid Dual-Head\\n' + '='*60)\n\n# [1] Data\nprint('\\n[1/5] Data paths...')\nassert Path(CFG['train_csv']).exists(), 'FAIL: CSV not found'\nassert Path(CFG['train_dir']).exists(), 'FAIL: Image dir not found'\n_df = pd.read_csv(CFG['train_csv'])\nassert len(_df) == 3662\nprint(f'  + OK: {len(_df)} rows')\n\n# [2] Hybrid model\nprint('\\n[2/5] HybridDualHead...')\n_sm = HybridDualHead(CFG).to(DEVICE)\n_p  = sum(p.numel() for p in _sm.parameters())/1e6\nprint(f'  + OK: {_p:.2f}M params')\n\n# [3] Forward pass\nprint('\\n[3/5] Forward pass...')\n_x = torch.randn(4, 3, 224, 224).to(DEVICE)\nwith torch.no_grad(): _o = _sm(_x)\nassert _o.shape == (4, 5), f'FAIL: {_o.shape}'\nprint(f'  + OK: output {_o.shape}')\n\n# [4] Mini training\nprint('\\n[4/5] Mini training (2 steps)...')\n_mini = _df.sample(32, random_state=42)\n_ds = DRDataset(_mini, CFG['train_dir'], get_transforms('train', CFG['img_size']))\n_dl = DataLoader(_ds, 8, shuffle=True, num_workers=0)\n_sc = GradScaler('cuda')\n_opt = torch.optim.AdamW(_sm.parameters(), lr=1e-4)\n_cr = nn.CrossEntropyLoss()\n_sm.train()\nfor _i, (_imgs, _lbls) in enumerate(_dl):\n    if _i >= 2: break\n    _imgs, _lbls = _imgs.to(DEVICE), _lbls.to(DEVICE)\n    _opt.zero_grad()\n    with autocast('cuda'): _loss = _cr(_sm(_imgs), _lbls)\n    _sc.scale(_loss).backward(); _sc.step(_opt); _sc.update()\n    print(f'  Step {_i+1}: loss={_loss.item():.4f}')\nassert not torch.isnan(_loss), 'FAIL: NaN loss'\n\n# [5] Checkpoint\nprint('\\n[5/5] Checkpoint save/reload...')\n_cp = '/kaggle/working/_smoke.pth'\ntorch.save({'model_state': _sm.state_dict(), 'val_qwk': 0.0,\n            'val_f1': 0.0, 'val_preds': [], 'val_targets': [], 'fold': 0, 'epoch': 0}, _cp)\n_r = torch.load(_cp, map_location='cpu', weights_only=False)\nassert 'model_state' in _r and 'val_qwk' in _r\nos.remove(_cp)\ndel _sm; torch.cuda.empty_cache()\nprint('  + OK')\n\nprint('\\n' + '='*60)\nprint('ALL SMOKE TESTS PASSED --- Safe to Save & Run All')\nprint('='*60)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-28T14:28:48.631106Z","iopub.execute_input":"2026-03-28T14:28:48.631747Z","iopub.status.idle":"2026-03-28T14:28:59.333819Z","shell.execute_reply.started":"2026-03-28T14:28:48.631717Z","shell.execute_reply":"2026-03-28T14:28:59.333134Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Training: Baseline vs Hybrid ─────────────────────────────────────────────\n# Step 1: MobileNetV3 + MLP (baseline)\nbaseline_results = run_cv(\n    model_fn = lambda: MobileNetMLP(CFG),\n    name     = 'mobilenet_mlp',\n    cfg      = CFG\n)\n\n# Step 2: MobileNetV3 + Hybrid Dual-Head (MLP + KAN)\nhybrid_results = run_cv(\n    model_fn = lambda: HybridDualHead(CFG),\n    name     = 'mobilenet_hybrid',\n    cfg      = CFG\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-03-28T15:05:03.686143Z","iopub.execute_input":"2026-03-28T15:05:03.686875Z","iopub.status.idle":"2026-03-28T15:05:26.333452Z","shell.execute_reply.started":"2026-03-28T15:05:03.686846Z","shell.execute_reply":"2026-03-28T15:05:26.331873Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ── Final Statistics ──────────────────────────────────────────────────────────\nfrom sklearn.metrics import classification_report\n\ndef bootstrap_ci(preds, targets, n=1000):\n    preds, targets = np.array(preds), np.array(targets)\n    metric = CohenKappa(task='multiclass', num_classes=5, weights='quadratic')\n    boot = [metric(torch.tensor(preds[np.random.choice(len(preds), len(preds), replace=True)]),\n                   torch.tensor(targets[np.random.choice(len(targets), len(targets), replace=True)])).item()\n            for _ in range(n)]\n    return np.percentile(boot, [2.5, 97.5])\n\ndef load_cv_results(name, n_folds=5):\n    all_preds, all_targets, fold_qwks, fold_f1s = [], [], [], []\n    for fold in range(1, n_folds+1):\n        p = Path(f'/kaggle/working/{name}_fold{fold}_best.pth')\n        if not p.exists(): continue\n        ckpt = torch.load(p, map_location='cpu', weights_only=False)\n        all_preds.extend(ckpt['val_preds'])\n        all_targets.extend(ckpt['val_targets'])\n        fold_qwks.append(ckpt['val_qwk'])\n        fold_f1s.append(ckpt['val_f1'])\n    return all_preds, all_targets, fold_qwks, fold_f1s\n\nGRADE_NAMES = ['No DR', 'Mild', 'Moderate', 'Severe', 'PDR']\nsummary = []\n\nfor name, label in [('mobilenet_mlp', 'MobileNetV3 + MLP (baseline)'),\n                     ('mobilenet_hybrid', 'MobileNetV3 + Hybrid (MLP+KAN)')]:\n    preds, targets, fold_qwks, fold_f1s = load_cv_results(name)\n    if not preds:\n        print(f'{label}: no results'); continue\n\n    mean_qwk = np.mean(fold_qwks); std_qwk = np.std(fold_qwks)\n    macro_f1 = f1_score(targets, preds, average='macro', zero_division=0)\n    ci = bootstrap_ci(preds, targets)\n\n    print(f'\\n{label}:')\n    print(f'  QWK  : {mean_qwk:.4f} +/- {std_qwk:.4f}')\n    print(f'  95%CI: [{ci[0]:.4f}, {ci[1]:.4f}]')\n    print(f'  F1   : {macro_f1:.4f}')\n    print(classification_report(targets, preds, target_names=GRADE_NAMES))\n\n    # Confusion matrix\n    cm = confusion_matrix(targets, preds)\n    cm_norm = cm.astype(float) / cm.sum(axis=1, keepdims=True)\n    fig, ax = plt.subplots(figsize=(7,6))\n    sns.heatmap(cm_norm, annot=True, fmt='.2f', cmap='Blues',\n                xticklabels=GRADE_NAMES, yticklabels=GRADE_NAMES, ax=ax)\n    ax.set_title(f'{label} — APTOS 2019 (5-Fold CV)', fontsize=12)\n    ax.set_xlabel('Predicted'); ax.set_ylabel('True')\n    plt.tight_layout()\n    plt.savefig(f'/kaggle/working/cm_{name}.png', dpi=300, bbox_inches='tight')\n    plt.close()\n    print(f'  Saved: cm_{name}.png')\n\n    summary.append({'model': name, 'label': label,\n                    'mean_qwk': mean_qwk, 'std_qwk': std_qwk,\n                    'ci_lo': ci[0], 'ci_hi': ci[1], 'macro_f1': macro_f1})\n\npd.DataFrame(summary).to_csv('/kaggle/working/hybrid_comparison.csv', index=False)\nprint('\\n=== Summary ===')\nprint(pd.DataFrame(summary)[['label','mean_qwk','std_qwk','ci_lo','ci_hi','macro_f1']].to_string(index=False))","metadata":{},"outputs":[],"execution_count":null}]}