简介:面向毕业设计、课程实践和自然语言处理入门研究的深度学习虚假新闻检测项目,提供一套基于Python的完整可运行源码。项目采用主流深度学习框架构建模型,运用RNN、LSTM及BERT等预训练语言模型对新闻文本进行深度特征提取与分类识别,覆盖数据收集、文本预处理、模型设计、参数训练、效果评估到系统部署的全流程。压缩包共137个文件,整体约49.64MB,除网络脚本、JSON配置、CSV训练与测试数据、HDF5模型权重外,还包含前端页面和LESS样式,配合MD说明文档构成清晰的项目目录。目前已有100人下载学习。程序代码均经过系统性验证,可直接执行;文档中附有环境搭建与依赖安装指引,可帮助用户快速跑通实验、复现识别效果,也为学位论文撰写和课程设计答辩提供了可落地的完整参照。
1. 虚假新闻检测为什么难:AI不是玄学,而是数据偏见的放大器
先给一个反直觉的结论:把一篇新闻直接扔进深度学习模型,模型判断为“假”的最强信号,往往不是内容本身,而是标题里的感叹号、正文的冗余排版,甚至来源域名。这不是模型不聪明,而是多数公开的假新闻样本天然带着这些“噪声指纹”。也就是说,深度学习在做虚假新闻检测时,真正的门槛不在模型架构,而在你怎么组织训练数据、怎么定义真假边界,以及怎么让模型学到语义而不是学到数据集里的偶然规律。这个项目适合两类人:一类是想快速跑通一个端到端的虚假新闻检测系统、需要可落地的Python源码和训练流程的同学;另一类是已经跑过基础分类任务、但被验证集虚高和上线效果崩塌困扰的从业者。它能解决的核心问题是:用一份普通标注数据,通过合理的特征工程、模型选择和调参手段,拿到一个在“没见过的新闻”上仍然能用的检测器,而不是只在测试集上好看的打分器。
2. 先看输入:如何构造一个适合深度学习训练的数据集
2.1 数据集的“真假”定义:二分类没你想的那么简单
虚假新闻检测表面上是一个二分类问题:标签为1表示虚假,标签为0表示真实。但第一步在数据定义上就会踩坑。常见的公开数据集里,假新闻样本往往来自特定时期的谣言聚合站,真新闻样本来自权威媒体,两者在发布时间、话题分布、写作风格上天然不同。如果模型学习的是“体育新闻为真、政治八卦为假”这种话题偏差,它换一批数据就失效。因此我们在构造自己的数据集时,至少要保证真假样本在话题维度上均匀分布,比如政治、健康、科技、娱乐几个类别都要同时覆盖真假样本,而不是新闻网站按频道贴标签。
另一个容易被忽略的问题是数据时效性。新闻的生命周期很短,旧样本里的表述方式、热点事件名称都会过时。常见的做法是按时间排序后切分训练集、验证集、测试集,而不是随机打乱。比如取前70%时间范围内的样本做训练,中间15%做验证,最后15%做测试,这样可以模拟“用过去训练、预测未来”的真实场景。随机打乱会让测试集里混入与训练集同一事件衍生出的重复报道,导致验证指标虚高,到线上就翻车。
2.2 文本清洗与格式统一:哪些预处理真正有用
把问题从CSV文件变成模型输入,中间要过一道清洗管线。我一般这样处理:把正文和标题拼接成一条文本;统一大小写;把URL替换成[URL]、数字替换成[NUM];过滤掉广告签名、版权行等与新闻内容无关的固定文案;保留问号和感叹号,因为标点符号在假新闻检测中反而是有区分度的特征,不要一刀切清洗掉。
这里给出一个最小可用的清洗函数,配合pandas直接使用:
import re import pandas as pd def clean_text(text: str) -> str: if not isinstance(text, str): return "" text = text.lower() text = re.sub(r"http\S+", "[url]", text) # 链接归一化 text = re.sub(r"\d{4,}", "[year]", text) # 长数字归一化 text = re.sub(r"[^a-z0-9\[\]!?.,;:'\s]", " ", text) # 保留标点 text = re.sub(r"\s+", " ", text).strip() return text df = pd.read_csv("news.csv") df["text"] = df["title"] + " " + df["content"] df["text"] = df["text"].apply(clean_text) df = df[df["text"].str.len() > 50] # 过滤过短样本代码逻辑是先用正则把高频噪声归一化,然后只保留字母、数字、中括号和常用标点。把URL替换为[url]而不是删除,是因为来源链接本身对真假判定有提示作用;数字替换为[year]则避免模型把年份当普通词记忆。过滤长度小于50字符的样本,是为了防止那些只有标题没有正文的记录干扰训练。注意清洗是在拼接标题和正文之后再进行,如果先清洗再拼接,标题和正文的边界就丢失了,模型更难利用标题的夸张风格。
2.3 划分与编码:建立验证集和文本张量化流程
拿到清洗后的文本,下一步是划分数据集并转成Tensor。这里建议采用分层抽样,按标签分层,保证每个子集里真假比例一致。同时按时间顺序分组,比如学到一个“时间泄漏”的概念。
from sklearn.model_selection import StratifiedKFold import torch from torch.utils.data import Dataset # 按时间排序后的数据不放回地分桶 df = df.sort_values("date").reset_index(drop=True) split_idx = int(len(df) * 0.7) val_idx = int(len(df) * 0.85) train_df = df.iloc[:split_idx].copy() val_df = df.iloc[split_idx:val_idx].copy() test_df = df.iloc[val_idx:].copy() class NewsDataset(Dataset): def __init__(self, texts, labels, vocab, max_len): self.texts = texts self.labels = labels self.vocab = vocab self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): tokens = self.texts[idx].split()[:self.max_len] ids = [self.vocab.get(t, 1) for t in tokens] # 1为unk ids += [0] * (self.max_len - len(ids)) # 0为pad return torch.tensor(ids), torch.tensor(self.labels[idx])StratifiedKFold在这里只是展示分层思路,实际代码中按时间切分的话直接排序后切即可。NewsDataset接收一个vocab字典,对句子做截断和补零,返回等长序列。关键参数是max_len,新闻文本通常300~500词就能覆盖多数特征,设置过大会把batch撑爆,设置过小会截掉关键论证部分,通常从256开始试。vocab的构建在做这一步之前,先用训练集统计词频,保留出现次数大于等于2的词,其余映射到unk。
3. 最小可跑方案:用PyTorch实现一个虚假新闻检测模型
3.1 模型选型:为什么从双向GRU开始而不是直接上Transformer
深度学习做文本分类,选型其实是在“效果”和“可控性”之间做取舍。很多同学一上来就放一个BERT,但BERT在只有一两万样本的新闻数据集上很容易过拟合,且显存占用高、训练时间长,跑通一次实验的成本很高。我更建议先搭一个Embedding + 双向GRU + 注意力的基线模型,把这个方案调稳定后再决定要不要换更重的预训练模型。双向GRU的优势在于:能捕捉正文前后文的依赖关系,参数量在一个可接受的范围内,CPU上也能完成一轮训练,适合用来验证数据切分、清洗逻辑是否正确。
另外,双向GRU在虚假新闻检测里有一个隐性优势:新闻的“假”往往体现在前后文矛盾上,比如前半段说某个结论是科学的,后半段引用了一个来源不明的案例。双向结构能让模型同时看到目标词前后的上下文,而不是只按从左到右的顺序计算。这也是为什么这类任务GRU基线的表现往往好于同样规模的一维卷积。
3.2 模型代码:搭建带注意力的双向GRU分类器
下面是一个可以完整运行的PyTorch模型定义,包含词嵌入、双向GRU、注意力池化和全连接分类层。代码尽可能只依赖torch,不引入额外库。
import torch import torch.nn as nn import torch.nn.functional as F class AttentionGRU(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, num_layers=2, num_classes=2, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.gru = nn.GRU(embed_dim, hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout) self.W = nn.Linear(hidden_dim * 2, hidden_dim * 2) self.v = nn.Linear(hidden_dim * 2, 1, bias=False) self.classifier = nn.Linear(hidden_dim * 2, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): emb = self.dropout(self.embedding(x)) # [B, L, D] outputs, _ = self.gru(emb) # [B, L, 2H] # 注意力打分 score = torch.tanh(self.W(outputs)) # [B, L, 2H] attn = self.v(score).squeeze(-1) # [B, L] if mask is not None: attn = attn.masked_fill(mask == 0, -1e9) attn_weights = F.softmax(attn, dim=1) # [B, L] # 注意力池化 context = torch.sum(attn_weights.unsqueeze(-1) * outputs, dim=1) out = self.classifier(self.dropout(context)) return out, attn_weights模型结构里的关键点是:padding_idx=0让补零位置不参与梯度更新;bidirectional=True表示使用双向GRU作为编码器;注意力机制让模型能够对不同位置的词加权求和,而不是简单取最后时刻的隐状态。mask参数对应padding位置的标记,在计算注意力分数时把无效位置设为一个极大的负数,使得softmax之后这些位置权重趋近于零。这一点容易被忽略,如果不做mask,补零位置也会获得注意力,模型会学到“多看空白区域”这种无意义规律。
3.3 训练主循环:一个带进度感的Epoch训练流程
代码里还需要把模型和数据接起来。训练循环是最通用的写法,但有几个细节我要单独说明:验证集的loss要在每个epoch结束后单独计算,不能和训练batch混合;保存模型时用验证loss最小的状态,而不是训练loss最小的状态。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for batch_idx, (input_ids, labels) in enumerate(loader): input_ids, labels = input_ids.to(device), labels.to(device) mask = (input_ids != 0).float() optimizer.zero_grad() logits, _ = model(input_ids, mask) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() return total_loss / max(len(loader), 1)mask = (input_ids != 0).float()构建的就是padding mask,传给模型用于注意力计算。clip_grad_norm_把梯度的范数限制在5.0以内,防止GRU在训练初期产生梯度爆炸。序列模型的梯度异常比全连接层更常见,因为时间步展开后梯度要回传很多步,不加梯度裁剪的话经常出现loss突然变成NaN。
3.4 选型边界:什么情况下才需要升级到预训练语言模型
GRU基线模型跑通之后,如果测试集AUC在0.85以下,或者你发现模型对长文本后半段的语义理解很差,再考虑升级到预训练模型。这个升级路径在代码层面只需替换编码器部分,把self.gru换成预训练模型的隐藏层输出。但需要注意三点:一、预训练模型的输入tokenizer和词表完全不同于我们自建的vocab,数据管线要重写;二、预训练模型的输入长度通常限制在512以内,新闻文本需要更积极的截断策略,比如只取标题加前300词;三、预训练模型的输出表示维度是768或更大,分类头的结构要同步调整。我的建议是:先确保GRU基线的数据管线没有问题,再升级模型,否则出错时你很难判断是数据问题还是模型问题。
4. 训练与调参:让模型真正记住“谎言模式”而不是数据噪声
4.1 损失函数与类别不平衡:别让少数类被淹没
新闻数据集中假新闻样本的比例通常低于30%,直接用标准交叉熵损失会让模型倾向于把所有样本预测为真实。解决这个问题有两种路径:一是调整损失函数的权重,二是调整采样器。我在实际操作中优先调整损失函数,因为它对训练过程更可控——只需给假新闻类更高的权重,不改变batch的整体分布。
class_counts = train_df["label"].value_counts().to_dict() total = sum(class_counts.values()) # 权重与样本量成反比 weight = torch.tensor([total / class_counts[0], total / class_counts[1]], device=device) criterion = nn.CrossEntropyLoss(weight=weight)这里的weight列表顺序对应标签0和1。如果假新闻是少数类(标签1),它会拿到更大的权重,相当于在计算loss时把假新闻样本的错误放大,模型被迫更重视这一类。如果类别极端不平衡,比如假新闻占比只有5%,可以搭配过采样一起用——在DataLoader里用WeightedRandomSampler对假新闻样本重复采样。不过要注意过采样会让模型接触到重复样本,需要配合更激进的dropout才能避免对个别样本死记硬背。
4.2 三个必调参数:学习率、序列长度和LSTM层数
先给一组我自己常用的初始参数:embed_dim=128,hidden_dim=128,num_layers=2,dropout=0.3,max_len=256,学习率1e-3。这套参数对多数两万规模的数据集都能得到一个合格基线。学习率是最关键的参数,GRU对学习率尤其敏感——1e-3是常见起点,但如果loss在前两个epoch内出现先降后弹、甚至直接NaN,优先把学习率降到3e-4。
层数方面,num_layers=2是性价比最高的选择。3层以上的GRU在训练数据不足时不仅训练速度大幅下降,还会带来更严重的过拟合,测试集效果可能反而变差。序列长度max_len的设置需要结合文本长度的分布来看:先绘制训练集文本长度的分位数,取95分位数的词数作为max_len,这样既保留大多数样本的完整语义,又不会让batch里全是无效padding。我见过一个项目把max_len设成1024,结果半数样本不足200词,训练时间和显存开销翻了几倍,效果却没有提升。
4.3 训练策略:早停、学习率衰减和模型保存
训练曲线是一个无法绕开的观察对象。每完成一个epoch,记录训练loss、验证loss、验证集AUC。训练集的loss会持续下降,验证集的loss会先降后升,当验证loss连续3个epoch不再下降时,说明模型开始过拟合训练数据。把这个状态保存下来作为最终模型,比固定训练多少个epoch靠谱得多。
best_val_loss = float("inf") patience = 0 for epoch in range(20): train_loss = train_one_epoch(...) val_loss, val_auc = evaluate(model, val_loader, criterion, device) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), "best_model.pt") patience = 0 else: patience += 1 if patience >= 3: print(f"early stop at epoch {epoch}") breakpatience设成3的意思就是验证loss连续3次不创新低就停。配合学习率衰减可以做一件事:每次验证loss不下降时,把学习率乘以0.5,最多衰减两次。这样既保留了继续训练的余地,又不会让学习率过小导致长时间原地踏步。这个“先早停、后衰减”两条策略同时存在并不矛盾——衰减是在patience还没到达阈值前的柔性处理,早停是最终的硬性截止。
4.4 验证指标:为什么AUC比准确率更能说明问题
二分类任务中,准确率在类别不平衡时会给出极具误导性的结果。比如测试集里真实新闻占80%,模型全部预测为真实就能得到80%的准确率,看起来效果不错,但对假新闻的检出率是0。AUC则计算的是模型对正负样本排序能力的综合表现,不受阈值影响。对于虚假新闻检测,我一般同时观察三个指标:AUC、假新闻类的召回率、以及在所有预测为假的样本中的精确率。召回率低说明大量假新闻漏掉了,精确率低说明系统频繁把正常新闻标记为可疑,用户体验会很差。调阈值时把验证集上F1最高的点作为默认阈值,再根据业务容忍度调整。
5. 踩坑记录:从离线评测到上线前最常见的5个问题
5.1 训练集loss正常下降,验证集AUC却只有0.6
现象:模型在训练集上准确率超过0.95,但验证集AUC始终在0.6附近波动,和随机猜测差别不大。
原因:最常见的是数据切分时发生了话题泄漏。同一事件的假新闻和真新闻会被多个媒体转载改写,随机切分时这些改写版本同时出现在训练集和测试集,但测试集里也有大量完全不带训练集话题词的样本,模型一遇到新话题就失效。另一种原因是文本清洗过度,把内容核心命名实体全删掉了,模型无特征可学。
解决:改用按时间切分,并用MinHash或SimHash对文本去重,确保同一新闻事件的变体只出现在一个子集里。同时检查清洗规则对保留内容的实际影响,比如打印几条预处理后的样本文本,确认关键信息没有被误删。
5.2 模型对所有新闻都预测为“真实”
现象:测试集上准确率尚可,但统计混淆矩阵后发现假新闻类的召回率接近0,系统退化为一个“全真”分类器。
原因:类别不平衡时,模型发现把所有样本判为多数类就能把整体损失压到很低。特别是使用标准交叉熵且没有设置类别权重时,少数类的梯度信号被大量多数类样本淹没。
解决:给损失函数加类别权重,并按F1或AUC选择预测阈值,而不是默认的0.5。如果加权重后假新闻召回率上来了但精确率掉得厉害,说明正负样本的特征边界本身存在重叠,需要检查训练数据里假新闻样本的标注质量。
5.3 loss在训练中期突然变为NaN
现象:前几个epoch正常,到某个batch之后loss变成NaN,之后无法恢复。
原因:绝大多数情况是梯度爆炸。GRU反向传播经过多时间步后梯度范数增长很快,学习率稍大就在某个batch触发溢出。另一个少见原因是embedding层某个词向量的梯度异常,特别是语料中出现超长重复文本时。
解决:在训练循环里加梯度裁剪,max_norm=5.0是一个安全的起点。同时把学习率降低一个量级。如果NaN出现在训练开始后不久,还要检查输入是否存在全零样本或全填充序列,这类样本会让GRU计算出现退化。
5.4 部署后处理速度太慢,批处理延迟超过业务容忍线
现象:模型离线测试时单条预测很快,但部署成HTTP接口后,在高并发下单次响应耗时飙升,CPU占用一直满载。
原因:线上推理时没有做batch padding,每条请求独立走模型。每句话长度不同,短句子和长句子混在一起时,短句也被迫等长句算完,造成大量无效计算。同时没有做文本预处理缓存,同样一段文本每次请求都要重新清洗。
解决:在线服务里做一个带超时等待的动态batch聚合,把10毫秒内到达的请求拼成一个batch一起推理。另外把分词结果缓存到内存,命中缓存直接取token id,跳过重复预处理。这样单机Python服务的吞吐量可以提升3~5倍。
5.5 验证集AUC很高,但人工抽检一批新新闻时判断结果与常识相悖
现象:模型把一篇报道某种天然疗法能治愈慢性病的文章判为“真实”,理由是该文章没有明显的夸张标点和来源缺失。
原因:模型学到的是数据集的表面模式,而不是深层的事实一致性。当训练数据里假新闻普遍带有格式化特征时,模型把“形式可疑”等同于“内容虚假”。一篇精心排版、语气克制的假新闻,很容易绕过这样的模型。
解决:一是在训练特征里加入外部元信息,比如来源域名、作者是否可查、是否包含引用来源链接,让模型有更多维度可依;二是把阈值调严格一些,宁可多标可疑、由人工复核,也不要把假新闻干净的放过去。这个问题的本质是数据本身的局限,不是调参能完全解决的,需要持续补充新样本。
6. 让检测结果可解释:用注意力权重定位模型眼里的“假新闻证据”
系统上线之后,业务方一定会追问一个问题:凭什么说这篇文章是假的?这时把模型当作黑匣子是不可接受的。好在双向GRU加注意力池化方案里,注意力权重天然就是可视化素材——softmax之后的attn_weights告诉我们是哪几个词主导了最终分类。写一个输出Top词权重的函数并不复杂。
def explain(model, tokenizer, text, vocab, device, top_k=10): model.eval() tokens = text.split()[:256] ids = [vocab.get(t, 1) for t in tokens] input_ids = torch.tensor([ids], device=device) mask = (input_ids != 0).float() with torch.no_grad(): logits, attn = model(input_ids, mask) probs = F.softmax(logits, dim=1) attn = attn.squeeze(0).cpu().numpy() token_weights = sorted(zip(tokens, attn), key=lambda x: x[1], reverse=True) label = "假新闻" if probs[0][1] > 0.5 else "真实新闻" print(f"判定结果: {label}, 可信度: {probs.max():.3f}") for i, (tok, w) in enumerate(token_weights[:top_k], 1): print(f"{i:2d}. {tok:<20s} 权重 {w:.4f}") return token_weights这段代码把注意力分数和token词表对齐后按权重降序排列,重点看前十个词。如果权重集中在一个可疑数字、一个陌生机构名或一个情绪化形容词上,说明模型的判断依据是明确可解释的;如果权重平均散落在所有词上,说明模型没有找到关键证据,输出结果的可信度也要打个折扣。这个解释接口加到预测API里后,每次预测会返回判定结果和top关键词列表,业务方可以根据关键词初步判断是否需要人工复核。我的习惯是构建一个“证据留存”机制,每周人工抽检100条模型判为假的新闻,把模型列出的关键词和人工判断做对照,发现模型依赖的线索是否合理。如果某段时间模型频繁用同一个来源域名作为判假依据,就要警惕这个域名在训练数据中被过度代表了。这样既能保持模型效果的可见性,也能持续修正训练数据的偏差。希望帮到你。
本文还有配套的精品资源,点击获取