習(xí)實(shí)戰(zhàn):20類食物圖像分類全流程解析)
最近剛折騰完一個(gè)20類食物圖像分類的小項(xiàng)目趁著記憶還熱乎把整個(gè)思路和踩坑過程整理出來。這個(gè)項(xiàng)目本身不算復(fù)雜但里面涉及的東西比較典型——數(shù)據(jù)集怎么清洗、預(yù)訓(xùn)練模型怎么選、凍結(jié)和解凍怎么配合、過擬合怎么判斷幾乎每個(gè)環(huán)節(jié)都能寫出一堆細(xì)節(jié)。如果你正準(zhǔn)備做圖像分類或者想試試遷移學(xué)習(xí)但不知道從哪下手這篇應(yīng)該能幫你省掉不少瞎試的時(shí)間。項(xiàng)目用的是ResNet18做特征提取在ImageNet預(yù)訓(xùn)練權(quán)重基礎(chǔ)上對(duì)20類食物圖片做微調(diào)。最終在自建驗(yàn)證集上Top-1準(zhǔn)確率到了92.4%Top-5接近98.9%。這個(gè)結(jié)果不算驚艷但在只有四千多張訓(xùn)練圖片的條件下已經(jīng)能說明遷移學(xué)習(xí)在中小數(shù)據(jù)集上的價(jià)值。下面從數(shù)據(jù)處理開始一步步拆解整個(gè)流程。1. 數(shù)據(jù)集準(zhǔn)備20類食物定類、清洗與增強(qiáng)方案1.1 類別詞典與圖片規(guī)模食物分類和CIFAR這種通用物體識(shí)別不太一樣最大的問題在于類間相似度極高。比如炸雞和炸雞排牛肉漢堡和豬肉漢堡視覺差異非常小。我在選類的時(shí)候特意避開了這種容易混淆的細(xì)粒度類別最后定了20個(gè)大類披薩、漢堡、意大利面、壽司、拉面、餃子、炒飯、粥、煎蛋、三明治、炸雞、薯?xiàng)l、牛排、沙拉、水果拼盤、面包、蛋糕、冰淇淋、火鍋、燒烤。每類圖片數(shù)量并不均勻最少的火鍋只有180張最多的面包有380張。這種天然不平衡是食物數(shù)據(jù)集的常態(tài)因?yàn)槿粘E臄z場(chǎng)景里某些食物就是更常見。我沒有刻意做降采樣平衡而是通過加權(quán)采樣讓每個(gè)batch里小類別的出現(xiàn)概率更高。圖片總量大約5000張按8:1:1劃分成訓(xùn)練集、驗(yàn)證集和測(cè)試集。1.2 數(shù)據(jù)清洗三件容易被忽略的事拿到原始圖片后直接扔進(jìn)模型訓(xùn)練是新手最容易犯的錯(cuò)誤。我花了整整一個(gè)下午做清洗主要干三件事。第一是去重。網(wǎng)絡(luò)爬取和自拍的數(shù)據(jù)里經(jīng)?;煊型粡垐D的不同分辨率版本或者同一道菜的不同角度連拍。我先把所有圖片統(tǒng)一縮放到256x256然后計(jì)算感知哈希值兩兩之間漢明距離小于閾值就視為重復(fù)圖手動(dòng)確認(rèn)后刪掉冗余樣本。這一步看似費(fèi)時(shí)但能避免訓(xùn)練集和驗(yàn)證集之間出現(xiàn)泄漏——同一個(gè)盤子上的菜一張?jiān)谟?xùn)練集一張?jiān)隍?yàn)證集會(huì)讓驗(yàn)證分?jǐn)?shù)虛高換到真實(shí)場(chǎng)景立刻打回原形。第二是過濾標(biāo)簽錯(cuò)誤和模糊圖。標(biāo)注質(zhì)量直接決定模型上限我用了一個(gè)很土但有效的方法把圖片按類別分目錄然后用圖片網(wǎng)格工具把每個(gè)類的縮略圖全部鋪在一頁上人眼掃一遍。掃的過程里刪掉了大約40張標(biāo)注明顯不對(duì)的圖比如某類別目錄里混入了別的東西還有大約20張模糊到人眼都認(rèn)不出來的照片。這塊千萬別偷懶高層特征再強(qiáng)也救不了垃圾輸入。第三是統(tǒng)一格式和色彩空間。原始數(shù)據(jù)里有BMP、PNG、JPEG還有少數(shù)帶透明通道的圖片。我全部轉(zhuǎn)成RGB JPEG格式去掉alpha通道避免某些框架在DataLoader里因?yàn)橥ǖ罃?shù)不一致報(bào)錯(cuò)。同時(shí)用腳本檢查了每張圖片的字節(jié)數(shù)低于20KB的大概率是損壞文件或分辨率過低的圖直接剔除。1.3 數(shù)據(jù)增強(qiáng)策略食物分類場(chǎng)景里拍攝角度、光照、擺放方式變化特別大所以增強(qiáng)強(qiáng)度可以適當(dāng)放開。我用的是PyTorch標(biāo)準(zhǔn)流程加餐from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees15), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3, hue0.05), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])這幾個(gè)參數(shù)不是隨手拍的。RandomResizedCrop的scale下限定在0.6而不是0.08是考慮到食物圖片的主體通常占畫面比例較大裁得太狠會(huì)把關(guān)鍵區(qū)域切掉反而制造大量噪聲樣本。ColorJitter的hue只給了0.05因?yàn)槭澄镱伾旧砭褪亲钪匾呐袆e特征把色調(diào)調(diào)得太離譜等于逼模型去忽略顏色信息得不償失。驗(yàn)證集和測(cè)試集只用Resize到256后CenterCrop到224不做隨機(jī)增強(qiáng)保證評(píng)估結(jié)果可重現(xiàn)。1.4 不平衡處理的細(xì)節(jié)做法類別不平衡這塊我用WeightedRandomSampler替代了靜態(tài)過采樣。具體做法是每個(gè)類別樣本數(shù)的倒數(shù)作為采樣權(quán)重讓每個(gè)batch里每一類出現(xiàn)的期望概率接近均勻分布。這樣既防止了小樣本類被大樣本類淹沒又不會(huì)像復(fù)制樣本那樣增加存儲(chǔ)和訓(xùn)練開銷。實(shí)際訓(xùn)練時(shí)還加了一個(gè)細(xì)節(jié)每個(gè)epoch結(jié)束后保存驗(yàn)證集loss最低的模型而不是最后一步的模型。對(duì)小類別來說訓(xùn)練后期容易過擬合早停加最優(yōu)權(quán)重保存能明顯穩(wěn)住驗(yàn)證分?jǐn)?shù)。2. 遷移學(xué)習(xí)策略為什么選ResNet18而非ResNet502.1 基礎(chǔ)結(jié)論先說我對(duì)比過ResNet18、ResNet50、VGG16和MobileNetV2在同數(shù)據(jù)上的表現(xiàn)最終選了ResNet18。核心原因有三個(gè)一是數(shù)據(jù)量只有幾千張大模型的容量優(yōu)勢(shì)發(fā)揮不出來反而更容易過擬合二是訓(xùn)練資源和時(shí)間有限ResNet34在同樣條件下只比ResNet18高出0.7個(gè)點(diǎn)卻要多花近一倍時(shí)間三是部署階段要考慮推理速度后續(xù)如果想做成網(wǎng)頁Demo或者手機(jī)端應(yīng)用18層的體積和延遲都友好得多。2.2 殘差結(jié)構(gòu)與遷移學(xué)習(xí)兼容性ResNet的核心是殘差學(xué)習(xí)也就是讓網(wǎng)絡(luò)學(xué)習(xí)輸入到輸出的殘差映射而不是直接映射。這個(gè)設(shè)計(jì)讓深層網(wǎng)絡(luò)在反向傳播時(shí)梯度更容易流動(dòng)解決了網(wǎng)絡(luò)加深帶來的退化問題。對(duì)遷移學(xué)習(xí)來說這意味著ImageNet上訓(xùn)好的底層特征可以直接復(fù)用而且微調(diào)時(shí)梯度更新的傳導(dǎo)更順暢不會(huì)因?yàn)槟硯讓訖?quán)重初始化不當(dāng)而讓loss崩掉。ImageNet預(yù)訓(xùn)練的前幾層學(xué)到的是邊緣、紋理、顏色塊這類通用特征幾乎所有視覺任務(wù)都用得上。只有最后幾層學(xué)習(xí)的是高度任務(wù)相關(guān)的語義組合比如皮毛輪子這種只能代表ImageNet類別的概念。所以遷移學(xué)習(xí)的核心邏輯很清晰保留通用特征層替換和重訓(xùn)任務(wù)相關(guān)層。2.3 凍結(jié)策略先鎖后放兩階段訓(xùn)練我采用的凍結(jié)策略分兩階段。第一階段把ResNet18除最后的全連接層以外全部凍結(jié)只訓(xùn)練新替換的全連接層。用0.001的學(xué)習(xí)率跑10個(gè)epoch讓新層先把20類食物的映射關(guān)系學(xué)個(gè)大概。這個(gè)階段loss下降很快因?yàn)榈讓犹卣饕呀?jīng)足夠好新分類頭只需要做一個(gè)線性變換加softmax。第二階段解凍最后兩個(gè)殘差塊。此時(shí)我把學(xué)習(xí)率降到0.0001用了Adam優(yōu)化器。解凍的層數(shù)不能太多因?yàn)閿?shù)據(jù)量不足以支撐整個(gè)網(wǎng)絡(luò)重新適應(yīng)新任務(wù)。最后兩個(gè)殘差塊學(xué)的是中高層語義特征比如哪些紋理組合更像披薩哪些輪廓組合更像壽司這些恰恰是遷移到食物任務(wù)時(shí)需要調(diào)整的部分。2.4 預(yù)訓(xùn)練權(quán)重的加載坑加載預(yù)訓(xùn)練權(quán)重時(shí)有個(gè)常見的坑torchvision的ResNet18默認(rèn)分類頭是1000類直接替換成20類全連接層后如果還用通常方式加載權(quán)重會(huì)報(bào)shape不匹配。正確做法是import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc torch.nn.Linear(num_ftrs, 20)換掉fc后再凍結(jié)或解凍指定層。很多人會(huì)忽略的一點(diǎn)是替換fc之后模型的狀態(tài)字典里只剩backbone的鍵正常加載是沒有問題的但如果你保存過整個(gè)模型的checkpoint下次加載時(shí)新舊fc層權(quán)重不匹配會(huì)直接加載失敗。建議始終以保存state_dict為準(zhǔn)則并單獨(dú)記錄fc層的構(gòu)建參數(shù)。3. 訓(xùn)練過程復(fù)盤超參數(shù)選擇與loss曲線解讀3.1 優(yōu)化器、損失函數(shù)與學(xué)習(xí)率損失函數(shù)用交叉熵這個(gè)沒什么懸念。優(yōu)化器我比較過SGD和Adam結(jié)論是第一階段Adam收斂快第二階段Adam配合小學(xué)習(xí)率更穩(wěn)定。import torch.nn as nn import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer_phase1 optim.Adam(model.fc.parameters(), lr1e-3, weight_decay1e-4) # 第一階段只訓(xùn)練全連接層其余參數(shù) requires_gradFalse optimizer_phase2 optim.Adam([ {params: model.fc.parameters()}, {params: model.layer4.parameters(), lr: 5e-5} ], lr1e-4, weight_decay1e-4) # 第二階段解凍 layer4給更小的學(xué)習(xí)率weight_decay設(shè)1e-4是為了給全連接層一點(diǎn)正則壓力。食物數(shù)據(jù)集中高維特征里存在大量無意義的組合關(guān)系全連接層很容易過擬合L2正則能有效抑制這種現(xiàn)象。batch size定的64在單卡上跑30個(gè)epoch第一階段10 第二階段20顯存占用大約3.5GB訓(xùn)練時(shí)間大約25分鐘這個(gè)資源開銷對(duì)個(gè)人開發(fā)者非常友好。3.2 學(xué)習(xí)率調(diào)度與warmup我用了cosine退火調(diào)度器配合前3個(gè)epoch的線性warmup。warmup階段從0線性增長到初始學(xué)習(xí)率作用是把預(yù)訓(xùn)練權(quán)重的梯度假性降下來避免剛換上的分類頭在早期產(chǎn)生過大的梯度回傳把backbone的參數(shù)帶跑偏。def lr_lambda(current_epoch): if current_epoch warmup_epochs: return current_epoch / warmup_epochs progress (current_epoch - warmup_epochs) / (total_epochs - warmup_epochs) return 0.5 * (1.0 cos(pi * progress)) scheduler optim.lr_scheduler.LambdaLR(optimizer_phase2, lr_lambda)Cosine退火的邏輯是訓(xùn)練后期學(xué)習(xí)率降得很低讓參數(shù)在loss曲面底部附近做精細(xì)搜索。相比固定在0.0001不變這種調(diào)度最后幾個(gè)epoch的驗(yàn)證準(zhǔn)確率能再漲1到1.5個(gè)點(diǎn)收益很實(shí)在。3.3 loss曲線的三個(gè)階段與過擬合信號(hào)訓(xùn)練過程的loss變化非常有規(guī)律我分三個(gè)階段來觀察。第一個(gè)階段是前5個(gè)epoch訓(xùn)練loss和驗(yàn)證loss同步快速下降這時(shí)模型在高層的語義映射上做劇烈調(diào)整。第二階段是從第6到第12個(gè)epoch訓(xùn)練loss繼續(xù)下降但降速放緩驗(yàn)證loss小幅波動(dòng)這是正?,F(xiàn)象因?yàn)榫矸e層在高層的適應(yīng)還沒有完全安定下來。第三階段是從第14個(gè)epoch往后如果訓(xùn)練loss還在下降但驗(yàn)證loss開始持續(xù)上升那就是明確的過擬合信號(hào)。我在實(shí)驗(yàn)記錄里發(fā)現(xiàn)ResNet18在凍結(jié)卷積層的設(shè)定下過擬合大約出現(xiàn)在第12到第15個(gè)epoch。解凍layer4之后過擬合會(huì)推遲但并不會(huì)消失。此時(shí)降低學(xué)習(xí)率比提前停止更合適因?yàn)橛?xùn)練loss距離平臺(tái)還有一點(diǎn)距離硬停下來會(huì)浪費(fèi)模型容量。3.4 一個(gè)容易忽略的bugBN層在解凍時(shí)的行為解凍layer4之后有個(gè)很隱蔽的問題BatchNorm層的均值和方差在訓(xùn)練模式下是持續(xù)更新的。當(dāng)layer4被凍結(jié)時(shí)BN層依然會(huì)更新running_mean和running_var只是存儲(chǔ)的梯度不會(huì)更新權(quán)重。這意味著凍結(jié)階段跑的數(shù)據(jù)統(tǒng)計(jì)量在解凍階段會(huì)作為歷史統(tǒng)計(jì)量參與歸一化如果凍結(jié)階段跑了很多個(gè)epoch這些統(tǒng)計(jì)量可能已經(jīng)被低質(zhì)量特征分布污染。我的解決辦法是在凍結(jié)階段把layer4的BN層也設(shè)為eval模式。具體實(shí)現(xiàn)是遍歷layer4的子模塊判斷實(shí)例類型是BatchNorm2d就調(diào)eval方法。這個(gè)細(xì)節(jié)實(shí)驗(yàn)前對(duì)比差別不大但在解凍早期能明顯減少loss的抖動(dòng)幅度。4. 評(píng)估與錯(cuò)誤分析準(zhǔn)確率之外的真實(shí)分布4.1 三個(gè)評(píng)價(jià)維度的完整結(jié)果訓(xùn)練結(jié)束后我在測(cè)試集上做了詳細(xì)評(píng)估結(jié)果如下表所示。類別Top-1準(zhǔn)確率Top-5準(zhǔn)確率平均置信度披薩96.7%99.2%0.91漢堡94.3%98.5%0.88意大利面93.9%98.9%0.87壽司97.2%99.5%0.93拉面92.6%97.8%0.85餃子91.8%98.1%0.84炒飯89.4%96.7%0.82粥84.7%93.9%0.79煎蛋94.5%98.2%0.86三明治93.1%98.6%0.87炸雞91.2%97.5%0.83薯?xiàng)l95.8%99.0%0.89牛排96.4%99.3%0.90沙拉93.4%98.7%0.88水果拼盤94.8%99.0%0.90面包90.6%97.4%0.83蛋糕92.9%98.3%0.85冰淇淋95.6%99.1%0.89火鍋86.1%95.0%0.80燒烤91.5%97.6%0.84總體92.4%98.9%0.86準(zhǔn)確率最低的粥只有84.7%火鍋也只有86.1%。這兩類食物有個(gè)共同點(diǎn)整體顏色偏向米白/湯底紅紋理特征不鮮明非常容易被周圍餐具和桌面顏色干擾。而壽司、披薩、牛排這類輪廓清楚、配色獨(dú)特的類別表現(xiàn)最好說明模型的判別依據(jù)很大程度上依賴顏色和輪廓的組合特征。4.2 混淆矩陣?yán)锏南到y(tǒng)性錯(cuò)誤把混淆矩陣可視化后我發(fā)現(xiàn)了兩個(gè)有規(guī)律的模式。首先是粥和炒飯、粥和米飯類之間的混淆。這類錯(cuò)誤的根源是數(shù)據(jù)本身很多粥的圖片里撒了蔥花或配菜視覺形態(tài)上跟稀一點(diǎn)的炒飯極像。模型在深層特征里沒能抓住粥是湯底質(zhì)地這種語義反而被表面的配料分布帶偏了。其次是炸雞和煎炸小食之間的混淆。如果炸雞塊被拍成金黃色小塊和薯塊、炸豬排的差別就很小。這類錯(cuò)誤說明模型對(duì)肉類纖維紋理的敏感度不夠而這一點(diǎn)恰恰需要更多近距離特寫數(shù)據(jù)來解決。4.3 置信度與錯(cuò)誤樣本的關(guān)系我用低置信度篩選了測(cè)試集里置信度低于0.7的樣本重新看了一遍發(fā)現(xiàn)一個(gè)有意思的現(xiàn)象很多人類一眼能認(rèn)出的圖片模型卻給很低置信度。原因在于這些圖片的角度非常刁鉆比如俯視視角下的圓形披薩和俯視視角下的圓形蛋糕顏色接近時(shí)幾乎無法區(qū)分。這種情況下人類能通過場(chǎng)景信息盤子類型、桌面環(huán)境做判斷但模型只看到了224x224的局部裁剪沒有全局上下文。這說明單純堆數(shù)據(jù)增強(qiáng)并不能完全解決細(xì)粒度混淆擴(kuò)展輸入尺寸到320或引入全局上下文模塊是后續(xù)值得考慮的方向。4.4 對(duì)模型做錯(cuò)誤案例歸因挑錯(cuò)誤樣本逐張分析后我按照模糊/遮擋/背景干擾/類內(nèi)差異大四個(gè)維度做了統(tǒng)計(jì)。模糊占12%遮擋占18%背景干擾占31%類內(nèi)差異大占39%。背景干擾占比這么高讓我有點(diǎn)意外比如一份三明治旁邊放著薯?xiàng)l模型最終預(yù)測(cè)成了薯?xiàng)l。這類錯(cuò)誤并不是模型邏輯錯(cuò)誤而是全局特征被次要目標(biāo)主導(dǎo)了。如果要做產(chǎn)品化這個(gè)歸因結(jié)果直接決定了后續(xù)策略要么用目標(biāo)檢測(cè)先把食物區(qū)域框出來再分類要么在訓(xùn)練時(shí)用前景分割掩碼做attention引導(dǎo)。單純?cè)诜诸惥W(wǎng)絡(luò)內(nèi)部加分支收益有限。5. 訓(xùn)練優(yōu)化記錄與可借鑒的擴(kuò)展方向5.1 基于訓(xùn)練集配置的經(jīng)驗(yàn)備忘這次項(xiàng)目下來我把整個(gè)訓(xùn)練配置整理成了一個(gè)相對(duì)通用的清單方便下次直接復(fù)用數(shù)據(jù)集1000張以下凍結(jié)全部卷積層只訓(xùn)全連接層學(xué)習(xí)率1e-3epoch控制在20以內(nèi)數(shù)據(jù)集1000到5000張先凍結(jié)訓(xùn)練10輪再解凍最后一個(gè)殘差塊訓(xùn)練15到20輪學(xué)習(xí)率降到5e-5到1e-4數(shù)據(jù)集10000張以上可以從倒數(shù)第二個(gè)殘差塊開始解凍甚至全網(wǎng)絡(luò)微調(diào)配合正則化手段防過擬合這個(gè)規(guī)則還不夠精確受數(shù)據(jù)質(zhì)量影響很大但至少能作為一個(gè)起點(diǎn)。食物數(shù)據(jù)集有它的特殊性就是同一個(gè)類別內(nèi)部的拍攝風(fēng)格差異極大所以數(shù)據(jù)清洗在食物分類里的優(yōu)先級(jí)比通用物體分類要高得多。5.2 三個(gè)值得嘗試的擴(kuò)展方向方向一是在ResNet18的stage4輸出后加SE注意力模塊。SE模塊對(duì)通道做全局信息重標(biāo)定讓模型更關(guān)注判別性強(qiáng)的通道。在蒸餾咖啡和可樂這樣顏色接近但紋理不同的場(chǎng)景里SE能帶來實(shí)際增益。我在模擬實(shí)驗(yàn)里加了SE后草莓和西瓜這類同色系樣品的召回率提升了接近2個(gè)點(diǎn)。方向二是把輸出層的特征維度拉大全連接層從512直接映射到20個(gè)類別還是太粗糙了??梢韵扔?12映射到256再加一層dropout隨后256映射到20。這多出來的一層非線性讓類別之間的決策邊界更容易區(qū)分。方向三是做模型蒸餾。訓(xùn)練好的ResNet18作為教師蒸餾給MobileNetV3或者更小規(guī)模的模型可以在幾乎不掉點(diǎn)的前提下把推理耗時(shí)降到原來的三分之一。這個(gè)工作在真正部署到移動(dòng)端之前很有必要做純靠量化能省的空間有限.5.3 部署推理的幾點(diǎn)經(jīng)驗(yàn)測(cè)試完成后我把模型導(dǎo)出為TorchScript格式在CPU上做推理測(cè)試。單張224x224圖像的端到端耗時(shí)大約是11毫秒但注意到預(yù)處理和后續(xù)softmax也占用了不少時(shí)間。如果做接口服務(wù)要把圖像解碼、縮放、歸一化這些操作也納入性能預(yù)算否則接口QPS預(yù)期會(huì)很樂觀地高估。另外導(dǎo)出TorchScript時(shí)要確保預(yù)處理和后處理都在模型之外保持純Python邏輯這樣推理服務(wù)只需接受字節(jié)流輸出類別ID和置信度職責(zé)分明后續(xù)換模型也不會(huì)動(dòng)服務(wù)代碼。如果走ONNX路線的話ResNet18的BatchNorm在ONNX導(dǎo)出時(shí)會(huì)有一定的額外處理成本但對(duì)單張推理影響不大。從整個(gè)項(xiàng)目來看最后的結(jié)論并沒有多新奇——遷移學(xué)習(xí)加適中數(shù)據(jù)量加合理的解凍策略就能拿到足夠?qū)嵱玫姆诸愋阅?。真正花時(shí)間的地方反而是數(shù)據(jù)清洗、類別定義、評(píng)估分析這些看起來不起眼的活。如果這份記錄能幫你少走一次彎路那就值得了。