{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":24800,"datasetId":1042002,"databundleVersionId":1831594}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# !pip -q install -U ultralytics==8.2.0 ensemble-boxes==1.0.9 iterative-stratification==0.1.7 timm==1.0.7 albumentations==1.4.7","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T17:48:53.857462Z","iopub.execute_input":"2026-05-11T17:48:53.85777Z","iopub.status.idle":"2026-05-11T17:49:04.642087Z","shell.execute_reply.started":"2026-05-11T17:48:53.857732Z","shell.execute_reply":"2026-05-11T17:49:04.641299Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip -q install -U ultralytics ensemble-boxes==1.0.9 iterative-stratification==0.1.7 timm==1.0.7 albumentations==1.4.7","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, gc, cv2, json, math, glob, shutil, random\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\n\nimport pydicom\nfrom pydicom.pixel_data_handlers.util import apply_voi_lut\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nimport timm\nfrom ensemble_boxes import weighted_boxes_fusion\nfrom iterstrat.ml_stratifiers import MultilabelStratifiedKFold\n\nfrom ultralytics import YOLO\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\n\nDATA_ROOT = \"/kaggle/input/competitions/vinbigdata-chest-xray-abnormalities-detection\"\nTRAIN_DIR = f\"{DATA_ROOT}/train\"\nTEST_DIR  = f\"{DATA_ROOT}/test\"\nTRAIN_CSV = f\"{DATA_ROOT}/train.csv\"\nSAMPLE_SUB = f\"{DATA_ROOT}/sample_submission.csv\"\n\nWORKDIR = \"/kaggle/working\"\nPROC_DIR = f\"{WORKDIR}/proc\"              # ảnh đã convert\nYOLO_DIR = f\"{WORKDIR}/yolo_data\"         # txt/yaml/labels\nCKPT_DIR = f\"{WORKDIR}/checkpoints\"       # model weights\n\nos.makedirs(PROC_DIR, exist_ok=True)\nos.makedirs(YOLO_DIR, exist_ok=True)\nos.makedirs(CKPT_DIR, exist_ok=True)\n\n# ===== Improvements vs paper =====\nIMG_SIZE = 1024          # paper: 640. tăng imgsz thường cải thiện mAP (đổi 768 nếu thiếu disk/time)\nIMG_EXT = \"jpg\"          # lưu jpg cho nhẹ\nJPG_QUALITY = 95\n\nN_FOLDS = 5\nDETECTOR_MODEL = \"yolov8l.pt\"   # paper: yolov5. Bạn có thể thử yolov8x.pt nếu đủ time/GPU\nEPOCHS_DET = 30\nBATCH_DET = 8\n\nEPOCHS_CLS = 5\nBATCH_CLS = 16\nCLS_MODEL_NAME = \"convnext_small\"  # nâng cấp ResNet50 -> ConvNeXt/EffNetV2\nLR_CLS = 3e-4\n\n# Gating theo paper (2 ngưỡng)\nLOW_TH  = 0.15\nHIGH_TH = 0.85\n\n# WBF (train labels + inference ensemble)\nWBF_IOU_TRAIN = 0.55\nWBF_IOU_TEST  = 0.55\n\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nprint(\"DEVICE:\", DEVICE)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T17:51:00.208396Z","iopub.execute_input":"2026-05-11T17:51:00.208768Z","iopub.status.idle":"2026-05-11T17:51:00.221185Z","shell.execute_reply.started":"2026-05-11T17:51:00.208738Z","shell.execute_reply":"2026-05-11T17:51:00.220419Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_df = pd.read_csv(TRAIN_CSV)\ntrain_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T17:51:01.855575Z","iopub.execute_input":"2026-05-11T17:51:01.856236Z","iopub.status.idle":"2026-05-11T17:51:02.057539Z","shell.execute_reply.started":"2026-05-11T17:51:01.856204Z","shell.execute_reply":"2026-05-11T17:51:02.056661Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# one row / image: multi-hot 14 classes (không tính class 14)\nimage_ids = train_df[\"image_id\"].unique()\ncls_ids = list(range(14))\n\nimg2multi = {}\nfor img_id, g in train_df.groupby(\"image_id\"):\n    present = set(g.loc[g[\"class_id\"].between(0,13), \"class_id\"].astype(int).tolist())\n    multi = np.zeros(14, dtype=int)\n    for c in present:\n        multi[c] = 1\n    img2multi[img_id] = multi\n\nimg_df = pd.DataFrame({\n    \"image_id\": image_ids,\n})\nimg_df[\"multi\"] = img_df[\"image_id\"].map(img2multi)\nmulti_mat = np.stack(img_df[\"multi\"].values)\n\nmskf = MultilabelStratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED)\nimg_df[\"fold\"] = -1\nfor fold, (tr_idx, va_idx) in enumerate(mskf.split(img_df[\"image_id\"], multi_mat)):\n    img_df.loc[va_idx, \"fold\"] = fold\n\nimg_df.head(), img_df[\"fold\"].value_counts()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T17:51:08.680373Z","iopub.execute_input":"2026-05-11T17:51:08.681014Z","iopub.status.idle":"2026-05-11T17:51:16.89277Z","shell.execute_reply.started":"2026-05-11T17:51:08.680972Z","shell.execute_reply":"2026-05-11T17:51:16.891975Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def dicom_to_uint8(path):\n    dcm = pydicom.dcmread(path)\n    img = apply_voi_lut(dcm.pixel_array, dcm)\n\n    if getattr(dcm, \"PhotometricInterpretation\", \"\") == \"MONOCHROME1\":\n        img = np.max(img) - img\n\n    img = img.astype(np.float32)\n    lo = np.percentile(img, 1)\n    hi = np.percentile(img, 99)\n    img = (img - lo) / (hi - lo + 1e-6)\n    img = np.clip(img, 0, 1)\n    img = (img * 255.0).astype(np.uint8)\n\n    # CLAHE\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n    img = clahe.apply(img)\n\n    return img, dcm\n\ndef save_proc_image(dicom_path, out_path, imgsz=IMG_SIZE):\n    img, dcm = dicom_to_uint8(dicom_path)\n    h0, w0 = img.shape[:2]\n    img = cv2.resize(img, (imgsz, imgsz), interpolation=cv2.INTER_AREA)\n    img3 = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)\n    cv2.imwrite(out_path, img3, [cv2.IMWRITE_JPEG_QUALITY, JPG_QUALITY])\n    return (w0, h0)\n\n# convert all train+test (có thể tốn thời gian)\ntrain_paths = {os.path.basename(p).split(\".\")[0]: p for p in glob.glob(f\"{TRAIN_DIR}/*\")}\ntest_paths  = {os.path.basename(p).split(\".\")[0]: p for p in glob.glob(f\"{TEST_DIR}/*\")}\n\nall_ids = list(train_paths.keys()) + list(test_paths.keys())\nprint(\"n_train:\", len(train_paths), \"n_test:\", len(test_paths))\n\nmeta = []\nfor img_id in tqdm(all_ids):\n    src = train_paths.get(img_id, None) or test_paths.get(img_id)\n    out = f\"{PROC_DIR}/{img_id}.{IMG_EXT}\"\n    if not os.path.exists(out):\n        w0, h0 = save_proc_image(src, out, imgsz=IMG_SIZE)\n    else:\n        # đọc lại size gốc bằng dicom (nhanh hơn: lưu meta trong lần đầu; ở đây đơn giản)\n        dcm = pydicom.dcmread(src, stop_before_pixels=True)\n        # đôi khi Rows/Columns tồn tại\n        h0 = int(getattr(dcm, \"Rows\", IMG_SIZE))\n        w0 = int(getattr(dcm, \"Columns\", IMG_SIZE))\n    meta.append((img_id, w0, h0))\n\nmeta_df = pd.DataFrame(meta, columns=[\"image_id\", \"orig_w\", \"orig_h\"])\nmeta_df.to_csv(f\"{WORKDIR}/meta_sizes.csv\", index=False)\nmeta_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T17:51:16.89413Z","iopub.execute_input":"2026-05-11T17:51:16.894377Z","iopub.status.idle":"2026-05-11T23:47:42.478251Z","shell.execute_reply.started":"2026-05-11T17:51:16.894354Z","shell.execute_reply":"2026-05-11T23:47:42.477503Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Tạo dict: image_id -> {class_id -> {rad_id -> list(boxes)} }\n# boxes format: [x1,y1,x2,y2] absolute pixel theo ảnh gốc\n\ndef wbf_merge_gt_for_image(g_img, w0, h0, iou_thr=WBF_IOU_TRAIN):\n    \"\"\"\n    g_img: dataframe của 1 image_id\n    return list of (class_id, x1,y1,x2,y2) in ABS coords (original space)\n    \"\"\"\n    out = []\n    g_img = g_img[g_img[\"class_id\"].between(0,13)].copy()\n    if len(g_img) == 0:\n        return out\n\n    for c, gc in g_img.groupby(\"class_id\"):\n        boxes_list, scores_list, labels_list = [], [], []\n        for rad_id, gr in gc.groupby(\"rad_id\"):\n            boxes = gr[[\"x_min\",\"y_min\",\"x_max\",\"y_max\"]].values.astype(float)\n            # normalize to 0..1 for WBF\n            b = boxes.copy()\n            b[:, [0,2]] /= w0\n            b[:, [1,3]] /= h0\n            boxes_list.append(b.tolist())\n            scores_list.append([1.0]*len(b))\n            labels_list.append([int(c)]*len(b))\n\n        # WBF needs at least 1 list\n        if sum(len(x) for x in boxes_list) == 0:\n            continue\n\n        boxes, scores, labels = weighted_boxes_fusion(\n            boxes_list, scores_list, labels_list,\n            iou_thr=iou_thr, skip_box_thr=0.0\n        )\n        # denormalize back to abs\n        for b, lab in zip(boxes, labels):\n            x1,y1,x2,y2 = b\n            out.append((int(lab), x1*w0, y1*h0, x2*w0, y2*h0))\n    return out\n\n\n# viết label YOLO (theo ảnh đã resize IMG_SIZE x IMG_SIZE)\nLABEL_DIR = f\"{YOLO_DIR}/labels\"\nIMG_DIR   = f\"{YOLO_DIR}/images\"\nos.makedirs(LABEL_DIR, exist_ok=True)\nos.makedirs(IMG_DIR, exist_ok=True)\n\n# copy/mirror ảnh sang YOLO_DIR/images (để ultralytics đọc dễ)\n# (có thể symlink, nhưng Kaggle đôi lúc hạn chế; copy chắc chắn hơn)\nfor p in tqdm(glob.glob(f\"{PROC_DIR}/*.{IMG_EXT}\")):\n    dst = f\"{IMG_DIR}/{os.path.basename(p)}\"\n    if not os.path.exists(dst):\n        shutil.copy2(p, dst)\n\nmeta_map = meta_df.set_index(\"image_id\")[[\"orig_w\",\"orig_h\"]].to_dict(\"index\")\n\ndef to_yolo_line(cls, x1,y1,x2,y2, w_res=IMG_SIZE, h_res=IMG_SIZE, w0=1, h0=1):\n    # scale from original -> resized square\n    sx = w_res / w0\n    sy = h_res / h0\n    x1r, x2r = x1*sx, x2*sx\n    y1r, y2r = y1*sy, y2*sy\n    # clamp\n    x1r = np.clip(x1r, 0, w_res-1)\n    x2r = np.clip(x2r, 0, w_res-1)\n    y1r = np.clip(y1r, 0, h_res-1)\n    y2r = np.clip(y2r, 0, h_res-1)\n\n    bw = max(1e-3, x2r - x1r)\n    bh = max(1e-3, y2r - y1r)\n    xc = x1r + bw/2\n    yc = y1r + bh/2\n    # normalize\n    return f\"{cls} {xc/w_res:.6f} {yc/h_res:.6f} {bw/w_res:.6f} {bh/h_res:.6f}\"\n\n# tạo label cho toàn bộ train images\nfor img_id, g in tqdm(train_df.groupby(\"image_id\")):\n    w0 = meta_map[img_id][\"orig_w\"]\n    h0 = meta_map[img_id][\"orig_h\"]\n    fused = wbf_merge_gt_for_image(g, w0=w0, h0=h0, iou_thr=WBF_IOU_TRAIN)\n\n    label_path = f\"{LABEL_DIR}/{img_id}.txt\"\n    lines = []\n    for (c, x1,y1,x2,y2) in fused:\n        lines.append(to_yolo_line(c, x1,y1,x2,y2, w0=w0, h0=h0))\n\n    with open(label_path, \"w\") as f:\n        f.write(\"\\n\".join(lines))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T23:51:05.851871Z","iopub.execute_input":"2026-05-11T23:51:05.852591Z","iopub.status.idle":"2026-05-11T23:51:37.424571Z","shell.execute_reply.started":"2026-05-11T23:51:05.852557Z","shell.execute_reply":"2026-05-11T23:51:37.42399Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"CLASS_NAMES = [\n    \"Aortic enlargement\",\"Atelectasis\",\"Calcification\",\"Cardiomegaly\",\"Consolidation\",\n    \"ILD\",\"Infiltration\",\"Lung Opacity\",\"Nodule/Mass\",\"Other lesion\",\"Pleural effusion\",\n    \"Pleural thickening\",\"Pneumothorax\",\"Pulmonary fibrosis\"\n]\n\ndata_yaml_base = {\n    \"path\": YOLO_DIR,\n    \"names\": {i:n for i,n in enumerate(CLASS_NAMES)}\n}\nwith open(f\"{YOLO_DIR}/data_base.yaml\", \"w\") as f:\n    import yaml\n    yaml.safe_dump(data_yaml_base, f, sort_keys=False)\n\n# tạo train/val txt cho từng fold (cải tiến: dùng full ảnh, YOLO tự handle empty labels)\nfor fold in range(N_FOLDS):\n    tr_ids = img_df.loc[img_df.fold != fold, \"image_id\"].tolist()\n    va_ids = img_df.loc[img_df.fold == fold, \"image_id\"].tolist()\n\n    train_txt = f\"{YOLO_DIR}/train_fold{fold}.txt\"\n    val_txt   = f\"{YOLO_DIR}/val_fold{fold}.txt\"\n\n    with open(train_txt, \"w\") as f:\n        for img_id in tr_ids:\n            f.write(f\"{IMG_DIR}/{img_id}.{IMG_EXT}\\n\")\n    with open(val_txt, \"w\") as f:\n        for img_id in va_ids:\n            f.write(f\"{IMG_DIR}/{img_id}.{IMG_EXT}\\n\")\n\n    data_yaml = dict(data_yaml_base)\n    data_yaml[\"train\"] = train_txt\n    data_yaml[\"val\"] = val_txt\n    with open(f\"{YOLO_DIR}/data_fold{fold}.yaml\", \"w\") as f:\n        yaml.safe_dump(data_yaml, f, sort_keys=False)\n\nprint(\"Done YAML/TXT\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T23:48:56.721297Z","iopub.execute_input":"2026-05-11T23:48:56.721539Z","iopub.status.idle":"2026-05-11T23:48:56.766715Z","shell.execute_reply.started":"2026-05-11T23:48:56.721517Z","shell.execute_reply":"2026-05-11T23:48:56.766116Z"},"jupyter":{"source_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!zip -r yolo_data.zip /kaggle/working/yolo_data","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-12T00:29:35.522821Z","iopub.execute_input":"2026-05-12T00:29:35.523705Z","iopub.status.idle":"2026-05-12T00:33:25.772373Z","shell.execute_reply.started":"2026-05-12T00:29:35.523664Z","shell.execute_reply":"2026-05-12T00:33:25.771422Z"},"collapsed":true,"jupyter":{"outputs_hidden":true}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_detector_one_fold(fold):\n    data_yaml = f\"{YOLO_DIR}/data_fold{fold}.yaml\"\n    model = YOLO(DETECTOR_MODEL)\n\n    run_name = f\"det_y8_fold{fold}_img{IMG_SIZE}\"\n    model.train(\n        data=data_yaml,\n        imgsz=IMG_SIZE,\n        epochs=EPOCHS_DET,\n        batch=BATCH_DET,\n        lr0=0.01,\n        optimizer=\"AdamW\",      # cải tiến recipe (thường tốt hơn SGD mặc định cho finetune)\n        cos_lr=True,\n        weight_decay=5e-4,\n        warmup_epochs=2.0,\n        patience=20,\n        close_mosaic=10,\n        device=0 if DEVICE==\"cuda\" else \"cpu\",\n        project=CKPT_DIR,\n        name=run_name,\n        pretrained=True,\n        amp=True,\n        verbose=False\n    )\n    best = f\"{CKPT_DIR}/{run_name}/weights/best.pt\"\n    return best\n\n# Train tất cả folds (bạn có thể train 1 fold trước)\nbest_det_ckpts = []\nfor fold in range(N_FOLDS):\n    best_path = train_detector_one_fold(fold)\n    best_det_ckpts.append(best_path)\n    print(\"best:\", best_path)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T23:48:56.768031Z","iopub.execute_input":"2026-05-11T23:48:56.768245Z","iopub.status.idle":"2026-05-11T23:48:58.093346Z","shell.execute_reply.started":"2026-05-11T23:48:56.768225Z","shell.execute_reply":"2026-05-11T23:48:58.09238Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# tạo label image-level: abnormal=1 nếu có bất kỳ box class 0..13\nimg_label = {}\nfor img_id, g in train_df.groupby(\"image_id\"):\n    abnormal = int((g[\"class_id\"].between(0,13)).any())\n    img_label[img_id] = abnormal\n\nclass CXRClsDataset(Dataset):\n    def __init__(self, ids, labels, img_dir=IMG_DIR, augment=None):\n        self.ids = ids\n        self.labels = labels\n        self.img_dir = img_dir\n        self.augment = augment\n\n    def __len__(self): return len(self.ids)\n\n    def __getitem__(self, i):\n        img_id = self.ids[i]\n        y = self.labels[img_id]\n        path = f\"{self.img_dir}/{img_id}.{IMG_EXT}\"\n        img = cv2.imread(path)\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        if self.augment:\n            img = self.augment(image=img)[\"image\"]\n        return img, torch.tensor([y], dtype=torch.float32)\n\ndef get_cls_aug(train=True, size=IMG_SIZE):\n    # paper có blur/contrast/rgb shift/channel shuffle.\n    # CXR là grayscale nhưng ta vẫn augment nhẹ để tăng robust.\n    if train:\n        return A.Compose([\n            A.RandomResizedCrop(size, size, scale=(0.85, 1.0), ratio=(0.9, 1.1), p=1.0),\n            A.ShiftScaleRotate(shift_limit=0.02, scale_limit=0.08, rotate_limit=7, border_mode=cv2.BORDER_CONSTANT, p=0.7),\n            A.HorizontalFlip(p=0.5),\n            A.RandomBrightnessContrast(0.1, 0.1, p=0.7),\n            A.GaussianBlur(blur_limit=(3,5), p=0.2),\n            A.RGBShift(10,10,10, p=0.2),\n            A.ChannelShuffle(p=0.1),\n            A.Normalize(mean=(0.485,0.456,0.406), std=(0.229,0.224,0.225)),\n            ToTensorV2()\n        ])\n    else:\n        return A.Compose([\n            A.Resize(size, size),\n            A.Normalize(mean=(0.485,0.456,0.406), std=(0.229,0.224,0.225)),\n            ToTensorV2()\n        ])\n\ndef train_classifier_one_fold(fold):\n    tr_ids = img_df.loc[img_df.fold != fold, \"image_id\"].tolist()\n    va_ids = img_df.loc[img_df.fold == fold, \"image_id\"].tolist()\n\n    tr_ds = CXRClsDataset(tr_ids, img_label, augment=get_cls_aug(True, IMG_SIZE))\n    va_ds = CXRClsDataset(va_ids, img_label, augment=get_cls_aug(False, IMG_SIZE))\n\n    tr_loader = DataLoader(tr_ds, batch_size=BATCH_CLS, shuffle=True, num_workers=2, pin_memory=True, drop_last=True)\n    va_loader = DataLoader(va_ds, batch_size=BATCH_CLS, shuffle=False, num_workers=2, pin_memory=True)\n\n    model = timm.create_model(CLS_MODEL_NAME, pretrained=True, num_classes=1)\n    model.to(DEVICE)\n\n    # imbalance handling\n    y_tr = np.array([img_label[i] for i in tr_ids])\n    pos = (y_tr==1).sum()\n    neg = (y_tr==0).sum()\n    pos_weight = torch.tensor([neg/(pos+1e-6)], device=DEVICE)\n    criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)\n\n    opt = torch.optim.AdamW(model.parameters(), lr=LR_CLS, weight_decay=1e-4)\n    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=EPOCHS_CLS)\n\n    best_auc = -1\n    best_path = f\"{CKPT_DIR}/cls_fold{fold}_{CLS_MODEL_NAME}.pt\"\n\n    from sklearn.metrics import roc_auc_score\n\n    for epoch in range(EPOCHS_CLS):\n        model.train()\n        tr_loss = 0\n        for x,y in tr_loader:\n            x,y = x.to(DEVICE), y.to(DEVICE)\n            opt.zero_grad(set_to_none=True)\n            logits = model(x)\n            loss = criterion(logits, y)\n            loss.backward()\n            opt.step()\n            tr_loss += loss.item() * x.size(0)\n        tr_loss /= len(tr_loader.dataset)\n\n        model.eval()\n        va_logits, va_y = [], []\n        with torch.no_grad():\n            for x,y in va_loader:\n                x = x.to(DEVICE)\n                logits = model(x).detach().cpu().numpy().reshape(-1)\n                va_logits.append(logits)\n                va_y.append(y.numpy().reshape(-1))\n        va_logits = np.concatenate(va_logits)\n        va_y = np.concatenate(va_y)\n        va_prob = 1/(1+np.exp(-va_logits))\n        auc = roc_auc_score(va_y, va_prob)\n\n        scheduler.step()\n\n        if auc > best_auc:\n            best_auc = auc\n            torch.save(model.state_dict(), best_path)\n\n        print(f\"[fold {fold}] epoch {epoch+1}/{EPOCHS_CLS} loss={tr_loss:.4f} auc={auc:.4f} best_auc={best_auc:.4f}\")\n\n    return best_path\n\nbest_cls_ckpts = []\nfor fold in range(N_FOLDS):\n    p = train_classifier_one_fold(fold)\n    best_cls_ckpts.append(p)\n    print(\"best cls:\", p)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T23:48:58.09382Z","iopub.status.idle":"2026-05-11T23:48:58.094139Z","shell.execute_reply.started":"2026-05-11T23:48:58.093993Z","shell.execute_reply":"2026-05-11T23:48:58.094009Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"@torch.no_grad()\ndef predict_classifier_prob(img_path, cls_models):\n    img = cv2.imread(img_path)\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    aug = get_cls_aug(False, IMG_SIZE)\n    x = aug(image=img)[\"image\"].unsqueeze(0).to(DEVICE)\n\n    probs = []\n    for m in cls_models:\n        logit = m(x).squeeze().item()\n        prob = 1/(1+math.exp(-logit))\n        probs.append(prob)\n    return float(np.mean(probs))  # P(abnormal=1)\n\ndef load_cls_models(ckpts):\n    models = []\n    for p in ckpts:\n        m = timm.create_model(CLS_MODEL_NAME, pretrained=False, num_classes=1)\n        m.load_state_dict(torch.load(p, map_location=\"cpu\"))\n        m.to(DEVICE).eval()\n        models.append(m)\n    return models\n\ndef wbf_ensemble_boxes(list_of_preds, img_w, img_h, iou_thr=WBF_IOU_TEST, skip_thr=0.0):\n    \"\"\"\n    list_of_preds: list of (boxes_xyxy, scores, labels) per model\n    all boxes are ABS in resized space (IMG_SIZE)\n    returns ABS boxes in resized space\n    \"\"\"\n    boxes_list, scores_list, labels_list = [], [], []\n    for (boxes, scores, labels) in list_of_preds:\n        if len(boxes) == 0:\n            boxes_list.append([])\n            scores_list.append([])\n            labels_list.append([])\n            continue\n        b = np.array(boxes, dtype=np.float32)\n        b[:, [0,2]] /= img_w\n        b[:, [1,3]] /= img_h\n        boxes_list.append(b.tolist())\n        scores_list.append([float(s) for s in scores])\n        labels_list.append([int(l) for l in labels])\n\n    boxes, scores, labels = weighted_boxes_fusion(\n        boxes_list, scores_list, labels_list,\n        iou_thr=iou_thr, skip_box_thr=skip_thr\n    )\n    boxes = np.array(boxes, dtype=np.float32)\n    boxes[:, [0,2]] *= img_w\n    boxes[:, [1,3]] *= img_h\n    return boxes, np.array(scores), np.array(labels)\n\n# load detectors\ndet_models = [YOLO(p) for p in best_det_ckpts]\n# load classifiers\ncls_models = load_cls_models(best_cls_ckpts)\n\ntest_df = pd.read_csv(SAMPLE_SUB)\ntest_ids = test_df[\"image_id\"].tolist()\n\nmeta_map = pd.read_csv(f\"{WORKDIR}/meta_sizes.csv\").set_index(\"image_id\")[[\"orig_w\",\"orig_h\"]].to_dict(\"index\")\n\ndef scale_back_to_original(boxes_xyxy_resized, orig_w, orig_h, imgsz=IMG_SIZE):\n    # resized is square imgsz x imgsz\n    sx = orig_w / imgsz\n    sy = orig_h / imgsz\n    b = boxes_xyxy_resized.copy().astype(np.float32)\n    b[:, [0,2]] *= sx\n    b[:, [1,3]] *= sy\n    return b\n\npred_strings = []\n\nfor img_id in tqdm(test_ids):\n    img_path = f\"{IMG_DIR}/{img_id}.{IMG_EXT}\"\n    if not os.path.exists(img_path):\n        # fallback: từ proc_dir (nếu chưa copy)\n        img_path = f\"{PROC_DIR}/{img_id}.{IMG_EXT}\"\n\n    # classifier gating (p_abnormal)\n    p_abn = predict_classifier_prob(img_path, cls_models)\n    p_normal = 1.0 - p_abn\n\n    # detector predictions from each fold model\n    per_model_preds = []\n    for dm in det_models:\n        r = dm.predict(\n            source=img_path,\n            imgsz=IMG_SIZE,\n            conf=0.001,     # để WBF xử lý, giữ low conf\n            iou=0.7,\n            verbose=False,\n            device=0 if DEVICE==\"cuda\" else \"cpu\"\n        )[0]\n        if r.boxes is None or len(r.boxes) == 0:\n            per_model_preds.append(([], [], []))\n            continue\n        boxes = r.boxes.xyxy.cpu().numpy()\n        scores = r.boxes.conf.cpu().numpy()\n        labels = r.boxes.cls.cpu().numpy().astype(int)\n        per_model_preds.append((boxes, scores, labels))\n\n    # WBF ensemble (trên resized space)\n    boxes, scores, labels = wbf_ensemble_boxes(per_model_preds, IMG_SIZE, IMG_SIZE, iou_thr=WBF_IOU_TEST)\n\n    # map boxes về original pixel coords (competition yêu cầu theo ảnh gốc)\n    ow = meta_map[img_id][\"orig_w\"]\n    oh = meta_map[img_id][\"orig_h\"]\n    if len(boxes) > 0:\n        boxes_orig = scale_back_to_original(boxes, ow, oh, imgsz=IMG_SIZE)\n    else:\n        boxes_orig = boxes\n\n    # apply gating như paper\n    tokens = []\n\n    if p_normal > HIGH_TH:\n        # ignore all detections, output No finding\n        tokens.append(f\"14 {p_normal:.6f} 0 0 1 1\")\n    else:\n        # keep detections\n        for (b, s, c) in zip(boxes_orig, scores, labels):\n            x1,y1,x2,y2 = b\n            x1 = float(np.clip(x1, 0, ow-1))\n            y1 = float(np.clip(y1, 0, oh-1))\n            x2 = float(np.clip(x2, 0, ow-1))\n            y2 = float(np.clip(y2, 0, oh-1))\n            tokens.append(f\"{int(c)} {float(s):.6f} {x1:.1f} {y1:.1f} {x2:.1f} {y2:.1f}\")\n\n        # uncertain region: append no finding with low-ish score (giống paper)\n        if LOW_TH <= p_normal <= HIGH_TH:\n            tokens.append(f\"14 {p_normal:.6f} 0 0 1 1\")\n\n        # nếu detector không ra box nào -> fallback No finding\n        if len(tokens) == 0:\n            tokens.append(f\"14 {p_normal:.6f} 0 0 1 1\")\n\n    pred_strings.append(\" \".join(tokens))\n\nsub = pd.DataFrame({\"image_id\": test_ids, \"PredictionString\": pred_strings})\nsub.to_csv(f\"{WORKDIR}/submission.csv\", index=False)\nsub.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-05-11T23:48:58.094897Z","iopub.status.idle":"2026-05-11T23:48:58.095155Z","shell.execute_reply.started":"2026-05-11T23:48:58.095037Z","shell.execute_reply":"2026-05-11T23:48:58.095058Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}