網(wǎng)絡(luò)的垃圾識(shí)別分類系統(tǒng)設(shè)計(jì)與實(shí)踐)
簡介面向高校深度學(xué)習(xí)課程設(shè)計(jì)與期末大作業(yè)場景這份資源提供了一套基于卷積神經(jīng)網(wǎng)絡(luò)的垃圾識(shí)別分類系統(tǒng)完整Python實(shí)現(xiàn)。項(xiàng)目已獲導(dǎo)師指導(dǎo)并獲得97分高分涵蓋數(shù)據(jù)集、模型與全部源碼下載后可直接運(yùn)行適合需要在較短時(shí)間內(nèi)搭建可演示圖像分類項(xiàng)目的學(xué)習(xí)者。壓縮包內(nèi)含2000個(gè)文件其中近2000張jpg/jpeg圖片構(gòu)成訓(xùn)練與驗(yàn)證數(shù)據(jù)集包含電池、塑料瓶等常見生活垃圾的圖像樣本13個(gè)py腳本負(fù)責(zé)數(shù)據(jù)加載、模型構(gòu)建、訓(xùn)練與預(yù)測流程另附j(luò)son配置與md說明文檔整體包體約564.69MB目錄結(jié)構(gòu)清晰。已有264人學(xué)習(xí)使用。通過該資源可系統(tǒng)了解CNN圖像分類項(xiàng)目的完整組織方式包括數(shù)據(jù)預(yù)處理、網(wǎng)絡(luò)結(jié)構(gòu)設(shè)計(jì)、訓(xùn)練調(diào)參與結(jié)果評估等關(guān)鍵環(huán)節(jié)源碼注釋完整便于在此基礎(chǔ)上二次開發(fā)也可直接用于課程設(shè)計(jì)答辯演示或期末大作業(yè)提交。1. 課程設(shè)計(jì)級的垃圾識(shí)別系統(tǒng)到底在做什么拿到“基于深度學(xué)習(xí)卷積神經(jīng)網(wǎng)絡(luò)的垃圾識(shí)別分類系統(tǒng)”這份源碼包先別急著解壓找里面的模型文件。課程設(shè)計(jì)級別的項(xiàng)目核心不是“識(shí)別”這個(gè)動(dòng)作本身而是你有沒有把一條完整的深度學(xué)習(xí)鏈路講清楚數(shù)據(jù)怎么整理、卷積神經(jīng)網(wǎng)絡(luò)怎么從圖里提特征、訓(xùn)練時(shí)哪些參數(shù)要調(diào)、最后怎么讓模型跑一個(gè)可演示的界面。這套系統(tǒng)的本質(zhì)是一個(gè)圖像多分類任務(wù)——卷積神經(jīng)網(wǎng)絡(luò)把一張垃圾照片區(qū)分成紙板、玻璃、金屬、紙張、塑料、廚余這幾個(gè)固定類別同時(shí)輸出每個(gè)類別的置信度。它能幫你應(yīng)付答辯中最常見的追問“網(wǎng)絡(luò)結(jié)構(gòu)為什么這么定”“訓(xùn)練集有多少張圖”“l(fā)oss降到多少算正?!?。適合誰適合正在做課設(shè)或畢設(shè)的學(xué)生以及想快速跑通一個(gè)完整CNN項(xiàng)目的Python開發(fā)者。2. 卷積神經(jīng)網(wǎng)絡(luò)為什么能區(qū)分垃圾特征提取邏輯與選型理由2.1 卷積核在干什么從邊緣、紋理到局部形狀一張垃圾照片進(jìn)入卷積神經(jīng)網(wǎng)絡(luò)并不是像人眼那樣先“看到”一個(gè)瓶子、一團(tuán)紙巾而是先被切成一塊塊小窗口每個(gè)窗口與一組卷積核做乘法累加。淺層卷積核對的是邊緣和顏色突變比如瓶蓋的圓弧輪廓、玻璃碎片的高光邊界中層的卷積核把這些邊緣組合成紋理比如紙張的褶皺、金屬的反光條紋深層則組合出局部形狀“這里有一個(gè)帶弧度的瓶身”“這里有一團(tuán)柔軟材料的質(zhì)感”。每一層之間還夾著激活函數(shù)和池化。ReLU把卷積結(jié)果里的負(fù)響應(yīng)清零相當(dāng)于篩選出“對這個(gè)特征有反應(yīng)”的區(qū)域池化把分辨率縮小讓網(wǎng)絡(luò)對垃圾在畫面里的位置偏移不那么敏感。這個(gè)“卷積-ReLU-池化”的疊加過程就是CNN在垃圾識(shí)別場景下最有價(jià)值的特性它不需要人為設(shè)計(jì)顏色直方圖或紋理算子而是讓數(shù)據(jù)自己決定用哪些特征去區(qū)分紙殼和塑料瓶。用Python驗(yàn)證網(wǎng)絡(luò)到底學(xué)到了什么最直接的辦法是把第一層卷積核畫出來。以下是可視化第一個(gè)卷積層權(quán)重的小腳本import torch import matplotlib.pyplot as plt def visualize_first_layer(model): conv1 model.conv1.weight.data # 形狀 [64, 3, 7, 7] kernel_grid conv1[:16] # 只看前16個(gè)卷積核 fig, axes plt.subplots(4, 4, figsize(8, 8)) for i, ax in enumerate(axes.flat): kernel kernel_grid[i].permute(1, 2, 0).numpy() # 歸一化到 0~255 區(qū)間不然圖像太暗看不清 kernel (kernel - kernel.min()) / (kernel.max() - kernel.min()) ax.imshow(kernel) ax.axis(off) plt.savefig(conv1_filters.png, dpi120)這段代碼的邏輯不復(fù)雜model.conv1.weight.data拿到的是第一個(gè)卷積層的權(quán)重張量形狀是[輸出通道數(shù), 輸入通道數(shù), 核高, 核寬]對RGB圖片來說輸入通道數(shù)是3所以每個(gè)卷積核本身是一張三通道小圖。permute(1, 2, 0)把維度從[C, H, W]整成[H, W, C]才能被matplotlib正常顯示。參數(shù)上只取了前16個(gè)卷積核目的是拼成4x4網(wǎng)格時(shí)每張圖還能看清紋路。如果畫出來全是均勻噪點(diǎn)說明網(wǎng)絡(luò)還沒訓(xùn)練或者訓(xùn)練崩了如果能看到清晰的邊緣、顏色條紋說明底層的特征提取器已經(jīng)成立。2.2 垃圾識(shí)別這類任務(wù)為什么最終都會(huì)落到CNN做課程設(shè)計(jì)時(shí)最常見的替代方案有兩個(gè)一是傳統(tǒng)的HOG特征加SVM分類器二是直接用Transformer結(jié)構(gòu)。前者的問題是特征完全靠人工調(diào)瓶子的高光、紙張的褶皺這些外觀在垃圾場景里變化太大HOG描述子很難穩(wěn)定描述“透明玻璃瓶”和“碎玻璃片”的共性后者在幾千張圖片的課設(shè)數(shù)據(jù)集上容易欠擬合Transformer沒有卷積那種先天的局部歸納偏置訓(xùn)練樣本不夠時(shí)注意力機(jī)制學(xué)到的往往是噪聲。CNN的平移等變性是垃圾識(shí)別最需要的性質(zhì)。一個(gè)易拉罐出現(xiàn)在畫面左上角還是右下角卷積網(wǎng)絡(luò)都能給出類似響應(yīng)因?yàn)榫矸e核在整張圖上滑動(dòng)不依賴物體位置而塑料瓶和紙杯在自然場景里往往就是隨手扔在地上的姿態(tài)和位置千差萬別這個(gè)特性直接決定了識(shí)別能不能穩(wěn)定。另外CNN在小數(shù)據(jù)集上的梯度回傳更平穩(wěn)ResNet這類結(jié)構(gòu)還帶了短路連接訓(xùn)練時(shí)不容易出現(xiàn)梯度消失對課設(shè)這種“機(jī)器配置一般、數(shù)據(jù)量不大”的邊界條件非常寬容。當(dāng)然CNN也有限制。它本質(zhì)上是在學(xué)習(xí)紋理和形狀的統(tǒng)計(jì)關(guān)聯(lián)所以當(dāng)玻璃和透明塑料放在一起、光線又很雜時(shí)模型會(huì)把“高光區(qū)域”當(dāng)作決策依據(jù)這就容易出現(xiàn)誤分類。這個(gè)短板不是網(wǎng)絡(luò)結(jié)構(gòu)能單獨(dú)解決的要放到數(shù)據(jù)增強(qiáng)和類別平衡那一層去處理。2.3 用forward hook查看中間特征圖判斷模型有沒有走偏訓(xùn)練中途想確認(rèn)網(wǎng)絡(luò)到底在關(guān)注垃圾的哪些部位可以給某一層掛一個(gè)前向鉤子把特征圖抽出來feature_map {} def hook_fn(name): def fn(module, input, output): feature_map[name] output.detach() return fn model.layer1[0].conv1.register_forward_hook(hook_fn(layer1)) model.layer4[2].conv2.register_forward_hook(hook_fn(layer4)) with torch.no_grad(): output model(torch.randn(1, 3, 224, 224)) print(feature_map[layer1].shape) # 期望 [1, 64, 112, 112] print(feature_map[layer4].shape) # 期望 [1, 256, 14, 14]register_forward_hook是PyTorch提供的內(nèi)置接口鉤子函數(shù)會(huì)在前向傳播經(jīng)過該模塊時(shí)被調(diào)用input是輸入張量output是輸出張量。這里layer1[0].conv1是ResNet第一個(gè)殘差塊里的第一個(gè)卷積層輸出分辨率還是112x112適合觀察淺層特征layer4[2].conv2靠近輸出特征圖縮小到14x14語義更抽象。打印shape時(shí)注意淺層特征圖上單個(gè)像素對應(yīng)原圖很小的一個(gè)感受野所以看到一片白色或黑色區(qū)域不要慌用熱力圖疊加到原圖上更直觀。把第2章的這些手段串起來你就能說清楚“模型為什么把這張塑料瓶圖片分到了玻璃那一類”這在答辯中非常加分。3. 數(shù)據(jù)集整理與預(yù)處理從解壓目錄到可訓(xùn)練狀態(tài)3.1 數(shù)據(jù)目錄約定與訓(xùn)練/驗(yàn)證劃分腳本課程設(shè)計(jì)的垃圾數(shù)據(jù)集通常有兩種組織方式一種是按類別建子目錄比如dataset/glass/xxx.jpg、dataset/plastic/yyy.jpg另一種是給一個(gè)CSV表格每行記錄圖片路徑和類別標(biāo)簽。前一種直接用torchvision.datasets.ImageFolder就能讀取后一種需要自己寫Dataset類。無論哪種第一步都要把原始數(shù)據(jù)拆成訓(xùn)練集和驗(yàn)證集保證模型在沒見過的照片上評估。下面這段腳本把“原始圖片目錄”按8:2比例劃分到train和val兩個(gè)目錄下import os import random import shutil random.seed(42) # 固定隨機(jī)種子讓結(jié)果可復(fù)現(xiàn) src_dir dataset_raw # 原始數(shù)據(jù)內(nèi)部按類別分子目錄 out_dir dataset_split for class_name in os.listdir(src_dir): class_path os.path.join(src_dir, class_name) if not os.path.isdir(class_path): continue images [f for f in os.listdir(class_path) if f.lower().endswith((.jpg, .jpeg, .png))] random.shuffle(images) val_count int(len(images) * 0.2) for split, subset in ((train, images[val_count:]), (val, images[:val_count])): dest os.path.join(out_dir, split, class_name) os.makedirs(dest, exist_okTrue) for img in subset: shutil.copy(os.path.join(class_path, img), os.path.join(dest, img)) print(f{class_name}: 總數(shù){len(images)}驗(yàn)證集{val_count}張)關(guān)鍵參數(shù)說明random.seed(42)不只是為了“可復(fù)現(xiàn)”這個(gè)說法而是讓你在多次對比實(shí)驗(yàn)時(shí)驗(yàn)證集始終是同一批圖片否則你很難判斷精度提升來自模型改動(dòng)還是數(shù)據(jù)劃分差異。劃分比例用8:2是因?yàn)檎n設(shè)數(shù)據(jù)集通常只有幾百到幾千張驗(yàn)證集太小的話精度指標(biāo)抖動(dòng)會(huì)非常明顯。注意這里用的是shutil.copy而不是move原始數(shù)據(jù)盡量保留備份后面要重新劃分比例或做增強(qiáng)對比時(shí)還能再用。如果你拿到的是CSV標(biāo)注的數(shù)據(jù)集就在這一步把CSV按行索引shuffle后再寫回兩個(gè)新CSV邏輯一樣。3.2 transform組合與歸一化參數(shù)小數(shù)據(jù)集怎么調(diào)數(shù)據(jù)增強(qiáng)是垃圾識(shí)別課設(shè)里最影響最終精度的環(huán)節(jié)。一個(gè)常用但合理的訓(xùn)練集transform是這樣from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), # 隨機(jī)轉(zhuǎn)15度模擬隨手扔在地上的姿態(tài) transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.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), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])訓(xùn)練集和驗(yàn)證集transform必須分開寫這是新手最容易忽略的坑。訓(xùn)練時(shí)RandomResizedCrop會(huì)隨機(jī)裁剪一塊區(qū)域再縮放成224x224scale(0.6, 1.0)的意思是裁剪區(qū)域占原圖面積的60%到100%低于0.6會(huì)導(dǎo)致垃圾主體被裁掉一半網(wǎng)絡(luò)學(xué)到的全是殘片特征。ColorJitter的三個(gè)參數(shù)用來模擬不同光照下的垃圾但brightness不宜超過0.3因?yàn)槔R(shí)別里顏色是有效特征——綠色玻璃瓶和棕色玻璃瓶本身就是兩個(gè)視覺類別把顏色抖動(dòng)調(diào)太大反而制造噪聲。驗(yàn)證集不加入任何隨機(jī)變換只用Resize(256)加CenterCrop(224)這是為了保證評估時(shí)每次輸入同一位置的圖像內(nèi)容精度指標(biāo)才可比較。均值標(biāo)準(zhǔn)差用的是ImageNet的標(biāo)準(zhǔn)值如果你的模型是加載ImageNet預(yù)訓(xùn)練權(quán)重這里保持一致能讓特征分布直接對接。3.3 自定義Dataset加載CSV標(biāo)注并做類別映射遇到CSV格式的數(shù)據(jù)集時(shí)寫一個(gè)自定義Dataset是標(biāo)準(zhǔn)做法。以下代碼把CSV里的相對路徑和標(biāo)簽轉(zhuǎn)換成模型輸入所需的張量對import pandas as pd import torch from torch.utils.data import Dataset from PIL import Image class GarbageCSVDataset(Dataset): def __init__(self, csv_path, root_dir, transformNone): self.data pd.read_csv(csv_path) # 列img_path, label self.root_dir root_dir self.transform transform self.classes sorted(self.data[label].unique()) def __len__(self): return len(self.data) def __getitem__(self, idx): row self.data.iloc[idx] img Image.open(f{self.root_dir}/{row[img_path]}).convert(RGB) if self.transform: img self.transform(img) label torch.tensor(self.classes.index(row[label])) return img, label__getitem__返回兩個(gè)東西一個(gè)是經(jīng)過transform的圖片張量一個(gè)是整型標(biāo)簽。這里self.classes用sorted排序讓類別索引在多次運(yùn)行中保持一致避免訓(xùn)練完保存模型、下次推理時(shí)發(fā)現(xiàn)“玻璃”的索引變了。convert(RGB)這行很容易漏但很重要——數(shù)據(jù)集里的圖片如果本身就是灰度圖PIL會(huì)返回L模式單通道圖直接進(jìn)卷積網(wǎng)絡(luò)通道數(shù)對不上.convert()強(qiáng)制統(tǒng)一成三通道。標(biāo)簽這里直接返回Tensor而不是整數(shù)是為了方便后續(xù)直接喂給CrossEntropyLoss少一次類型轉(zhuǎn)換。數(shù)據(jù)量很小的時(shí)候pd.read_csv是瓶頸但課設(shè)規(guī)模無所謂不需要上LMDB或TFRecord那套工業(yè)方案。4. 模型搭建與訓(xùn)練從遷移學(xué)習(xí)到完整訓(xùn)練循環(huán)4.1 為什么用ResNet18遷移而不是從零訓(xùn)練自定義CNN垃圾識(shí)別數(shù)據(jù)集的規(guī)模決定了模型選擇。課設(shè)數(shù)據(jù)通常幾百到兩三千張從零訓(xùn)練一個(gè)七層自定義CNN參數(shù)數(shù)量輕松超過百萬這點(diǎn)數(shù)據(jù)根本不夠?qū)W出穩(wěn)定的卷積核。更穩(wěn)的做法是加載在ImageNet上預(yù)訓(xùn)練過的ResNet18把最后一層全連接替換成垃圾類別數(shù)。預(yù)訓(xùn)練權(quán)重里的淺層卷積核已經(jīng)能識(shí)別通用邊緣紋理你只需要讓網(wǎng)絡(luò)在垃圾數(shù)據(jù)上微調(diào)高層語義。ResNet18相比ResNet50參數(shù)量小四倍左右在課設(shè)數(shù)據(jù)集上過擬合的風(fēng)險(xiǎn)低很多。單卡訓(xùn)練時(shí)ResNet18用224x224輸入、batch size 32顯存占用大約2.5GB集成顯卡也能跑。如果用的是自建CNN效果往往比遷移差是因?yàn)樽越ńY(jié)構(gòu)沒有預(yù)訓(xùn)練先驗(yàn)這是結(jié)構(gòu)設(shè)計(jì)彌補(bǔ)不了的不是網(wǎng)絡(luò)深度問題。4.2 訓(xùn)練循環(huán)代碼與超參數(shù)配置搭建模型和訓(xùn)練主循環(huán)的代碼本身不算長難點(diǎn)在超參數(shù)配合。以下代碼是課設(shè)場景下驗(yàn)證過比較穩(wěn)的配置import torch import torch.nn as nn from torch.optim import lr_scheduler from torchvision import models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_classes 6 model.fc nn.Linear(model.fc.in_features, num_classes) model model.cuda() criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.001, momentum0.9, weight_decay1e-4) scheduler lr_scheduler.CosineAnnealingLR(optimizer, T_max30, eta_min1e-6) batch_size 32 epochs 30 for epoch in range(epochs): model.train() running_loss 0.0 correct 0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() scheduler.step() print(fEpoch {epoch1}: loss{running_loss/len(train_loader.dataset):.4f}, facc{correct/len(train_loader.dataset):.4f})三個(gè)核心參數(shù)值得展開說。第一優(yōu)化器用的是SGD加momentum0.9而不是直接在遷移任務(wù)上默認(rèn)用AdamAdam收斂快但泛化性能通常略遜SGD在課設(shè)這種小數(shù)據(jù)上差距更明顯。weight_decay1e-4是L2正則能壓住過擬合別加到1e-2那會(huì)把權(quán)重約束得過死loss下不去。第二學(xué)習(xí)率從0.001開始配合CosineAnnealingLR每輪把lr往下降訓(xùn)練后期用一個(gè)更細(xì)的步長在局部最小值附近精調(diào)。T_max30要和epochs一致如果epochs改成50而T_max不跟著改余弦周期就對不上。第三全連接層替換成num_classes維度后模型其余部分的參數(shù)仍然沿用ImageNet預(yù)訓(xùn)練值整個(gè)模型一起參與訓(xùn)練不需要手動(dòng)凍結(jié)淺層。loss.item() * images.size(0)這里乘的是batch張數(shù)后面除以len(train_loader.dataset)時(shí)才是每個(gè)樣本的平均loss不是batch維度的平均。4.3 checkpoint保存策略不只存模型還要存優(yōu)化器訓(xùn)練到二十輪左右loss會(huì)變得平滑這時(shí)候要做的是保存“可能繼續(xù)訓(xùn)練的狀態(tài)”而不僅僅是推理用的權(quán)重。以下是保存和恢復(fù)的兩種寫法# 推理時(shí)夠了只存模型權(quán)重 torch.save({model_state: model.state_dict(), num_classes: num_classes}, garbage_model.pt) # 中斷恢復(fù)時(shí)夠了把優(yōu)化器和epoch也存下來 torch.save({model_state: model.state_dict(), optimizer_state: optimizer.state_dict(), epoch: epoch, loss: running_loss}, garbage_model_checkpoint.pt)只保存state_dict而不保存整個(gè)model對象是因?yàn)閙odel對象包含網(wǎng)絡(luò)結(jié)構(gòu)定義反序列化時(shí)對PyTorch版本和代碼結(jié)構(gòu)敏感只存權(quán)重推理時(shí)先用代碼重建ResNet18再load_state_dict兼容性最好。把num_classes一并存進(jìn)字典是為了防止你換了數(shù)據(jù)集類別數(shù)后加載舊權(quán)重時(shí)報(bào)尺寸不匹配。第一行代碼保存的權(quán)重用于最終演示和答辯第二行專門用于訓(xùn)練中途斷電或調(diào)參后回退load時(shí)用optimizer.load_state_dict(checkpoint[optimizer_state])恢復(fù)學(xué)習(xí)率調(diào)度器也會(huì)跟著回到正確位置。5. 避坑記錄垃圾識(shí)別訓(xùn)練與部署中的典型翻車現(xiàn)場5.1 loss一直在降但驗(yàn)證集精度卡在30%現(xiàn)象訓(xùn)練loss從0.8降到0.2曲線很正常但每輪驗(yàn)證精度始終在30%左右跟隨機(jī)猜差不多。原因數(shù)據(jù)增強(qiáng)里的RandomResizedCrop在裁剪時(shí)把垃圾主體切沒了網(wǎng)絡(luò)一直在學(xué)垃圾周圍的背景地板也可能是驗(yàn)證集圖片和訓(xùn)練集來自同一場景模型學(xué)到的是場景而不是垃圾本身。解決先關(guān)掉RandomResizedCrop改用Resize(256)CenterCrop(224)做一輪基準(zhǔn)實(shí)驗(yàn)確認(rèn)精度能上去后再逐步加增強(qiáng)如果關(guān)掉后精度就正常把scale下限從0.6提高到0.8限制裁剪范圍。5.2 訓(xùn)練時(shí)正常推理單張圖片全預(yù)測成同一個(gè)類現(xiàn)象訓(xùn)練和驗(yàn)證精度都達(dá)到90%但拿手機(jī)拍一張塑料瓶模型輸出“玻璃”置信度99%換紙殼還是“玻璃”。原因推理時(shí)忘了做和訓(xùn)練一樣的預(yù)處理。訓(xùn)練時(shí)用了Normalize(mean/std)推理代碼里如果只做ToTensor()輸入分布完全偏離模型期望——訓(xùn)練數(shù)據(jù)每個(gè)通道的像素均值接近0.5而你的輸入是0到1的原始像素值。解決把驗(yàn)證集的val_transform原封不動(dòng)用到推理管道前后保持一致不要手工再寫一遍。5.3 廚余垃圾樣本占一半其他類別只有幾十張現(xiàn)象總精度看起來有85%但看每個(gè)類別的召回率廚余垃圾接近100%金屬只有20%。原因類別不平衡模型只需把所有圖片都判成廚余就能拿到低loss。解決給CrossEntropyLoss傳一個(gè)weight參數(shù)類別樣本越少權(quán)重越大讓少數(shù)類別的錯(cuò)誤得到更高的懲罰。計(jì)算方式是統(tǒng)計(jì)每類樣本數(shù)取倒數(shù)后再歸一化以下是具體代碼import torch import torch.nn as nn class_counts torch.tensor([800, 120, 90, 300, 200, 60], dtypetorch.float32) total class_counts.sum() weights total / (class_counts * len(class_counts)) weights weights / weights.sum() * len(class_counts) # 歸一化回均值1 criterion nn.CrossEntropyLoss(weightweights.cuda())這里total / (class_counts * len(class_counts))是給少數(shù)類更高權(quán)重的經(jīng)典做法。設(shè)權(quán)重均值被歸一化回1是為了避免整體loss數(shù)值被放大后需要重新調(diào)學(xué)習(xí)率。注意class_counts的順序必須和你的類別索引順序一致而這個(gè)順序就是自定義Dataset里classes排序后的順序兩處千萬別錯(cuò)位。5.4 加載保存的模型報(bào)尺寸不匹配現(xiàn)象load_state_dict報(bào)錯(cuò)提示fc.weight的大小和預(yù)期不符或者某個(gè)block的參數(shù)缺失。原因模型保存時(shí)用的是num_classes6現(xiàn)在重建的模型類別數(shù)改成了8全連接層維度對不上另一種常見原因是讀權(quán)重時(shí)用了torch.load(path)直接加載整個(gè)文件而不是先建模型再load。解決檢查model.fc.out_features是否等于數(shù)據(jù)集實(shí)際類別數(shù)如果保存時(shí)還存了num_classes字段加載時(shí)先讀出來再做判斷。代碼寫法上統(tǒng)一用新建模型加load_state_dict的方式避免依賴pickle反序列化帶來的版本兼容問題。5.5 顯存不夠batch size只能設(shè)為8訓(xùn)練慢還抖動(dòng)現(xiàn)象batch size設(shè)32直接爆顯存改成8以后能跑但loss曲線震蕩很厲害。原因除模型本身外DataLoader的num_workers和pin_memory也會(huì)占額外顯存batch size過小時(shí)每個(gè)batch計(jì)算出的梯度方差大優(yōu)化器更新方向不穩(wěn)定。解決優(yōu)先檢查圖片分辨率是不是真需要224如果不是降到160×160能省約一半顯存其次用torch.cuda.amp混合精度訓(xùn)練把a(bǔ)utocast包住前向傳播和loss計(jì)算顯存占用能再降三分之一并且課設(shè)數(shù)據(jù)量小混合精度對精度幾乎沒有影響。6. 讓“黑匣子”開口說話用Grad-CAM和混淆矩陣驗(yàn)證模型真的學(xué)會(huì)了課程設(shè)計(jì)答辯時(shí)最怕的問題是“你怎么證明模型是學(xué)到了垃圾特征而不是記性背下了訓(xùn)練集”。兩個(gè)工具能直接回答這個(gè)問題Grad-CAM熱力圖展示模型決策時(shí)看了圖片的哪個(gè)區(qū)域混淆矩陣列出錯(cuò)誤到底發(fā)生在哪些類別之間。Grad-CAM的思路并不復(fù)雜把最后一個(gè)卷積層的輸出特征圖按類別梯度做加權(quán)求和得到一張和輸入圖片同尺寸的熱力圖熱力高的區(qū)域就是模型做出判斷的主要依據(jù)。用PyTorch實(shí)現(xiàn)時(shí)需要在前向時(shí)記錄目標(biāo)層輸出反向時(shí)記錄目標(biāo)層的梯度from torch.autograd import Function # 記錄最后一層卷積的輸出和梯度 features {} def save_feature(module, input, output): features[output] output def save_grad(module, grad_input, grad_output): features[grad] grad_output[0] target_layer model.layer4[2].conv2 target_layer.register_forward_hook(save_feature) target_layer.register_backward_hook(save_grad)拿到特征圖和梯度后先對梯度做空間維度的平均得到每個(gè)通道的權(quán)重再對特征圖做加權(quán)求和最后套R(shí)eLU只保留正響應(yīng)區(qū)域weights features[grad].mean(dim(2, 3), keepdimTrue) cam (weights * features[output]).sum(dim1, keepdimTrue) cam torch.relu(cam).squeeze().detach().cpu().numpy() # 插值回224x224并歸一化到0~255 from torch.nn.functional import interpolate cam interpolate(torch.tensor(cam).unsqueeze(0).unsqueeze(0), size(224, 224), modebilinear).squeeze().numpy()最后把熱力圖疊加到原圖上alpha設(shè)為0.4左右超過0.6會(huì)蓋住垃圾本身的細(xì)節(jié)看不清模型關(guān)注的是瓶蓋還是標(biāo)簽。我自己的習(xí)慣是會(huì)同時(shí)跑一組“錯(cuò)誤分類”的圖片把預(yù)測錯(cuò)誤和預(yù)測正確的熱力圖并排貼到答辯PPT里——大概率你會(huì)發(fā)現(xiàn)模型對錯(cuò)誤樣本的關(guān)注區(qū)域也和正確樣本大不相同這說明它并不是單純在猜?;煜仃嚨慕庾x重點(diǎn)不要放在“整體精度多少”而要看哪些類別互混。兩張紙類和紙板類如果經(jīng)常被弄混說明兩者在紋理特征上接近解決方案是增加這兩個(gè)類別的樣本數(shù)量或者手動(dòng)去除訓(xùn)練集里標(biāo)注錯(cuò)誤的圖片——課設(shè)數(shù)據(jù)集里標(biāo)注錯(cuò)誤的情況其實(shí)比想象的常見。按上述操作做完答辯時(shí)說“我通過熱力圖發(fā)現(xiàn)模型主要依據(jù)瓶身紋理分類并通過混淆矩陣定位到紙類和紙板類的混淆針對性補(bǔ)充了100張訓(xùn)練圖片”這句話比報(bào)一個(gè)95%的精度更有說服力。做這個(gè)課設(shè)項(xiàng)目的最后一段路我踩過最深的坑就是“訓(xùn)練時(shí)改了一堆增強(qiáng)參數(shù)但沒有記錄每次改了什么”。從第3章開始每次實(shí)驗(yàn)把transform配置、學(xué)習(xí)率、epoch數(shù)、驗(yàn)證精度一行行記進(jìn)一個(gè)TXT文件等你回頭調(diào)參或者寫論文時(shí)這套記錄就是最快的后悔藥。希望這篇筆記幫你在答辯前少走幾個(gè)彎路祝順利。本文還有配套的精品資源點(diǎn)擊獲取