☰
图像分类实战:从数据准备到小样本评估的完整流程
2026/10/11 2:18:20 网站建设 项目流程

简介:这份资源面向机器学习入门者与图像分类方向的开发者,围绕SVM与贝叶斯分类器展开,帮助读者理解如何用经典机器学习方法完成图像分类任务,并提供一个可直接运行的图形界面工具用于对比不同算法的分类效果。压缩包共216个文件,约12.87MB,以bmp图像样本、h头文件与cpp源码、obj与sbr编译中间文件为主,另含exe可执行程序、txt说明、ico与rc等界面资源,以及vcproj、sln、dsp等工程文件,构成一套完整的VC项目结构,便于二次编译与调试。目前已有2483人学习下载。资源核心价值在于将SVM超平面分类、贝叶斯先验与条件概率计算等理论落地为可操作软件,用户可通过界面直观比较算法表现,同时源码中保留了特征提取与分类流程的实现细节,适合作为课程设计、实验验证或算法入门的参考素材。

1. 从一堆图片到能跑的模型:这套图像分类实战资源到底值不值得拆

手头有一批图片,想训一个能区分猫狗、缺陷、车型或者森林植被的分类器,结果卡在第一步——环境装完不知道从哪下手,数据集摆在那不知道目录怎么组织,模型跑起来 loss 不降反升。这套「机器学习方法的图像分类」资源,解决的就是从零散图片到可复现分类流程的完整链路问题。它不挑框架,核心是用经典机器学习与深度学习方法做图像分类的通用范式:数据组织、特征提取、模型选型、训练调参、评估验证。适合两类人:一是刚入门机器学习、想拿图像分类当第一个完整项目练手的;二是已经会调库、但每次换数据集就翻车、想理清标准流程的。下面按「资源是什么 → 怎么用 → 坑在哪」推一遍,能抄的代码和参数我都标出来。

2. 图像分类的数据准备与特征工程:目录结构、划分比例和三种特征提取路线

图像分类翻车,八成不是模型的问题,是数据没整明白。这一章把数据准备和特征工程讲透,因为后面模型再强,喂进去的是乱数据,结果就是玄学。

2.1 数据集目录结构与训练验证测试划分

绝大多数图像分类框架(PyTorch 的 ImageFolder、TensorFlow 的 image_dataset_from_directory)都认一种目录结构:每个类别一个文件夹,文件夹名就是标签。这是最省事的做法,也是我一般会优先采用的。

# 标准图像分类数据集目录结构 dataset/ ├── train/ # 训练集 │ ├── cat/ # 类别1,文件夹名即标签 │ │ ├── 001.jpg │ │ └── 002.jpg │ └── dog/ # 类别2 │ ├── 001.jpg │ └── 002.jpg ├── val/ # 验证集,结构同上 │ ├── cat/ │ └── dog/ └── test/ # 测试集,结构同上 ├── cat/ └── dog/

划分比例没有绝对标准,常见做法是 7:1.5:1.5 或 8:1:1。小样本场景(比如 1-shot、5-shot)要另说,后面第 5 章会展开。这里有个血泪经验:划分必须在类别层面分层抽样,不能随机切。如果某个类别只有 20 张图,随机切可能导致验证集里一张都没有,评估结果直接失真。

import os import shutil import random from pathlib import Path def split_dataset(src_dir, dst_dir, ratios=(0.7, 0.15, 0.15), seed=42): """ 按类别分层划分数据集 src_dir: 原始数据,每个类别一个子文件夹 ratios: (train, val, test) 比例,和为1 """ random.seed(seed) src = Path(src_dir) dst = Path(dst_dir) classes = [d.name for d in src.iterdir() if d.is_dir()] for cls in classes: imgs = list((src / cls).glob('*.jpg')) + list((src / cls).glob('*.png')) random.shuffle(imgs) n = len(imgs) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) splits = { 'train': imgs[:n_train], 'val': imgs[n_train:n_train + n_val], 'test': imgs[n_train + n_val:] } for split_name, files in splits.items(): out = dst / split_name / cls out.mkdir(parents=True, exist_ok=True) for f in files: shutil.copy2(f, out / f.name) print(f"划分完成,共 {len(classes)} 个类别") split_dataset('./raw_images', './dataset')

这段脚本的关键参数是ratios和seed。seed固定后每次划分结果一致,方便复现。ratios根据数据量调整:数据量少于 1000 张时,验证集和测试集各留 10% 就够,训练集尽量大;数据量过万时,1.5:1.5 的验证测试比例更稳。

2.2 传统机器学习路线的特征提取:HOG、SIFT 与颜色直方图

如果不用深度学习,传统机器学习做图像分类的流程是「特征提取 + 分类器」。特征提取决定了上限,分类器(SVM、随机森林)只是在这个上限内找最优边界。三种常用特征:

特征方法适合场景维度对光照/旋转的鲁棒性
HOG形状明显的目标(行人、车辆)中对光照较鲁棒,对旋转一般
SIFT纹理丰富、需要尺度不变性高对旋转尺度鲁棒,计算慢
颜色直方图颜色区分度高的场景(森林、植被)低对光照敏感,对旋转不敏感
import cv2 import numpy as np from skimage.feature import hog from sklearn.svm import SVC from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline def extract_hog(img_path, size=(128, 128)): """提取HOG特征""" img = cv2.imread(img_path) img = cv2.resize(img, size) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # orientations=9: 梯度方向分9个bin # pixels_per_cell=(8,8): 每个cell 8x8像素 # cells_per_block=(2,2): 每2x2个cell组成一个block做归一化 features = hog(gray, orientations=9, pixels_per_cell=(8, 8), cells_per_block=(2, 2), visualize=False) return features # 构建SVM分类流水线 clf = Pipeline([ ('scaler', StandardScaler()), # HOG特征做标准化 ('svm', SVC(kernel='rbf', C=10, gamma='scale')) ])

HOG 的参数里,orientations控制梯度方向的分辨率,9 是经典值;pixels_per_cell越小,特征越细但维度越高,8x8 是速度和精度的平衡点。SVM 的C越大越容易过拟合,gamma控制 RBF 核的作用范围。传统方法在数据量小于 5000 张、类别少于 10 类时,训练速度比深度学习快一个数量级,但精度通常低 5 到 15 个百分点。

2.3 深度学习路线的数据增强与归一化

深度学习做图像分类,数据增强是标配。常见做法是随机裁剪、水平翻转、颜色抖动。归一化必须和预训练模型匹配——用 ResNet50 的预训练权重,就得用 ImageNet 的均值和标准差。

from torchvision import transforms # ImageNet标准归一化参数 IMAGENET_MEAN = [0.485, 0.456, 0.406] IMAGENET_STD = [0.229, 0.224, 0.225] train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), # 随机裁剪到224x224 transforms.RandomHorizontalFlip(p=0.5), # 50%概率水平翻转 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 颜色抖动 transforms.ToTensor(), transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD) ]) val_transform = transforms.Compose([ transforms.Resize(256), # 先缩到256 transforms.CenterCrop(224), # 中心裁剪224 transforms.ToTensor(), transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD) ])

验证集和测试集不能做随机增强,只能用确定性的 resize + center crop。这个坑我见过太多次:有人在验证集上也加了 RandomHorizontalFlip,结果每次评估精度都在跳,还以为是模型不稳定。归一化参数不匹配预训练权重,会导致训练初期 loss 震荡甚至不收敛,这是黑匣子式的翻车,排查起来很费时间。

3. 模型选型与训练:从 ResNet50 到 ViT 的分类头调整

数据整好了,下一步是选模型和训模型。这一章讲清楚两条主流路线的选型逻辑和训练细节,尤其是迁移学习场景下分类头怎么改。

3.1 ResNet50 迁移学习:冻结策略与分类头替换

ResNet50 是图像分类的万金油,预训练权重在 ImageNet 上训过,特征提取能力已经很强。迁移学习的标准做法是:替换最后的全连接层,改成自己类别的数量,然后分阶段解冻。

import torch import torch.nn as nn from torchvision import models def build_resnet50(num_classes, freeze_backbone=True): """ 构建ResNet50分类模型 num_classes: 自己的类别数 freeze_backbone: 是否冻结主干网络 """ model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) if freeze_backbone: # 冻结所有主干参数 for param in model.parameters(): param.requires_grad = False # 替换最后的全连接层 in_features = model.fc.in_features # ResNet50是2048 model.fc = nn.Sequential( nn.Dropout(0.3), # 防过拟合 nn.Linear(in_features, num_classes) ) return model model = build_resnet50(num_classes=10, freeze_backbone=True) # 只有fc层的参数需要训练 optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)

冻结主干时,只有model.fc的参数在更新,学习率可以设大一点(1e-3)。训练几轮后如果精度上不去,再解冻最后几个 block,学习率降到 1e-4 到 1e-5。这个「先冻结后解冻」的策略,比一上来就全量微调更稳,尤其是在目标数据集很小的时候。Dropout(0.3)是经验值,类别越少、数据越少,dropout 可以适当调大。

3.2 ViT 评估时分类头要不要调整

用 ViT(Vision Transformer)做迁移学习时,分类头的处理方式和 ResNet 不一样。ViT 的输出是一个[CLS]token 经过 MLP head 得到分类结果。常见做法是替换整个 head,而不是只换最后一层。

import timm import torch.nn as nn def build_vit(num_classes, model_name='vit_base_patch16_224'): """ 构建ViT分类模型 timm库会自动处理预训练权重加载 """ model = timm.create_model( model_name, pretrained=True, num_classes=num_classes # 直接指定类别数,timm会替换head ) return model model = build_vit(num_classes=10) # ViT微调学习率要小,通常1e-5到5e-5 optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01)

关于「用 ViT 评估时分类头用调整吗」这个问题,答案是:训练时必须替换分类头,因为预训练模型的 head 输出是 1000 类(ImageNet),你的类别数不是 1000。评估时用的是替换后的 head,不需要额外调整。但有一个细节:如果做的是 1-shot 或 5-shot 小样本评估,常见做法是冻结整个 ViT 主干,只训练一个线性分类器,甚至直接用原型网络(Prototypical Network)的思路,不训练 head,而是用支持集的特征均值做分类。这个后面第 5 章展开。

ViT 微调比 ResNet 更吃数据,数据量少于 1000 张时,ViT 通常打不过 ResNet50。如果非要用 ViT,建议用 DINO 或 MAE 预训练的权重,它们在小样本上表现更好。

3.3 训练循环与学习率调度

训练循环里,学习率调度和早停是两个关键机制。常见做法是用 CosineAnnealing 或 ReduceLROnPlateau。

from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder # 数据加载 train_dataset = ImageFolder('./dataset/train', transform=train_transform) val_dataset = ImageFolder('./dataset/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) # 训练配置 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) scheduler = CosineAnnealingLR(optimizer, T_max=50) # 50个epoch周期 best_acc = 0.0 patience = 10 # 早停耐心值 counter = 0 for epoch in range(50): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) acc = correct / total scheduler.step() if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_model.pth') counter = 0 else: counter += 1 if counter >= patience: print(f"早停于 epoch {epoch},最佳精度 {best_acc:.4f}") break print(f"Epoch {epoch}: val_acc={acc:.4f}, best={best_acc:.4f}")

batch_size=32是常见起点,显存不够就降到 16 或 8。num_workers=4在 Linux 上加速数据加载,Windows 上如果报错就改成 0。T_max=50要和总 epoch 数匹配,CosineAnnealing 会在 50 个 epoch 内把学习率从初始值降到接近 0。早停的patience=10表示验证精度连续 10 轮不提升就停,防止过拟合。

4. 避坑与排查:图像分类训练中最容易翻车的五个地方

这一章全是踩坑记录,每条按「现象 → 原因 → 解决」写。这些坑我基本都亲自踩过,有些排查了大半天才找到原因。

4.1 现象:loss 不降或震荡剧烈

原因:最常见的是归一化参数不匹配。用 ImageNet 预训练权重,但归一化用了mean=[0.5,0.5,0.5],特征分布和预训练时不一致,模型相当于在噪声上训练。其次是学习率太大,尤其是解冻主干后还用 1e-3 的学习率,梯度爆炸。

解决:检查归一化参数是否和预训练权重匹配。ResNet50 和 ViT 的 ImageNet 权重都用mean=[0.485,0.456,0.406]、std=[0.229,0.224,0.225]。解冻主干后学习率降到 1e-4 到 1e-5。如果 loss 还是震荡,加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。

4.2 现象:训练精度很高,验证精度很低

原因:过拟合。数据量太少、模型太大、训练太久都会导致。另一个容易被忽略的原因是数据泄露——训练集和验证集有重复图片,或者同一张图的不同增强版本分别进了训练集和验证集。

解决:先查数据泄露,用图片的 MD5 或感知哈希去重。确认没有泄露后,加数据增强、加 Dropout、加权重衰减(weight_decay=0.01到0.1)、早停。如果数据量确实太少(每类少于 100 张),考虑用更强的预训练权重或做小样本学习。

4.3 现象:验证集精度波动大,每次评估结果不一样

原因:验证集用了随机增强。有人在val_transform里加了RandomHorizontalFlip或RandomResizedCrop,导致每次评估的输入不同。另一个原因是 BatchNorm 在验证时没有切换到 eval 模式,model.eval()没调用。

解决:验证和测试的 transform 只用确定性的Resize+CenterCrop+Normalize。评估前必须调model.eval(),并用torch.no_grad()包住推理过程。

4.4 现象:GPU 显存溢出(OOM)

原因:batch_size 太大、图片分辨率太高、模型参数量太大。ViT 比 ResNet 更吃显存,因为注意力机制的计算复杂度是序列长度的平方。

解决:先降 batch_size,从 32 降到 16 或 8。如果还不行,降输入分辨率,ViT 可以从 224 降到 192 或 160。再不行就用梯度累积,accumulation_steps=4表示每 4 个 batch 才更新一次参数,等效于 batch_size 翻 4 倍但显存不变。

# 梯度累积示例 accumulation_steps = 4 optimizer.zero_grad() for i, (imgs, labels) in enumerate(train_loader): outputs = model(imgs) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

4.5 现象:类别不平衡导致少数类精度极低

原因:某个类别样本数远少于其他类别,模型倾向于预测多数类。比如 1000 张猫、50 张狗,模型全预测猫也有 95% 的精度,但狗一张都认不出来。

解决:三种常见做法。一是重采样,对少数类过采样或对多数类欠采样。二是加类别权重,CrossEntropyLoss(weight=class_weights),权重和类别频率成反比。三是用 Focal Loss,降低易分类样本的权重,让模型关注难样本。

# 类别权重计算 from collections import Counter import torch labels = [label for _, label in train_dataset] counts = Counter(labels) total = sum(counts.values()) class_weights = torch.tensor( [total / (len(counts) * counts[i]) for i in range(len(counts))], dtype=torch.float32 ).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)

5. 小样本图像分类实战:1-shot 与 5-shot 的评估流程和 ResNet50 完整步骤

小样本图像分类是这两年图像分类里热度很高的方向,1-shot 和 5-shot 是最常见的评估设定。这一章讲清楚评估流程,并给出用 ResNet50 做小样本分类的完整步骤。

5.1 1-shot 与 5-shot 的评估逻辑

小样本评估的核心是「N-way K-shot」:从数据集中抽 N 个类别,每个类别抽 K 张支持集(support set)图片和若干查询集(query set)图片。模型只看支持集,然后对查询集分类。1-shot 就是 K=1,5-shot 就是 K=5。

常见做法是原型网络(Prototypical Network):用模型提取支持集特征,每个类别的特征均值作为该类的原型,查询集样本离哪个原型近就归哪类。这个过程不需要训练分类头,直接用预训练模型的特征提取能力。

import torch import torch.nn.functional as F def evaluate_few_shot(model, dataset, n_way=5, k_shot=1, n_query=15, n_episodes=100): """ 小样本评估:N-way K-shot n_way: 每轮抽取的类别数 k_shot: 每类支持集样本数 n_query: 每类查询集样本数 n_episodes: 评估轮数 """ model.eval() accs = [] for _ in range(n_episodes): # 随机抽n_way个类别 classes = random.sample(range(len(dataset.classes)), n_way) support, query = [], [] for cls in classes: indices = [i for i, (_, label) in enumerate(dataset) if label == cls] selected = random.sample(indices, k_shot + n_query) support_idx = selected[:k_shot] query_idx = selected[k_shot:] for idx in support_idx: img, _ = dataset[idx] support.append((img, cls)) for idx in query_idx: img, _ = dataset[idx] query.append((img, cls)) # 提取支持集特征,计算原型 prototypes = {} with torch.no_grad(): for cls in classes: cls_imgs = torch.stack([img for img, c in support if c == cls]).to(device) features = model(cls_imgs) prototypes[cls] = features.mean(dim=0) # 查询集分类 correct = 0 for img, true_cls in query: feat = model(img.unsqueeze(0).to(device)) # 找最近的原型 dists = {cls: F.pairwise_distance(feat, proto.unsqueeze(0)) for cls, proto in prototypes.items()} pred = min(dists, key=dists.get) if pred == true_cls: correct += 1 accs.append(correct / len(query)) return sum(accs) / len(accs)

这段代码的关键参数是n_way、k_shot、n_query和n_episodes。标准设定是 5-way 1-shot 和 5-way 5-shot,n_query通常取 15,n_episodes取 100 到 1000 轮取平均。评估时模型必须冻结,不能更新参数。

5.2 用 ResNet50 做小样本分类的完整步骤

用 ResNet50 做小样本分类,完整流程分五步:

第一步:加载预训练 ResNet50,去掉最后的全连接层。只保留特征提取部分,输出 2048 维特征向量。

import torch.nn as nn from torchvision import models def build_feature_extractor(): """构建ResNet50特征提取器,去掉fc层""" model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) # 去掉最后的fc层,保留avgpool输出 model = nn.Sequential(*list(model.children())[:-1]) model.eval() return model feature_extractor = build_feature_extractor().to(device)

第二步:准备小样本数据集。用ImageFolder加载,但评估时按 episode 抽样,不用 DataLoader 的 batch 机制。

第三步:计算原型。对每个类别的支持集图片提取特征,取均值作为原型。

第四步:查询集分类。对每张查询集图片提取特征,计算与各原型的欧氏距离,取最近的原型类别。

第五步:多轮评估取平均。单轮评估随机性太大,必须跑 100 轮以上取平均。1-shot 的精度通常比 5-shot 低 10 到 20 个百分点,这是正常的。

5.3 小样本评估的常见参数与结果解读

参数常见值说明
n_way5每轮类别数,也有 10-way、20-way
k_shot1 或 5支持集每类样本数
n_query15查询集每类样本数
n_episodes100~1000评估轮数,越多越稳
距离度量欧氏距离也有用余弦相似度的

结果解读时注意:1-shot 精度在 5-way 设定下,ResNet50 通常在 50% 到 70% 之间;5-shot 能到 70% 到 85%。如果 1-shot 精度低于 40%,检查特征提取器是否加载了预训练权重,以及归一化参数是否正确。如果 5-shot 比 1-shot 还低,大概率是支持集抽样有 bug,比如同一张图被抽进了多个类别。

6. 把分类器推到能用的最后一公里:混淆矩阵、置信度校准与一个习惯

模型训完、精度看着还行,不代表能直接用。最后一公里要做三件事:看混淆矩阵找系统性错误、做置信度校准、固定随机种子复现。

混淆矩阵能暴露精度数字掩盖的问题。比如 10 类分类整体精度 90%,但混淆矩阵显示类别 3 和类别 7 互相误判率高达 40%,这两个类在实际场景里可能恰好是需要区分的。常见做法是用sklearn.metrics.confusion_matrix画出来,重点看非对角线上的大值。

from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt def evaluate_with_confusion(model, test_loader, class_names): """评估并输出混淆矩阵""" model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for imgs, labels in test_loader: imgs = imgs.to(device) outputs = model(imgs) preds = outputs.argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_names=class_names)) plt.figure(figsize=(10, 8)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=class_names, yticklabels=class_names) plt.xlabel('Predicted') plt.ylabel('True') plt.savefig('confusion_matrix.png', dpi=150, bbox_inches='tight') return cm

置信度校准是另一个容易被忽略的点。模型输出的 softmax 概率往往偏高,比如预测猫的概率是 0.95,但实际正确率只有 0.8。常见做法是用温度缩放(Temperature Scaling):在验证集上拟合一个温度参数 T,推理时用softmax(logits / T)。T 大于 1 会让概率分布更平滑,更接近真实正确率。

class TemperatureScaler(nn.Module): """温度缩放校准""" def __init__(self): super().__init__() self.temperature = nn.Parameter(torch.ones(1) * 1.5) def forward(self, logits): return logits / self.temperature # 在验证集上优化temperature参数 scaler = TemperatureScaler().to(device) optimizer = torch.optim.LBFGS([scaler.temperature], lr=0.01, max_iter=50) criterion = nn.CrossEntropyLoss() def closure(): optimizer.zero_grad() calibrated = scaler(all_logits) loss = criterion(calibrated, all_labels) loss.backward() return loss optimizer.step(closure) print(f"最优温度: {scaler.temperature.item():.3f}")

最后是一个习惯:每次实验固定所有随机种子。PyTorch、NumPy、Python 内置的 random 都要固定,否则同一份代码跑两次结果不一样,排查问题时根本分不清是改动生效了还是随机波动。

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)

cudnn.deterministic = True会让训练稍慢,但结果可复现。benchmark = False也是同样目的。从那以后我每次跑图像分类实验,都强制走一遍set_seed、检查归一化参数、确认验证集没有随机增强,这三步做完再开始训,省下的排查时间比训练本身还多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询