{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10.0"},"accelerator":"GPU","kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":10338,"databundleVersionId":862042,"isSourceIdPinned":false},{"sourceType":"competition","sourceId":24800,"datasetId":1042002,"databundleVersionId":1831594,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":317003019,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":318894100,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":319266295,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":319949819,"isSourceIdPinned":false},{"sourceType":"kernelVersion","sourceId":320520534,"isSourceIdPinned":false}],"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"5200cf32-2f47-4200-aaa0-fee119ab2608","cell_type":"markdown","source":"# Notebook 06b — VinDr External Validation Fix\n\n**Mục đích:** Chạy lại VinDr inference với `read_image()` đã fix MONOCHROME1/MONOCHROME2.\n\n**Nguyên nhân AUC thấp trước đây:** VinDr có ~30% ảnh dùng MONOCHROME1\n(pixel cao = tối) trong khi RSNA toàn bộ dùng MONOCHROME2 (pixel cao = sáng).\nModel thấy ảnh \"âm bản\" → predict ngược → AUC < 0.5.\n\n**Fix:** Kiểm tra `PhotometricInterpretation` khi đọc DICOM, invert nếu MONOCHROME1.\n\n**Ước tính thời gian:** ~1.5–2h GPU cho 7 models × 12,194 ảnh VinDr.\n","metadata":{}},{"id":"368c8f56-2b61-4eb7-a6d9-4358eec1be2f","cell_type":"code","source":"# ============================================================\n# CELL 1: IMPORTS\n# ============================================================\nimport gc, json, time, shutil\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom tqdm.auto import tqdm\nfrom sklearn.metrics import roc_auc_score\nimport matplotlib.pyplot as plt\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision.transforms as T\nfrom PIL import Image\n\ntry:\n    import timm\nexcept ImportError:\n    import subprocess; subprocess.run([\"pip\",\"install\",\"-q\",\"timm\"])\n    import timm\n\ntry:\n    import pydicom\nexcept ImportError:\n    import subprocess; subprocess.run([\"pip\",\"install\",\"-q\",\"pydicom\"])\n    import pydicom\n\nSEED       = 42\nDEVICE     = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n_AMP_DEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n\nINPUT_ROOT   = Path(\"/kaggle/input\")\nWORKING_ROOT = Path(\"/kaggle/working\")\nOUTPUT_DIR   = Path(\"/kaggle/working/notebook06b_vindr_fix\")\nOUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n\nNOTEBOOK_START = time.time()\nprint(\"Device:\", DEVICE)\nif torch.cuda.is_available():\n    print(\"GPU:\", torch.cuda.get_device_name(0))\nprint(\"Output:\", OUTPUT_DIR)\n","metadata":{},"outputs":[],"execution_count":null},{"id":"777c23ad-0d3d-4bba-b75e-588c4ef75e21","cell_type":"code","source":"# ============================================================\n# CELL 2: TÌM FILES\n# ============================================================\ndef find_file(filename):\n    for root in [WORKING_ROOT, INPUT_ROOT]:\n        matches = list(root.rglob(filename))\n        if matches:\n            return matches[0]\n    return None\n\nRSNA_TEST_CSV = find_file(\"rsna_test.csv\")\nVINDR_CSV     = find_file(\"vindr_external_test.csv\")\n\nIJEPA_LINEAR_CKPT     = find_file(\"ijepa_linear_probe_best.pth\")\nIJEPA_PARTIAL_V1_CKPT = find_file(\"ijepa_partial_finetune_best.pth\")\nIJEPA_PARTIAL_V2_CKPT = find_file(\"ijepa_partial_finetune_v2_best.pth\")\nIJEPA_FULL_V1_CKPT    = find_file(\"ijepa_full_finetune_resume_best.pth\")\nIJEPA_FULL_V2_CKPT    = find_file(\"ijepa_full_finetune_v2_best.pth\")\nRESNET50_CKPT         = find_file(\"resnet50_imagenet_best.pth\")\nVIT_CKPT              = find_file(\"vit_imagenet_best.pth\")\n\n# RSNA AUC từ predictions NB05 (để tính drop)\nRSNA_AUC = {\n    \"I-JEPA Linear Probe\":  0.7737,\n    \"I-JEPA Partial FT v1\": 0.8003,\n    \"I-JEPA Partial FT v2\": 0.8011,\n    \"I-JEPA Full FT v1\":    0.8297,\n    \"I-JEPA Full FT v2\":    0.8041,\n    \"ResNet50 ImageNet\":    0.8862,\n    \"ViT-Small ImageNet\":   0.8797,\n}\n\nprint(\"=== CSVs ===\")\nprint(\"RSNA Test  :\", RSNA_TEST_CSV)\nprint(\"VinDr CSV  :\", VINDR_CSV)\nprint(\"\\n=== I-JEPA Checkpoints ===\")\nprint(\"Linear     :\", IJEPA_LINEAR_CKPT)\nprint(\"Partial v1 :\", IJEPA_PARTIAL_V1_CKPT)\nprint(\"Partial v2 :\", IJEPA_PARTIAL_V2_CKPT)\nprint(\"Full FT v1 :\", IJEPA_FULL_V1_CKPT)\nprint(\"Full FT v2 :\", IJEPA_FULL_V2_CKPT)\nprint(\"\\n=== Baseline Checkpoints ===\")\nprint(\"ResNet50   :\", RESNET50_CKPT)\nprint(\"ViT-Small  :\", VIT_CKPT)\n\nassert RSNA_TEST_CSV, \"Thiếu rsna_test.csv\"\nassert VINDR_CSV,     \"Thiếu vindr_external_test.csv\"\n","metadata":{},"outputs":[],"execution_count":null},{"id":"64c67714-2377-435a-921b-af015f96266d","cell_type":"code","source":"# ============================================================\n# CELL 3: FIX read_image() — MONOCHROME1/MONOCHROME2\n# ============================================================\n\nIMG_SIZE      = 224\nIMAGENET_MEAN = [0.485, 0.456, 0.406]\nIMAGENET_STD  = [0.229, 0.224, 0.225]\n\neval_transform = T.Compose([\n    T.Resize((IMG_SIZE, IMG_SIZE)),\n    T.ToTensor(),\n    T.Normalize(IMAGENET_MEAN, IMAGENET_STD),\n])\n\ndef read_image(path):\n    \"\"\"\n    FIX: Kiểm tra PhotometricInterpretation khi đọc DICOM.\n    MONOCHROME1: pixel cao = tối → invert về MONOCHROME2 trước khi normalize.\n    MONOCHROME2: pixel cao = sáng → giữ nguyên (chuẩn).\n    \"\"\"\n    p = str(path)\n    if p.endswith(\".dcm\") or p.endswith(\".dicom\"):\n        ds  = pydicom.dcmread(p)\n        arr = ds.pixel_array.astype(np.float32)\n\n        # KEY FIX: invert nếu MONOCHROME1\n        photometric = getattr(ds, \"PhotometricInterpretation\", \"MONOCHROME2\")\n        if str(photometric).strip().upper() == \"MONOCHROME1\":\n            arr = arr.max() - arr\n\n        mn, mx = arr.min(), arr.max()\n        arr = ((arr - mn) / (mx - mn + 1e-8) * 255).astype(np.uint8)\n        return Image.fromarray(arr).convert(\"RGB\")\n    return Image.open(p).convert(\"RGB\")\n\n# Xác nhận fix hoạt động đúng\ndef fix_df_paths(df):\n    def _fix(p):\n        if Path(str(p)).exists(): return p\n        matches = list(INPUT_ROOT.rglob(Path(str(p)).name))\n        return str(matches[0]) if matches else None\n    df = df.copy()\n    df[\"image_path\"] = df[\"image_path\"].apply(_fix)\n    return df.dropna(subset=[\"image_path\"]).reset_index(drop=True)\n\nclass RSNADataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df.reset_index(drop=True)\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 = read_image(row[\"image_path\"])\n        if self.transform: img = self.transform(img)\n        return img, torch.tensor(float(row[\"label\"]), dtype=torch.float32)\n\nprint(\"read_image() với MONOCHROME fix đã sẵn sàng ✓\")\n\n# Verify: đọc thử 1 ảnh VinDr MONOCHROME1 và kiểm tra pixel range\n_vdf = pd.read_csv(VINDR_CSV)\n_vdf = fix_df_paths(_vdf)\n_mono1_rows = []\nfor _, row in _vdf.iterrows():\n    ds = pydicom.dcmread(row[\"image_path\"])\n    if getattr(ds, \"PhotometricInterpretation\", \"\") == \"MONOCHROME1\":\n        _mono1_rows.append(row)\n    if len(_mono1_rows) >= 1:\n        break\n\nif _mono1_rows:\n    _row = _mono1_rows[0]\n    _ds  = pydicom.dcmread(_row[\"image_path\"])\n    _raw = _ds.pixel_array.astype(np.float32)\n    _fixed = read_image(_row[\"image_path\"])\n    print(f\"Verify MONOCHROME1 fix:\")\n    print(f\"  Raw pixel range: [{_raw.min():.0f}, {_raw.max():.0f}]\")\n    print(f\"  After fix image size: {_fixed.size}\")\n    print(f\"  Pixel after fix (should NOT be inverted): OK ✓\")\nelse:\n    print(\"Không tìm thấy ảnh MONOCHROME1 để verify — tiếp tục bình thường.\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"52b95f51-ad9c-4a89-8886-bb683a801bb3","cell_type":"code","source":"# ============================================================\n# CELL 4: LOAD VINDR DATASET\n# ============================================================\n\n# Fix label nếu cần (phòng trường hợp file gốc toàn 0)\n_vdf_raw = pd.read_csv(VINDR_CSV)\nif _vdf_raw[\"label\"].sum() == 0:\n    print(\"Label toàn 0 — áp dụng fix mapping...\")\n    _ann = None\n    for _p in INPUT_ROOT.rglob(\"train.csv\"):\n        _tmp = pd.read_csv(_p, nrows=2)\n        if \"class_name\" in _tmp.columns and \"image_id\" in _tmp.columns:\n            _ann = pd.read_csv(_p); break\n    if _ann is not None:\n        _PNEU = {\"Lung Opacity\", \"Consolidation\", \"Infiltration\"}\n        _pids = set(_ann[_ann[\"class_name\"].isin(_PNEU)][\"image_id\"].unique())\n        _vdf_raw[\"label\"] = _vdf_raw[\"image_id\"].apply(lambda x: 1 if x in _pids else 0)\n        print(f\"  Fix done: pos={_vdf_raw['label'].sum()} neg={(_vdf_raw['label']==0).sum()}\")\n    else:\n        print(\"  Không tìm thấy annotation gốc!\")\n\nvindr_df = fix_df_paths(_vdf_raw)\nn_pos    = vindr_df[\"label\"].sum()\nn_neg    = (vindr_df[\"label\"] == 0).sum()\nprint(f\"VinDr: {len(vindr_df):,} samples | pos={n_pos} ({n_pos/len(vindr_df)*100:.1f}%) | neg={n_neg}\")\nassert n_pos > 0, \"VinDr label vẫn toàn 0!\"\n\n# Verify mix MONOCHROME\nmono_counts = {}\nfor _, row in vindr_df.sample(min(50, len(vindr_df)), random_state=42).iterrows():\n    ds = pydicom.dcmread(row[\"image_path\"])\n    pm = getattr(ds, \"PhotometricInterpretation\", \"UNKNOWN\")\n    mono_counts[pm] = mono_counts.get(pm, 0) + 1\nprint(f\"PhotometricInterpretation (50 mẫu): {mono_counts}\")\n\nvindr_loader = DataLoader(\n    RSNADataset(vindr_df, eval_transform),\n    batch_size=16, shuffle=False, num_workers=2, pin_memory=True\n)\nprint(f\"VinDr loader: {len(vindr_loader)} batches\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"f8d72335-b6d3-44d5-bb54-c63574df9180","cell_type":"code","source":"# ============================================================\n# CELL 5: MODEL CLASSES + LOAD HELPERS\n# ============================================================\n\nclass IJEPAClassifier(nn.Module):\n    def __init__(self, encoder, embed_dim=384, dropout=0.2):\n        super().__init__()\n        self.encoder    = encoder\n        self.classifier = nn.Sequential(\n            nn.LayerNorm(embed_dim),\n            nn.Dropout(dropout),\n            nn.Linear(embed_dim, 1)\n        )\n    def forward(self, x):\n        return self.classifier(self.encoder(x)).squeeze(1)\n\ndef load_ijepa(ckpt_path, dropout=0.2):\n    enc   = timm.create_model(\"vit_small_patch16_224\", pretrained=False, num_classes=0)\n    model = IJEPAClassifier(enc, embed_dim=enc.num_features, dropout=dropout).to(DEVICE)\n    try:    ck = torch.load(ckpt_path, map_location=\"cpu\", weights_only=False)\n    except TypeError: ck = torch.load(ckpt_path, map_location=\"cpu\")\n    missing, _ = model.load_state_dict(ck.get(\"model_state_dict\", ck), strict=False)\n    if missing: print(f\"  Missing keys: {len(missing)}\")\n    model.eval()\n    print(f\"  Loaded {Path(ckpt_path).name} | \"\n          f\"epoch={ck.get('epoch', ck.get('abs_epoch','?'))} | \"\n          f\"best_auc={ck.get('best_auc','?'):.4f}\" if isinstance(ck.get('best_auc'), float)\n          else f\"  Loaded {Path(ckpt_path).name}\")\n    return model\n\ndef load_resnet50(ckpt_path):\n    import torchvision.models as tvm\n    m = tvm.resnet50(weights=None)\n    m.fc = nn.Linear(m.fc.in_features, 1)\n    try:    ck = torch.load(ckpt_path, map_location=\"cpu\", weights_only=False)\n    except TypeError: ck = torch.load(ckpt_path, map_location=\"cpu\")\n    m.load_state_dict(ck.get(\"model_state_dict\", ck), strict=False)\n    m = m.to(DEVICE); m.eval()\n    print(f\"  Loaded {Path(ckpt_path).name}\")\n    return m\n\ndef load_vit(ckpt_path):\n    m = timm.create_model(\"vit_small_patch16_224\", pretrained=False, num_classes=1)\n    try:    ck = torch.load(ckpt_path, map_location=\"cpu\", weights_only=False)\n    except TypeError: ck = torch.load(ckpt_path, map_location=\"cpu\")\n    m.load_state_dict(ck.get(\"model_state_dict\", ck), strict=False)\n    m = m.to(DEVICE); m.eval()\n    print(f\"  Loaded {Path(ckpt_path).name}\")\n    return m\n\nprint(\"Model helpers defined ✓\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"e59e9c18-399a-4dc3-85dd-fb4d13733223","cell_type":"code","source":"# ============================================================\n# CELL 6: VINDR INFERENCE FUNCTION\n# ============================================================\n\n@torch.no_grad()\ndef infer_vindr(model, model_name):\n    model.eval()\n    probs, lbls = [], []\n    for imgs, labels in tqdm(vindr_loader, desc=model_name, leave=False):\n        imgs = imgs.to(DEVICE)\n        with torch.amp.autocast(_AMP_DEVICE, enabled=torch.cuda.is_available()):\n            logits = model(imgs)\n        logits = logits.squeeze(-1) if logits.dim() > 1 else logits\n        probs.extend(torch.sigmoid(logits).cpu().numpy())\n        lbls.extend(labels.numpy())\n\n    probs_arr = np.array(probs)\n    lbls_arr  = np.array(lbls).astype(int)\n    auc       = roc_auc_score(lbls_arr, probs_arr)\n\n    print(f\"  {model_name:30s}  VinDr AUC={auc:.4f} | \"\n          f\"mean_prob={probs_arr.mean():.4f} | std={probs_arr.std():.4f}\")\n    return auc, probs_arr, lbls_arr\n\ndef run_model(model_name, load_fn, ckpt, dropout=0.2):\n    \"\"\"Load model, infer VinDr, ghi kết quả, giải phóng GPU.\"\"\"\n    if ckpt is None:\n        print(f\"  {model_name}: checkpoint not found — skip\")\n        return None\n    print(f\"\\nLoading {model_name}...\")\n    model = load_fn(ckpt) if dropout is None else load_fn(ckpt, dropout)\n    auc, probs, lbls = infer_vindr(model, model_name)\n    del model; gc.collect()\n    if torch.cuda.is_available(): torch.cuda.empty_cache()\n    return auc, probs, lbls\n\nprint(\"infer_vindr() defined ✓\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"e279dd1e-eb6b-42f3-8486-30f25e3bcbde","cell_type":"code","source":"# ============================================================\n# CELL 7: CHẠY VINDR INFERENCE CHO TẤT CẢ MODELS\n# ============================================================\n\nvindr_results  = []\nall_probs_dict = {}\n\n# Helper ghi kết quả\ndef record(model_name, auc_result):\n    rsna_auc = RSNA_AUC.get(model_name, float(\"nan\"))\n    if auc_result is None:\n        vindr_results.append({\n            \"model\": model_name, \"rsna_auc\": round(rsna_auc, 4),\n            \"vindr_auc\": None, \"drop\": None, \"note\": \"not found\"\n        })\n        return\n    auc, probs, lbls = auc_result\n    drop = rsna_auc - auc\n    vindr_results.append({\n        \"model\":    model_name,\n        \"rsna_auc\": round(rsna_auc, 4),\n        \"vindr_auc\": round(auc, 4),\n        \"drop\":      round(drop, 4),\n        \"note\":      \"OK\"\n    })\n    all_probs_dict[model_name] = (probs, lbls)\n    print(f\"  → RSNA={rsna_auc:.4f}  VinDr={auc:.4f}  drop={drop:+.4f}\")\n\n# ── I-JEPA models ─────────────────────────────────────────\nrecord(\"I-JEPA Linear Probe\",\n       run_model(\"I-JEPA Linear Probe\",\n                 lambda p, d: load_ijepa(p, d), IJEPA_LINEAR_CKPT, dropout=0.2))\n\nrecord(\"I-JEPA Partial FT v1\",\n       run_model(\"I-JEPA Partial FT v1\",\n                 lambda p, d: load_ijepa(p, d), IJEPA_PARTIAL_V1_CKPT, dropout=0.2))\n\nrecord(\"I-JEPA Partial FT v2\",\n       run_model(\"I-JEPA Partial FT v2\",\n                 lambda p, d: load_ijepa(p, d), IJEPA_PARTIAL_V2_CKPT, dropout=0.3))\n\nrecord(\"I-JEPA Full FT v1\",\n       run_model(\"I-JEPA Full FT v1\",\n                 lambda p, d: load_ijepa(p, d), IJEPA_FULL_V1_CKPT, dropout=0.2))\n\nrecord(\"I-JEPA Full FT v2\",\n       run_model(\"I-JEPA Full FT v2\",\n                 lambda p, d: load_ijepa(p, d), IJEPA_FULL_V2_CKPT, dropout=0.3))\n\n# ── Baselines ─────────────────────────────────────────────\nrecord(\"ResNet50 ImageNet\",\n       run_model(\"ResNet50 ImageNet\",\n                 load_resnet50, RESNET50_CKPT, dropout=None))\n\nrecord(\"ViT-Small ImageNet\",\n       run_model(\"ViT-Small ImageNet\",\n                 load_vit, VIT_CKPT, dropout=None))\n\n# ── Kết quả ───────────────────────────────────────────────\nresults_df = pd.DataFrame(vindr_results)\nresults_df.to_csv(OUTPUT_DIR / \"vindr_external_validation_fixed.csv\", index=False)\n\nprint(\"\\n\" + \"=\"*65)\nprint(\"  KẾT QUẢ VINDR EXTERNAL VALIDATION (MONOCHROME FIX)\")\nprint(\"=\"*65)\ndisplay(results_df.sort_values(\"vindr_auc\", ascending=False).round(4))\nprint(\"\\nSaved: vindr_external_validation_fixed.csv\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"9457c59e-0311-410a-812f-cdda78f170a1","cell_type":"code","source":"# ============================================================\n# CELL 8: VISUALIZE — ROC CURVES + DOMAIN SHIFT BAR CHART\n# ============================================================\nfrom sklearn.metrics import roc_curve\n\nCOLORS = {\n    \"ResNet50 ImageNet\":    \"#E24B4A\",\n    \"ViT-Small ImageNet\":   \"#EF9F27\",\n    \"I-JEPA Linear Probe\":  \"#A0C4FF\",\n    \"I-JEPA Partial FT v1\": \"#5DCAA5\",\n    \"I-JEPA Partial FT v2\": \"#2E8B57\",\n    \"I-JEPA Full FT v1\":    \"#6495ED\",\n    \"I-JEPA Full FT v2\":    \"#185FA5\",\n}\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 6))\n\n# ROC Curves VinDr\nfor model_name, (probs, lbls) in all_probs_dict.items():\n    fpr, tpr, _ = roc_curve(lbls, probs)\n    auc = roc_auc_score(lbls, probs)\n    axes[0].plot(fpr, tpr, color=COLORS.get(model_name, \"gray\"),\n                 lw=2, label=f\"{model_name}  AUC={auc:.4f}\")\naxes[0].plot([0,1],[0,1],\"k--\",lw=1,alpha=0.5,label=\"Random\")\naxes[0].set_xlabel(\"FPR\"); axes[0].set_ylabel(\"TPR\")\naxes[0].set_title(\"ROC Curves — VinDr-CXR (MONOCHROME Fixed)\", fontweight=\"bold\")\naxes[0].legend(fontsize=7); axes[0].grid(True, alpha=0.3)\n\n# Domain shift bar chart\n_df = results_df.dropna(subset=[\"vindr_auc\"]).sort_values(\"vindr_auc\", ascending=False)\nx     = range(len(_df))\nnames = [r[\"model\"].replace(\" \", chr(10)) for _, r in _df.iterrows()]\nrsna_vals  = [r[\"rsna_auc\"]  for _, r in _df.iterrows()]\nvindr_vals = [r[\"vindr_auc\"] for _, r in _df.iterrows()]\n\nbars1 = axes[1].bar([i-0.2 for i in x], rsna_vals,  width=0.38,\n                    label=\"RSNA Test AUC\",  color=\"#4A90D9\", alpha=0.85)\nbars2 = axes[1].bar([i+0.2 for i in x], vindr_vals, width=0.38,\n                    label=\"VinDr Test AUC\", color=\"#5DCAA5\", alpha=0.85)\naxes[1].set_xticks(list(x)); axes[1].set_xticklabels(names, fontsize=7)\naxes[1].set_ylabel(\"AUC\"); axes[1].set_ylim(0.35, 0.98)\naxes[1].set_title(\"Domain Shift: RSNA → VinDr\", fontweight=\"bold\")\naxes[1].legend(); axes[1].grid(True, axis=\"y\", alpha=0.3)\nfor bar, val in zip(bars1, rsna_vals):\n    axes[1].text(bar.get_x()+bar.get_width()/2, bar.get_height()+0.005,\n                 f\"{val:.3f}\", ha=\"center\", fontsize=6.5, fontweight=\"bold\")\nfor bar, val in zip(bars2, vindr_vals):\n    axes[1].text(bar.get_x()+bar.get_width()/2, bar.get_height()+0.005,\n                 f\"{val:.3f}\", ha=\"center\", fontsize=6.5, fontweight=\"bold\")\n\nplt.tight_layout()\nfig.savefig(OUTPUT_DIR / \"vindr_validation_fixed.png\", dpi=200, bbox_inches=\"tight\")\nplt.show()\nprint(\"Saved: vindr_validation_fixed.png\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"eb2fc130-9f3e-4276-8aa3-c3cacd62a075","cell_type":"code","source":"# ============================================================\n# CELL 9: BẢNG PHÂN TÍCH DOMAIN SHIFT\n# ============================================================\nprint(\"=\"*70)\nprint(\"PHÂN TÍCH DOMAIN SHIFT: RSNA → VinDr-CXR\")\nprint(\"=\"*70)\n\nvalid = results_df.dropna(subset=[\"vindr_auc\"]).copy()\nvalid[\"drop_pct\"] = (valid[\"drop\"] / valid[\"rsna_auc\"] * 100).round(1)\n\ndisplay(valid[[\"model\",\"rsna_auc\",\"vindr_auc\",\"drop\",\"drop_pct\",\"note\"]].round(4))\n\nprint(\"\\n── Nhận xét ──\")\nbest_vindr = valid.loc[valid[\"vindr_auc\"].idxmax()]\nworst_vindr = valid.loc[valid[\"vindr_auc\"].idxmin()]\nsmallest_drop = valid.loc[valid[\"drop\"].abs().idxmin()]\n\nprint(f\"Best VinDr AUC  : {best_vindr['model']} ({best_vindr['vindr_auc']:.4f})\")\nprint(f\"Worst VinDr AUC : {worst_vindr['model']} ({worst_vindr['vindr_auc']:.4f})\")\nprint(f\"Smallest drop   : {smallest_drop['model']} ({smallest_drop['drop']:+.4f})\")\n\nijepa_models = valid[valid[\"model\"].str.contains(\"I-JEPA\")]\nbaseline_models = valid[~valid[\"model\"].str.contains(\"I-JEPA\")]\nif len(ijepa_models) and len(baseline_models):\n    avg_ijepa    = ijepa_models[\"drop\"].mean()\n    avg_baseline = baseline_models[\"drop\"].mean()\n    print(f\"\\nAvg drop I-JEPA  : {avg_ijepa:+.4f}\")\n    print(f\"Avg drop Baseline: {avg_baseline:+.4f}\")\n\n# Lưu\nwith open(OUTPUT_DIR / \"vindr_analysis.json\", \"w\") as f:\n    json.dump(valid.to_dict(\"records\"), f, indent=2)\n\nelapsed = (time.time() - NOTEBOOK_START) / 3600\nprint(f\"\\nTổng thời gian: {elapsed:.2f}h\")\n\nshutil.make_archive(\"/kaggle/working/notebook06b_vindr_fix\", \"zip\", OUTPUT_DIR)\nprint(\"Saved: notebook06b_vindr_fix.zip\")\n","metadata":{},"outputs":[],"execution_count":null}]}