简介:基于Python的虚假新闻检测多模态识别完整项目,面向计算机、人工智能等专业的学生与开发者,适合作为毕设、课设或入门进阶的实战参考。项目涵盖数据处理、特征工程、模型训练与评估环节,包含BERT语义模型、LightGBM与CatBoost融合等核心代码,并配有README等说明文档,可帮助理解多模态信息在虚假新闻识别中的应用方式。压缩包共39个文件,以Python脚本为主,辅以Markdown/TXT文档、Shell脚本、TSV数据文件及模型检查点,整体仅350KB,轻量易部署。目前已有297人学习浏览,代码经测试运行通过,下载后可按文档快速复现实验流程,也可在此基础上修改扩展,完成其他分类任务。
1. 多模态识别:虚假新闻检测里最该盯住的是“图文对不上”
基于Python的虚假新闻检测多模态识别,一句话说就是让模型同时读新闻正文和配图,判断两路信息是不是在互相支撑。这几年社交平台和内容网站上的假新闻,早就不只是“整篇编造”了,更多是拿旧图配新文、把无关图片硬贴到热点事件上,纯文本模型很难抓。多模态识别的价值就在于多了一个“图文互相印证”的维度,适合要做内容审核前置过滤的团队,也适合做舆情产品、想给一线人员输出“可疑点”的算法工程师。下面我按自己实际搭过的方案,从建模选型、最小可跑工程到参数和踩坑,完整讲一遍。
2. 两路编码器加一个融合层:把图文判别拆成三条可替代的路线
多模态识别落到工程上,就是“文本编码器 + 图像编码器 + 融合层 + 分类头”四段式。先想清楚每一段选什么,后面写代码才不会反复返工。
2.1 文本编码器和图像编码器:直接用预训练模型,不要从零训练
新闻正文和配图是两类完全不同的信号:文本是离散词序列,图像是连续像素。强行把两者塞进同一个模型并不划算,常规做法是先分别抽成向量再做融合。文本端我用的是BERT类预训练模型,中文场景默认从bert-base-chinese这类权重起步;图像端用带ImageNet预训练权重的ResNet或EfficientNet。原因不是“大家都在用”,而是新闻检测任务能拿到的标注样本通常只有几千到几万条,从零训练一个文本编码器或图像编码器,效果远不如把预训练模型拿过来微调。
这里最常见的误区是“把整个BERT和整个ResNet都解冻,让它们自己学”。真实情况是:文本端微调学习率超过5e-5,几轮之后预训练权重就被冲坏;图像端如果全量解冻,又要用更大的学习率,两路优化步长不一致,融合层会被带偏。我的建议是分阶段:先冻结两端编码器,只训练随机初始化的融合层,等loss能稳定下降,再按需解冻BERT最后两层和ResNet的高层block。这也是后面代码里freeze_text和freeze_image两个开关存在的原因。
2.2 融合层选型:拼接、门控与交叉注意力,各解决什么问题
融合层决定了“文本特征和图像特征以什么方式见面”。三种方案我都在业务里试过,落地优先级不一样。
第一种是拼接(concat),把文本CLS向量和图像全局特征拼起来,过一个MLP。这是最稳的起点,几分钟就能跑通一个完整基线。缺点是模型只能从两个静态向量里硬学相关性,几乎无法表达“图文里哪一部分是相互矛盾的”。
第二种是门控融合(gated),给两个模态分别学一个可学习的权重。它的直接好处是能看出这条样本是靠文本判的、靠图像判的、还是两边都起作用,对噪声图片有一定抑制作用。代码只比concat多几行:
# 门控融合示例:g 是标量门控,实际可扩展为逐维度门控 import torch import torch.nn as nn class GatedFusion(nn.Module): def __init__(self, dim_t, dim_i, num_labels=2): super().__init__() self.gate = nn.Linear(dim_t + dim_i, 1) self.cls = nn.Linear(dim_t + dim_i, num_labels) def forward(self, text_feat, image_feat): h = torch.cat([text_feat, image_feat], dim=-1) g = torch.sigmoid(self.gate(h)) fused = g * text_feat + (1 - g) * image_feat return self.cls(fused)这段代码的关键是g = torch.sigmoid(self.gate(h)),门控值被压到0到1之间,模型可以近似选择“只看文本”或“只看图像”。实际运行时要注意:如果数据里图文多数是匹配的,门控初始值会偏向一边,建议把gate的bias初始化为0,让它从“五五开”开始学。
第三种是交叉注意力,用文本向量做query、图像特征做key和value,让两个模态互相修正。效果上限最高,但对样本量和显存的要求也最高,小数据集上很容易过拟合。我的经验是先不用它,等concat基线和门控都跑完、确认多模态信号确实存在,再升级到这个结构。
2.3 小数据下的微调策略:冻结谁、解冻谁、什么时候全量微调
微调策略比换模型更影响最终指标。我一般把训练分成两个阶段:阶段一冻结BERT和ResNet,只训练融合层。这个阶段看的是“随机初始化的融合层能不能把已有特征用起来”,loss能降到正常水平,说明特征提取没问题;阶段二才解冻部分编码器,BERT用2e-5左右的学习率,图像端用1e-4左右,并且只解冻最后两层。全量微调我不建议一上来就做,尤其是样本量只有一两千的时候,验证集指标看起来不错,换一批新数据就崩。
这里还要提一句显存策略:解冻编码器之后,梯度会占用大量显存。如果只有一张11GB左右的显卡,batch_size往往只能开到8甚至4。这时别硬撑,把梯度累积打开,累积两步再更新一次参数,效果比强行调小batch稳定得多。
3. 用一个最小 PyTorch 工程跑通图文假新闻判别
这一章给一套能直接运行的最小源代码,目录结构、数据加载、模型、训练循环都在里面。先把目录搭出来:
project/ ├── dataset.py ├── model.py ├── train.py ├── train.csv ├── val.csv └── images/train.csv和val.csv里至少有三列:text存新闻正文或标题,image_path存图片文件名,label存标签,0代表真实、1代表虚假。把非结构化的图文数据整理成这种结构化的CSV,是后面所有步骤的前提。
3.1 最小图文数据集:CSV加图片目录,用Dataset类加载
# dataset.py # 最小图文对数据集:读取 CSV 中的 text/image_path/label import os import torch from PIL import Image from torch.utils.data import Dataset from torchvision import transforms IMG_MEAN = [0.485, 0.456, 0.406] IMG_STD = [0.229, 0.224, 0.225] def build_image_transform(resize=224): return transforms.Compose([ transforms.Resize((resize, resize)), transforms.ToTensor(), transforms.Normalize(mean=IMG_MEAN, std=IMG_STD), ]) class MMNewsDataset(Dataset): def __init__(self, csv_path, tokenizer, image_root="images/", max_len=64): super().__init__() import pandas as pd self.df = pd.read_csv(csv_path) self.tokenizer = tokenizer self.image_root = image_root self.max_len = max_len self.transform = build_image_transform() def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] text = row["text"] enc = self.tokenizer( text, truncation=True, max_length=self.max_len, padding="max_length", return_tensors="pt", ) # 图片加载失败时用全零图占位,避免整个训练进程中断 try: image = Image.open(os.path.join(self.image_root, row["image_path"])).convert("RGB") image_tensor = self.transform(image) except Exception: image_tensor = torch.zeros((3, 224, 224)) return { "input_ids": enc["input_ids"].squeeze(0), "attention_mask": enc["attention_mask"].squeeze(0), "image": image_tensor, "label": torch.tensor(row["label"], dtype=torch.long), }这段代码有三处需要说明。第一,max_len=64是我针对“新闻标题加首段”这个场景设的,超过64个token的直接截断;文本过长时BERT的计算量会明显上涨,而标题类文本的信息密度通常集中在前几十个token。第二,padding="max_length"会一次性把序列填充到定长,虽然显存多一点,但能避免DataLoader在batch维度上处理变长序列的麻烦。第三,图片加载失败的占位逻辑很重要,后面避坑章节会专门展开。
3.2 模型定义:BERT加ResNet,concat进MLP
# model.py # 文本端用 BERT,图像端用 ResNet50,融合层用 Concat + MLP import torch import torch.nn as nn from transformers import BertModel from torchvision.models import resnet50, ResNet50_Weights class MultimodalFakeNewsClassifier(nn.Module): def __init__( self, text_model_name="bert-base-chinese", hidden_size=256, num_labels=2, freeze_text=True, freeze_image=True, ): super().__init__() self.text_encoder = BertModel.from_pretrained(text_model_name) self.image_encoder = resnet50(weights=ResNet50_Weights.IMAGENET1K_V1) # 去掉 ResNet 原分类头,让它输出 2048 维图像特征 self.image_encoder.fc = nn.Identity() if freeze_text: for p in self.text_encoder.parameters(): p.requires_grad = False if freeze_image: for p in self.image_encoder.parameters(): p.requires_grad = False # BERT 的 CLS 向量是 768 维,ResNet50 特征是 2048 维 self.fusion = nn.Sequential( nn.Linear(768 + 2048, hidden_size), nn.ReLU(), nn.Dropout(0.2), nn.Linear(hidden_size, num_labels), ) def forward(self, input_ids, attention_mask, image): # pooler_output 对应 [CLS] token 过全连接后的向量 text_feat = self.text_encoder( input_ids=input_ids, attention_mask=attention_mask )["pooler_output"] image_feat = self.image_encoder(image) # 输出 (B, 2048) logits = self.fusion(torch.cat([text_feat, image_feat], dim=-1)) return logits这里最需要注意的是freeze_text=True和freeze_image=True的默认值。第一次跑通流程时,建议保持两个都为True,只训练融合层;确认数据、loss、评估流程都正常后,再解冻图像编码器,最后解冻BERT。两个编码器输出维度分别是768和2048,直接拼成2948维,中间层用256维就够,没必要把hidden_size设得过大,否则小数据上很容易过拟合。
3.3 训练与验证循环:一组参数跑完,同时看准确率和F1
# train.py # 训练主循环,包含验证集的准确率和 F1 输出 import torch import torch.nn as nn from torch.utils.data import DataLoader from transformers import BertTokenizer from sklearn.metrics import accuracy_score, f1_score from dataset import MMNewsDataset from model import MultimodalFakeNewsClassifier def evaluate(model, loader, device): model.eval() y_true, y_pred = [], [] with torch.no_grad(): for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) image = batch["image"].to(device) logits = model(input_ids, attention_mask, image) pred = logits.argmax(dim=-1).cpu().numpy() y_pred.extend(pred) y_true.extend(batch["label"].numpy()) return accuracy_score(y_true, y_pred), f1_score(y_true, y_pred, average="binary") def train(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") train_ds = MMNewsDataset("train.csv", tokenizer, image_root="images/") val_ds = MMNewsDataset("val.csv", tokenizer, image_root="images/") train_loader = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=2) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=2) model = MultimodalFakeNewsClassifier( freeze_text=True, freeze_image=False ).to(device) optimizer = torch.optim.AdamW([ {"params": model.text_encoder.parameters(), "lr": 2e-5}, {"params": model.image_encoder.parameters(), "lr": 1e-4}, {"params": model.fusion.parameters(), "lr": 1e-3}, ]) loss_fn = nn.CrossEntropyLoss() for epoch in range(5): model.train() total_loss = 0.0 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) optimizer.zero_grad() logits = model(input_ids, attention_mask, image) loss = loss_fn(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() acc, f1 = evaluate(model, val_loader, device) print(f"epoch={epoch} loss={total_loss / len(train_loader):.4f} " f"val_acc={acc:.4f} val_f1={f1:.4f}") if __name__ == "__main__": train()训练循环里最关键的参数是分组学习率。BERT用2e-5,ResNet用1e-4,融合层用1e-3,原因在于三部分“出身不同”:BERT和ResNet都是预训练权重,学习率大了会灾难性遗忘;融合层是随机初始化的,学习率小了又长得太慢。batch_size=16是我在11GB显存下的安全配置,如果你显存更小,把batch_size降到8,同时在optimizer.step()之前累积两次gradient,等价于还是16的更新节奏。评估指标只看准确率会骗人,假新闻样本通常占少数,类别不平衡时F1更有参考价值。
4. 直接抄的初始参数表:多模态识别想收敛,先守住这几个上限
多模态模型的超参不是越多越好,真正影响成败的就这么几个。先给一张我验证过的初始参数表,再讲怎么调。
4.1 被验证过的一组初始超参和它们的作用边界
| 参数 | 建议值 | 说明与坑 |
|---|---|---|
| max_len | 64 | 标题加首段足够;过长会明显增加BERT计算量 |
| image_size | 224 | 与ResNet预训练输入保持一致,不宜随意改 |
| batch_size | 16 | 11GB显存安全值;显存小就配合梯度累积 |
| text_lr | 2e-5 | 解冻BERT时的安全上限,超过5e-5容易学崩 |
| image_lr | 1e-4 | 解冻ResNet时的安全值 |
| fusion_lr | 1e-3 | 新初始化的MLP可以给大一点 |
| dropout | 0.2 | 融合层防过拟合;BERT内部dropout不要动 |
| freeze_text | True起步 | 数据不足时先冻结跑通流程 |
| freeze_image | False | 图像解冻比文本安全,优先解冻 |
| epochs | 5到10 | 用val_f1选模型,不要只看loss |
| warmup_ratio | 0.1 | 前10%训练步数线性升学习率,BERT微调必备 |
这张表最想强调的不是某个数字,而是“每个参数都有边界”。比如image_size=224,你把图片改成448,ResNet的预训练位置编码和池化虽然能兼容,但计算量翻几倍,换来提升通常很小。warmup_ratio这个问题很多新手会忽略,BERT这类模型在训练初期对学习率特别敏感,一上来就用2e-5直接更新,前几百步loss可能不降反升;加了warmup之后曲线会平滑很多。
4.2 调优顺序:先看loss,再动数据,最后才动网络结构
我见过太多人一上来就换网络结构,结果问题出在数据上。调优顺序我基本固定为三步。第一步,跑3到5个epoch,观察loss曲线。如果loss不降,优先查数据:CSV里label是不是反了,text列是不是全部为空,图片是不是大部分加载失败变成了全零图。第二步,loss在降但val_f1上不去,这是类别不平衡或者图文不一致样本太少导致的,先做数据层面修正,比如给假新闻样本加权、补充更多拼贴样本,不要急着加网络模块。第三步,前两步都排除了,再考虑把concat换成门控或交叉注意力。
判断模型有没有真的用上图像信号,有一个很直观的方法:把验证集里的图片全部换成纯色图,重新跑一次评估。如果F1几乎没有变化,说明模型根本没依赖图像端,基本退化成纯文本分类器了。这个“换图测试”我每次都会做,成本极低,但能避免上线后才发现多模态白做了。
5. 多模态虚假新闻检测的4个常见坑:现象、原因、解法都记在这
多模态检测的坑比单模态多得多,因为数据链路长,文本、图像、加载、融合任何一个环节出问题,都会反映成指标异常。下面四条是我反复撞过、也帮同事排查过的典型问题。
5.1 图片里的小字没被当成文本,模型自然抓不到“图内文案”
现象:验证集F1一直卡在0.6左右,模型对“图片上印着大段文字”的假新闻基本漏判。原因:配图里的文字是像素,不是token。ResNet提取的是视觉纹理特征,它能识别出“这里有一片文字区域”,但读不懂里面写了什么。很多拼接型假新闻恰恰把关键信息做成图片文字,比如聊天记录截图、海报、公告。解决:在数据预处理阶段增加OCR通道,用现成的OCR服务把图片里的文字识别出来,拼到文本端。常见做法是把OCR文本用“图片文字:”前缀拼在正文后面,让BERT一起编码;也可以在融合层另加一路OCR特征,但工程复杂度会高很多。如果业务对时效要求不高,先用OCR拼文本是性价比最高的方案。
5.2 训练集和验证集标题重复,F1很高但上线就翻车
现象:训练时val_f1能上0.9,灰度抽样一看,检测率远低于离线指标。原因:数据划分时直接按样本行随机切分,同一个新闻事件的图文对既进了训练集又进了验证集。模型其实记住了具体新闻标题,而不是学到了“图文不一致”的规律。解决:重新按“新闻事件ID”或者“文本去重后的主键”划分数据,保证同一事件的所有样本只出现在一个集合里。更严格一点,还要对图片做感知哈希去重,因为同一张图配不同文本、或者同一条新闻换不同图,在线上都算新样本。数据泄漏是多模态任务里最容易出现、也最隐蔽的坑。
5.3 坏图直接让DataLoader崩溃,导致半天训练白跑
现象:训练跑到第10个epoch,突然报PIL无法识别图片,整个进程退出,前面全部作废。原因:线上采集的新闻图片格式五花八门,有的是webp、有的是截图存成了jpg但实际是png、有的文件本身只有几个字节。代码里用PIL直接打开,遇到损坏文件就抛异常。解决:在Dataset的__getitem__里加try except,加载失败时返回全零图占位,同时把该样本索引记到日志里,训练结束后统一筛查。另外建议在预处理阶段就做一轮图片健康检查,把打不开、宽高为0、文件小于1KB的图片直接剔除。全零图占位会引入噪声,但延后的是排查时间,不会让训练中断。
5.4 图文不一致样本太少,模型退化成单模态“标题党识别器”
现象:模型在测试集上表现不错,但看attention权重,图像端的梯度几乎为空。原因:构造训练数据时,大多数虚假样本的图片和文本其实是语义相关的,只是观点扭曲;真正的“图文完全无关”样本太少,模型学不到跨模态矛盾信号,干脆全部依赖文本模式。解决:主动构造图文不一致负样本。常见做法是拿真实新闻的正文,随机配一张语义无关的历史图片,生成负样本;再拿虚假新闻的正文,配一张强相关的真实图片,生成另一类困难样本。这一步对指标提升往往比换模型结构更明显,因为它直接决定了模型能不能见到“图文矛盾”这种关键模式。
6. 用30行代码给检测结果“验伤”:图文相似度与单条排查脚本
多模态模型最难受的地方是它像个黑匣子,你不知道它到底靠哪个模态做判断。上线前我习惯跑一个“验伤脚本”,把文本特征和图像特征的余弦相似度算出来,检查模型是否真的在做跨模态核对。
# inspect_one.py:检查单条样本的图文相似度与模型置信度 import torch from transformers import BertTokenizer from dataset import build_image_transform from model import MultimodalFakeNewsClassifier from PIL import Image device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = MultimodalFakeNewsClassifier( freeze_text=True, freeze_image=False ).to(device) model.load_state_dict(torch.load("best_model.pt")) model.eval() def inspect(text, image_path): image = build_image_transform()( Image.open(image_path).convert("RGB") ).unsqueeze(0).to(device) enc = tokenizer( text, truncation=True, max_length=64, padding="max_length", return_tensors="pt" ).to(device) with torch.no_grad(): text_feat = model.text_encoder( input_ids=enc["input_ids"], attention_mask=enc["attention_mask"], )["pooler_output"] image_feat = model.image_encoder(image) sim = torch.cosine_similarity(text_feat, image_feat, dim=-1).item() logits = model( enc["input_ids"], enc["attention_mask"], image ) prob = torch.softmax(logits, dim=-1).cpu().numpy() print(f"图文相似度: {sim:.4f}") print(f"虚假概率: {prob[0][1]:.4f}")这段脚本的好处是能同时看两个信号:如果一条真实新闻被模型判成虚假(高概率),但它本身的图文相似度很高,说明模型没在关注图文一致性,可能在文本端学到了奇怪模式,需要进一步查训练数据;如果虚假新闻图文相似度很低,但模型判成真实,说明融合层失效,图像端特征根本没参与最终决策。
我的习惯是每次训练完,都把验证集所有样本的图文相似度按标签分组画分布图。正常情况应该是:虚假样本组的相似度整体偏低,真实样本组偏高。如果两组分布几乎重合,那这个模型上线就是在赌博。这条经验帮我避免了一次灰度上线后大规模误报,也让我后来在所有多模态项目里都把“验伤脚本”写进交付文档。希望帮到你。
本文还有配套的精品资源,点击获取