網(wǎng)絡(luò)CNN貓狗圖像分類實(shí)戰(zhàn):從數(shù)據(jù)準(zhǔn)備到模型調(diào)參)
簡(jiǎn)介這份資源是面向計(jì)算機(jī)相關(guān)專業(yè)學(xué)生與項(xiàng)目實(shí)戰(zhàn)學(xué)習(xí)者的貓狗圖像分類完整方案基于Python卷積神經(jīng)網(wǎng)絡(luò)CNN實(shí)現(xiàn)可直接用于畢業(yè)設(shè)計(jì)、期末大作業(yè)或課程設(shè)計(jì)場(chǎng)景難度適中適合希望掌握深度學(xué)習(xí)圖像分類流程的初學(xué)者與進(jìn)階者。壓縮包共2000個(gè)文件以1992張jpg貓狗圖片構(gòu)成訓(xùn)練與測(cè)試數(shù)據(jù)集另有7個(gè)py源碼文件負(fù)責(zé)模型搭建、訓(xùn)練與預(yù)測(cè)以及1個(gè)md說明文檔整體約86.82MB目錄結(jié)構(gòu)清晰便于按數(shù)據(jù)與代碼模塊分別查閱。目前已有207人學(xué)習(xí)下載可作為同類任務(wù)的參考范例。讀者可從中獲得一套經(jīng)本地編譯調(diào)試、導(dǎo)師認(rèn)可且評(píng)審98分的可運(yùn)行項(xiàng)目涵蓋數(shù)據(jù)讀取、CNN網(wǎng)絡(luò)定義、訓(xùn)練調(diào)參、模型保存與推理預(yù)測(cè)等關(guān)鍵環(huán)節(jié)既能直接復(fù)現(xiàn)實(shí)驗(yàn)也能據(jù)此理解卷積、池化與全連接層的組織方式為后續(xù)遷移學(xué)習(xí)或更復(fù)雜視覺任務(wù)打下基礎(chǔ)。1. 貓狗分類這個(gè)練手項(xiàng)目為什么值得你花一個(gè)周末跑通如果你正在找一個(gè)能寫進(jìn)簡(jiǎn)歷、又能真正跑起來(lái)的深度學(xué)習(xí)入門項(xiàng)目基于 Python 卷積神經(jīng)網(wǎng)絡(luò) CNN 的貓狗圖像分類幾乎是繞不開的選項(xiàng)。它不像 MNIST 手寫數(shù)字那樣簡(jiǎn)單到?jīng)]有區(qū)分度也不像 ImageNet 千類競(jìng)賽那樣需要多卡集群才能出結(jié)果。貓和狗這兩類目標(biāo)在視覺特征上有足夠的差異——耳朵形狀、面部比例、毛發(fā)紋理——但又不是靠顏色直方圖就能輕松分開這恰好逼著你去理解卷積層到底在學(xué)什么。我見過不少同學(xué)拿這個(gè)項(xiàng)目當(dāng)課程設(shè)計(jì)或面試作品問題往往不在模型本身而在于數(shù)據(jù)怎么組織、訓(xùn)練怎么監(jiān)控、過擬合怎么判斷。這篇筆記就按我實(shí)際做過的路徑從數(shù)據(jù)準(zhǔn)備到模型調(diào)參再到排錯(cuò)把每一步講清楚讓你能照著復(fù)現(xiàn)也能看懂邊界在哪。2. 數(shù)據(jù)準(zhǔn)備與目錄結(jié)構(gòu)別讓臟數(shù)據(jù)毀掉你的第一個(gè)模型2.1 貓狗數(shù)據(jù)集從哪來(lái)、怎么劃分才不翻車常見做法是從公開數(shù)據(jù)集中取貓狗兩類圖片總量通常在兩萬(wàn)五千張左右貓狗各半。拿到手的第一件事不是寫模型而是檢查圖片完整性。我一般會(huì)先跑一個(gè)腳本統(tǒng)計(jì)每張圖的尺寸、通道數(shù)和是否能正常解碼把損壞文件、灰度圖、異常小圖篩出來(lái)。這一步花十分鐘能省掉后面訓(xùn)練時(shí)莫名其妙的 loss 震蕩。劃分訓(xùn)練集、驗(yàn)證集、測(cè)試集時(shí)比例建議 8:1:1。注意驗(yàn)證集和測(cè)試集必須從同一分布里抽不能把某幾個(gè)品種的狗全塞進(jìn)測(cè)試集否則準(zhǔn)確率會(huì)虛高。目錄結(jié)構(gòu)按類別分文件夾這是 PyTorch 和 TensorFlow 的 ImageFolder 都能直接吃的格式dataset/ ├── train/ │ ├── cat/ │ └── dog/ ├── val/ │ ├── cat/ │ └── dog/ └── test/ ├── cat/ └── dog/每個(gè)類別文件夾下直接放圖片不要嵌套子目錄。如果原始數(shù)據(jù)是扁平的用下面這段腳本按比例搬移import os import random import shutil random.seed(42) src_dir raw_images dst_dir dataset classes [cat, dog] split_ratio {train: 0.8, val: 0.1, test: 0.1} for cls in classes: files os.listdir(os.path.join(src_dir, cls)) random.shuffle(files) n len(files) n_train int(n * split_ratio[train]) n_val int(n * split_ratio[val]) splits { train: files[:n_train], val: files[n_train:n_train n_val], test: files[n_train n_val:] } for split, flist in splits.items(): out_dir os.path.join(dst_dir, split, cls) os.makedirs(out_dir, exist_okTrue) for f in flist: shutil.copy(os.path.join(src_dir, cls, f), os.path.join(out_dir, f))這段代碼的關(guān)鍵參數(shù)是random.seed(42)固定隨機(jī)種子保證每次劃分一致方便復(fù)現(xiàn)。split_ratio按 8:1:1 分配如果數(shù)據(jù)量少于五千張驗(yàn)證集比例可以提到 0.15避免驗(yàn)證指標(biāo)抖動(dòng)太大。2.2 圖像預(yù)處理與增強(qiáng)哪些操作真正有用預(yù)處理分兩步統(tǒng)一尺寸和歸一化。CNN 要求輸入尺寸一致貓狗圖片原始分辨率參差不齊我一般縮放到 224×224這是 ResNet、VGG 等經(jīng)典骨干網(wǎng)絡(luò)的默認(rèn)輸入。歸一化用 ImageNet 的均值和標(biāo)準(zhǔn)差即使你從零訓(xùn)練這個(gè)先驗(yàn)也能加速收斂。數(shù)據(jù)增強(qiáng)是防止過擬合的第一道防線。但不是什么增強(qiáng)都往上堆。隨機(jī)水平翻轉(zhuǎn)、隨機(jī)裁剪、輕微旋轉(zhuǎn)這三樣對(duì)貓狗分類幾乎無(wú)副作用。顏色抖動(dòng)要謹(jǐn)慎貓狗毛色是重要特征過度抖動(dòng)會(huì)讓模型學(xué)偏。下面是一個(gè)可用的增強(qiáng)配置from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomResizedCrop的scale(0.8, 1.0)表示裁剪面積占原圖的 80% 到 100%再縮放到 224。RandomRotation(15)是正負(fù) 15 度再大就可能把貓耳朵轉(zhuǎn)成狗耳朵的形態(tài)。驗(yàn)證集只做中心裁剪不做任何隨機(jī)增強(qiáng)保證評(píng)估結(jié)果穩(wěn)定。提示增強(qiáng)只作用于訓(xùn)練集驗(yàn)證集和測(cè)試集必須用確定性變換否則每次評(píng)估結(jié)果都在變你根本不知道模型是變好了還是運(yùn)氣好。3. 從零搭建 CNN 還是用預(yù)訓(xùn)練骨干兩條路線的取舍3.1 一個(gè)夠用的自定義 CNN 結(jié)構(gòu)長(zhǎng)什么樣如果你是為了理解卷積、池化、全連接的作用從零搭一個(gè)四層卷積網(wǎng)絡(luò)就夠了。結(jié)構(gòu)不用花哨但每一層的通道數(shù)和卷積核大小要有依據(jù)。我的習(xí)慣是第一層用大核抓邊緣和紋理后面逐層減小核尺寸、增加通道數(shù)讓網(wǎng)絡(luò)從低級(jí)特征過渡到高級(jí)語(yǔ)義。import torch.nn as nn class CatDogCNN(nn.Module): def __init__(self, num_classes2): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) return self.classifier(x)BatchNorm2d放在卷積和激活之間能穩(wěn)定訓(xùn)練、允許更大學(xué)習(xí)率。AdaptiveAvgPool2d(1)把任意空間尺寸壓成 1×1這樣你換輸入尺寸時(shí)不用改全連接層。Dropout(0.5)只在分類頭用卷積層里不加因?yàn)榫矸e本身有參數(shù)共享過擬合風(fēng)險(xiǎn)相對(duì)小。這個(gè)網(wǎng)絡(luò)參數(shù)量大約在 110 萬(wàn)左右在單張消費(fèi)級(jí)顯卡上 batch size 設(shè) 32 能跑得很舒服。訓(xùn)練時(shí)用 Adam 優(yōu)化器學(xué)習(xí)率 1e-3跑 30 個(gè) epoch 通常能在驗(yàn)證集上到 85% 以上。如果低于 80%先檢查數(shù)據(jù)劃分和歸一化而不是急著加層。3.2 預(yù)訓(xùn)練模型微調(diào)什么時(shí)候該換路線自定義 CNN 的天花板大概在 90% 左右再往上提很吃力。如果你要沖高分項(xiàng)目或者簡(jiǎn)歷上寫一個(gè)亮眼數(shù)字換預(yù)訓(xùn)練骨干是更務(wù)實(shí)的選擇。ResNet18 是最常用的起點(diǎn)參數(shù)量 1100 萬(wàn)ImageNet 預(yù)訓(xùn)練權(quán)重已經(jīng)把底層特征學(xué)得很好了。微調(diào)策略有兩種只替換最后的全連接層凍結(jié)前面所有層或者解凍最后幾個(gè)卷積塊一起訓(xùn)。我一般先凍結(jié)訓(xùn)練 5 個(gè) epoch讓新分類頭適應(yīng)數(shù)據(jù)分布再解凍最后一個(gè) stage 用更小的學(xué)習(xí)率跑 10 個(gè) epoch。這樣比一上來(lái)就全量微調(diào)穩(wěn)定得多。import torchvision.models as models import torch.nn as nn model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) for param in model.parameters(): param.requires_grad False model.fc nn.Linear(model.fc.in_features, 2) # 第一階段只訓(xùn)分類頭 optimizer torch.optim.Adam(model.fc.parameters(), lr1e-3) # 第二階段解凍 layer4 for param in model.layer4.parameters(): param.requires_grad True optimizer torch.optim.Adam([ {params: model.layer4.parameters(), lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3} ])注意第二階段用了參數(shù)組卷積層學(xué)習(xí)率 1e-4分類頭保持 1e-3。這是因?yàn)轭A(yù)訓(xùn)練權(quán)重已經(jīng)很好大學(xué)習(xí)率會(huì)把它破壞掉。這個(gè)配置在貓狗數(shù)據(jù)集上通常能到 97% 以上但你要清楚這個(gè)數(shù)字里有 ImageNet 的先驗(yàn)功勞面試時(shí)被問到要能說清楚。4. 訓(xùn)練循環(huán)與監(jiān)控loss 不降的時(shí)候先看這三個(gè)地方4.1 一個(gè)帶驗(yàn)證和保存的完整訓(xùn)練模板訓(xùn)練循環(huán)本身不復(fù)雜但要把訓(xùn)練損失、驗(yàn)證損失、驗(yàn)證準(zhǔn)確率都記下來(lái)還要在驗(yàn)證指標(biāo)最好時(shí)保存權(quán)重。下面這個(gè)模板我用了很多次結(jié)構(gòu)清晰改起來(lái)方便import torch from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) return running_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) running_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) return running_loss / total, correct / total train_dataset ImageFolder(dataset/train, transformtrain_transform) val_dataset ImageFolder(dataset/val, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) device torch.device(cuda if torch.cuda.is_available() else cpu) model CatDogCNN().to(device) criterion torch.nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) best_acc 0.0 for epoch in range(30): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) print(fEpoch {epoch1}: train_loss{train_loss:.4f} train_acc{train_acc:.4f} fval_loss{val_loss:.4f} val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)num_workers4在 Linux 上能加速數(shù)據(jù)加載Windows 上如果報(bào)錯(cuò)就改成 0。shuffleTrue只對(duì)訓(xùn)練集開驗(yàn)證集必須關(guān)掉否則評(píng)估結(jié)果沒有可比性。保存權(quán)重時(shí)用state_dict()而不是整個(gè)模型文件更小加載也更靈活。4.2 學(xué)習(xí)率調(diào)度與早停兩個(gè)讓訓(xùn)練更省心的技巧固定學(xué)習(xí)率在后期容易在最優(yōu)解附近震蕩。加一個(gè)余弦退火或者階梯下降能讓驗(yàn)證準(zhǔn)確率再漲一兩個(gè)點(diǎn)。我常用CosineAnnealingLR配合早停機(jī)制驗(yàn)證損失連續(xù) 5 個(gè) epoch 不降就停scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) patience 5 counter 0 best_val_loss float(inf) for epoch in range(30): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step() if val_loss best_val_loss: best_val_loss val_loss counter 0 torch.save(model.state_dict(), best_model.pth) else: counter 1 if counter patience: print(fEarly stop at epoch {epoch1}) breakT_max30表示余弦周期覆蓋 30 個(gè) epoch學(xué)習(xí)率從初始值平滑降到接近零。早停的patience5是經(jīng)驗(yàn)值數(shù)據(jù)量小可以設(shè) 3數(shù)據(jù)量大可以設(shè) 8。注意早??吹氖球?yàn)證損失而不是準(zhǔn)確率損失更敏感能更早發(fā)現(xiàn)過擬合。5. 避坑與排查那些讓我熬夜的翻車現(xiàn)場(chǎng)5.1 驗(yàn)證準(zhǔn)確率遠(yuǎn)高于訓(xùn)練準(zhǔn)確率現(xiàn)象訓(xùn)練到第 5 個(gè) epoch訓(xùn)練準(zhǔn)確率 70%驗(yàn)證準(zhǔn)確率 85%。原因通常是訓(xùn)練時(shí)開了 Dropout 和強(qiáng)增強(qiáng)驗(yàn)證時(shí)全關(guān)掉模型在驗(yàn)證集上反而表現(xiàn)更好。這不是 bug是正?,F(xiàn)象。解決方式是看訓(xùn)練損失是否在降如果損失正常下降就不用管準(zhǔn)確率的倒掛。如果訓(xùn)練損失也不降檢查數(shù)據(jù)標(biāo)簽是否錯(cuò)位。5.2 loss 變成 NaN現(xiàn)象訓(xùn)練幾個(gè) batch 后 loss 突然變成 nan。原因多半是學(xué)習(xí)率太大或者數(shù)據(jù)里有異常值。先檢查輸入歸一化是否做了再檢查有沒有損壞圖片產(chǎn)生極端像素值。解決方式是把學(xué)習(xí)率降到 1e-4 重跑同時(shí)在 DataLoader 里加drop_lastTrue避免最后一個(gè) batch 只有一張圖導(dǎo)致 BatchNorm 統(tǒng)計(jì)量不穩(wěn)。5.3 顯存不夠用現(xiàn)象報(bào) CUDA out of memory。原因可能是 batch size 太大、模型參數(shù)量太大、或者沒有用torch.no_grad()包住驗(yàn)證循環(huán)。解決方式是先把 batch size 減半驗(yàn)證循環(huán)確認(rèn)加了torch.no_grad()如果還不夠就換更小的骨干網(wǎng)絡(luò)或者用梯度累積模擬大 batch。5.4 測(cè)試集準(zhǔn)確率比驗(yàn)證集低很多現(xiàn)象驗(yàn)證集 95%測(cè)試集只有 80%。原因通常是驗(yàn)證集和測(cè)試集分布不一致或者測(cè)試集里有訓(xùn)練時(shí)沒見過的品種。解決方式是重新檢查劃分腳本確保隨機(jī)種子固定且分層抽樣。如果數(shù)據(jù)量允許做 5 折交叉驗(yàn)證看方差有多大方差大說明數(shù)據(jù)本身難度不均。5.5 模型把貓全預(yù)測(cè)成狗現(xiàn)象混淆矩陣顯示某一類召回率接近零。原因可能是類別標(biāo)簽映射反了或者某一類圖片在預(yù)處理時(shí)被錯(cuò)誤增強(qiáng)。解決方式是打印前 10 個(gè) batch 的標(biāo)簽和圖片肉眼確認(rèn)。另外檢查ImageFolder的類別順序它是按文件夾名字母序排的cat 在 dog 前面別搞反。6. 把準(zhǔn)確率再往上推一個(gè)我常用的驗(yàn)證技巧當(dāng)你已經(jīng)跑通基線想沖更高分?jǐn)?shù)時(shí)別急著換更大的模型。先做一件事用測(cè)試時(shí)增強(qiáng)TTA看看推理階段的提升空間。TTA 的思路是對(duì)同一張測(cè)試圖做多次不同變換把預(yù)測(cè)概率平均。這個(gè)技巧不增加訓(xùn)練成本通常能漲 0.5 到 1.5 個(gè)百分點(diǎn)。def predict_with_tta(model, image, device): model.eval() transforms_list [ val_transform, transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.RandomHorizontalFlip(p1.0), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) ] probs [] with torch.no_grad(): for t in transforms_list: inp t(image).unsqueeze(0).to(device) out torch.softmax(model(inp), dim1) probs.append(out) return torch.mean(torch.stack(probs), dim0)這里只用了原圖和水平翻轉(zhuǎn)兩個(gè)視圖因?yàn)樨埞贩诸悓?duì)翻轉(zhuǎn)不敏感再多視圖收益遞減。如果你用的是預(yù)訓(xùn)練模型還可以把不同 epoch 保存的權(quán)重做集成效果比單模型 TTA 更穩(wěn)。另一個(gè)我常做的驗(yàn)證是畫混淆矩陣和看錯(cuò)誤樣本。把測(cè)試集里預(yù)測(cè)錯(cuò)的圖挑出來(lái)按置信度從高到低排看前 20 張。如果錯(cuò)的是模糊、遮擋、多目標(biāo)同框的圖說明模型已經(jīng)學(xué)到該學(xué)的剩下的靠數(shù)據(jù)清洗解決。如果錯(cuò)的是清晰單目標(biāo)圖那就要回頭查標(biāo)簽或者增強(qiáng)策略。這個(gè)習(xí)慣幫我省了很多盲目調(diào)參的時(shí)間也讓我在寫項(xiàng)目報(bào)告時(shí)能說清楚模型的邊界在哪。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取