ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ResNet50特征提取+逻辑回归:猫狗大战快速分类实战

ResNet50特征提取+逻辑回归:猫狗大战快速分类实战 简介这份源码案例面向深度学习入门者与计算机视觉初学者围绕猫狗二分类任务演示如何用预训练ResNet50提取图像特征再交由逻辑回归完成分类。案例完整覆盖数据预处理、加载并微调ResNet50、批量提取特征向量、训练逻辑回归、评估准确率与F1等指标以及超参数调整和未知图片预测帮助读者理解深度特征与浅层分类器融合的完整流程。压缩包共43个文件以25个py脚本和11个pyc缓存为主另含png图示、txt说明、json配置、md文档与pickle特征文件整体约907KB目录按数据构建、模型训练、工具函数等模块划分结构清晰便于按需查阅。目前已有219人学习。读者可据此掌握迁移学习与特征工程结合的基本套路并直接复用脚本完成自己的图像分类实验。1. 从一份 ResNet50 加逻辑回归的源码说起为什么特征提取比端到端训练更值得先跑通猫狗大战这个数据集几乎每个做图像分类的人都碰过。但真正到了工程落地你会发现一个反直觉的事实用 ResNet50 做迁移学习把最后的全连接层换成二分类头去微调往往不如「ResNet50 只做特征提取 逻辑回归做分类」来得稳、来得快、来得省。原因不复杂——当你的标注样本只有几千张、GPU 只有一张消费级卡的时候微调整个 25M 参数的骨干网络过拟合风险和显存压力都会让你难受。而把 ResNet50 当成一个固定的特征编码器每张图吐出一个 2048 维向量再拿逻辑回归去拟合训练时间从小时级压到分钟级准确率却经常能咬住微调方案。这份源码要解决的就是这条路径用预训练的 ResNet50 把猫狗图片批量转成特征矩阵再用 scikit-learn 的逻辑回归完成分类。它适合三类人刚学完 python 基础语法、想找一个能跑通的完整机器学习项目练手的新手手头有少量标注图片、想快速验证分类可行性的从业者以及需要把深度学习特征和传统分类器组合起来做 baseline 的工程师。整条链路不依赖大规模算力一台带普通显卡的机器甚至纯 CPU 都能跑完这也是它比端到端微调更适合作为第一个落地项目的原因。2. 特征提取这条路的原理与选型ResNet50 为什么适合当固定编码器2.1 迁移学习的两种用法以及为什么这里选「冻结骨干」迁移学习在图像领域有两条常见路线。第一条是微调加载预训练权重后解冻部分或全部卷积层用新数据继续训练让骨干网络适应你的任务分布。第二条是特征提取把预训练网络当成一个黑匣子只取它某一层的输出作为特征向量骨干参数完全不动后面接一个独立的分类器。微调的上限更高但代价也明显。ResNet50 在 ImageNet 上预训练后浅层学到的是边缘、纹理这类通用特征深层学到的是语义部件。猫狗大战的图片和 ImageNet 分布接近所以深层特征本身就已经很有判别力不需要再动骨干。冻结骨干带来的好处是每次训练只需要前向传播一次拿到特征之后所有实验都在 2048 维向量上做迭代成本极低。你可以今天试逻辑回归明天换 SVM后天换 XGBoost特征只提一次。选 ResNet50 而不是更小的 ResNet18 或更大的 ResNet101是一个精度和速度的折中。ResNet18 的全局池化输出是 512 维特征容量偏小猫狗这种类间差异不算极端的任务上准确率通常比 ResNet50 低一到两个点。ResNet101 参数量翻倍特征维度还是 2048但提取速度慢不少收益却不明显。ResNet50 卡在中间是社区里被验证最多的选择。2.2 取哪一层的输出全局平均池化层是默认答案ResNet50 的结构里最后一个卷积块输出的是 7×7×2048 的特征图。如果直接展平得到的是 100352 维向量维度太高逻辑回归在这种维度上容易过拟合训练也慢。常见做法是取全局平均池化层Global Average Pooling之后的输出也就是每个通道的 7×7 特征图求平均压成一个 2048 维向量。这一步在 PyTorch 里对应的是把模型最后的fc层替换成nn.Identity()或者直接取avgpool之后的张量。2048 维是一个比较舒服的维度信息量足够逻辑回归能收敛存储和计算都不吃力。如果你想要更强的空间信息也可以取layer4的输出自己做池化但对猫狗大战这个任务全局平均池化已经够用。2.3 逻辑回归在这里的角色一个线性分类器为什么够用有人会问ResNet50 提取的特征已经这么强了为什么后面只接一个逻辑回归而不是再接几层 MLP答案在于特征的可分性。预训练骨干在 ImageNet 上学到的表示已经把猫和狗映射到了一个线性可分程度很高的空间里。逻辑回归本质上是在这个空间里找一个超平面把两类分开。如果特征本身够好线性分类器就能拿到很高的准确率加非线性层反而增加过拟合风险。逻辑回归的另一个好处是可解释性和训练速度。它的损失函数是凸的优化到全局最优没有悬念不需要调学习率衰减、不需要担心陷入局部极小。对于「先跑通再优化」这个目标它是最省心的选择。scikit-learn 的LogisticRegression默认用 L2 正则配合liblinear或lbfgs求解器在几千个样本、2048 维特征上几秒钟就能收敛。3. 从图片到特征矩阵ResNet50 批量提取的完整实现3.1 环境准备与依赖安装先把环境搭起来。这份源码依赖 PyTorch、torchvision、scikit-learn、Pillow 和 numpy。如果你用的是 conda建议单独建一个环境避免和系统里的包冲突。conda create -n catdog python3.9 -y conda activate catdog pip install torch torchvision scikit-learn pillow numpy tqdm如果你只有 CPUPyTorch 的 CPU 版本就够用提取几千张图大概几分钟。有 NVIDIA 显卡的话装对应 CUDA 版本的 torch速度会快一个数量级。装完之后用下面这行验证一下python -c import torch, torchvision, sklearn; print(torch.__version__, torch.cuda.is_available())输出里True表示 GPU 可用。如果显示False但你确实有显卡多半是 CUDA 版本和 torch 版本没对上去 PyTorch 官网按你的 CUDA 版本重新装一次。3.2 数据目录结构与预处理管道猫狗大战的原始数据通常是train/cat.0.jpg、train/dog.0.jpg这种命名或者按类别分文件夹。源码里一般会统一成按文件夹分dataset/ train/ cat/ cat.001.jpg ... dog/ dog.001.jpg ... val/ cat/ dog/预处理管道要和 ResNet50 训练时的保持一致否则特征分布会偏。标准做法是缩放到 256×256中心裁剪到 224×224转成张量再用 ImageNet 的均值和标准差归一化。from torchvision import transforms preprocess transforms.Compose([ transforms.Resize(256), # 短边缩到 256 transforms.CenterCrop(224), # 中心裁到 224x224 transforms.ToTensor(), # 转张量像素值归到 [0,1] transforms.Normalize( # ImageNet 统计量 mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ), ])这里的mean和std是 ImageNet 训练集的统计值不是随便填的。如果你换成自己的数据集统计量特征分布会和预训练时不一致准确率会掉。Resize(256)加CenterCrop(224)是 torchvision 官方示例里的标准组合短边缩到 256 再裁中心能保留主体又统一尺寸。3.3 加载 ResNet50 并剥离分类头接下来加载预训练模型把最后的全连接层去掉只保留到全局平均池化。import torch import torch.nn as nn from torchvision import models device torch.device(cuda if torch.cuda.is_available() else cpu) # 加载 ImageNet 预训练权重 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) # 把最后的 fc 层替换成 Identity输出就是 2048 维池化特征 model.fc nn.Identity() model model.to(device) model.eval() # 关键切到推理模式冻结 BN 和 Dropoutmodel.eval()这行不能省。ResNet50 里有 BatchNorm 层训练模式下它会用当前 batch 的统计量做归一化推理模式下才用训练时累积的 running mean 和 running var。如果你忘了切 eval同一张图在不同 batch 里提取出的特征会不一样后面逻辑回归的训练就会莫名其妙地不稳定。这是血泪经验里排前三的坑。weightsmodels.ResNet50_Weights.IMAGENET1K_V2是 torchvision 新版的写法老版本用pretrainedTrue。两者加载的权重略有差异V2 版本在 ImageNet 上精度更高建议用新的。3.4 批量提取特征并保存为 npy有了模型和预处理就可以遍历数据集提取特征了。核心是用torch.no_grad()关掉梯度计算省显存也提速。import os import numpy as np from PIL import Image from torch.utils.data import DataLoader, Dataset from tqdm import tqdm class ImageFolderFlat(Dataset): def __init__(self, root, transform): self.samples [] self.transform transform # 约定root 下每个子文件夹是一个类别 for label, cls in enumerate(sorted(os.listdir(root))): cls_dir os.path.join(root, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(cls_dir, fname), label)) self.classes sorted(os.listdir(root)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) return self.transform(img), label def extract_features(root, model, transform, batch_size32): ds ImageFolderFlat(root, transform) loader DataLoader(ds, batch_sizebatch_size, shuffleFalse, num_workers4) feats, labels [], [] with torch.no_grad(): for imgs, lbls in tqdm(loader, descfextract {root}): imgs imgs.to(device) out model(imgs) # 形状 [B, 2048] feats.append(out.cpu().numpy()) labels.append(lbls.numpy()) return np.concatenate(feats), np.concatenate(labels), ds.classes X_train, y_train, classes extract_features(dataset/train, model, preprocess) X_val, y_val, _ extract_features(dataset/val, model, preprocess) np.save(X_train.npy, X_train) np.save(y_train.npy, y_train) np.save(X_val.npy, X_val) np.save(y_val.npy, y_val) print(train:, X_train.shape, val:, X_val.shape, classes:, classes)几个参数值得说清楚。batch_size32是显存和速度的平衡点2048 维输出不大32 或 64 都行显存紧张就降到 16。num_workers4控制数据加载的并行进程数Windows 上如果报错就改成 0。shuffleFalse是必须的因为我们要保证特征和标签的顺序严格对应打乱会导致标签错位。提取完成后把特征存成.npy后面调分类器时直接加载不用每次重新跑骨干网络。ImageFolderFlat里用sorted(os.listdir(root))保证类别顺序稳定cat 在前 dog 在后标签就是 0 和 1。如果你直接用os.listdir不排序不同机器上文件系统返回的顺序可能不同标签含义就乱了。这个细节在单机上跑没事一旦换机器复现就对不上。4. 逻辑回归训练与评估把 2048 维特征变成分类结果4.1 特征标准化与逻辑回归的关键参数特征提取完之后先做一步标准化。ResNet50 的输出经过 ReLU值域是 [0, ∞)不同维度的尺度差异可能很大。逻辑回归对特征尺度敏感标准化能显著加快收敛。from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, classification_report scaler StandardScaler() X_train_s scaler.fit_transform(X_train) X_val_s scaler.transform(X_val) # 注意用训练集的统计量 clf LogisticRegression( C1.0, # 正则强度倒数越大越弱 penaltyl2, # L2 正则防止过拟合 solverlbfgs, # 拟牛顿法适合中小规模 max_iter1000, # 迭代上限不够会警告 n_jobs-1, # 用满 CPU ) clf.fit(X_train_s, y_train) pred clf.predict(X_val_s) print(val acc:, accuracy_score(y_val, pred)) print(classification_report(y_val, pred, target_namesclasses))C是最需要调的参数。C 越大正则越弱模型越倾向拟合训练集C 越小正则越强偏向简单模型。猫狗大战这种特征质量高的任务C 在 0.1 到 10 之间试几个值就能找到不错的点。solverlbfgs是默认选择2048 维、几千样本的规模它处理得很好。如果特征维度再高一个量级可以换saga它支持 L1 正则且对稀疏特征更友好。StandardScaler的fit_transform只在训练集上调用验证集必须用transform复用训练集的均值和方差。如果验证集也fit_transform等于把验证集的分布信息泄漏进了训练过程评估结果会虚高。这是新手最容易犯的错之一。4.2 用交叉验证找 C 的合理区间单次划分的验证集准确率有波动想稳一点就用交叉验证扫一遍 C。from sklearn.model_selection import cross_val_score import numpy as np for c in [0.01, 0.1, 1.0, 10.0, 100.0]: clf LogisticRegression(Cc, max_iter1000, n_jobs-1) scores cross_val_score(clf, X_train_s, y_train, cv5, scoringaccuracy) print(fC{c:6} mean{scores.mean():.4f} std{scores.std():.4f})5 折交叉验证在几千样本上跑起来很快。看mean找最高的 C同时看std如果某个 C 的 std 明显大说明模型对数据划分敏感换更小的 C 增加正则通常能稳住。实践中 C1 附近往往就是不错的起点不需要扫太细。4.3 混淆矩阵与错误样本分析准确率只是一个数想知道模型错在哪看混淆矩阵和具体错分样本。from sklearn.metrics import confusion_matrix cm confusion_matrix(y_val, pred) print(cm) # 找出预测错误的验证集样本索引 wrong np.where(pred ! y_val)[0] print(错分数量:, len(wrong))混淆矩阵能告诉你模型是偏向把猫认成狗还是反过来。如果两类错分数量严重不对称可能是训练集里两类样本数不均衡或者某一类的图片质量整体偏差。把错分样本的路径打出来肉眼看看是不是有标注错误、模糊图、或者主体不明显的图。猫狗大战数据集里确实存在少量标错的样本这部分噪声会拉低上限清洗掉能涨点。4.4 保存模型与推理脚本训练完把 scaler 和分类器一起存下来推理时要成对使用。import joblib joblib.dump({scaler: scaler, clf: clf, classes: classes}, catdog_lr.pkl) # 推理示例 bundle joblib.load(catdog_lr.pkl) def predict_image(path): img Image.open(path).convert(RGB) x preprocess(img).unsqueeze(0).to(device) with torch.no_grad(): feat model(x).cpu().numpy() feat bundle[scaler].transform(feat) idx bundle[clf].predict(feat)[0] return bundle[classes][idx]注意推理时特征提取用的model必须和训练时是同一个preprocess也必须一致。如果换了预处理参数特征分布变了scaler 和分类器都会失效。把模型、scaler、类别列表打包成一个文件能避免推理时漏掉某一步。5. 避坑与排查这条链路上最容易翻车的五个地方5.1 忘了 model.eval() 导致特征每次都不一样现象同一张图跑两次提取得到的 2048 维向量数值不同逻辑回归训练 loss 震荡验证准确率忽高忽低。原因ResNet50 里的 BatchNorm 在训练模式下用当前 batch 的统计量做归一化batch 组成一变输出就变。Dropout 层也会随机置零。解决加载模型后立刻调用model.eval()并且整个特征提取过程包在torch.no_grad()里。这两步是配套的缺一不可。5.2 验证集用了 fit_transform 造成数据泄漏现象验证集准确率异常高接近 99%但拿新图片测试时表现差很多。原因对验证集调用scaler.fit_transform等于用验证集的均值和方差去标准化验证集把验证集分布信息泄漏了。解决训练集fit_transform验证集和测试集一律transform。把 scaler 存下来推理时复用同一个。5.3 图片通道数不对导致预处理报错现象RuntimeError: output with shape [1, 224, 224] doesnt match the broadcast shape [3, 224, 224]。原因数据集里有灰度图或带 alpha 通道的 PNGImage.open读出来是单通道或四通道和 ResNet50 期望的三通道不匹配。解决在__getitem__里统一Image.open(path).convert(RGB)。这一行能挡掉绝大多数通道问题代价只是多一次转换。5.4 标签顺序不稳定导致类别对不上现象训练时 cat 是 0、dog 是 1换台机器重新提特征后cat 变成了 1之前存的模型预测全反了。原因os.listdir返回的顺序依赖文件系统不同环境可能不同。解决用sorted(os.listdir(root))固定类别顺序并把classes列表和模型一起保存。推理时按保存的classes映射回类别名不要重新扫目录。5.5 特征没标准化直接喂逻辑回归现象逻辑回归训练很慢max_iter调到几千还在警告不收敛准确率也不理想。原因ResNet50 输出经过 ReLU各维度尺度差异大逻辑回归的梯度下降在这种条件下收敛慢。解决训练前用StandardScaler标准化。这一步几乎零成本但对收敛速度和最终精度都有明显帮助。标准化后再跑max_iter1000通常就够了。6. 把准确率再往上推一点特征拼接与分类器替换的实操技巧跑通基础版本之后如果想把验证集准确率从九十个点再往上推一推有几个成本不高但有效的做法。第一个是特征拼接。ResNet50 的全局平均池化输出是 2048 维你可以额外取layer4输出的全局最大池化再拼一个 2048 维凑成 4096 维。平均池化捕捉的是整体响应强度最大池化捕捉的是最显著的区域响应两者互补。拼接后维度翻倍逻辑回归训练时间增加不多但准确率往往能涨零点五到一个点。代码上就是在模型 forward 里同时返回两个池化结果或者用 hook 抓中间层输出。第二个是换分类器做对比。逻辑回归是线性 baseline你可以用同样的特征跑一遍 SVMRBF 核或 LightGBM看哪个更高。实践中在 2048 维特征上线性 SVM 和逻辑回归通常打平RBF 核 SVM 可能略高但训练慢很多LightGBM 在样本量几千时表现也不错。选哪个取决于你对推理延迟和可解释性的要求。逻辑回归的系数可以直接看哪些维度对分类贡献大树模型和核方法就没这么直观。第三个是数据增强后的特征平均。对训练集里每张图做几次随机裁剪或水平翻转分别提取特征后取平均相当于用增强样本的 feature 做了一次集成。这个做法能让特征更鲁棒尤其是对拍摄角度变化大的图片。代价是提取时间乘以增强倍数但特征只提一次后面实验都受益。方案特征维度验证集准确率参考训练耗时适用场景ResNet50 逻辑回归2048基线秒级快速验证、教学平均最大池化拼接4096基线 0.5~1秒级追求精度、算力有限拼接 LightGBM4096基线 1~2分钟级有调参经验拼接 增强特征平均4096基线 1~2提取时间×N图片差异大我自己的习惯是先把基础版本跑通、把准确率记下来再逐项加技巧每加一项单独评估确认有正向收益才保留。曾经有一次把三个技巧一起加上结果准确率反而掉了排查半天才发现是增强倍数太高平均后的特征把判别性抹平了。从那以后我坚持一次只动一个变量这条习惯帮我省了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表