简介:这是基于Python的虚假新闻检测多模态识别项目,面向高校期末大作业、课程设计与毕设参考。项目以文本与图像等多模态特征为核心,结合BERT预训练模型与LightGBM/CatBoost融合策略完成真假新闻分类,代码含详细注释,结构清晰,下载后可直接运行,也便于二次开发。压缩包共39个文件、整体约353KB,以16个Python脚本为主,覆盖模型训练、预测与调参流程;另有Markdown/TXT说明文档、Shell一键脚本、TSV数据文件、JSON配置、TensorBoard事件及模型检查点等,便于快速复现实验并查看训练过程。资源已有389人学习,属于97分高分参考项目,适合希望系统了解多模态检测流程的Python学习者借鉴。完整源码配合文档说明,可帮助理解特征工程、模型集成与结果评估等关键环节,作为课程设计或期末项目模板,实用性较强。
1. 一张配图就能让文本分类失效:多模态虚假新闻检测到底怎么搭
我之前接手过一个舆情分析的需求:只做文本分类,准确率已经到 0.92,结果一条“某明星因航班延误被警方带走”的假新闻配着一张几年前机场旧照,文本模型直接判成真实。原因很简单——文本里看不出破绽,破绽全在配图上。这就是多模态虚假新闻检测要解决的问题:把标题/正文和新闻配图联合起来判断真假,而不是只盯着文字。标题里的“基于 Python + 多模态识别”指的就是用 Python 生态完成文本编码、图像编码和特征融合三件事,最终输出真/假(或更细的类别)。适合正在做毕业设计、课程设计或者想在小规模数据集上练手多模态识别的读者,交付产物就是一个能跑通训练、验证、导出的完整项目:Python 源代码加文档说明。
2. 多模态数据集预处理:用 PyTorch 的 Dataset 对齐文本与图像
2.1 数据集选型:为什么 Fakeddit 适合做高分项目
做多模态虚假新闻检测,最不缺的就是“自己爬点新闻图片打标签”的做法,但我不建议课程项目这么干。先不说微博/新闻网站的图片版权和反爬问题,单是“给一条新闻打真假标签”这个动作,主观性就很大,最后答辩时评审一问你“标签标准是什么”就容易被问住。
常见做法是直接用公开的 Fakeddit 数据集。它采集自 Reddit 的帖子,每条样本同时包含标题文本、配图链接、评论和标签,按粒度分为 2-way(真/假)、3-way 和 6-way 三类任务。用它的理由有三个:第一,图片路径直接给全,省去自己爬图的环节;第二,有社区打分作为标签依据,不算纯主观标注;第三,国内外课程项目用它做 baseline 的论文不少,你有现成的对比结果可查。
实际使用时我会做两件事:一是把官方划分的训练/验证/测试集重新按各自文件载入,二是把图片全部下载到本地缓存目录。这里有个大坑:数据集中有很多外链图片已经失效,下载时会出现大面积请求失败。下载脚本里我会加一个失败重试和占位图策略,对应代码写在 2.2 节的 Dataset 中会遇到,这也是做真实数据必须面对的第一个不稳定因素。
2.2 实现 TextImageDataset 预处理管线
下面是我常用的 Dataset 写法,兼容 pandas 读进来的 CSV 和本地图片路径。这里的关键在于同时返回input_ids、attention_mask、image和label,任何一步错位都会导致模型“学得很好但全是虚的”。
import torch import pandas as pd from PIL import Image from torch.utils.data import Dataset from transformers import BertTokenizer from torchvision import transforms class FakeNewsDataset(Dataset): def __init__(self, csv_path, image_root, tokenizer_name="bert-base-uncased", max_len=128, image_size=224): self.df = pd.read_csv(csv_path) # 只保留有本地图片的样本,避免训练时 404 self.df = self.df[self.df["image_path"].notna()].reset_index(drop=True) self.image_root = image_root self.tokenizer = BertTokenizer.from_pretrained(tokenizer_name) self.max_len = max_len self.transform = transforms.Compose([ transforms.Resize((image_size, image_size)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] # 文本编码:截断 + padding 到 max_len text = row["title"] if isinstance(row["title"], str) else "" encoding = self.tokenizer( text, max_length=self.max_len, truncation=True, padding="max_length", return_tensors="pt", ) input_ids = encoding["input_ids"].squeeze(0) # [max_len] attention_mask = encoding["attention_mask"].squeeze(0) image_path = f"{self.image_root}/{row['image_path']}" try: image = Image.open(image_path).convert("RGB") except FileNotFoundError: # 下载失败时用纯色占位图,保证数据不中断 image = Image.new("RGB", (224, 224), color=(128, 128, 128)) image = self.transform(image) label = torch.tensor(int(row["label"]), dtype=torch.long) return input_ids, attention_mask, image, label代码里有几个参数需要注意:
max_len=128:新闻标题通常不超过 30 个词,128 足够;如果你要跑正文而不是标题,把它改成 256 或 512,但显存会明显增加。image_size=224:ResNet 系列默认输入就是 224x224,改了会破坏预训练权重对图像分布的假设。normalize的 mean/std:必须和图像编码器预训练时使用的值一致。这里写的是 ImageNet 的统计值,如果换用 CLIP 的视觉编码器,这两个数要改成 CLIP 内部那组(0.48145, 0.45782, 0.40821, ...)。- 占位图处理:失效链接直接用灰色图填充。这不是最优做法,但能保证整个训练流程不断在 IO 上;后面修数据时只用补跑一小段脚本即可高效替换缓存。
2.3 预处理参数速查表与中文场景替换
下表是我调这个 Dataset 时默认使用的一组参数,适用 8GB 显存的入门卡:
| 参数 | 推荐值 | 改动建议 |
|---|---|---|
文本最大长度max_len | 128 或 256 | 只跑标题用 128,带正文记忆用 256 |
图片尺寸image_size | 224 | 显存紧张可降到 176,但准确率会波动 |
Dataloaderbatch_size | 16 | 显存不足就先降到 8,不优先调 patch size |
Dataloadernum_workers | 4 | Windows 下建议设为 0,否则容易报错 |
| 缓存图片格式 | JPEG | 原图是 PNG 也转成 JPEG,读取速度快得多 |
如果题目限定中文新闻,替换方式很简单:把BertTokenizer.from_pretrained的模型名换成"bert-base-chinese"或"hfl/chinese-roberta-wwm-ext",代码其他部分不用动。数据集方面没有特别标准的公开中文多模态假新闻集,常见做法是用微博带有“辟谣”标识的新闻配图自建一个小批量样本,或者把 Fakeddit 文本用机器翻译转成中文再人工校对。前者适合做原型验证,后者适合作为高分项目里“数据集构建”部分的加分描述。
3. 模型架构:写一个双编码器 + 融合层的 FakeNews 检测器
3.1 融合方式选型:早期、晚期还是跨注意力
拿到文本特征和图像特征之后,怎么把它们合起来是整篇文章的核心决策点。我见过不少人直接把两个特征向量torch.cat起来进一个全连接层,这是早期融合的朴素写法;也有人先让文本模型和图像模型各自打分,最后把两个分数加权平均,这是晚期融合。
三种方式在这个任务里的表现我简单说明:
- 早期融合(拼接后进 MLP):实现最简单,梯度能同时传到两个编码器,适合小数据集起步。缺点是当某个模态噪声大时,另一个模态可能会被拖累。
- 晚期融合(软投票 / 加权平均):对单模态过拟合容忍度更高,但本质上模型没有学到“图文冲突”这种跨模态特征,效果上限低。
- 跨注意力融合(文本 token 对图像 patch 做 attention):能学到类似“这张图里根本没有文字里提到的关键对象”的冲突证据,是论文里最常用的方案,但代码量和显存开销都会翻倍。
考虑到这是一个“高分项目”定位,我的建议是:主代码用早期融合跑通,然后在文档说明中明确写出“为什么不用跨注意力”,并留一个可选的跨注意力模块作为进阶方向。这样既保证复现稳健,又把提升空间讲清楚了,评审会认为你理解到了边界而不是仅仅会调库。
3.2 双编码器加融合模块的模型代码
下面是我用作 baseline 的标准结构:BERT 编码文本,ResNet18 编码图像,两者输出向量拼接后过两层 MLP。
import torch import torch.nn as nn from transformers import BertModel from torchvision import models class FakeNewsMultimodalModel(nn.Module): def __init__(self, num_labels=2, dropout=0.2): super().__init__() # 文本编码器:取 BERT 的 pooler 输出作为句子向量 self.text_encoder = BertModel.from_pretrained("bert-base-uncased") text_dim = self.text_encoder.config.hidden_size # 768 # 图像编码器:ResNet18,把最后一层全连接换成 Identity self.image_encoder = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) self.image_encoder.fc = nn.Identity() image_dim = 512 # resnet18 输出维度 # 融合层:拼接后接 MLP self.fusion = nn.Sequential( nn.Linear(text_dim + image_dim, 256), nn.ReLU(), nn.Dropout(dropout), nn.Linear(256, num_labels), ) def forward(self, input_ids, attention_mask, image): # 文本特征 [B, 768] text_feat = self.text_encoder( input_ids=input_ids, attention_mask=attention_mask ).pooler_output # 图像特征 [B, 512] image_feat = self.image_encoder(image) # 拼接 [B, 1280] fused = torch.cat([text_feat, image_feat], dim=-1) logits = self.fusion(fused) return logits代码里的三个设计点单独展开说:
第一,为什么文本特征用pooler_output而不是last_hidden_state[:, 0, :]。BERT 的[CLS]位置输出经过 pooler 层带一个 tanh 激活,这个向量是预训练时专门为句子级分类任务的输出对齐的;对我们这个任务来说直接取它要省事且效果通常更好。如果后续要加跨注意力,那就得改成拿last_hidden_state,因为需要保留每个 token 的表示。
第二,ResNet 的fc替换为Identity后,输出的 512 维向量是全局平均池化后的结果,不是 ImageNet 分类用的 logits,这一步去掉分类头是必须的,否则会把 1000 类物体分布带进融合层。
第三,num_labels=2对应二分类,Fakeddit 的 3-way 任务对应 “true / fake / uncertain”,6-way 任务则按label字典映射。整体代码不需要改结构,只改这个数字。
3.3 编码器参数量与显存控制
这个模型的总参数量大概是 110M + 11M,其中 110M 全在 BERT 上。如果显存只有 6GB,直接全参数训练很容易 OOM。我常用的做法是分阶段训练:
# 第一阶段:冻结文本编码器,只训图像和融合层 for param in model.text_encoder.parameters(): param.requires_grad = False # 第三阶段:解冻 BERT 的顶层,用小学习率微调 for param in model.text_encoder.parameters(): param.requires_grad = False for param in model.text_encoder.encoder.layer[-2:].parameters(): param.requires_grad = True冻结策略与其说是玄学,不如说是在“你拥有的数据量”和“预训练模型的先验知识”之间做权衡。新闻标题这种文本本身短,语义信息密集,BERT 前几层学到的通用语法已经够用,真正需要更新的是靠近输出的最后一两层,让文本表示向“真假判别”这个任务偏移。图像侧同理,一张新闻配图的语义分布和 ImageNet 差异不小,微调 ResNet 的特征提取层是值得的。
如果还想再省显存,可以退一步:用 ResNet 提前把每张图抽成 512 维特征保存为.npy文件,训练时直接读向量而不是加载图像。这样 Dataset 里不再需要 PIL 打开图片,显存占用基本等于只跑 BERT 加融合层。常见做法是把这个方案作为文档里的“轻量版部署”写给读者。
4. 训练与调参:学习率、样本不均衡与守住 F1
4.1 训练循环:冻结骨干、早停与损失设计
多模态分类项目的训练脚本和单模态没有本质区别,关键都在优化器和训练节奏上。我一般用 AdamW,学习率lr=2e-5,这个值对微调 BERT 是安全的入门下限;如果全网络只解冻了最后两层,lr=1e-5更稳。图像编码器的学习率习惯上可以放大十倍,因为它不是主模型,且冻结过一层。
下面是训练循环的骨架,包含梯度裁剪、早停和每轮评估:
import torch from torch.utils.data import DataLoader from transformers.optimization import get_linear_schedule_with_warmup def train_model(model, train_loader, val_loader, epochs=5, lr=2e-5): optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps ) criterion = torch.nn.CrossEntropyLoss() best_f1 = 0 patience_counter = 0 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(epochs): model.train() for input_ids, attention_mask, image, label in train_loader: input_ids = input_ids.to(device) attention_mask = attention_mask.to(device) image = image.to(device) label = label.to(device) logits = model(input_ids, attention_mask, image) loss = criterion(logits, label) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() f1 = evaluate(model, val_loader, device) if f1 > best_f1: best_f1 = f1 torch.save(model.state_dict(), "best_model.pt") patience_counter = 0 else: patience_counter += 1 if patience_counter >= 2: print(f"第 {epoch} 轮触发早停") breakclip_grad_norm_这行非常重要,它防止单 batch 的异常梯度直接把整个多模态模型权重冲飞。文本编码器和图像编码器的梯度量级不一样,不做裁剪很容易在训练到一半时 loss 突然变成nan。warmup 比例设在 10% 也是为了让 BERT 这类预训练模型在训练初期不被大步长破坏,属于稳定复现的常规设置。
4.2 对不均衡数据的两种有效处理
虚假新闻数据天然不均衡,真实新闻远多于虚假新闻。直接训练的话,模型会学到“全判为真实”来获得一个不高但也不低的准确率,然后你从 val 指标上根本发现不了问题,因为多分类准确率被大类别主导了。
最省事的办法是加权交叉熵。先用 sklearn 计算类别权重,然后传给CrossEntropyLoss:
from sklearn.utils.class_weight import compute_class_weight import numpy as np labels = train_df["label"].values class_weights = compute_class_weight( class_weight="balanced", classes=np.array([0, 1]), y=labels ) criterion = torch.nn.CrossEntropyLoss( weight=torch.tensor(class_weights, dtype=torch.float32).to(device) )如果加权之后 F1 还是上不去,再看有没有过采样 / 欠采样的空间。对于图文数据我不建议用单纯复制少数类样本的方式过采样,因为复制出来的图文对不会增加多样性。更稳妥的做法是给图像侧做随机裁剪、水平翻转做增强,给文本侧用同义词替换或回译,在训练时提高少数类样本的运气。这是数据层面的增强方法,改动最小但收益比较真实。
4.3 训练参数速查与评估夹角
下面的参数表来自我多次在 8GB 显存下跑这套模型的配置,直接抄基本不会翻车:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| optimizer | AdamW | 对 Transformer 类模型远比 SGD 稳 |
| 文本编码器学习率 | 2e-5 | 解冻层数越多越要小 |
| 图像编码器学习率 | 2e-4 | 通常是文本的十倍 |
| 融合层学习率 | 1e-3 | 新初始化层可以大一点 |
| weight_decay | 0.01 | AdamW 默认可用 |
| max_norm | 1.0 | 防梯度爆炸 |
| batch_size | 16 | 显存不够先降这里 |
| epochs | 5 | 配早停,多数情况第 3 轮到顶 |
| warmup_ratio | 0.1 | 可保证前几轮稳定 |
评估指标一定要同时打印 Accuracy 和 Macro F1。Acc 高但 Macro F1 低,说明模型只是对多数类好;反过来则是牺牲整体准确率保少数类。一个合理的高分项目,最后应该在测试集上给出一行“Acc / MacroF1 / 单类别 F1 表”,让评审一眼看到模型不是只会蒙。线上场景里我看指标从来都先把 Macro F1 拎出来看——它是真的不会骗人。
5. 多模态训练中的常见坑:图文错位、虚高准确率与融合失效
5.1 图文对错位:训练指标奇高但过拟合得莫名其妙
一句话概括:模型acc到 0.95,但你拿到线下验证时发现它根本没看图片。
现象:训练很快,验证集指标也好看,但换一批真实新闻数据后崩盘。 原因:DataLoader 输出的文本和图像分属不同样本。常见于你对 DataFrame 做了两次reset_index,或者图片路径的iloc索引和文本iloc索引没有保持同一行。 解决:在Dataset.__getitem__里强制用同一个row = self.df.iloc[idx]取所有字段,不要单独建图片列表。检查方式很简单:把__getitem__返回的文本打印出来,再打印图片路径,人工比对前 5 条就能定位。
我在项目里还会加一个断言:读进来的图片文件名和该行的id字段必须包含相同前缀。这不是性能开销,而是防止清洗数据时把两列排序弄乱。
5.2 val 准确率高但每类 F1 差距悬殊
现象:Acc 跑到 0.88,查看分类报告发现“虚假新闻”这一类 Recall 只有 0.2。 原因:类别失衡条件下的经典场景。模型学到用“多数类先验”来预测,少数类几乎全错。你被总 Acc 骗了。 解决:把所有评估都改成打印classification_report,并让早停以 Macro F1 为目标,而不是 Acc。如果 F1 还是不行,就得上 4.2 节的加权损失或采样策略。
5.3 文本编码器在小数据集上剧烈过拟合
现象:loss 在训练集上降到很低,但 val loss 从第二个 epoch 开始反弹。 原因:新闻标题文本太短,BERT 这类大模型很容易把少得可怜的训练样本死记下来。你的数据量可能就几千条,但参数量一亿多。 解决:冻结大部分 BERT 层,只微调最后两层;把 dropout 提到 0.3;训练轮数压到 3~4 轮。另一个选择是把 BERT 当特征提取器,完全冻结它,只把输出向量当作文本特征喂给融合层。后者适合做 baseline,不会让答辩难堪。
5.4 去掉图像后指标几乎不降:融合层根本没学到跨模态信息
现象:你把image_feat改成全零向量,Acc 反而还高了一点。 原因:融合层的初始状态里,文本特征方向占主导,反向传播对图像分支的信号太弱。可能是 ResNet 被冻结,也可能是图像特征没归一化,尺度比文本特征小,MLP 在训练中干脆忽略了它。 解决:先把图像侧换成一个独立的单模态分类头,看图像分支单独训练的准确率是否高于随机;再把拼接前的两个特征向量分别做 LayerNorm,保证尺度一致;最后可以暂时只解冻图像编码器的最后几个 block,看 loss 是否随图像增强策略有响应。
5.5 训练中途出现 nan 或者梯度爆炸
现象:第三个 epoch 开始 loss 变成nan,或者某个 batch 后参数全部变成超大值。 原因:图像侧偶尔会出现异常像素值(例如加载到损坏图片),也可能是学习率过大,或者图像增强里没做归一化。 解决:在训练循环里加clip_grad_norm_(见 4.1);用torch.isnan(loss)做断言,触发后跳过当前 batch;检查DataLoader的collate_fn,确保没有图片是空文件。排掉这三个因素,nan 基本会消失。
6. 让项目拿高分的验证手段:混淆矩阵、注意力可视化与文档结构
一个“基于 Python 的虚假新闻检测多模态识别”要拿高分,不能只靠模型训练跑出来,还需要验证和交付这两部分。我把验证分为三层:指标层、行为层和代码层。
指标层最容易做的提升是画混淆矩阵。多模态分类和二分类不同,评审最想看到的是“哪一类是难点”。用下面的代码在测试集上生成混淆矩阵,比直接贴一行 Acc 有用得多:
import seaborn as sns import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix y_true, y_pred = [], [] model.eval() with torch.no_grad(): for input_ids, attention_mask, image, label in test_loader: logits = model(input_ids.to(device), attention_mask.to(device), image.to(device)) preds = logits.argmax(dim=-1) y_true.extend(label.tolist()) y_pred.extend(preds.tolist()) cm = confusion_matrix(y_true, y_pred) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues") plt.xlabel("Predicted") plt.ylabel("True") plt.savefig("confusion_matrix.png", dpi=150, bbox_inches="tight")行为层需要给模型做出“可解释的证据”。对文本模态,比 BERT 的 attention 可视化更直观的做法是拿 GradCAM 看图像编码器最后的卷积特征图,让评审看到模型决策时聚焦的是图片里的什么区域:
from torchvision.transforms.functional import to_pil_image # 用一张输入图片的 feature_map 与对 logits[0] 的梯度做加权平均 # 得到热力图后叠加在原图上这段代码不需要很复杂,能让人看到“假新闻配图里有一段文字涂改区域高亮”就够了。行为层表现会让项目和普通三分类项目拉开差距。
最后是文档说明。源代码再好,没有 README 也会扣分。我会把文档分成四块:环境依赖与安装、数据目录结构、训练与评估脚本用法、实验结果表格。其中“实验结果表格”要放上三种本地对比:文本单模态、图像单模态、多模态融合,这样能直接证明“多模态确实带来了提升”,而不是把一堆指标堆在一起无人理解。别忘了在 README 里写明数据版权和来源,这是一个容易被忽略但评审很在意的细节。我经历中吃过文档不全的亏,后来学乖了,先把 README 当第一优先级写,文档就是你的后悔药。
至于“源代码管理”,说白了就是给项目建目录时别把所有脚本摞在一起。我一般会分data/、models/、train.py、evaluate.py、visualize.py和README.md,再配一个requirements.txt固定 torch 和 transformers 版本。这个目录本身就是给评审看的第一份文档,它整洁与否往往比一行花哨的准确率更能说明问题。希望这些经验能帮你在自己的项目上少走一段我不曾避开的弯路。
本文还有配套的精品资源,点击获取