{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.11"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"8e7a1842-730b-4bad-973c-f74f61b45f28","cell_type":"markdown","source":"# CXR-CAD · Account A · Phase 1 · Bước 2 — Freeze backbone, train Head chính thức (mục 6)\n\nKiến trúc CUỐI theo mục 4: `DetectionHead` (patch-level conv1x1 + attention-MIL\npooling trên chính patch-score map, **không** CLS riêng, **không** head thứ 2).\nLoss: focal + hard-negative-mining (patch-level) + weighted BCE (image-level),\ncân bằng bằng **Uncertainty Weighting** (Kendall et al. 2018, học `log(σ²)` cho\nmỗi loss — theo đúng xác nhận của bạn, không dùng trọng số cố định thủ công).\n\nBackbone vẫn **frozen** ở bước này (chỉ train Head) — LoRA là bước sau (mục 6).\n\n**Trước khi chạy:**\n1. Add Data → dataset competition **VinBigData Chest X-ray Abnormalities Detection**.\n2. Add Data → dataset Output **Account A đã publish** ở bước linear probe (chứa\n   `wbf_cache_full.json`, `size_map.json`, `train_ids.json`, `val_ids.json`) —\n   không tự chạy lại WBF/DICOM-header, dùng đúng 1 bản đã có (mục 11).\n3. (Tuỳ chọn, khuyến nghị) Add Data → dataset **cache preprocessing** đã publish\n   từ Account B (`cache_resized/`, ~15.000 file `.npy`) — cache này dùng chung\n   an toàn cho MỌI account/config (đã verify kỹ, xem lịch sử review). Nếu có,\n   set `CFG.CACHE_INPUT_DIR` ở mục 1 cho khớp path — bỏ qua hẳn được bước build\n   cache lại từ đầu.\n4. Bật Internet: On.\n","metadata":{}},{"id":"e759cd21-4240-4d12-b593-37ee6cab96ca","cell_type":"code","source":"# ============================================================\n# 0. Cài đặt / import\n# ============================================================\n!pip install -q -U transformers ensemble-boxes timm\n\nimport os, json, math, time, random, warnings\nwarnings.filterwarnings(\"ignore\")\n\nimport numpy as np\nimport pandas as pd\nimport pydicom\nimport cv2\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\n\nfrom transformers import AutoImageProcessor, AutoModel\nfrom sklearn.metrics import roc_auc_score, average_precision_score\n\nSEED = 42\nrandom.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(\"Device:\", DEVICE)\n\n# FIX (port từ bước linear probe): mốc thời gian tính từ đầu notebook, dùng để\n# tính ngân sách thời gian an toàn trước khi Kaggle cắt session ở 12h (mục 8).\nNOTEBOOK_START_TIME = time.time()\n","metadata":{},"outputs":[],"execution_count":null},{"id":"5fde8f85-e286-4ccf-aac2-a72758e6a6d0","cell_type":"markdown","source":"## 1. Config","metadata":{}},{"id":"7c23e373-2b35-4ecb-ac0d-d17c5356f021","cell_type":"code","source":"# ============================================================\n# 1. Config\n# ============================================================\nimport glob\n\ndef _find_data_root():\n    \"\"\"Tự tìm thư mục chứa train.csv trong /kaggle/input (không hardcode tên\n    dataset), báo lỗi rõ ràng nếu chưa Add Data. Kaggle mount competition data\n    lồng thêm 1 cấp (/kaggle/input/competitions/<slug>/train.csv) nên tìm đệ quy.\"\"\"\n    candidates = sorted(glob.glob(\"/kaggle/input/**/train.csv\", recursive=True))\n    if not candidates:\n        existing = os.listdir(\"/kaggle/input\") if os.path.isdir(\"/kaggle/input\") else []\n        raise FileNotFoundError(\n            \"Khong tim thay train.csv trong bat ky thu muc nao duoi /kaggle/input/. \"\n            \"Vao Add Data -> Competitions -> tim 'VinBigData Chest X-ray \"\n            \"Abnormalities Detection' va add vao notebook truoc khi chay tiep. \"\n            f\"Cac thu muc dang co trong /kaggle/input: {existing}\"\n        )\n    if len(candidates) > 1:\n        print(f\"Canh bao: tim thay nhieu train.csv, dung cai dau tien: {candidates}\")\n    return os.path.dirname(candidates[0])\n\n\ndef _find_account_a_dir():\n    \"\"\"Tự dò dataset Output Account A (bước linear probe) đã publish, qua glob\n    đệ quy tìm wbf_cache_full.json -- không hardcode path (đã từng vỡ vì Kaggle\n    mount dataset qua /kaggle/input/datasets/<username>/<slug>/ thay vì thẳng\n    /kaggle/input/<slug>/).\"\"\"\n    candidates = sorted(glob.glob(\"/kaggle/input/**/wbf_cache_full.json\", recursive=True))\n    if not candidates:\n        existing = os.listdir(\"/kaggle/input\") if os.path.isdir(\"/kaggle/input\") else []\n        raise FileNotFoundError(\n            \"Khong tim thay wbf_cache_full.json trong bat ky thu muc nao duoi \"\n            \"/kaggle/input/. Vao Add Data -> chon dung Kaggle Dataset Output cua \"\n            \"chinh Account A (buoc linear probe) truoc khi chay tiep. \"\n            f\"Cac thu muc dang co trong /kaggle/input: {existing}\"\n        )\n    if len(candidates) > 1:\n        print(f\"Canh bao: tim thay nhieu wbf_cache_full.json, dung cai dau tien: {candidates}\")\n    return os.path.dirname(candidates[0])\n\n\nclass CFG:\n    DATA_ROOT = _find_data_root()\n    TRAIN_CSV = f\"{DATA_ROOT}/train.csv\"\n    TRAIN_DICOM_DIR = f\"{DATA_ROOT}/train\"\n\n    # WBF cache + size_map + train/val split -- LOAD LẠI từ bước linear probe đã\n    # publish (mục 11: \"tiền xử lý 1 lần\"), KHÔNG tự chạy lại WBF/đọc DICOM header.\n    _ACCOUNT_A_DIR = _find_account_a_dir()\n    WBF_CACHE_PATH = f\"{_ACCOUNT_A_DIR}/wbf_cache_full.json\"\n    SIZE_MAP_PATH = f\"{_ACCOUNT_A_DIR}/size_map.json\"\n    TRAIN_IDS_PATH = f\"{_ACCOUNT_A_DIR}/train_ids.json\"\n    VAL_IDS_PATH = f\"{_ACCOUNT_A_DIR}/val_ids.json\"\n\n    RADDINO_ID = \"microsoft/rad-dino\"\n\n    CLASS_NAMES = [\n        \"Aortic enlargement\", \"Atelectasis\", \"Calcification\", \"Cardiomegaly\",\n        \"Consolidation\", \"ILD\", \"Infiltration\", \"Lung Opacity\",\n        \"Nodule/Mass\", \"Other lesion\", \"Pleural effusion\", \"Pleural thickening\",\n        \"Pneumothorax\", \"Pulmonary fibrosis\",\n    ]\n    NUM_CLASSES = len(CLASS_NAMES)\n    NO_FINDING_ID = 14\n\n    # ---- Training (Head, backbone frozen) ----\n    VAL_FRACTION = 0.15  # không dùng để split (đã load train_ids/val_ids), giữ lại cho tham chiếu\n    BATCH_SIZE = 8\n    NUM_WORKERS = max(1, min((os.cpu_count() or 4) - 1, 4))\n    PIN_MEMORY = (DEVICE.type == \"cuda\")\n    PREFETCH_FACTOR = 4\n    LR_HEAD = 1e-3\n    WEIGHT_DECAY = 1e-4\n    EPOCHS = 8\n    MAX_TRAIN_IMAGES = None\n\n    # ---- Loss (mục 6) ----\n    FOCAL_GAMMA = 2.0\n    FOCAL_ALPHA = 0.25\n    NEG_POS_RATIO = 3\n    ATTN_DIM = 128\n\n    # ---- Evaluation (mục 7): IoU@nhiều ngưỡng, tách theo nhóm kích thước tổn\n    # thương. Nhóm tính theo SỐ PATCH dương thật (nhất quán với lưới patch dùng\n    # cho mọi thứ khác trong pipeline, không cần quy đổi lại diện tích pixel).\n    IOU_THRESHOLDS = (0.1, 0.3, 0.5)\n    LESION_SIZE_BINS = (4, 15)  # nhỏ: <=4 patch | trung bình: <=15 | còn lại: lớn\n\n    KAGGLE_SESSION_LIMIT_SECONDS = 12 * 3600\n    TIME_BUDGET_SECONDS = KAGGLE_SESSION_LIMIT_SECONDS - 3600\n\n    CKPT_DIR = \"/kaggle/working/checkpoints\"\n\n    # Cache preprocessing dùng chung (đã build/verify từ trước, xem Account B\n    # run01) -- set path sau khi Add Data, bỏ qua hẳn bước build cache lại.\n    CACHE_DIR = \"/kaggle/working/cache_resized\"\n    CACHE_INPUT_DIR = None   # vd \"/kaggle/input/datasets/<user>/cxr-cad-cache-resized\"\n\n    RESUME_CKPT_INPUT_DIR = None   # vd \"/kaggle/input/cxr-cad-a-head-prev-run/checkpoints\"\n    LOG_CSV = \"/kaggle/working/run_log.csv\"\n    ACCOUNT = \"A\"\n    RUN_ID = \"A-head-frozen-attnmil\"\n\nos.makedirs(CFG.CKPT_DIR, exist_ok=True)\nprint(f\"Run: {CFG.RUN_ID}\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"9c1fbba4-0bc4-4432-95d8-9845f483647a","cell_type":"markdown","source":"## 2. Load WBF cache + size_map + train/val split (đã publish từ bước linear probe)\n\nKhông tự chạy lại WBF hay đọc DICOM header -- dùng đúng 1 bản đã có, đúng\nnguyên tắc \"tiền xử lý 1 lần, publish\" (mục 11).","metadata":{}},{"id":"cc2116e2-caf5-4704-89d2-315888116666","cell_type":"code","source":"df_raw = pd.read_csv(CFG.TRAIN_CSV)\n\nwith open(CFG.WBF_CACHE_PATH) as f:\n    wbf_cache = json.load(f)\nprint(f\"Loaded cached WBF for {len(wbf_cache)} images (từ bước linear probe, không tự chạy lại)\")\n\nwith open(CFG.SIZE_MAP_PATH) as f:\n    size_map = {k: tuple(v) for k, v in json.load(f).items()}\nprint(f\"Loaded size_map for {len(size_map)} images\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"ca9f91c1-d73e-4fcf-89f3-9a1f9b964398","cell_type":"markdown","source":"## 3. Nhãn image-level","metadata":{}},{"id":"a0b1c50a-2a73-4a3b-b1c7-aaeba3109f2d","cell_type":"code","source":"def image_level_labels_from_wbf(wbf_boxes, num_classes=CFG.NUM_CLASSES):\n    y = np.zeros(num_classes, dtype=np.float32)\n    for box in wbf_boxes:\n        if box[\"class_id\"] < num_classes:\n            y[box[\"class_id\"]] = 1.0\n    return y\n\nimage_ids_available = list(wbf_cache.keys())\n\nif CFG.MAX_TRAIN_IMAGES:\n    image_ids_available = image_ids_available[:CFG.MAX_TRAIN_IMAGES]\n    print(f\"MAX_TRAIN_IMAGES={CFG.MAX_TRAIN_IMAGES} -> chỉ dùng {len(image_ids_available)} ảnh \"\n          f\"đầu tiên trong wbf_cache đã publish (chạy thử nhanh)\")\n\nlabels_map = {img_id: image_level_labels_from_wbf(wbf_cache[img_id]) for img_id in image_ids_available}\n","metadata":{},"outputs":[],"execution_count":null},{"id":"056c0f32-6c97-4a78-8fd9-a2beec558e59","cell_type":"markdown","source":"## 4. DICOM loading (windowing) + preprocessing + cache","metadata":{}},{"id":"9064e403-6de5-470d-878f-47dd1391e18a","cell_type":"code","source":"processor = AutoImageProcessor.from_pretrained(CFG.RADDINO_ID)\nPATCH_SIZE = 14  # ViT-B/14\n\ndef load_dicom_as_rgb(path):\n    \"\"\"RescaleSlope/Intercept + VOI LUT (WindowCenter/WindowWidth) thay vì chỉ\n    min-max toàn ảnh -- min-max thô có thể bị kéo méo bởi outlier (marker, vùng\n    ngoài collimation), nén mất dải tương phản mô phổi thật.\"\"\"\n    dcm = pydicom.dcmread(path)\n    arr = dcm.pixel_array.astype(np.float32)\n\n    slope = float(getattr(dcm, \"RescaleSlope\", 1.0))\n    intercept = float(getattr(dcm, \"RescaleIntercept\", 0.0))\n    arr = arr * slope + intercept\n\n    wc, ww = getattr(dcm, \"WindowCenter\", None), getattr(dcm, \"WindowWidth\", None)\n    if wc is not None and ww is not None:\n        wc = float(wc[0]) if hasattr(wc, \"__iter__\") else float(wc)\n        ww = float(ww[0]) if hasattr(ww, \"__iter__\") else float(ww)\n        lo, hi = wc - ww / 2, wc + ww / 2\n        arr = np.clip(arr, lo, hi)\n        arr = (arr - lo) / (hi - lo + 1e-6)\n    else:\n        arr = (arr - arr.min()) / (arr.max() - arr.min() + 1e-6)\n\n    if getattr(dcm, \"PhotometricInterpretation\", \"\") == \"MONOCHROME1\":\n        arr = 1.0 - arr\n\n    arr = np.clip(arr, 0.0, 1.0)\n    arr = (arr * 255.0).astype(np.uint8)\n    rgb = cv2.cvtColor(arr, cv2.COLOR_GRAY2RGB)\n    return rgb\n\n\ndef preprocess_image(rgb):\n    \"\"\"pixel_values (RAD-DINO chuẩn, keep_aspect_ratio, do_pad=False) +\n    (grid_h, grid_w) + scale factor để map bbox.\"\"\"\n    orig_h, orig_w = rgb.shape[:2]\n    out = processor(images=rgb, return_tensors=\"pt\")\n    pixel_values = out[\"pixel_values\"][0]\n    _, new_h, new_w = pixel_values.shape\n    grid_h, grid_w = new_h // PATCH_SIZE, new_w // PATCH_SIZE\n    scale_x, scale_y = new_w / orig_w, new_h / orig_h\n    return pixel_values, (grid_h, grid_w), (scale_x, scale_y)\n\n\n# ============================================================\n# CACHE PREPROCESSING: chỉ decode DICOM + windowing + resize 1 LẦN, dùng lại\n# mọi epoch. Đã verify round-trip uint8 an toàn (mean|diff| trong vùng bbox\n# nhỏ nhất = 0.0046, dưới ngưỡng 0.01) và đã dùng thật ở Account B run01/run02\n# với kết quả AUROC khớp bản không-cache -- an toàn dùng ở đây.\n# ============================================================\nos.makedirs(CFG.CACHE_DIR, exist_ok=True)\n\n_img_mean = torch.tensor(processor.image_mean).view(3, 1, 1)\n_img_std = torch.tensor(processor.image_std).view(3, 1, 1)\n\ndef _decode_cached_array(arr):\n    t = torch.from_numpy(arr).float().permute(2, 0, 1) / 255.0\n    t = (t - _img_mean) / _img_std\n    grid_h, grid_w = arr.shape[0] // PATCH_SIZE, arr.shape[1] // PATCH_SIZE\n    return t, (grid_h, grid_w)\n\ndef build_cache_entry(img_id, dicom_dir, cache_path):\n    dicom_path = os.path.join(dicom_dir, f\"{img_id}.dicom\")\n    rgb = load_dicom_as_rgb(dicom_path)\n    pixel_values, _, _ = preprocess_image(rgb)\n    denorm = (pixel_values * _img_std + _img_mean).clamp(0, 1)\n    uint8_img = (denorm * 255).byte().permute(1, 2, 0).numpy()\n    np.save(cache_path, uint8_img)\n\ndef load_cached_pixel_values(img_id, dicom_dir):\n    if CFG.CACHE_INPUT_DIR:\n        p_input = f\"{CFG.CACHE_INPUT_DIR}/{img_id}.npy\"\n        if os.path.exists(p_input):\n            return _decode_cached_array(np.load(p_input))\n\n    p_local = f\"{CFG.CACHE_DIR}/{img_id}.npy\"\n    if not os.path.exists(p_local):\n        build_cache_entry(img_id, dicom_dir, p_local)\n    return _decode_cached_array(np.load(p_local))\n","metadata":{},"outputs":[],"execution_count":null},{"id":"025411c9-b715-4afb-890f-c2bfbc2201f0","cell_type":"markdown","source":"## 5. Patch-label mapping (vector hoá)","metadata":{}},{"id":"bf911622-43f0-4ee2-a8f2-96235a361643","cell_type":"code","source":"def build_patch_labels(wbf_boxes, grid_h, grid_w, scale_x, scale_y, num_classes=CFG.NUM_CLASSES):\n    \"\"\"Tâm patch nằm trong bbox (đã WBF) -> nhãn dương cho lớp tương ứng.\n    Vector hoá bằng numpy broadcasting (chạy trong __getitem__ mỗi ảnh/epoch).\"\"\"\n    patch_labels = np.zeros((num_classes, grid_h, grid_w), dtype=np.float32)\n    if not wbf_boxes:\n        return patch_labels\n\n    cy = (np.arange(grid_h) + 0.5) * PATCH_SIZE\n    cx = (np.arange(grid_w) + 0.5) * PATCH_SIZE\n\n    for box in wbf_boxes:\n        cid = box[\"class_id\"]\n        if cid >= num_classes:\n            continue\n        x1, y1 = box[\"x_min\"] * scale_x, box[\"y_min\"] * scale_y\n        x2, y2 = box[\"x_max\"] * scale_x, box[\"y_max\"] * scale_y\n\n        row_hit = (cy >= y1) & (cy <= y2)\n        col_hit = (cx >= x1) & (cx <= x2)\n        hit = row_hit[:, None] & col_hit[None, :]\n        patch_labels[cid] = np.logical_or(patch_labels[cid], hit)\n\n    return patch_labels\n","metadata":{},"outputs":[],"execution_count":null},{"id":"8d8dd5e4-fd37-4d1c-8863-1145d16f37c4","cell_type":"markdown","source":"## 6. Dataset + collate + Bucket sampler\n\nKhác linear probe: `patch_labels`/`patch_mask` bây giờ THẬT SỰ được dùng (Head\nchính thức cần patch-level target), không lazy-skip.","metadata":{}},{"id":"add604c0-9334-414a-b2d9-2bc18173264c","cell_type":"code","source":"class CXRDataset(Dataset):\n    def __init__(self, image_ids, wbf_cache, labels_map, dicom_dir):\n        self.image_ids = image_ids\n        self.wbf_cache = wbf_cache\n        self.labels_map = labels_map\n        self.dicom_dir = dicom_dir\n\n    def __len__(self):\n        return len(self.image_ids)\n\n    def __getitem__(self, idx):\n        img_id = self.image_ids[idx]\n        pixel_values, (grid_h, grid_w) = load_cached_pixel_values(img_id, self.dicom_dir)\n        scale_x = pixel_values.shape[2] / size_map[img_id][0]\n        scale_y = pixel_values.shape[1] / size_map[img_id][1]\n\n        patch_labels = build_patch_labels(\n            self.wbf_cache[img_id], grid_h, grid_w, scale_x, scale_y\n        )\n        image_label = self.labels_map[img_id]\n\n        return {\n            \"image_id\": img_id,\n            \"pixel_values\": pixel_values,\n            \"grid_hw\": (grid_h, grid_w),\n            \"patch_labels\": torch.from_numpy(patch_labels),\n            \"image_label\": torch.from_numpy(image_label),\n        }\n\n\ndef collate_fn(batch):\n    max_h = max(b[\"pixel_values\"].shape[1] for b in batch)\n    max_w = max(b[\"pixel_values\"].shape[2] for b in batch)\n    max_gh, max_gw = max_h // PATCH_SIZE, max_w // PATCH_SIZE\n\n    pixel_values = torch.zeros(len(batch), 3, max_h, max_w)\n    patch_labels = torch.zeros(len(batch), CFG.NUM_CLASSES, max_gh, max_gw)\n    patch_mask = torch.zeros(len(batch), max_gh, max_gw, dtype=torch.bool)\n    image_labels = torch.stack([b[\"image_label\"] for b in batch])\n    image_ids = [b[\"image_id\"] for b in batch]\n\n    for i, b in enumerate(batch):\n        _, h, w = b[\"pixel_values\"].shape\n        gh, gw = b[\"grid_hw\"]\n        pixel_values[i, :, :h, :w] = b[\"pixel_values\"]\n        patch_labels[i, :, :gh, :gw] = b[\"patch_labels\"]\n        patch_mask[i, :gh, :gw] = True\n\n    return {\n        \"image_id\": image_ids,\n        \"pixel_values\": pixel_values,\n        \"patch_labels\": patch_labels,\n        \"patch_mask\": patch_mask,\n        \"image_label\": image_labels,\n    }\n\n\nclass AspectRatioBucketSampler(torch.utils.data.Sampler):\n    \"\"\"RAD-DINO forward không có attention mask -> ảnh bị zero-pad nhiều trong\n    cùng 1 batch sẽ làm nhiễu CLS/patch token qua self-attention. Sort theo tỉ lệ\n    khung hình gốc (từ size_map) rồi chia batch theo cụm liền kề -> giảm padding.\"\"\"\n    def __init__(self, image_ids, size_map, batch_size, shuffle=True, drop_last=True):\n        self.image_ids = list(image_ids)\n        self.batch_size = batch_size\n        self.shuffle = shuffle\n        self.drop_last = drop_last\n        ratio = pd.Series({img_id: size_map[img_id][0] / size_map[img_id][1]\n                            for img_id in self.image_ids})\n        self.order = list(ratio.sort_values().index)\n\n    def __iter__(self):\n        buckets = [self.order[i:i + self.batch_size] for i in range(0, len(self.order), self.batch_size)]\n        if self.shuffle:\n            random.shuffle(buckets)\n        idx_of = {img_id: i for i, img_id in enumerate(self.image_ids)}\n        for b in buckets:\n            if len(b) == self.batch_size or not self.drop_last:\n                yield [idx_of[img_id] for img_id in b]\n\n    def __len__(self):\n        n_full = len(self.order) // self.batch_size\n        if not self.drop_last and len(self.order) % self.batch_size:\n            return n_full + 1\n        return n_full\n","metadata":{},"outputs":[],"execution_count":null},{"id":"674b81d7-4993-48fb-b704-adcc607a59fa","cell_type":"markdown","source":"## 6b. (Tuỳ chọn) Build cache trước cho toàn bộ ảnh\n\nBỏ qua nếu đã set `CFG.CACHE_INPUT_DIR` trỏ đến cache đã publish từ Account B\n(khuyến nghị — nhanh hơn nhiều). Chỉ chạy cell này nếu chưa có cache nào.","metadata":{}},{"id":"a0ef7335-a3a2-4604-8a4f-0f7da6c19c4d","cell_type":"code","source":"from tqdm.auto import tqdm\n\n_all_ids = list(image_ids_available)\n_missing = [i for i in _all_ids\n            if not (CFG.CACHE_INPUT_DIR and os.path.exists(f\"{CFG.CACHE_INPUT_DIR}/{i}.npy\"))\n            and not os.path.exists(f\"{CFG.CACHE_DIR}/{i}.npy\")]\nprint(f\"Tổng {len(_all_ids)} ảnh | đã có cache: {len(_all_ids) - len(_missing)} | cần build: {len(_missing)}\")\n\nfor img_id in tqdm(_missing, desc=\"Build cache\"):\n    build_cache_entry(img_id, CFG.TRAIN_DICOM_DIR, f\"{CFG.CACHE_DIR}/{img_id}.npy\")\n\nprint(f\"-> Xong. {len(os.listdir(CFG.CACHE_DIR))} file .npy trong {CFG.CACHE_DIR}.\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"dc0c0111-b534-4b4a-b2dc-a847f30c655f","cell_type":"markdown","source":"## 7. Train/val split (load lại từ Account A đã publish) + class_weights + sampler","metadata":{}},{"id":"d2204d7a-e7d2-4fd3-ac9e-0aa527a52056","cell_type":"code","source":"with open(CFG.TRAIN_IDS_PATH) as f:\n    train_ids = json.load(f)\nwith open(CFG.VAL_IDS_PATH) as f:\n    val_ids = json.load(f)\nprint(f\"Train: {len(train_ids)} | Val: {len(val_ids)} (load từ Account A đã publish, không tự shuffle)\")\n\ntrain_label_matrix = np.stack([labels_map[i] for i in train_ids])\ntrain_pos_rate = train_label_matrix.mean(axis=0)\nclass_weights = (1.0 / (train_pos_rate + 1e-4))\nclass_weights = class_weights / class_weights.mean()\nclass_weights = np.clip(class_weights, a_min=None, a_max=10.0)\nclass_weights = torch.tensor(class_weights, dtype=torch.float32)\n\ntrain_ds = CXRDataset(train_ids, wbf_cache, labels_map, CFG.TRAIN_DICOM_DIR)\nval_ds = CXRDataset(val_ids, wbf_cache, labels_map, CFG.TRAIN_DICOM_DIR)\n\ntrain_sampler = AspectRatioBucketSampler(train_ids, size_map, CFG.BATCH_SIZE, shuffle=True, drop_last=True)\nval_sampler = AspectRatioBucketSampler(val_ids, size_map, CFG.BATCH_SIZE, shuffle=False, drop_last=False)\n\n_loader_extra = dict(\n    pin_memory=CFG.PIN_MEMORY,\n    persistent_workers=(CFG.NUM_WORKERS > 0),\n)\nif CFG.NUM_WORKERS > 0:\n    _loader_extra[\"prefetch_factor\"] = CFG.PREFETCH_FACTOR\n\ntrain_loader = DataLoader(train_ds, batch_sampler=train_sampler,\n                           num_workers=CFG.NUM_WORKERS, collate_fn=collate_fn, **_loader_extra)\nval_loader = DataLoader(val_ds, batch_sampler=val_sampler,\n                         num_workers=CFG.NUM_WORKERS, collate_fn=collate_fn, **_loader_extra)\nprint(f\"NUM_WORKERS={CFG.NUM_WORKERS} (tự động theo os.cpu_count()={os.cpu_count()})\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"abe9b940-c9db-4e74-9055-9dd8356e7a57","cell_type":"markdown","source":"## 8. Model — DetectionHead (kiến trúc cuối, mục 4)\n\nPatch-level: conv1x1 trên patch tokens -> 14 lớp × mỗi vị trí. Image-level:\ngated-attention MIL (Ilse et al.) pooling **trên chính patch-score map** —\nkhông CLS riêng, không head thứ 2 độc lập.","metadata":{}},{"id":"9a82056b-013d-47e2-81ae-fa70fcfc21cf","cell_type":"code","source":"class RadDinoBackbone(nn.Module):\n    def __init__(self, model_id=CFG.RADDINO_ID, freeze=True):\n        super().__init__()\n        self.backbone = AutoModel.from_pretrained(model_id)\n        if freeze:\n            for p in self.backbone.parameters():\n                p.requires_grad = False\n            self.backbone.eval()\n\n    # KHÔNG decorate @torch.no_grad() -- requires_grad=False (freeze=True) đã đủ\n    # để không có gradient chảy qua backbone khi frozen, mà KHÔNG chặn gradient\n    # của LoRA adapter chèn vào backbone ở bước sau (freeze=False).\n    def forward(self, pixel_values):\n        out = self.backbone(pixel_values=pixel_values)\n        cls_token = out.last_hidden_state[:, 0, :]\n        patch_tokens = out.last_hidden_state[:, 1:, :]\n        return cls_token, patch_tokens\n\n\nclass DetectionHead(nn.Module):\n    def __init__(self, in_dim=768, num_classes=CFG.NUM_CLASSES, attn_dim=CFG.ATTN_DIM):\n        super().__init__()\n        self.patch_classifier = nn.Conv2d(in_dim, num_classes, kernel_size=1)\n        self.attn_V = nn.Linear(num_classes, attn_dim)\n        self.attn_U = nn.Linear(num_classes, attn_dim)\n        self.attn_w = nn.Linear(attn_dim, num_classes)\n\n    def forward(self, patch_tokens, grid_h, grid_w, patch_mask=None):\n        B, N, C = patch_tokens.shape\n        assert N == grid_h * grid_w, (\n            f\"Patch grid mismatch: backbone trả về {N} token nhưng \"\n            f\"grid_h*grid_w={grid_h*grid_w} ({grid_h}x{grid_w}).\"\n        )\n        x = patch_tokens.transpose(1, 2).reshape(B, C, grid_h, grid_w)\n        patch_logits = self.patch_classifier(x)               # (B, 14, gh, gw)\n\n        flat = patch_logits.flatten(2).transpose(1, 2)          # (B, gh*gw, 14)\n        A = torch.tanh(self.attn_V(flat)) * torch.sigmoid(self.attn_U(flat))\n        A = self.attn_w(A)\n        if patch_mask is not None:\n            mask_flat = patch_mask.flatten(1).unsqueeze(-1)\n            A = A.masked_fill(~mask_flat, float(\"-inf\"))\n        A = torch.softmax(A, dim=1)\n        image_logits = (A * flat).sum(dim=1)                     # (B, 14)\n        return patch_logits, image_logits\n","metadata":{},"outputs":[],"execution_count":null},{"id":"0bfa0112-d01b-4a2a-a09d-954ec07e9734","cell_type":"markdown","source":"## 9. Loss — focal + hard-negative-mining (patch-level), weighted BCE\n(image-level), cân bằng bằng Uncertainty Weighting (Kendall et al. 2018)","metadata":{}},{"id":"b5243b3f-e03f-4025-9c8c-a5e515cbc36b","cell_type":"code","source":"def weighted_bce_loss(logits, targets, class_weights):\n    loss = F.binary_cross_entropy_with_logits(logits, targets, reduction=\"none\")\n    return (loss * class_weights.to(logits.device)).mean()\n\n\ndef focal_loss_with_hard_negative_mining(patch_logits, patch_labels, patch_mask,\n                                          gamma=CFG.FOCAL_GAMMA, alpha=CFG.FOCAL_ALPHA,\n                                          neg_pos_ratio=CFG.NEG_POS_RATIO):\n    prob = torch.sigmoid(patch_logits)\n    ce = F.binary_cross_entropy_with_logits(patch_logits, patch_labels, reduction=\"none\")\n    p_t = prob * patch_labels + (1 - prob) * (1 - patch_labels)\n    focal = ((1 - p_t) ** gamma) * ce\n    alpha_t = alpha * patch_labels + (1 - alpha) * (1 - patch_labels)\n    focal = alpha_t * focal\n\n    mask = patch_mask.unsqueeze(1).expand_as(patch_labels)\n    pos_mask = (patch_labels == 1) & mask\n    neg_mask = (patch_labels == 0) & mask\n\n    n_pos = pos_mask.sum().clamp(min=1)\n    n_neg_keep = (n_pos * neg_pos_ratio).long()\n\n    neg_losses = focal[neg_mask]\n    if neg_losses.numel() > n_neg_keep:\n        neg_losses, _ = torch.topk(neg_losses, n_neg_keep.item())\n\n    pos_loss = focal[pos_mask].sum()\n    neg_loss = neg_losses.sum()\n    denom = n_pos + max(neg_losses.numel(), 1)\n    return (pos_loss + neg_loss) / denom\n\n\nclass UncertaintyWeighting(nn.Module):\n    \"\"\"Kendall et al. 2018 -- học log(σ²) cho mỗi loss thay vì trọng số cố định.\"\"\"\n    def __init__(self, num_losses=2):\n        super().__init__()\n        self.log_vars = nn.Parameter(torch.zeros(num_losses))\n\n    def forward(self, losses):\n        total = 0.0\n        for i, l in enumerate(losses):\n            total = total + torch.exp(-self.log_vars[i]) * l + self.log_vars[i]\n        return total\n","metadata":{},"outputs":[],"execution_count":null},{"id":"80be4a83-597c-4a8b-b6a6-31b6f1e0d9f0","cell_type":"markdown","source":"## 10. Evaluation — AUROC/AUPRC/ECE (image-level) + pointing-game + IoU@ngưỡng\ntheo nhóm kích thước tổn thương (mục 7)","metadata":{}},{"id":"7c5ae056-5a5c-4199-a8cc-d91edb280d62","cell_type":"code","source":"def expected_calibration_error(probs, targets, n_bins=15):\n    probs, targets = probs.flatten(), targets.flatten()\n    bins = np.linspace(0, 1, n_bins + 1)\n    ece = 0.0\n    for i in range(n_bins):\n        lo, hi = bins[i], bins[i + 1]\n        mask = (probs > lo) & (probs <= hi)\n        if mask.sum() == 0:\n            continue\n        acc = targets[mask].mean()\n        conf = probs[mask].mean()\n        ece += (mask.sum() / len(probs)) * abs(acc - conf)\n    return ece\n\n\ndef pointing_game_accuracy(patch_logits, patch_labels, patch_mask):\n    \"\"\"Điểm heatmap cao nhất (theo mask hợp lệ) có rơi vào patch dương thật\n    không. Chỉ tính trên (ảnh, lớp) có ít nhất 1 patch dương.\"\"\"\n    B, C, H, W = patch_logits.shape\n    mask = patch_mask.unsqueeze(1).expand(-1, C, -1, -1)\n    logits_masked = patch_logits.masked_fill(~mask, float(\"-inf\"))\n    flat_logits = logits_masked.flatten(2)\n    flat_labels = patch_labels.flatten(2)\n\n    has_pos = flat_labels.sum(-1) > 0\n    if has_pos.sum() == 0:\n        return float(\"nan\")\n\n    argmax_idx = flat_logits.argmax(-1)\n    hit = torch.gather(flat_labels, 2, argmax_idx.unsqueeze(-1)).squeeze(-1) > 0\n    return hit[has_pos].float().mean().item()\n\n\ndef iou_by_size_at_thresholds(patch_logits, patch_labels, patch_mask,\n                               thresholds=CFG.IOU_THRESHOLDS,\n                               size_bins=CFG.LESION_SIZE_BINS):\n    \"\"\"Mục 7: IoU@nhiều ngưỡng, TÁCH RIÊNG theo nhóm kích thước tổn thương.\n    Trả về list các dict {threshold, size_bucket, iou} -- 1 dòng cho mỗi\n    (ảnh, lớp) có ít nhất 1 patch dương thật. Nhóm kích thước tính theo SỐ PATCH\n    dương (nhất quán với lưới patch dùng cho mọi thứ khác trong pipeline, không\n    cần quy đổi lại diện tích pixel).\"\"\"\n    probs = torch.sigmoid(patch_logits)\n    B, C, H, W = patch_logits.shape\n    mask = patch_mask.unsqueeze(1).expand(-1, C, -1, -1)\n    gt = (patch_labels > 0) & mask\n\n    t1, t2 = size_bins\n    rows = []\n    for b in range(B):\n        for c in range(C):\n            gt_bc = gt[b, c]\n            n_pos = gt_bc.sum().item()\n            if n_pos == 0:\n                continue\n            bucket = \"small\" if n_pos <= t1 else (\"medium\" if n_pos <= t2 else \"large\")\n            for thr in thresholds:\n                pred_bc = (probs[b, c] > thr) & mask[b, c]\n                inter = (pred_bc & gt_bc).sum().item()\n                union = (pred_bc | gt_bc).sum().item()\n                iou = inter / union if union > 0 else 0.0\n                rows.append({\"threshold\": thr, \"size_bucket\": bucket, \"iou\": iou})\n    return rows\n\n\n@torch.no_grad()\ndef evaluate(head, backbone, loader, class_names=CFG.CLASS_NAMES):\n    was_training = backbone.training\n    backbone.eval(); head.eval()\n    all_logits, all_targets = [], []\n    all_patch_logits, all_patch_labels, all_patch_mask = [], [], []\n    iou_rows = []\n\n    for batch in loader:\n        pixel_values = batch[\"pixel_values\"].to(DEVICE)\n        patch_mask = batch[\"patch_mask\"].to(DEVICE)\n        patch_labels = batch[\"patch_labels\"].to(DEVICE)\n        gh, gw = patch_mask.shape[1], patch_mask.shape[2]\n\n        _, patch_tokens = backbone(pixel_values)\n        patch_logits, image_logits = head(patch_tokens, gh, gw, patch_mask)\n\n        all_logits.append(image_logits.cpu())\n        all_targets.append(batch[\"image_label\"])\n        all_patch_logits.append(patch_logits.cpu())\n        all_patch_labels.append(patch_labels.cpu())\n        all_patch_mask.append(patch_mask.cpu())\n        iou_rows.extend(iou_by_size_at_thresholds(patch_logits, patch_labels, patch_mask))\n\n    if was_training:\n        backbone.train()\n\n    logits = torch.cat(all_logits).numpy()\n    targets = torch.cat(all_targets).numpy()\n    probs = 1 / (1 + np.exp(-logits))\n\n    results = {}\n    for i, name in enumerate(class_names):\n        y_true, y_score = targets[:, i], probs[:, i]\n        if y_true.sum() == 0 or y_true.sum() == len(y_true):\n            results[name] = {\"auroc\": float(\"nan\"), \"auprc\": float(\"nan\")}\n            continue\n        results[name] = {\n            \"auroc\": roc_auc_score(y_true, y_score),\n            \"auprc\": average_precision_score(y_true, y_score),\n        }\n\n    valid_aurocs = [v[\"auroc\"] for v in results.values() if not math.isnan(v[\"auroc\"])]\n    macro_auroc = float(np.mean(valid_aurocs)) if valid_aurocs else float(\"nan\")\n    ece = expected_calibration_error(probs, targets)\n\n    accs = []\n    for pl, pla, pm in zip(all_patch_logits, all_patch_labels, all_patch_mask):\n        a = pointing_game_accuracy(pl, pla, pm)\n        if not math.isnan(a):\n            accs.append(a)\n    pointing_acc = float(np.mean(accs)) if accs else float(\"nan\")\n\n    return results, macro_auroc, ece, pointing_acc, iou_rows\n","metadata":{},"outputs":[],"execution_count":null},{"id":"3653f1ea-cf83-4fa3-b80b-156932131c12","cell_type":"markdown","source":"## 11. Optimizer + Training loop (2 loss qua Uncertainty Weighting,\nresume + early-stop)","metadata":{}},{"id":"86ce3364-fcd2-41a1-8ea1-f0797142d451","cell_type":"code","source":"backbone = RadDinoBackbone(freeze=True).to(DEVICE)\nhead = DetectionHead().to(DEVICE)\nuw = UncertaintyWeighting(num_losses=2).to(DEVICE)\n\noptimizer = torch.optim.AdamW(\n    list(head.parameters()) + list(uw.parameters()),\n    lr=CFG.LR_HEAD, weight_decay=CFG.WEIGHT_DECAY,\n)\nscaler = torch.cuda.amp.GradScaler(enabled=(DEVICE.type == \"cuda\"))\n\nhistory = []\nbest_macro_auroc = -1.0\nstart_epoch = 0\n\nimport shutil\nif CFG.RESUME_CKPT_INPUT_DIR and os.path.isdir(CFG.RESUME_CKPT_INPUT_DIR):\n    _prev_ckpts = glob.glob(f\"{CFG.RESUME_CKPT_INPUT_DIR}/*.pt\")\n    for f in _prev_ckpts:\n        shutil.copy(f, CFG.CKPT_DIR)\n    print(f\"-> Da copy {len(_prev_ckpts)} checkpoint tu {CFG.RESUME_CKPT_INPUT_DIR} vao {CFG.CKPT_DIR}\")\n\nexisting_ckpts = sorted(\n    glob.glob(f\"{CFG.CKPT_DIR}/{CFG.RUN_ID}_epoch*.pt\"),\n    key=lambda p: int(p.split(\"epoch\")[-1].split(\".pt\")[0]),\n)\nif existing_ckpts:\n    _latest = torch.load(existing_ckpts[-1], map_location=DEVICE)\n    head.load_state_dict(_latest[\"head\"])\n    uw.load_state_dict(_latest[\"uw\"])\n    start_epoch = _latest[\"epoch\"]\n    _best_path = f\"{CFG.CKPT_DIR}/{CFG.RUN_ID}_best.pt\"\n    if os.path.exists(_best_path):\n        best_macro_auroc = torch.load(_best_path, map_location=DEVICE)[\"macro_auroc\"]\n    if \"elapsed_s\" in _latest:\n        history.append({\"epoch\": start_epoch, \"elapsed_s\": _latest[\"elapsed_s\"]})\n    print(f\"-> Resume {CFG.RUN_ID} tu epoch {start_epoch} (checkpoint: {existing_ckpts[-1]}), \"\n          f\"best_macro_auroc hien tai = {best_macro_auroc:.4f}\")\nelse:\n    print(f\"-> Khong tim thay checkpoint cu cho {CFG.RUN_ID}, train tu dau.\")\n\nfor epoch in range(start_epoch, CFG.EPOCHS):\n    elapsed_total = time.time() - NOTEBOOK_START_TIME\n    projected = elapsed_total + (history[-1][\"elapsed_s\"] * 1.15 if history else 0)\n    if projected > CFG.TIME_BUDGET_SECONDS:\n        print(f\"-> Dung som truoc epoch {epoch + 1}/{CFG.EPOCHS} ({CFG.RUN_ID}): \"\n              f\"da dung {elapsed_total/3600:.2f}h / ngan sach {CFG.TIME_BUDGET_SECONDS/3600:.1f}h. \"\n              f\"Chay lai notebook (Save Version) de tu resume tiep tu epoch {epoch}.\")\n        break\n\n    head.train()\n    epoch_loss = epoch_img_loss = epoch_patch_loss = 0.0\n    t0 = time.time()\n\n    for step, batch in enumerate(train_loader):\n        pixel_values = batch[\"pixel_values\"].to(DEVICE)\n        image_label = batch[\"image_label\"].to(DEVICE)\n        patch_labels = batch[\"patch_labels\"].to(DEVICE)\n        patch_mask = batch[\"patch_mask\"].to(DEVICE)\n        gh, gw = patch_mask.shape[1], patch_mask.shape[2]\n\n        with torch.cuda.amp.autocast(enabled=(DEVICE.type == \"cuda\")):\n            _, patch_tokens = backbone(pixel_values)\n            patch_logits, image_logits = head(patch_tokens.float(), gh, gw, patch_mask)\n\n            img_loss = weighted_bce_loss(image_logits, image_label, class_weights)\n            patch_loss = focal_loss_with_hard_negative_mining(\n                patch_logits, patch_labels, patch_mask\n            )\n            loss = uw([img_loss, patch_loss])\n\n        optimizer.zero_grad()\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n\n        epoch_loss += loss.item()\n        epoch_img_loss += img_loss.item()\n        epoch_patch_loss += patch_loss.item()\n\n    results, macro_auroc, ece, pointing_acc, _ = evaluate(head, backbone, val_loader)\n    elapsed = time.time() - t0\n    n_steps = len(train_loader)\n    print(f\"[Epoch {epoch+1}/{CFG.EPOCHS}] loss={epoch_loss/n_steps:.4f} \"\n          f\"(img={epoch_img_loss/n_steps:.4f} patch={epoch_patch_loss/n_steps:.4f}) \"\n          f\"val_macro_AUROC={macro_auroc:.4f} val_ECE={ece:.4f} \"\n          f\"pointing_game={pointing_acc:.4f} ({elapsed:.0f}s)\")\n\n    history.append({\"epoch\": epoch + 1, \"train_loss\": epoch_loss / n_steps,\n                     \"val_macro_auroc\": macro_auroc, \"val_ece\": ece,\n                     \"pointing_game\": pointing_acc, \"elapsed_s\": elapsed})\n\n    ckpt_path = f\"{CFG.CKPT_DIR}/{CFG.RUN_ID}_epoch{epoch+1}.pt\"\n    ckpt = {\"epoch\": epoch + 1, \"head\": head.state_dict(), \"uw\": uw.state_dict(),\n            \"macro_auroc\": macro_auroc, \"elapsed_s\": elapsed}\n    torch.save(ckpt, ckpt_path)\n\n    if macro_auroc > best_macro_auroc:\n        best_macro_auroc = macro_auroc\n        torch.save(ckpt, f\"{CFG.CKPT_DIR}/{CFG.RUN_ID}_best.pt\")\n\nprint(f\"\\nBest val macro AUROC: {best_macro_auroc:.4f}\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"c0ed97a5-1989-44e7-8974-0fe21924e813","cell_type":"markdown","source":"## 12. Final eval (reload best) + log 3 tiêu chí + IoU-by-size summary","metadata":{}},{"id":"6b4fe85d-0a29-4ed9-84bb-dc8e061bf198","cell_type":"code","source":"_best_ckpt = torch.load(f\"{CFG.CKPT_DIR}/{CFG.RUN_ID}_best.pt\", map_location=DEVICE)\nhead.load_state_dict(_best_ckpt[\"head\"])\nuw.load_state_dict(_best_ckpt[\"uw\"])\nprint(f\"-> Da nap lai checkpoint TOT NHAT (epoch {_best_ckpt['epoch']}, \"\n      f\"macro_auroc={_best_ckpt['macro_auroc']:.4f}) truoc final-eval.\")\n\nfinal_results, final_macro_auroc, final_ece, final_pointing, final_iou_rows = evaluate(\n    head, backbone, val_loader\n)\n\nreport_df = pd.DataFrame(final_results).T\nreport_df.loc[\"MACRO\"] = {\"auroc\": final_macro_auroc, \"auprc\": report_df[\"auprc\"].mean()}\nreport_df[\"ece_overall\"] = final_ece\nreport_df\n","metadata":{},"outputs":[],"execution_count":null},{"id":"160ecbe6-707d-4bce-931e-69be90150182","cell_type":"code","source":"# IoU-by-size summary (mục 7) -- lưu riêng, không nhét vào run_log.csv (khác\n# schema với A/B, đa chiều theo threshold x size_bucket).\niou_df = pd.DataFrame(final_iou_rows)\nif len(iou_df):\n    iou_summary = (\n        iou_df.groupby([\"size_bucket\", \"threshold\"])[\"iou\"]\n        .agg(mean_iou=\"mean\", n=\"count\")\n        .reset_index()\n    )\n    iou_summary_path = f\"/kaggle/working/iou_by_size_{CFG.RUN_ID}.csv\"\n    iou_summary.to_csv(iou_summary_path, index=False)\n    print(f\"-> Đã lưu {iou_summary_path}\")\n    iou_summary\nelse:\n    print(\"Không có (ảnh, lớp) nào có patch dương trong val -- không tính được IoU-by-size.\")\n","metadata":{},"outputs":[],"execution_count":null},{"id":"6d01033e-1943-4769-98ec-2b36b8a00c0a","cell_type":"code","source":"rarest_class = min(\n    ((name, v[\"auroc\"]) for name, v in final_results.items() if not math.isnan(v[\"auroc\"])),\n    key=lambda x: train_label_matrix[:, CFG.CLASS_NAMES.index(x[0])].sum()\n)\n\nlog_row = {\n    \"run_id\": CFG.RUN_ID,\n    \"account\": CFG.ACCOUNT,\n    \"config\": f\"detection_head|lr_head={CFG.LR_HEAD}|focal_gamma={CFG.FOCAL_GAMMA}|\"\n              f\"focal_alpha={CFG.FOCAL_ALPHA}|epochs={CFG.EPOCHS}|bs={CFG.BATCH_SIZE}\",\n    \"macro_auroc\": final_macro_auroc,\n    \"ece_overall\": final_ece,\n    \"rarest_class_name\": rarest_class[0],\n    \"rarest_class_auroc\": rarest_class[1],\n    \"pointing_game_acc\": final_pointing,\n    \"checkpoint_path\": f\"{CFG.CKPT_DIR}/{CFG.RUN_ID}_best.pt\",\n    \"timestamp\": pd.Timestamp.now().isoformat(),\n}\n\nlog_df = pd.DataFrame([log_row])\nif os.path.exists(CFG.LOG_CSV):\n    log_df = pd.concat([pd.read_csv(CFG.LOG_CSV), log_df], ignore_index=True)\nlog_df.to_csv(CFG.LOG_CSV, index=False)\nlog_df\n","metadata":{},"outputs":[],"execution_count":null},{"id":"e2e7f70c-2300-46b2-87a6-c1790c701f50","cell_type":"markdown","source":"---\n### Next action\n\n1. Save Version → publish Output (checkpoint `A-head-frozen-attnmil_best.pt`,\n   `run_log.csv`, `iou_by_size_*.csv`) thành Kaggle Dataset nếu muốn dùng làm\n   điểm khởi đầu cho bước LoRA (mục 6, bước 3).\n2. Gộp `run_log.csv` này với `run_log.csv` của Account A (linear probe) + 4\n   run của Account B để so sánh trade-off theo đúng mục 1 (\"ưu tiên\n   calibration hơn AUROC thô\") — gộp 1 lần khi đủ cả 4 run B.\n3. Xem `iou_by_size_*.csv` để đánh giá riêng khả năng định vị theo từng nhóm\n   kích thước tổn thương — nếu nhóm \"small\" (tổn thương nhỏ) có IoU thấp hơn\n   rõ rệt so với \"medium\"/\"large\", đây là tín hiệu quan trọng cho việc có cần\n   tinh chỉnh thêm augmentation/loss riêng cho tổn thương nhỏ ở bước LoRA hay\n   không.\n","metadata":{}}]}