{"nbformat":4,"nbformat_minor":5,"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10.0"}},"cells":[{"cell_type":"markdown","id":"md-title","metadata":{},"source":"# 🧠 Xử lý dữ liệu MRI não — RSNA-MICCAI\n**Pipeline:** DICOM → Resample → Skull strip → Crop → Normalize → Chọn 32 slice → Tensor `[4, 32, 128, 128]`"},{"cell_type":"code","execution_count":null,"id":"block-0-install","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 0 — Cài thư viện cần thiết\n# Chạy 1 lần duy nhất khi bắt đầu session Kaggle\n# ============================================================\n!pip install -q SimpleITK pydicom nibabel scikit-image tqdm"},{"cell_type":"code","execution_count":null,"id":"block-1-imports","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 1 — Import thư viện\n# ============================================================\nimport os\nimport numpy as np\nimport pandas as pd\nimport pydicom                          # đọc file DICOM\nimport SimpleITK as sitk                # resample voxel spacing\nimport nibabel as nib                   # đọc/ghi NIfTI (dùng cho HD-BET)\nfrom pathlib import Path\nfrom skimage.transform import resize    # resize từng slice 2D\nfrom scipy.ndimage import label as nd_label, binary_fill_holes, binary_dilation\nfrom skimage.filters import threshold_otsu\nfrom tqdm.auto import tqdm\nimport warnings\nwarnings.filterwarnings('ignore')\n\nprint('✅ Import xong')"},{"cell_type":"code","execution_count":null,"id":"block-2-config","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 2 — Cấu hình chung\n# Chỉ chỉnh sửa ở block này, không chạm vào các block khác\n# ============================================================\n\n# Đường dẫn dataset trên Kaggle\nDATA_ROOT  = Path('/kaggle/input/rsna-miccai-brain-tumor-radiogenomic-classification')\nTRAIN_DIR  = DATA_ROOT / 'train'\nTEST_DIR   = DATA_ROOT / 'test'\nLABEL_CSV  = DATA_ROOT / 'train_labels.csv'\n\n# Nơi lưu tensor đã xử lý (cache) — tránh xử lý lại\nCACHE_DIR  = Path('/kaggle/working/cache')\nCACHE_DIR.mkdir(parents=True, exist_ok=True)\n\n# 4 chuỗi MRI sẽ dùng làm 4 channel đầu vào\nSEQUENCES  = ['FLAIR', 'T1w', 'T1wCE', 'T2w']\n\n# Kích thước đầu ra mong muốn\nNUM_SLICES = 32      # số slice axial giữ lại\nSLICE_SIZE = 128     # chiều cao/rộng mỗi slice (pixel)\n\n# Voxel spacing chuẩn hoá về 1mm³ (bắt buộc — đồng nhất đa máy scanner)\nTARGET_SPACING = (1.0, 1.0, 1.0)\n\nprint(f'✅ Config xong | output shape: [4, {NUM_SLICES}, {SLICE_SIZE}, {SLICE_SIZE}]')"},{"cell_type":"code","execution_count":null,"id":"block-3-functions","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 3 — Các hàm xử lý từng bước\n# ============================================================\n\n# ------------------------------------------------------------\n# Bước 1: Đọc các file DICOM trong 1 thư mục → mảng 3D numpy\n# ------------------------------------------------------------\ndef load_dicom(dicom_dir):\n    \"\"\"\n    Đọc tất cả file .dcm trong thư mục, sắp xếp theo vị trí z thực tế.\n    Trả về: volume (D, H, W) float32  +  spacing (dz, dy, dx) tính bằng mm\n    \"\"\"\n    # Tìm tất cả file dcm\n    files = sorted(Path(dicom_dir).glob('*.dcm'))\n    if not files:\n        files = sorted(Path(dicom_dir).glob('*.DCM'))  # Linux case-sensitive\n    if not files:\n        raise FileNotFoundError(f'Không tìm thấy file DICOM trong: {dicom_dir}')\n\n    # Đọc header tất cả slice\n    slices = [pydicom.dcmread(str(f)) for f in files]\n\n    # Sắp xếp theo tọa độ z thực tế (ImagePositionPatient[2])\n    # KHÔNG dựa vào tên file vì tên file không đảm bảo đúng thứ tự\n    try:\n        slices.sort(key=lambda s: float(s.ImagePositionPatient[2]))\n    except AttributeError:\n        slices.sort(key=lambda s: int(s.InstanceNumber))\n\n    # Ghép tất cả slice thành volume 3D  shape: (D, H, W)\n    volume = np.stack([s.pixel_array.astype(np.float32) for s in slices], axis=0)\n\n    # Lấy khoảng cách voxel thực từ DICOM header (tính bằng mm)\n    try:\n        row_sp, col_sp = [float(x) for x in slices[0].PixelSpacing]\n        thick          = float(slices[0].SliceThickness)\n    except Exception:\n        row_sp = col_sp = thick = 1.0  # fallback nếu thiếu tag\n\n    spacing = (thick, row_sp, col_sp)  # (dz, dy, dx)\n    return volume, spacing\n\n\n# ------------------------------------------------------------\n# Bước 2: Resample về voxel spacing đồng nhất (1mm x 1mm x 1mm)\n# ĐÂY LÀ BƯỚC QUAN TRỌNG NHẤT\n# Lý do: các máy MRI khác nhau có spacing khác nhau (VD: 0.5x0.5x5mm)\n# Nếu không resample, mô hình học sai kích thước thực của khối u\n# ------------------------------------------------------------\ndef resample_volume(volume, original_spacing, target_spacing=TARGET_SPACING):\n    \"\"\"\n    Resample volume 3D về spacing mục tiêu bằng SimpleITK.\n    Dùng nội suy tuyến tính (trilinear) để giữ smooth.\n    \"\"\"\n    # Chuyển numpy → SimpleITK (ITK dùng thứ tự xyz = col,row,depth)\n    img = sitk.GetImageFromArray(volume.astype(np.float32))\n    img.SetSpacing((float(original_spacing[2]),   # dx\n                    float(original_spacing[1]),   # dy\n                    float(original_spacing[0])))  # dz\n\n    # Tính kích thước mới sau resample\n    old_size    = np.array(img.GetSize(),    dtype=float)  # (W, H, D)\n    old_spacing = np.array(img.GetSpacing(), dtype=float)  # (dx, dy, dz)\n    new_size    = np.round(old_size * old_spacing / np.array(target_spacing[::-1])).astype(int)\n\n    # Thiết lập bộ resample\n    resampler = sitk.ResampleImageFilter()\n    resampler.SetOutputSpacing(list(reversed(target_spacing)))  # ITK: (dx,dy,dz)\n    resampler.SetSize(new_size.tolist())\n    resampler.SetInterpolator(sitk.sitkLinear)        # nội suy tuyến tính\n    resampler.SetOutputDirection(img.GetDirection())\n    resampler.SetOutputOrigin(img.GetOrigin())\n    resampler.SetDefaultPixelValue(0)\n\n    resampled = resampler.Execute(img)\n    return sitk.GetArrayFromImage(resampled).astype(np.float32)  # → (D', H', W')\n\n\n# ------------------------------------------------------------\n# Bước 3: Skull stripping — loại bỏ hộp sọ\n# Dùng Otsu threshold + largest connected component\n# (Fallback không cần GPU — HD-BET tốt hơn nếu có GPU)\n# ------------------------------------------------------------\ndef skull_strip(volume):\n    \"\"\"\n    Tạo brain mask bằng Otsu threshold:\n    1. Ngưỡng Otsu để tách nền (không khí, hộp sọ) khỏi mô não\n    2. Giữ connected component lớn nhất (= khối não)\n    3. Lấp đầy lỗ hổng bên trong (ventricle, vùng hoại tử)\n    4. Dilation nhẹ để không cắt mất vỏ não\n    Trả về: volume đã strip  +  brain mask (bool)\n    \"\"\"\n    brain_vals = volume[volume > 0]\n    if brain_vals.size == 0:\n        return volume, np.zeros_like(volume, dtype=bool)\n\n    # Tính ngưỡng Otsu\n    thresh = threshold_otsu(brain_vals)\n    mask   = volume > thresh\n\n    # Giữ lại connected component lớn nhất\n    labeled, n = nd_label(mask)\n    if n > 0:\n        sizes        = np.bincount(labeled.ravel())\n        sizes[0]     = 0   # bỏ nhãn background (0)\n        mask         = labeled == sizes.argmax()\n\n    # Lấp lỗ hổng và mở rộng nhẹ\n    mask = binary_fill_holes(mask)\n    mask = binary_dilation(mask, iterations=2)\n\n    stripped = volume * mask.astype(np.float32)\n    return stripped, mask.astype(bool)\n\n\n# ------------------------------------------------------------\n# Bước 4: Crop viền đen — cắt bỏ vùng nền đen xung quanh não\n# ------------------------------------------------------------\ndef crop_background(volume, mask, margin=5):\n    \"\"\"\n    Tìm bounding box 3D của brain mask, crop volume về đó.\n    margin: số voxel giữ lại xung quanh để không cắt mất vỏ não.\n    \"\"\"\n    coords = np.where(mask)\n    if len(coords[0]) == 0:\n        return volume, mask  # mask rỗng → trả về nguyên\n\n    # Tính bounding box có thêm margin\n    d0 = max(coords[0].min() - margin, 0);  d1 = min(coords[0].max() + margin + 1, volume.shape[0])\n    h0 = max(coords[1].min() - margin, 0);  h1 = min(coords[1].max() + margin + 1, volume.shape[1])\n    w0 = max(coords[2].min() - margin, 0);  w1 = min(coords[2].max() + margin + 1, volume.shape[2])\n\n    return volume[d0:d1, h0:h1, w0:w1], mask[d0:d1, h0:h1, w0:w1]\n\n\n# ------------------------------------------------------------\n# Bước 5: Chuẩn hoá cường độ\n# Clip [1%, 99%] trước để loại outlier sáng (T1-Gd contrast)\n# Sau đó Z-score per volume per sequence\n# ------------------------------------------------------------\ndef normalize(volume):\n    \"\"\"\n    Chuẩn hoá 2 bước:\n    - Clip percentile [1%, 99%] → loại voxel sáng bất thường\n    - Z-score (mean=0, std=1) → đồng nhất cường độ giữa các bệnh nhân\n    Chỉ tính trên brain voxels (>0), không tính nền đen.\n    \"\"\"\n    brain = volume[volume > 0]\n    if brain.size == 0:\n        return volume\n\n    # Clip outlier\n    p1, p99 = np.percentile(brain, [1, 99])\n    volume  = np.clip(volume, p1, p99)\n\n    # Z-score\n    brain  = volume[volume > 0]\n    mean   = brain.mean()\n    std    = brain.std()\n    volume = (volume - mean) / (std + 1e-8)\n\n    # Giữ nền = 0\n    volume[volume == 0] = 0.0\n    return volume.astype(np.float32)\n\n\n# ------------------------------------------------------------\n# Bước 6: Chọn 32 slice có chứa nhiều khối u nhất\n# Strategy: Center-of-Mass window (tốt hơn top-area đơn thuần)\n# ------------------------------------------------------------\ndef select_slices(volume, mask, n=NUM_SLICES):\n    \"\"\"\n    Chọn n slice axial tập trung quanh vùng khối u:\n    1. Tạo tumor-proxy: voxel sáng hơn percentile 85 (khối u hyper-intense)\n    2. Tính center-of-mass theo trục z\n    3. Lấy cửa sổ ±n//2 quanh CoM\n    4. Rank trong cửa sổ theo diện tích tumor-proxy\n    5. Zero-pad nếu không đủ n slice\n    \"\"\"\n    D, H, W = volume.shape\n\n    # Tạo tumor-proxy mask từ top-15% intensity\n    brain_vals = volume[mask]\n    if brain_vals.size == 0:\n        # Không có não → lấy n slice giữa\n        mid   = D // 2\n        start = max(0, mid - n // 2)\n        return volume[start:start + n]\n\n    thresh_proxy = np.percentile(brain_vals, 85)\n    proxy        = (volume > thresh_proxy) & mask  # (D, H, W)\n\n    # Diện tích proxy per slice\n    areas = proxy.sum(axis=(1, 2))  # (D,)\n\n    # Center-of-mass theo z\n    total = areas.sum()\n    if total == 0:\n        com_z = D // 2\n    else:\n        com_z = int(np.round((np.arange(D) * areas).sum() / total))\n\n    # Cửa sổ ±n//2 quanh CoM\n    z0     = max(0, com_z - n // 2)\n    z1     = min(D, com_z + n // 2)\n    window = np.arange(z0, z1)\n\n    # Rank trong cửa sổ theo diện tích giảm dần, lấy top-n\n    ranked  = window[np.argsort(areas[window])[::-1]]\n    top_idx = np.sort(ranked[:n])  # re-sort về thứ tự anatomical\n\n    selected = volume[top_idx]  # (<=n, H, W)\n\n    # Zero-pad nếu thiếu slice\n    if selected.shape[0] < n:\n        pad     = np.zeros((n - selected.shape[0], H, W), dtype=np.float32)\n        selected = np.concatenate([selected, pad], axis=0)\n\n    return selected[:n]  # đảm bảo đúng n slice\n\n\n# ------------------------------------------------------------\n# Bước 7: Resize mỗi slice về SLICE_SIZE x SLICE_SIZE\n# ------------------------------------------------------------\ndef resize_slices(slices, size=SLICE_SIZE):\n    \"\"\"\n    Resize mỗi slice 2D về (size x size) bằng bicubic interpolation.\n    Anti-aliasing bật khi downscale để tránh aliasing artifact.\n    \"\"\"\n    result = np.zeros((slices.shape[0], size, size), dtype=np.float32)\n    for i, slc in enumerate(slices):\n        need_aa     = slc.shape[0] > size or slc.shape[1] > size\n        result[i]   = resize(slc, (size, size),\n                             order=3,                 # bicubic\n                             mode='constant', cval=0,\n                             anti_aliasing=need_aa,\n                             preserve_range=True).astype(np.float32)\n    return result\n\n\nprint('✅ Định nghĩa hàm xong')"},{"cell_type":"code","execution_count":null,"id":"block-4-processor","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 4 — Class DataProcessor\n# Gọi tất cả các bước trên theo đúng thứ tự cho 1 bệnh nhân\n# ============================================================\n\nclass DataProcessor:\n    \"\"\"\n    Xử lý toàn bộ pipeline cho 1 bệnh nhân:\n      DICOM → resample → skull strip → crop → normalize → chọn slice → resize\n    Đầu ra: tensor shape [4, 32, 128, 128]  (4 chuỗi MRI)\n    \"\"\"\n\n    def process_one(self, patient_dir):\n        \"\"\"\n        Xử lý 1 bệnh nhân.\n        patient_dir: thư mục chứa 4 thư mục con FLAIR/ T1w/ T1wCE/ T2w/\n        Trả về: numpy array [4, 32, 128, 128]  hoặc None nếu lỗi\n        \"\"\"\n        patient_dir  = Path(patient_dir)\n        channels     = []     # sẽ chứa 4 tensor shape [32, 128, 128]\n        brain_mask   = None   # brain mask tính từ FLAIR, dùng lại cho các chuỗi khác\n\n        for seq in SEQUENCES:\n            seq_dir = patient_dir / seq\n\n            # Nếu thiếu chuỗi → thêm channel 0 để giữ shape\n            if not seq_dir.exists():\n                print(f'  ⚠ Thiếu chuỗi {seq} — dùng channel 0')\n                channels.append(np.zeros((NUM_SLICES, SLICE_SIZE, SLICE_SIZE), np.float32))\n                continue\n\n            try:\n                # --- Bước 1: Đọc DICOM ---\n                volume, spacing = load_dicom(seq_dir)\n\n                # --- Bước 2: Resample isotropic 1mm³ ---\n                volume = resample_volume(volume, spacing)\n\n                # --- Bước 3: Skull stripping ---\n                # Tính mask 1 lần từ FLAIR, tái sử dụng cho 3 chuỗi còn lại\n                if seq == 'FLAIR' or brain_mask is None:\n                    volume, brain_mask = skull_strip(volume)\n                else:\n                    # Resize mask nếu shape khác sau resample\n                    if brain_mask.shape != volume.shape:\n                        from scipy.ndimage import zoom\n                        scale      = np.array(volume.shape) / np.array(brain_mask.shape)\n                        brain_mask = zoom(brain_mask.astype(float), scale, order=0).astype(bool)\n                    volume = volume * brain_mask.astype(np.float32)\n\n                # --- Bước 4: Crop viền đen ---\n                volume, mask_crop = crop_background(volume, brain_mask)\n\n                # --- Bước 5: Normalize cường độ ---\n                volume = normalize(volume)\n\n                # --- Bước 6: Chọn 32 slice tốt nhất ---\n                slices = select_slices(volume, mask_crop)\n\n                # --- Bước 7: Resize mỗi slice về 128×128 ---\n                slices = resize_slices(slices)\n\n                channels.append(slices)  # [32, 128, 128]\n\n            except Exception as e:\n                print(f'  ✗ Lỗi chuỗi {seq}: {e}')\n                channels.append(np.zeros((NUM_SLICES, SLICE_SIZE, SLICE_SIZE), np.float32))\n\n        if len(channels) != 4:\n            return None\n\n        # Ghép 4 chuỗi thành 4 channel → [4, 32, 128, 128]\n        return np.stack(channels, axis=0).astype(np.float32)\n\n\n    def process_all(self, split_dir, force=False):\n        \"\"\"\n        Xử lý toàn bộ bệnh nhân trong 1 split (train hoặc test).\n        Kết quả được lưu cache .npy — lần sau load lại không cần xử lý lại.\n\n        split_dir : thư mục train/ hoặc test/\n        force     : True → xử lý lại kể cả đã có cache\n        Trả về    : dict {patient_id: tensor [4, 32, 128, 128]}\n        \"\"\"\n        patients = sorted([p for p in Path(split_dir).iterdir() if p.is_dir()])\n        print(f'▶ Bắt đầu xử lý {len(patients)} bệnh nhân từ: {split_dir}')\n\n        data   = {}\n        failed = []\n\n        for p_dir in tqdm(patients, desc='Xử lý', unit='BN'):\n            pid        = p_dir.name\n            cache_file = CACHE_DIR / f'{pid}.npy'\n\n            # Load cache nếu đã xử lý rồi\n            if cache_file.exists() and not force:\n                data[pid] = np.load(str(cache_file))\n                continue\n\n            # Xử lý mới\n            tensor = self.process_one(p_dir)\n            if tensor is None:\n                failed.append(pid)\n                continue\n\n            # Lưu cache\n            np.save(str(cache_file), tensor)\n            data[pid] = tensor\n\n        print(f'✅ Xong: {len(data)} bệnh nhân | Lỗi: {len(failed)}')\n        if failed:\n            print(f'   Lỗi: {failed}')\n        return data\n\n\nprint('✅ Định nghĩa class DataProcessor xong')"},{"cell_type":"code","execution_count":null,"id":"block-5-run","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 5 — Chạy xử lý toàn bộ dataset train\n# ============================================================\n\n# Đọc file nhãn\ndf          = pd.read_csv(LABEL_CSV)\nlabels_map  = dict(zip(\n    df['BraTS21ID'].astype(str).str.zfill(5),  # ID dạng '00000'\n    df['MGMT_value'].astype(int)\n))\nprint(f'Nhãn: {len(labels_map)} BN | MGMT=1: {sum(labels_map.values())} | MGMT=0: {len(labels_map)-sum(labels_map.values())}')\n\n# Xử lý train\nprocessor  = DataProcessor()\ntrain_data = processor.process_all(TRAIN_DIR, force=False)\n\nprint(f'\\nTổng bệnh nhân đã xử lý: {len(train_data)}')"},{"cell_type":"code","execution_count":null,"id":"block-6-check","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 6 — Kiểm tra kết quả\n# ============================================================\n\n# Lấy 1 bệnh nhân bất kỳ để kiểm tra\npid    = list(train_data.keys())[0]\ntensor = train_data[pid]\nlabel  = labels_map.get(pid, '?')\n\nprint(f'Bệnh nhân : {pid}  | MGMT = {label}')\nprint(f'Shape     : {tensor.shape}  → mong đợi (4, {NUM_SLICES}, {SLICE_SIZE}, {SLICE_SIZE})')\nprint(f'dtype     : {tensor.dtype}')\nprint(f'Giá trị   : min={tensor.min():.3f}  max={tensor.max():.3f}')\nprint()\n\nfor i, seq in enumerate(SEQUENCES):\n    ch = tensor[i]\n    print(f'  {seq:6s} → mean={ch.mean():.4f}  std={ch.std():.4f}  '\n          f'nonzero={np.count_nonzero(ch)} voxel')\n\n# Kiểm tra shape đúng không\nassert tensor.shape == (4, NUM_SLICES, SLICE_SIZE, SLICE_SIZE), '❌ Shape sai!'\nprint('\\n✅ Shape đúng — tensor sẵn sàng đưa vào model')"},{"cell_type":"code","execution_count":null,"id":"block-7-visualize","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 7 — Hiển thị các slice của 1 bệnh nhân\n# ============================================================\nimport matplotlib.pyplot as plt\n\ndef show_patient(tensor, pid, label=None, n_show=8):\n    \"\"\"\n    Hiển thị n_show slice cho 4 chuỗi MRI của 1 bệnh nhân.\n    Mỗi hàng = 1 chuỗi (FLAIR, T1w, T1wCE, T2w)\n    Mỗi cột = 1 slice axial\n    \"\"\"\n    # Chọn n_show slice cách đều nhau trong 32 slice\n    idx = np.linspace(0, NUM_SLICES - 1, n_show, dtype=int)\n\n    fig, axes = plt.subplots(4, n_show, figsize=(n_show * 2, 4 * 2))\n    fig.suptitle(f'BN: {pid}  |  MGMT = {label if label is not None else \"?\"}',\n                 fontsize=13, y=1.01)\n\n    for row, seq in enumerate(SEQUENCES):\n        for col, si in enumerate(idx):\n            ax = axes[row, col]\n            ax.imshow(tensor[row, si], cmap='gray')\n            ax.axis('off')\n            if col == 0:\n                ax.set_title(seq, fontsize=9, pad=2)\n            if row == 0:\n                ax.set_xlabel(f'z={si}', fontsize=8)\n\n    plt.tight_layout()\n    plt.show()\n\n\n# Hiển thị bệnh nhân đầu tiên\nshow_patient(tensor, pid=pid, label=label, n_show=8)"},{"cell_type":"code","execution_count":null,"id":"block-8-dataloader","metadata":{},"outputs":[],"source":"# ============================================================\n# BLOCK 8 — PyTorch Dataset + DataLoader (sẵn sàng train)\n# ============================================================\nimport torch\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.model_selection import StratifiedKFold\n\n\nclass MRIDataset(Dataset):\n    \"\"\"\n    Dataset đơn giản — load tensor từ cache, trả về (tensor, label).\n    augment=True: bật augmentation cơ bản khi train\n    \"\"\"\n\n    def __init__(self, pids, labels_map, augment=False):\n        self.pids       = pids\n        self.labels_map = labels_map\n        self.augment    = augment\n\n    def __len__(self):\n        return len(self.pids)\n\n    def __getitem__(self, idx):\n        pid    = self.pids[idx]\n\n        # Load tensor từ cache\n        tensor = np.load(str(CACHE_DIR / f'{pid}.npy')).astype(np.float32)\n\n        # Augmentation cơ bản (chỉ khi train)\n        if self.augment:\n            # Lật ngẫu nhiên theo chiều dọc (axial flip)\n            if np.random.rand() > 0.5:\n                tensor = tensor[:, ::-1, :, :].copy()\n            # Lật trái-phải\n            if np.random.rand() > 0.5:\n                tensor = tensor[:, :, :, ::-1].copy()\n            # Thêm nhiễu Gaussian nhẹ\n            tensor += np.random.normal(0, 0.02, tensor.shape).astype(np.float32)\n\n        label = self.labels_map.get(pid, -1)\n        return torch.tensor(tensor), torch.tensor(label, dtype=torch.float32)\n\n\n# --- Tạo DataLoader cho fold 0 (5-fold cross validation) ---\n\n# Chỉ lấy BN có cả tensor và nhãn\nall_pids   = sorted([p for p in train_data if p in labels_map])\nall_labels = [labels_map[p] for p in all_pids]\n\n# Stratified 5-fold — đảm bảo tỉ lệ MGMT 0/1 đều nhau trong từng fold\nskf    = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\nsplits = list(skf.split(all_pids, all_labels))\n\nfold        = 0   # ← đổi 0-4 để chạy fold khác\ntrain_idx, val_idx = splits[fold]\ntrain_pids  = [all_pids[i] for i in train_idx]\nval_pids    = [all_pids[i] for i in val_idx]\n\nprint(f'Fold {fold} | Train: {len(train_pids)} BN | Val: {len(val_pids)} BN')\n\ntrain_loader = DataLoader(\n    MRIDataset(train_pids, labels_map, augment=True),\n    batch_size=8, shuffle=True, num_workers=2, pin_memory=True\n)\nval_loader = DataLoader(\n    MRIDataset(val_pids, labels_map, augment=False),\n    batch_size=8, shuffle=False, num_workers=2, pin_memory=True\n)\n\n# Kiểm tra 1 batch\nX, y = next(iter(train_loader))\nprint(f'\\n✅ DataLoader OK')\nprint(f'   Batch tensor : {X.shape}  → (B, 4, 32, 128, 128)')\nprint(f'   Batch labels : {y.tolist()}')"}]}