简介:这是一份基于Python的虚假新闻多模态识别项目,面向需要完成课程设计、期末大作业或入门多模态深度学习的高校学生与开发者。项目结合文本与视觉等多源信息,通过预训练模型与轻量梯度提升机、类别提升等融合策略判断新闻真伪,完整覆盖数据处理、特征工程、模型训练与推理流程,适合作为高分课程设计和期末大作业的参考基线。压缩包共39个文件,包括16个Python脚本(模型主程序、融合脚本与预测工具)、1个交互分析笔记、4个文档说明、4个数据配置、3个一键运行脚本、3个数据文件、2个配置文件以及训练日志等,整体仅353KB,目录结构清晰。代码注释详细,小白也能理解,并配有文档说明环境配置、数据集格式与模型结构,下载即可运行。目前已有389人学习/下载,具有较高参考价值,可继续优化特征或替换网络结构进行二次开发。
1. 为什么用Python做虚假新闻多模态识别:不只是把图文拼在一起
很多人第一次听到“虚假新闻多模态识别”这个标题,下意识会把它当成一个图文二分类问题:标题、正文、配图一起丢进模型,输出“真”或“假”。真做下去你会很快发现,难点根本不在分类器,而在“图文之间到底对不对得上”。一条新闻的文本可能四平八稳,但配图里的人名、路牌、拍摄时间与文本产生冲突;反过来,文本和图片单独都真实,组合到一起却构成误导。多模态识别要做的,是把文本描述的事实与图像呈现的细节拉进同一个语义空间里做交叉验证,而不是做一次简单的拼接分类。
这篇笔记写给正在准备课程设计、毕业设计或入门级科研的Python开发者。整个过程我会按“模型选型 → 数据准备 → 训练代码 → 典型踩坑 → 交付演示”的顺序展开,你可以直接沿用这里的网络结构、参数和脚本骨架,再换自己的数据集落地。只要Python基础还顺、会基本的PyTorch API,就能完整跟上。
2. 多模态识别模型怎么选型:融合方式与基座模型的取舍
2.1 先想清楚:多模态识别到底在比对的哪两个“模态”
多模态这个词听起来复杂,落到虚假新闻检测里,底层逻辑只有两条路线。第一条是实体对齐:文本中出现的人名、地名、组织名,与图像里的人脸、车牌、建筑做一一对应。这条路的维护成本极高,因为新闻配图经常不是实拍而是插画、存档图或二次裁剪,实体根本对不上;一旦对不上,整条模型就会直接失效。
第二条是语义一致性:把整段文本和整张图片分别编码成向量,然后让模型自己学习“什么样的图文搭配是合理的”。这个方法不需要显式抽取实体,训练数据只需要成对的图文加一个真假标签。目前学术论文和多模态检测竞赛里的主流方案,都是走“独立编码 + 融合分类”这条路,原因很简单——它的泛化能力比实体对齐好,Python生态里也最容易复现。
既然主线已经定了,真正需要做决策的只剩下三点:融合方式、基座模型和融合层的结构。融合方式上,常见的有早期融合、中期融合和晚期融合。早期融合在输入层直接把文本向量和图像向量拼起来,实现最快,但两种模态的特征空间差异很大,拼接后训练困难;晚期融合在池化之后拼接特征,训练参数最少,适合小数据量;中期融合在编码器中间层做交叉注意力,效果最强,代价是显存占用、训练时间都成倍上涨。
我的建议是,先默认选择晚期融合,把项目跑通拿到基线分数,再去试中期融合。因为虚假新闻检测的数据集普遍不超过十万条,晚期融合在数据量上的劣势不明显,但带来的稳定性和代码可读性收益非常大。很多初学者一上来就参考最新论文堆了一堆注意力模块,结果数据量根本喂不饱,训练集都没收敛,反而被一个简单拼接模型超过。
2.2 文本分支与图像分支的基座:从BERT到CLIP
选定融合方式后,下一步是选基座模型。文本端我几乎无脑选BERT,而不是更大的RoBERTa或ELECTRA。原因是虚假新闻的正文通常很短,标题加摘要往往不到两百个token,超大模型在这里发挥不了容量优势,反而会因为训练样本少而快速过拟合。我用transformers库加载BertModel,输出last_hidden_state,取[CLS]位置向量作为整段文本的表示。文本最大长度我固定为128,超出截断、不足补齐,这个值在多个数据集上比512更稳,训练速度还要快一倍。
图像端有两条路线:一条是用torchvision里的ResNet50或ViT做视觉编码器,另一条是用OpenAI的CLIP视觉塔。实测下来CLIP视觉塔明显更好,因为CLIP在预训练阶段就已经对齐了图文语义,它对画面内容的抽象能力比纯ImageNet分类模型强很多,也非常契合“文本图像配对干不干净”这个检测目标。代价是CLIP的输入分辨率通常是224×224,部分版本要求336×336,显存占用会跟着上涨。
如果手头只有一块消费级显卡,我建议用openai/clip-vit-base-patch32这个规格,输入224分辨率。它的参数规模不大,单卡8G显存也能跑起来;如果换成更大规格的CLIP变体,就要考虑冻结图像编码器,否则一个batch就要占掉4G以上显存。
2.3 融合层和分类头:注意力池化为什么比简单拼接稳
拿到文本向量和图像向量后,最简单粗暴的做法是torch.cat拼接,再过一个线性层。我在前面说过这个方案能跑,但不稳。因为拼接后的向量各管各的,模型在反向传播时倾向于从信号更强的模态里学特征,弱的那个几乎变成噪声。具体表现是训练集收敛很快,验证集指标波动很大,换一批数据后掉点严重。
更稳的方案是在两个向量之间加一层跨模态注意力。以文本向量作为Query,图像向量作为Key和Value,让模型动态决定“这条新闻里,图像到底给文本提供了多少有效证据”。实现上用nn.MultiheadAttention(embed_dim=768, num_heads=1, batch_first=True),关键参数是num_heads=1。这里关注的是对齐单一语义,不需要多头的多关系建模;头数多了反而容易学到随机相关性,在数据量不足时过拟合。
融合层的其他参数我比较固定:注意力输出后接一个Dropout(0.1),分类头是一个Linear(768→2)。为什么不接隐藏层?因为融合向量本身已经过一层注意力,维度也足够高,再接隐藏层只会增加过拟合风险,尤其当你的训练集只有几千条时。注意力层的计算量其实不大,参数集中在Query、Key、Value三个投影矩阵和输出投影矩阵上,整体规模约等于一个小的全连接块,所以它对训练速度的拖累可以忽略。
2.4 损失函数与批次采样:类别不平衡怎么调配
虚假新闻的数据集天然不平衡,真实新闻往往占大头。如果直接用默认的CrossEntropyLoss,模型很快会发现“全预测真实”也能拿高准确率。中文项目里我一般分两步处理。第一步是给损失函数传入类别权重,按样本数反比计算;第二步是训练时用WeightedRandomSampler控制每个batch里两个类别的比例,让少数类不会被多数类淹没。
优化器我选AdamW,初始学习率2e-5,配合线性预热和余弦退火。这里有一个必须注意的细节:BERT和CLIP是预训练权重,融合层和分类头是随机初始化,两者的学习率不能一样。我的做法是用param_groups分开,预训练部分用主学习率十分之一,融合层用1e-4。很多初学者图省事全网络一个学习率,结果预训练权重被粗暴更新,训练集上损失飞速下降,验证集上却完全没泛化,这就是典型的“自欺欺人式训练”。
批次大小也需要单独说。batch size取16还是32,对多模态项目的影响比单模态更大,因为图像分支的batch normalization和注意力层对batch统计量敏感。数据量小就用16,显存也紧张;数据量大可以试着32,但要注意学习率同步调整:batch翻倍,学习率也跟着乘以1.4左右,否则收敛速度会明显变慢。如果你的显卡实在跑不动32,就用16加梯度累积两步,效果接近但更省显存。
还有一个经常被忽略但很实用的细节:不要在样本数极少的小数据集上使用完整BERT加CLIP的参数量。如果你手里只有几百条样本,要么把全部预训练层冻结,只训练融合层和分类头,要么改用一个更小的文本模型和一个更小的图像塔,否则训练结果的方差会大到让你怀疑人生。
2.5 要不要套用多模态大模型:基线思维和可交付性
这个标题下的项目,常常有人会问:为什么不直接调用一个大模型的多模态接口?答案是:那些在评估集上分数很高,但在课程设计、竞赛或真实部署场景里,你拿不到源代码、无法自定义数据格式,也没有稳定的推理资源。项目要求的是“源代码+文档说明”,说明这条路要能本地跑通、能修改、能复现,而不是黑匣子式的API调用。
我习惯先把一个简单拼接模型作为基线跑通,记录F1分数,再逐步加注意力模块和更复杂的融合方式。如果新增模块带来的增益小于一个点,说明它在你的数据上没有贡献,不要为了结构好看而保留。最后再确认这层收益是不是来自随机种子——多跑两三次训练,观察F1的标准差,如果波动超过三个点,结果基本不可信。这个基线思维能让你在评审时站得住脚,而不是靠一次运气好的运行结果。
3. 把数据集整理成模型能吃的格式:图文对构建与预处理
3.1 数据集怎么选:Fakeddit与自己构建的三点建议
开源数据里,最常用的英文多模态假新闻数据集是Fakeddit,它包含了数十万条Reddit帖子及配图,标签从二分类到六分类都有。另一个常见的基准是MediaEval的多媒体验证任务,重点检测“图像是否被挪用到另一条新闻场景中”。这两个数据集都适合用来验证模型结构是否正常工作,但有一个共同问题:它们的图像链接很多已经失效,下载阶段必须做好充分的容错处理。
中文场景目前没有特别标准的开箱即用多模态新闻数据集,所以很多课程设计会自己构建。自己构建时我一般给三条硬性建议。第一,每条样本保留原始发布时间,因为旧图新用、时间错位是虚假新闻的典型手段;第二,不要把图像链接存进训练集,要下载到本地统一管理,否则训练时网络波动会把整个DataLoader卡住;第三,每个样本至少包含“标题 + 正文前几句 + 配图”,只拿标题和配图很难让模型学到有用的证据。
数据量方面也有一个大致参考:图文对少于5000条,神经网络基本学不出稳定的跨模态关系,不如退回到单模态文本基线;超过两万条,融合层的收益才会明显体现。如果你的数据量偏小,可以考虑用预训练CLIP对图文对算相似度分数,把它作为额外特征喂给分类器,这种半监督的做法在小数据集上意外地有效。
标注质量也是自己构建数据集时最容易翻车的点。两条新闻文本相似但配图不同,算不算同一类?配图真实但文本事实存疑,该怎么标?我一般定一套简单的标注规则:图片与文本存在明显矛盾,标虚假;图片真实但文本内容无法核实,标真实但保留到低置信度集合;完全无法判断的样本直接丢弃。规则越简单,标注一致性越高,模型训练越稳定。
3.2 图文对构建:JSONL格式与图像下载脚本
数据预处理第一步,是把零散的抓取结果变成标准的JSONL文件。我常用的字段设计是id、text、image_path、label,其中text由标题和正文摘要拼接,image_path指向本地文件,label为0或1。不要贪图方便把图片存成base64塞进JSONL,文件体积会膨胀到几GB,训练时每次读取都要解码,效率低得难以接受。
import json import os import requests from PIL import Image def build_jsonl(entries, out_path, image_dir): os.makedirs(image_dir, exist_ok=True) with open(out_path, "w", encoding="utf-8") as f: for i, entry in enumerate(entries): img_path = os.path.join(image_dir, f"{entry['id']}.jpg") try: r = requests.get(entry["image_url"], timeout=(3, 7)) with open(img_path, "wb") as fp: fp.write(r.content) with Image.open(img_path) as im: if min(im.size) < 224: continue im.convert("RGB").save(img_path) except Exception as e: print(f"[skip] {entry['id']}: {e}") continue item = { "id": entry["id"], "text": entry["title"] + "\n" + entry["summary"], "image_path": img_path, "label": entry["label"], } f.write(json.dumps(item, ensure_ascii=False) + "\n")这段脚本的逻辑是:先下载图片,再用PIL检查图像能否正常解码、尺寸是否达到224像素,满足条件的转成RGB后覆盖保存,不满足的直接跳过整条样本。timeout=(3, 7)指的是连接等待3秒、数据包间隔7秒,避免某个站点响应太慢拖垮整个下载流程;im.convert("RGB")会去掉透明通道和异常色彩空间,统一三通道格式,避免后续训练时通道数不一致的报错。
跑完之后,你得到的JSONL每行是一条干净的样本,图片全部在本地。这里我要强调一个隐蔽问题:requests.get默认不检查页面返回的类型,很多站点对404请求也会返回内容为HTML的200响应。PIL.Image.open能拦截大部分坏图,但如果图片下载下来是WebP等非常见格式,convert("RGB")虽然能转换,但早期数据质量会很差,建议在下载后把图片统一重采样成RGB的JPEG,顺便降低后续的IO开销。
3.3 文本清洗与图像增强:克制比花哨更重要
多模态数据增强的目标不是给模型制造更多噪声,而是让它忽略无关的变化。图像端,我用随机水平翻转、随机尺寸裁剪和轻度的颜色抖动,幅度都控制得很小。原因很简单:虚假新闻检测中,配图的某些细节本身就是判别证据,比如路牌上的地名、时间戳上的日期。如果增强把关键部分裁剪掉或模糊掉,模型不仅学不到这个线索,还可能学会把模糊图像和“假新闻”关联起来,这是一个非常大的坑。
文本端增强同样要克制。常见做法包括对实体词做随机掩码,模拟转述差异,或者用机器翻译做回译,生成另一种表述。但回译在中文新闻里容易把专有名词译错,反而引入噪声。我自己的项目里,文本端只做一种增强:正文超过128个token时,随机截断成两个不同的片段作为两条训练样本,相当于变相扩充了语料变化。
验证集上不要做任何随机增强,只做固定大小的Resize((224, 224))和Normalize。这一点看似简单,实际操作中很容易遗忘。如果你把增强同样用到验证集,每个epoch的验证指标都会受随机性影响,模型到底有没有进步你根本判断不出来,最后浪费大量调参时间。
3.4 DataLoader的collate_fn:把变长文本和图像统一成批次
多模态训练的DataLoader,难点在于批次内的长度对齐。文本端经过tokenizer后,input_ids长度各不相同;图像端尺寸常用固定224,反而简单。所以我自定义了一个collate_fn,在批次内把文本padding到本批次最大长度,然后统一堆叠。
from torch.nn.utils.rnn import pad_sequence import torch def collate_fn(batch): texts = [item["input_ids"] for item in batch] masks = [item["attention_mask"] for item in batch] images = [item["image_tensor"] for item in batch] labels = [item["label"] for item in batch] texts_padded = pad_sequence(texts, batch_first=True, padding_value=0) masks_padded = pad_sequence(masks, batch_first=True, padding_value=0) images_tensor = torch.stack(images, dim=0) labels_tensor = torch.tensor(labels, dtype=torch.long) return { "input_ids": texts_padded, "attention_mask": masks_padded, "image_tensor": images_tensor, "label": labels_tensor, }pad_sequence把长度不一的tensor补到本批次最长,padding_value=0正好对应BERT的[PAD]标记。图像已经预处理为相同尺寸,可以直接torch.stack。如果某张图因为增强变成了不同尺寸,stack会直接报错,这个报错实际上是在提醒你数据加载链路出了问题。labels使用torch.tensor(..., dtype=torch.long),配合CrossEntropyLoss正好。
提示:这个
collate_fn里没有处理“文本长度算出来是0”的情况,建议在数据集类的__getitem__里增加防御,万一样本正文为空,就返回一个只含[CLS]的序列占位,避免批次堆叠时报错。
别忘了给DataLoader设置合理的num_workers。图像IO是多模态训练的瓶颈,num_workers=0会让GPU频繁空转,num_workers=4或8能明显提速。Windows系统下如果num_workers大于0报错,请把主要逻辑放进if __name__ == "__main__":保护块,这是Python多进程在Windows下的经典限制。
4. 用PyTorch训练多模态假新闻分类器:核心代码与关键参数
4.1 定义多模态分类模型:冻结预训练权重,放开融合层
这一节开始,全部用可直接运行的PyTorch代码来推进。先定义模型:文本端用BertModel,图像端用CLIP视觉塔,融合层是前文提到的跨模态注意力,最后接分类头。预训练部分默认冻结,只有融合层和分类头可训练。
import torch import torch.nn as nn from transformers import BertModel, CLIPModel class MultimodalFakeNewsModel(nn.Module): def __init__(self, freeze_text=True, freeze_image=True): super().__init__() self.bert = BertModel.from_pretrained("bert-base-chinese") self.clip = CLIPModel.from_pretrained("openai/clip-vit-base-patch32") self.image_proj = nn.Linear(512, 768) self.cross_attn = nn.MultiheadAttention(768, num_heads=1, batch_first=True) self.dropout = nn.Dropout(0.1) self.classifier = nn.Linear(768, 2) if freeze_text: for p in self.bert.parameters(): p.requires_grad_(False) if freeze_image: for p in self.clip.parameters(): p.requires_grad_(False) def forward(self, input_ids, attention_mask, image_tensor): text_vec = self.bert(input_ids, attention_mask=attention_mask).last_hidden_state image_vec = self.clip.get_image_features(pixel_values=image_tensor) image_vec = self.image_proj(image_vec).unsqueeze(1) attn_out, _ = self.cross_attn(text_vec, image_vec, image_vec) text_vec = text_vec[:, 0, :] attn_out = attn_out[:, 0, :] fused = torch.cat([text_vec, attn_out], dim=-1) fused = self.dropout(fused) logits = self.classifier(fused) return logits这段代码里有几个必须说明的点。BertModel的last_hidden_state是(B, seq_len, 768),CLIP的get_image_features返回(B, 512),两个维度不匹配,所以中间用image_proj把512维转成768维。nn.MultiheadAttention在batch_first=True时输入要求(B, L, E),所以image_vec要补一个维度变成序列长度1。前向的最后取各自序列的第一个位置,也就是[CLS],拼接后过分类头。
注意:第一次跑通前,先用一个假batch测试forward的输出维度是不是
(B, 2),而不是直接进训练循环。维度错误越早暴露,后面排错越省时间。
freeze_text和freeze_image两个开关建议保留成类参数,方便做消融实验。你可能需要冻结文本端但放开图像端,看看单模态表现;也可能两个都放开做全量微调。把这些做成开关而不是写死在初始化里,代码会灵活很多。
4.2 训练脚本骨架:分段学习率与梯度裁剪
模型定义好后,最关键的是优化器配置。我使用带param_groups的AdamW,给融合层和预训练层分配不同学习率。预训练部分用主学习率的十分之一,融合层从1e-4起步。
from transformers import AdamW, get_linear_schedule_with_warmup pretrained_params = [] fusion_params = [] for name, param in model.named_parameters(): if not param.requires_grad: continue if "classifier" in name or "cross_attn" in name or "image_proj" in name: fusion_params.append(param) else: pretrained_params.append(param) optimizer = AdamW([ {"params": fusion_params, "lr": 1e-4}, {"params": pretrained_params, "lr": 2e-5}, ])这里的划分思路是:融合层和分类头是随机初始化的,需要大步长快速收敛;预训练权重已经有了很好的语义空间,学习率过大很容易破坏它。实际训练中,如果验证集F1不涨,我一般先把pretrained_params的学习率降到1e-5试一次,而不是直接调大整体学习率。
梯度裁剪同样要加上,在optimizer.step()之前执行:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)多模态项目里,图像端的梯度范数波动比纯文本模型大得多,经常在几步之内把loss冲成NaN。梯度裁剪不会显著拖慢训练,但能防止训练中途翻车后一切重来。我的建议值是1.0,如果你的模型参数规模更大,可以放松到2.0,但不要完全不设。
还有学习率调度器,简单用get_linear_schedule_with_warmup,设置预热比例为总步数的10%。先小步走再正常走,能让多模态模型在早期避免指标震荡,实测对稳定性很有帮助。训练循环骨架基本固定:
for epoch in range(epochs): for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) image_tensor = batch["image_tensor"].to(device) labels = batch["label"].to(device) optimizer.zero_grad() logits = model(input_ids, attention_mask, image_tensor) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step()每两个epoch在验证集上过一次F1,保存F1最高的检查点作为最终模型。不要在训练集loss降到最低时保存,那是过拟合的开始,而不是模型的最佳状态。
4.3 混合精度训练:把显存占用降一半的边界条件
如果你的电脑用的是英伟达显卡,可以直接上混合精度。PyTorch 2.x的写法是torch.autocast配合GradScaler。训练循环里有三处需要改动:forward和loss计算包在autocast里,反向传播换成scaler.scale(loss).backward(),参数更新换成scaler.step(optimizer)和scaler.update()。这套组合能把显存占用降低40%左右,训练速度也有提升。
但混合精度有边界条件。文本的input_ids进入nn.Embedding时必须保持torch.long,不能被autocast转成半精度。图像tensor可以先.cuda()再送进CLIP,CLIP内部会自行处理。nn.MultiheadAttention在fp16下偶尔不稳定,我的做法是在进入注意力层之前把两个向量显式转回.float(),等注意力输出后再转回fp16,虽然损失一点速度,但训练过程稳定很多。
混合精度不是银弹。如果你的显存大于24G,训练集又只有几千条,那直接用fp32训练更省心。混合精度的收益主要在“显存挤”且“数据量大”时体现。另一个判断标准是观察loss曲线:如果fp32下loss正常下降,fp16下却反复波动,先回退到fp32确认代码没有隐藏bug,再尝试修注意力层。
4.4 把训练好的模型保存成完整交付物,而不是一个权重文件
训练完成后,很多同学只保存一个model.pt交差。这个习惯在多模态项目里非常危险。推理时需要重新加载BertModel和CLIPModel的config、分词器、图像transform、类别映射,缺一环整个推理结果就错位。
我的保存方案是把模型、tokenizer、transform和配置都放到同一个目录下:
torch.save(model.state_dict(), os.path.join(save_dir, "model.pt")) tokenizer.save_pretrained(save_dir)对于自定义的nn.Module,还要单独用json.dump把freeze_text、freeze_image、max_length、image_size这些变量记录在config.json。推理时先读这个文件实例化模型,再加载权重。这样你换一台电脑,只要装好requirements,一分钟内就能把模型拉起来。
5. 多模态虚假新闻检测的避坑指南:5个典型踩坑记录
这部分内容来自多个项目反复踩过坑之后的经验汇总,每一条都按“现象 → 原因 → 解决”的格式描述。
5.1 图像链接下载一半失效,DataLoader卡死不动
现象:训练第一个epoch正常,第二个epoch开始后进程忽然卡住,CPU和GPU的使用率都接近0,命令行也不报错。
原因:图片URL来自不同站点,有些源站响应特别慢,collate_fn在读取图片时被阻塞;更常见的是某个URL访问时一直返回200,但内容传不完,requests.get没有限制,会一直等下去。
解决:下载脚本里把超时设置为timeout=(3, 7)。同时把下载结果校验放在数据准备阶段,而不是训练阶段;凡是校验不过的样本直接丢弃。另外一个实用技巧是把所有图片统一重采样成固定尺寸的JPEG,保存到同一个目录,训练时只做简单的Resize和Normalize,减少不必要的IO开销。
5.2 某个样本文本为空或图像损坏,batch运行时炸掉
现象:训练跑到第20个batch,突然抛RuntimeError: stack expects each tensor to be equal size,但前19个batch都正常。
原因:文本经过tokenizer后有极小概率长度是0,图像在增强时有极小概率变成单通道,或者缩到0尺寸。这些都在collate_fn的stack环节才暴露,但问题根子在数据生成阶段。
解决:在数据集的__getitem__里增加防御逻辑。文本长度为零时,用[CLS]填充占位;图像通道数不等于3时,强制convert("RGB")。这不算冗余,而是多模态项目结构性的必需品。更彻底的做法是在build_jsonl阶段把坏样本全部排除,确保每一行数据进入训练前都经过验证。
5.3 模型学到“来源域名”的捷径,换领域就翻车
现象:训练集准确率95%,验证集也有90%,但换一批别的来源新闻测试,准确率掉到60%。
原因:数据里隐含了“某些域名总是发假新闻”的强特征。模型一眼捕捉到域名、发布者名这些元数据,于是不再关注图文内容。很多回复文本里保留了“据XX网报道”这类字眼,模型一看到就倾向输出某一类标签。
解决:在构建训练集时把域名、作者名、发布时间等元数据从文本中全部剔除。如果仍想保留来源信号,可以转成匿名化的[SRC]占位符。此外,要保证训练集和验证集来自不同来源,避免评估时露馅。类似的做法是采取分组划分,同一来源的全部样本只进训练集或只进验证集,不要混在一起。
5.4 冻结策略不对,loss在训练中变成NaN
现象:训练到第5个epoch,loss突然变成NaN,之后再也回不来,只能重跑。
原因:最常见的是预训练部分学习率设得太大。BERT顶层参数更新太猛,在数据量少的时候很容易产生梯度爆炸;另一种原因是混合精度下某个算子溢出,导致梯度为无穷。
解决:先跑一个“单batch过拟合测试”,确认loss能降到接近0,再逐步放大数据量和学习率。梯度裁剪的max_norm从1.0改为0.5,能进一步压低爆炸概率。如果训练集里只有少量样本属于某一类,建议在WeightedRandomSampler里保证每个batch至少出现一个这样的样本,否则它的梯度会被多数类淹没,数值稳定性会更差。
5.5 准确率85%但F1很低,你是被准确率骗了
现象:模型报告准确率85%,看起来合格,但看混淆矩阵,虚假新闻的召回率只有20%。
原因:数据集中真实新闻占90%,模型只要全预测真实就能拿90%准确率,它确实这么干了。这属于非常经典的“准确率陷阱”,尤其在类别不平衡的虚假新闻场景里几乎必然出现。
解决:训练阶段用F1或AUC做早停指标,不要看准确率选模型。给CrossEntropyLoss加类别权重,把少数类权重调到2到3倍。推理的时候不要直接取argmax(logits),改成先用sigmoid输出概率,再在验证集上搜一个最佳阈值,比如0.6,往往能同时提高精确率和召回率。把这三步改完之后再比较模型表现,你才是在做多模态检测,而不是在做卖地盘策略。
6. 从训练到交付:把模型封装成单文件推理脚本
模型训练完,交付给课程设计评审或组内同事验证时,一定不能只丢一个.pt文件。多模态模型的推理依赖完整的预处理流水线、tokenizer词典和图像transform,任何一环配置不一致,模型行为就会变。我的习惯是把推理逻辑封装成单文件脚本,并附带一份简短的文档说明。
脚本只需要三个函数:加载模型、预处理一条新闻、输出判别概率。核心逻辑如下:
def predict(text, image_path, model, tokenizer, transform, device): model.eval() inputs = tokenizer(text, max_length=128, truncation=True, padding=True, return_tensors="pt").to(device) image = Image.open(image_path).convert("RGB") image_tensor = transform(image).unsqueeze(0).to(device) with torch.no_grad(): logits = model(inputs["input_ids"], inputs["attention_mask"], image_tensor) prob = torch.softmax(logits, dim=-1) return {"fake_prob": prob[0, 1].item(), "real_prob": prob[0, 0].item()}注意tokenizer和transform必须来自训练时保存的那一份,图像统一convert("RGB")。容易忽视的点是:训练时如果用了RandomResizedCrop,推理时必须换成Resize((224,224)),因为两者的坐标空间不同,模型看到图像分布会变,概率输出会出现明显偏差。
交付文档怎么组织?我的方案是固定写四件事:环境依赖,包括Python版本、PyTorch版本、transformers版本,最好导出一个requirements.txt;数据格式,说明JSONL字段和图像目录结构;训练命令,明确batch size、learning rate、freeze开关;推理命令,给一条输入样例和对应的输出格式。课程设计的文档说明不需要几十页,把参数和默认值标全就行,别人拿到后几分钟能复现,你也不用反复口头解释。
如果想进一步验证自己方案的稳定程度,可以做三组消融实验:只用文本、只用图像、图文融合,分别在同一验证集上记录F1。这三组实验花不了多少时间,却是评审最常问的问题。我在项目中跑过的一组结果,文本单模态F1约0.78,图像单模态只有0.65,融合后0.83。这也说明了跨模态注意力真实有效,而不是在拼接特征。
这个项目做完后,我最大的教训是不要一开始就追求多模态的华丽结构。先用单模态文本模型跑基线,再叠加图像分支看增益。如果图像分支只让F1提升不到1个点,就说明图像数据质量或对齐方式出了问题,而不是模型不够强。把基线、消融、最终结果三组数字贴在文档里,比任何花哨的结构图都更有说服力。希望帮到你。
本文还有配套的精品资源,点击获取