简介:多模态学习是深度学习的重要方向,其核心在于融合多种信息源以提升模型判别能力。在虚假新闻检测场景中,仅依赖文本或图像的单模态模型往往难以捕捉伪造信息的完整证据链,而文本与图像的双路融合能显著增强识别鲁棒性。本文从BERT语义编码与ResNet视觉特征提取的原理出发,解析了早期拼接与注意力加权两种融合方案的差异,并给出了基于Python的工程实现细节,包括数据预处理、训练参数调节及评估指标解读。该技术可应用于社交媒体内容审核、舆情监控等场景,为构建可解释、高精度的多模态虚假新闻检测系统提供了完整参考实践。
1. 多模态虚假新闻检测:单模态撑不起大作业,也别拿它糊弄毕设
训练集里那张图片清晰得像官方通告截图,文字语气也一本正经,模型却毫不犹豫把它判成谣言——这种翻车几乎每个做过虚假新闻检测的人都碰过。原因不玄学:纯文本模型看不到图里的PS痕迹,纯图像模型读不懂正文里的诱导话术,只有把两种模态一起喂进模型,才能抓住伪造信息的真正破绽。这套基于 Python 的多模态虚假新闻检测系统,就是把文本、图像两条分支融合起来做联合判断,训练、评估、可视化全流程跑通,源码和配套文档都齐,直接落到期末大作业或毕业设计里完全够用。
2. 摸清整套源码的结构:目录、数据流与预处理管线
拿到源码包先别急着跑train.py,第一步是搞明白每个文件在数据流里的位置。这套系统的代码组织方式很常规:配置、数据、模型、训练、评估五块分离,跟企业里深度学习项目的标准布局一致。你改参数、换数据集、调模型结构的时候,不用在几百行代码里翻来翻去,改对应文件就行。
2.1 目录结构:每个文件在数据流里的职责
先看一份典型目录组织,我拆过的多数同类项目长这样:
multimodal_fake_news/ ├── config.py # 全局参数:路径、学习率、batch_size、类别权重 ├── data/ │ ├── dataset.py # Dataset 子类:负责单条样本的文本+图像加载 │ ├── text_preprocess.py # 文本清洗、分词、BERT tokenizer 封装 │ └── image_preprocess.py# 图像读取、resize、归一化、数据增强 ├── models/ │ ├── text_encoder.py # 文本分支:BERT 提取语义特征 │ ├── image_encoder.py # 图像分支:ResNet 提取视觉特征 │ ├── fusion.py # 多模态融合层:拼接 / 注意力加权 │ └── classifier.py # 分类头:MLP + Softmax ├── train.py # 训练主循环:加载数据、计算损失、反向传播 ├── evaluate.py # 评估脚本:ACC、F1、AUC、混淆矩阵 ├── predict.py # 单条样本推理:输入图文,输出概率 └── requirements.txt # 依赖清单这套划分的逻辑在于:数据预处理和模型解耦,你换数据集只动data/,换主干网络只动models/,训练逻辑基本不受影响。config.py是所有参数的唯一下游,后面调参只改这个文件,不用去各个脚本里找魔法数字。
2.2 文本预处理:BERT Tokenizer 与清洗规则
文本分支这条线,常见做法是加载预训练 BERT 做特征提取。预处理不是简单encode一下,需要先清洗再 tokenize:
# text_preprocess.py import re from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-uncased") def clean_text(text: str) -> str: # 去掉 URL、@用户、#话题,保留句子主体信息 text = re.sub(r"http\S+|https\S+", "", text) text = re.sub(r"@\w+|#\w+", "", text) # 压缩连续空格和换行 text = re.sub(r"\s+", " ", text).strip() return text def text_to_tokens(text: str, max_len: int = 128): cleaned = clean_text(text) encoded = tokenizer( cleaned, max_length=max_len, padding="max_length", truncation=True, return_tensors="pt" ) # 返回 input_ids 和 attention_mask,供 BERT 使用 return encoded["input_ids"].squeeze(0), encoded["attention_mask"].squeeze(0)清洗规则里删 URL 和 @ 用户是必要的:正文里的链接对模型判断意图没有增益,反而会撑爆 token 长度;tokenizer的padding="max_length"保证 batch 内所有样本长度一致,truncation=True处理超长文本。max_len=128是经验值,B站、微博这类短文本 128 够用,如果是新闻长文,可以放宽到 256,但显存占用和训练时间会明显增加。
2.3 图像预处理:Resize、归一化与增强策略
图像分支这条线,核心是匹配预训练模型的输入要求。ImageNet 上预训练的 ResNet,要求输入是 224×224、三通道 RGB、像素归一化到特定均值和标准差:
# image_preprocess.py from PIL import Image from torchvision import transforms # ImageNet 预训练模型的标准化参数,照抄别改 normalize = transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), # 训练时随机裁剪,增强鲁棒性 transforms.RandomHorizontalFlip(), transforms.ToTensor(), normalize ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), # 验证时中心裁剪,保证可复现 transforms.ToTensor(), normalize ]) def load_image(path: str): img = Image.open(path).convert("RGB") return imgRandomCrop和RandomHorizontalFlip是训练集专用,验证集和测试集必须用确定性变换,否则每次评估结果都会跳动。convert("RGB")这一步很多人漏掉:灰度图、RGBA 图不转成 RGB,ToTensor()之后通道数对不上,模型直接报错,这是跑这个系统最常遇到的第一道门槛。
Dataset 类把两条线汇总到一起,每次__getitem__返回一个字典:
# dataset.py class FakeNewsDataset(Dataset): def __init__(self, df, image_dir, transform=None, max_len=128): self.df = df self.image_dir = image_dir self.transform = transform self.max_len = max_len def __getitem__(self, idx): row = self.df.iloc[idx] # 文本侧拿 tokenizer 输出 input_ids, attention_mask = text_to_tokens(row["text"], self.max_len) # 图像侧拿预处理后的 tensor image = load_image(f"{self.image_dir}/{row['image_name']}") if self.transform: image = self.transform(image) label = torch.tensor(row["label"], dtype=torch.long) return { "input_ids": input_ids, "attention_mask": attention_mask, "image": image, "label": label }这个__getitem__返回的字典就是后面 DataLoader 喂给模型的格式。文本侧是 token 序列,图像侧是 3×224×224 的 tensor,标签是整型——对应交叉熵损失的要求。如果你的数据标注文件不是text、image_name、label这三列,在导入时先做一次列名对齐,不然row["text"]直接 KeyError。
3. 多模态模型这样搭:文本编码、图像编码与两种融合方案
模型部分是这个项目真正的重头戏。很多人以为多模态就是把两个模型输出拼在一起,实际上融合层怎么设计、两个分支的梯度怎么平衡,直接决定最终效果。这套系统里文本分支、图像分支、融合层是三个独立模块,方便你单独替换。
3.1 文本分支:用 BERT 提取语义,别从零训 LSTM
文本分支的选型在这套项目里用的是预训练 BERT,不是从零训练的 LSTM。原因很现实:虚假新闻检测是语义理解任务,从零训练的循环神经网络需要海量语料才能学到复杂句间关系,期末大作业和毕设根本没有这个数据量。BERT 在开源语料上已经学到通用语义,你只需要微调最后几层。
# text_encoder.py from transformers import BertModel class TextEncoder(nn.Module): def __init__(self, freeze_bert=True): super().__init__() self.bert = BertModel.from_pretrained("bert-base-uncased") # 冻结 BERT 全部参数,只训练后续层,显存友好 if freeze_bert: for p in self.bert.parameters(): p.requires_grad = False def forward(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) # 取 [CLS] 向量作为整条文本的语义表示,维度 768 cls_feat = outputs.pooler_output return cls_featfreeze_bert=True这个参数是策略性的:冻结 BERT 后显存占用降到原来的三分之一左右,单卡 6GB 也能跑;代价是文本表征能力上限略低,但期末大作业完全够用。如果显存够大、想让效果更好,设成False微调 BERT,学习率必须调到 2e-5 这个量级,用默认的 1e-3 会让预训练权重直接被冲毁。
3.2 图像分支:ResNet 做视觉特征,砍掉分类头
图像分支采用 ResNet50 作为特征提取器,去掉最后一层全连接分类头,只保留前面的卷积特征。ResNet 的残差结构让梯度能顺畅传到浅层,训练比 VGG 稳定得多,尤其适合多模态联合训练的场景。
# image_encoder.py import torchvision.models as models class ImageEncoder(nn.Module): def __init__(self, freeze_resnet=True): super().__init__() resnet = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) # 去掉最后的 fc 层,取出特征提取部分 self.features = nn.Sequential(*list(resnet.children())[:-1]) def forward(self, x): # x: (batch, 3, 224, 224) feat = self.features(x) # (batch, 2048, 1, 1) return feat.flatten(1) # (batch, 2048)flatten(1)把空间维度压平,得到 2048 维向量。ResNet50_Weights.DEFAULT在 PyTorch 2.0 以上版本会自动下载预训练权重,如果网络受限,手动下载后放到项目目录,再改from_pretrained方式的加载路径。冻结策略和 BERT 分支保持一致,先冻结省显存,跑通后再考虑解冻。
3.3 融合层:早期拼接与注意力加权的选型差异
融合层是这套系统的灵魂。最简单的方案是把 768 维文本向量和 2048 维图像向量直接拼接,送进 MLP;但实践中发现,直接拼接相当于让分类器自己学到两个模态的相对权重,数据量不够时容易过拟合。
# fusion.py class ConcatFusion(nn.Module): def __init__(self, text_dim=768, image_dim=2048, hidden_dim=256, num_classes=2): super().__init__() self.classifier = nn.Sequential( nn.Linear(text_dim + image_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.3), nn.Linear(hidden_dim, num_classes) ) def forward(self, text_feat, image_feat): fused = torch.cat([text_feat, image_feat], dim=-1) return self.classifier(fused)改进方案是注意力加权融合:让模型学一个标量权重,判断当前样本是文本更可信还是图像更可信。比如新闻正文文字信息量大、配图模糊,模型应该把注意力偏向文本;反之则偏向图像。
# fusion.py 中的注意力融合 class AttentionFusion(nn.Module): def __init__(self, text_dim=768, image_dim=2048, hidden_dim=128): super().__init__() # 先把两个模态映射到同一维度 self.text_proj = nn.Linear(text_dim, hidden_dim) self.image_proj = nn.Linear(image_dim, hidden_dim) self.attn = nn.Linear(hidden_dim, 1) def forward(self, text_feat, image_feat): text_h = torch.tanh(self.text_proj(text_feat)) image_h = torch.tanh(self.image_proj(image_feat)) # 算各自注意力分数,softmax 归一化成权重 t_score = self.attn(text_h) # (batch, 1) i_score = self.attn(image_h) weights = torch.softmax(torch.cat([t_score, i_score], dim=-1), dim=-1) # 加权求和 fused = weights[:, 0:1] * text_feat + weights[:, 1:2] * image_feat return fused两个融合方案在绝大多数公开数据上,注意力融合比直接拼接高 2 到 4 个点的 F1,原因是它显式建模了模态置信度。但这套系统的classifier.py里两种都实现了,你可以在config.py里用fusion_type="attention"或"concat"切换,跑一遍对比实验,这两个对比数据放进毕设的实验中正好是"消融"的素材。
4. 训练与评估:损失设置、参数调节与指标解读
模型结构搭好后,训练环节是最容易被忽略、也是翻车率最高的部分。这套系统的train.py把训练循环写得比较直白,适合看懂每一步在做什么,然后把参数换成你自己的。
4.1 训练主循环:数据加载、梯度更新与日志记录
训练循环的核心是数据加载和梯度更新。有个关键细节:文本分支和图像分支需要不同的学习率,BERT 微调用 2e-5,ResNet 和分类头用 1e-3,如果统一用一个学习率,结果大概率是 BERT 权重被破坏或图像分支收敛太慢。
# train.py 核心逻辑(省略样板代码) from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True ) # 两个参数组,分别用不同学习率 optimizer = torch.optim.AdamW([ {"params": text_encoder.parameters(), "lr": 2e-5}, {"params": image_encoder.parameters(), "lr": 1e-3}, {"params": fusion.parameters(), "lr": 1e-3}, ]) loss_fn = nn.CrossEntropyLoss() for epoch in range(10): for batch in train_loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) images = batch["image"].to(device) labels = batch["label"].to(device) # 前向:三个模块各自输出 text_feat = text_encoder(input_ids, attention_mask) image_feat = image_encoder(images) logits = fusion(text_feat, image_feat) # 分类头在 fusion 内部,logits 直接算损失 loss = loss_fn(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() if step % 50 == 0: print(f"Epoch {epoch} Step {step} Loss {loss.item():.4f}")DataLoader里的shuffle=True对训练集必备,验证集必须shuffle=False。pin_memory=True在 GPU 训练时能减少数据从 CPU 到 GPU 的拷贝时间。10 个 epoch 是 baseline,跑完看验证集曲线再决定加不加轮次。
4.2 评估指标:ACC、F1 和 AUC 在虚假新闻场景里的含义
评估脚本evaluate.py输出四样东西:准确率、F1 分数、AUC 和混淆矩阵。这里要特别注意,在虚假新闻检测场景里,正负样本往往不平衡——真实新闻占多数,谣言占少数。只看 ACC 会被带偏,模型把所有样本都判成真实新闻也能有 80% 的准确率,但这个模型没有任何用处。F1 才是核心指标,它同时惩罚漏报和误报:
# evaluate.py 核心逻辑 from sklearn.metrics import accuracy_score, f1_score, roc_auc_score, confusion_matrix with torch.no_grad(): all_preds = [] all_probs = [] all_labels = [] for batch in val_loader: logits = model(batch) probs = torch.softmax(logits, dim=-1) preds = probs.argmax(dim=-1) all_probs.extend(probs[:, 1].cpu().numpy()) all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch["label"].cpu().numpy()) acc = accuracy_score(all_labels, all_preds) f1 = f1_score(all_labels, all_preds) auc = roc_auc_score(all_labels, all_probs) cm = confusion_matrix(all_labels, all_preds)看结果时先看 F1 和 AUC,再看 ACC。如果 F1 明显低于 ACC,说明模型在少数类上表现差,需要处理类别不平衡,后面的避坑章节会展开。AUC 高于 0.85 这个项目基本能撑住答辩了,0.9 以上是优秀水平。
4.3 参数表:学习率、batch size、epoch 的合理范围
这套系统默认配置适合在普通消费级 GPU 上跑通,参数调整有一个大致的合理范围,超出范围模型就不容易收敛:
| 参数 | 推荐值 | 调整方向说明 |
|---|---|---|
| BERT 学习率 | 2e-5 ~ 5e-5 | 超过 1e-4 预训练权重会被破坏 |
| ResNet 学习率 | 1e-3 ~ 3e-3 | 低于 1e-4 收敛太慢 |
| batch_size | 16 ~ 32 | 大幅超出显存会 OOM |
| epoch | 8 ~ 15 | 超过 15 容易过拟合,看验证集 F1 早停 |
| dropout | 0.2 ~ 0.5 | 数据量小用偏大值,防止过拟合 |
| max_len | 128 ~ 256 | 短文本 128 够,长新闻用 256 |
# config.py 参数全在这里,改这一个文件即可 class Config: text_model = "bert-base-uncased" image_model = "resnet50" fusion_type = "attention" # 或 "concat" max_len = 128 batch_size = 32 epochs = 10 bert_lr = 2e-5 image_lr = 1e-3 dropout = 0.3 num_classes = 2验证集上的 F1 不再上升时,直接早停,不要等到 epoch 跑完。每轮保存一次最佳权重best_model.pt,后面预测、答辩演示都用这个文件,不是用最后一轮的。
5. 避坑指南:五个最常见翻车现场与对应解法
这一章是我在拆这个项目时遇到过的最实在的问题。每一条都是真实发生过的报错或烂效果,按"现象、原因、解决"三件套给你留个底。
5.1 特征维度对不上:text 和 image 在 batch 维度上错位
现象:训练跑起来第一轮就报错,RuntimeError: size mismatch,提示文本特征的第二维和图像特征的第二维不匹配。
原因:常见的是数据集里某条样本只有文本没有图片,或者图片路径失效,load_image返回了空张量。更隐蔽的一种是 DataLoader 的collate_fn把文本 tensor 堆叠成(batch, seq_len),而图像 tensor 是(batch, 3, H, W),在某个分支误用了batch.first()或显式索引,导致维度错位。
解决:先检查图片文件是否全部存在,缺失的样本直接丢弃或在dataset里用占位图代替。然后在模型 forward 的第一行加断言:
assert text_feat.shape[0] == image_feat.shape[0], "batch dim mismatch"如果 batch 维度一直对不上,优先查 DataLoader 的drop_last=True参数。总样本数不能被 batch_size 整除时,最后一个 batch 会少几条样本,而某些实现里对短 batch 的处理会出错。
5.2 GPU 显存溢出:OOM 永远在最差的时机出现
现象:训练到一半,CUDA out of memory,前面几轮日志全部白跑。
原因:BERT 加上 ResNet 两个大模型同时在前向和反向,梯度图占用的显存远超预期。特别是 BERT 分支没有冻结,反向传播时要保存所有中间激活值,显存消耗暴涨。
解决:先用freeze_bert=True和freeze_resnet=True跑通,确认效果后再考虑解冻。如果还是不够,把batch_size从 32 降到 8,这是最直接的手段。还有一种做法是梯度累积,用多个小 batch 累加梯度再更新,模拟大 batch:
accumulation_steps = 4 loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()显存本身不足的情况下,这个技巧让 16 的 batch 跑出 64 batch 的稳定效果,代价是训练时间变长。
5.3 验证集 F1 一直很低:模型把所有样本判成真实新闻
现象:ACC 有 0.8,F1 只有 0.2 左右,查看混淆矩阵发现模型几乎没预测出任何一条谣言。
原因:典型的类别不平衡。数据集中真实新闻占比远高于谣言,交叉熵损失被多数类主导,少数类的梯度被淹没。
解决:两个方案。首先是给损失函数加类别权重,让少数类样本的 loss 贡献更大:
class_weights = torch.tensor([1.0, 5.0]).to(device) # 少数类权重调大 loss_fn = nn.CrossEntropyLoss(weight=class_weights)class_weights的设置频率一般是多数类样本数 ÷ 少数类样本数,比如比例 5:1 就设1.0和5.0。第二个方案是改用 Focal Loss,它在交叉熵基础上自动降低易分类样本的权重,专治这种不平衡场景,在loss.py里可以直接替换。
5.4 中文数据乱码或分词不可用:tokenizer 选错
现象:用微博中文谣言数据集训练,loss 不下降,打印出来 input_ids 解码全是[UNK]。
原因:BertTokenizer.from_pretrained("bert-base-uncased")是英文模型的分词器,对中文字根本不认识。中文和英文的 token 切分逻辑完全不同,必须用中文预训练模型对应的分词器。
解决:换用支持中文的模型和分词器:
from transformers import BertTokenizer, BertModel tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") text_encoder = TextEncoder(model_name="bert-base-chinese")注意换模型后,文本特征维度如果变了,fusion.py里的text_dim要同步修改。另外中文文本清洗时,不要用英文的正则规则去删空格,中文的词与词之间没有空格,重点清理的应该是特殊符号和 emoji。
5.5 预训练权重下载失败或超时
现象:第一次运行from_pretrained卡在下载,或者报Connection error,整个流程推进不下去。
原因:HuggingFace 和 PyTorch 的权重托管在国外服务器,网络环境不稳定时下载会中断,尤其在跑期末项目时遇到这种情况最让人头大,会误以为是代码问题。
解决:提前手动下载权重文件放到本地,然后用本地路径加载:
tokenizer = BertTokenizer.from_pretrained("/path/to/bert-base-uncased/") model = BertModel.from_pretrained("/path/to/bert-base-uncased/")下载完成后检查目录里必须有config.json和pytorch_model.bin两个文件,缺哪个都会报错。ResNet 权重同理,torchvision下载失败时从源站手动下载.pth文件,再通过state_dict手动加载。
6. 进阶玩法:消融实验、注意力可视化与轻量推理
这套系统把基础流程跑通之后,真正的加分项在进阶验证上。答辩时老师最常问的问题就是"你怎么证明多模态融合是有效的"和"模型具体看的是什么",这两个问题用消融实验和注意力可视化就能回答得漂亮。
6.1 消融实验:证明每个模态真的有贡献
消融实验就是把某个分支拿掉看效果掉多少。用这套代码实现很简单:临时把text_encoder输出换成全零向量跑一遍,再把image_encoder输出换全零跑一遍,和完整模型对比三个 F1:
# ablate.py 核心逻辑 full_f1 = run_eval(text_encoder, image_encoder, fusion) # 完整模型 no_text_f1 = run_eval(ZeroEncoder(), image_encoder, fusion) # 去掉文本 no_image_f1 = run_eval(text_encoder, ZeroEncoder(), fusion) # 去掉图像我一般会让ZeroEncoder输出和原模型同样维度的全零向量,这样融合层的维度完全不动,代码改动最小。完整版比两个单模态版本各高 3 到 5 个点,这就是你答辩时的效果图素材。
6.2 注意力可视化:抓出模型在看哪张图哪个区域
如果图像分支用的是注意力融合,你可以把AttentionFusion里的权重提取出来,画成条形图。这能清楚展示模型对这条新闻是更信任文本还是更信任图像,也是多模态模型"可解释性"的直接证据。
具体做法是在推理模式下把weights保存下来,对应标注成"文本注意力"和"图像注意力",画在一张图上,每个样本两条柱。你会发现传播力度大的谣言样本,图像注意力明显偏高——因为配图往往是伪造的关键证据,模型学习到了这一点。
6.3 轻量推理:导出权重写一个单条预测脚本
跑完训练后,把三部分模型打包成一个推理脚本,方便演示:
# predict.py def predict_single(text: str, image_path: str, model_path="best_model.pt"): # 加载三个模块并合并成一个 nn.Module model = load_all_modules(model_path) model.eval() with torch.no_grad(): input_ids, attention_mask = text_to_tokens(text) image = load_image(image_path) # 推理流程与训练一致,只是去掉 backward text_feat = model.text_encoder(input_ids.unsqueeze(0), attention_mask.unsqueeze(0)) image_feat = model.image_encoder(image.unsqueeze(0)) prob = torch.softmax(model.fusion(text_feat, image_feat), dim=-1) return {"真实": prob[0][0].item(), "谣言": prob[0][1].item()}这个脚本是答辩现场的演示核心,输入一条新闻文本加一张配图,直接输出两个概率值。从那以后我每次做多模态项目,都会强制走一遍"训练→消融→推理"三连,因为光有训练指标根本撑不住提问环节,能现场跑出结果才是真正把项目做透了。希望这套源码和文档能帮你在期末大作业或毕设上省下几个通宵,少踩几个我已经踩过的坑。
本文还有配套的精品资源,点击获取