{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Dogs vs Cats Redux - EfficientNet-B3\n\n这个 notebook 用迁移学习完成 Kaggle **Dogs vs Cats Redux** 竞赛，并生成 `/kaggle/working/submission.csv`。\n\n建议 Kaggle 设置：开启 GPU，开启 Internet。","metadata":{}},{"cell_type":"markdown","source":"## 1. 导入依赖与基础配置\n\n模型使用 ImageNet 预训练的 EfficientNet-B3，并把原来的分类头替换为单输出二分类头：cat = 0，dog = 1。","metadata":{}},{"cell_type":"code","source":"import os\nimport re\nimport zipfile\nimport random\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\nfrom torchvision.models import efficientnet_b3, EfficientNet_B3_Weights\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import log_loss\n\n\nSEED = 42\n\nDATA_CANDIDATES = [\n    Path('/kaggle/input/competitions/dogs-vs-cats-redux-kernels-edition'),\n    Path('/kaggle/input/dogs-vs-cats-redux-kernels-edition'),\n]\nDATA_DIR = next((p for p in DATA_CANDIDATES if (p / 'train.zip').exists()), None)\nif DATA_DIR is None:\n    raise FileNotFoundError('Could not find the competition data directory.')\n\nWORK_DIR = Path('/kaggle/working/dogs_vs_cats')\nTRAIN_EXTRACT_DIR = WORK_DIR / 'train_images'\nTEST_EXTRACT_DIR = WORK_DIR / 'test_images'\n\nTRAIN_ZIP = DATA_DIR / 'train.zip'\nTEST_ZIP = DATA_DIR / 'test.zip'\nSAMPLE_SUB = DATA_DIR / 'sample_submission.csv'\n\nIMG_SIZE = 300\nBATCH_SIZE = 32\nEPOCHS = 3\nLR = 2e-4\nWEIGHT_DECAY = 1e-4\nVALID_SIZE = 0.08\n\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint('Device:', device)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T11:11:50.887211Z","iopub.execute_input":"2026-06-25T11:11:50.887499Z","iopub.status.idle":"2026-06-25T11:11:50.900345Z","shell.execute_reply.started":"2026-06-25T11:11:50.887474Z","shell.execute_reply":"2026-06-25T11:11:50.898783Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. 解压并整理数据\n\n竞赛数据中的图片保存在 `train.zip` 和 `test.zip` 中。这里先解压到 `/kaggle/working`，再根据文件名生成训练用的 dataframe。","metadata":{}},{"cell_type":"code","source":"def seed_everything(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n\n\ndef unzip_once(zip_path, out_dir):\n    out_dir.mkdir(parents=True, exist_ok=True)\n    marker = out_dir / f'.{zip_path.stem}_unzipped'\n    if marker.exists():\n        return\n\n    print(f'Unzipping {zip_path.name} ...')\n    with zipfile.ZipFile(zip_path, 'r') as z:\n        z.extractall(out_dir)\n    marker.write_text('done')\n\n\ndef image_files(root):\n    return sorted([p for p in root.rglob('*.jpg') if p.is_file()])\n\n\nseed_everything(SEED)\nunzip_once(TRAIN_ZIP, TRAIN_EXTRACT_DIR)\nunzip_once(TEST_ZIP, TEST_EXTRACT_DIR)\n\nif not SAMPLE_SUB.exists():\n    raise FileNotFoundError(f'Missing sample_submission.csv: {SAMPLE_SUB}')\n\nsample_submission = pd.read_csv(SAMPLE_SUB)\ntrain_files = image_files(TRAIN_EXTRACT_DIR)\ntest_image_files = image_files(TEST_EXTRACT_DIR)\n\nif len(train_files) == 0:\n    raise ValueError(f'No training images found under {TRAIN_EXTRACT_DIR}')\n\ntest_path_by_id = {}\nfor p in test_image_files:\n    m = re.search(r'\\d+', p.stem)\n    if m:\n        test_path_by_id[int(m.group())] = str(p)\n\nrequired_ids = sample_submission['id'].astype(int).tolist()\nmissing_ids = [img_id for img_id in required_ids if img_id not in test_path_by_id]\nif missing_ids:\n    raise ValueError(f'Missing {len(missing_ids)} test images. First missing ids: {missing_ids[:10]}')\n\ntest_df = pd.DataFrame({\n    'id': required_ids,\n    'path': [test_path_by_id[img_id] for img_id in required_ids],\n})\n\ndf = pd.DataFrame({\n    'path': [str(p) for p in train_files],\n    'label': [1 if p.name.startswith('dog') else 0 for p in train_files],\n})\n\ntrain_df, valid_df = train_test_split(\n    df,\n    test_size=VALID_SIZE,\n    random_state=SEED,\n    stratify=df['label'],\n)\n\ntrain_df = train_df.reset_index(drop=True)\nvalid_df = valid_df.reset_index(drop=True)\n\nprint('Data dir:', DATA_DIR)\nprint('Train images:', len(train_files))\nprint('Test images:', len(test_df))\nprint('Sample submission rows:', len(sample_submission))\nprint('Train split:')\nprint(train_df['label'].value_counts())\nprint('Valid split:')\nprint(valid_df['label'].value_counts())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T11:11:50.901197Z","iopub.status.idle":"2026-06-25T11:11:50.901672Z","shell.execute_reply.started":"2026-06-25T11:11:50.901440Z","shell.execute_reply":"2026-06-25T11:11:50.901466Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. 构建 Dataset、图像增强和 DataLoader\n\n训练集使用轻量图像增强，验证集和测试集只做固定预处理，保证评估和预测结果稳定。","metadata":{}},{"cell_type":"code","source":"class CatDogDataset(Dataset):\n    def __init__(self, dataframe, transform=None, is_test=False):\n        self.df = dataframe.reset_index(drop=True)\n        self.transform = transform\n        self.is_test = is_test\n\n    def __len__(self):\n        return len(self.df)\n\n    def __getitem__(self, idx):\n        path = self.df.loc[idx, 'path']\n        image = Image.open(path).convert('RGB')\n\n        if self.transform:\n            image = self.transform(image)\n\n        if self.is_test:\n            return image, path\n\n        label = float(self.df.loc[idx, 'label'])\n        return image, torch.tensor(label, dtype=torch.float32)\n\n\nweights = EfficientNet_B3_Weights.IMAGENET1K_V1\nimagenet_mean = weights.transforms().mean\nimagenet_std = weights.transforms().std\n\ntrain_tfms = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE)),\n    transforms.RandomHorizontalFlip(p=0.5),\n    transforms.RandomRotation(8),\n    transforms.ColorJitter(brightness=0.12, contrast=0.12, saturation=0.08, hue=0.02),\n    transforms.ToTensor(),\n    transforms.Normalize(imagenet_mean, imagenet_std),\n])\n\nvalid_tfms = transforms.Compose([\n    transforms.Resize((IMG_SIZE, IMG_SIZE)),\n    transforms.ToTensor(),\n    transforms.Normalize(imagenet_mean, imagenet_std),\n])\n\ntrain_loader = DataLoader(\n    CatDogDataset(train_df, train_tfms),\n    batch_size=BATCH_SIZE,\n    shuffle=True,\n    num_workers=2,\n    pin_memory=True,\n)\n\nvalid_loader = DataLoader(\n    CatDogDataset(valid_df, valid_tfms),\n    batch_size=BATCH_SIZE * 2,\n    shuffle=False,\n    num_workers=2,\n    pin_memory=True,\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T11:11:50.903013Z","iopub.status.idle":"2026-06-25T11:11:50.903437Z","shell.execute_reply.started":"2026-06-25T11:11:50.903214Z","shell.execute_reply":"2026-06-25T11:11:50.903240Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. 构建模型\n\n这里把 ImageNet 的多分类头换成单输出层。损失函数使用 `BCEWithLogitsLoss`，所以只在验证和预测阶段手动做 sigmoid。","metadata":{}},{"cell_type":"code","source":"model = efficientnet_b3(weights=weights)\nin_features = model.classifier[1].in_features\nmodel.classifier[1] = nn.Linear(in_features, 1)\nmodel = model.to(device)\n\ncriterion = nn.BCEWithLogitsLoss()\noptimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)\nscheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS)\nscaler = torch.cuda.amp.GradScaler(enabled=(device.type == 'cuda'))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T11:11:50.904538Z","iopub.status.idle":"2026-06-25T11:11:50.904899Z","shell.execute_reply.started":"2026-06-25T11:11:50.904707Z","shell.execute_reply":"2026-06-25T11:11:50.904727Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. 训练与验证\n\n验证指标使用 log loss，与竞赛评分方式一致。每轮训练后会根据验证集 log loss 保存当前最优权重。","metadata":{}},{"cell_type":"code","source":"def train_one_epoch(epoch):\n    model.train()\n    total_loss = 0.0\n\n    pbar = tqdm(train_loader, desc=f'Epoch {epoch} train')\n    for images, labels in pbar:\n        images = images.to(device, non_blocking=True)\n        labels = labels.to(device, non_blocking=True).view(-1, 1)\n\n        optimizer.zero_grad(set_to_none=True)\n\n        with torch.cuda.amp.autocast(enabled=(device.type == 'cuda')):\n            logits = model(images)\n            loss = criterion(logits, labels)\n\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n\n        total_loss += loss.item() * images.size(0)\n        pbar.set_postfix(loss=loss.item())\n\n    return total_loss / len(train_loader.dataset)\n\n\n@torch.no_grad()\ndef validate():\n    model.eval()\n    losses = []\n    preds = []\n    targets = []\n\n    for images, labels in tqdm(valid_loader, desc='Valid'):\n        images = images.to(device, non_blocking=True)\n        labels = labels.to(device, non_blocking=True).view(-1, 1)\n\n        with torch.cuda.amp.autocast(enabled=(device.type == 'cuda')):\n            logits = model(images)\n            loss = criterion(logits, labels)\n\n        prob = torch.sigmoid(logits).detach().cpu().numpy().reshape(-1)\n        losses.append(loss.item() * images.size(0))\n        preds.extend(prob.tolist())\n        targets.extend(labels.detach().cpu().numpy().reshape(-1).tolist())\n\n    preds_clip = np.clip(np.array(preds), 1e-5, 1 - 1e-5)\n    score = log_loss(targets, preds_clip)\n    return sum(losses) / len(valid_loader.dataset), score\n\n\nbest_score = float('inf')\nbest_path = '/kaggle/working/best_effnet_b3.pth'\n\nfor epoch in range(1, EPOCHS + 1):\n    train_loss = train_one_epoch(epoch)\n    valid_loss, valid_logloss = validate()\n    scheduler.step()\n\n    print(\n        f'Epoch {epoch}: '\n        f'train_loss={train_loss:.5f}, '\n        f'valid_loss={valid_loss:.5f}, '\n        f'valid_logloss={valid_logloss:.5f}'\n    )\n\n    if valid_logloss < best_score:\n        best_score = valid_logloss\n        torch.save(model.state_dict(), best_path)\n        print(f'Saved best model: {best_score:.5f}')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T11:11:50.905946Z","iopub.status.idle":"2026-06-25T11:11:50.906312Z","shell.execute_reply.started":"2026-06-25T11:11:50.906130Z","shell.execute_reply":"2026-06-25T11:11:50.906155Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. 预测测试集并生成提交文件\n\n预测时使用简单的水平翻转 TTA。由于本竞赛使用 log loss 评分，最终概率会做轻微 clip，避免过度自信的 0 或 1。","metadata":{}},{"cell_type":"code","source":"model.load_state_dict(torch.load(best_path, map_location=device))\nmodel.eval()\n\ntest_loader = DataLoader(\n    CatDogDataset(test_df, valid_tfms, is_test=True),\n    batch_size=BATCH_SIZE * 2,\n    shuffle=False,\n    num_workers=2,\n    pin_memory=True,\n)\n\n\n@torch.no_grad()\ndef predict_with_tta(loader):\n    all_ids = []\n    all_probs = []\n\n    for images, paths in tqdm(loader, desc='Test'):\n        images = images.to(device, non_blocking=True)\n\n        with torch.cuda.amp.autocast(enabled=(device.type == 'cuda')):\n            logits1 = model(images)\n            logits2 = model(torch.flip(images, dims=[3]))\n\n        probs = (torch.sigmoid(logits1) + torch.sigmoid(logits2)) / 2.0\n        probs = probs.detach().cpu().numpy().reshape(-1)\n\n        for path, prob in zip(paths, probs):\n            all_ids.append(int(Path(path).stem))\n            all_probs.append(float(prob))\n\n    return all_ids, all_probs\n\n\nids, probs = predict_with_tta(test_loader)\n\npred_df = pd.DataFrame({\n    'id': ids,\n    'label': probs,\n})\n\nsubmission = sample_submission[['id']].copy()\nsubmission = submission.merge(pred_df, on='id', how='left')\n\nif submission['label'].isna().any():\n    missing = submission.loc[submission['label'].isna(), 'id'].head(10).tolist()\n    raise ValueError(f'Submission has missing predictions. First missing ids: {missing}')\n\nif len(submission) != len(sample_submission):\n    raise ValueError(f'Bad submission row count: {len(submission)} != {len(sample_submission)}')\n\nsubmission['label'] = submission['label'].clip(0.003, 0.997)\nsubmission.to_csv('/kaggle/working/submission.csv', index=False)\n\nprint('Best valid logloss:', best_score)\nprint(submission.head())\nprint(submission.tail())\nprint('Saved to /kaggle/working/submission.csv')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T11:11:51.060189Z","iopub.execute_input":"2026-06-25T11:11:51.060946Z","iopub.status.idle":"2026-06-25T11:11:51.079956Z","shell.execute_reply.started":"2026-06-25T11:11:51.060905Z","shell.execute_reply":"2026-06-25T11:11:51.078834Z"}},"outputs":[],"execution_count":null}]}