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