
简介这是一套基于CNN图像分类与决策树算法的垃圾分类系统完整源码包面向机器学习课程本科生、毕业设计或课程设计开发者。项目已通过本地编译调试可独立运行适用于图像识别、特征工程与多算法对比研究等场景。资源共包含2000个文件其中1985张jpg图片构成训练验证数据集8个py脚本为核心算法与界面实现4份docx文档覆盖需求规格说明书、系统设计报告、测试方案及项目进度计划另有md说明文档压缩包整体约53MB数据、代码与文档相互配套便于对照学习。目前已有196人浏览学习。docx部分呈现从需求分析到测试验收的完整课程项目流程py脚本涉及CNN模型训练、决策树分类与可视化模块jpg数据集可直接用于模型训练和精度验证。整套资料难度适中评分达95分以上适合需要快速搭建可演示垃圾分类项目的学习者参考复用。1. 垃圾要分类先让模型分得清这套系统到底在解决什么问题“Python基于CNN的图像分类算法、基于决策树的垃圾分类算法实现的垃圾分类系统”这个标题看起来像是两个算法拼在一起实际落地时你会发现真正值钱的地方在于CNN负责“看图片”决策树负责“做判断”两者各守一段最后汇成一个输出。拍一张垃圾照片先经过CNN得到四个类别的置信度再把置信度连同“易碎、含水、有有害标志”这类结构化信息送进决策树树给出最终分类——可回收、厨余、有害还是其他。这套方案对三类人特别有用正在做课程设计、需要同时展示深度学习和传统机器学习两种算法的学生想快速搭一个垃圾识别demo、又不想上来就部署几十层大模型的从业者以及已经有图像模型、但发现只靠图片判断总在玻璃和陶瓷上翻车想加一道可解释决策层的人。它解决的从来不是“把准确率刷到99%”而是用一个可控成本让系统分得稳、改得动、讲得清。下面的每一章都围绕这个目标展开。2. 先看整体设计CNN和决策树各自守哪一道关卡2.1 为什么是CNN加决策树的组合而不是单一模型CNN卷积神经网络擅长的事非常明确从像素里找特征。瓶子的边缘、塑料袋的纹理、电池的金属反光这些视觉线索用卷积核一层层提取再靠全连接层映射到类别上。它对光照变化、拍摄角度和背景干扰的容忍度是传统手工特征方法比不了的。但这不意味着光靠CNN就能把垃圾分类做扎实。垃圾的“类别”并不只由外观决定一个玻璃瓶和一个陶瓷碗外观像到什么程度连人都可能看走眼而“有没有害”很多时候看的是垃圾上的标志、残留物状态这类信息只有局部出现在图片里。这个时候决策树算法就体现出它的价值。决策树吃的是结构化特征可以自然地加进来图像之外的辅助信息重量、体积、是否易碎、是否有毒有害标志的概率、含水率。这些特征在纯CNN里要么没法编码要么得单独再接一个回归头让整个网络复杂一截。用决策树单独做这一层训练快、可解释、出问题时能沿着树的分支一路回溯到“是哪条规则判错了”。所以常见做法是CNN输出置信度向量结构化信息拼在后面一起喂给决策树做最终判定。这等于给黑匣子一样的深度学习模型装了一个人类能看懂的决策出口。还有一层现实考虑是开发和评审成本。课程设计和工程验收都要求你能讲清楚“为什么这么设计”。单一CNN可以讲但很难解释误判原因单一决策树可以讲清楚规则但图片特征又没法人工提取。CNN加决策树这个组合两边各取所长深度模型负责难搞的视觉特征传统模型负责有据可查的规则判断两个算法各有各的训练脚本、验证指标出了问题能分别排查。2.2 拿到源码包先看这三样说明文档、数据集组织、训练脚本入口拿到这样一个源码压缩包我一般不会急着跑代码先花十几分钟把三样东西对齐说明文档、数据集目录、训练脚本。说明文档一般会写明运行顺序、依赖清单和数据集的目录结构。如果说明文档写得比较粗就按依赖关系倒着推——先找到训练脚本看它 import 了什么再顺着 import 去查数据集和权重文件放在哪。这个习惯能省下后面一大半排错时间。一个规范的垃圾分类项目包目录结构常见是这样garbage_classification/ ├── README.md # 运行说明、依赖、算法介绍 ├── requirements.txt ├── dataset/ │ ├── train/ │ │ ├── recyclable/ │ │ ├── kitchen_waste/ │ │ ├── hazardous/ │ │ └── other/ │ ├── val/ │ └── test/ ├── cnn/ │ ├── train_cnn.py │ ├── model.py │ └── best_cnn.pt ├── tree/ │ ├── build_features.py │ ├── train_tree.py │ └── tree_model.pkl └── inference/ └── predict.py这个结构里的数据流是dataset 目录下的图片经过 train_cnn.py 训练出 CNN 权重CNN 对每张训练图片输出置信度向量build_features.py 把这些置信度和结构化特征拼成一张特征表train_tree.py 用这张表训练决策树最后 predict.py 把两个模型串起来输入一张图片输出一个最终分类。先看懂这个数据流再去看代码细节比一头扎进某个文件里有效得多。数据集的组织方式直接决定训练代码怎么写。如果你的代码用 torchvision 的 ImageFolder 加载数据那么目录必须严格按类别分文件夹文件夹名字就是标签。常见的有四分类可回收、厨余、有害、其他也有六分类在这四个基础上再加纸张、塑料等细分。拿到数据集先数一下类别文件夹数量和代码里的 num_classes 对齐这个数字对不上后面全是白跑。2.3 Python环境与依赖对齐先把解释器和库版本钉死环境问题占据这类项目踩坑记录的一半以上而且大部分是 Python 版本和 PyTorch 预编译包不匹配造成的。我建议直接用 Python 3.8 或 3.9这两个版本对 PyTorch 的预编译支持最稳。太新的 Python 3.11、3.12 在某些机器上装 torch 会退回源码编译一个不小心就是半小时起步还容易失败。用 vscode 跑项目的话创建虚拟环境后记得在右下角切换解释器切错了会出现“import torch 在终端能用、在 vscode 里报错”的奇怪现象。python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install scikit-learn opencv-python pillow numpy matplotlib第一行创建虚拟环境第二行激活之后所有安装都装进这个环境里不会污染系统 Python。这里有个经验首次装依赖torch 和 torchvision 要一起装避免它们各自解析出互不兼容的版本opencv 装 opencv-python 就够了不要装 opencv-contrib 全家桶后者的依赖路径偶尔会和 torch 抢 DLL。装完后跑一句验证import torch, torchvision, sklearn, cv2 print(torch.__version__, torchvision.__version__, sklearn.__version__, cv2.__version__)四个版本都能打出来环境就是通的。如果“import cv2”报错说找不到 lib多半是系统缺了 opencv 的底层依赖库但 Windows 上大概率是装错了包卸载 opencv-contrib-python 重装 opencv-python 就好。环境这块宁可慢一点也别跳步因为后面的训练和推理报错有六成以上都能追溯到环境不一致。3. 用CNN做图像分类网络结构、训练循环与超参设置3.1 一个能跑起来的最小CNN分类网络垃圾分类图像不像 ImageNet 那样有极其细微的类别差异一个三层卷积的小网络在几千张图片的数据集上就能达到可用的效果。我一般不用 ResNet 这类深层网络直接上手原因很实际训练慢、容易过拟合、出问题时难定位。深网络在数据量不足时并不会比小网络好多少反而让小数据集上的训练变得很玄学。import torch.nn as nn class GarbageCNN(nn.Module): def __init__(self, num_classes4): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(128, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))这个网络的设计逻辑输入是 3 通道 RGB 图片三个卷积层把通道数从 3 扩到 32、64、128每层后面跟一个池化把分辨率减半。AdaptiveAvgPool2d((1, 1)) 是一个很容易被忽略但很关键的设计——它把任意尺寸的输入特征图强制压缩成 1x1这样全连接层的输入维度永远是 128不会因为输入图片尺寸变化而报错。Dropout(0.5) 放在全连接层之间让一半神经元随机失活是抵御小数据集过拟合最有效的手段之一。参数选择上kernel_size 用 3、padding 用 1是为了让卷积不改变特征图尺寸边界像素也能被处理到池化用 2每池化一次宽高减半三层下来 224x224 的输入变成 28x28信息保留度正好。这个结构在 CPU 上训练一张 224 的图大约几十毫秒几千张图一轮也在可接受范围内。如果你的机器特别老可以把输入缩到 128x128训练时间能省一半以上准确率通常只掉两三个点。3.2 数据加载与归一化图像尺寸、Batch大小、均值方差都要统一数据加载是“看似简单、实际全是细节”的部分。项目里数据集如果是零散图片最省事的做法是用 torchvision 的 ImageFolder它按子目录名自动生成标签省去手写 CSV 的麻烦。但要注意ImageFolder 的标签顺序是目录字母序不是你在文件夹里看到的顺序。这一点后面专门讲在这里先记住“标签是按目录名字母排的”。from torchvision import datasets, transforms from torch.utils.data import DataLoader transform_train transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) transform_val transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(dataset/train, transformtransform_train) val_ds datasets.ImageFolder(dataset/val, transformtransform_val) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers0) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers0)训练集和验证集的 transform 必须分开写。验证集不做随机翻转、不做颜色抖动因为验证集的作用是模拟真实场景加了随机增强会让指标虚高或虚低失去参考意义。Resize 到统一尺寸是硬要求CNN 全连接层的输入维度固定了图片尺寸不一致在 AdaptiveAvgPool2d 之前就会爆维度错误。Normalize 用的 mean 和 std 是 ImageNet 数据集的统计值对日常照片类图片通用性很强不需要自己在小数据集上重新统计。num_workers0 是 Windows 上最保险的选择。不是不能设成 2 或 4但 Windows 下多进程数据加载偶发“DataLoader worker 意外退出”的诡异问题排查成本极高。batch_size 设 32 是综合考虑太大 GPU 显存不够、CPU 内存压力大太小梯度噪声大收敛慢。如果发现一轮训练特别慢先把 batch_size 降到 16再把图片 Resize 到 128二选一就能解决大部分性能问题。3.3 训练循环与模型保存损失曲线、断点续训、最佳权重训练循环本身不难难在怎么设计“保存模型”的逻辑。很多新手只在训练结束保存一次结果训练到第 15 轮过拟合了前面第 8 轮的权重才是最好的最后只能重训。我的做法是每个 epoch 验证一次只要验证损失创了新低就覆盖保存这样永远保留的是最佳状态而不是最后一个状态。import torch import torch.nn as nn from torch.optim import Adam model GarbageCNN(num_classes4) criterion nn.CrossEntropyLoss() optimizer Adam(model.parameters(), lr1e-3) best_loss float(inf) for epoch in range(20): model.train() train_loss 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() model.eval() val_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: outputs model(images) loss criterion(outputs, labels) val_loss loss.item() preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) val_loss / len(val_loader) acc correct / total print(fEpoch {epoch1} | train_loss{train_loss/len(train_loader):.4f} f| val_loss{val_loss:.4f} | acc{acc:.4f}) if val_loss best_loss: best_loss val_loss torch.save(model.state_dict(), cnn/best_cnn.pt)optimizer 选 Adam 而不是 SGD理由是小数据集上 Adam 的默认参数就能收敛不需要精细调整学习率策略lr 用 1e-3这是 Adam 的常用起点。每次梯度更新前必须 optimizer.zero_grad()否则梯度会累加导致 loss 剧烈波动。model.eval() 这一句不能省它关闭了 Dropout 和 BatchNorm 的训练态行为否则验证集上每次结果都不一样。torch.no_grad() 关掉梯度计算推理阶段省内存和显存。训练中要盯的不是 “精确率”而是验证损失。如果 train_loss 一直降但 val_loss 在第 8 轮附近掉头向上就是过拟合开始最佳权重应该在第 8 轮附近。torch.save(model.state_dict(), ...) 只保存参数不保存优化器状态。如果你想断点续训——也就是从第 12 轮中断的地方接着训得把 optimizer.state_dict() 也一起保存这才是名副其实的后悔药。加载的时候需要注意即使只做推理也要先实例化模型再 load直接 load 进空对象会报“missing keys”错误。4. 用决策树做二次判别特征拼接与融合决策4.1 决策树吃什么样的特征CNN置信度加结构化属性决策树不像 CNN 那样能从原始像素里提特征它只能吃人为构造好的表格。因此关键问题是哪些特征能代表一个垃圾样本我常用的方案是把 CNN 的输出和结构化属性拼在一起。CNN 的最后一层通常是 4 个类别的 logits过 softmax 之后变成总和为 1 的置信度比如 [0.02, 0.88, 0.07, 0.03]这 4 个数本身就是强特征——它浓缩了图片里的视觉信息。import numpy as np import torch.nn.functional as F def build_feature_row(cnn_logits, structured_attrs): probs F.softmax(cnn_logits, dim0).detach().numpy() # structured_attrs: [weight_kg, volume_l, is_fragile, # has_hazard_mark, is_wet] return np.concatenate([probs, np.array(structured_attrs, dtypenp.float32)])这里用 softmax 后的概率而不是原始 logits是因为概率值域固定0 到 1 之间决策树分叉时更直观。而且概率天然带“置信程度”——CNN 对这张图越确定概率分布越尖锐越犹豫分布越平。这种不确定性信息对决策树非常有用CNN 犹豫的时候决策树可以依靠结构化特征做判断。结构化属性的选择要结合可获取性真实硬件项目里重量和体积来自传感器课程设计里可以用模拟值或人工标注值填充但特征必须有物理意义不能凭空造一列随机数喂进去。特征维度建议控制在 8 到 20 维之间。决策树对特征数量不敏感但无关特征太多会引入噪声让树长出没有意义的深层规则。4 个置信度加 4 到 6 个结构化属性通常是够用的。如果特征里混入了“图片文件名长度”这类垃圾字段树很有可能拿它做分裂特征并产生严重的过拟合这种事我见过不止一次。4.2 决策树训练深度、剪枝与交叉验证决策树的训练脚本和 CNN 分开写因为训练数据来源完全不同CNN 吃图片目录决策树吃的是上面 build_feature_row 生成的 Numpy 特征矩阵。给每个训练样本算好特征行之后一般还会打上标签然后交给 scikit-learn 的 DecisionTreeClassifier。核心超参是 max_depth、min_samples_leaf、criterion 和 class_weight这四项直接决定树的泛化能力。from sklearn.tree import DecisionTreeClassifier from sklearn.model_selection import cross_val_score X np.load(tree/features.npy) # shape: (n_samples, n_features) y np.load(tree/labels.npy) # shape: (n_samples,) model DecisionTreeClassifier( criterionentropy, max_depth6, min_samples_leaf10, class_weightbalanced, ) scores cross_val_score(model, X, y, cv5, scoringaccuracy) print(fCV accuracy: {scores.mean():.4f} (/- {scores.std():.4f})) model.fit(X, y) from joblib import dump dump(model, tree/tree_model.pkl)criterion 用 entropy 还是 gini 在这个任务上差别不大entropy 分出的树通常略浅更好解释。max_depth6 是经验值纯四分类任务深度 5 到 7 足够表达规则再深就是背训练集了。min_samples_leaf10 要求每个叶子节点至少命中 10 个训练样本等于强制剪枝避免出现“一个样本一条规则”的极端情况。class_weightbalanced 是针对类别不平衡的——有害垃圾样本往往远少于厨余垃圾balanced 会自动给少数类更高的权重防止树把所有样本都判成多数类。交叉验证输出的是一个均值加减标准差比如 0.87 (/- 0.03)。这个数字比单次划分的准确率可信得多因为它是 5 次不同训练验证划分的平均结果。在训练之前先做交叉验证还有一个额外好处如果 CV 分数明显低于你在 CNN 上看到的准确率说明特征拼接或者标签对齐出了问题而不是树本身不行。模型训练完成后用 joblib.dump 保存,不要用 pickle 默认格式joblib 对包含大量数组的 scikit-learn 模型序列化效率高几个量级。决策树真正的优势要在融合阶段才体现出来。光看单棵树的准确率通常比 CNN 低几个点这很正常——它手里的信息本来就少。但把它放在 CNN 后面做二次判断整个系统的鲁棒性会明显提升而不是简单地把两个模型准确率相加。4.3 融合逻辑什么时候让决策树推翻CNN的结论两个模型串起来最常见也最稳的融合方式是让 CNN 先说话决策树有否决权。具体做法图片先经过 CNN 得到 4 类置信度如果最高置信度大于某个阈值比如 0.65说明 CNN 很确定直接采用 CNN 结果低于阈值说明 CNN 自己也没谱这时候把置信度和结构化特征一起交给决策树让树采用规则作出判断。如果数据集里每条样本都能拿到结构化属性更推荐直接让决策树总做最终出口——也就是 CNN 置信度只是决策树的一列输入特征。这样 CNN 负责“提取视觉证据”决策树负责“综合所有证据下结论”整套系统只有一个判定逻辑不用维护两套阈值规则。缺点是当结构化属性缺失时决策树输入不全推理会直接报错。所以工程上要看数据完备程度属性齐全就树做出口属性经常缺失就用阈值切换方案。融合逻辑里最容易犯的错是让 CNN 的概率分布和决策树的输出重复参与决策比如 CNN 已经说了“可回收”置信度 0.9决策树也说是“可回收”然后你把两个结果再加权平均一次。这等于同一批信息过两遍模型并不会带来新信息反而把系统的行为搞复杂。正确思路是明确分工一个负责看、一个负责断中间只传递一次特征不重复投票。5. 踩坑清单垃圾分类系统最容易翻车的5个地方5.1 标签错位训练集顺序一变混淆矩阵全乱现象训练过程 loss 正常下降验证准确率却不涨或者混淆矩阵对角线上全是零预测结果整体偏移。原因ImageFolder 按目录名的字母序生成标签比如 “hazardous” 是 0、“kitchen_waste” 是 1、“other” 是 2、“recyclable” 是 3。如果代码里 num_classes 对应的类别顺序或者手工标注的特征表里类别写的是 “可回收0、厨余1、有害2、其他3”两套顺序对不上模型从第一轮就在学一套错乱的映射。解决训练前先打印 train_ds.class_to_idx 和 train_ds.classes确认标签顺序写进说明文档。所有后续环节——决策树特征表里的标签列、评估脚本里的混淆矩阵行名——都以这个 class_to_idx 为唯一基准。如果后来增删了类别目录整个标签体系要重新生成不能只新增一个文件夹就继续训练。5.2 玻璃和陶瓷互相误判CNN特征太像时怎么办现象验证集中玻璃瓶和陶瓷碗的混淆特别严重两个类别之间有 30% 以上的样本被互换。原因这两种材质在视觉上高度重合都是光滑表面、有反光、颜色偏白或透明卷积核提取到的纹理和边缘特征几乎一致。这是图像本身的歧义不是网络结构的问题。解决不要指望换更大的网络自动解决先承认视觉歧义的存在然后分两条路走。其一数据集层面在训练集里给两类分别增加带标签背景的样本比如玻璃瓶通常带塑料盖、陶瓷碗带碗沿阴影用上下文信息拉开类别差距。其二系统层面这正是决策树该介入的地方。“易碎”和“重量”两个属性可以区分大多数情况——陶瓷明显更重、玻璃更轻。把这两个属性送进决策树树的规则会学成“CNN 分不清时看重量”系统整体准确率能一下子提上去。5.3 决策树过拟合不限制深度测试集掉点明显现象训练集交叉验证分数几乎满分0.99测试集准确率却比 CNN 单独还低 10 个点。原因DecisionTreeClassifier 默认不限制深度数据样本只有几千条时树会一直分裂到每个叶子只剩一条样本把训练集的特征噪声当规律学了进去。树的深度 15 层以上的规则没有规律可言。解决把 max_depth 限制在 5 到 7min_samples_leaf 设为 5 到 10。调完立刻看交叉验证分数如果训练深度受限后 CV 分数只掉了一个点以内但测试集分数明显上涨说明原来的深度就是在硬背样本。更稳的做法是用 GridSearchCV 在 max_depth[3,4,5,6,7]、min_samples_leaf[5,10,20] 的组合里跑一遍选出 CV 分数最高的一组参数再全量训练。5.4 OpenCV读图和PyTorch训练的颜色通道差异现象用脚本跑测试单张图片时准确率突然比验证集低很多而且错的类别有规律。原因OpenCV 的 cv2.imread 读进来的图片是 BGR 通道顺序而 PyTorch 训练时用的是 RGB。训练阶段用 ImageFolder 配合 ToTensor 是正常的 RGB推理阶段用 cv2 读图直接送入模型红蓝通道互换模型看到的颜色整体偏色自然预测不准。这个坑隐蔽在颜色敏感的物体上特别致命比如有害垃圾的红色标志会变成蓝色。解决推理脚本里统一用 PIL 的 Image.open 读图它返回 RGB和训练管线一致。如果项目代码里大量用了 cv2也可以在读完立刻转换cv2.cvtColor(img, cv2.COLOR_BGR2RGB)再走 ToTensor 预处理。这个通道问题不只在 OpenCV 上有某些摄像头 SDK 也会输出 BGR接入真实设备前先用一张已知图片自测颜色是否正常。5.5 类别不平衡有害垃圾样本少模型学成“万年其他”现象总体准确率看着有 90%但打开混淆矩阵发现有害垃圾这一类的召回率只有 20%大量有害垃圾被分进“其他”。原因训练集里有害垃圾图片只有几十张其他类有上千张。CNN 学到的最省事策略是全都预测多数类因为这样 loss 整体最小决策树也会被同样的不平衡带偏。这说明“全部准确率”在不平衡数据集上没有参考价值。解决模型层面CNN 的 CrossEntropyLoss 传入 weight 参数给少数类更高的损失权重决策树设置 class_weightbalanced。评估层面不看总量准确率看每类别的查准率和召回率用 macro-F1 作为最终指标。数据层面对有害垃圾做数据增强——旋转、翻转、颜色扰动把几十张扩成几百张。这三个层面都做比单纯堆数据快得多也能避免让模型把“其他”当垃圾桶。6. 让系统更实用类别权重、置信度阈值与接口封装6.1 用混淆矩阵而不是准确率验收模型我现在的习惯是任何模型改动之后先打印归一化混淆矩阵再谈指标。普通准确率在小类别上会骗人混淆矩阵能直接看出哪个类别之间容易混淆。sklearn 里用 confusion_matrix 和 ConfusionMatrixDisplay 就能画出来。如果玻璃碗之间互相串去看决策树那层是不是“易碎”特征权重不够如果有害垃圾被分到其他去看训练集各类样本数量是否均衡。6.2 把两个模型封装成一个predict接口训练和评估都在脚本里完成但真正给外部调用时最好封装成一个干净的 predict 函数。这里的技巧是把预处理、CNN 推理、决策树推理的细节全部收进函数内部外部只传图片路径或图片数组拿到最终类别名称。这样无论是后续做 GUI 还是接摄像头都不用改这套核心逻辑。def predict(image_path, structured_attrs): img Image.open(image_path).convert(RGB) img_tensor transform_val(img).unsqueeze(0) cnn_model.eval() with torch.no_grad(): logits cnn_model(img_tensor) probs F.softmax(logits, dim1).squeeze(0).numpy() features np.concatenate([probs, np.array(structured_attrs, dtypenp.float32)]) label_idx tree_model.predict([features])[0] return cnn_model.classes[label_idx]这个接口设计的要点图片用 PIL 读入并 convert(RGB) 保证通道统一transform_val 必须和验证集用同一套预处理不能临时改尺寸或归一化结构化属性 structured_attrs 要和训练时特征顺序完全一致。漏掉其中任何一项输出的类别就会无规律漂移这类 bug 很难肉眼发现最直接的排查办法是拿训练集里的一张图片和它的原始特征跑一遍 predict和训练时的输出对比。经过这一轮搭建和踩坑垃圾分类系统基本能稳定跑通了。放下对“更深网络必然更准”的执念把 CNN 当作视觉特征提取器把决策树当作可解释的决策出口你会发现这套双模型组合在数据集不雄厚的现实条件下反而比硬上迁移学习更实用。希望这篇文章能帮你在自己的项目里少走几步弯路。本文还有配套的精品资源点击获取