简介:面向高校期末大作业、课程设计与NLP入门学习者,这是一套基于Python的虚假新闻多模态识别完整工程,重点解决文本与图像两类信息的特征融合及真伪判别问题。项目覆盖BERT模型微调、LGB与CatBoost融合、训练与预测流程组织等环节,代码注释详细,结构清晰,便于复现和二次开发。包内共39个文件:16个Python源码覆盖数据预处理、模型训练与结果预测,3个Shell脚本用于环境部署与一键运行,4个Markdown和4个TXT提供方案说明、使用指南与训练记录,另有JSON配置、Jupyter Notebook、checkpoint与训练日志,可完整还原实验过程;压缩包整体仅353KB,轻量易用。目前已有389人学习下载,既适合作为课程设计或期末大作业的高分参考,也能帮助读者理解多模态检测的完整工程落地方式,从数据准备、特征融合到模型评估均可按图索骥。
1. 多模态虚假新闻检测不是堆模型:从一份 97 分的代码说起
做基于 Python 的虚假新闻检测,最怕听到“多模态”三个字就以为要同时端出 CNN、BERT 和一串花哨框架。拆这份项目的时候我发现,真正让它拿到高分的关键是分工:BERT 管文本语义,图像分支管视觉线索,LightGBM 和 CatBoost 管最终分类。
code/lgb_cat_blend_lb9546.py这个文件名本身信息量挺大,lb9546大概率是融合模型在某次验证里拿到的 0.9546 分。代码注释完整,目录结构清晰,课程设计和期末大作业复现都很合适。新手按 README 搭环境能跑通,熟手也能在特征层和二开层面继续榨价值。
2. 文本分支:bert-final.py 如何把新闻正文变成向量
2.1 先确认 BERT 路径:本地源码 + TensorFlow 预训练权重
model/tf_bert_model里放的是 TensorFlow 格式的 BERT 预训练权重,code/bert-master里是 BERT 官方仓库源码。bert-final.py采用本地源码导入,而不是用 transformers 包:
import sys sys.path.append('./bert-master') from tokenization import FullTokenizer from modeling import BertModel, BertConfig BERT_CONFIG = './model/tf_bert_model/bert_config.json' VOCAB_PATH = './model/tf_bert_model/vocab.txt' CKPT_PATH = './model/tf_bert_model/bert_model.ckpt'这样写有两个现实原因。第一,官方源码用 TensorFlow 1.x 的 Session 加 checkpoint 加载方式,和tf_bert_model目录天然匹配;第二,把bert-master完整打进来,复现时不用再拉外部仓库,离线环境也能跑。
提示:别图省事把
bert-master换成 transformers 的BertForSequenceClassification,两边的 checkpoint 变量命名不通用,saver.restore大概率报变量不存在。
2.2 新闻文本清洗:标题和正文怎么拼才不丢线索
虚假新闻的输入一般是标题加正文。标题往往承载最大争议点,正文提供事实铺陈。清洗时注意别过度:
import re def clean_text(raw): raw = re.sub(r'<[^>]+>', '', raw) # 去 HTML 标签 raw = raw.replace('\n', ' ').replace('\r', ' ') return ' '.join(raw.split()) title = clean_text(title) body = clean_text(body) text = title + '[SEP]' + body[SEP]让模型知道前一段是标题、后一段是正文。清洗正则只需要处理 HTML 标签和空白符。真实爬下来的新闻正文常带图片说明、版权声明,但清洗别太狠,有些造假线索恰恰藏在异常 URL、时间戳、大小写突变这类细节里。
2.3 tokenization 后必须同时拿到三个等长序列
BERT 模型的真实输入是input_ids、input_mask、segment_ids三个等长数组,不是字符串。三个数组长度不一致时,feed_dict 阶段直接报 shape 错误。常见实现:
def encode_text(text, tokenizer, max_seq_length=128): tokens = tokenizer.tokenize(text) if len(tokens) > max_seq_length - 2: tokens = tokens[:max_seq_length - 2] # 预留 [CLS] 和 [SEP] 两个位置 tokens = ['[CLS]'] + tokens + ['[SEP]'] input_ids = tokenizer.convert_tokens_to_ids(tokens) input_mask = [1] * len(input_ids) segment_ids = [0] * len(input_ids) pad_len = max_seq_length - len(input_ids) input_ids += [0] * pad_len input_mask += [0] * pad_len segment_ids += [0] * pad_len return input_ids, input_mask, segment_ids几个关键参数要记住:
max_seq_length=128:标题加正文前 126 个 subword,通常覆盖核心事实。设 256 会明显拉高显存占用,收益并不成比例。tokenizer.tokenize会把英文单词切成 subword,比如deepfake可能被切三块,截断长度必须以切词后的结果算。- 三个数组用 0 补齐,
input_mask里 0 的位置在 BERT 内部不会参与 Attention 计算。
2.4 抽 [CLS] 向量并缓存:树模型阶段不要再跑 BERT
BERT 的pooled_output是句级语义向量,比把整串 token 隐层拉平更适合分类场景。bert-final.py的核心推理片段:
saver = tf.train.Saver() with tf.Session() as sess: saver.restore(sess, CKPT_PATH) feed_dict = { input_ids_ph: batch_ids, input_mask_ph: batch_mask, segment_ids_ph: batch_seg, is_training: False, } pooled = sess.run(pooled_output, feed_dict=feed_dict) np.save('./features/bert_text.npy', pooled)为什么要先抽特征缓存成 npy,而不是边训树模型边调 BERT?一是 BERT 推理耗时大,调参阶段反复重跑浪费算力;二是 LightGBM 和 CatBoost 只吃稠密特征矩阵,提前把特征固化能让融合阶段内存更可控。BERT-base 的pooled_output是 768 维,如果tf_bert_model是 large 结构就是 1024 维。后面和图像特征拼接前先确认这个维度,不然np.hstack会在某个隐蔽的维度错位处埋雷。
2.5 文本分支参数速查
| 参数 | 常见取值 | 说明 |
|---|---|---|
| max_seq_length | 64 / 128 / 256 | 128 性价比最高 |
| batch_size | 8 / 16 / 32 | 显存不足先减半 |
| learning_rate | 2e-5 ~ 5e-5 | BERT 微调常用区间,别用 1e-3 |
| num_epochs | 2 ~ 3 | 微调轮数过多会过拟合 |
| 特征维度 | 768(base) / 1024(large) | 以 bert_config.json 为准 |
BERT 微调的learning_rate比普通网络小一个数量级,因为预训练权重已经收敛,学习率太大会把语义空间冲坏。我一般取 3e-5,前 10% step 做 warmup,收敛最稳。如果你对 python 环境变量配置和虚拟环境还不太熟,这一步先按 README 把环境和路径对齐,再谈改参数。
文本特征缓存完成之后,./features/目录下至少会有bert_text.npy。建议顺手记录一个bert_text_shape.txt,把样本数和维度写下来,因为后续多模态拼接、五折交叉验证都需要拿这个 shape 做校验。我见过不止一次因为np.load出来的维度是(n, 1024)而程序里写死(n, 768)导致静默截断的问题,这种 bug 不会报错,只会让模型分数悄悄变差。
3. 多模态配合:图像特征与文本特征是怎么对齐的
3.1 图像侧用预训练 CNN 抽向量,而不是重新训练
虚假新闻配图常见的异常是旧图重用、截图被篡改、画面与正文无关。这些特征人眼能看,但用随机初始化的 CNN 从头学很慢,数据量不够时还容易过拟合。常见做法是加载 ImageNet 预训练权重,去掉全连接层,取最后一个池化输出作为图像特征:
from tensorflow.keras.applications import ResNet50 from tensorflow.keras.applications.resnet50 import preprocess_input from tensorflow.keras.preprocessing import image base_model = ResNet50(weights='imagenet', include_top=False, pooling='avg') def extract_img_feature(img_path): img = image.load_img(img_path, target_size=(224, 224)) x = image.img_to_array(img) x = preprocess_input(x[None, ...]) feat = base_model.predict(x)[0] return feat # 2048 维参数说明:target_size=(224, 224)是 ResNet 系列默认输入;pooling='avg'用全局平均池化把空间维度压掉;preprocess_input做 ImageNet 的归一化,不归一化直接喂图,特征分布会明显走偏。宽高比严重失衡的长截图,先补边再缩放,直接拉伸会改变画面比例,特征也跟着失真。
我一般会在图像特征抽取脚本里同时把news_id存下来,不是为了抽向量本身,是为了后续对齐。多模态项目绝大多数数据事故出在“顺序对不上”,而不是模型效果不够。
3.2 为什么两个模态都有必要
多模态虚假新闻检测能比单模态做得更好,核心原因是造假信号分散在两个独立的通道里:文本侧表现为情绪化措辞、空洞事实、事件时间倒错;图像侧表现为重复配图、时间戳异常、画面内容与标题无关。只看文本会漏掉“配图是旧图”这类强线索,只看图像会漏掉“文字在煽动”这类语义信号。
这份项目里文本 768 维、图像 2048 维,拼接后一共 2816 维特征。树模型在这个特征空间里的分裂点,能自动记录“哪些文本维度搭配哪些图像维度更容易判别真假”,这就是多模态融合和单纯拼接的本质差别。后面用 SHAP 做解释时,也是靠这个特征空间。
3.3 融合策略:先特征拼接,再做概率融合
多模态融合在工程上常见的三种:
- 特征拼接:文本向量和图像向量首尾相连,喂给一个分类器;
- 概率平均:BERT 分类头和图像分类头各出一个概率,加权平均;
- 分层融合:两个单模型的预测概率也作为额外特征,喂给第二层。
从lgb_cat_blend_lb9546.py的文件名和项目结构看,它走的是第一种叠加第三种:BERT 抽文本向量、CNN 抽图像向量、拼接后喂 LightGBM 和 CatBoost,两个树模型的概率再做加权融合。
X = np.hstack([text_feat, image_feat]) # (n, 768 + 2048)这样设计的好处是树模型能学习模态间的条件组合,比如“文本情绪分很高,但图像与正文相似度很低”这种跨模态判别路径。
3.4 train.sh 的完整链路与顺序问题
code/train.sh的作用是把文本、图像、融合三步串起来。拆开看大概是这样(图像分支由独立脚本负责,文件名以实际代码为准):
# 1. 文本特征抽取 python bert-final.py --mode train \ --data ./data/train.csv \ --save_path ./features/bert_text.npy # 2. 图像特征抽取 python extract_image_feat.py \ --image_dir ./data/train_images \ --save_path ./features/image_feat.npy # 3. 特征拼接 + 树模型融合训练 python lgb_cat_blend_lb9546.py --train \ --text_feat ./features/bert_text.npy \ --image_feat ./features/image_feat.npy \ --label ./data/train_label.csv注意:train.sh 每跑完一步,先检查生成文件的大小再继续。BERT 抽出几百 MB 的 npy 是正常的;如果文件是 0 KB,大概率是上一步脚本的保存路径写错,而不是模型训练慢。
对齐顺序是整个流程里最影响结果、也最不容易被发现的环节。文本特征和图像特征由两个独立脚本产生,假如两边遍历数据的顺序不一样,np.hstack后每一行都对应错误的样本,模型不会报错,只会让验证分数莫名其妙地变差。稳妥做法是每个样本带唯一news_id,抽取时同时保存,拼接前先 merge:
text_df = pd.DataFrame({'news_id': text_ids, 'feat': list(text_feat)}) img_df = pd.DataFrame({'news_id': img_ids, 'feat': list(img_feat)}) merged = text_df.merge(img_df, on='news_id', how='inner') X = np.vstack(merged['feat'].values)这段代码值不值得抄?值。我在另一个多模态项目里吃过这个亏:两边都按“读文件夹顺序”遍历,训练时 AUC 有 0.91,换到测试集就崩到 0.72,最后查了一天发现是训练时恰好按某种巧合对齐了,测试时文件夹顺序变了。从那以后,所有多模态特征合并一律走news_id关联。
4. 融合分类:LightGBM + CatBoost 的得分是怎么 blend 的
4.1 为什么是两个树模型
BERT 输出的向量已经携带语义信息,但直接接 softmax 分类头,本质上还是线性分类器。树模型能自动做特征交叉,LightGBM 和 CatBoost 虽然都是梯度提升树,一个用 leaf-wise 生长、一个用 oblivious tree,误差相关性没那么高,融合以后会比单模型稳。lb9546大概率就是这么 blend 出来的验证分。
文本 768 维加图像 2048 维之后,特征维度到两千多,树模型对高维特征有天然容忍度,不需要像神经网络那样做特别复杂的正则。这也是为什么这类课设项目选用 LGB + CatBoost 而不是再叠一层 MLP——多少有点“够用就好”的工程思路。
4.2 lgb_cat_blend_lb9546.py 的核心逻辑
拆开看,核心结构是:定义参数 → 五折交叉验证 → OOF 预测 → 搜索融合权重。参数部分长这样:
import lightgbm as lgb from catboost import CatBoostClassifier from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score param_lgb = { 'objective': 'binary', 'learning_rate': 0.02, 'num_leaves': 31, 'feature_fraction': 0.6, 'bagging_fraction': 0.8, 'metric': 'auc', 'verbose': -1, } param_cat = { 'iterations': 2000, 'learning_rate': 0.03, 'depth': 6, 'eval_metric': 'AUC', 'verbose': 100, }参数说明:
num_leaves=31:LightGBM 默认叶子数,特征维度高时不要盲目加大,否则单棵树复杂度过高;feature_fraction=0.6:每棵树随机采样 60% 特征。2816 维特征里存在大量冗余,降采样能抑制过拟合;- CatBoost 的
depth=6直接控制树深,比 LightGBM 的num_leaves更直观。
五折循环里,每折单独训练两个模型,预测概率存在 OOF 数组:
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) lgb_oof = np.zeros(len(X)) cat_oof = np.zeros(len(X)) for train_idx, valid_idx in skf.split(X, y): X_tr, X_va = X[train_idx], X[valid_idx] y_tr, y_va = y[train_idx], y[valid_idx] lgb_model = lgb.train( param_lgb, lgb.Dataset(X_tr, y_tr), num_boost_round=2000, valid_sets=[lgb.Dataset(X_va, y_va)], early_stopping_rounds=100 ) lgb_oof[valid_idx] = lgb_model.predict( X_va, num_iteration=lgb_model.best_iteration ) cat_model = CatBoostClassifier(**param_cat) cat_model.fit(X_tr, y_tr, eval_set=(X_va, y_va), early_stopping_rounds=100) cat_oof[valid_idx] = cat_model.predict_proba(X_va)[:, 1]代码逻辑:StratifiedKFold保证每一折里正负样本比例与全集一致,对类别不平衡的数据很关键。lgb_oof和cat_oof里装的都是“该样本在其所在验证折上”的预测概率,也就是说每个样本的预测都来自没有见过它的模型,融合权重在 OOF 上搜索不会过拟合。早停轮数设在 100,验证集上连续 100 轮没有提升就停,避免把训练噪声学进去。如果你的 lightgbm 版本较新,early_stopping_rounds写法可能会报警告,改用callbacks=[lgb.early_stopping(100)]即可。
4.3 融合权重搜索:别默认 0.5/0.5
两个模型效果不一样时,权重不该平均。常见做法是在 OOF 上做小步长网格搜索:
best_w, best_auc = 0.5, 0.0 for w in np.arange(0.2, 0.81, 0.05): blended = w * lgb_oof + (1 - w) * cat_oof auc = roc_auc_score(y, blended) if auc > best_auc: best_auc, best_w = auc, w print(f'best w = {best_w:.2f}, auc = {best_auc:.4f}')说明:w是 LightGBM 的权重,1-w给 CatBoost。步长 0.05 足够定位明显更优的区间,没必要用更细的步长去抠小数点后第三位的提升,那种提升往往换一个随机种子就消失了。搜索前先看两个单模型的 OOF AUC 差距:如果 CatBoost 明显强于 LightGBM,最优权重自然偏向 CatBoost;如果两者 AUC 接近但预测相关性不高,0.5/0.5 附近通常就是不错的位置。
融合失效的典型情况我也见过:两个模型的预测概率相关系数超过 0.98,加权平均几乎没有增益。这时问题不在权重,而在两个模型吃的是同一套特征、学出了几乎相同的错误模式。要改的是特征侧,比如在图像分支加入一个额外的颜色直方图特征,而不是继续调融合权重。
5. 避坑与排查:跑这份代码最容易翻车的五个地方
下面五条是我按这份代码实际跑过之后整理的,基本覆盖了从环境到结果的所有翻车点。排查时建议按顺序来:先解决模型加载,再解决环境,最后才是参数和阈值。
5.1 bert-master 与 tf_bert_model 版本被替换
现象:saver.restore报Key ... not found in checkpoint,或提示变量不存在。
原因:checkpoint 里的变量名和当前模型图的变量名对不上。常见于把bert-master换成了新版本源码,或换用了其他来源的预训练权重,名字都叫 BERT,变量命名空间却是两套。
解决:用项目自带的bert-master和tf_bert_model配对。确需换模型时,先打印tf.global_variables()与 checkpoint 里变量名做 diff,确认匹配再跑。另一种典型情况是变量多了bert/前缀,可以在构建图时把变量统一放进bert命名空间,或加载时用tf.train.init_from_checkpoint做前缀映射。
5.2 req.sh 装完环境,import tensorflow 直接崩
现象:python bert-final.py一执行就报AttributeError: module 'tensorflow' has no attribute 'Session'。
原因:BERT 官方源码是 TF 1.x 写法,而 pip 默认安装的 tensorflow 是 2.x,tf.Session已经被移除。req.sh只写包名、不锁版本时,pip 会装成最新版。
解决:新建虚拟环境,严格按packages.txt里的版本逐个安装。装好后先执行python -c "import tensorflow as tf; print(tf.__version__)",确认主版本是 1.x。如果你对 python 安装和 pip 不熟,千万别直接用系统 Python 硬闯,这一步翻车率极高。
5.3 显存不足报错发生在 BERT 而不是树模型
现象:BERT 抽特征时报ResourceExhaustedError,LightGBM 训练则一切正常。
原因:BERT base 权重占显存几百 MB,max_seq_length和batch_size设得太大时,激活值叠加起来会把显存顶穿。
解决:batch_size从 32 降到 8 或 16,max_seq_length从 256 降到 128。文本特征反正要先缓存成 npy,小 batch 只是多花点时间,不会影响最终特征质量。还可以在创建 Session 时打开显存按需增长:
config = tf.ConfigProto() config.gpu_options.allow_growth = True sess = tf.Session(config=config)这个设置对复现很有用,它能防止 TF 一开始就把整个显存占住,多模型并行时尤其明显。
5.4 融合模型概率全挤在 0.5 附近
现象:predict_test.py输出的概率集中在 0.45~0.55,AUC 数值尚可,但实际区分度很差。
原因:虚假新闻数据里“假”类通常远少于“真”类,树模型对不平衡数据敏感,默认 0.5 阈值把所有样本都判成了多数类。也可能是图像特征全是 0 向量,说明图片加载全部失败,模型退化成了纯文本模型。
解决:训练前先算y.sum() / len(y),正样本占比低于 10% 时,LightGBM 设is_unbalance=True或scale_pos_weight,CatBoost 用class_weights。训练完重新搜索判断阈值,不要拿 0.5 当铁律。同时用np.isnan(image_feat).sum()检查图像特征矩阵,确认不是数据读取的锅。
5.5 catboost_info 目录残留与日志累积
现象:CatBoost 训练时报无法写入catboost_info,或这个目录越来越大,磁盘被日志占满。
原因:CatBoost 默认在工作目录下创建catboost_info存放日志和快照。项目已经带着一个catboost_info,说明复现前就跑过;多次五折训练叠加,旧日志不会自动清理。
解决:跑之前清空这个目录,或者直接指定train_dir=/tmp/catboost_log_$(date +%s)。想彻底不带日志,可以设allow_writing_files=False,但这也意味着失去断点续训能力,离线大任务慎用。保留最近一次的catboost_info其实对调试有好处,里面记录了每一轮迭代的 bestAuc,能直观看到早停发生在哪一步。
6. 端到端验证:predict_test.py 跑通一条样本并校准概率
6.1 从一条样本看完整预测链路
test.sh是整体测试入口,predict_test.py则面向单条样本。复现完成后,我建议先跑单条样本,确认链路是通的:
python predict_test.py \ --text "某地凌晨突发大火,点击链接查看现场视频" \ --image ./data/sample.jpg \ --model ./model/tf_bert_modelpredict_test.py内部会依次做:文本清洗和 tokenization、BERT 抽 [CLS] 向量、CNN 抽图像特征、特征拼接、加载训练好的 LightGBM 和 CatBoost 权重、输出融合概率。如果这条明显夸大的样本输出概率在 0.8 以上,说明链路正常;如果在 0.5 附近游移,先查阈值,再查特征是否被正确加载。
6.2 进阶:概率校准
树模型输出的概率并不是真实概率,它只是排序分数。如果要做置信度展示,常见做法是温度缩放:把两个树模型的 logit 平均后除以 T,再经过 sigmoid。
T = 2.0 logit = np.log(blend_prob / (1 - blend_prob)) / T calibrated_prob = 1 / (1 + np.exp(-logit))T 大于 1 时概率分布变平缓,通常取 T=2 能让预测曲线更贴近真实分布。幅度不大,但做期末答辩时,“我做过概率校准”比“我训了个模型”要耐问得多。训练好 CatBoost 后也建议用save_model/load_model保存模型,比 pickle 跨环境更稳定。
我一开始复现这份代码也翻过车,直接拿系统 Python 跑了bert-final.py,被tf.Session报错卡到深夜,最后老实新建虚拟环境、按packages.txt锁版本才跑通。从那以后我每次拿到新项目,第一件事就是看 README 和依赖清单,再决定要不要改代码。希望这份拆解能帮到你。
本文还有配套的精品资源,点击获取