別實(shí)戰(zhàn):從FER2013訓(xùn)練到部署)
簡介面向Python期末大作業(yè)的人臉表情識(shí)別項(xiàng)目基于ResNet搭建覆蓋圖像讀取、數(shù)據(jù)預(yù)處理、模型訓(xùn)練與效果評(píng)估的完整流程適合需要完成課程設(shè)計(jì)或入門深度學(xué)習(xí)的Python開發(fā)者參考。壓縮包共103個(gè)文件約54.11MB核心為19個(gè)py源碼文件32個(gè)png、14個(gè)jpg等圖片構(gòu)成表情樣本集3個(gè)hdf5文件提供預(yù)訓(xùn)練權(quán)重可直接加載模型進(jìn)行測(cè)試另有xml配置文件、mp4演示視頻、md說明文檔和gif效果圖方便按需查閱。目前已有135人學(xué)習(xí)下載。源碼經(jīng)過本機(jī)調(diào)試可運(yùn)行附帶的說明文檔能幫助理清數(shù)據(jù)集組織方式與訓(xùn)練流程訓(xùn)練好的權(quán)重文件讓不熟悉訓(xùn)練細(xì)節(jié)的同學(xué)也能直接體驗(yàn)人臉表情識(shí)別效果而完整源碼又適合深入分析ResNet各層結(jié)構(gòu)與分類原理。整體難度適中既能支撐期末答辯也可作為K12階段接觸人工智能的實(shí)踐素材。1. 基于ResNet的人臉表情識(shí)別為什么是期末大作業(yè)里最穩(wěn)的選型每年期末都會(huì)看到一批人臉表情識(shí)別選題涌上來選型卻總在第一步翻車有人拿VGG在48×48的小圖上硬撐三天出不了能看的Loss有人自研CNN在驗(yàn)證集上過擬合到懷疑人生?;赗esNet做表情識(shí)別之所以穩(wěn)是因?yàn)樗选吧疃取焙汀翱捎?xùn)性”同時(shí)給了你——?dú)埐钸B接讓梯度回傳不再玄學(xué)ResNet18在小數(shù)據(jù)集上就能壓得住數(shù)據(jù)量和算力都在一臺(tái)普通筆記本可承受范圍內(nèi)。這套方案本身是一份期末大作業(yè)的完整打包源碼、數(shù)據(jù)集加說明文檔適合正在趕進(jìn)度、想用最少踩坑把項(xiàng)目跑通的同學(xué)。下面按“數(shù)據(jù)處理→模型訓(xùn)練→部署推理→問題排查”的順序拆開講每個(gè)環(huán)節(jié)都給能直接復(fù)現(xiàn)的代碼和參數(shù)。2. 把FER2013數(shù)據(jù)處理成ResNet能吃的格式讀取、增強(qiáng)與標(biāo)簽映射2.1 FER2013的CSV結(jié)構(gòu)一行一圖一標(biāo)簽FER2013是表情識(shí)別最常見的公開數(shù)據(jù)集存儲(chǔ)方式有點(diǎn)反直覺它不是一個(gè)圖片文件夾而是一個(gè)CSV文件每一行是一張48×48的灰度圖。第一列是emotion標(biāo)簽第二列是pixels第三列是Usage用來標(biāo)記這張圖屬于訓(xùn)練集還是測(cè)試集。pixels這一列是把48×48灰度值按行展開成2304個(gè)數(shù)字用空格分隔。做這個(gè)項(xiàng)目時(shí)第一步就是先把這三列拆明白不然后面讀數(shù)據(jù)全是坑。常見做法是先用Pandas讀進(jìn)來看一眼Training、PublicTest、PrivateTest各自的數(shù)量。需要特別留意的是FER2013默認(rèn)切分是Training約28709張、PublicTest約3589張、PrivateTest約3589張。期末大作業(yè)里我一般用Training做訓(xùn)練、PrivateTest做驗(yàn)證PublicTest可以直接并入訓(xùn)練集讓ResNet18多看到約12%的數(shù)據(jù)這個(gè)操作對(duì)最后兩三個(gè)百分點(diǎn)的準(zhǔn)確率是有實(shí)際幫助的。import pandas as pd df pd.read_csv(fer2013.csv) print(df[Usage].value_counts()) # 按 Usage 切分Training 訓(xùn)練PrivateTest 驗(yàn)證PublicTest 并入訓(xùn)練 train_df df[df[Usage] Training] val_df df[df[Usage] PrivateTest] extra_df df[df[Usage] PublicTest] train_df pd.concat([train_df, extra_df], ignore_indexTrue) print(train_df.shape, val_df.shape)這段代碼的邏輯是先打印三個(gè)子集的樣本數(shù)確認(rèn)數(shù)據(jù)量然后分別篩出訓(xùn)練、驗(yàn)證和可擴(kuò)充部分最后把PublicTest拼進(jìn)訓(xùn)練集。參數(shù)說明ignore_indexTrue是讓合并后的索引重新編號(hào)否則后面按索引取樣本時(shí)train_df和val_df的索引會(huì)重疊容易在自定義Dataset里張冠李戴val_df保持原樣不動(dòng)是為了讓驗(yàn)證集獨(dú)立于任何訓(xùn)練數(shù)據(jù)衡量到的準(zhǔn)確率才真實(shí)。切分完建議順手打印一下每個(gè)類別的樣本數(shù)FER2013的類別分布很不均勻這個(gè)信息后面做數(shù)據(jù)增強(qiáng)和類別加權(quán)時(shí)要用。2.2 數(shù)據(jù)增強(qiáng)參數(shù)翻轉(zhuǎn)、裁剪與歸一化表情識(shí)別一個(gè)容易被低估的點(diǎn)是數(shù)據(jù)增強(qiáng)。FER2013只有三萬多張灰度小圖ResNet18參數(shù)量一千多萬直接硬訓(xùn)十有八九過擬合。我一般會(huì)在訓(xùn)練側(cè)用RandomHorizontalFlip、RandomAffine和RandomCrop三件套驗(yàn)證側(cè)只做歸一化和縮放堅(jiān)決不做隨機(jī)變換——這是必須遵守的約定否則驗(yàn)證集就失去了評(píng)估意義。具體參數(shù)上有講究。RandomHorizontalFlip的概率設(shè)0.5水平翻轉(zhuǎn)不會(huì)改變表情語義能把數(shù)據(jù)多樣性直接翻倍RandomAffine的degrees設(shè)10translate設(shè)0.1scale設(shè)0.9到1.1之間輕微旋轉(zhuǎn)和縮放模擬拍攝角度差異。注意角度不要設(shè)太大旋轉(zhuǎn)超過15度后人臉特征會(huì)明顯失真表情識(shí)別不是對(duì)旋轉(zhuǎn)很魯棒的任務(wù)這個(gè)是我調(diào)參數(shù)調(diào)出來的血淚經(jīng)驗(yàn)。from torchvision import transforms train_transform transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(p0.5), transforms.RandomAffine(degrees10, translate(0.1, 0.1), scale(0.9, 1.1)), transforms.RandomCrop(48, padding4), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_transform transforms.Compose([ transforms.ToPILImage(), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ])關(guān)鍵點(diǎn)在于Normalize的mean和std都填0.5。因?yàn)镕ER2013是單通道灰度圖所以mean和std是長度為1的列表不是ImageNet預(yù)訓(xùn)練常用的三通道寫法[0.485, 0.456, 0.406]。RandomCrop的padding4表示先把圖擴(kuò)邊到52×52再隨機(jī)裁回48×48等效于做了8像素范圍內(nèi)的平移增強(qiáng)這個(gè)操作對(duì)緩解過擬合很有效。如果你的數(shù)據(jù)集不是48×48需要把RandomCrop和模型輸入尺寸一起改成目標(biāo)值兩者必須保持一致這也是新手最容易埋下的隱患。2.3 自定義Dataset類的完整代碼FER2013的CSV格式?jīng)Q定了不能用torchvision的ImageFolder直接讀必須寫一個(gè)自定義Dataset。這個(gè)類要做三件事從DataFrame里取到pixels字符串、轉(zhuǎn)成48×48的numpy數(shù)組、再套上對(duì)應(yīng)的transform。代碼本身不復(fù)雜但邊界條件多寫的時(shí)候容易漏。from torch.utils.data import Dataset import numpy as np class Fer2013Dataset(Dataset): def __init__(self, df, transformNone): self.df df.reset_index(dropTrue) self.transform transform self.label_map {0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral} def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] label int(row[emotion]) pixels np.array(row[pixels].split(), dtypenp.uint8) image pixels.reshape(48, 48) if self.transform: image self.transform(image) return image, label核心邏輯在__getitem__里pixels列用split()按空格拆成字符串列表再轉(zhuǎn)成uint8數(shù)組并reshape成48×48。dtype必須用np.uint8而不是默認(rèn)的int64否則后面ToTensor會(huì)把輸入值域理解錯(cuò)int64數(shù)組在部分PyTorch版本里甚至?xí)苯訄?bào)類型錯(cuò)誤。label_map這個(gè)字典是給最終推理時(shí)把數(shù)字標(biāo)簽翻譯成可讀字符串用的訓(xùn)練階段不參與計(jì)算但建議提前定義好后面畫混淆矩陣和報(bào)告寫分析都要用到。數(shù)據(jù)集這一層是整個(gè)流程的底座如果這里出了問題后面訓(xùn)練的模型全部白搭。建議在寫訓(xùn)練代碼之前單獨(dú)跑一段驗(yàn)證取Dataset的前5個(gè)樣本打印image.shape和label確認(rèn)輸出是torch.Size([1, 48, 48])和整型標(biāo)簽再繼續(xù)。這一步只花兩分鐘能省掉后面排查半天數(shù)據(jù)加載問題的痛苦。3. ResNet18搭建與訓(xùn)練核心代碼和參數(shù)設(shè)定3.1 為什么期末大作業(yè)選ResNet18而不是ResNet50選ResNet18而不是ResNet50核心不是玄學(xué)是成本和收益的權(quán)衡。ResNet18的參數(shù)量大約1120萬ResNet50大約2550萬后者在ImageNet這種千萬級(jí)數(shù)據(jù)集上確實(shí)更強(qiáng)但表情識(shí)別只有三萬多張訓(xùn)練圖深度加深帶來的收益會(huì)被過擬合抵消。更現(xiàn)實(shí)的是訓(xùn)練時(shí)間一臺(tái)普通筆記本CPU訓(xùn)練ResNet18一個(gè)epoch大約3到4分鐘ResNet50要翻倍期末大作業(yè)通常沒有那么多時(shí)間反復(fù)調(diào)參。實(shí)際操作上不管用torchvision自帶的resnet18還是自己實(shí)現(xiàn)都要做三處改動(dòng)第一層卷積的in_channels從3改成1適配灰度圖conv1的kernel_size從7×7改成3×3stride改成1因?yàn)?8×48的輸入圖用stride2的7×7卷積會(huì)直接丟掉一半信息最后的全連接層輸出改成7對(duì)應(yīng)7類表情。這三處改完剩下的殘差塊結(jié)構(gòu)原樣保留。import torch.nn as nn from torchvision import models def build_resnet18(num_classes7): model models.resnet18(pretrainedFalse) model.conv1 nn.Conv2d(1, 64, kernel_size3, stride1, padding1, biasFalse) model.fc nn.Linear(model.fc.in_features, num_classes) return modelpretrainedFalse這里值得專門說。torchvision的resnet18默認(rèn)帶有ImageNet預(yù)訓(xùn)練權(quán)重輸入是三通道RGB而我們的數(shù)據(jù)是單通道灰度圖直接加載會(huì)出現(xiàn)第一層通道數(shù)不匹配。pretrainedFalse讓模型從頭初始化配合前面對(duì)conv1的修改訓(xùn)練時(shí)不會(huì)報(bào)維度錯(cuò)誤。如果真想用遷移學(xué)習(xí)需要另外處理權(quán)重復(fù)制這個(gè)我在最后一部分單獨(dú)講。改完conv1之后模型的第一個(gè)卷積輸出仍然是64個(gè)通道殘差塊不需要任何額外調(diào)整這個(gè)改動(dòng)是侵入性最小的。3.2 訓(xùn)練主循環(huán)與超參數(shù)設(shè)定訓(xùn)練主循環(huán)是整套代碼里最不值得自己造輪子的部分。常見做法就是標(biāo)準(zhǔn)PyTorch循環(huán)每個(gè)epoch遍歷訓(xùn)練DataLoader計(jì)算Loss、反向傳播、更新權(quán)重然后跑一遍驗(yàn)證集計(jì)算準(zhǔn)確率。不需要自己寫分布式、混合精度這些東西期末大作業(yè)用不上加了還容易出環(huán)境兼容問題。import torch import torch.nn as nn from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model build_resnet18().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) train_dataset Fer2013Dataset(train_df, transformtrain_transform) val_dataset Fer2013Dataset(val_df, transformval_transform) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers2) val_loader DataLoader(val_dataset, batch_size128, shuffleFalse, num_workers2) for epoch in range(30): model.train() train_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() * images.size(0) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1:02d} | Loss {train_loss / len(train_dataset):.4f} | Acc {correct / total:.4f})這里的超參數(shù)是權(quán)衡過的。batch_size設(shè)128是因?yàn)镕ER2013單張圖只有48×48×1顯存占用很小6GB顯存的顯卡毫無壓力如果CPU訓(xùn)練建議降到32或64減少每批次計(jì)算峰值。Adam的學(xué)習(xí)率1e-3是經(jīng)驗(yàn)?zāi)J(rèn)值weight_decay設(shè)1e-4做L2正則對(duì)壓制過擬合很關(guān)鍵。30個(gè)epoch是基線配置實(shí)際訓(xùn)練中盯著驗(yàn)證集準(zhǔn)確率如果10個(gè)epoch后還在漲就繼續(xù)連續(xù)4個(gè)epoch不再提升就該考慮早?;蛘呓祵W(xué)習(xí)率了。參數(shù)取值說明batch_size128GPU可放寬CPU建議3264lr1e-3Adam默認(rèn)經(jīng)驗(yàn)值配合調(diào)度器動(dòng)態(tài)調(diào)整weight_decay1e-4L2正則系數(shù)過擬合時(shí)優(yōu)先調(diào)大到1e-3epochs30基線配置按驗(yàn)證集表現(xiàn)提前停或延長3.3 學(xué)習(xí)率調(diào)整與模型保存訓(xùn)練到后半程固定學(xué)習(xí)率容易在Loss曲面底部來回震蕩。我一般會(huì)在第15個(gè)epoch之后把學(xué)習(xí)率降到原來的十分之一讓權(quán)重在更小的步長下微調(diào)。常見做法是用ReduceLROnPlateau它會(huì)監(jiān)控驗(yàn)證集Loss連續(xù)不下降就自動(dòng)降學(xué)習(xí)率省去手動(dòng)判斷的麻煩。from torch.optim.lr_scheduler import ReduceLROnPlateau scheduler ReduceLROnPlateau(optimizer, modemin, factor0.1, patience3) # 每個(gè)epoch驗(yàn)證結(jié)束后調(diào)用 scheduler.step(val_loss)保存模型這里有個(gè)經(jīng)典誤區(qū)只保存state_dict而不是整個(gè)model對(duì)象。state_dict是權(quán)重的純字典形式體積小、加載快、跨機(jī)器兼容性好整個(gè)model序列化會(huì)把類的定義路徑也存進(jìn)去換目錄或者換環(huán)境立刻報(bào)錯(cuò)。保存時(shí)把驗(yàn)證集準(zhǔn)確率最高的那份單獨(dú)命名為best_model.pth覆蓋保存這樣訓(xùn)練后期即使過擬合加重手里永遠(yuǎn)握著最佳版本算是有后悔藥。best_acc 0.0 # 每個(gè)epoch驗(yàn)證結(jié)束后 if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth)加載時(shí)用同一個(gè)build_resnet18函數(shù)先實(shí)例化模型再load_state_dict。注意要先把模型to(device)再加載否則CPU和GPU之間的權(quán)重device不匹配會(huì)報(bào)錯(cuò)。如果是訓(xùn)練中斷后續(xù)訓(xùn)只加載權(quán)重還不夠——optimizer的state_dict也要一起保存和加載因?yàn)锳dam的動(dòng)量信息丟失后重啟訓(xùn)練前幾個(gè)epoch的效果會(huì)明顯變差。提示進(jìn)程被殺或斷電時(shí)best_model.pth里保存的是最近一次驗(yàn)證集最優(yōu)的權(quán)重。重新啟動(dòng)直接加載這個(gè)文件繼續(xù)調(diào)參不需要從頭重跑能省下大量時(shí)間。4. 訓(xùn)練完怎么用圖片推理與攝像頭實(shí)時(shí)識(shí)別4.1 單張圖片推理代碼訓(xùn)練完成后最直接的驗(yàn)證方式是拿一張沒見過的圖片跑前向推理。這個(gè)流程每一步都有對(duì)應(yīng)的坑讀圖→轉(zhuǎn)灰度→縮放48×48→歸一化→進(jìn)模型→取softmax最大值對(duì)應(yīng)的類別。OpenCV的imread默認(rèn)讀出來是BGR三通道必須先用cvtColor轉(zhuǎn)成灰度否則通道數(shù)對(duì)不上模型輸入。import cv2 import torch def predict_image(model, image_path, device): model.eval() img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) resized cv2.resize(gray, (48, 48)) tensor torch.from_numpy(resized).unsqueeze(0).unsqueeze(0).float() / 255.0 tensor (tensor - 0.5) / 0.5 tensor tensor.to(device) with torch.no_grad(): output model(tensor) prob torch.softmax(output, dim1) pred torch.argmax(prob, dim1).item() label_map {0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral} return label_map[pred], prob[0, pred].item()這里有一個(gè)容易翻車的細(xì)節(jié)訓(xùn)練時(shí)Normalize用的是mean0.5、std0.5推理時(shí)也必須做一模一樣的歸一化不能只除以255就進(jìn)模型。很多人在這里忘了減均值除標(biāo)準(zhǔn)差導(dǎo)致推理結(jié)果和驗(yàn)證集準(zhǔn)確率對(duì)不上還以為是模型壞了。tensor從H×W變成1×1×H×W第一個(gè)unsqueeze(0)模擬batch維度第二個(gè)模擬channel維度因?yàn)榛叶葓D只有一個(gè)通道。softmax概率值同時(shí)返回方便在界面上顯示置信度答辯展示時(shí)比只給一個(gè)標(biāo)簽更有說服力。4.2 用OpenCV做攝像頭實(shí)時(shí)表情識(shí)別攝像頭實(shí)時(shí)識(shí)別是答辯時(shí)最出效果的部分。核心是用OpenCV的VideoCapture讀每一幀用Haar級(jí)聯(lián)檢測(cè)人臉并框出ROI把ROI縮放到48×48送進(jìn)模型再把預(yù)測(cè)結(jié)果畫在框上。人臉檢測(cè)用的xml文件在OpenCV安裝目錄里自帶不需要額外下載。import cv2 import torch model build_resnet18() model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() cap cv2.VideoCapture(0) face_cascade cv2.CascadeClassifier(cv2.data.haarcascades haarcascade_frontalface_default.xml) label_map {0: angry, 1: disgust, 2: fear, 3: happy, 4: sad, 5: surprise, 6: neutral} while True: ret, frame cap.read() if not ret: break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale(gray, scaleFactor1.1, minNeighbors5, minSize(48, 48)) for (x, y, w, h) in faces: roi gray[y:yh, x:xw] roi cv2.resize(roi, (48, 48)) tensor torch.from_numpy(roi).unsqueeze(0).unsqueeze(0).float() / 255.0 tensor (tensor - 0.5) / 0.5 with torch.no_grad(): output model(tensor) pred torch.argmax(output, dim1).item() prob torch.softmax(output, dim1)[0, pred].item() cv2.rectangle(frame, (x, y), (xw, yh), (0, 255, 0), 2) cv2.putText(frame, f{label_map[pred]} {prob:.2f}, (x, y-10), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2) cv2.imshow(FER, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()detectMultiScale的三個(gè)核心參數(shù)直接決定檢測(cè)靈敏度。scaleFactor1.1表示每次搜索窗口縮小10%越接近1越慢但越準(zhǔn)minNeighbors5表示一個(gè)候選框至少要有5個(gè)鄰近框確認(rèn)才算人臉調(diào)小會(huì)出更多誤檢調(diào)大容易漏檢測(cè)minSize(48, 48)直接過濾掉比訓(xùn)練圖還小的框因?yàn)檫@種框即使檢測(cè)到了縮放進(jìn)模型后也丟光了細(xì)節(jié)。這一段的實(shí)際體驗(yàn)對(duì)答辯很關(guān)鍵建議提前在教室或?qū)嶒?yàn)室的光照條件下跑一遍確認(rèn)能穩(wěn)定識(shí)別而不是到答辯現(xiàn)場才第一次試。5. 避坑指南訓(xùn)練過程中的5個(gè)常見翻車現(xiàn)場5.1 過擬合Loss下降但驗(yàn)證集準(zhǔn)確率不動(dòng)現(xiàn)象訓(xùn)練集Loss每輪都在降驗(yàn)證集準(zhǔn)確率在60%上下原地踏步甚至往下掉。原因FER2013只有三萬多張圖ResNet18的擬合能力太強(qiáng)模型開始背誦訓(xùn)練樣本而不是學(xué)習(xí)表情的通用特征。這是小數(shù)據(jù)集配深網(wǎng)絡(luò)最典型的癥狀。解決三步走。第一確認(rèn)數(shù)據(jù)增強(qiáng)有沒有實(shí)際作用到訓(xùn)練集很多人把增強(qiáng)寫在transform里卻在Dataset構(gòu)造時(shí)忘了傳進(jìn)去。第二增大weight_decay從1e-4加到1e-3給權(quán)重更強(qiáng)的正則約束。第三在最后一層全連接前加一個(gè)nn.Dropout(p0.3)。我自己的經(jīng)驗(yàn)是增大weight_decay比加Dropout效果來得更快同時(shí)訓(xùn)練Loss會(huì)略微抬高這是正常的說明過擬合被壓住了。5.2 數(shù)據(jù)不平衡個(gè)別類別準(zhǔn)確率低得離譜現(xiàn)象訓(xùn)練完成看分類報(bào)告angry和disgust的F1分?jǐn)?shù)明顯低于happy和neutral單獨(dú)抽這幾類樣本測(cè)試準(zhǔn)確率不到40%。原因FER2013類別分布本來就不均勻disgust類只有大約600張訓(xùn)練樣本而happy有7000多張模型天然傾向預(yù)測(cè)占比大的類別少數(shù)類被犧牲掉。解決最有效的是在Loss層面做類別加權(quán)。torch.nn.CrossEntropyLoss自帶weight參數(shù)傳入與各類別樣本數(shù)倒數(shù)成比例的權(quán)重向量即可。另一個(gè)辦法是用WeightedRandomSampler做訓(xùn)練集采樣讓每個(gè)epoch中少數(shù)類被重復(fù)采樣到接近多數(shù)類的數(shù)量這樣不用改Loss就能讓模型在訓(xùn)練時(shí)看到均衡的類別比例。這兩種方法可以同時(shí)用期末作業(yè)里選用一種就能看到明顯改善。5.3 灰度圖和三通道維度不匹配現(xiàn)象DataLoader跑起來第一輪就報(bào)錯(cuò)RuntimeError提示channel維度不匹配expected 3 got 1或者反過來在推理時(shí)報(bào)expected 1 got 3。原因模型第一層卷積的輸入通道寫的還是3但FER2013的數(shù)據(jù)是單通道灰度圖。反過來如果你推理時(shí)用cv2.imread直接讀彩色圖送進(jìn)模型也會(huì)出現(xiàn)同樣的維度沖突。解決模型搭建時(shí)就要統(tǒng)一把conv1.in_channels改成1這是第一步。第二個(gè)容易忽略的是訓(xùn)練和推理的預(yù)處理必須完全一致如果訓(xùn)練用灰度圖推理就不能傳彩色圖。遇到這類錯(cuò)誤優(yōu)先檢查模型定義和transform不要先懷疑數(shù)據(jù)本身。5.4 顯存不足或者CPU訓(xùn)練慢到崩潰現(xiàn)象訓(xùn)練到一半進(jìn)程直接崩掉提示CUDA out of memory或者CPU訓(xùn)練一個(gè)epoch要十幾分鐘整個(gè)流程根本跑不完。原因batch_size太大把顯存擠爆或者num_workers設(shè)置不當(dāng)導(dǎo)致內(nèi)存開銷過高。學(xué)生筆記本往往是4GB顯存或干脆只有集顯本來負(fù)擔(dān)就重。解決batch_size降到32甚至16并確認(rèn)模型和訓(xùn)練數(shù)據(jù)都在cuda上而沒有跑偏。如果CPU訓(xùn)練num_workers設(shè)成0可以避免多進(jìn)程加載帶來的額外內(nèi)存開銷。更狠一點(diǎn)的辦法是先把預(yù)處理后的48×48圖存成npy數(shù)組訓(xùn)練時(shí)直接讀npy省掉每次CSV字符串解析的耗時(shí)實(shí)測(cè)CPU訓(xùn)練能提速20%以上。5.5 加載模型時(shí)key不匹配現(xiàn)象加載best_model.pth時(shí)報(bào)錯(cuò)提示Missing key(s)和Unexpected key(s)比如fc.weight和conv1.weight對(duì)不上。原因兩個(gè)常見來源。一是模型實(shí)例化時(shí)改了結(jié)構(gòu)但保存的權(quán)重來自改結(jié)構(gòu)之前的模型二是環(huán)境A和B里的模型類定義不一致比如一臺(tái)機(jī)器用的全連接層輸出是7類另一臺(tái)改成了別的數(shù)值。解決先打印模型每一層名字和權(quán)重形狀檢查conv1和fc是否和構(gòu)造時(shí)一致。保存和加載必須用同一個(gè)build_resnet18函數(shù)不要手動(dòng)New一個(gè)結(jié)構(gòu)后再load。另外torch.load時(shí)加map_locationcpu即使權(quán)重是在GPU上訓(xùn)練的也能在無GPU的機(jī)器上加載不會(huì)因?yàn)閐evice不匹配報(bào)錯(cuò)這是最實(shí)用的一個(gè)技巧。6. 讓準(zhǔn)確率再進(jìn)一步遷移學(xué)習(xí)與混淆矩陣分析6.1 用torchvision預(yù)訓(xùn)練權(quán)重做遷移學(xué)習(xí)如果驗(yàn)證集準(zhǔn)確率卡在65%左右上不去一個(gè)可嘗試的提升手段是用ImageNet預(yù)訓(xùn)練權(quán)重做遷移學(xué)習(xí)。但這里的坑很明確ImageNet是三通道彩色圖FER2013是單通道灰度圖直接加載會(huì)通道數(shù)不匹配。常見做法是把灰度圖復(fù)制成三通道然后加載預(yù)訓(xùn)練模型再凍結(jié)前幾層只微調(diào)后面幾層。from torchvision import models model models.resnet18(pretrainedTrue) model.conv1 nn.Conv2d(1, 64, kernel_size3, stride1, padding1, biasFalse) # 復(fù)制權(quán)重新conv1每個(gè)輸出通道取原權(quán)重在輸入通道維度的均值 with torch.no_grad(): model.conv1.weight.copy_(model.conv1.weight.mean(dim1, keepdimTrue)) model.fc nn.Linear(512, 7)權(quán)重復(fù)制這里用取均值的方法原conv1權(quán)重形狀是[64, 3, 7, 7]在輸入通道維度求均值后變成[64, 1, 7, 7]keepdimTrue保持維度數(shù)不變。但說實(shí)話FER2013和ImageNet的域差距很大預(yù)訓(xùn)練權(quán)重的收益沒有目標(biāo)檢測(cè)任務(wù)里那么明顯我從65%提到68%左右提升有限。如果你時(shí)間緊先做好數(shù)據(jù)增強(qiáng)和正則也足夠交差遷移學(xué)習(xí)是加分項(xiàng)而不是必選項(xiàng)。6.2 混淆矩陣的繪制與報(bào)告引用期末大作業(yè)的說明文檔里準(zhǔn)確率一個(gè)數(shù)字說服力有限混淆矩陣是答辯時(shí)最直觀展示模型短板的東西。它一眼就能看出模型把難過誤判成了中性還是把恐懼誤判成了驚訝。繪制方法是在驗(yàn)證集上收集所有預(yù)測(cè)結(jié)果和真實(shí)標(biāo)簽用sklearn生成矩陣再用seaborn畫成熱力圖。from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt import seaborn as sns y_true, y_pred [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) y_true.extend(labels.cpu().numpy()) y_pred.extend(predicted.cpu().numpy()) cm confusion_matrix(y_true, y_pred) labels [angry, disgust, fear, happy, sad, surprise, neutral] plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, xticklabelslabels, yticklabelslabels) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)這段代碼里y_true和y_pred要全部收集完再統(tǒng)一算一次混淆矩陣不能在每個(gè)batch里分別算再相加因?yàn)榛煜仃嚤举|(zhì)是全量預(yù)測(cè)結(jié)果的計(jì)數(shù)。annotTrue會(huì)在每個(gè)格子顯示數(shù)字fmtd表示用整數(shù)格式顯示否則seaborn默認(rèn)用科學(xué)計(jì)數(shù)法。混淆矩陣放進(jìn)報(bào)告時(shí)記得在下方配一段簡短分析指出哪兩類最容易混淆比如sad和neutral再解釋這個(gè)結(jié)果和人類認(rèn)知的一致性。只貼圖不解釋的扣分概率很高這段文字是評(píng)分時(shí)的實(shí)際加分項(xiàng)。最后說說我的習(xí)慣。做這個(gè)項(xiàng)目時(shí)我在訓(xùn)練完成后會(huì)留著最后一版模型和第一版模型的準(zhǔn)確率對(duì)比寫報(bào)告時(shí)把每次調(diào)參改了什么、驗(yàn)證集漲了多少點(diǎn)逐條列出來這份過程記錄比最后的準(zhǔn)確率數(shù)字更能體現(xiàn)工作量。答辯時(shí)被問到“你怎么知道這是過擬合”直接把自己記錄里訓(xùn)練Loss下降、驗(yàn)證Acc停滯的曲線拿出來講比背概念有說服力得多。這個(gè)基于ResNet的表情識(shí)別項(xiàng)目真正值錢的不是那幾行模型代碼而是你有沒有把數(shù)據(jù)、訓(xùn)練、推理這條鏈路上的每個(gè)環(huán)節(jié)都走通并且把原理說清楚。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取