{"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":"none","dataSources":[],"dockerImageVersionId":28755,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"!pip install pydicom opencv-python -q","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-31T17:51:31.762918Z","iopub.execute_input":"2026-08-31T17:51:31.76331Z","iopub.status.idle":"2026-08-31T17:51:36.359122Z","shell.execute_reply.started":"2026-08-31T17:51:31.763281Z","shell.execute_reply":"2026-08-31T17:51:36.358308Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport pandas as pd\nimport numpy as np\n\n!pip install iterative-stratification -q\nfrom iterstrat.ml_stratifiers import MultilabelStratifiedKFold\n\nCOMPETITION_DATA_PATH = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\nif not os.path.exists(COMPETITION_DATA_PATH):\n    COMPETITION_DATA_PATH = \"/kaggle/input/rsna-knee-abnormality-detection\"\n\nLABEL_PATH = [\n    \"/kaggle/input/notebooks/abhinav4516788/rsna-phase-1-nlp-pseudo-labels/train_pseudo_labeled_part1.csv\",\n    \"/kaggle/input/notebooks/abhinav4516788/rsna-phase1-part2/train_pseudo_labeled_part2.csv\",\n    \"/kaggle/input/notebooks/abhinav4516788/rsna-phase1-part3/train_pseudo_labeled_part3.csv\",\n    \"/kaggle/input/notebooks/abhinavreddy6480/rsna-phase1-part4/train_pseudo_labeled_part4.csv\"\n]\n\ntarget_cols = [\n    \"ACL\", \"MCL\", \"Medial Meniscus\", \"Lateral Meniscus\",\n    \"Medial OA\", \"Lateral OA\", \"PF OA\", \"Effusion\",\n    \"Synovitis\", \"Baker's\", \"Contusion\", \"Fracture\"\n]\n\n# Column names for the validity mask (1 = label genuinely present, 0 = imputed)\nmask_cols = [f\"{col}__valid\" for col in target_cols]\n\nfor path in LABEL_PATH:\n    if not os.path.exists(path):\n        raise FileNotFoundError(f\"Missing pseudo-label file:\\n{path}\")\n\ntrain_csv = os.path.join(COMPETITION_DATA_PATH, \"train.csv\")\nif not os.path.exists(train_csv):\n    raise FileNotFoundError(f\"Missing official train.csv:\\n{train_csv}\")\n\n# Load pseudo-labels\nlabels = pd.concat([pd.read_csv(path) for path in LABEL_PATH], ignore_index=True)\ndf = labels.groupby(\"StudyInstanceUID\", as_index=False)[target_cols].max()\n\n# Load official train labels\nraw_train = pd.read_csv(train_csv)\n\n# Identify gold studies\ngold_df = raw_train.dropna(subset=[\"ACL\"]).copy()\ngold_uids = set(gold_df[\"StudyInstanceUID\"])\ndf[\"is_gold\"] = df[\"StudyInstanceUID\"].isin(gold_uids)\n\n# Overwrite pseudo-labels with official labels for gold studies\ndf = df.merge(gold_df[[\"StudyInstanceUID\"] + target_cols], on=\"StudyInstanceUID\", how=\"left\", suffixes=(\"\", \"_official\"))\n\n# --- Pseudo-label quality check, before we overwrite with official labels ---\nprint(\"=\" * 55)\nprint(\"PSEUDO-LABEL vs GOLD-LABEL AGREEMENT (on the 58 gold studies)\")\nprint(\"=\" * 55)\ngold_mask_check = df[f\"{target_cols[0]}_official\"].notna()\nfor col in target_cols:\n    pseudo_vals = df.loc[gold_mask_check, col].fillna(0).astype(int)\n    gold_vals = df.loc[gold_mask_check, f\"{col}_official\"].fillna(0).astype(int)\n    agreement = (pseudo_vals == gold_vals).mean()\n    gold_pos = gold_vals.sum()\n    recall = (((pseudo_vals == 1) & (gold_vals == 1)).sum() / gold_pos) if gold_pos > 0 else float(\"nan\")\n    pseudo_pos = pseudo_vals.sum()\n    precision = (((pseudo_vals == 1) & (gold_vals == 1)).sum() / pseudo_pos) if pseudo_pos > 0 else float(\"nan\")\n    print(f\"{col:<18}: agreement={agreement:.2f}  gold_positives={gold_pos:<3}  \"\n          f\"pseudo_recall={recall:.2f}  pseudo_precision={precision:.2f}\")\nprint(\"=\" * 55 + \"\\n\")\n\nfor col in target_cols:\n    df[col] = np.where(df[f\"{col}_official\"].notna(), df[f\"{col}_official\"], df[col])\n    df.drop(columns=[f\"{col}_official\"], inplace=True)\n\n# --- NaN rate per target, weak (pseudo-labeled) studies only ---\nprint(\"=\" * 55)\nprint(\"NaN RATE PER TARGET — weak (pseudo-labeled) studies only\")\nprint(\"=\" * 55)\nweak_only = df.loc[~df[\"is_gold\"], target_cols]\nfor col in target_cols:\n    nan_pct = weak_only[col].isna().mean()\n    print(f\"{col:<18}: {nan_pct:.1%} missing\")\nprint(\"=\" * 55 + \"\\n\")\n\n# --- NEW: build a validity mask BEFORE filling NaNs, so the training loop\n# can tell \"confirmed negative\" apart from \"never mentioned, imputed as 0\" ---\nfor col, mcol in zip(target_cols, mask_cols):\n    df[mcol] = df[col].notna().astype(np.float32)\n\nn_nan_before = df[target_cols].isna().sum().sum()\nif n_nan_before > 0:\n    print(f\"⚠ Found {n_nan_before} NaN label values — filling with 0, but marking them invalid in {mask_cols}.\")\ndf[target_cols] = df[target_cols].fillna(0)\n\ndf[\"fold\"] = -1\n\n# --- Stratify Gold and Weak separately to guarantee balance ---\ngold_mask = df[\"is_gold\"] == True\ny_gold = df[gold_mask][target_cols].fillna(0).astype(int).values\nmskf_gold = MultilabelStratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n\nfor fold, (_, val_idx) in enumerate(mskf_gold.split(df[gold_mask], y_gold)):\n    actual_indices = df[gold_mask].iloc[val_idx].index\n    df.loc[actual_indices, \"fold\"] = fold\n\nweak_mask = df[\"is_gold\"] == False\ny_weak = df[weak_mask][target_cols].fillna(0).astype(int).values\nmskf_weak = MultilabelStratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n\nfor fold, (_, val_idx) in enumerate(mskf_weak.split(df[weak_mask], y_weak)):\n    actual_indices = df[weak_mask].iloc[val_idx].index\n    df.loc[actual_indices, \"fold\"] = fold\n\nprint(\"========== FINAL SPLIT ==========\")\nprint(f\"Full dataset:   {len(df):,}\")\nprint(f\"Gold studies:   {df['is_gold'].sum():,}\")\nprint(\"\\nGold Validation Studies per Fold (No fold is empty!):\")\nprint(df[df[\"is_gold\"] == True][\"fold\"].value_counts().sort_index())\nprint(\"\\nDataset preparation complete.\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-08-31T17:51:36.360739Z","iopub.execute_input":"2026-08-31T17:51:36.361034Z","iopub.status.idle":"2026-08-31T17:51:41.575649Z","shell.execute_reply.started":"2026-08-31T17:51:36.361004Z","shell.execute_reply":"2026-08-31T17:51:41.574615Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport glob\nimport pandas as pd\n\nINPUT_ROOT = \"/kaggle/input\"\nCOMPETITION_DATA_PATH = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\nif not os.path.exists(COMPETITION_DATA_PATH):\n    COMPETITION_DATA_PATH = \"/kaggle/input/rsna-knee-abnormality-detection\"\n\nprint(\"Searching Kaggle input files...\")\nzip_files = sorted(glob.glob(f\"{INPUT_ROOT}/**/rsna-15slice*.zip\", recursive=True))\n\nif not zip_files:\n    raise FileNotFoundError(\"No rsna-15slice ZIP files found.\")\n\nprint(f\"Found {len(zip_files)} Phase 2 ZIP files:\")\nfor path in zip_files:\n    print(\"✓\", path)\n\nTRAIN_SERIES_PATH = os.path.join(COMPETITION_DATA_PATH, \"train_series.csv\")\n\nprint(\"\\nLocating train_series.csv...\")\nif not os.path.exists(TRAIN_SERIES_PATH):\n    raise FileNotFoundError(f\"Missing official train_series.csv:\\n{TRAIN_SERIES_PATH}\")\nelse:\n    print(\"✓ train_series.csv located successfully.\")\n\nseries_df = pd.read_csv(TRAIN_SERIES_PATH)\n\nif \"Anatomical_Plane\" in series_df.columns:\n    series_df = series_df.rename(columns={\"Anatomical_Plane\": \"AnatomicalPlane\"})\n\nrequired_cols = [\"StudyInstanceUID\", \"SeriesInstanceUID\", \"AnatomicalPlane\"]\nmissing = [c for c in required_cols if c not in series_df.columns]\nif missing:\n    raise ValueError(f\"train_series.csv missing columns: {missing}\")\n\nprint(\"\\n\" + \"=\" * 50)\nprint(\"PHASE 2 METADATA READY\")\nprint(\"=\" * 50)\nprint(f\"train_series.csv rows: {len(series_df):,}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-08-31T17:51:41.577006Z","iopub.execute_input":"2026-08-31T17:51:41.577447Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport shutil\nimport zipfile\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport pydicom\nfrom tqdm import tqdm\n\nprint(\"--- RECOVERING 17 MISSING STUDIES ---\")\n\nbroken_study_uids = [\n    \"1.2.826.0.1.3680043.8.498.64408609256163127278435484217683272910\",\n    \"1.2.826.0.1.3680043.8.498.64703506772167798469048460791472465039\",\n    \"1.2.826.0.1.3680043.8.498.65708905118633339771181857781063784327\",\n    \"1.2.826.0.1.3680043.8.498.67188121544063723837669983597453941774\",\n    \"1.2.826.0.1.3680043.8.498.69392348385274125385290015404144639002\",\n    \"1.2.826.0.1.3680043.8.498.72853333220043794904856138561095171921\",\n    \"1.2.826.0.1.3680043.8.498.73527530686853911124431549317032662220\",\n    \"1.2.826.0.1.3680043.8.498.73926443729786165628848843707532839995\",\n    \"1.2.826.0.1.3680043.8.498.75187434248356774277526985329346125190\",\n    \"1.2.826.0.1.3680043.8.498.77362718298550276679350855963451855003\",\n    \"1.2.826.0.1.3680043.8.498.78512215519177850923279235346968676828\",\n    \"1.2.826.0.1.3680043.8.498.82166943552764439138333504456139890254\",\n    \"1.2.826.0.1.3680043.8.498.86968600239724310678905311244945464037\",\n    \"1.2.826.0.1.3680043.8.498.88077418639301174409926781329613570435\",\n    \"1.2.826.0.1.3680043.8.498.90283565381042081768587894596970552767\",\n    \"1.2.826.0.1.3680043.8.498.94433753471890306108089557761231739312\",\n    \"1.2.826.0.1.3680043.8.498.97274720257634584071500649275217521662\",\n]\n\nCOMPETITION_DATA_PATH = \"/kaggle/input/competitions/rsna-knee-abnormality-detection\"\nif not os.path.exists(COMPETITION_DATA_PATH):\n    COMPETITION_DATA_PATH = \"/kaggle/input/rsna-knee-abnormality-detection\"\n\nRECOVERY_DIR = \"/kaggle/working/recovery_arrays\"\nos.makedirs(RECOVERY_DIR, exist_ok=True)\nIMG_SIZE, NUM_SLICES, TARGET_CROP_MM, CONTEXT_SCALE = 256, 15, 150.0, 1.15\n\n# [Helper functions compressed for Phase 3]\ndef normalize_laterality(image, dcm):\n    return np.fliplr(image) if str(getattr(dcm, \"ImageLaterality\", getattr(dcm, \"Laterality\", None))).upper() == \"L\" else image\n\ndef estimate_knee_center(image):\n    if image is None or image.size == 0: return None\n    h, w = image.shape[:2]\n    norm = cv2.GaussianBlur(cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX, dtype=cv2.CV_8U), (5, 5), 0)\n    _, mask = cv2.threshold(norm, max(10, int(np.percentile(norm, 5))), 255, cv2.THRESH_BINARY)\n    mask = cv2.morphologyEx(cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)), cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))\n    contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    valid = [c for c in contours if cv2.contourArea(c) >= 0.02 * h * w]\n    if not valid: valid = contours\n    if not valid: return w/2.0, h/2.0\n    moments = cv2.moments(max(valid, key=cv2.contourArea))\n    if moments[\"m00\"] > 0: return moments[\"m10\"] / moments[\"m00\"], moments[\"m01\"] / moments[\"m00\"]\n    x, y, wb, hb = cv2.boundingRect(max(valid, key=cv2.contourArea))\n    return x + wb / 2.0, y + hb / 2.0\n\ndef crop_knee(image, dcm, center):\n    if image is None or image.size == 0: return image\n    h, w = image.shape[:2]\n    cx, cy = center if center else (w/2.0, h/2.0)\n    crop_h, crop_w = None, None\n    if hasattr(dcm, \"PixelSpacing\"):\n        try:\n            if float(dcm.PixelSpacing[0]) > 0 and float(dcm.PixelSpacing[1]) > 0:\n                crop_h = int((TARGET_CROP_MM / float(dcm.PixelSpacing[0])) * CONTEXT_SCALE)\n                crop_w = int((TARGET_CROP_MM / float(dcm.PixelSpacing[1])) * CONTEXT_SCALE)\n        except: pass\n    if crop_h is None or crop_w is None: crop_h, crop_w = int(h * 0.75), int(w * 0.75)\n    cs = min(max(crop_h, crop_w, 64), h, w)\n    x1, y1 = int(round(cx - cs//2)), int(round(cy - cs//2))\n    x2, y2 = x1 + cs, y1 + cs\n    if x1 < 0: x2 -= x1; x1 = 0\n    if y1 < 0: y2 -= y1; y1 = 0\n    if x2 > w: x1 -= (x2 - w); x2 = w\n    if y2 > h: y1 -= (y2 - h); y2 = h\n    cropped = image[max(0, x1):min(w, x2), max(0, y1):min(h, y2)]\n    return cropped if cropped.size > 0 else image\n\ndef get_sorted_paths(folder):\n    files = [os.path.join(folder, f) for f in os.listdir(folder) if f.lower().endswith(\".dcm\")]\n    if not files: return []\n    hdrs = [(f, pydicom.dcmread(f, stop_before_pixels=True)) for f in files]\n    if hasattr(hdrs[0][1], \"ImagePositionPatient\") and hasattr(hdrs[0][1], \"ImageOrientationPatient\"):\n        try:\n            orient = hdrs[0][1].ImageOrientationPatient\n            norm_vec = np.cross(np.array(orient[:3], dtype=np.float32), np.array(orient[3:], dtype=np.float32))\n            return [f for f, _ in sorted(hdrs, key=lambda i: float(np.dot(norm_vec, np.array(i[1].ImagePositionPatient, dtype=np.float32))))]\n        except: pass\n    if hasattr(hdrs[0][1], \"SliceLocation\"):\n        return [f for f, _ in sorted(hdrs, key=lambda i: float(getattr(i[1], \"SliceLocation\", 0)))]\n    return sorted(files)\n\n# Extract subset from series_df (assuming series_df is already loaded in Phase 3 cell 2)\nsubset = series_df[series_df[\"StudyInstanceUID\"].isin(broken_study_uids)].copy()\n\n# Best series logic\nselected_series = []\nfor study_id, group in subset.groupby(\"StudyInstanceUID\"):\n    for plane in [\"sagittal\", \"coronal\", \"axial\"]:\n        plane_mask = group[\"AnatomicalPlane\"].astype(str).str.lower().str.contains(plane[:3], na=False)\n        plane_df = group[plane_mask].copy()\n        if len(plane_df) == 0: continue\n        plane_df[\"score\"] = 0\n        if \"SeriesDescription\" in plane_df.columns:\n            def score_desc(desc):\n                d = str(desc).lower(); s = 0\n                if plane in [\"sagittal\", \"coronal\"]:\n                    if any(k in d for k in [\"fs\", \"pd\", \"stir\", \"pdfs\"]): s += 10\n                    if \"t1\" in d: s -= 5\n                else:\n                    if \"t2\" in d: s += 10\n                    if \"t1\" in d: s += 5\n                return s\n            plane_df[\"score\"] = plane_df[\"SeriesDescription\"].apply(score_desc)\n        selected_series.append(plane_df.loc[plane_df[\"score\"].idxmax()])\nbest_subset = pd.DataFrame(selected_series).reset_index(drop=True)\n\n# Process them\nrecovered = 0\nfor _, row in tqdm(best_subset.iterrows(), total=len(best_subset)):\n    study_id, series_id = row[\"StudyInstanceUID\"], row[\"SeriesInstanceUID\"]\n    save_path = os.path.join(RECOVERY_DIR, f\"{study_id}_{series_id}.npy\")\n    if os.path.exists(save_path): continue\n    \n    folder = os.path.join(COMPETITION_DATA_PATH, \"train_images\", str(study_id), str(series_id))\n    if not os.path.exists(folder):\n        folder = os.path.join(COMPETITION_DATA_PATH, \"train_series\", str(study_id), str(series_id))\n    if not os.path.exists(folder): continue\n        \n    try:\n        paths = get_sorted_paths(folder)\n        if not paths: continue\n        n = len(paths)\n        lo, hi = int(0.1 * (n-1)), int(0.9 * (n-1))\n        idxs = np.unique(np.linspace(lo, hi, NUM_SLICES).astype(int)).tolist() if hi > lo else [n//2]\n        while len(idxs) < NUM_SLICES: idxs.append(idxs[-1])\n        \n        loc_idxs = np.unique(np.linspace(0, n-1, min(7, n)).astype(int)).tolist()\n        cx_list, cy_list = [], []\n        for i in loc_idxs:\n            try:\n                img = normalize_laterality(pydicom.dcmread(paths[i]).pixel_array.astype(np.float32), pydicom.dcmread(paths[i]))\n                c = estimate_knee_center(img)\n                if c: cx_list.append(c[0]); cy_list.append(c[1])\n            except: pass\n        series_center = (float(np.median(cx_list)), float(np.median(cy_list))) if cx_list else None\n        \n        frames = []\n        for idx in idxs:\n            slices = []\n            for offset in [-1, 0, 1]:\n                j = max(0, min(n-1, idx + offset))\n                dcm = pydicom.dcmread(paths[j])\n                img = normalize_laterality(dcm.pixel_array.astype(np.float32), dcm)\n                img = crop_knee(img, dcm, series_center)\n                slices.append(cv2.resize(img, (IMG_SIZE, IMG_SIZE), interpolation=cv2.INTER_LINEAR))\n            frames.append(np.stack(slices, axis=0))\n            \n        arr = np.stack(frames, axis=0).astype(np.float32)\n        p1, p99 = np.percentile(arr, (1, 99))\n        arr = np.clip(arr, p1, p99)\n        arr = (arr - p1) / (p99 - p1) if p99 > p1 else np.zeros_like(arr)\n        np.save(save_path, arr.astype(np.float16))\n        recovered += 1\n    except: pass\n\n# Zip it\nwith zipfile.ZipFile(\"/kaggle/working/rsna-15slice-recovery.zip\", \"w\", zipfile.ZIP_DEFLATED) as zf:\n    for fname in os.listdir(RECOVERY_DIR):\n        zf.write(os.path.join(RECOVERY_DIR, fname), arcname=fname)\nprint(f\"✓ Recovered {recovered} series. rsna-15slice-recovery.zip created.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport glob\nimport zipfile\n\nprint(\"Indexing Phase 2 NPY files...\")\n\n# SEARCH BOTH /kaggle/input AND /kaggle/working\nzip_files = sorted(glob.glob(\"/kaggle/input/**/rsna-15slice*.zip\", recursive=True)) + \\\n            sorted(glob.glob(\"/kaggle/working/rsna-15slice*.zip\", recursive=True))\n\nNPY_ZIP_MAP = {}\nnpy_count = 0\n\nfor zip_path in zip_files:\n    print(f\"\\nScanning: {os.path.basename(zip_path)}\")\n    with zipfile.ZipFile(zip_path, \"r\") as z:\n        for name in z.namelist():\n            if name.lower().endswith(\".npy\"):\n                key = os.path.basename(name)\n                if key in NPY_ZIP_MAP:\n                    pass # Only warn if we want, but earlier zips might have duplicates. We'll just overwrite.\n                NPY_ZIP_MAP[key] = (zip_path, name)\n                npy_count += 1\n\nprint(f\"\\n✓ NPY files indexed: {npy_count:,}\")\nif not NPY_ZIP_MAP:\n    raise FileNotFoundError(\"No NPY files found inside Phase 2 ZIPs.\")\nprint(\"\\nPHASE 2 NPY INDEX READY\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import io\nimport zipfile\nimport numpy as np\nimport torch\nfrom torch.utils.data import Dataset\n\nZERO_FALLBACK_COUNT = 0\nZERO_FALLBACK_TOTAL = 0\n\nclass RSNAMultiViewDataset(Dataset):\n    def __init__(self, df, series_df, npy_zip_map, seq_len=15):\n        self.df = df.reset_index(drop=True)\n        self.series_df = series_df\n        self.npy_zip_map = npy_zip_map\n        self.seq_len = seq_len\n        self.zip_handles = {}\n        self.series_grouped = self.series_df.groupby(\"StudyInstanceUID\")\n\n    def __len__(self):\n        return len(self.df)\n\n    def _get_zip(self, zip_path):\n        if zip_path not in self.zip_handles:\n            self.zip_handles[zip_path] = zipfile.ZipFile(zip_path, \"r\")\n        return self.zip_handles[zip_path]\n\n    def _load_npy(self, filename):\n        zip_path, internal_path = self.npy_zip_map[filename]\n        z = self._get_zip(zip_path)\n        with z.open(internal_path) as f:\n            data = np.load(io.BytesIO(f.read()))\n        return data\n\n    def _find_series_candidates(self, study_uid, plane):\n        if study_uid not in self.series_grouped.groups:\n            return []\n        study_series = self.series_grouped.get_group(study_uid)\n        plane_mask = study_series[\"AnatomicalPlane\"].astype(str).str.lower().str.contains(plane[:3].lower())\n        rows = study_series[plane_mask]\n        return rows[\"SeriesInstanceUID\"].tolist()\n\n    def _load_view(self, study_uid, plane):\n        global ZERO_FALLBACK_COUNT, ZERO_FALLBACK_TOTAL\n        ZERO_FALLBACK_TOTAL += 1\n\n        candidates = self._find_series_candidates(study_uid, plane)\n        filename = None\n        for series_uid in candidates:\n            candidate_name = f\"{study_uid}_{series_uid}.npy\"\n            if candidate_name in self.npy_zip_map:\n                filename = candidate_name\n                break\n\n        if filename is None:\n            ZERO_FALLBACK_COUNT += 1\n            return torch.zeros((self.seq_len, 3, 256, 256), dtype=torch.float32)\n\n        try:\n            volume = self._load_npy(filename)\n            volume = np.asarray(volume, dtype=np.float32)\n            volume = np.nan_to_num(volume, nan=0.0, posinf=0.0, neginf=0.0)\n            if volume.ndim != 4 or volume.shape[1] != 3:\n                raise ValueError(f\"Unexpected volume shape {volume.shape} for {filename}\")\n            volume = self._resize_sequence(volume)\n            return torch.from_numpy(volume.copy()).float()\n        except Exception as e:\n            print(f\"⚠ Failed to load {filename}: {e}\")\n            ZERO_FALLBACK_COUNT += 1\n            return torch.zeros((self.seq_len, 3, 256, 256), dtype=torch.float32)\n\n    def _resize_sequence(self, volume):\n        n_slices = volume.shape[0]\n        if n_slices == self.seq_len:\n            return volume\n        indices = np.linspace(0, n_slices - 1, self.seq_len).astype(int)\n        return volume[indices]\n\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        study_uid = row[\"StudyInstanceUID\"]\n\n        sagittal = self._load_view(study_uid, \"sagittal\")\n        coronal = self._load_view(study_uid, \"coronal\")\n        axial = self._load_view(study_uid, \"axial\")\n\n        labels = torch.tensor(row[target_cols].values.astype(np.float32))\n        label_mask = torch.tensor(row[mask_cols].values.astype(np.float32))  # NEW\n\n        return sagittal, coronal, axial, labels, label_mask\n\n    def __del__(self):\n        for z in self.zip_handles.values():\n            try:\n                z.close()\n            except Exception:\n                pass\n\n\ndef print_zero_fallback_stats():\n    if ZERO_FALLBACK_TOTAL == 0:\n        print(\"No view loads recorded yet.\")\n        return\n    ratio = ZERO_FALLBACK_COUNT / ZERO_FALLBACK_TOTAL\n    print(f\"Zero-fallback views: {ZERO_FALLBACK_COUNT:,} / {ZERO_FALLBACK_TOTAL:,} ({ratio:.2%})\")\n    if ratio > 0.02:\n        print(\"⚠ More than 2% of view-loads are blank — check zip/UID coverage before trusting any AUC number.\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport timm\n\nclass SequenceMultiViewModel(nn.Module):\n    def __init__(self, backbone_name=\"convnext_small\", num_classes=12,\n                 embed_dim=512, pretrained=True, seq_len=15):\n        super().__init__()\n        self.seq_len = seq_len\n        self.num_classes = num_classes\n        self.embed_dim = embed_dim\n\n        self.backbone = timm.create_model(\n            backbone_name, pretrained=pretrained, num_classes=0\n        )\n        self.backbone_dim = self.backbone.num_features\n        \n        self.rnn = nn.GRU(\n            input_size=self.backbone_dim,\n            hidden_size=embed_dim // 2,\n            num_layers=2,\n            batch_first=True,\n            bidirectional=True,\n            dropout=0.2\n        )\n\n        self.target_queries = nn.Parameter(\n            torch.randn(num_classes, embed_dim) * 0.02\n        )\n\n        self.cross_attn = nn.MultiheadAttention(\n            embed_dim=embed_dim,\n            num_heads=8,\n            batch_first=True,\n            dropout=0.1\n        )\n        \n        self.classifier = nn.Linear(embed_dim, 1)\n\n    def forward(self, sagittal_x, coronal_x, axial_x):\n        x = torch.stack([sagittal_x, coronal_x, axial_x], dim=1)\n        B, V, S, C, H, W = x.shape\n\n        if S != self.seq_len:\n            raise ValueError(f\"Expected {self.seq_len} slices, received {S}.\")\n        if C != 3:\n            raise ValueError(f\"Expected 3 channels, received {C}.\")\n\n        x = x.reshape(B * V * S, C, H, W)\n        \n        features = self.backbone(x)\n        features = features.reshape(B * V, S, self.backbone_dim)\n\n        _, h_n = self.rnn(features)\n        fused = torch.cat([h_n[-2], h_n[-1]], dim=-1)\n        fused = fused.reshape(B, V, self.embed_dim)\n\n        queries = self.target_queries.unsqueeze(0).expand(B, -1, -1)\n\n        attn_out, _ = self.cross_attn(\n            query=queries,\n            key=fused,\n            value=fused\n        )\n        \n        return self.classifier(attn_out).squeeze(-1)\n\n# Architecture test\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Using device: {device}\")\n\nmodel = SequenceMultiViewModel(\n    backbone_name=\"convnext_small\",\n    num_classes=12,\n    embed_dim=512,\n    pretrained=False,\n    seq_len=15\n).to(device)\n\ndummy_sagittal = torch.randn(4, 15, 3, 256, 256, device=device)\ndummy_coronal = torch.randn(4, 15, 3, 256, 256, device=device)\ndummy_axial = torch.randn(4, 15, 3, 256, 256, device=device)\n\nwith torch.no_grad():\n    output = model(dummy_sagittal, dummy_coronal, dummy_axial)\n\nprint(\"\\n✓ Model successfully built & stripped to match Phase 4 exactly.\")\nprint(f\"Output shape: {tuple(output.shape)}\")","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import shutil, glob, os\n\n\nfound = glob.glob(\"/kaggle/input/**/best_model_fold*.pth\", recursive=True)\n\nif not found:\n    print(\"⚠ No prior checkpoints found under /kaggle/input — did you attach all 4 previous version outputs?\")\n\nfor ckpt in found:\n    dest = os.path.join(\"/kaggle/working\", os.path.basename(ckpt))\n    if not os.path.exists(dest):\n        shutil.copy(ckpt, dest)\n        print(f\"Restored {os.path.basename(ckpt)} from {ckpt}\")\n    else:\n        print(f\"{os.path.basename(ckpt)} already in /kaggle/working, skipping\")\n\nprint(\"\\nCheckpoints currently in /kaggle/working:\")\nfor f in sorted(glob.glob(\"/kaggle/working/best_model_fold*.pth\")):\n    print(\" -\", os.path.basename(f))","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport torch\nimport torch.nn as nn\nimport numpy as np\nfrom sklearn.metrics import roc_auc_score\nfrom torch.utils.data import DataLoader\nfrom tqdm import tqdm\n\nTARGET_COLS = target_cols\nMASK_COLS = mask_cols\n\nBATCH_SIZE = 1\nSTAGE1_EPOCHS = 3\nSTAGE2_EPOCHS = 2\nLR = 1e-4\nWEIGHT_DECAY = 1e-5\nNUM_WORKERS = 0\nACCUMULATION_STEPS = 4\nMAX_POS_WEIGHT = 20.0\n\nFOLDS_TO_RUN = [2,3,4]  # override e.g. FOLDS_TO_RUN = [2, 3] for a specific batch\n\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\ndef compute_pos_weight(frame, cols, mcols):\n    \"\"\"pos_weight computed only from genuinely-labeled (non-imputed) rows.\"\"\"\n    y = frame[cols].values.astype(np.float32)\n    m = frame[mcols].values.astype(np.float32)\n    pos = (y * m).sum(axis=0)\n    neg = (m * (1 - y)).sum(axis=0)\n    weight = np.where(pos > 0, neg / np.clip(pos, 1, None), MAX_POS_WEIGHT)\n    weight = np.clip(weight, 1.0, MAX_POS_WEIGHT)\n    return torch.tensor(weight, dtype=torch.float32)\n\ndef compute_val_auc(preds, targets, cols):\n    aucs = []\n    for i in range(len(cols)):\n        y_true = targets[:, i]\n        if len(np.unique(y_true)) < 2:\n            continue\n        aucs.append(roc_auc_score(y_true, preds[:, i]))\n    return float(np.mean(aucs)) if aucs else float(\"nan\")\n\nprint(\"=\" * 55)\nprint(\"STARTING 5-FOLD TWO-STAGE TRAINING\")\nprint(\"=\" * 55)\n\nfor current_fold in FOLDS_TO_RUN:\n    checkpoint_path = f\"best_model_fold{current_fold}.pth\"\n\n    if os.path.exists(checkpoint_path):\n        print(f\"\\nFold {current_fold} already has a checkpoint — skipping.\")\n        continue\n\n    print(f\"\\n{'=' * 55}\\nTRAINING FOLD {current_fold}\\n{'=' * 55}\")\n\n    train_mixed_df = df[df[\"fold\"] != current_fold].reset_index(drop=True)\n    train_gold_df = train_mixed_df[train_mixed_df[\"is_gold\"] == True].reset_index(drop=True)\n    val_df = df[(df[\"fold\"] == current_fold) & (df[\"is_gold\"] == True)].reset_index(drop=True)\n\n    print(f\"Stage 1 (Mixed) rows: {len(train_mixed_df):,}\")\n    print(f\"Stage 2 (Gold) rows:  {len(train_gold_df):,}\")\n    print(f\"Validation rows:      {len(val_df):,}\")\n\n    if len(val_df) == 0:\n        raise ValueError(f\"Fold {current_fold} has no gold validation data.\")\n\n    train_mixed_dataset = RSNAMultiViewDataset(train_mixed_df, series_df, NPY_ZIP_MAP)\n    train_gold_dataset = RSNAMultiViewDataset(train_gold_df, series_df, NPY_ZIP_MAP)\n    val_dataset = RSNAMultiViewDataset(val_df, series_df, NPY_ZIP_MAP)\n\n    train_mixed_loader = DataLoader(train_mixed_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=NUM_WORKERS, pin_memory=torch.cuda.is_available())\n    train_gold_loader = DataLoader(train_gold_dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=NUM_WORKERS, pin_memory=torch.cuda.is_available())\n    val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=NUM_WORKERS, pin_memory=torch.cuda.is_available())\n\n    model = SequenceMultiViewModel(\n        backbone_name=\"convnext_small\", pretrained=True,\n        num_classes=12, embed_dim=512, seq_len=15\n    ).to(device)\n\n    stage1_pos_weight = compute_pos_weight(train_mixed_df, TARGET_COLS, MASK_COLS).to(device)\n    stage2_pos_weight = compute_pos_weight(train_gold_df, TARGET_COLS, MASK_COLS).to(device)\n    criterion = nn.BCEWithLogitsLoss(pos_weight=stage1_pos_weight, reduction=\"none\")\n\n    optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\n\n    use_amp = torch.cuda.is_available()\n    scaler = torch.amp.GradScaler(\"cuda\", enabled=use_amp)\n\n    best_val_auc = -1.0\n\n    def masked_loss(logits, labels, mask):\n        raw = criterion(logits, labels)          # (B, 12), unreduced\n        return (raw * mask).sum() / mask.sum().clamp(min=1.0)\n\n    def train_epoch(loader, epoch, total_epochs, stage_name):\n        model.train()\n        train_loss = 0.0\n        n_valid_batches = 0\n        n_skipped = 0\n        optimizer.zero_grad(set_to_none=True)\n        progress = tqdm(loader, desc=f\"{stage_name} | Fold {current_fold} | Epoch {epoch + 1}/{total_epochs}\")\n\n        for step, batch in enumerate(progress):\n            sag_x, cor_x, axi_x, labels, mask = batch\n            sag_x, cor_x, axi_x = sag_x.to(device, non_blocking=True), cor_x.to(device, non_blocking=True), axi_x.to(device, non_blocking=True)\n            labels, mask = labels.to(device, non_blocking=True).float(), mask.to(device, non_blocking=True).float()\n\n            with torch.amp.autocast(device_type=\"cuda\", enabled=use_amp):\n                logits = model(sag_x, cor_x, axi_x)\n                loss = masked_loss(logits, labels, mask) / ACCUMULATION_STEPS\n\n            if not torch.isfinite(loss):\n                n_skipped += 1\n                optimizer.zero_grad(set_to_none=True)\n                continue\n\n            scaler.scale(loss).backward()\n\n            if (step + 1) % ACCUMULATION_STEPS == 0 or (step + 1) == len(loader):\n                scaler.step(optimizer)\n                scaler.update()\n                optimizer.zero_grad(set_to_none=True)\n\n            train_loss += loss.item() * ACCUMULATION_STEPS\n            n_valid_batches += 1\n            progress.set_postfix(loss=f\"{loss.item() * ACCUMULATION_STEPS:.4f}\", skipped=n_skipped)\n\n        if n_skipped > 0:\n            print(f\"⚠ Skipped {n_skipped} NaN/Inf batches this epoch.\")\n\n        return train_loss / max(n_valid_batches, 1)\n\n    def validate():\n        model.eval()\n        val_loss = 0.0\n        all_preds, all_targets = [], []\n        with torch.no_grad():\n            for batch in val_loader:\n                sag_x, cor_x, axi_x, labels, mask = batch\n                sag_x, cor_x, axi_x = sag_x.to(device, non_blocking=True), cor_x.to(device, non_blocking=True), axi_x.to(device, non_blocking=True)\n                labels, mask = labels.to(device, non_blocking=True).float(), mask.to(device, non_blocking=True).float()\n\n                with torch.amp.autocast(device_type=\"cuda\", enabled=use_amp):\n                    logits = model(sag_x, cor_x, axi_x)\n                    loss = masked_loss(logits, labels, mask)\n                if torch.isfinite(loss):\n                    val_loss += loss.item()\n\n                all_preds.append(torch.sigmoid(logits).float().cpu().numpy())\n                all_targets.append(labels.cpu().numpy())\n\n        preds_np = np.vstack(all_preds)\n        targets_np = np.vstack(all_targets)\n        mean_auc = compute_val_auc(preds_np, targets_np, TARGET_COLS)\n        return val_loss / max(len(val_loader), 1), mean_auc\n\n    print(\"\\n--- STAGE 1: Mixed Data Pre-training ---\")\n    for epoch in range(STAGE1_EPOCHS):\n        t_loss = train_epoch(train_mixed_loader, epoch, STAGE1_EPOCHS, \"Stage 1\")\n        v_loss, v_auc = validate()\n        print(f\"Stage 1 | Epoch {epoch + 1}/{STAGE1_EPOCHS} | Train Loss: {t_loss:.4f} | Val Loss: {v_loss:.4f} | Val AUC: {v_auc:.4f}\")\n        if v_auc > best_val_auc:\n            best_val_auc = v_auc\n            torch.save(model.state_dict(), checkpoint_path)\n            print(f\"✓ Saved best model (Val AUC: {v_auc:.4f})\")\n\n    print(\"\\n--- STAGE 2: Gold Data Fine-tuning ---\")\n    for p in model.backbone.parameters():\n        p.requires_grad = False\n    for param_group in optimizer.param_groups:\n        param_group['lr'] = LR * 0.1\n    criterion = nn.BCEWithLogitsLoss(pos_weight=stage2_pos_weight, reduction=\"none\")\n\n    for epoch in range(STAGE2_EPOCHS):\n        t_loss = train_epoch(train_gold_loader, epoch, STAGE2_EPOCHS, \"Stage 2\")\n        v_loss, v_auc = validate()\n        print(f\"Stage 2 | Epoch {epoch + 1}/{STAGE2_EPOCHS} | Train Loss: {t_loss:.4f} | Val Loss: {v_loss:.4f} | Val AUC: {v_auc:.4f}\")\n        if v_auc > best_val_auc:\n            best_val_auc = v_auc\n            torch.save(model.state_dict(), checkpoint_path)\n            print(f\"✓ Saved best model (Val AUC: {v_auc:.4f})\")\n\n    print(f\"\\nFold {current_fold} best validation AUC: {best_val_auc:.4f}\")\n    print_zero_fallback_stats()\n\n    del model, train_mixed_loader, train_gold_loader, val_loader, train_mixed_dataset, train_gold_dataset, val_dataset, optimizer\n    if torch.cuda.is_available():\n        torch.cuda.empty_cache()\n\nprint(\"\\n\" + \"=\" * 55 + \"\\nALL FOLDS COMPLETE (OR SKIPPED IF CHECKPOINTED)\\n\" + \"=\" * 55)\nprint_zero_fallback_stats()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport torch\nimport numpy as np\nimport pandas as pd\nfrom sklearn.metrics import roc_auc_score\nfrom torch.utils.data import DataLoader\nfrom tqdm import tqdm\n\nprint(\"==========================================\")\nprint(\"STARTING OUT-OF-FOLD (OOF) EVALUATION\")\nprint(\"==========================================\")\n\nall_oof_preds = []\nall_oof_targets = []\nall_study_ids = []\n\nfor current_fold in [2,3,4]:\n    print(f\"\\nEvaluating Fold {current_fold}...\")\n\n    val_df = df[(df[\"fold\"] == current_fold) & (df[\"is_gold\"] == True)].reset_index(drop=True)\n    if len(val_df) == 0:\n        raise ValueError(f\"Fold {current_fold} contains zero gold validation studies.\")\n\n    checkpoint_path = f\"best_model_fold{current_fold}.pth\"\n    if not os.path.exists(checkpoint_path):\n        print(f\"⚠ Checkpoint not found for fold {current_fold}, skipping: {checkpoint_path}\")\n        continue\n\n    val_dataset = RSNAMultiViewDataset(val_df, series_df, NPY_ZIP_MAP)\n    val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE, shuffle=False, num_workers=0, pin_memory=True)\n\n    model = SequenceMultiViewModel(\n        backbone_name=\"convnext_small\", pretrained=False, seq_len=15, num_classes=12, embed_dim=512\n    ).to(device)\n    model.load_state_dict(torch.load(checkpoint_path, map_location=device))\n    model.eval()\n\n    running_index = 0\n    with torch.no_grad():\n        for sag_x, cor_x, axi_x, labels, _mask in tqdm(val_loader, desc=f\"Fold {current_fold}\"):\n            sag_x, cor_x, axi_x = sag_x.to(device), cor_x.to(device), axi_x.to(device)\n\n            logits = model(sag_x, cor_x, axi_x)\n            probs = torch.sigmoid(logits).cpu().numpy()\n            targets = labels.cpu().numpy()\n\n            all_oof_preds.append(probs)\n            all_oof_targets.append(targets)\n\n            batch_size_actual = probs.shape[0]\n            batch_ids = val_df[\"StudyInstanceUID\"].iloc[running_index : running_index + batch_size_actual].tolist()\n            all_study_ids.extend(batch_ids)\n            running_index += batch_size_actual\n\n    del model, val_loader, val_dataset\n    if torch.cuda.is_available():\n        torch.cuda.empty_cache()\n\nprint_zero_fallback_stats()\n\noof_preds_np = np.vstack(all_oof_preds)\noof_targets_np = np.vstack(all_oof_targets)\n\nprint(f\"\\nOOF predictions: {oof_preds_np.shape} | Study IDs: {len(all_study_ids):,}\")\nprint(\"\\n🏆 FINAL PHASE 3 OOF AUC SCORES 🏆\\n\" + \"-\" * 40)\nauc_scores = []\nfor i, target in enumerate(target_cols):\n    try:\n        auc = roc_auc_score(oof_targets_np[:, i], oof_preds_np[:, i])\n        print(f\"{target:<18}: {auc:.4f}\")\n        auc_scores.append(auc)\n    except ValueError:\n        print(f\"{target:<18}: Error (Only one class present)\")\nprint(\"-\" * 40 + f\"\\nMEAN AUC          : {np.mean(auc_scores):.4f}\")\n\noof_df = pd.DataFrame(oof_preds_np, columns=[f\"{col}_pred\" for col in target_cols])\noof_df.insert(0, \"StudyInstanceUID\", all_study_ids)\noof_df.to_csv(\"phase3_oof_predictions.csv\", index=False)\nprint(\"\\n✓ OOF predictions saved: phase3_oof_predictions.csv\")","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}