ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多模态虚假新闻检测实战:Python双塔模型与图文特征融合指南

多模态虚假新闻检测实战:Python双塔模型与图文特征融合指南

简介:一份基于Python实现的多模态虚假新闻检测完整工程,包含可运行的源码与说明文档,面向计算机相关专业学生、算法爱好者以及需要课程设计或毕设参考的开发者,也适合作为入门进阶的实战样例。方案采用BERT对新闻文本进行特征提取,利用卷积神经网络处理配图信息,并设计了有效的模态融合方法提升判别准确率,该项目曾在相关比赛中获得第一名。压缩包共41个文件,大小约400KB,包括16个Python脚本覆盖模型构建、训练、微调与预测,另有Markdown与txt说明文档、Shell部署脚本、JSON/TSV参数与评测数据、tfevents训练日志,以及模型权重和字典文件,结构清晰便于复现。目前已有392人学习下载。代码全部测试通过,答辩评审平均分96分,采用MIT许可证允许自由修改分发;下载后如遇运行问题可私聊咨询,支持远程教学,适合深入理解文本与图像融合检测的完整流程。

1. 拆开这个工程包:多模态虚假新闻检测在解决什么问题

判断一条新闻的真假,只看文本经常会翻车。迷惑性新闻最典型的表现是:文字一本正经,配图却来自别的场合、别的时间,图文信息错位。基于 Python 的虚假新闻检测多模态识别,正是为这个场景设计的——把标题、正文和配图一起送进模型,从文本和图像两个模态各抽特征,再在融合层里做综合判决。这份带源码和文档说明的工程包,落地目标不是演示一遍“能跑”,而是让你换成自己的新闻数据重新训练、评估,看清多模态比单模态到底赢在哪、输在哪。想学多模态分类、在做新闻真实性方向的检测,或者想验证“图文不一致”这件事能不能被模型捕捉,都适合顺着这个工程往下做。

2. 先立框架:多模态检测的输入组织与数据管线

2.1 为什么采用“双塔”结构:一条特征通道解决不了图文错位

多模态检测最基本的工程形态是双塔:文本塔和图像塔各自独立编码,最后把两个表示融合起来。为什么不做单塔?单模态模型无论多强都只能拿到一边的证据。文本塔看不到配图,面对“配图与内容无关”的样本,它和人类只看字一样,容易被表面上合理的措辞带偏;图像塔没有语义上下文,一张普通城市街景在真实新闻和虚假新闻里都可能出现,单独分类几乎毫无区分力。双塔结构让两个分支在特征层面互相补充,这也是这类源码包几乎都采用的结构。

选型上还有一条路是“级联”:先用文本模型粗筛,拿不准的样本再送去图像模型复审。这样做推理阶段能省一部分算力,但训练和评估流程复杂,阈值要单独调;对于要先跑通任务的新手来说,双塔更容易调试——两个分支的输出维度清晰、可以单独跑 baseline、换掉一条分支不影响另一边的结构。我一般建议先按双塔把整个流程跑通,再根据实际部署需求考虑要不要改成级联。

数据是这里面最容易出问题的地方。公开的新闻检测数据通常给的是文本元信息加图片路径的结构,很多包用 JSON Lines 存样本、用文件夹存图片。写死绝对路径不是好习惯,代码换到别人机器上就没法复用;所以我习惯让源码包的入口脚本接收两个相对路径参数,一个指 meta 文件,一个指图片根目录。

2.2 数据管线怎么搭:从 meta 文件到可训练的 collate 函数

下面这份 Dataset 是这类工程的标准起点。它的职责只是把 meta 里的每一条新闻文本、配图和标签读出来,做最基础的合法性检查,不负责具体的分词或图像变换。

import json import os from torch.utils.data import Dataset from PIL import Image class NewsSampleDataset(Dataset): """读取meta文件并加载对应配图,输出文本/图像/标签三元组。""" def __init__(self, meta_path, image_dir): super().__init__() self.image_dir = image_dir self.samples = [] with open(meta_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue item = json.loads(line) # 每一行是一条新闻的元信息 # 过滤缺图或标签异常的样本,避免训练中途崩掉 img_path = os.path.join(image_dir, item["image_name"]) if not os.path.exists(img_path): continue if item["label"] not in (0, 1): continue self.samples.append(item) # 统计正负样本比例,方便后面调整loss权重 self.label_dist = {0: 0, 1: 0} for s in self.samples: self.label_dist[s["label"]] += 1 def __len__(self): return len(self.samples) def __getitem__(self, idx): item = self.samples[idx] # 标题是可靠信号,正文爬取质量不稳定,可自行决定是否拼接 text = item["title"] if "content" in item and item["content"]: text = text + " " + item["content"] # 统一转RGB,避免灰度图和彩色图混用导致输入维度不一致 image = Image.open(os.path.join(self.image_dir, item["image_name"])).convert("RGB") return { "text": text, "image": image, "label": int(item["label"]), }

构造函数里的 meta_path、image_dir 建议都用相对路径传参,这样代码迁移不碰路径逻辑。label_dist 不是装饰,真实新闻数据正负比例经常失衡,这个字典能让你在训练前就发现类别不平衡问题。__getitem__返回的 text 是字符串、image 是 PIL 对象,真正转张量在 collate 阶段做。

真实抓取的新闻数据里还有三类噪声要提前处理。第一类是重复样本,同一条新闻被多家渠道转载,meta 里 id 不同但标题和配图完全相同,不去重会让 train 和 dev 互相污染,把“记住样本”当成“学会泛化”;第二类是能打开但已损坏的图片,我习惯用 try/except 包住 Image.open,初始化阶段就把打不开的图剔除;第三类是文本里混入爬虫模板残留,比如“正文共 1234 字”这类后缀,会影响分词效果。这些清洗逻辑都放在 Dataset 构造函数里,而不是训练循环里,换新数据时不会漏做。

collate_fn 把一批样本转成模型吃得到的张量。文本用预训练模型自带的分词器统一编码,图像用 torchvision 的 transform 做缩放和归一化:

import torch def collate_news_samples(batch, tokenizer, image_transform): texts = [b["text"] for b in batch] images = torch.stack([image_transform(b["image"]) for b in batch]) labels = torch.tensor([b["label"] for b in batch], dtype=torch.long) # padding统一到batch内最长,truncation截断到max_length encoded = tokenizer( texts, padding=True, truncation=True, max_length=128, return_tensors="pt", ) return { "input_ids": encoded["input_ids"], "attention_mask": encoded["attention_mask"], "images": images, "labels": labels, }

文本截断 max_length=128,新闻标题加部分正文已经够用,塞到 250 以上并不会提效果,只会增加显存。tokenizer 的 padding 方式选 batch 内动态 padding,固定 padding 到 512 会白占显存。image_transform 需要注意:训练和验证要用不同配置,训练做随机裁剪和水平翻转,验证只做中心裁剪和缩放,保证评估结果确定。

注意:别把新闻配图的原图直接塞进模型。新闻图分辨率差异很大,直接使用会让 batch_size 和显存同时失控。先统一缩放到 224×224 或 256×256,再做后续处理。

DataLoader 参数也有讲究。常见搭配是 num_workers=4、pin_memory=True,配合 CUDA 使用;num_workers 不是越大越好,超过逻辑核心数后进程切换开销会抵消并行收益。多模态任务的 batch_size 通常被图像分支吃满,文本是定长序列、图像是 2048 维向量,显存主要花在 CNN 前向阶段。我一般先用单 batch 跑一次前向,确认不 OOM 再调大 batch_size。

3. 两条特征分支:文本怎么抽、配图怎么变成向量的

3.1 文本分支的选型:从词频向量到预训练序列模型

文本通道的选型决定了这个项目的精度下限。最省事的做法是 TF-IDF 加逻辑回归,但它是词袋模型,抓不到否定、转折这些句法信息,对假新闻这种“带着说服语气”的文本很吃亏。更稳妥的起点是用预训练语言模型,取它的 CLS 向量作为整句表示,也就是下面这个 TextEncoder 的写法。

我一般会在工程里留一个开关:先跑一个纯文本 baseline 验证数据质量,有把握后再把文本编码器换成更大规模的模型。不要一开始就把模型上到最大,否则后面排查数据问题时整个流程都被拉慢。下面是个封装好的文本编码器:

import torch.nn as nn from transformers import AutoModel, AutoTokenizer class TextEncoder(nn.Module): """文本编码器:加载预训练模型,输出句级CLS向量。""" def __init__(self, model_name, freeze=True): super().__init__() self.tokenizer = AutoTokenizer.from_pretrained(model_name) self.backbone = AutoModel.from_pretrained(model_name) if freeze: # 冻结全部参数,只把CLS向量送给后面的融合层 for p in self.backbone.parameters(): p.requires_grad = False def forward(self, input_ids, attention_mask): out = self.backbone( input_ids=input_ids, attention_mask=attention_mask, ) # last_hidden_state: (batch, seq_len, hidden) return out.last_hidden_state[:, 0, :]

freeze=True 的含义要理解清楚:冻结之后预训练模型不参与梯度更新,显存占用和训练时间都明显下降,但模型不能针对新闻领域微调,效果上限会低一截。我把 freeze 做成开关,先用冻结版跑通整个流程,再在验证集上比较冻结和微调两者的 F1,通常微调能带来两个点以上的提升——前提是训练数据量不少于几千条。model_name 按你的数据语言环境选择对应预训练模型即可,中文场景用中文语料预训练的版本,英文场景换英文版本,不要混用。

文本塔的前向一次拿到 (batch, hidden) 的 CLS 向量,hidden 通常是 768。这个维度后面要和图像特征对齐,对齐方式在第 3.3 节说。

3.2 图像分支:用预训练 CNN 把配图转成固定长度特征

图像分支的常见做法是使用预训练 CNN,去掉最后的 1000 类分类头,把池化后的向量当作配图表示。选 ResNet50 是工程上的折中——它在显存、速度、特征质量之间比较平衡,参考实现也多;如果只追求效果,EfficientNet 系列在同样精度下参数量更少,但训练时对 BatchNorm 更敏感,环境配置不对容易翻车。

代码里有三件事要注意。预训练权重直接用 torchvision 提供的版本,不要下载陌生来源的权重文件,那既可能有版权问题,也可能是被植入恶意代码的权重。归一化参数用 ImageNet 标准值,也就是 mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225],做图像加载的 transform 时跟它保持一致。backbone.fc 用 nn.Identity() 替换,forward 输出的就是池化特征而不是分类 logits。

import torch.nn as nn from torchvision import models from torchvision.models import ResNet50_Weights class ImageEncoder(nn.Module): """图像编码器:ResNet50去掉分类头,输出2048维视觉特征。""" def __init__(self, freeze=True): super().__init__() # DEFAULT 会加载 ImageNet 预训练权重 self.backbone = models.resnet50(weights=ResNet50_Weights.DEFAULT) # 把最后的1000类全连接替换成恒等映射,保留全局池化结果 self.backbone.fc = nn.Identity() if freeze: for p in self.backbone.parameters(): p.requires_grad = False def forward(self, x): # x: (batch, 3, 224, 224),输入前必须做ImageNet归一化 return self.backbone(x) # (batch, 2048)

freeze=True 时整个图像塔的梯度不计算,显存占用小、训练速度快,但视觉特征只能停留在 ImageNet 学到的通用结构上。如果你的配图领域性很强,比如全是截图、图表或证件照,微调图像塔后半段会有明显收益。输入 x 要求是已经被 image_transform 归一化的张量,也就是第 2 章 collate 函数里的 images;如果数据里混了灰度图,必须在加载时 .convert("RGB") 统一通道,否则输入维度直接报错。

3.3 维度对齐:768 和 2048 怎么拼

现在两条分支的输出维度不一样。文本 CLS 是 768,图像池化特征是 2048,直接把两个向量 concat 起来也不是不行,但后面的融合层要同时适应两个不同空间里的分布,收敛更慢。我习惯在进入融合层之前各过一个单层 Linear + ReLU,把两个模态都投影到同一个维度,比如 256 或 512。

这样做有两个好处。一是门控和注意力机制只需要在一个统一的语义空间里计算,权重才有可比性;二是投影层本身是可学习参数,相当于给每个模态一次降维表达的机会。投影维度不用选太大,新闻检测任务没有细粒度分类那么复杂,256 到 512 足够支撑后续融合,投影太大反而给融合层增加不必要的参数。这一步在第 4 章的融合模块里直接实现,不需要单独训练一个投影网络。

4. 融合才是上限:特征拼接之后,门控和注意力差在哪里

4.1 三种融合方式的定位:拼接、门控、跨模态注意力

多模态检测的精度上限往往不在单模态编码器,而在融合层。先看三种最常见方案的定位差别。

融合方式实现思路复杂度适用情况
直接拼接concat 两个模态向量后接分类头最低快速建立 baseline
门控融合用 sigmoid 学一组权重,给两个模态加权低模态噪声不平衡时稳住下限
跨模态注意力让一个模态按注意力查询另一模态的证据中高图文一致性问题为主的任务

直接拼接的缺点在于分类器对所有模态一视同仁,当图像分支质量差时,它会拖累文本分类。门控融合把“该信谁”变成可学习的权重,是我在这个项目里推荐的默认起点。跨模态注意力做的是信息交换,文本可以去“查看”图像的哪些区域与当前内容相关,能力最强也最吃调参,具体实现放到第 6 章。

这三种写法的实现难度和收益不是线性的。直接拼接的代码只有一行 torch.cat,几分钟就能出结果,但它暴露问题也慢:如果融合后 F1 不如单模态,你很难判断是两个编码器的问题还是融合策略的问题。门控融合多了一个可学习权重,代码量不大,却能让你直观看到模型到底更相信哪个模态,这对定位数据问题很有帮助。跨模态注意力留到流程全部跑通之后再替换,不要一开始就上。

4.2 一份可以直接嵌入训练循环的门控融合模块

门控融合的核心是一个 sigmoid 门:它拿到两个模态的投影特征,输出一组权重,决定最终融合向量里文本和图像各占多少。

import torch import torch.nn as nn class GatedFusion(nn.Module): """门控融合:先投影到统一维度,再让门控决定两个模态的贡献比例。""" def __init__(self, text_dim, image_dim, proj_dim=256, num_classes=2): super().__init__() # 两个模态各自投影到同一维度 self.text_proj = nn.Linear(text_dim, proj_dim) self.image_proj = nn.Linear(image_dim, proj_dim) # 门控:输入拼接后的特征,输出两个模态的权重 self.gate = nn.Sequential( nn.Linear(proj_dim * 2, 2), nn.Sigmoid(), ) self.classifier = nn.Sequential( nn.Linear(proj_dim, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, text_feat, image_feat): # 两个特征先投影再激活 t = torch.relu(self.text_proj(text_feat)) # (batch, proj_dim) v = torch.relu(self.image_proj(image_feat)) # (batch, proj_dim) gate = self.gate(torch.cat([t, v], dim=-1)) # (batch, 2) # 归一化约束:gate两维相加等于1 gate = gate / (gate.sum(dim=-1, keepdim=True) + 1e-8) fused = gate[:, :1] * t + gate[:, 1:] * v logits = self.classifier(fused) return logits, gate

投影层解决维度不一致问题,统一到 proj_dim=256 再做门控。对 gate 做归一化保证两个权重相加为 1,避免训练时两个权重同时走强导致特征幅度失控。forward 返回 logits 和 gate 两个值:训练时用 logits 算交叉熵,验证时统计 gate[:, 0] 的均值——如果这个均值始终高于 0.9,说明图像分支几乎没有起到作用,问题大概率出在图像塔或图像数据质量上。

参数调整方面,proj_dim 太小丢信息、太大加重融合层负担,256 在本任务里比较稳妥。Dropout(0.3) 是针对新闻数据量通常只有几千到几万条这个现实设置的,过拟合风险比通用视觉分类任务高得多。如果 label_dist 里正负样本差距超过 5 倍,分类器里需要加类别权重,不要只靠调整阈值。

注意:每次训练都要打印 gate 均值和单模态 baseline。这不是调试负担,而是确认融合有效的唯一快速手段。

4.3 分类头与损失:稳定的默认组合

融合向量接一个两层 MLP 分类头即可,不需要花哨的复杂网络。损失函数默认用交叉熵,不需要因为它是多模态就特殊处理——真正的复杂度在数据分布和特征质量,不在损失函数。类别不平衡时给 loss 传 weight=[1, ratio],ratio 等于负样本数除以正样本数;如果肉眼看出误判集中在某一类上,可以考虑 Focal Loss,但先跑通默认配置再调这些旋钮,不要第一次训练就同时动太多地方。

训练周期上,我习惯按验证集的 macro-F1 做早停,而不是固定 epoch 数。新闻数据集规模小,模型在 5 到 8 轮之后很容易过拟合,早停配合 Dropout 比单纯正则化更省心。优化器选 AdamW,学习率按编码器是否冻结来定:冻结编码器时融合层可以用 1e-3;微调编码器时整体降到 2e-5,否则预训练权重会在前几步就被冲乱。

5. 训练与评估避坑:跑多模态检测的四个经典翻车点

5.1 现象:训练集准确率走高,测试集 F1 惨不忍睹

原因:新闻数据集正负样本严重不平衡,模型把大多数样本判成多数类就能拿到高准确率。交叉熵对多数类梯度占主导,少数类召回率一路走低。常见结果是准确率能到 90%,F1 却不到 0.6,整个评估报告失真。

解决:先看 Dataset 里的 label_dist,在 loss 里做类别加权。我习惯把负类数量除以正类数量作为权重传给交叉熵,例如负样本 8000、正样本 2000,weight=[1.0, 4.0]。同时评估指标改成 macro-F1,不再只盯着 accuracy。类别不平衡严重时,验证集的切分也要用分层采样,保证 dev/test 的标签分布和训练集一致。

5.2 现象:GPU 利用率只有个位数,CPU 内存倒先爆了

原因:Dataset 的__getitem__里每次实时打开原图,图片分辨率大,transform 在 CPU 上执行,GPU 只能空转等数据;这是多模态项目最常见的性能瓶颈,不是代码写错,而是数据流没通。

解决:在数据预处理阶段把图片统一缩放到 224×224 并缓存到临时目录,Dataset 里只加载缩放后的版本;DataLoader 设置 num_workers=4、pin_memory=True。如果内存还有富余,可以把整个缓存图片预加载成一个 list,省去每次的磁盘 IO;但要注意内存占用,几千张图问题不大,几万张就要谨慎。

5.3 现象:接上图像分支之后,效果反而不如纯文本

原因:图像信息与文本信息重叠度高,或者配图本身噪声大。拼接后分类面更复杂,融合层学到的可能只是文本分支的重复表达,图像分支反而引入了额外的分布偏移。

解决:先单独训练两个单模态 baseline,分别记录 F1。只有当两条 baseline 接近时,融合才有意义;如果文本 F1 明显高于图像,说明图像通道要先换更强的模型、做更彻底的数据清洗,或者干脆检查是不是 image_transform 归一化配置不对。门控融合在这个场景下尤其有价值——gate 均值会告诉你模型是不是自己学会了忽略图像分支。

5.4 现象:同一份代码换台机器就复现不了

原因:深度学习项目里 random、numpy、torch、CUDA 四处都有随机性,只固定其中一个种子等于没固定;不同版本之间算子实现差异也会带来结果漂移,“换机器结果对不上”是运行环境问题,不是模型问题。

解决:训练入口统一固定四个种子,random.seed、numpy.random.seed、torch.manual_seed、torch.cuda.manual_seed_all 全都要设;同时把 torch.backends.cudnn.benchmark 关掉,避免卷积算子选择引入不确定性。requirements.txt 里锁定主版本号,并把随机种子和依赖版本写进文档说明,方便换机器后排错。

这些坑我基本都踩过一遍,前三个是数据问题、一个是环境问题,没有一个需要改模型结构。跑多模态项目时,我给自己定的习惯是:先打印数据分布,再跑单模态 baseline,最后才碰融合层。这套顺序能挡掉大半无头绪的“玄学调参”。

6. 进阶方向:从门控往跨模态注意力走一步,把图文关系真正用起来

6.1 跨模态注意力的最小实现与验证

门控融合的粒度是整个模态,它只能回答“文本和图像各自该信多少”,却看不到“文本里哪个词跟图像里哪个区域产生了矛盾”。跨模态注意力能回答后者:把文本表示作为 query,图像特征作为 key/value,让文本去查询图像特征里与它最相关的部分,得到带着视觉证据的文本表示。核心代码其实很短:

import torch def cross_attention(tokens, image_feats): # tokens: (batch, seq_len, dim) 图像查询文本 # image_feats: (batch, num_regions, dim) scale = tokens.shape[-1] ** 0.5 attn = torch.matmul(tokens, image_feats.transpose(-1, -2)) / scale attn = torch.softmax(attn, dim=-1) # 用注意力权重加权视觉特征,得到对齐后的视觉表示 return torch.matmul(attn, image_feats)

从门控升级到注意力,有一个前提变化:特征不再是一维句向量,而是序列级表示。文本塔要输出 token 级特征,图像塔要输出类似 num_regions 的位置特征,而不是只留一个 2048 维池化向量。实现时还要把文本的 attention_mask 传进来,避免 padding 位置参与注意力计算,不然效果会倒退。

验证方法上,替换融合层后保持其他参数不动,单独对比三档结果:直接拼接、门控、跨模态注意力,记录各自的 macro-F1。如果跨模态注意力在新闻数据上不能超过门控两个点以上,先别急着上线——注意力头数、mask 传递、投影维度都值得再调,数据量太小时注意力机制很容易过拟合。我最初做这个方向时犯过一个错误:以为只要把两个模型的特征拼起来就算多模态,结果融合后 F1 反而不如纯文本。后来才明白,图文错位这个任务真正要学的是“关系”,而不是简单的特征堆叠。跨模态注意力是我试过最稳的进阶方向,但前提是前面的单模态分支和数据清洗都过关。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进