練CNN到PyTorch遷移學(xué)習(xí):Food-11圖像分類實(shí)戰(zhàn)全記錄)
最近在折騰食物圖片分類拿 Food-11 數(shù)據(jù)集練手從零手寫了一個 CNN又用 PyTorch 遷移學(xué)習(xí)刷了一輪兩種方案都完整跑通了。這篇東西把整個項(xiàng)目的思路、網(wǎng)絡(luò)設(shè)計、訓(xùn)練細(xì)節(jié)、踩過的坑都整理出來想給剛學(xué)完 PyTorch 基礎(chǔ)、正準(zhǔn)備做圖像分類實(shí)戰(zhàn)的人一點(diǎn)參考。適合兩類人一是想徹底搞懂 CNN 每層在干什么、怎么調(diào)參的初學(xué)者二是手上數(shù)據(jù)量不大、想用遷移學(xué)習(xí)快速出效果的朋友。整個過程沒有用特別復(fù)雜的結(jié)構(gòu)重點(diǎn)是把每一步為什么這么做講清楚。Food-11 這個數(shù)據(jù)集很有意思它包含 11 類常見食物圖片類別包括面包、甜點(diǎn)、雞蛋、油炸食品、肉類、面條、披薩、米飯、海鮮、湯和沙拉蔬菜。和 MNIST、CIFAR 那種入門數(shù)據(jù)集相比它的難度高不少——同類食物有煎炸、烘焙、燉煮等不同形態(tài)不同類別之間又存在視覺相似性比如米飯和面條、湯和燉菜很容易混淆。這種類內(nèi)差異大、類間有重疊的特點(diǎn)恰好能檢驗(yàn)一個模型真正的特征提取能力。數(shù)據(jù)規(guī)模大約是訓(xùn)練集 9866 張、驗(yàn)證集 3430 張、測試集 3347 張對消費(fèi)級顯卡來說完全跑得動。1. 項(xiàng)目整體設(shè)計與思路拆解1.1 為什么選 Food-11 作為實(shí)戰(zhàn)任務(wù)很多新手入門 CNN 都從 CIFAR-10 或者 MNIST 開始這兩個數(shù)據(jù)集確實(shí)經(jīng)典但有個問題類別之間差異太大背景干凈、物體居中隨便搭一個簡單網(wǎng)絡(luò)就能刷到 90% 以上很容易給人深度學(xué)習(xí)挺簡單的錯覺。一旦換到真實(shí)場景的數(shù)據(jù)立刻被打回原形。Food-11 的好處在于它足夠真實(shí)。圖片不是規(guī)范化的商品圖而是帶著不同光照、不同拍攝角度、不同擺盤方式的生活照每張圖的尺寸也不一樣有橫構(gòu)圖有豎構(gòu)圖還有各種背景干擾。你要做分類模型必須學(xué)會忽略背景、聚焦食物主體。這正好模擬了實(shí)際項(xiàng)目里最常見的場景用手機(jī)拍菜單識別菜品、用監(jiān)控視頻分析食堂消耗量之類的需求本質(zhì)上都是同一類問題。數(shù)據(jù)規(guī)模方面Food-11 也處于一個微妙的位置。它比 CIFAR-10 大但又遠(yuǎn)小于 ImageNet。這個規(guī)模決定了從頭訓(xùn)練一個小型 CNN 是有可能收斂到不錯精度的但又不會讓你輕松到接近 90%讓你能充分體驗(yàn)過擬合和欠擬合之間的拉扯。與此同時這個規(guī)模也適合對比遷移學(xué)習(xí)——你很快就能看出同樣一個任務(wù)帶預(yù)訓(xùn)練權(quán)重和從零初始化收斂速度差距有多大。1.2 先手寫 CNN 再遷移學(xué)習(xí)的意義我見過不少人直接上來就用遷移學(xué)習(xí)ResNet 加載預(yù)訓(xùn)練權(quán)重、替換全連接層、訓(xùn)練完事準(zhǔn)確率看著不錯但問到他為什么 ResNet 里的 shortcut 能解決梯度消失、為什么凍結(jié)底層比凍結(jié)高層效果好、為什么微調(diào)時學(xué)習(xí)率要調(diào)小他就答不上來了。這不怪他因?yàn)檫w移學(xué)習(xí)把太多東西封裝在看不見的地方你不需要理解也能出結(jié)果但出了問題就不知道怎么排查。手寫 CNN 的價值恰恰在這里。當(dāng)你自己定義每一個卷積層、自己計算每一層的尺寸變化、自己觀察訓(xùn)練集和驗(yàn)證集準(zhǔn)確率差距的時候你會對為什么卷積核要越來越大、為什么通道數(shù)要逐層翻倍、為什么全連接層這么容易過擬合有直觀感受。這些東西寫 100 遍筆記不如實(shí)際跑一輪來得深刻。所以我的路線是先花幾天時間從零寫一個簡單的 CNN調(diào)參、觀測、記錄把這個過程做完吃透。然后在此基礎(chǔ)上引入遷移學(xué)習(xí)把預(yù)訓(xùn)練模型換上來同樣是訓(xùn)練、觀測、記錄。最后把兩套方案的準(zhǔn)確率曲線、訓(xùn)練時長、顯存占用放在一起對比。這樣做完整跑一遍你對網(wǎng)絡(luò)容量、數(shù)據(jù)增強(qiáng)、特征遷移這幾個概念的理解會比刷課程深刻得多。1.3 方案選型與預(yù)期目標(biāo)在開始之前我給自己定了幾個具體目標(biāo)方便最后做復(fù)盤的時候衡量方案預(yù)期測試集準(zhǔn)確率訓(xùn)練預(yù)算從零訓(xùn)練 CNN65%-75%50-70 epoch遷移學(xué)習(xí) ResNet1885% 以上20-40 epoch實(shí)際做下來從零訓(xùn)練的 CNN 測試集準(zhǔn)確率落在 70% 附近遷移學(xué)習(xí)的 ResNet18 微調(diào)后測試集達(dá)到 87% 左右。兩套方案都算達(dá)到了預(yù)期。評估維度我選了四個測試集準(zhǔn)確率、訓(xùn)練收斂速度、顯存占用、過擬合程度。準(zhǔn)確率是最直觀的收斂速度看驗(yàn)證集損失下降到平穩(wěn)值所需的 epoch 數(shù)顯存占用直接看訓(xùn)練時 GPU 占用峰值過擬合程度則看訓(xùn)練集和驗(yàn)證集準(zhǔn)確率之間的差距。2. 環(huán)境準(zhǔn)備與數(shù)據(jù)管線搭建2.1 環(huán)境配置與依賴安裝這次項(xiàng)目的環(huán)境用的是 Python 3.10 PyTorch 2.1 CUDA 11.8顯卡恰好是 8GB 顯存。這個配置很普通不挑硬件大多數(shù)人的筆記本都能跑唯一需要注意的是顯存不夠時要把 batch size 調(diào)小。安裝依賴的時候有個常見的坑值得提醒PyTorch 的安裝要匹配 CUDA 版本。直接用pip install torch裝的是 CPU 版訓(xùn)練速度會慢到讓人懷疑人生。正確做法是去 PyTorch 官網(wǎng)根據(jù)你的 CUDA 版本復(fù)制對應(yīng)的安裝命令。如果不想查 CUDA 版本也可以在命令行跑nvidia-smi看右上角的 CUDA Version那個數(shù)字決定了你該選哪一檔。除了 torch 本體還需要 torchvision——因?yàn)橐盟虞d數(shù)據(jù)集和預(yù)訓(xùn)練模型。另外裝上 matplotlib 用于畫訓(xùn)練曲線tensorboard 用于實(shí)時監(jiān)控訓(xùn)練過程這個后面會細(xì)說。完整安裝命令大致長這樣pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install matplotlib tensorboard pandas如果機(jī)器上沒有獨(dú)立顯卡或者 CUDA 裝不上也不是完全不能做這個項(xiàng)目。把代碼里的.cuda()換成.to(cpu)batch size 調(diào)小一點(diǎn)訓(xùn)練時間大概會多 5 到 10 倍但代碼流程是一樣的原理也能學(xué)明白。不過遷移學(xué)習(xí)部分在 CPU 上跑會非常煎熬建議至少用云平臺的 GPU 實(shí)例。2.2 Food-11 數(shù)據(jù)集下載與加載Food-11 數(shù)據(jù)集來自某高校公開的食品圖像研究項(xiàng)目在公開渠道可以下載到。下載后是一個壓縮包解壓出來分三個文件夾分別是 training、validation、testing每個文件夾下面再按類別分子目錄。這種目錄結(jié)構(gòu)正好可以用torchvision.datasets.ImageFolder直接加載不需要手寫 Dataset 類。不過要留意一個細(xì)節(jié)Food-11 有些版本用的是類別數(shù)字編號做目錄名比如 0、1、2、3 一直到 10有些版本則標(biāo)注了類別名稱。你需要先在驗(yàn)證集上跑一次小測試確認(rèn)目錄編號和實(shí)際食物類別是否對得上否則后面畫混淆矩陣的時候會一臉懵。我自己就遇到過一次編號 5 標(biāo)的是肉類但圖片全是披薩排查了半天才發(fā)現(xiàn)是數(shù)據(jù)集內(nèi)部分類順序和文檔不一致。加載代碼很簡單from torchvision import datasets, transforms train_transform transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(p0.5), 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), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(food11/training, transformtrain_transform) val_dataset datasets.ImageFolder(food11/validation, transformval_transform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue)這里定義了兩種不同的 transform訓(xùn)練集用了隨機(jī)裁剪和隨機(jī)翻轉(zhuǎn)做數(shù)據(jù)增強(qiáng)驗(yàn)證集只用固定尺寸的中心裁剪不做隨機(jī)增強(qiáng)。因?yàn)轵?yàn)證集的作用是評估模型真實(shí)表現(xiàn)如果每一輪驗(yàn)證的圖片都在變化你看到的準(zhǔn)確率波動就會很大干擾判斷。2.3 數(shù)據(jù)增強(qiáng)策略的設(shè)計邏輯數(shù)據(jù)增強(qiáng)是這個小項(xiàng)目里最容易被忽視卻非常重要的環(huán)節(jié)。Food-11 每類大概不到 1000 張訓(xùn)練圖這點(diǎn)數(shù)據(jù)量對于從零訓(xùn)練的 CNN 來說是不夠的。如果不用數(shù)據(jù)增強(qiáng)模型很快就會把訓(xùn)練集背下來然后驗(yàn)證集準(zhǔn)確率卡在一個尷尬的位置。我用的訓(xùn)練增強(qiáng)組合是RandomResizedCrop(224)RandomHorizontalFlipColorJitter。RandomResizedCrop會從原圖隨機(jī)裁剪一塊比例不同的區(qū)域然后縮放到 224x224等于同時做了尺度擾動和位置擾動這比RandomCrop更能模擬真實(shí)場景中食物在畫面中大小不一的情況。ColorJitter則是隨機(jī)調(diào)整亮度、對比度和飽和度幫助模型對光照變化不敏感。但遷移學(xué)習(xí)階段我特意把ColorJitter的強(qiáng)度調(diào)低了只保留輕微的變化。原因是預(yù)訓(xùn)練模型在 ImageNet 上見過的是比較標(biāo)準(zhǔn)的顏色分布如果用強(qiáng)顏色增強(qiáng)把圖片色調(diào)改得面目全非預(yù)訓(xùn)練特征就發(fā)揮不出作用了。這種情況下你等于拿從零訓(xùn)練的規(guī)則去約束一個有經(jīng)驗(yàn)的老模型反而束縛了它。歸一化參數(shù)用的是 ImageNet 的均值和標(biāo)準(zhǔn)差即使是從零訓(xùn)練我也用了這套參數(shù)。嚴(yán)格的講自己動手統(tǒng)計 Food-11 的均值和標(biāo)準(zhǔn)差會稍微更準(zhǔn)確但我實(shí)測下來兩者差異很小不值得為這點(diǎn)提升額外增加代碼復(fù)雜度。使用統(tǒng)一歸一化參數(shù)還有個額外好處后期遷移學(xué)習(xí)階段直接復(fù)用同一套 transform代碼不用改。3. 從零手寫 CNN 并訓(xùn)練到收斂3.1 網(wǎng)絡(luò)結(jié)構(gòu)設(shè)計手寫 CNN 不是越復(fù)雜越好關(guān)鍵是結(jié)構(gòu)合理、每層的作用清清楚楚。我用的結(jié)構(gòu)是三個卷積塊加兩個全連接層輸入 224x224x3 的彩色圖片輸出 11 個類別的分?jǐn)?shù)。設(shè)計原則就三條通道數(shù)逐層翻倍、空間尺寸逐層減半、在卷積和全連接的銜接處用池化降維。import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes11): 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), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))為什么每個卷積層都用 3x3 卷積因?yàn)閮蓚€串聯(lián)的 3x3 卷積堆疊出來的感受野相當(dāng)于一個 5x5 卷積參數(shù)更少、非線性表達(dá)更強(qiáng)。為什么通道數(shù)從 32 到 64 到 128 翻倍因?yàn)殡S著空間尺寸不斷減半要保持特征信息量不下降就得靠增加通道數(shù)來補(bǔ)償。展平后的維度是這樣算的輸入 224x224經(jīng)過三次 MaxPool2d(2) 后空間尺寸變?yōu)?224/2/2/2 28所以展平后是 1282828 100352 維。這個全連接輸入維度有點(diǎn)大但實(shí)際訓(xùn)練時問題不大。如果不想每次手算維度也可以在最前面加一層nn.AdaptiveAvgPool2d((7, 7))這樣不管輸入尺寸怎么變都能統(tǒng)一輸出到 7x7代碼更優(yōu)雅我后面在遷移學(xué)習(xí)中就用這種寫法。還有兩個容易被忽略的細(xì)節(jié)。一個是BatchNorm2d放在卷積和 ReLU 之間先歸一化再激活這是目前實(shí)踐中最穩(wěn)定的排列。另一個是最后一個卷積塊后面沒有接全局平均池化而是直接把特征圖展平喂給全連接層。這么做不是最優(yōu)的VGG 時代的大量實(shí)驗(yàn)表明展平操作會導(dǎo)致較大參數(shù)量、更容易過擬合換成AdaptiveAvgPool2d后參數(shù)會少很多。你可以把這個當(dāng)成一個優(yōu)化方向但作為學(xué)習(xí)用的第一版網(wǎng)絡(luò)展平方案更直觀能讓你看懂特征圖是如何變成一維向量的。3.2 訓(xùn)練流程與損失函數(shù)設(shè)置訓(xùn)練一個小型 CNN 分類模型的代碼框架是通用的以后你換任何一個數(shù)據(jù)集這個骨架都能用。核心就是四個步驟前向傳播、計算損失、反向傳播、更新參數(shù)。import torch import torch.nn as nn import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN(num_classes11).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.StepLR(optimizer, step_size20, gamma0.5) epochs 60 for epoch in range(epochs): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) train_acc correct / total avg_loss total_loss / total # 驗(yàn)證階段 model.eval() val_correct, val_total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) val_correct (preds labels).sum().item() val_total labels.size(0) val_acc val_correct / val_total print(fEpoch {epoch1:03d} | Loss {avg_loss:.4f} | fTrain Acc {train_acc:.4f} | Val Acc {val_acc:.4f}) scheduler.step()這里的幾個設(shè)計點(diǎn)都有實(shí)際意義。損失函數(shù)用nn.CrossEntropyLoss()這個函數(shù)內(nèi)部自動幫你做了 softmax 和 log所以不要在模型最后一層額外加 softmax否則等于做了兩次歸一化數(shù)值行為和梯度都會受影響。優(yōu)化器我選了 Adam初始學(xué)習(xí)率 1e-3配了一個很輕的 L2 正則化weight_decay1e-4。Adam 的好處是對學(xué)習(xí)率不敏感、收斂穩(wěn)對新手友好。SGD 配合動量理論上有更好的泛化性能但需要調(diào)學(xué)習(xí)率和動量策略花的時間更多實(shí)戰(zhàn)中遇到 Adam 收斂不上去再考慮換 SGD。訓(xùn)練過程中我用了model.train()和model.eval()來切換模式。這個切換非常關(guān)鍵Dropout在訓(xùn)練時隨機(jī)丟棄神經(jīng)元但驗(yàn)證時不能丟BatchNorm在訓(xùn)練時用當(dāng)前 batch 的統(tǒng)計量驗(yàn)證時用訓(xùn)練階段累積的滑動平均。忘了切回 eval 模式是新手最容易犯的錯誤之一后果是驗(yàn)證準(zhǔn)確率莫名其妙地低而且每次跑結(jié)果不一樣。3.3 訓(xùn)練過程監(jiān)控與調(diào)參記錄訓(xùn)練到 20 輪左右的時候訓(xùn)練準(zhǔn)確率已經(jīng)到了 85% 左右驗(yàn)證集 62% 左右這時明顯出現(xiàn)了過擬合跡象。訓(xùn)練集學(xué)得比驗(yàn)證集快很多的情況在從零訓(xùn)練的小網(wǎng)絡(luò)上幾乎無法避免只能通過增強(qiáng)正則化和降低模型容量來緩解。我把 Dropout 從 0.3 提到了 0.5同時把ColorJitter的增強(qiáng)幅度調(diào)大了一點(diǎn)讓模型看到的訓(xùn)練樣本更加多樣化。調(diào)整后驗(yàn)證集準(zhǔn)確率往上走了一些但最終也只是穩(wěn)定在 70% 附近。從這里你能直觀感受到淺層網(wǎng)絡(luò)的容量瓶頸是什么意思。不是過擬合問題解決不了而是即便解決了過擬合這個網(wǎng)絡(luò)本身的表達(dá)能力也就到這個程度。11 個類別里米飯和面條、湯和燉菜這類細(xì)粒度差異需要更深層的特征才能區(qū)分而三層卷積只能學(xué)到紋理和顏色級別的特征再往上就需要更多層來組合語義信息。另一個值得記錄的調(diào)參經(jīng)驗(yàn)是學(xué)習(xí)率衰減。我用StepLR(step_size20, gamma0.5)也就是每 20 輪把學(xué)習(xí)率減半。如果一直用恒定學(xué)習(xí)率訓(xùn)練后期 loss 會在一個區(qū)間反復(fù)震蕩準(zhǔn)確率曲線也呈鋸齒狀衰減之后曲線明顯平滑下來。你可以在 tensorboard 里觀察 loss 曲線的尾部如果出現(xiàn)高頻震蕩而驗(yàn)證集準(zhǔn)確率不再提升大概率就是學(xué)習(xí)率偏大的信號。最終手寫 CNN 的結(jié)果是訓(xùn)練 60 輪測試集準(zhǔn)確率 70.8%。這個成績在沒有任何預(yù)訓(xùn)練權(quán)重的情況下對于 Food-11 這類真實(shí)圖片數(shù)據(jù)集來說屬于一個合理但談不上優(yōu)秀的結(jié)果。它證明了這個網(wǎng)絡(luò)架構(gòu)是能學(xué)到東西的但天花板也很明顯。4. 遷移學(xué)習(xí)從預(yù)訓(xùn)練權(quán)重到微調(diào)落地4.1 預(yù)訓(xùn)練模型選型遷移學(xué)習(xí)的核心思路是不從一個全新的隨機(jī)初始化開始而是用一個已經(jīng)在 ImageNet 上訓(xùn)練過的模型作為起點(diǎn)。ImageNet 有 1000 個類別、上百萬張圖片模型在分類訓(xùn)練中被迫學(xué)會識別各種通用視覺模式比如邊緣、紋理、形狀、物體部件。這些特征對絕大多數(shù)圖像任務(wù)都有復(fù)用價值。torchvision 里提供多種預(yù)訓(xùn)練模型我選了 ResNet18。選它的理由有三個參數(shù)量約 1170 萬不算大8GB 顯存跑起來毫無壓力結(jié)構(gòu)經(jīng)典殘差連接解決了深層網(wǎng)絡(luò)的梯度傳播問題這讓你在微調(diào)時即使解凍更多層也不用太擔(dān)心訓(xùn)練崩潰在 Food-11 這種中等復(fù)雜度的任務(wù)上ResNet18 的能力已經(jīng)足夠上 ResNet50 提升有限但訓(xùn)練時間和顯存占用都會明顯增加。ResNet50 適合什么時候用如果你手里的數(shù)據(jù)量更大、圖片分辨率更高、類別更多或者類別之間差異非常細(xì)微那時候 ResNet50 或更大的模型才有意義。但在這個項(xiàng)目里選了 ResNet50 也不會讓準(zhǔn)確率有質(zhì)的飛越反而會讓訓(xùn)練變慢過擬合風(fēng)險也更高。4.2 模型替換與參數(shù)凍結(jié)策略加載預(yù)訓(xùn)練 ResNet18 之后第一件事就是把輸出層的全連接替換掉。ResNet18 原來的全連接層輸出 1000 個類別分?jǐn)?shù)我們的任務(wù)是 11 類所以需要換成一個nn.Linear(resnet.fc.in_features, 11)。這里用in_features拿原層的輸入維度是標(biāo)準(zhǔn)寫法避免硬編碼 512 這種數(shù)字以后換 ResNet34、ResNet50 時代碼不用改。接下來是最關(guān)鍵的策略決策凍結(jié)哪些層解凍哪些層。import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) for param in model.parameters(): param.requires_grad_(False) for param in model.layer3.parameters(): param.requires_grad_(True) for param in model.layer4.parameters(): param.requires_grad_(True) model.fc nn.Linear(model.fc.in_features, 11)這個代碼做了三件事先把所有參數(shù)鎖定requires_gradFalse然后解凍了 layer3 和 layer4 這兩個殘差塊最后替換分類頭。解凍的含義是這些層的參數(shù)會在訓(xùn)練中被更新沒解凍的層參數(shù)保持預(yù)訓(xùn)練權(quán)重不變。凍結(jié)層的參數(shù)不參與訓(xùn)練這兩大好處第一大幅減少需要計算的梯度顯存占用下降第二避免在小數(shù)據(jù)集上破壞預(yù)訓(xùn)練學(xué)到的高質(zhì)量通用特征。但也不宜凍太多——layer1 和 layer2 學(xué)到的是邊緣、紋理這些通用的特征layer3 和 layer4 學(xué)到的是偏語義的特征對食物類別更關(guān)鍵解凍它們讓模型有空間去適應(yīng)當(dāng)前任務(wù)這是精度和數(shù)據(jù)規(guī)模之間的一個平衡點(diǎn)。如果你想先跑一個快速的 baseline可以把所有層都凍結(jié)只訓(xùn)練新的全連接層。這樣訓(xùn)練速度極快十幾分鐘就能出一個結(jié)果通常在 Food-11 上能到 80% 左右。解凍 layer3、layer4 后準(zhǔn)確率可以再提升幾個點(diǎn)。所以一個實(shí)用的流程是先只訓(xùn)分類頭確認(rèn) baseline再逐步解凍更深層觀察準(zhǔn)確率變化找到收益最大且不發(fā)生過擬合的解凍深度。優(yōu)化器這里需要特別處理一下不同層要用不同的學(xué)習(xí)率。因?yàn)槿B接層是隨機(jī)初始化的需要較大的學(xué)習(xí)率快速收斂而預(yù)訓(xùn)練層已經(jīng)有較好的初始化只需要很小的學(xué)習(xí)率做微調(diào)不然一步下去就把之前學(xué)好的特征給破壞了。optimizer optim.Adam([ {params: model.layer3.parameters(), lr: 1e-4}, {params: model.layer4.parameters(), lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3}, ], weight_decay1e-4)權(quán)重衰減對微調(diào)階段同樣重要它能抑制預(yù)訓(xùn)練層在微調(diào)過程中的參數(shù)漂移。有人擔(dān)心weight_decay加在預(yù)訓(xùn)練層上會破壞原有權(quán)重實(shí)測下來只要 lr 足夠小這個影響可以忽略反而能提升最終精度。4.3 微調(diào)實(shí)驗(yàn)結(jié)果與對比分析遷移學(xué)習(xí)階段我用相同的數(shù)據(jù)增強(qiáng)和 batch size訓(xùn)練了 30 輪。前 10 輪就達(dá)到了手寫 CNN 最終的水平驗(yàn)證集準(zhǔn)確率超過 70%20 輪后驗(yàn)證集穩(wěn)定在 87% 左右30 輪后測試集準(zhǔn)確率定格在 87.3%。整個過程沒有遇到明顯的過擬合訓(xùn)練集和驗(yàn)證集準(zhǔn)確率的差距大約在 3-4 個百分點(diǎn)比手寫 CNN 的 10 個百分點(diǎn)以上健康得多。這個結(jié)果說明預(yù)訓(xùn)練模型在 ImageNet 上學(xué)到的通用特征對食物圖片依然高度有效。底層卷積提取的紋理、邊緣、顏色斑塊等低層特征本身就和食物圖像沒有太大領(lǐng)域差異而高層的語義特征通過微調(diào)也能很快適應(yīng)食物這個特定領(lǐng)域。這是遷移學(xué)習(xí)最核心的邏輯低層特征通用高層特征特異微調(diào)相當(dāng)于把特異的部分重新訓(xùn)練一下而低層保持不動。對比手寫 CNN遷移學(xué)習(xí)帶來的收益非常明確準(zhǔn)確率提升接近 17 個百分點(diǎn)收斂輪數(shù)減少一半以上過擬合風(fēng)險大幅降低。唯一的代價是需要額外加載一個 44MB 左右的預(yù)訓(xùn)練權(quán)重文件以及第一輪訓(xùn)練前的參數(shù)下載時間。5. 兩套方案的對比與選型建議5.1 準(zhǔn)確率、訓(xùn)練時長、魯棒性三維對比兩套方案完整跑完后我列了一張對比表直觀反映差異。對比維度從零訓(xùn)練 CNNResNet18 遷移學(xué)習(xí)測試集準(zhǔn)確率70.8%87.3%達(dá)到 70% 所需輪數(shù)約 40 輪約 8 輪顯存峰值占用約 1.2GB約 1.8GB訓(xùn)練/驗(yàn)證準(zhǔn)確率差距10%3%-4%過擬合風(fēng)險高低收斂穩(wěn)定性需要調(diào)參相對穩(wěn)定從零訓(xùn)練 CNN 最大的問題是過擬合。訓(xùn)練集準(zhǔn)確率接近 96%驗(yàn)證集只有 79% 左右差距接近 17 個百分點(diǎn)。遷移學(xué)習(xí)就健康得多訓(xùn)練集 91%驗(yàn)證集 87%差距被壓縮在可接受范圍內(nèi)。這背后的原因不只是預(yù)訓(xùn)練權(quán)重更重要的是預(yù)訓(xùn)練模型本身的設(shè)計殘差結(jié)構(gòu)配合 batch normalization讓梯度傳播更穩(wěn)定訓(xùn)練過程不容易被噪聲樣本帶偏。5.2 什么場景該選哪種方案做完這個項(xiàng)目之后我形成了一個判斷框架以后拿到新的圖像分類任務(wù)可以直接套用。當(dāng)數(shù)據(jù)量每類超過 5000 張、圖片內(nèi)容高度標(biāo)準(zhǔn)化、且類別間差異明顯時從零訓(xùn)練一個淺層網(wǎng)絡(luò)可能是一個選項(xiàng)但那也僅僅是為了研究網(wǎng)絡(luò)結(jié)構(gòu)才需要做的事。工程上無論是訓(xùn)練效率還是最終精度遷移學(xué)習(xí)幾乎總是更好的選擇。數(shù)據(jù)量中等、每類有幾百張圖片而且任務(wù)和自然圖像比較接近的時候遷移學(xué)習(xí)是首選推薦先用凍結(jié)全部層訓(xùn)練分類頭做 baseline再逐步解凍深層這是最快找到最優(yōu)配置的路徑。數(shù)據(jù)量極少、每類只有幾十張圖片時那就只訓(xùn)練分類頭、保持所有特征層完全凍結(jié)同時依賴更強(qiáng)的數(shù)據(jù)增強(qiáng)來撐住訓(xùn)練。有一種情況值得警惕如果目標(biāo)圖片是醫(yī)學(xué)影像、衛(wèi)星遙感、工業(yè)檢測這類和自然圖像差異巨大的領(lǐng)域預(yù)訓(xùn)練權(quán)重的價值會打折扣。底層卷積的紋理、邊緣特征仍然有用但高層的語義特征跟你的任務(wù)完全對不上。這時候不要盲目解凍所有層應(yīng)該從淺到深逐層解凍每解凍一層就評估一次一旦發(fā)現(xiàn)驗(yàn)證集準(zhǔn)確率下降就停在那里。5.3 遷移學(xué)習(xí)工程的四個經(jīng)驗(yàn)第一個經(jīng)驗(yàn)是 BatchNorm 的統(tǒng)計量與 batch size 高度相關(guān)。微調(diào)時如果 batch size 設(shè)得太小比如小于 16BN 層計算出來的均值和方差波動很大會拖累整個模型的表現(xiàn)。遇到這種情況可以把凍結(jié)層的 BN 也設(shè)成 eval 模式或者干脆用一個足夠大的 batch size。我建議微調(diào)過程中的 batch size 不要低于 32。第二個經(jīng)驗(yàn)是輸入分辨率盡量保持 224x224。預(yù)訓(xùn)練模型在 ImageNet 上訓(xùn)練時用的就是 224 分辨率你如果為了省顯存用 160x160雖然 ResNet 的全連接層會因?yàn)檩斎胱冃《鵁o法 forward但用AdaptiveAvgPool2d的結(jié)構(gòu)能適應(yīng)不同分辨率只是準(zhǔn)確率會下降幾個點(diǎn)。所以最穩(wěn)妥的方式是 resize 到 256 再 center crop 到 224這個方案在速度和精度之間是公認(rèn)的平衡點(diǎn)。第三個經(jīng)驗(yàn)是數(shù)據(jù)增強(qiáng)可以比手寫網(wǎng)絡(luò)用的更強(qiáng)但也不能亂來。Food-11 上我稍微調(diào)大了RandomResizedCrop的裁剪范圍讓模型看到更多不同大小的食物區(qū)域效果是正向的。但ColorJitter如果設(shè)置得過強(qiáng)預(yù)訓(xùn)練層的顏色統(tǒng)計會被打亂效果反而變差。第四個經(jīng)驗(yàn)關(guān)于推理階段。訓(xùn)練結(jié)束后做測試推斷時記得也要用驗(yàn)證集那套 transform不要用包括了隨機(jī)翻轉(zhuǎn)的訓(xùn)練 transform。如果測試時圖像被隨機(jī)翻轉(zhuǎn)你的預(yù)測結(jié)果每次都不一樣無法得到穩(wěn)定的測試集準(zhǔn)確率。6. 常見問題與排查技巧實(shí)錄6.1 損失不下降或訓(xùn)練成 NaN訓(xùn)練過程中損失值不下降或者直接變成 NaN是新手遇到最多的問題。我自己在這上面踩過坑整理一下排查順序。先看學(xué)習(xí)率。如果損失長時間在一個高數(shù)值徘徊不往下走十有八九是學(xué)習(xí)率偏大或者偏小。Adam 的默認(rèn)學(xué)習(xí)率 1e-3 在這個小網(wǎng)絡(luò)上通常合適但如果換成更大更深的網(wǎng)絡(luò)這個值就偏大了損失會在幾個小 epoch 內(nèi)直接炸掉。遇到 NaN 的第一反應(yīng)就是把學(xué)習(xí)率除以 10 再試試。再看有沒有在模型最后一層重復(fù)加 softmax。nn.CrossEntropyLoss內(nèi)部的實(shí)現(xiàn)是log(softmax(x))如果你額外在forward里加了一個 softmax梯度計算路徑就多了一層非線性變換數(shù)值不穩(wěn)定損失甚至?xí)霈F(xiàn)負(fù)值。這個錯誤在代碼里不好肉眼發(fā)現(xiàn)最好用一個小批量數(shù)據(jù)做一輪前向推斷打印輸出層數(shù)值范圍正常應(yīng)該在 -5 到 5 之間。最后看數(shù)據(jù)歸一化。如果忘了用Normalize直接把原始像素值0-255喂給網(wǎng)絡(luò)或者歸一化的 mean/std 寫錯位置輸入分布的數(shù)值范圍會和模型預(yù)期差異很大導(dǎo)致訓(xùn)練很難收斂。Food-11 這種自然圖像一般按 ImageNet 的統(tǒng)計參數(shù)歸一化就好。6.2 過擬合嚴(yán)重時的應(yīng)對次序當(dāng)訓(xùn)練集準(zhǔn)確率很高、驗(yàn)證集準(zhǔn)確率停滯不前的時候按照這個順序調(diào)整先從數(shù)據(jù)側(cè)入手增強(qiáng)RandomResizedCrop和RandomHorizontalFlip有需要再加RandomRotation和ColorJitter再從模型側(cè)入手提高 Dropout 比例、降低通道數(shù)最后從訓(xùn)練策略入手加 weight_decay、早停、降低學(xué)習(xí)率。遷移學(xué)習(xí)階段如果出現(xiàn)過擬合最快的辦法是減少解凍的層數(shù)不要一上來就解凍全部。另一個有效手段是使用更小的學(xué)習(xí)率比如把微調(diào)學(xué)習(xí)率從 1e-4 降到 3e-5讓參數(shù)在已經(jīng)很小的范圍內(nèi)繼續(xù)調(diào)整。微調(diào)更怕矯枉過正寧可慢一點(diǎn)也不要讓預(yù)訓(xùn)練權(quán)重被幾個 high-loss 的樣本帶著跑偏。6.3 加載預(yù)訓(xùn)練模型報 key 不匹配加載torchvision預(yù)訓(xùn)練模型偶爾會遇到state_dict的 key 不匹配或者下面的報錯RuntimeError: Error(s) in loading state_dict for ResNet: Missing key(s) in state_dict: fc.weight, fc.bias. Unexpected key(s) in state_dict: module.fc.weight, module.fc.bias.這個報錯通常有兩種原因。第一種是你用了多 GPU 訓(xùn)練保存的權(quán)重key 前面統(tǒng)一帶著module.前綴第二種是你已經(jīng)替換了最后的全連接層但加載的權(quán)重還是舊的 1000 類版本。解決辦法是加載時用strictFalse并且手動剔除module.前綴。from collections import OrderedDict state_dict torch.load(your_checkpoint.pth) new_state_dict OrderedDict() for k, v in state_dict.items(): if k.startswith(module.): k k[7:] # 去掉 module. 前綴 new_state_dict[k] v model.load_state_dict(new_state_dict, strictFalse)預(yù)處理最好是在保存 checkpoint 時就規(guī)范化 key省得每次加載都寫這段代碼。如果不小心保存了帶module.的權(quán)重上面這段代碼可以直接復(fù)用。6.4 顯存不足OOM時的三板斧8GB 顯存跑 ResNet18 很從容但并不說明你可以隨意浪。batch size 設(shè)到 128、圖片分辨率用到 256OOM 就會找上門。遇到顯存不足按照下面的順序處理。第一減小 batch size。這是最直接的辦法從 64 降到 32顯存立刻減半。代價是訓(xùn)練會多一些波動可以配合適當(dāng)降低學(xué)習(xí)率彌補(bǔ)。第二利用凍結(jié)層的特性。如果只訓(xùn)練分類頭整個特征提取器不需要梯度但實(shí)際上前向計算中的中間特征圖仍然會被保留用于后續(xù)的反向傳播解決辦法是把requires_gradFalse的層放在torch.no_grad()上下文里計算或者直接用autocast混合精度訓(xùn)練。第三開自動混合精度。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for images, labels in train_loader: images, labels images.to(device), labels.to(device) with autocast(): outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度訓(xùn)練把部分計算改為半精度浮點(diǎn)數(shù)顯存占用和計算量都能減少將近一半而精度損失在這個任務(wù)上幾乎觀察不到。這是現(xiàn)代 PyTorch 訓(xùn)練里性價比最高的一個優(yōu)化手段。6.5 訓(xùn)練速度慢排查方向如果你的訓(xùn)練速度異常慢先用nvidia-smi看一下 GPU 利用率和顯存占用。如果 GPU 利用率只有 20% 以下問題多半不在模型而在數(shù)據(jù)加載流程。PyTorch 的 DataLoader 里num_workers設(shè)置太小時CPU 處理圖像速度跟不上 GPU 的消費(fèi)速度整個訓(xùn)練就卡在數(shù)據(jù)讀取上。num_workers設(shè)成 0 表示在主進(jìn)程里加載數(shù)據(jù)速度最慢通常 2 到 4 就夠用。如果設(shè)置到 8 以上你就需要檢查系統(tǒng)內(nèi)存是否足夠內(nèi)存交換會讓訓(xùn)練變慢幾個數(shù)量級。另外開啟pin_memoryTrue可以加速 GPU 和設(shè)備端的數(shù)據(jù)拷貝這個小參數(shù)很多人會忽略。如果數(shù)據(jù)加載已經(jīng)很快但 GPU 利用率還是低就檢查 transform 的復(fù)雜度。RandomResizedCrop這類操作是 CPU 密集型計算再疊加ColorJitter傳統(tǒng)實(shí)現(xiàn)也比較重。有條件的可以把 transform 簡化或者用多進(jìn)程預(yù)處理。在普通項(xiàng)目里我通常把num_workers調(diào)到 4、pin_memory打開就能解決大部分速度問題。7. 訓(xùn)練過程中記錄的幾條心得整個項(xiàng)目跑完我對圖像分類實(shí)戰(zhàn)有了更具體的認(rèn)識。從零手寫 CNN 的過程雖然最后準(zhǔn)確率不高但它教會我網(wǎng)絡(luò)容量限制了模型的表達(dá)能力數(shù)據(jù)增強(qiáng)和正則化只是緩解癥狀真正解決問題要靠結(jié)構(gòu)設(shè)計。這種領(lǐng)悟不是看任何教程能直接獲得的必須在調(diào)參和對比中慢慢沉淀。遷移學(xué)習(xí)讓我重新理解了預(yù)訓(xùn)練這三個字的重量。同樣的網(wǎng)絡(luò)結(jié)構(gòu)、同樣的數(shù)據(jù)、同樣的訓(xùn)練輪數(shù)唯一的差別就是初始化方式不同最終結(jié)果差了 16 個百分點(diǎn)。在工業(yè)界大多數(shù)場景里數(shù)據(jù)和算力才是稀缺資源而預(yù)訓(xùn)練權(quán)重提供了一條用別人已訓(xùn)練好的知識來彌補(bǔ)自己數(shù)據(jù)不足的捷徑。最后一個小技巧想分享給做類似項(xiàng)目的朋友從最開始就建立固定格式的訓(xùn)練日志把每一輪的損失、準(zhǔn)確率、學(xué)習(xí)率、顯存占用都記錄到文件里。這些看似瑣碎的數(shù)據(jù)在你需要回頭對比不同方案時價值極大。我的習(xí)慣是用一張 CSV 記錄關(guān)鍵指標(biāo)再用 Python 腳本畫準(zhǔn)確率曲線幾個方案放在同一張圖上對比誰好誰差一目了然。