{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.10.0"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":4117,"databundleVersionId":46665,"isSourceIdPinned":false},{"sourceType":"datasetVersion","sourceId":15522655,"datasetId":9931127,"databundleVersionId":16449701},{"sourceType":"datasetVersion","sourceId":15743447,"datasetId":10088103,"databundleVersionId":16686091}],"dockerImageVersionId":31329,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# ============================================================\n# CELL 1: Import\n# ============================================================","metadata":{}},{"cell_type":"code","source":"import os, json, gc, copy, warnings\nimport numpy as np\nimport pandas as pd\nimport psutil\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.amp import autocast, GradScaler\nfrom torch.utils.data import (DataLoader, random_split,\n                               WeightedRandomSampler, Subset, Dataset)\nfrom torchvision import datasets, transforms, models\nfrom sklearn.metrics import f1_score, classification_report\n\nwarnings.filterwarnings(\"ignore\")\nos.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\nos.environ[\"OMP_NUM_THREADS\"]        = \"2\"\nos.environ[\"MKL_NUM_THREADS\"]        = \"2\"\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(f\"Device : {DEVICE}\")\nprint(f\"GPU    : {torch.cuda.device_count()} × {torch.cuda.get_device_name(0)}\")\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!nvidia-smi","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ============================================================\n# CELL 2: Đường dẫn — chỉ dùng RGB\n# ============================================================","metadata":{}},{"cell_type":"code","source":"DATASET_CONFIGS = {\n    \"microsoft_rgb\": {\n        \"path\":        \"/kaggle/input/datasets/vnhtbo/microsoft/train_rgb/train_rgb\",\n        \"csv_path\":    \"/kaggle/input/competitions/malware-classification/trainLabels.csv\",\n        \"is_rgb\":      True,\n        \"num_classes\": 9,\n        \"description\": \"Microsoft Malware RGB — EfficientNet-B4 Teacher\",\n    },\n    \"malimg_rgb\": {\n        \"path\":        \"/kaggle/input/datasets/dongquan/malimg-rgb/kaggle/working/malimg_rgb\",\n        \"csv_path\":    None,  # Set thành None vì không dùng CSV nữa\n        \"is_rgb\":      True,\n        \"num_classes\": 25,    # THAY ĐỔI: Nhập đúng số lượng thư mục virus bạn có\n        \"description\": \"Malimg RGB Folder-based — EfficientNet-B4 Teacher\",\n    },\n}\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ============================================================\n# CELL 3: Verify đường dẫn\n# ============================================================","metadata":{}},{"cell_type":"code","source":"for name, cfg in DATASET_CONFIGS.items():\n    path = cfg[\"path\"]\n    if not os.path.exists(path):\n        print(f\"❌ KHÔNG TỒN TẠI: {name} → {path}\")\n        continue\n    csv_path = cfg.get(\"csv_path\")\n    if csv_path:\n        df = pd.read_csv(csv_path)\n        classes = sorted(df[\"Class\"].unique())\n        total = len(df)\n        counts = sorted(\n            [(c, len(df[df[\"Class\"] == c])) for c in classes],\n            key=lambda x: x[1]\n        )\n    else:\n        classes = [d for d in os.listdir(path)\n                   if os.path.isdir(os.path.join(path, d))]\n        total = sum(len(os.listdir(os.path.join(path, c))) for c in classes)\n        counts = sorted(\n            [(c, len(os.listdir(os.path.join(path, c)))) for c in classes],\n            key=lambda x: x[1]\n        )\n    print(f\"✅ {name}\")\n    print(f\"  Classes : {len(classes)} | Total: {total} ảnh\")\n    print(f\"  Min : {counts[0][1]} ảnh ({counts[0][0]})\")\n    print(f\"  Max : {counts[-1][1]} ảnh ({counts[-1][0]})\")\n    print()\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ============================================================\n# CELL 4: Hyperparameters — EfficientNet-B4 Teacher+\n# ============================================================","metadata":{}},{"cell_type":"code","source":"# ══════════════════════════════════════════════════════════\n# EfficientNet-B4 — Teacher+ (Ablation: knowledge gap hypothesis)\n# ══════════════════════════════════════════════════════════\n# Vai trò       : Teacher mạnh hơn để so sánh KD quality\n# Input         : 224×224 (đồng nhất, KHÔNG dùng native 380×380)\n# Transfer      : 2 phase\n#   Phase 1     : Toàn bộ freeze, train head only\n#                 Epochs : FREEZE_EPOCHS (5)\n#                 LR     : LR_HEAD (1e-3)\n#   Phase 2     : Unfreeze 4 block cuối (block 4, 5, 6, 7)\n#                 Epochs : UNFREEZE_EPOCHS (22)\n#                 LR     : block sớm(4)=3`e-5, block muộn(5,6,7)=1e-4, head=2e-4\n# Scheduler     : CosineAnnealingLR\n# Batch size    : 16 physical + accum×2 = effective 32\n# Runs          : 1 (best checkpoint)\n# ══════════════════════════════════════════════════════════\n\nFREEZE_EPOCHS      = 5\nUNFREEZE_EPOCHS    = 22\nTOTAL_EPOCHS       = FREEZE_EPOCHS + UNFREEZE_EPOCHS   # = 27\nEARLY_STOP_PATIENCE= 7\nN_RUNS             = 3\nBATCH_SIZE         = 8      # Physical — EB4 nặng hơn EB3\nACCUMULATION_STEPS = 4       # Effective batch = 32\nLR_HEAD            = 1e-3\nLR_BLOCK_EARLY     = 3e-5    # Block sớm (block 4) — thấp hơn EB3\nLR_BLOCK_LATE      = 1e-4    # Block muộn (block 5, 6, 7)\nLR_HEAD_PHASE2     = 2e-4\nIMG_SIZE           = 380     # Đồng nhất với các model khác\nVAL_RATIO          = 0.15\nTEST_RATIO         = 0.15\nSEED               = 42\n\nCKPT_DIR = \"/kaggle/working/checkpoints\"\nos.makedirs(CKPT_DIR, exist_ok=True)\n\ndef ckpt_path(name, run):\n    return f\"{CKPT_DIR}/best_{name}_run{run}.pt\"\n\ndef resume_path(name, run):\n    return f\"{CKPT_DIR}/resume_{name}_run{run}.pt\"\n\ndef split_path(name):\n    return f\"{CKPT_DIR}/split_{name}.json\"\n\ndef progress_path():\n    return f\"{CKPT_DIR}/progress.json\"\n\ndef load_progress():\n    p = progress_path()\n    if os.path.exists(p):\n        with open(p) as f:\n            return json.load(f)\n    return {}\n\ndef save_progress(results):\n    with open(progress_path(), \"w\") as f:\n        json.dump(results, f, indent=2, ensure_ascii=False)\n    print(f\"  📝 Progress saved\")\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ============================================================\n# CELL 5: Transforms\n# ============================================================","metadata":{}},{"cell_type":"code","source":"def get_transforms(is_rgb: bool):\n    norm_mean = [0.485, 0.456, 0.406] if is_rgb else [0.5, 0.5, 0.5]\n    norm_std  = [0.229, 0.224, 0.225] if is_rgb else [0.5, 0.5, 0.5]\n    base = [transforms.Resize((IMG_SIZE, IMG_SIZE))]\n    if not is_rgb:\n        base.append(transforms.Grayscale(num_output_channels=3))\n    train_tf = transforms.Compose(base + [\n        # transforms.RandomHorizontalFlip(),\n        # transforms.RandomVerticalFlip(p=0.3),\n        # transforms.RandomRotation(15),\n        transforms.ColorJitter(brightness=0.2, contrast=0.2),\n        transforms.ToTensor(),\n        transforms.Normalize(norm_mean, norm_std),\n    ])\n    val_tf = transforms.Compose(base + [\n        transforms.ToTensor(),\n        transforms.Normalize(norm_mean, norm_std),\n    ])\n    return train_tf, val_tf\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ============================================================\n# CELL 6+7: Build EfficientNet-B4 + Eval\n# ============================================================","metadata":{}},{"cell_type":"code","source":"class CSVImageDataset(Dataset):\n    def __init__(self, root, csv_path, transform=None):\n        df = pd.read_csv(csv_path)\n        unique_classes = sorted(df[\"Class\"].unique())\n        self.class_to_idx = {c: i for i, c in enumerate(unique_classes)}\n        self.classes = [str(c) for c in unique_classes]\n\n        existing = set(os.listdir(root))\n        self.samples = []\n        self.targets = []\n        for _, row in df.iterrows():\n            fname = f\"{row['Id']}.png\"\n            if fname in existing:\n                label = self.class_to_idx[row[\"Class\"]]\n                self.samples.append((os.path.join(root, fname), label))\n                self.targets.append(label)\n\n        self.transform = transform\n        print(f\"  Loaded {len(self.samples)}/{len(df)} images, {len(self.classes)} classes\")\n\n    def __len__(self):\n        return len(self.samples)\n\n    def __getitem__(self, idx):\n        path, label = self.samples[idx]\n        from PIL import Image\n        img = Image.open(path).convert(\"RGB\")\n        if self.transform:\n            img = self.transform(img)\n        return img, label\n\n\ndef clear_memory():\n    gc.collect()\n    torch.cuda.empty_cache()\n    torch.cuda.synchronize()\n    ram = psutil.virtual_memory()\n    print(f\"  🧹 RAM {ram.used/1e9:.1f}GB/{ram.total/1e9:.1f}GB \"\n          f\"({ram.percent}%) | \"\n          f\"GPU {torch.cuda.memory_allocated()/1e9:.2f}GB\")\n\n\ndef get_fixed_split(full_ds, dataset_name):\n    spath = split_path(dataset_name)\n    n     = len(full_ds)\n    n_test  = int(n * TEST_RATIO)\n    n_val   = int(n * VAL_RATIO)\n    n_train = n - n_val - n_test\n\n    if not os.path.exists(spath):\n        gen = torch.Generator().manual_seed(SEED)\n        train_ds, val_ds, test_ds = random_split(\n            full_ds, [n_train, n_val, n_test], generator=gen\n        )\n        json.dump({\n            \"train\": train_ds.indices,\n            \"val\":   val_ds.indices,\n            \"test\":  test_ds.indices,\n            \"seed\":  SEED,\n            \"total\": n,\n        }, open(spath, \"w\"))\n        print(f\"  💾 Split mới đã lưu → {spath}\")\n    else:\n        saved    = json.load(open(spath))\n        train_ds = Subset(full_ds, saved[\"train\"])\n        val_ds   = Subset(full_ds, saved[\"val\"])\n        test_ds  = Subset(full_ds, saved[\"test\"])\n        print(f\"  📂 Split cũ đã load → {spath} \"\n              f\"(seed={saved['seed']}, total={saved['total']})\")\n\n    print(f\"  Train: {len(train_ds)} | \"\n          f\"Val: {len(val_ds)} | Test: {len(test_ds)}\")\n    return train_ds, val_ds, test_ds\n\n\n# ── Model ─────────────────────────────────────────────────\ndef build_efficientnet_b4(num_classes: int) -> nn.Module:\n    model = models.efficientnet_b4(weights=models.EfficientNet_B4_Weights.IMAGENET1K_V1)\n    for param in model.parameters():\n        param.requires_grad = False\n    in_features = model.classifier[1].in_features\n    model.classifier = nn.Sequential(\n        nn.Dropout(p=0.4, inplace=True),  # Default EB4 dropout\n        nn.Linear(in_features, num_classes),\n    )\n    # if torch.cuda.device_count() > 1:\n    #     print(f\"  >> Dùng {torch.cuda.device_count()} GPU song song\")\n    #     model = nn.DataParallel(model)\n    return model.to(DEVICE)\n\n\ndef unfreeze_last_blocks_eb4(base_model):\n    \"\"\"Unfreeze 4 block cuối (block 4, 5, 6, 7) của EfficientNet-B4\n    \n    EfficientNet features layout:\n      features[0] = stem\n      features[1..7] = 7 MBConv block stages\n      features[8] = head conv 1x1\n    Block 4,5,6,7 = features[4], features[5], features[6], features[7]\n    Cũng unfreeze features[8] (head conv)\n    \"\"\"\n    for idx in [4, 5, 6, 7, 8]:\n        for param in base_model.features[idx].parameters():\n            param.requires_grad = True\n    trainable = sum(p.numel() for p in base_model.parameters()\n                    if p.requires_grad)\n    print(f\"  >> Unfreeze block 4,5,6,7 + head conv | Trainable: {trainable/1e6:.1f}M\")\n\n\ndef train_one_epoch(model, loader, optimizer, criterion,\n                    scaler, accumulation_steps=2):\n    model.train()\n    total_loss = correct = total = 0\n    optimizer.zero_grad()\n    for step, (imgs, labels) in enumerate(loader):\n        imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n        with autocast('cuda'):\n            outputs = model(imgs)\n            loss    = criterion(outputs, labels) / accumulation_steps\n        scaler.scale(loss).backward()\n        if (step + 1) % accumulation_steps == 0:\n            scaler.step(optimizer)\n            scaler.update()\n            optimizer.zero_grad()\n        total_loss += loss.item() * accumulation_steps\n        correct    += (outputs.detach().argmax(1) == labels).sum().item()\n        total      += labels.size(0)\n        del imgs, labels, outputs, loss\n    return total_loss / len(loader), correct / total\n\n\ndef evaluate(model, loader, criterion):\n    model.eval()\n    total_loss, preds, trues = 0, [], []\n    with torch.no_grad():\n        for imgs, labels in loader:\n            imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)\n            with autocast('cuda'):\n                out        = model(imgs)\n                total_loss += criterion(out, labels).item()\n            preds.extend(out.argmax(1).cpu().numpy())\n            trues.extend(labels.cpu().numpy())\n            del imgs, labels, out\n    acc = np.mean(np.array(preds) == np.array(trues))\n    f1  = f1_score(trues, preds, average=\"macro\", zero_division=0)\n    return total_loss / len(loader), acc, f1, preds, trues\n\n\nprint(\"✅ Tất cả hàm sẵn sàng — EfficientNet-B4 Teacher+\")\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# ============================================================\n# CELL 8: Train EfficientNet-B4 Teacher+ — RGB only\n# ============================================================","metadata":{}},{"cell_type":"code","source":"CURRENT_DATASET = \"microsoft_rgb\"\ncfg             = DATASET_CONFIGS[CURRENT_DATASET]\nOUTPUT_FILE     = f\"/kaggle/working/results_eb4_teacher_plus.json\"\n\nprint(f\"{'='*60}\")\nprint(f\"  {cfg['description']}\")\nprint(f\"{'='*60}\")\n\ntrain_tf, val_tf = get_transforms(cfg[\"is_rgb\"])\nif cfg.get(\"csv_path\"):\n    _tmp = CSVImageDataset(cfg[\"path\"], cfg[\"csv_path\"], transform=train_tf)\nelse:\n    _tmp = datasets.ImageFolder(cfg[\"path\"], transform=train_tf)\nget_fixed_split(_tmp, CURRENT_DATASET)\n_class_counts    = np.bincount(_tmp.targets)\n_all_targets     = _tmp.targets[:]\ndel _tmp; clear_memory()\n\nw         = 1.0 / _class_counts\nw         = torch.FloatTensor(w / w.sum() * len(w)).to(DEVICE)\ncriterion = nn.CrossEntropyLoss(weight=w)\n\nprint(f\"  Imbalance — Min: {_class_counts.min()} | \"\n      f\"Max: {_class_counts.max()}\")\n\nrun_test_acc  = []\nrun_test_f1   = []\nrun_histories = []\n\nfor run in range(1, N_RUNS + 1):\n    print(f\"\\n  {'─'*56}\")\n    print(f\"  RUN {run}/{N_RUNS}\")\n    print(f\"  {'─'*56}\")\n    clear_memory()\n\n    torch.manual_seed(SEED + run)\n    np.random.seed(SEED + run)\n\n    if cfg.get(\"csv_path\"):\n        full_ds = CSVImageDataset(cfg[\"path\"], cfg[\"csv_path\"], transform=train_tf)\n    else:\n        full_ds = datasets.ImageFolder(cfg[\"path\"], transform=train_tf)\n    saved_split = json.load(open(split_path(CURRENT_DATASET)))\n    train_ds    = Subset(full_ds, saved_split[\"train\"])\n\n    val_copy  = copy.copy(full_ds); val_copy.transform  = val_tf\n    test_copy = copy.copy(full_ds); test_copy.transform = val_tf\n    val_ds_final  = Subset(val_copy,  saved_split[\"val\"])\n    test_ds_final = Subset(test_copy, saved_split[\"test\"])\n\n    sample_weights = np.array([\n        1.0 / _class_counts[_all_targets[i]]\n        for i in saved_split[\"train\"]\n    ])\n    sampler = WeightedRandomSampler(\n        weights     = torch.FloatTensor(sample_weights),\n        num_samples = len(sample_weights),\n        replacement = True,\n    )\n\n    train_loader = DataLoader(train_ds,     batch_size=BATCH_SIZE,\n                              sampler=sampler, num_workers=2,\n                              pin_memory=True)\n    val_loader   = DataLoader(val_ds_final, batch_size=BATCH_SIZE,\n                              shuffle=False,  num_workers=2,\n                              pin_memory=True)\n    test_loader  = DataLoader(test_ds_final,batch_size=BATCH_SIZE,\n                              shuffle=False,  num_workers=2,\n                              pin_memory=True)\n\n    model      = build_efficientnet_b4(cfg[\"num_classes\"])\n    base_model = model.module if isinstance(model, nn.DataParallel) \\\n                 else model\n    scaler     = GradScaler('cuda')\n    optimizer  = optim.AdamW(\n        base_model.classifier.parameters(),\n        lr=LR_HEAD, weight_decay=1e-4\n    )\n    scheduler  = optim.lr_scheduler.CosineAnnealingLR(\n        optimizer, T_max=FREEZE_EPOCHS\n    )\n\n    best_val_f1   = 0\n    best_val_loss = float('inf')\n    best_epoch    = 0\n    no_improve    = 0\n    start_epoch   = 1\n    phase         = 1\n    history       = []\n\n    # ── Resume ────────────────────────────────────────\n    rpath = resume_path(\"eb4_teacher_plus\", run)\n    if os.path.exists(rpath):\n        print(f\"  🔄 Resume run {run}...\")\n        ckpt          = torch.load(rpath, map_location=DEVICE)\n        base_model.load_state_dict(ckpt[\"model\"])\n        best_val_f1   = ckpt[\"best_val_f1\"]\n        best_val_loss = ckpt[\"best_val_loss\"]\n        best_epoch    = ckpt[\"best_epoch\"]\n        no_improve    = ckpt[\"no_improve\"]\n        start_epoch   = ckpt[\"epoch\"] + 1\n        history       = ckpt[\"history\"]\n        phase         = ckpt[\"phase\"]\n        if phase == 2:\n            unfreeze_last_blocks_eb4(base_model)\n            optimizer = optim.AdamW([\n                {\"params\": base_model.features[4].parameters(),\n                 \"lr\": LR_BLOCK_EARLY},\n                {\"params\": base_model.features[5].parameters(),\n                 \"lr\": LR_BLOCK_LATE},\n                {\"params\": base_model.features[6].parameters(),\n                 \"lr\": LR_BLOCK_LATE},\n                {\"params\": base_model.features[7].parameters(),\n                 \"lr\": LR_BLOCK_LATE},\n                {\"params\": base_model.features[8].parameters(),\n                 \"lr\": LR_BLOCK_LATE},\n                {\"params\": base_model.classifier.parameters(),\n                 \"lr\": LR_HEAD_PHASE2},\n            ], weight_decay=1e-4)\n            scheduler = optim.lr_scheduler.CosineAnnealingLR(\n                optimizer, T_max=UNFREEZE_EPOCHS\n            )\n        optimizer.load_state_dict(ckpt[\"optimizer\"])\n        scheduler.load_state_dict(ckpt[\"scheduler\"])\n        scaler.load_state_dict(ckpt[\"scaler\"])\n        print(f\"  ✅ Resume epoch {start_epoch} | \"\n              f\"Phase {phase} | Best F1: {best_val_f1:.4f}\")\n\n    print(f\"\\n  {'Ep':>4} {'Ph':>3} │ \"\n          f\"{'TrainLoss':>9} {'TrainAcc':>9} │ \"\n          f\"{'ValLoss':>8} {'ValAcc':>8} {'ValF1':>7} │ \"\n          f\"{'Best':>7} {'NoImp':>5} │ RAM\")\n    print(f\"  {'─'*4} {'─'*3} ┼ \"\n          f\"{'─'*9} {'─'*9} ┼ \"\n          f\"{'─'*8} {'─'*8} {'─'*7} ┼ \"\n          f\"{'─'*7} {'─'*5} ┼ {'─'*10}\")\n\n    for epoch in range(start_epoch, TOTAL_EPOCHS + 1):\n\n        if epoch == FREEZE_EPOCHS + 1 and phase == 1:\n            phase = 2\n            unfreeze_last_blocks_eb4(base_model)\n            optimizer = optim.AdamW([\n                {\"params\": base_model.features[4].parameters(),\n                 \"lr\": LR_BLOCK_EARLY},       # block sớm: 3e-5\n                {\"params\": base_model.features[5].parameters(),\n                 \"lr\": LR_BLOCK_LATE},         # block muộn: 1e-4\n                {\"params\": base_model.features[6].parameters(),\n                 \"lr\": LR_BLOCK_LATE},\n                {\"params\": base_model.features[7].parameters(),\n                 \"lr\": LR_BLOCK_LATE},\n                {\"params\": base_model.features[8].parameters(),\n                 \"lr\": LR_BLOCK_LATE},\n                {\"params\": base_model.classifier.parameters(),\n                 \"lr\": LR_HEAD_PHASE2},        # head: 2e-4\n            ], weight_decay=1e-4)\n            scheduler = optim.lr_scheduler.CosineAnnealingLR(\n                optimizer, T_max=UNFREEZE_EPOCHS\n            )\n            scaler    = GradScaler('cuda')\n            no_improve = 0\n            print(f\"\\n  ── Phase 2 (epoch {epoch}–{TOTAL_EPOCHS}) ──\\n\")\n\n        train_loss, train_acc = train_one_epoch(\n            model, train_loader, optimizer, criterion,\n            scaler, accumulation_steps=ACCUMULATION_STEPS\n        )\n        val_loss, val_acc, val_f1, _, _ = evaluate(\n            model, val_loader, criterion\n        )\n        scheduler.step()\n\n        history.append({\n            \"epoch\":      epoch,\n            \"phase\":      phase,\n            \"train_loss\": round(float(train_loss), 4),\n            \"train_acc\":  round(float(train_acc),  4),\n            \"val_loss\":   round(float(val_loss),   4),\n            \"val_acc\":    round(float(val_acc),    4),\n            \"val_f1\":     round(float(val_f1),     4),\n        })\n\n        if val_f1 > best_val_f1:\n            best_val_f1   = val_f1\n            best_val_loss = val_loss\n            best_epoch    = epoch\n            no_improve    = 0\n            torch.save(base_model.state_dict(),\n                       ckpt_path(\"eb4_teacher_plus\", run))\n            saved_mark = \"💾\"\n        else:\n            no_improve += 1\n            saved_mark = \"  \"\n\n        ram = psutil.virtual_memory()\n        print(f\"  {epoch:>4} {phase:>3} │ \"\n              f\"{train_loss:>9.4f} {train_acc:>9.4f} │ \"\n              f\"{val_loss:>8.4f} {val_acc:>8.4f} {val_f1:>7.4f} │ \"\n              f\"{best_val_f1:>7.4f} {no_improve:>5} │ \"\n              f\"{ram.used/1e9:.1f}GB {saved_mark}\")\n\n        if epoch % 5 == 0:\n            torch.save({\n                \"epoch\":         epoch,\n                \"phase\":         phase,\n                \"model\":         base_model.state_dict(),\n                \"optimizer\":     optimizer.state_dict(),\n                \"scheduler\":     scheduler.state_dict(),\n                \"scaler\":        scaler.state_dict(),\n                \"best_val_f1\":   best_val_f1,\n                \"best_val_loss\": best_val_loss,\n                \"best_epoch\":    best_epoch,\n                \"no_improve\":    no_improve,\n                \"history\":       history,\n            }, rpath)\n\n        if no_improve >= EARLY_STOP_PATIENCE:\n            print(f\"\\n  🛑 Early stop: F1 không tăng \"\n                  f\"{no_improve} epochs\")\n            break\n\n        # if len(history) >= 6 and phase == 2:\n        #     rv = np.mean([h[\"val_loss\"]   for h in history[-3:]])\n        #     pv = np.mean([h[\"val_loss\"]   for h in history[-6:-3]])\n        #     rt = np.mean([h[\"train_loss\"] for h in history[-3:]])\n        #     pt = np.mean([h[\"train_loss\"] for h in history[-6:-3]])\n        #     if rv > pv and rt < pt:\n        #         print(f\"\\n  🛑 Overfit │ \"\n        #               f\"val {pv:.4f}→{rv:.4f} │ \"\n        #               f\"train {pt:.4f}→{rt:.4f}\")\n        #         break\n\n        gc.collect()\n        torch.cuda.empty_cache()\n\n    print(f\"\\n  Best epoch: {best_epoch} | Best F1: {best_val_f1:.4f}\")\n    base_model.load_state_dict(\n        torch.load(ckpt_path(\"eb4_teacher_plus\", run),\n                   map_location=DEVICE)\n    )\n    _, test_acc, test_f1, preds, trues = evaluate(\n        model, test_loader, criterion\n    )\n    print(f\"  ✅ Run {run} TEST — \"\n          f\"Acc: {test_acc:.4f} | F1: {test_f1:.4f}\")\n    print(classification_report(\n        trues, preds,\n        target_names=full_ds.classes,\n        zero_division=0\n    ))\n\n    run_test_acc.append(float(test_acc))\n    run_test_f1.append(float(test_f1))\n    run_histories.append(history)\n\n    if os.path.exists(rpath):\n        os.remove(rpath)\n\n    del model, base_model, optimizer, scheduler, scaler\n    del train_loader, val_loader, test_loader, sampler\n    del full_ds, train_ds\n    del val_ds_final, test_ds_final, val_copy, test_copy\n    del sample_weights, saved_split\n    clear_memory()\n\n# Tính toán cho Accuracy\navg_acc = np.mean(run_test_acc)\nstd_acc = np.std(run_test_acc)\n\n# Tính toán cho F1-Macro\navg_f1  = np.mean(run_test_f1)\nstd_f1  = np.std(run_test_f1)\n\nprint(f\"\\n  {'═'*56}\")\nprint(f\"  📊 KẾT QUẢ TRUNG BÌNH ({N_RUNS} RUNS) — {cfg['description']}\")\nprint(f\"  {'═'*56}\")\nprint(f\"  Mean Accuracy : {avg_acc:.4f} (+/- {std_acc:.4f})\")\nprint(f\"  Mean F1-Macro : {avg_f1:.4f} (+/- {std_f1:.4f})\")\n\n# Cập nhật kết quả vào biến result để lưu file JSON\nresult = {\n    \"description\":   cfg[\"description\"],\n    \"model\":         \"EfficientNet-B3\",\n    \"role\":          \"Teacher\",\n    \"num_runs\":      N_RUNS,\n    \"test_accuracy_avg\": round(float(avg_acc), 4),\n    \"test_accuracy_std\": round(float(std_acc), 4), # Đã thêm STD của Acc vào đây\n    \"test_f1_macro_avg\": round(float(avg_f1),  4),\n    \"test_f1_std\":       round(float(std_f1),  4),\n    \"all_runs_acc\":  run_test_acc,\n    \"all_runs_f1\":   run_test_f1,\n    \"histories\":     run_histories,\n}\n\nwith open(OUTPUT_FILE, \"w\") as f:\n    json.dump(result, f, indent=2, ensure_ascii=False)\nprint(f\"\\n  ✅ Đã lưu → {OUTPUT_FILE}\")\n\ndel criterion, w, _class_counts, _all_targets\ndel run_test_acc, run_test_f1, run_histories, result\nclear_memory()\n","metadata":{},"outputs":[],"execution_count":null}]}