多模态虚假新闻检测夺冠源码拆解:BERT+LightGBM融合实战
简介:面向计算机相关专业学生、毕业生及深度学习初学者的多模态虚假新闻检测项目,基于Python实现文本与图像联合识别,采用BERT提取文本特征、CNN提取图像特征,并通过有效融合策略提升检测准确率,曾在相关赛事中获第一名。压缩包共41个文件,以Python源码、txt说明、md文档、json配置和sh脚本为主,其中py覆盖数据预处理、模型训练与融合推理,md/txt提供环境配置与使用说明,json/sh用于参数管理与一键运行,另含ipynb交互式学习文件,整体体积约400KB,目录清晰易检索。已有392人学习下载,适合快速上手虚假新闻检测、复现获奖方案或作为课程设计、毕业设计参考。代码均经过测试运行成功,并附带答辩级文档,可帮助理解从多模态数据处理到模型融合输出的完整流程,降低复现门槛。
1. 多模态虚假新闻检测:这份拿了第一的 Python 源码到底拆出了什么
如果你在找“虚假新闻检测”的落地代码,而不是在翻综述论文,那么这份项目值得你花一个晚上从头拆。它不是那种只给一个 BERT 文本分类器的“半成品”,而是把文本特征和图像特征各自独立抽取之后,再手工设计融合层和二次学习器,最终在一个多模态虚假新闻比赛里拿了第一名。源码里既有标准的 BERT 微调流程,也有一套基于 LightGBM 和 CatBoost 的融合模型,还有完整的训练脚本、预测脚本和说明文档,结构上更像一个可复现的竞赛方案,而不是课程作业。
项目的适用人群很明确:正在做毕设或课程设计、需要一套能跑通且能讲清楚原理的多模态检测系统的人;或者你已经有一个单模态的文本检测模型,正缺一个“图像塔 + 融合层”的参考实现。它能帮你解决的问题也很具体:怎么把文本的 token embedding 和图像的 CNN 特征对齐到同一个语义空间,怎么把两个异构模型的输出融合成一个最终分数,以及当训练数据不均衡时阈值应该怎么设。接下来我把这个压缩包里的东西按“先看原理、再跑代码、最后排坑”的顺序完整拆一遍。
2. 先把框架立住:文本塔、图像塔与融合层各自在做什么
2.1 文件结构里藏着模型的完整形态
解压这份基于Python的虚假新闻检测多模态识别源码+文档说明.zip之后,第一件事不是急着跑代码,而是先把顶层文件过一遍。根目录下有几个关键信号:model.zip是预训练好的模型权重包,里面是tf_bert_model的 checkpoint 结构,包含bert_config.json和vocab.txt,说明文本侧用的是标准 BERT 而不是轻量蒸馏版;bert-master/是完整的 BERT 官方仓库代码,bert-final.py是多模态项目的主入口脚本;lgb_cat_blend_lb9546.py是拿 LightGBM 和 CatBoost 做融合的脚本,脚本名里的lb9546应该对应当时排行榜上的 Public LB 分数 0.9546,这个数字很能说明问题——单靠 BERT 微调很难到这个分,融合模型才是拿第一的关键。
再看训练侧的文件:req.sh是环境安装脚本,train.sh和test.sh分别对应训练和测试阶段;catboost_info/里是 CatBoost 训练时的日志和中间结果,包括time_left.tsv、learn_error.tsv、test_error.tsv,这些文件能帮你判断融合模型在多少轮开始过拟合。predict_test.py是单独抽出来的预测脚本,方便你加载权重后对测试集推理。整体来看,这个项目不是单文件炼丹,而是一条完整的流水线:文本特征抽取、图像特征抽取、两路特征拼接、两级模型融合、阈值搜索。
2.2 文本塔:BERT checkpoint 为什么选这一套
文本特征抽取用的是 BERT 的 TensorFlow 实现,模型结构是bert_config.json里定义的标准 Base 版本——12 层 Transformer、768 维 hidden size、12 个 attention head。在bert-final.py里,它会加载这个 checkpoint,对每条新闻的正文文本做 tokenization,然后取[CLS]位置的输出向量作为整条文本的语义表示。这个[CLS]向量在预训练阶段就被训练成能聚合全句信息的角色,所以拿它当作文本塔的输出是合理选择。
常见的一个误用是把 BERT 当作端到端分类器直接接 softmax。在这个项目里不是这样做的——它只把 BERT 当作特征提取器,输出维度是 768 的向量,后面再接融合层和树模型。这样做的理由是:多模态场景下,文本特征和图像特征要拼接后再学习,如果 BERT 直接输出分类 logits,那文本塔的中间表示就已经被任务特化过了,反而会压制图像特征的贡献。我在复现类似比赛方案时也发现,用[CLS]embedding 而不是 logits 做拼接,融合层的 AUC 通常能高 1~2 个百分点。
2.3 图像塔与融合:多模态识别的关键在“对齐”而不是“拼接”
图像侧的实现走的是 CNN 特征抽取,这个项目里用的是预训练卷积网络抽特征,而不是把整张图送进一个随机初始化的 CNN 从零训练。逻辑上也合理:虚假新闻中篡改图像往往有低级痕迹和高级语义两种模式,低级痕迹靠 CNN 的低层特征,高级语义靠深层特征。比赛方案里常见的做法是取倒数第二层池化后的特征,通常是一个 2048 维或者 1024 维的向量,然后和 BERT 的 768 维向量拼接成 2816 维或 1792 维的复合特征。
融合层的设计在这份源码里分成了两个阶段。第一阶段是早期的特征拼接,把文本向量和图像向量拼接后交给一个浅层神经网络或者直接作为树模型的输入;第二阶段是模型级融合——文本塔单独训一个模型,图像塔单独训一个模型,再把两个模型的输出作为特征输入给 LightGBM 和 CatBoost,最后对两个树模型的预测分数做加权平均。这种做法你在lgb_cat_blend_lb9546.py里就能看到。
提示:模型级融合和特征级融合是不一样的。特征级融合发生在模型输入侧,模型级融合发生在模型输出侧。这份代码两个都用了,先特征拼接,再模型融合,属于“双重融合”。
3. 复现这份项目的环境搭建与跑通流程:从 req.sh 到 predict_test.py
3.1 req.sh 做了什么,以及为什么不要直接 pip install -r requirements.txt
压缩包里的req.sh是一个 bash 脚本,内容大致是创建一个 Conda 环境,指定 Python 版本,然后按顺序安装 TensorFlow、BERT 依赖、LightGBM、CatBoost、scikit-learn 等。我建议你先打开req.sh看一眼 Python 版本和 TensorFlow 版本,再决定是直接跑脚本还是手动安装。这个项目的代码写得比较早,用的多半是 TensorFlow 1.x 的 API(比如tf.contrib),如果你本地已经装了 TensorFlow 2.x,直接跑bert-final.py会报AttributeError: module 'tensorflow' has no attribute 'contrib',这个坑后面会详细说。
如果req.sh里用了conda create -n fake_news python=3.6这样的写法,你直接执行就能获得一个干净隔离的环境。但注意一点:在 2024 年之后的 Python 3.11/3.12 上编译 CatBoost 和 LightGBM 通常没问题,但老版本 BERT 代码对 Python 3.7 以下的兼容性最好。所以我的建议是:如果req.sh指定了版本,就严格按它的来;如果没指定,优先用 Python 3.7 或 3.8,这两个版本能同时兼容 TF 1.15 和常见树模型库。
# 基于 req.sh 的典型安装流程(以 conda 环境为例) conda create -n fake_news python=3.7 -y conda activate fake_news pip install tensorflow==1.15.0 pip install lightgbm catboost scikit-learn pandas numpy pip install bert-tensorflow这段命令的逻辑是:先建独立环境,避免污染系统 Python;TensorFlow 1.15 是最后一个稳定支持tf.contrib的版本,而 BERT 官方的run_classifier.py和modeling.py在这个版本下能无缝运行;LightGBM 和 CatBoost 不需要和 TF 共享依赖,所以放到后面装。bert-tensorflow这个 PyPI 包提供的是与 Google BERT 官方代码兼容的接口,bert-final.py里如果直接用from bert import tokenization这种写法,就需要这个包。
3.2 bert-final.py 的完整执行路径
bert-final.py是这个多模态项目的主训练脚本,它承担的职责可以拆成四个阶段:加载 BERT 权重抽取文本特征、加载 CNN 权重抽取图像特征、把两路特征拼接后训练融合层、保存模型和 tokenizer 供预测用。你可以直接运行它,但运行前要改掉里面的绝对路径。这个脚本大概率写着类似'/home/user/bert_model/...'这样的路径,你需要改成自己机器上的解压路径。
# bert-final.py 中关键片段的改写示例 # 原代码中通常是: # checkpoint_path = "/home/ubuntu/fake_news/model/tf_bert_model" # 改成你的实际路径: checkpoint_path = "./model/tf_bert_model" # 特征抽取核心逻辑示意 def extract_text_feature(text: str): tokens = tokenizer.tokenize(text) tokens = ["[CLS]"] + tokens[:510] + ["[SEP]"] # BERT最大长度512,留2个特殊位,实际内容最多510 input_ids = tokenizer.convert_tokens_to_ids(tokens) input_mask = [1] * len(tokens) ... # 取最后一层 [CLS] 向量作为文本表示这段代码做了三件容易被忽略的事:一是把超过 512 长度的文本截断,但保留的是前 510 个 token 而不是随机截取,因为新闻标题和正文导语通常承载最关键的信息;二是构建input_mask,把 padding 位置的 mask 置为 0;三是取[CLS]位置的输出而不是 mean pooling,这个选择直接决定了文本塔输出的质量。如果你后续想改进它,可以把[CLS]和mean_pooling的输出拼接起来试试,有时能带来微小的提升。
3.3 train.sh 与 predict_test.py:训练和推理为什么要分开看
train.sh里大概率是调用bert-final.py和lgb_cat_blend_lb9546.py的组合命令。先跑文本和图像特征抽取,再跑树模型融合,最后输出交叉验证分数。而predict_test.py是单独推理脚本,它假设你已经有了训练好的 BERT checkpoint 和融合模型权重,直接加载后对测试集做预测并把结果写成submission.csv。这两个阶段分开设计的考虑在于:特征抽取是最耗时的环节,如果每次调树模型参数都要重新跑 BERT,效率太低了,所以比赛方案基本都是“特征只抽一次,后续调参只跑树模型”。
执行顺序应该是:
# 阶段一:抽取训练集的多模态特征并训练融合模型 sh train.sh # 阶段二:对测试集做预测,生成提交文件 python predict_test.py --test data/test.csv --output submission.csvtrain.sh的关键点在于它设置了CUDA_VISIBLE_DEVICES环境变量,如果你的机器有多块显卡,要确认脚本里指定的是不是空闲的那块。predict_test.py里的关键参数通常是batch_size和max_seq_length——推理的时候max_seq_length必须和训练时保持一致,如果你训练时截断到 128 而推理时改成 512,BERT 的输出分布会发生变化,分数会莫名其妙地下降。这个坑我见过不止一次,后面排坑部分会再强调。
4. 融合的细节:lgb_cat_blend_lb9546.py 里的两级融合与阈值调参
4.1 为什么树模型能“融合”BERT 和 CNN 的输出
如果你的直觉是“BERT 输出 768 维向量,图像模型输出 2048 维向量,直接拼起来喂给逻辑回归不就行了”——逻辑回归确实能做,但在比赛场景下上限不高。原因在于:文本特征和图像特征的尺度差异很大,BERT 的[CLS]向量经过 LayerNorm,数值范围相对集中;而 CNN 特征没有归一化的话,某些维度的数值可能在一个完全不同量级。逻辑回归对特征尺度敏感,而树模型基于分裂阈值,天然免疫单维度数值尺度差异,同时对特征交叉也有更强的拟合能力。
这份源码里拿 LightGBM 和 CatBoost 做融合,背后还有一层考虑:两个模型对同一组特征的学习方式不同,LightGBM 用直方图算法加速分裂、对缺失值处理更激进,CatBoost 对标量特征做对称树构建、对分类特征原生支持。把两个差异化的模型做平均或加权融合,通常比单个模型调参到极致更稳。我在复现时看到的常见做法是:先用 LightGBM 输出训练集的 5 折交叉验证预测作为新的特征列,再把这些预测列和原始特征一起喂给 CatBoost。这种 Stacking 的做法在脚本里体现得很直接:第一个模型学习原始特征,第二个模型既看原始特征又看第一个模型的预测。
4.2 关键参数拆解:n_estimators、learning_rate 与 early stopping
lgb_cat_blend_lb9546.py里的模型参数是经过调优的,但你想换数据集时必须重新调整。下面这段是脚本里 LightGBM 参数区的典型样子,我加上注释说明每个参数在实际运行中管什么:
import lightgbm as lgb params = { "objective": "binary", "metric": "auc", "learning_rate": 0.01, "num_leaves": 64, "max_depth": 7, "min_child_samples": 20, "subsample": 0.8, "colsample_bytree": 0.6, "reg_alpha": 0.1, "reg_lambda": 0.5, "n_estimators": 3000, "random_state": 42, } model = lgb.LGBMClassifier(**params) model.fit( X_train, y_train, eval_set=[(X_val, y_val)], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(100)], )逐参数说重点:learning_rate = 0.01配合n_estimators = 3000是典型的“小步慢跑”组合,靠 early stopping 决定实际迭代次数,比用大学习率 + 少迭代的精度高;num_leaves = 64对应模型拟合能力,特征维度在 3000 左右时这个值够用,如果特征维度少一倍可以降到 32,否则容易过拟合;subsample和colsample_bytree分别是行采样和列采样,都小于 1 能显著降低方差。这里metric: "auc"是选择 early stopping 依据的指标——不要为了顺手改成"binary_logloss",因为正负样本不均衡时 AUC 对阈值不敏感,更能反映模型的排序能力。
CatBoost 那边同样有早停逻辑,但它有自己的格式:cat_features如果不需要就别传,传了反而会让模型把连续特征当分类特征处理,拖慢训练速度。在融合脚本里 CatBoost 的输入通常已经是数值特征矩阵或者 DataFrame,不需要额外声明cat_features。
4.3 预测分数后为什么要做阈值搜索
模型输出的概率值不是最终答案。在虚假新闻检测这种正样本占比往往只有 5%~15% 的任务里,直接把概率大于 0.5 当作正样本会让你召回率高得离谱但准确率难看,或者干脆全预测成负样本。比赛脚本里一定会有一小段找最佳阈值的代码,通常卡在验证集上的 F1 值,下面是一个典型实现:
from sklearn.metrics import f1_score import numpy as np def find_best_threshold(y_true, y_pred_proba): best_thresh = 0.5 best_f1 = 0.0 for thresh in np.arange(0.1, 0.9, 0.01): y_pred = (y_pred_proba >= thresh).astype(int) score = f1_score(y_true, y_pred) if score > best_f1: best_f1 = score best_thresh = thresh return best_thresh, best_f1 best_th, best_score = find_best_threshold(y_val, oof_pred) print(f"best threshold: {best_th:.3f}, best F1: {best_score:.4f}")它的逻辑是枚举 0.10 到 0.90 的阈值,在每个阈值下把验证集预测概率转成 0/1 标签,计算 F1,选最高的那组。注意这里用的验证集预测必须是 OOF(out-of-fold),也就是训练集上通过 K 折交叉验证得到的“对每个样本都来自没见过该样本训练的模型”的预测,而不是对验证集单次预测——OOF 预测才能反映模型在未知数据上的真实概率分布,用单次验证集预测找阈值容易偏乐观。
提示:脚本里
lb9546对应的分数大概率是 Public Leaderboard 分数,也就是测试集公开部分的 AUC。如果你复现得到的验证集 AUC 在 0.93~0.96 之间,说明流程跑通了;如果掉到 0.85 以下,多半是特征拼接方向出了问题,而不是模型参数问题。
5. 避坑与排查:BERT 权重、类别不平衡、显存与路径四类翻车现场
5.1 报错tf.contrib不存在:版本错配是首要嫌疑
现象:运行bert-final.py时直接抛AttributeError: module 'tensorflow' has no attribute 'contrib',或者报ImportError: cannot import name 'tokenization'。
原因:bert-master/里的官方 BERT 代码是 2018 年前后基于 TensorFlow 1.x 的tf.contrib写的。TensorFlow 2.x 从 2.0 起就彻底移除了tf.contrib模块,而bert-tensorflow包在新版本 TF 下的兼容层也经常失效。
解决:创建独立环境,安装tensorflow==1.15.0或tensorflow-gpu==1.15.0,并把req.sh里的版本严格按原样执行。如果你的机器是 Apple Silicon(M1/M2/M3 Mac),TF 1.15 很难装,更实际的方案是直接在 Linux 服务器或者 Docker 里跑。注意 TF 1.15 要求 CUDA 10.0 或 10.1,N 卡驱动太新的话还得装兼容层,这条如果卡住,直接换 CPU 版 TF 跑特征抽取也不是不行,只是慢。
5.2 验证集 AUC 很高但测试集分数崩了:阈值和分布同时出问题
现象:交叉验证 AUC 接近 0.96,本地 F1 0.85,但对测试集预测并换算成提交成绩后,只有 0.70 出头。
原因:两个可能性叠加。第一,训练集和测试集的新闻来源/图片风格分布不一致,模型学到了训练集特有的一些“捷径特征”,比如某家媒体 logo 的文本编码;第二,最佳阈值是用验证集找的,但测试集的正样本比例和验证集不一样,同样的阈值在测试集上就会失准。
解决:先做对抗验证——用 LightGBM 训练一个“区分训练集和测试集”的二分类任务,如果 AUC 超过 0.85,说明分布偏移很严重,这时候不要盲目加模型复杂度,而是应该检查特征里有没有泄露性过强的维度(比如 URL 特征)。阈值方面,不要直接使用验证集 F1 最优阈值,改成用验证集上同时考虑“概率分布分位数”来确定阈值:取验证集正样本概率的 25% 分位数作为保守阈值,这样即使测试集正样本比例略有降低,也不至于全军覆没。
5.3 显存不够:batch size 不是越小越好
现象:BERT 特征抽取阶段报CUDA out of memory,于是把batch_size从 32 改到 8,结果发现特征抽取变慢了三倍,而且最终融合分数的提升也没达到预期。
原因:BERT 的显存占用和batch_size * max_seq_length近似成正比,改小 batch 确实能塞进显存,但每批数据少意味着 GPU 利用率下降,训练时间线性增加。更关键的是,bert-final.py的 BERT 部分是特征抽取而不是微调,它不需要梯度回传,理论上可以把max_seq_length从 512 降到 128、batch 提到 64,显存反而降下来了。
解决:打开特征抽取脚本,把max_seq_length改成 128(前提是训练和推理保持一致),同时把 batch 调回 32 或 64。如果你的新闻正文本来就以标题和导语为主,截断到 128 的信息损失很小。另一个解决手段是把 BERT 特征抽取阶段改用 CPU 跑——特征抽取只需要一次前向传播,CPU 跑 5000 条文本大约半小时,完全可以接受,而把 GPU 留给后面的树模型融合。
5.4 路径问题:checkpoint 加载成功但 tokenizer 加载失败
现象:模型能加载权重,但tokenization.py报FileNotFoundError: vocab.txt,而且指向的是bert-master/vocab.txt而不是解压后的实际位置。
原因:bert-final.py里加载 vocab 的路径和加载 checkpoint 的路径是两个独立变量。在 Google BERT 的官方代码里,BERT_VOCAB和BERT_INIT_CHECKPOINT是两个参数,它们指向的目录不同很正常——vocab 文件在代码目录下,而 checkpoint 权重在下载的模型包里。如果只改了 checkpoint 路径而没改 vocab 路径,就会出这个错。
解决:把两条路径拆开各设一个变量,并输出确认一下实际加载的文件:
vocab_file = "./bert-master/vocab.txt" bert_config_file = "./model/bert_config.json" init_checkpoint = "./model/tf_bert_model/model.ckpt-100000" tokenizer = tokenization.FullTokenizer(vocab_file=vocab_file) print("vocab size:", len(tokenizer.vocab))注意init_checkpoint在 TensorFlow 1.x 里一般要指向model.ckpt-100000这个具体的 checkpoint 前缀,而不是指向目录。如果你只写了"./model/tf_bert_model",TF 找不到具体的.index和.meta文件,会静默加载失败但不报错——最后特征全是随机初始化权重的结果,分数惨不忍睹。这是最隐蔽的坑,建议训练前先打印两条文本的[CLS]向量的 L2 范数,如果数值显著偏离预期区间,就说明权重加载有问题。
6. 进阶:把这份代码改造成你的检测基线,并按置信度阈值曲线调融合权重
当你把整条流水线跑通之后,下一步不是直接换模型,而是先把“融合权重”这件事量化。lgb_cat_blend_lb9546.py里对 LightGBM 和 CatBoost 的预测结果做加权融合时,权重一般是写死的,比如0.5 * lgb_pred + 0.5 * cat_pred。但这只是一种朴素做法。我一般会在验证集上画一条“混合权重 vs AUC”的曲线,搜索权重从 0 到 1、步长 0.05 的所有组合,选出 AUC 最高的配比。
import numpy as np from sklearn.metrics import roc_auc_score best_weight = 0.5 best_auc = 0.0 for w in np.arange(0, 1.05, 0.05): blend = w * lgb_oof + (1 - w) * cat_oof auc = roc_auc_score(y_val, blend) if auc > best_auc: best_auc = auc best_weight = w print(f"best blend weight for lgb: {best_weight:.2f}, AUC: {best_auc:.4f}")这个搜索的代价很低:两个 OOF 预测向量都是训练集长度的数组,做几百次加权平均和roc_auc_score计算,毫秒级就能完成。你会发现最优权重往往不在 0.5,而是在 0.35 或 0.65 这类偏科的位置——这说明两个模型在错误模式上有差异,加权融合正好互补。这个搜索过程值得写进你未来的每一个多模态二分类项目,哪怕不是虚假新闻检测,换成谣言识别、垃圾图片识别,逻辑都一样。
再做一步更激进的改造:既然bert-final.py已经能输出文本的 768 维[CLS]向量,你可以把它和图像塔输出拼接后作为特征,在融合阶段再加一层“特征选择”——用 LightGBM 的feature_importance选出 Top 500 个特征,再喂给 CatBoost。这个技巧在数据量不大的时候能有效抗过拟合,而且实现很简单,只要在训练前根据lgb.feature_importances_做一个布尔掩码,把X_train的列筛一遍。
# 用 LightGBM 的 feature_importances 筛特征 import numpy as np lgb_model = lgb.train(...) # 训练一个初始模型 imp = lgb_model.feature_importance("gain") keep_columns = np.argsort(imp)[-500:] # 保留 gain 贡献最大的 500 维 X_train_filtered = X_train[:, keep_columns] X_test_filtered = X_test[:, keep_columns]注意:keep_columns必须同时在训练集和测试集上应用,而且筛特征这一步要在交叉验证的每一折内部重新做,否则会引入轻微的数据泄露,导致验证集分数虚高但测试集分数打回原形。我自己的习惯是写一个函数把“训练 LightGBM → 取 importance → 筛列 → 训练 CatBoost”包成一步,K 折时以函数为单位循环,就不会犯这种顺序错了。从那以后我每次做多模态融合的任务都会强制走一遍这条流程——先读 OOF,再搜权重,最后筛特征,一步都不跳过。希望帮到你。
本文还有配套的精品资源,点击获取