简介:基于Python的虚假新闻多模态识别项目,是一份面向高校课程设计和期末大作业的高分参考代码包。项目围绕文本与图像等多模态信息,借助BERT预训练模型、LightGBM、CatBoost等主流算法构建完整识别流程,覆盖从原始数据清洗、图文特征提取、模型集成到预测结果分析的完整链路,代码注释详细,适合从零复现或二次开发。压缩包内共三十九个文件,十六个Python源码文件构成核心实现,另有Jupyter Notebook交互演示、Markdown说明文档、Shell训练测试脚本、TSV数据集文件、JSON与TFEvents配置及训练日志,同时附有环境依赖清单与一键运行脚本,整体体积仅三百五十三KB,轻量易用。目前已有三百八十七人学习/下载。通过阅读文档与注释,可以快速理解多模态特征融合和模型集成思路,利用现成脚本直接运行或拓展,用于课程答辩、期末大作业或独立课题扩展时都能提供有力支撑,是入门虚假新闻检测的一份实用资料。
1. 虚假新闻检测不只是文本分类:多模态识别到底在补哪块短板
把文本丢进 BERT,把配图丢进 ResNet,再把两路特征一拼,分类准确率就该往上走——这个直觉在我第一次做虚假新闻检测多模态识别项目时,很快就被推翻了。单纯拼接两路特征,效果经常比只读文本还差。原因说穿了很简单:一条新闻的文本和配图,信息量不在同一个尺度上,噪声模式也不在同一个频道里。虚假新闻检测要抓的不是“这张图假不假”,而是“图配文是否说得通”。多模态识别真正在补的短板,是跨模态的不一致性。
这个方向适合三类人:准备课程设计或毕业设计的学生,做内容风控落地的开发者,以及想从单模态过渡到多模态入门的人。它同时要求你会 NLP、懂 CV、能组织数据管线,是一个典型的工程型题目。整体目标就是跑通端到端流程,量化多模态带来的真实增益,并且能解释模型什么时候失效、为什么失效。
2. 选型决定上限:文本分支、视觉分支与融合策略的取舍
多模态模型的上限,不是由某一个分支决定的,而是由你“怎么把两路信息合起来”决定的。我在实际项目中常见的失败模式是:两个分支各自都很强,拼在一起反而互相干扰。所以这一章先不谈代码,先把选型理清楚,这部分想明白了,后面写代码就是填表格的事。
2.1 文本分支:从轻量基线到 BERT 的路线选择
文本分支要回答的核心问题是:新闻正文到底是短文本还是长文章。短文本用 TF-IDF 损失太大,长文章直接整篇喂给 BERT 也很浪费。我一般会按三档来选,而不是一开始就上重模型。
基础档是 TF-IDF 加逻辑回归。这档模型只能当作 baseline,用来确认数据管道有没有跑通,它本身没有上下文语义,但作为对照能让你看清深度学习模型到底带来多少提升。第二档是 TextCNN 加预训练词向量,训练快、显存占用小、单 epoch 跑下来用不了几分钟,这是课程设计里性价比最高的方案。第三档是 BERT 及其变体,效果上限最高,但显存和训练时间翻倍涨,而且调参翻车概率也大。
很多标着“高分项目”的方案都写了 BERT,但答辩时经常被追问一个问题:“你的 BERT 是被冻结的,为什么效果还行?”这个问题的坑在于,不是所有人都会意识到 BERT 微调在几千条新闻数据上很容易过拟合。我的建议是折中:BERT 做编码但冻结大部分层,后面接一个可训练的 TextCNN 头,这样既吃到预训练语义,又不会把模型训练时间拖到不可接受。
2.2 视觉分支:为什么是预训练 ResNet 而不是从零训练 CNN
虚假新闻的配图来源很杂,常见的有新闻首图、社交平台截图、被二次压缩的老照片。这些图的共性是清晰度参差不齐,有的被加了水印,有的被截掉一角,和你平时做图像分类用的 ImageNet 风格差得很远。在这类数据上从零训练一个 CNN,几乎必然过拟合,因为样本量撑不起几百层的卷积参数。
我用的是 ImageNet 预训练的 ResNet18 或 ResNet50,去掉最后的全连接分类层,取平均池化后的特征向量。ResNet18 在大多数场景下够用,因为虚假新闻配图里真正需要的是“场景级”而不是“物体级”的语义。如果数据量超过两万张,再考虑 ResNet50。前几层卷积学的是边缘、颜色、纹理这类通用特征,直接冻结,只微调最后几个 block,既省显存又不容易跑偏。
| 视觉模型 | 输出特征维 | 显存占用 | 建议场景 |
|---|---|---|---|
| ResNet18 | 512 | 低 | 样本量几千到一万的常规数据集 |
| ResNet50 | 2048 | 中 | 图像质量高、样本量大 |
| 从零训练 CNN | 自定义 | 低 | 不推荐,过拟合风险极高 |
视觉分支的另一个关键点是输入尺寸。新闻配图不要硬 resize 到大尺寸,224x224 是性价比很高的选择,再往上加分辨率对准确率提升很有限,显存却会明显上涨。
2.3 融合策略:早拼接、晚决策和注意力融合怎么选
融合是整个多模态项目的核心,也是标题里“多模态识别”真正落地的地方。常见的融合方式有三种,代价和效果差距很大。
第一种是特征拼接,也叫早融合。BERT 吐出一个 768 维向量,ResNet 吐出一个 512 维向量,拼成 1280 维,再过几层全连接。实现最简单,也能学到一部分跨模态交互,但有个隐患:两个分支的数值尺度如果差很多,强的会把弱的压得几乎没有梯度。比如 ResNet 特征输出被归一化到单位长度,而 BERT 的 pooler 输出没有,拼接后全连接层的权重会偏向其中一路。
第二种是晚融合,常见做法是把两个分支各自输出的概率取平均或加权投票。这种方案几乎不会翻车,因为两个分类器独立训练,最后只是决策合并。但它有一个硬伤:模型根本没有看到图与文的对应关系,所谓多模态识别就只剩下名字,实际效果和两条单模态通道的结果相差无几。
第三种是注意力融合,也是我目前最推荐的方案。用文本特征作为 query,图像特征作为 key 和 value,计算一个跨模态注意力权重。你可以理解成模型在读正文的每一句时,自己决定要不要看一眼图、看图的哪个区域。这条路效果上限最高,但需要更多数据来训练注意力参数,数据量少于五千条时容易看到融合部分过拟合。
| 融合方式 | 实现成本 | 能否学到跨模态关联 | 常见失败点 |
|---|---|---|---|
| 特征拼接 | 低 | 弱 | 强势模态压过弱势模态 |
| 概率平均 | 极低 | 不可 | 只有合并,没有融合 |
| 注意力融合 | 中高 | 强 | 数据少时注意力过拟合 |
时间紧就先用概率平均打底,拿到一组不翻车的结果;中期换成特征拼接加门控;想要冲高分,再把拼接升级成交叉注意力。这个升级路径每一步都能单独出一张消融实验表,写在文档说明里非常加分。
3. 数据的骨架:数据集怎么选,项目目录怎么搭
模型选型再好,数据没接好也是白搭。很多新手在拿到代码后第一件事就是跑训练,结果在数据格式上耗掉两三天。这一章把数据集的选型、项目目录的组织方式和清洗细节一次说清。
3.1 公开数据集怎么选:FakeNewsNet、LIAR 与中文场景
做虚假新闻检测多模态识别,最常用的公开数据集是 FakeNewsNet。它包含两个子集:gossipcop 和 politifact,每条新闻除了正文文本外还带有配图 URL 和社交上下文。gossipcop 子集规模相对大,适合做主力训练;politifact 数量少,适合做迁移验证。注意这两个子集的标签分布都不均衡,真实新闻明显多于假新闻,后面要专门处理。
LIAR 是纯文本数据集,每条样本是短句加说话人信息,没有配图。它不能直接用于多模态任务,但可以拿来做文本分支的基准测试,验证一下你的文本编码器在不同数据来源上的稳定性。
中文场景下,常见的公开数据集是微博虚假新闻数据,但这类数据在分享流传过程中标签往往被改动过,网上拿到的版本不一定和论文里的划分一致。如果你准备做中文方向,我建议拿原始数据后自己重做一遍标签抽检,随机抽 100 条让人工复核,别直接信任下载下来的 label 字段。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | str | 新闻唯一标识,用于划分数据 |
| title | str | 新闻标题 |
| content | str | 新闻正文,清洗后 |
| image_path | str | 配图本地路径 |
| label | int | 1 表示假新闻,0 表示真实新闻 |
3.2 项目目录怎么搭:一个能直接答辩的结构
代码组织方式直接影响你的文档说明能不能写清楚,也影响别人拿到源代码后能否复现。我一般会按配置、数据、模型、工具四个维度拆开,而不是把全部代码堆在几个文件里。下面这个结构可以作为参照:
fake_news_detection/ ├── config/ │ └── default.yaml ├── data/ │ ├── raw/ │ ├── processed/ │ └── split/ ├── models/ │ ├── text_model.py │ ├── vision_model.py │ └── fusion_model.py ├── utils/ │ ├── dataset.py │ ├── train.py │ └── metrics.py ├── requirements.txt ├── README.md └── run_train.pyconfig 目录单独放一个 yaml 配置文件,里面写学习率、batch size、epoch、截断长度这些参数,而不是把它们硬编码在训练脚本里。原因很直接:你在调参时每改一次学习率就要去翻训练函数,一旦参数散落在十个地方,改漏一个就会造成“这次效果变差但不知道改了什么”的尴尬局面。
models 目录按文本、图像、融合分成三个文件。很多人把三个模块写在同一个文件里,看起来代码短了,但实际上换文本分支或换图像分支时,要动的地方会互相牵扯。分开写之后,想从 ResNet18 换成 ResNet50,只需要改 vision_model.py 里的一个类。utils/dataset.py 负责数据加载,utils/train.py 放训练循环,metrics.py 里放准确率、精确率、召回率、F1 的计算。README 里写清楚 python 版本、依赖安装命令和每一步的运行命令,这对“文档说明”占分比重高的项目尤其重要。
3.3 文本和图谱清洗:三个容易忽略的预处理细节
数据清洗这一步多做一点,后面模型训练就少折腾一点。第一个坑是 HTML 转义和不可见字符。从新闻页面采集下来的正文常见的情况是包含\xa0、–、"这类残留,直接丢进 BERT 会让分词器多出很多无意义的 token。清洗时先做一次 HTML 反转义,再把空白字符统一替换成空格。
import re import html def clean_text(raw: str) -> str: if not isinstance(raw, str): return "" text = html.unescape(raw) text = re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text) text = re.sub(r"\s+", " ", text).strip() return text这段代码的逻辑很简单:先用 html.unescape 把实体还原成字符,然后去掉控制字符,最后把连续空白折叠成一个空格。参数说明只有一处要注意:\s+在 Python 默认匹配\n、\t和普通空格,不需要额外传 flags。
第二个坑是图片读取通道问题。OpenCV 的cv2.imread读出来是 BGR 顺序,PIL 读出来是 RGB,如果不统一,模型推理时看到的颜色是错位的。我在项目里统一用 PIL 读图,避免隐式转换。
第三个坑是文本截断位置。BERT 的输入长度有限,长新闻必须截断。主流做法是只保留前 128 或 256 个 token,这个策略对新闻基本够用,因为导语通常在前两段。但如果你的数据集里有一部分文章采用“事件背景在前、关键结论在后”的结构,只留头部会丢掉结论。稳妥的截断策略是保留头部和尾部各一段:truncation=True时手动指定max_length=128,并且把return_overflowing_tokens打开,溢出部分再补一次截断拼接。这个细节在测试集上可能只影响零点几个百分点,但能防止个别样本出现“关键后半段被整体切断”的情况。
4. 训练代码这样写:从数据加载到双流前向再到训练循环
这一章直接给出一套能在 PyTorch 里跑通的最小实现。代码不是最花哨的,但每一段都对应前面选型章节的决策,适合作为项目主体代码,也方便后续替换成更复杂的结构。
4.1 自定义 Dataset:把文本编码和图像变换打包
PyTorch 的 DataLoader 要求数据先被封装成 Dataset。我的做法是让每条样本同时返回编码后的文本和预处理后的图像,注意不要在这里对文本做词表映射,那是 tokenizer 的工作。
import torch from torch.utils.data import Dataset from PIL import Image class FakeNewsDataset(Dataset): def __init__(self, df, tokenizer, transform=None): self.df = df self.tokenizer = tokenizer self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] # 清洗后的正文,防止空值直接让 tokenizer 抛异常 text = str(row["clean_text"]) tokens = self.tokenizer( text, max_length=128, padding="max_length", truncation=True, return_tensors="pt" ) # 统一转成 RGB,避免灰度图或带透明通道的图导致维度不匹配 image = Image.open(row["image_path"]).convert("RGB") if self.transform: image = self.transform(image) label = torch.tensor(float(row["label"]), dtype=torch.float32) return { "input_ids": tokens["input_ids"].squeeze(0), "attention_mask": tokens["attention_mask"].squeeze(0), "image": image, "label": label }关于参数,max_length=128是前面文本选型里确定的截断长度,如果你的正文普遍超过五百字,可以提到 256,但要注意 BERT 的显存占用会随长度增加而快速上涨。return_tensors="pt"让 tokenizer 直接返回 PyTorch Tensor,省掉手动转换。squeeze(0)是因为 tokenizer 默认返回带 batch 维度的张量形状[1, seq_len],在 Dataset 里要去掉这个多余的维度。label转成 float 是因为后面要用的损失函数是BCEWithLogitsLoss,它要求标签是浮点类型。
4.2 双流模型:特征维度和 dropout 是关键
模型主体按前面的选型实现:BERT 负责文本,ResNet18 负责图像,特征拼接后过一个 MLP。这里重点是维度对齐和分类层的输出设计。
import torch import torch.nn as nn class MultiModalClassifier(nn.Module): def __init__(self, text_encoder, image_encoder): super().__init__() self.text_encoder = text_encoder self.image_encoder = image_encoder self.classifier = nn.Sequential( nn.Linear(768 + 512, 256), nn.GELU(), nn.Dropout(0.3), nn.Linear(256, 1) ) def forward(self, input_ids, attention_mask, image): text_feat = self.text_encoder( input_ids=input_ids, attention_mask=attention_mask ).pooler_output image_feat = self.image_encoder(image) fused = torch.cat([text_feat, image_feat], dim=1) return self.classifier(fused).squeeze(1)768 + 512是 BERT base 的 pooler 输出维度和 ResNet18 平均池化后特征维度的拼接,如果换成别的 backbone,这两个数字要改。pooler_output和最后一层 hidden state 的区别在于,pooler 经过了 tanh 激活,语义更浓缩,做分类更合适。分类层最后输出 1 维而不是 2 维,配合BCEWithLogitsLoss使用,数值上比CrossEntropyLoss更稳定,而且输出值可以直接解释成正样本对数几率。
Dropout(0.3)这个参数在多模态融合里格外重要。融合层是两路特征的交汇点,也是最容易过拟合的位置,dropout 小于 0.2 时融合层基本起不到正则作用,大于 0.5 又会明显拉慢收敛。从 0.3 起步调整是一个合理的做法。
4.3 训练循环:分层学习率、梯度裁剪和模型保存
训练阶段最关键的参数是学习率,而且不是一个学习率管全部。BERT 微调过猛会把预训练权重冲坏,图像分支和融合层则需要更大的学习率才能快速收敛。我的做法是给三部分分别配置学习率,经验值如下。
from torch.optim import AdamW from torch.nn.utils import clip_grad_norm_ optimizer = AdamW([ {"params": text_encoder.parameters(), "lr": 2e-5}, {"params": image_encoder.parameters(), "lr": 5e-5}, {"params": classifier.parameters(), "lr": 1e-4}, ]) for epoch in range(epochs): model.train() for batch in train_loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) image = batch["image"].to(device) labels = batch["label"].to(device) logits = model(input_ids, attention_mask, image) loss = loss_fn(logits, labels) optimizer.zero_grad() loss.backward() clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()学习率这里三个数字看着接近,实际上差别很大。BERT 用 2e-5 是因为它在预训练阶段已经被充分优化,过大的步长会让它快速遗忘通用语义;图像分支用 5e-5,因为 ResNet 只微调最后几个 block,步子可以稍大;融合分类层是从零训练的,需要 1e-4 才能在十几个 epoch 内收敛。clip_grad_norm_的max_norm=1.0是针对多模态模型特别重要的配置,两路梯度如果尺度差异大,梯度范数很容易爆炸,裁剪能保证更新方向不被其中一路劫持。
模型保存不要只保存最终的权重,而要按验证集 F1 动态保存最优权重。做法是在每个 epoch 结束后跑一遍验证集,如果 F1 比当前最优值高就把state_dict存一份,文件名带上 F1 数值。
5. 高频翻车点排查:五个多模态项目的“现象-原因-解决”
这一章的每一条都是我实际踩过的坑。多模态项目的翻车点比单模态多得多,而且很多错误不会直接报异常,只会显示为“效果变差”,排查起来特别费劲。以下五条按优先级排序,建议直接对照检查你的训练流程。
5.1 加了图像分支,效果反而倒退
现象:单文本分支验证集 F1 有 0.83,拼接图像后掉到 0.78,多次重跑结果一致。原因有两种可能:一是图像分支过拟合,把训练集的噪声细节当成了有用特征;二是两路特征尺度差异大,图像特征经过全局池化后数值范围远小于 BERT 特征,融合层实际上主要在拟合文本特征,图像分支等于白加。解决方法是先单独验证图像分支的分类能力,如果单图像 F1 本身就低于 0.7,就不要指望融合能提升效果,优先增强图像分支的数据增强、加大 dropout,或者换成更强的预训练模型。
5.2 按行随机划分数据,测试集泄漏
现象:训练时验证集 F1 很高,测试集表现也不错,但人工抽检时发现同一个热点事件的系列报道同时出现在训练集和测试集里。原因在于 DataFrame 按train_test_split默认分行切分,没有考虑同一新闻事件的多篇衍生报道。解决方法是按新闻主题或事件 ID 分组划分,用StratifiedGroupKFold代替普通的 KFold,保证同一事件的所有新闻只出现在一个集合中。
5.3 用同一个学习率训练所有模块,BERT 被冲坏
现象:训练前几个 epoch loss 下降正常,到第 3 个 epoch 后验证集指标反而还低于只训练两个 epoch 的结果。原因常见的是对 BERT 用了和其他层一样的学习率,像 1e-4 这个级别对预训练模型来说太大了。解决方法是按 4.3 节的分层策略设置学习率,并做一个简单实验:固定其他部分,只对比 BERT 学习率 2e-5 和 1e-4 的验证集 F1,你会看到差距通常在 3 到 5 个百分点。
5.4 6G 显存跑不动 BERT 加 ResNet
现象:batch size 设成 16 直接 OOM,报错出现在 BERT 的 embedding 层。原因是文本长度和 batch size 共同决定了显存峰值,BERT 的激活值会随序列长度线性增长。解决办法按顺序尝试:batch size 降到 4,图片尺寸从 224 降到 192,打开 PyTorch 的混合精度训练。如果还想再省,就把 BERT 换成更小的变体,或者冻结掉 BERT 的全部参数,让它只做特征提取。
5.5 模型成了黑匣子,答辩时解释不了判断依据
现象:对方问“这条新闻模型为什么判为假”,你只能回答“深度学习模型学出来的”。这种回答在课程设计答辩里非常吃亏。原因是模型没有提供任何可解释性的输出接口。解决方法是保存训练时每条样本输入模型前的文本和图像特征,用最简单的方式做注意力可视化:把 token 对应的注意力权重抽取出来,权重高的词标红。这个操作代码量不大,但对“文档说明”的完整度帮助极大。
6. 从 60 分提到 90 分:给项目加这三个动作
第一个动作是补一张消融实验表。这张表至少包含四行:单文本分支、单图像分支、特征拼接、注意力融合。每一行记录验证集上的准确率、精确率、召回率和 F1。没有这张表,你的多模态方案就只是一个“拼起来”的模型;有了它,你能明确说出图像分支带来了多少增益,融合策略比概率平均高了多少,这才是“高分项目”里最能说服评审的材料。
第二个动作是做可视化,把模型从黑匣子里放出来。文本分支抽取 attention 权重,把权重最高的几个 token 在原文里标出来;图像分支用 Grad-CAM 生成热力图,叠加在配图上。把这两样东西贴进文档说明,读者一眼就能看出模型在关注什么。可视化代码不复杂,但要注意保存的是原始尺寸图,不是训练时 resize 后的 224x224 小图。
第三个动作是把运行链路打磨完整。README 里写清 python 版本、依赖安装命令、数据目录结构、训练和推理各自的启动命令,requirements.txt 里的包一定要和实际运行环境完全一致。你不在文档里写的步骤,别人复现时就可能卡住,卡住一次你的可信度就掉一截。
我自己的教训是:花了两周把模型 F1 从 0.76 调到 0.82,却没花半天把可视化补上,结果答辩时评委只记住了“模型是黑匣子”。从那之后,我每做一个项目都会把消融表和可视化当成代码的一部分来写,而不是最后临时补。希望这个顺序能帮到你。
本文还有配套的精品资源,点击获取