{"cells":[{"cell_type":"markdown","metadata":{},"source":"# RSNA Knee Abnormality Detection: ConvNeXt-Tiny MRI baseline\n\nThis notebook keeps the MRI pipeline fixed and replaces only the ResNet backbone with a pretrained ConvNeXt-Tiny multilabel classifier.\n"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"from pathlib import Path\nimport random, warnings\nimport numpy as np\nimport pandas as pd\nimport pydicom\nfrom PIL import Image\nimport torch\nfrom torch import nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision.models import convnext_tiny, ConvNeXt_Tiny_Weights\n\nSEED = 42\nrandom.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)\ndevice = 'cpu'\nlabels = ['ACL', 'MCL', 'Medial Meniscus', 'Lateral Meniscus', 'Medial OA', 'Lateral OA', 'PF OA', 'Effusion', 'Synovitis', \"Baker's\", 'Contusion', 'Fracture']\nroot = next(p for p in Path('/kaggle/input').glob('**/train.csv') if (p.parent / 'sample_submission.csv').exists())\ntrain = pd.read_csv(root); test = pd.read_csv(root.parent / 'test.csv')\ntrain_series = pd.read_csv(root.parent / 'train_series.csv'); test_series = pd.read_csv(root.parent / 'test_series.csv')\ngold = train.dropna(subset=labels).reset_index(drop=True)\nprint(device, 'gold studies:', len(gold), 'test studies:', len(test))"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"def choose_series(df):\n    x = df.copy()\n    x['_score'] = 2 * pd.to_numeric(x.get('Fluid_Sensitive', 0), errors='coerce').fillna(0) + pd.to_numeric(x.get('Fat_Suppression', 0), errors='coerce').fillna(0)\n    plane = x.get('Anatomical_Plane', '').fillna('').astype(str).str.lower()\n    x.loc[plane.str.contains('sagittal'), '_score'] += 2\n    x.loc[plane.str.contains('coronal'), '_score'] += 1\n    selected = {}\n    for study, group in x.groupby('StudyInstanceUID'):\n        chosen = []\n        for wanted in ['sagittal', 'coronal', 'axial']:\n            match = group[group['Anatomical_Plane'].fillna('').astype(str).str.lower().eq(wanted)]\n            if len(match): chosen.append(match.sort_values('_score', ascending=False).iloc[0]['SeriesInstanceUID'])\n        if len(chosen) < 3:\n            for sid in group.sort_values('_score', ascending=False)['SeriesInstanceUID']:\n                if sid not in chosen: chosen.append(sid)\n                if len(chosen) == 3: break\n        selected[study] = chosen[:3]\n    return selected\n\ndef ordered_dicoms(folder):\n    paths = list(Path(folder).glob('*.dcm'))\n    rows = []\n    for path in paths:\n        try:\n            ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True)\n            pos = np.asarray(getattr(ds, 'ImagePositionPatient', []), dtype='float32')\n            ori = np.asarray(getattr(ds, 'ImageOrientationPatient', []), dtype='float32')\n            if pos.size == 3 and ori.size == 6:\n                normal = np.cross(ori[:3], ori[3:])\n                coord = float(np.dot(pos, normal))\n            else:\n                coord = float(getattr(ds, 'InstanceNumber', 0))\n        except Exception:\n            coord = 0.0\n        rows.append((coord, path))\n    return [path for _, path in sorted(rows, key=lambda item: item[0])]\n\ndef read_slice(path, size=224):\n    ds = pydicom.dcmread(str(path), force=True)\n    arr = ds.pixel_array.astype('float32')\n    arr = arr * float(getattr(ds, 'RescaleSlope', 1.0)) + float(getattr(ds, 'RescaleIntercept', 0.0))\n    lo, hi = np.percentile(arr, [1, 99]) if arr.max() > arr.min() else (arr.min(), arr.min() + 1)\n    arr = np.clip((arr - lo) / (hi - lo), 0, 1)\n    return np.asarray(Image.fromarray((arr * 255).astype('uint8')).resize((size, size)), dtype='float32') / 255\n\nclass KneeDataset(Dataset):\n    def __init__(self, frame, series, image_root, train_mode):\n        self.frame = frame.reset_index(drop=True); self.series = choose_series(series); self.image_root = Path(image_root); self.train_mode = train_mode; self.cache = {}\n    def __len__(self): return len(self.frame)\n    def __getitem__(self, i):\n        row = self.frame.iloc[i]; study = row.StudyInstanceUID\n        if study not in self.cache:\n            images = []\n            for sid in self.series.get(study, []):\n                paths = ordered_dicoms(self.image_root / str(study) / str(sid))\n                if paths: images.append(read_slice(paths[len(paths) // 2]))\n            if not images: warnings.warn(f'No DICOM files for {study}')\n            while len(images) < 3: images.append(np.zeros((224,224), dtype='float32'))\n            self.cache[study] = np.stack(images)\n        x = torch.tensor(self.cache[study]).float()\n        x = (x - 0.5) / 0.25\n        if self.train_mode and random.random() < 0.5: x = torch.flip(x, dims=[2])\n        y = torch.tensor(row[labels].to_numpy(dtype='float32')) if all(c in row for c in labels) else torch.zeros(len(labels))\n        return x, y, study"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"train_root = root.parent / 'train_series'; test_root = root.parent / 'test_series'\nperm = np.random.default_rng(SEED).permutation(len(gold)); cut = max(1, int(len(gold) * 0.8)); tr = gold.iloc[perm[:cut]].reset_index(drop=True); va = gold.iloc[perm[cut:]].reset_index(drop=True)\ntrain_ds = KneeDataset(tr, train_series, train_root, True); val_ds = KneeDataset(va, train_series, train_root, False)\ntrain_loader = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=2, pin_memory=True); val_loader = DataLoader(val_ds, batch_size=8, num_workers=2, pin_memory=True)\nmodel = convnext_tiny(weights=ConvNeXt_Tiny_Weights.DEFAULT); model.classifier[2] = nn.Linear(model.classifier[2].in_features, len(labels)); model.to(device)\nopt = torch.optim.AdamW(model.parameters(), lr=2e-4, weight_decay=1e-5); loss_fn = nn.BCEWithLogitsLoss(); best = float('inf'); best_state = None\nfor epoch in range(5):\n    model.train()\n    for x, y, _ in train_loader:\n        opt.zero_grad(); loss_fn(model(x.to(device)), y.to(device)).backward(); opt.step()\n    model.eval(); losses = []\n    with torch.no_grad():\n        for x, y, _ in val_loader: losses.append(loss_fn(model(x.to(device)), y.to(device)).item())\n    score = float(np.mean(losses)); print({'epoch': epoch + 1, 'val_loss': score})\n    if score < best: best = score; best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}\nif best_state is not None: model.load_state_dict(best_state)"},{"cell_type":"code","execution_count":null,"metadata":{},"outputs":[],"source":"model.eval(); test_ds = KneeDataset(test, test_series, test_root, False); test_loader = DataLoader(test_ds, batch_size=4, num_workers=2)\npreds = []\nwith torch.no_grad():\n    for x, _, _ in test_loader:\n        p = torch.sigmoid(model(x.to(device)))\n        p_flip = torch.sigmoid(model(torch.flip(x.to(device), dims=[3])))\n        preds.append(((p + p_flip) / 2).cpu().numpy())\npreds = np.concatenate(preds)\nsubmission = pd.read_csv(root.parent / 'sample_submission.csv')\nalphas = [16, 2, 16, 16, 2, 2, 8, 16, 16, 2, 4, 4]\nprior = np.array([(gold[label].astype(float).sum() + alpha) / (len(gold) + 2 * alpha) for label, alpha in zip(labels, alphas)])\nsubmission[labels] = 0.25 * preds + 0.75 * prior\nsubmission.to_csv('/kaggle/working/submission.csv', index=False)\ndisplay(submission.head())"}],"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10"}},"nbformat":4,"nbformat_minor":5}