简介:这是一份面向深度学习入门与计算机视觉实践者的完整案例源码,围绕ResNet50特征提取与逻辑回归分类展开,帮助读者理解如何将预训练卷积网络与传统机器学习方法结合,解决猫狗二分类这一经典问题。压缩包共43个文件,约907KB,以25个Python脚本为核心,辅以11个pyc缓存、3张png图示、1个pickle特征文件及txt、json、md等说明配置,涵盖数据预处理、模型构建、训练与评估等模块,目录结构清晰,便于按流程阅读与复现。已有219人学习下载,适合希望掌握迁移学习基本流程、了解图像特征表示与分类决策的读者。通过该案例,可系统走通从加载预训练ResNet50、提取特征向量到训练逻辑回归并评估准确率、精确率、召回率与F1分数的完整链路,同时体会深度特征与浅层分类器融合的工程思路,为后续迁移到其他图像任务提供可复用的参考模板。
1. ResNet50 提特征 + 逻辑回归分类:一条被低估的迁移学习基线
猫狗大战这个数据集,很多人第一次跑都是在 Keras 里搭个几层卷积从头训,结果要么过拟合到 99% 训练准确率、验证集却卡在 70% 上下,要么训一下午还在原地打转。其实有一条更省事、更稳的路子:拿一个在 ImageNet 上预训练好的 ResNet50 当固定特征提取器,把每张图压成一个 2048 维向量,再在这个向量上训一个逻辑回归。整套流程不需要 GPU 也能跑,CPU 上十几分钟出结果,验证集准确率通常能到 96% 以上。
这篇讲的就是这条基线怎么落地:ResNet50 的哪一层输出能当特征、图像预处理要注意什么、逻辑回归的正则强度怎么调、缓存特征怎么省时间,以及几个我实际踩过的坑。适合刚接触迁移学习、想快速拿到一个能用的猫狗分类器的人,也适合已经会训 CNN、但想找一个可解释、可复现的对照基线的人。源码是 Python,依赖 PyTorch 和 scikit-learn,环境配好之后照着敲就能跑通。
2. 为什么选 ResNet50 做特征提取器,而不是从头训 CNN
2.1 迁移学习的核心逻辑:特征复用
从头训一个 CNN 分类猫狗,本质是让网络自己从像素里学出「耳朵尖不尖」「毛色什么样」这些判别性特征。问题是猫狗大战训练集也就两万多张,这个量级对深层网络来说太少,卷积核很容易记住训练样本的噪声,泛化能力上不去。
ResNet50 在 ImageNet 上见过 120 万张图、1000 个类别,它的卷积层已经学到了一套通用的视觉特征:浅层是边缘、纹理,中层是局部形状,深层是语义部件。猫和狗在 ImageNet 里本来就有对应类别,所以这套特征对猫狗任务天然适配。把 ResNet50 的卷积部分冻结,只把它当特征映射函数用,等于免费借用了别人几百万张图训出来的视觉先验。
这里有个反直觉的点:特征提取器越深不一定越好。ResNet50 的 2048 维特征对猫狗这种二分类已经绰绰有余,换成 ResNet152 提升有限,但推理时间翻倍。我一般先用 ResNet50 打底,效果不够再考虑换 backbone。
2.2 逻辑回归为什么够用
2048 维特征 + 二分类,逻辑回归的参数量是 2049 个(含偏置),这个量级下模型几乎不可能过拟合,训练也快。更重要的是它可解释:每个特征的权重直接告诉你哪些维度对「是狗」贡献大。相比之下,在特征上再接一个 MLP,虽然可能涨零点几个百分点,但调参成本和不确定性都上去了。
逻辑回归的损失函数是交叉熵,配合 L2 正则控制权重幅度。scikit-learn 的LogisticRegression默认就带 L2,C参数是正则强度的倒数,C 越小正则越强。这个参数是整条流程里最值得调的,后面会细说。
2.3 环境准备与依赖安装
先把环境搭起来。Python 建议 3.8 以上,PyTorch 和 torchvision 版本要匹配。如果你用 conda,一条命令搞定;用 pip 的话注意 CUDA 版本对应关系。
# 创建虚拟环境(conda 方式) conda create -n catdog python=3.9 -y conda activate catdog # 安装 PyTorch(CPU 版本,有 GPU 的话去官网查对应 CUDA 命令) pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 安装其余依赖 pip install scikit-learn numpy pillow tqdm如果你用 vscode 配 python 环境,记得在右下角选对解释器,否则跑起来会报ModuleNotFoundError。这一步看着简单,但我见过太多人卡在这里,以为是代码问题,其实是解释器选错了。
依赖说明:torchvision里自带 ResNet50 的预训练权重,第一次调用会下载约 100MB 的模型文件,需要联网。tqdm只用来显示进度条,不影响功能。scikit-learn提供逻辑回归和评估指标。
3. 用 ResNet50 批量提取 2048 维特征
3.1 关键决策:取哪一层的输出
ResNet50 的结构是:conv1 → bn1 → relu → maxpool → layer1 → layer2 → layer3 → layer4 → avgpool → fc。最后的fc层输出 1000 维 ImageNet 类别分数,这个不能要,因为它是针对 ImageNet 类别训的。我们要的是avgpool之后的输出,形状是(batch, 2048, 1, 1),展平就是 2048 维特征向量。
在 PyTorch 里,最干净的做法是把模型的前面部分单独拿出来:
import torch import torch.nn as nn from torchvision import models, transforms from PIL import Image import numpy as np import os from tqdm import tqdm # 加载预训练 ResNet50 resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) # 去掉最后的全连接层,保留到 avgpool # children() 返回各模块,去掉最后一个 fc 即可 feature_extractor = nn.Sequential(*list(resnet.children())[:-1]) # 切到推理模式,关闭 dropout 和 batchnorm 的训练行为 feature_extractor.eval() # 有 GPU 就上 GPU device = torch.device("cuda" if torch.cuda.is_available() else "cpu") feature_extractor = feature_extractor.to(device)逻辑说明:list(resnet.children())[:-1]把除fc外的所有层打包成一个 Sequential,输入一张图输出(1, 2048, 1, 1)。eval()必须调用,否则 BatchNorm 会用当前 batch 的统计量,单张推理时结果会飘。参数上,weights=ResNet50_Weights.IMAGENET1K_V2是 torchvision 新版的写法,老版本用pretrained=True,效果一样。
3.2 图像预处理:必须和预训练时一致
ResNet50 训练时的预处理是:缩放到 256、中心裁剪 224、转张量、按 ImageNet 均值方差归一化。推理时如果预处理不一致,特征分布会偏移,逻辑回归的效果会明显下降。这是最容易被忽略的坑之一。
# 预处理管道,必须和 ImageNet 训练时一致 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] ), ]) def extract_single(img_path): """提取单张图的 2048 维特征""" img = Image.open(img_path).convert("RGB") # 强制 RGB,防止灰度图报错 tensor = preprocess(img).unsqueeze(0).to(device) # 加 batch 维度 with torch.no_grad(): # 关闭梯度,省显存 feat = feature_extractor(tensor) # (1, 2048, 1, 1) return feat.squeeze().cpu().numpy() # 展平成 (2048,)参数说明:Resize(256)是短边缩到 256,长边按比例;CenterCrop(224)从中心裁 224。如果你的猫狗图片主体不在中心,可以考虑改成Resize((224,224))直接拉伸,但那样会变形,一般还是中心裁剪稳。convert("RGB")处理灰度图和 RGBA 图,不加这行遇到 PNG 透明通道会直接崩。
3.3 批量提取与特征缓存
两万多张图一张张过太慢,用 DataLoader 批量跑。更重要的是把特征存成.npy文件,逻辑回归调参时直接读缓存,不用每次重新过 ResNet50。
from torch.utils.data import Dataset, DataLoader class ImageFolderFlat(Dataset): """读取 data/class_name/xxx.jpg 结构的图片""" def __init__(self, root, transform): self.samples = [] self.transform = transform 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)) 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_all(root, batch_size=64): """批量提取整个数据集的 ResNet50 特征""" ds = ImageFolderFlat(root, preprocess) loader = DataLoader(ds, batch_size=batch_size, shuffle=False, num_workers=4) feats, labels = [], [] with torch.no_grad(): for imgs, lbls in tqdm(loader, desc=f"extract {root}"): imgs = imgs.to(device) out = feature_extractor(imgs) # (B, 2048, 1, 1) out = out.squeeze(-1).squeeze(-1) # (B, 2048) feats.append(out.cpu().numpy()) labels.append(lbls.numpy()) return np.concatenate(feats), np.concatenate(labels) # 假设数据放在 data/train 和 data/val,各自下面有 cat/ 和 dog/ X_train, y_train = extract_all("data/train") X_val, y_val = extract_all("data/val") # 缓存到磁盘,下次直接加载 np.save("feat_train.npy", X_train) np.save("label_train.npy", y_train) np.save("feat_val.npy", X_val) np.save("label_val.npy", y_val) print("train:", X_train.shape, "val:", X_val.shape)逻辑说明:shuffle=False保证特征和标签顺序对应,虽然这里标签是一起存的,但养成习惯。num_workers=4加速图片读取,Windows 上如果报错就改成 0。squeeze(-1).squeeze(-1)把(B,2048,1,1)压成(B,2048),比flatten(1)更直观。
参数说明:batch_size=64在 CPU 上大概占 1GB 内存,显存不够就降到 32 或 16。提取完的特征文件,两万张图约 160MB(float32),加载比重新推理快几十倍。
4. 在 2048 维特征上训练逻辑回归
4.1 标准化:逻辑回归的隐形前提
逻辑回归对特征尺度敏感。ResNet50 的 2048 维特征各维度方差差异很大,不标准化的话,梯度下降会震荡,正则也会不公平地惩罚大方差维度。标准做法是减均值除标准差。
from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, classification_report # 用训练集拟合 scaler,再应用到验证集 scaler = StandardScaler() X_train_s = scaler.fit_transform(X_train) X_val_s = scaler.transform(X_val) # 注意:用训练集的统计量,不能重新 fit这里有个细节:验证集必须用训练集拟合出来的 scaler,不能自己 fit。否则等于把验证集的分布信息泄露进了预处理,评估结果会虚高。这个坑在特征工程里非常常见。
4.2 逻辑回归的关键参数:C 和 max_iter
scikit-learn 的LogisticRegression默认用 L2 正则,C是正则强度的倒数。C 越大正则越弱,C 越小正则越强。猫狗这种线性可分度较高的任务,C 在 0.01 到 10 之间都能用,但最优值值得搜一下。
# 基础训练 clf = LogisticRegression( C=1.0, # 正则强度倒数 max_iter=1000, # 迭代上限,特征维度高时默认 100 不够 solver="lbfgs", # 拟牛顿法,适合中小规模 multi_class="auto", # 二分类自动处理 n_jobs=-1 # 并行 ) 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_names=["cat", "dog"]))参数说明:max_iter=1000是必须调的,2048 维特征下默认的 100 次迭代经常不收敛,会弹ConvergenceWarning。solver="lbfgs"对 L2 正则支持好,数据量再大可以换saga。n_jobs=-1用满所有 CPU 核。
4.3 用交叉验证选 C,而不是拍脑袋
C 的选择直接影响准确率,别凭感觉设。用训练集做 5 折交叉验证,扫一遍候选值。
from sklearn.model_selection import cross_val_score import numpy as np for C in [0.001, 0.01, 0.1, 1.0, 10.0, 100.0]: clf = LogisticRegression(C=C, max_iter=1000, solver="lbfgs", n_jobs=-1) scores = cross_val_score(clf, X_train_s, y_train, cv=5, scoring="accuracy") print(f"C={C:>7}: cv acc = {scores.mean():.4f} (+/- {scores.std():.4f})")跑完你会看到一条曲线:C 太小欠拟合,C 太大过拟合,中间有个平台。我实测猫狗任务上 C=0.1 到 1.0 之间差异很小,选 C=1.0 就够。但如果你换数据集,这个扫描步骤别省。
参数说明:cv=5是 5 折,数据量小可以调到 10。scoring="accuracy"对均衡数据集合适,类别不均衡要换f1。交叉验证在 2048 维、两万样本上大概跑几分钟,值得等。
5. 避坑与排查:这条流程最容易翻车的五个地方
5.1 现象:验证准确率只有 50% 上下,像随机猜
原因:预处理和预训练不一致。最常见的是忘了Normalize,或者用了Resize((224,224))直接拉伸导致形变。ResNet50 对输入分布很敏感,归一化漏掉的话特征基本是废的。
解决:打印一张图预处理后的均值和方差,确认在 0 附近、标准差在 1 附近。对照本文 3.2 的管道逐行检查,mean和std的顺序不能反。
5.2 现象:ConvergenceWarning: lbfgs failed to converge
原因:max_iter太小。2048 维特征下,lbfgs 需要更多迭代才能收敛,默认 100 次不够。
解决:把max_iter提到 1000 甚至 2000。如果还警告,说明特征尺度差异太大,检查 StandardScaler 是否真的应用了。实在不行换solver="saga",它对大规模数据更稳,但慢一些。
5.3 现象:验证集准确率比训练集高
原因:验证集的 scaler 是自己 fit 的,或者特征提取时验证集用了不同的预处理。前者是数据泄露,后者是分布不一致。
解决:确认X_val_s = scaler.transform(X_val)用的是训练集的 scaler。特征提取阶段,训练集和验证集必须走同一个preprocess对象,不能各写一份。
5.4 现象:提取特征时内存爆掉
原因:batch_size太大,或者一次性把所有图片读进内存。两万张 224x224 的图,float32 下约 12GB,直接 OOM。
解决:用 DataLoader 流式读取,batch_size降到 32。特征提取完立刻存.npy,不要在内存里堆着。如果图片特别大,先把数据集缩放到 256 存一份小图,再提取特征。
5.5 现象:单张推理时结果和批量不一致
原因:忘了feature_extractor.eval(),BatchNorm 在单张推理时用了自己的统计量,和训练时的滑动平均不一致。
解决:模型加载后立刻.eval(),推理时套torch.no_grad()。这两个是标配,别省。
6. 把这条基线用起来:从单张预测到特征复用
6.1 单张图片端到端预测
训练完的模型要能对新图做预测,把 scaler、逻辑回归、特征提取器串起来。
def predict(img_path): """端到端预测单张图片""" feat = extract_single(img_path) # (2048,) feat = scaler.transform(feat.reshape(1, -1)) # 标准化 prob = clf.predict_proba(feat)[0] # [cat_prob, dog_prob] label = "dog" if prob[1] > 0.5 else "cat" return label, prob label, prob = predict("test/mycat.jpg") print(f"预测: {label}, 置信度: {max(prob):.4f}")逻辑说明:extract_single返回一维向量,reshape(1,-1)变成一行样本喂给 scaler。predict_proba返回两类概率,取大的那个。这套流程单张推理在 CPU 上约 50ms,够用。
6.2 特征复用:一次提取,多处使用
2048 维特征提取一次要十几分钟,但提取完就是通用的。同一份特征可以喂给 SVM、随机森林、XGBoost,横向对比哪个分类器最适合你的数据。我一般会顺手跑一个 LinearSVC 做对照:
from sklearn.svm import LinearSVC svm = LinearSVC(C=0.1, max_iter=5000) svm.fit(X_train_s, y_train) print("LinearSVC val acc:", accuracy_score(y_val, svm.predict(X_val_s)))猫狗任务上 LinearSVC 和逻辑回归通常差不到 0.5 个百分点,但逻辑回归能输出概率、可解释性更好,所以我默认用逻辑回归。如果你的任务需要概率校准,逻辑回归是更稳的选择。
6.3 一个容易被忽略的技巧:特征拼接
如果手头有额外的元信息(比如图片尺寸、拍摄设备),可以把这些低维特征和 2048 维 ResNet 特征拼在一起再训逻辑回归。拼接前记得对元信息也做标准化,否则量纲差异会淹没视觉特征。这个技巧在工业场景里很实用,纯视觉特征搞不定的 case,加一两个业务特征往往能救回来。
6.4 验证方法:别只看准确率
准确率在类别均衡时够用,但猫狗大战的验证集如果猫狗比例是 6:4,准确率会虚高。养成看混淆矩阵和每类 F1 的习惯:
from sklearn.metrics import confusion_matrix import pandas as pd cm = confusion_matrix(y_val, pred) print(pd.DataFrame(cm, index=["true_cat","true_dog"], columns=["pred_cat","pred_dog"]))如果某一类召回明显低,说明特征对那一类判别力不够,可以考虑换 backbone 或者做数据增强再提特征。我自己的习惯是:任何分类任务,先跑通这条 ResNet50 + 逻辑回归基线,拿到一个准确率数字,再决定要不要上更复杂的模型。这条基线跑一次不到半小时,但它给你的参照系,能帮你判断后面所有优化到底有没有用。希望帮到你。
本文还有配套的精品资源,点击获取