简介这套资料包面向Python课程设计与毕业设计场景围绕多模态垃圾分类任务整合图像识别与文本分类流程适合计算机相关专业学生及开发者快速搭建可演示项目。压缩包共1282个文件约77.96MB核心为621个Python源码文件和544个编译后的pyc文件另含模型配置、检查点、协议定义、运行脚本等辅助内容基本覆盖数据采集、预处理、特征提取、模型训练、用户界面等分层模块方便直接运行或二次开发。随附课程设计报告与项目文档从需求分析、系统设计到实现细节均有论述能够支撑答辩讲解与文档撰写。目前已有148人学习下载资源目录结构清晰可有效降低环境配置与代码阅读成本。1. 多模态在垃圾分类里不是噱头而是兜底垃圾分类这个题目被做了太多年单张图像输入 CNN 分类器刷到 90% 出头准确率并不难难的是在课设答辩现场被问到“这两类外观几乎一样你怎么区分”。玻璃碎片和陶瓷碎片、纸盒和塑料盒、干净塑料瓶和装过农药的塑料瓶纯视觉几乎无解。这个标题给了一个更稳的答案把文本模态引进来。用户输入一句话、或者系统从类别描述库里取一条材质说明与图像特征做融合分类器多了一个决策维度很多视觉上的模糊项就有了区分依据。课程设计级别的项目多模态不必做成大模型文本侧用 CLIP 文本编码器提取特征图像侧用 ImageNet 预训练卷积网络融合后接一个分类头就能稳定跑通。这篇文章按任务定义、数据集构造、双编码器实现、训练部署、评估排错的顺序把一条能复现的完整路径拆开讲。适合正在做课程设计、毕业设计或想低成本尝试多模态融合的 Python 开发者。2. 构建多模态垃圾分类数据集类别体系与文本侧构造2.1 类别体系设计决定分类器的上限垃圾分类的数据集公开渠道能拿到的主要是两类。一类是华为云垃圾分类数据集类别覆盖较全另一类是 Kaggle 上的 Garbage Classification按单类文件夹组织做课设很方便。常见做法是拿其中一部分类目来训但这里有个容易被忽略的点类别体系要按“可回收 / 厨余 / 有害 / 其他”四大类组织正向分类到具体哪一类垃圾。模型结构上可以在输出层只输出小类别再在代码里做一个四分类映射。这个树形结构对多模态融合非常重要。比如“碎玻璃”和“陶瓷碎片”在视觉特征上高度重叠但从文本模态的角度看“玻璃透明或绿色断面光滑”“陶瓷釉面断面粗糙”这两条描述加上词嵌入后向量距离是拉得开的。模型有了这路信号就能把视觉上分不开的两类样本掰开。所以第一个动作是确定类别集合以及每个类别对应的属性描述文本而不是先去调模型。另一种常见做法是把文本侧做成开放输入即用户在界面里打字描述垃圾系统把这句话编码成特征。这个方案适合演示但不好评估因为每个用户说法不一特征空间很散。课设阶段建议做成受控模板每个类别预先写好 3 到 4 条不同的描述推理时随机取一条。模板化之后文本特征稳定后续做消融实验也容易说明“多模态比单模态到底提升了多少”。2.2 文本模态的三种构造方案与模板设计多模态分类系统的核心问题是“文本从哪来”。下面这张表列出了三种可落地的输入方案按成本和效果排序方案文本侧输入推理成本适用场景类别名编码直接输入 battery最低文本编码一次可缓存快速演示、小数据集类别属性模板圆柱形金属外壳含重金属属于有害垃圾中推理时编码一次类内差异大、易混淆类别多的场景OCR 识别包装文字从垃圾图片上截取包装上的品牌或说明文字高需要额外接 OCR 模型包装垃圾占比高的真实场景我一般会推荐第二种。理由很直接类别名编码的信息增益太小因为模型本来就要学从图像到类别的映射文本侧再喂一个类别名等于把 label 泄漏进特征模型很容易走捷径只看文本不看图。属性模板则不同它提供了“材质、形状、颜色、风险”四个维度的信息这些信息在图像里往往存在但难以被卷积网络稳定提取融合后才是真正的互补。文本模板的写法要尽量口语化且属性稳定。以“1号电池”为例模板可以是这样圆柱形干电池金属外壳两端有金属帽重量较重含重金属物质废弃后属于有害垃圾这句描述里“圆柱形”“金属外壳”是视觉可见但容易和普通金属罐混淆的属性“含重金属”“有害垃圾”是视觉不可见属性。两类属性混在一条模板里文本编码器输出的向量才能既包含外观信号、又包含类别知识。模板要覆盖类别之间的混淆关系这是设计阶段最该花时间的局部。做数据准备时建议为每个类目至少写 4 条模板并在实验记录里标注“这条模板针对哪个易混淆类目”否则后面分析多模态到底有没有起作用时很难定位。2.3 样本不均衡处理WeightedRandomSampler 与增强策略垃圾分类数据集天然不均衡。“其他垃圾”里的卫生纸、烟蒂样本量很大“有害垃圾”里的过期药品、灯管样本量很少。如果直接按原始比例采样小样本类目很难收敛。实践中有两个处理阶段第一个是采样阶段用WeightedRandomSampler做欠采样补偿第二个是训练阶段对小样本类目做更强的数据增强。import torch from torch.utils.data import DataLoader, WeightedRandomSampler # labels 是所有训练样本的类别索引num_classes 为小类别总数 class_counts torch.bincount(labels, minlengthnum_classes) class_weights 1.0 / class_counts.float() sample_weights class_weights[labels] sampler WeightedRandomSampler( weightssample_weights, num_sampleslen(sample_weights), replacementTrue ) train_loader DataLoader( datasettrain_dataset, batch_size64, samplersampler, num_workers4, pin_memoryTrue )replacementTrue表示有放回采样小样本类目的样本会在一个 epoch 内被多次抽到。num_samples保持和原始样本数一致这样每个 epoch 的迭代步数不会变。sample_weights是按样本级别计算的权重每个样本的权重是它所属类别的总权重的倒数类别样本越少单样本被抽中的概率越高。增强方面小样本类目建议单独配置一组更强的增强随机旋转 30 度、随机缩放裁剪、颜色抖动三件套。大样本类目只做随机水平翻转和归一化。原因是小样本类目容易过拟合到背景和拍摄角度而大样本类目本身分布已经够丰富强增强反而会拖慢收敛。这里有一个容易忽略的细节增强策略要按类别区分不要在所有类目上统一使用最强配置否则模型对“容易看到的类目”的区分能力会被削掉。文本模板的增强也是必要的。每条模板可以在编码前做词汇级扰动把“金属外壳”替换成“表面是金属的”把“圆柱形”替换成“圆筒状”。这个操作对提升文本侧泛化能力有直接帮助尤其是后续要用模板生成大量伪样本时。3. 双编码器结构与多模态特征融合的实现3.1 图像侧选 ResNet18文本侧用冻结的 CLIP 编码器确定了数据方案之后模型结构的选择就很关键。常见的多模态融合论文里大多数方法是用两个编码器分别抽取图像和文本特征再在特征层做拼接、加权或交叉注意力。课设项目不必要上交叉注意力双分支拼接 全连接分类头是性价比最高的方案。图像侧选择 ResNet18 而不是 ResNet50原因有两点。第一垃圾分类的类间差异主要靠颜色、纹理、形状判断不需要太深的网络ResNet18 用 ImageNet 预训练权重在小数据集上微调效果和 ResNet50 差距很小。第二课设项目通常只有一块普通显卡甚至 CPU 训练ResNet18 的前向和反向都更轻迭代速度翻倍这意味着能跑更多实验实验迭代本身就是课设质量的核心。文本侧我建议用 CLIP 的ViT-B/32文本编码器权重冻结不参与训练。冻结的理由很实在文本侧可训练的参数量并不小但训练数据只是几千条模板如果放开微调文本编码器很快会把所有模板过拟合而且会破坏 CLIP 预训练时建立的图像-文本对齐空间。冻结之后它稳定输出一个 512 维文本特征这个特征保留了“鱼骨头”和“鸡骨头”在语义空间里的距离关系而图像特征也在另一个空间里体现它们的视觉相似性最后由分类头学习两个空间的映射关系。3.2 双分支拼接融合的模型定义模型定义上有两个坑需要提前说明。第一个是 CLIP 的文本编码器输出是最后一个 token 的隐状态经 LayerNorm 后的结果在代码里对应last_hidden_state[:, 0, :]而pooler_output在 CLIP 模型里同样适用但有时会被 Transformer 的实现差异影响。稳妥做法是取last_hidden_state的第二个维度第一个 token。第二个坑是输入尺寸必须匹配预训练要求ResNet18 吃 224×224文本输入走 CLIP 的 tokenizermax_length设为 77超出部分截断。import torch import torch.nn as nn from torchvision.models import resnet18, ResNet18_Weights from transformers import CLIPTextModel, CLIPTokenizer class MultiModalWasteClassifier(nn.Module): def __init__(self, num_classes, text_feat_dim512, img_feat_dim512): super().__init__() # 图像分支提取 512 维全局特征 self.img_encoder resnet18(weightsResNet18_Weights.IMAGENET1K_V1) self.img_encoder.fc nn.Identity() # 去掉预训练分类头只保留特征 # 文本分支加载 CLIP 文本编码器并冻结权重 self.tokenizer CLIPTokenizer.from_pretrained(openai/clip-vit-base-patch32) self.text_encoder CLIPTextModel.from_pretrained(openai/clip-vit-base-patch32) for param in self.text_encoder.parameters(): param.requires_grad False # 融合分类头1280 维输入 - 256 维 - 类别数 self.classifier nn.Sequential( nn.Linear(img_feat_dim text_feat_dim, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, images, input_ids, attention_mask): img_feat self.img_encoder(images) # [B, 512] text_feat self.text_encoder( input_idsinput_ids, attention_maskattention_mask ).last_hidden_state[:, 0, :] # [B, 512] fused torch.cat([img_feat, text_feat], dim1) # [B, 1024] return self.classifier(fused)拼接层的维度是 512 512 1024中间加了一个 BatchNorm1d 而不是直接接 ReLU。原因是图像特征来自 ImageNet 预训练空间文本特征来自 CLIP 空间两个空间的量纲和分布差异很大BatchNorm 可以把拼接后的分布拉回到相近尺度让后面的全连接层更容易收敛是一个非常省事的融合技巧。last_hidden_state[:, 0, :]这一步在训练阶段会通过梯度回传到文本编码器但因为文本编码器权重全部requires_gradFalse没有梯度更新所以推理和训练行为一致这也让后面的 ONNX 导出更干净。3.3 损失函数与优化器设置分类头输出层的设计建议保持线性输出不接 softmax。原因有两个nn.CrossEntropyLoss内部已经包含 log_softmax如果先用 softmax 再传入 loss数值稳定性和梯度传播都会受影响后续要加温度缩放做置信度校准也要求模型输出原始 logits。损失函数除了常规的交叉熵Logit Adjustment 是一个值得放在课设报告里的改进。核心思路是样本量少的类别在最终 logits 上加上一个类别先验补偿项让模型不再单纯压低小样本类别的输出概率。实现很简单但要在加分类头之前对 logits 做修正。import torch.nn.functional as F # logits: [B, num_classes], labels: [B] # class_prior: 每个类别的先验频率加了平滑避免 log(0) logit_adjust torch.log(class_prior 1e-8) * 0.1 adjusted_logits logits logit_adjust loss F.cross_entropy(adjusted_logits, labels)class_prior直接用训练集里每个类别的样本数除总样本数得到。系数 0.1 是正则强度太大整体准确率会被拉低太多太小等于没加。这个技巧在垃圾分类数据集上的典型效果是整体准确率几乎不变但是有害垃圾这一类的小类目召回率提升 5% 到 8%。优化器选择 AdamWlr1e-4weight_decay0.05。图像分支的骨干网络可以设置一个更小的学习率例如1e-5因为预训练权重已经足够好微调用太大的学习率容易破坏原有的特征提取能力。分类头和新增的 BatchNorm 用默认的1e-4。一个 epoch 后开始用余弦退火调度最小学习率降到初始的十分之一。4. 训练循环、置信度校准与 Python 端的部署导出4.1 训练循环里的验证流程与模型保存策略训练循环的骨架并不复杂但有几个细节直接影响最终效果。每一轮训练结束后必须做的是在验证集上计算准确率和损失同时保存验证集最优的权重文件。不要只保存最后一个 epoch 的模型因为余弦退火后期验证集指标可能出现小幅度抖动最优模型往往不是最后一个。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for images, input_ids, attention_mask, labels in loader: images, input_ids images.to(device), input_ids.to(device) attention_mask, labels attention_mask.to(device), labels.to(device) optimizer.zero_grad() logits model(images, input_ids, attention_mask) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * labels.size(0) correct (logits.argmax(dim1) labels).sum().item() total labels.size(0) return total_loss / total, correct / totalcriterion是在外部包好的 Logit Adjustment 损失函数传入model前向输出即可。optimizer.zero_grad()必须在loss.backward()之前调用否则梯度会在每个 batch 之间累积。loss.item() * labels.size(0)是为了按样本数加权平均 loss避免最后一个 batch 因长度不同而影响平均值的准确性。一个常见失误是把model.eval()和torch.no_grad()忘记加上导致验证阶段 BatchNorm 的均值和方差统计被错误的 batch 数据污染。验证时一定要显式切换模式并且验证集和训练集不要混用同一份数据。4.2 温度缩放让低置信度样本可被发现垃圾分类系统在答辩现场被问得最多的一个问题是“模型碰到没见过的垃圾怎么办”。这是所有闭集分类器的硬伤但可以使用温度缩放做置信度校准让模型在不确定时给出相对较低的置信度分数而不是硬输出一个高置信度的错误类别。温度缩放本质上是在训练结束后把 logits 除以一个可学习的温度 T。T 大于 1 时logits 被压缩softmax 输出的分布更平滑所有类别的置信度整体降低T 小于 1 时分布变尖锐模型会更“自信”。整个过程中模型权重不变只调 T。class TemperatureScaler: def __init__(self, devicecpu): self.T torch.tensor(1.0, devicedevice, requires_gradTrue) self.device device def fit(self, model, val_loader, criterion): optimizer torch.optim.LBFGS([self.T], lr0.01, max_iter100) model.eval() for _ in range(50): def closure(): optimizer.zero_grad() total_loss 0.0 n 0 for images, input_ids, attention_mask, labels in val_loader: images images.to(self.device) input_ids input_ids.to(self.device) attention_mask attention_mask.to(self.device) labels labels.to(self.device) with torch.no_grad(): logits model(images, input_ids, attention_mask) scaled_logits logits / self.T loss criterion(scaled_logits, labels) total_loss loss.item() * labels.size(0) n labels.size(0) loss.backward() return total_loss / n optimizer.step(closure) return self.T.item()LBFGS 优化器适合这种参数只有一个标量的场景收敛快且不需要调整学习率。with torch.no_grad()包住模型前向保证梯度不回传到模型权重只对 T 生效。温度 T 训练完成后保存下来推理时对分类头的输出统一除以这个值。评判校准效果的方法很简单计算所有正确分类样本的平均置信度和所有错误分类样本的平均置信度差距越大说明模型在错的时候越“犹豫”。4.3 导出 ONNX 并用 Python 推理脚本加载课设项目的交付物通常包含一个可运行的 Python 推理脚本但实际答辩现场机器可能没有训练环境甚至没有 GPU。常见做法是导出 ONNX 格式用onnxruntime在 CPU 上跑推理这样整个部署环节只有两个依赖包onnxruntime和numpy。导出时的关键点在于把图像编码器和文本编码器合成一个导出单元。最稳妥的方案是在导出前把文本模板预先编码成 embedding 并缓存成文件推理时直接读 embedding不用再加载 CLIP tokenizer。这样导出模型只接收两张输入[B, 3, 224, 224]的图像张量和[B, 512]的文本特征张量。pip install onnxruntime onnxmodel.eval() model.cpu() single_img torch.randn(1, 3, 224, 224) single_txt torch.randn(1, 512) torch.onnx.export( model, (single_img, single_txt), waste_classifier.onnx, input_names[image, text_embedding], output_names[logits], dynamic_axes{ image: {0: batch}, text_embedding: {0: batch}, logits: {0: batch}, }, opset_version13 )dynamic_axes把 batch 维度标记为动态这样推理时可以一次传入一张或多张图片不需要重新导出。opset_version13是 onnxruntime 兼容性最好的版本更高版本在部分老旧 CPU 上可能出现算子不支持的问题。导出后可以顺便跑一次推理确认输出的 logits 和 PyTorch 原始输出在误差范围内再加一行断言兜底。推理脚本侧的加载与调用如下import numpy as np import onnxruntime as ort sess ort.InferenceSession( waste_classifier.onnx, providers[CPUExecutionProvider] ) # image_tensor: (1, 3, 224, 224) 的归一化图像 # text_embedding: (1, 512) 的文本特征 outputs sess.run( [logits], { image: image_tensor.numpy(), text_embedding: text_embedding.numpy(), } ) logits outputs[0] prob 1.0 / (1.0 np.exp(-logits / temperature)) pred int(np.argmax(prob))文本 embedding 的缓存逻辑是用训练阶段同样的 CLIP tokenizer 对 3 到 4 条模板分别编码取平均后保存为.npy文件。推理时只做一次np.load。温度temperature是上一节拟合出的缩放系数在 numpy 侧计算时用 sigmoid 而不是 softmax因为导出的是 logits 而不是概率分布。5. 评估口径与部署过程中的三个典型坑5.1 用宏平均和混淆矩阵评估课设效果单看整体准确率不足以说明多模态有效因为大类样本占比高模型把所有样本猜成“可回收垃圾”也能刷到 70%。课设报告中建议补充三组数据每组类别上的精确率和召回率、四分类映射之后的混淆矩阵、以及单模态基线与多模态结果的准确率差值。from sklearn.metrics import classification_report, confusion_matrix # all_labels: 测试集真实类别索引 # all_preds: 模型预测类别索引 report classification_report( all_labels, all_preds, zero_division0, target_names[可回收, 厨余, 有害, 其他] ) print(report) cm confusion_matrix(all_labels, all_preds) print(cm)zero_division0可以避免某类预测数量为零时 sklearn 抛出的除零警告。混淆矩阵的输出重点看两个地方一是“其他垃圾”被误判成“可回收垃圾”的数量这个错误在实际中代价较大二是“有害垃圾”的召回率低于 0.85 说明文本侧没有发挥应有作用优先检查模板是否覆盖了有害垃圾的特性描述。单模态对齐实验的做法是在同一套数据划分下训练一个只输入图像的 ResNet18 分类器记录准确率、宏平均 F1和多模态模型做对比。差异值建议写进课设报告的开头摘要这是“多模态到底有没有用”的最直接证据一般来说差值在 3% 到 6% 之间属于正常范围超过 10% 需要检查实验设置是否有问题。5.2 部署中的三个典型坑第一个坑是图像 resize 和归一化参数在推理脚本里和训练时不一致。训练时用了均值[0.485, 0.456, 0.406]、标准差[0.229, 0.224, 0.225]推理脚本如果直接把 PIL 图像转成张量塞进来输入分布完全错位模型表现可能直接掉到随机水平。解决办法是把预处理函数也写进一次导出流程里用同样的参数定义统一入口。第二个坑是混合精度训练后 ONNX 导出报错。PyTorch 的torch.cuda.amp自动混合精度在训练时可以提升速度但半精度权重导出到 ONNX 后某些版本的 onnxruntime 对 FP16 算子支持不完整推理结果会静默出错。方案是训练时用混合精度、导出前再model.float()转回 FP32。第三个坑是类别映射不一致。训练数据集的类别索引可能在多次训练之后发生变化比如把“烟蒂”从“其他垃圾”移到“可回收垃圾”如果推理脚本里使用硬编码的类别名列表一旦没同步就没有任何报错只会整体错位。建议做法是把类别名列表保存成 JSON 文件放在模型权重旁边推理脚本启动时加载不手写。最后再留一个答辩场景的技巧在多模态推理脚本里加一个top_k置信度打印功能给出每个候选类别及对应置信度列表。用户输入一张图片和一段文字后系统同时打印 Top-3 结果和每个类别的置信度数字这比只输出一个标签更有说服力评委也能直观看到多模态融合特征在实际推理中的分布情况。本文还有配套的精品资源点击获取