☰
基于Yelp的半监督虚假评论检测:从数据预处理到伪标签实战
2026/9/26 8:21:54 网站建设 项目流程

简介:一个基于半监督学习的虚假评论检测项目,面向人工智能、数据挖掘方向的课程设计与期末大作业场景。项目以Yelp公开评论数据集为对象,使用Python语言实现,涵盖数据分布分析、欠采样处理、多种分类模型对比评估等完整流程;采用Random Forest、Naive Bayes等算法,并配置混淆矩阵、评论长度分布等可视化图表,帮助理解半监督学习在虚假评论识别中的实际应用。压缩包共14个文件,以py脚本为核心,配合csv数据文件、sh运行脚本、png/jpeg可视化图片及md说明文档,整体包体大小7.16MB,结构紧凑,便于快速部署。目前已有250人学习下载,适合需要高分项目参考、快速搭建检测模型的初学者与进阶者。资源内代码注释详细,随附可视化结果与数据文件,可直接运行复现,也可作为课程设计报告与答辩的支撑材料。

1. 半监督虚假评论检测:为什么说这是人工智能大作业里最值得认真做的方向

这个标题说的是一个很具体的落地场景:在Yelp评论数据集上,用半监督学习做虚假评论检测,并且交付一份跑得通的源码。很多人在人工智能导论或课程设计里拿到这个题目,第一反应是直接拿BERT微调一个分类器,结果发现标注样本太少——Yelp上千万条评论,人工确认过的虚假样本往往只有几百条,监督学习根本喂不饱。半监督学习恰恰是为这种场景设计的:用少量人工标注锁定决策边界,再用海量无标注评论持续修正边界的位置。这篇文章从数据解析、特征构造、伪标签实现到参数调优,按一条能复现的路径完整讲清楚,目标是让你拿到手后不只是会跑,还能答清楚原理和踩过的坑。

2. 半监督虚假评论检测的立足点:Yelp数据长什么样,标签为什么稀缺又带噪

2.1 Yelp Open Dataset的字段构成:review/user/business三张表怎么串

Yelp Open Dataset是官方公开的真实评论数据集,解压后的核心是三个JSON文件:business.json保存商家信息,review.json保存评论内容,user.json保存评论者档案。三个文件通过business_id和user_id这两个主键关联起来,review.json里每一条记录都带有评论者的ID和对应商家的ID。

import json with open('yelp_academic_dataset_review.json', 'r', encoding='utf-8') as f: for i, line in enumerate(f): record = json.loads(line.strip()) if i == 0: print(list(record.keys())) print(record['text'][:200]) break

这段代码只读第一行然后退出,不会把整个几GB的文件载入内存,是拿到数据集后第一件该做的事。打印的键名会告诉你能用的字段有哪些。不同年份发布的Yelp版本字段略有差异,老版本里直接携带过滤标记相关的字段,新版本的字段可能已经变化,先确认键名再做后续处理是常规动作。

常见字段大致如下:

字段含义在特征工程中的作用
review_id评论唯一ID只作为主键,不进特征
user_id评论者ID关联用户历史统计后丢弃
business_id商家ID关联商家统计后丢弃
stars用户打星(1-5)星级偏离度是强信号
text评论正文喂给文本模型的原料
date评论日期计算评论者活跃跨度
useful/funny/cool其他用户投票数反映评论被社区接受程度

这个表格看起来简单,但它是整个数据工程的起点。实际项目里一个很常见的翻车操作是直接把user_id和business_id做成embedding输入模型,模型很快会学会按ID记忆真假,换一批新商家立刻失灵。

2.2 标签构造的三种来源与噪声分析

Yelp官方数据集并不直接给每条评论标注“这是虚假评论”,这是整个项目最需要想清楚的地方。从业者常用的标签来源有三种,各有各的成本和噪声。

第一种是官方过滤信号。Yelp有自己的评论过滤系统,老版本数据集中被过滤掉的评论常被当作可疑正样本。优势是零人工成本,可以一下拿到几万条候选;劣势是过滤系统本身有误杀,也存在漏网之鱼,标签噪声比例估计在10%到30%之间。

第二种是启发式规则生成初筛标签。比如将正文高度重复、评论者一次性给多个商家写评论、账号活跃天数极短等规则组合,筛出一批疑似样本,剩余样本归为疑似正常。规则方案跑得快,但规则的边界很难定义得干净,通常用来做候选池,不适合直接做标签。

第三种是人工复核小样本。从上述候选池中随机抽取一批,让两三个人独立标注再取一致的部分,最终得到几百条高置信度的标注数据。这是半监督训练的“种子”。

我一般会把三种结合起来:启发式规则生成候选池、人工复核标注一小批种子集、官方信号或Yelp自身过滤结果作为补充参考,但绝不直接全部当labels用。原因很简单——半监督训练会把标签噪声成倍放大,如果种子标签里混了一堆错的,后面伪标签也会错误地自我强化。

2.3 半监督学习为什么在这个任务上能成立

半监督学习的核心假设是:无标注数据里蕴含着关于决策边界位置的信息,只是模型需要合适的方法把它提取出来。在虚假评论检测这个任务里,这个假设相当成立,因为虚假评论生产存在明显的模板化特征和群体行为模式。

三种常见半监督路线在这个任务上的表现差异很大。第一种是自训练加伪标签:先用种子模型给无标注数据打标签,挑高置信度的加入训练集,迭代若干轮。优点是实现简单、显存开销小;缺点是确认偏差问题突出,模型会把初始错误预测当成正确答案喂给自己。第二种是一致性正则化,对同一条评论做两次不同的随机增强,要求模型输出一致,代表方法有MixMatch、FixMatch一类。这能有效抑制确认偏差,但对增强方式的要求高,评论数据的语义增强并不好设计。第三种是图方法,把评论者、评论、商家构造成图,用标签传播做半监督学习,能利用行为网络结构信息,但构造和训练开销都很大。

对Yelp这个场景,我推荐的组合是伪标签为主、一致性正则辅助。伪标签让项目快速跑起来,一致性正则用来稳定训练。后面的代码会围绕这个组合展开。

3. Yelp数据预处理:从几GB原始JSON到半监督可用的训练集

3.1 分块读取与字段裁剪:不吃内存的IO写法

review.json未压缩时通常是几个GB,直接pandas.read_json会吃掉大量内存,经常在中途被kill。常规做法是逐行解析,先裁字段再转DataFrame,最后落盘为parquet,后续实验就不需要再碰原始大文件了。

import json import random import pandas as pd records = [] with open('yelp_academic_dataset_review.json', 'r', encoding='utf-8') as f: for line in f: record = json.loads(line.strip()) text = record.get('text', '') if len(text) < 20: # 过短文本信息量不足,直接丢掉 continue records.append({ 'review_id': record['review_id'], 'user_id': record['user_id'], 'business_id': record['business_id'], 'stars': record['stars'], 'text': text, 'date': record['date'], 'useful': record.get('useful', 0), 'funny': record.get('funny', 0), 'cool': record.get('cool', 0), }) if len(records) >= 500000: # 降采样到50万条,防止内存与训练时间失控 break df = pd.DataFrame(records) df.to_parquet('yelp_reviews_sample.parquet', index=False) print(df.shape)

这段代码里值得注意的参数是50万这个降采样上限。它不是一个标准答案,而是一个平衡点:数据量再大,半监督提升幅度开始收敛,训练时长却线性上升。如果你的机器内存充足且需要覆盖更多无标注分布,可以把上限放到200万,但要记得同时检查正负样本比例有没有因为截断而失真。

为什么要落盘成parquet而不是csv?因为parquet列式存储读取快、省空间,重复实验时直接load就行。实际体验里,这一步能把后续启动时间从几分钟压到十几秒,属于非常划算的一笔投入。

3.2 文本与行为特征工程:哪些数字真的暴露了虚假评论

特征工程直接影响模型上限。虚假评论在文本层面有几个常见弱点:词汇重复率偏高、句式机械化、情绪表达极端且空洞。把这些信号转成数字,比让模型从原始文本中自己摸索要直给得多。

import re def build_text_features(text): sentences = re.split(r'[.!?。!?]', text) words = re.findall(r'\b[a-zA-Z]+\b', text.lower()) unique_words = set(words) return { 'text_len': len(text), 'n_sentences': len([s for s in sentences if s.strip()]), 'avg_sent_len': len(text) / max(1, len([s for s in sentences if s.strip()])), 'capital_ratio': sum(1 for c in text if c.isupper()) / max(1, len(text)), 'exclam_count': text.count('!') + text.count('!'), 'unique_word_ratio': len(unique_words) / max(1, len(words)), }

这些特征不是随机凑的。capital_ratio高通常意味着评论用大量大写强调情绪,这是机器生成或文案模板常见痕迹。unique_word_ratio反映词汇丰富度,虚假评论大量复用固定句式时这个值明显偏低。

评论者行为特征更重要也更隐蔽。需要从user.json或者在同一份数据里按user_id聚合统计:评论者历史评论总数、历史平均打星、当前评论星级与历史均值的差、评论者从第一次到最后一次评论的时间跨度。把当前星级和历史均值做差,能抓到“大批量给不同商家打低分或打满分”的刷单行为模式。

注意这类行为特征只能以聚合值的形式进模型,原始user_id等身份字段必须丢弃。否则模型学到的是“ID为xyz的用户都是假的”,这属于身份捷径而非行为规律,完全经不起跨数据源验证。

3.3 有标注/无标注集合的切分:分层采样与隔离测试集

半监督实验里,切分方式对结果的影响甚至超过模型结构。最常见错误是从全量里随机抽几百条作为标注数据,这样做标注集里虚假评论占比可能连5%都不到,模型连一条像样的决策边界都学不出来。

from sklearn.model_selection import train_test_split # 假设已通过规则初筛得到候选正样本和负样本 train_pos, test_pos = train_test_split( labeled_pos, test_size=200, random_state=42) train_neg, test_neg = train_test_split( labeled_neg, test_size=200, random_state=42) # 有标注集合按 25% 正样本、75% 负样本的比例配额 n_pos = 125 n_neg = 375 labeled_df = pd.concat([ train_pos.sample(n_pos, random_state=42), train_neg.sample(n_neg, random_state=42) ]) # 无标注集合 = 全部数据 - 有标注集合 - 测试集合 unlabeled_df = df_full.loc[ ~df_full.index.isin(labeled_df.index | test_pos.index | test_neg.index) ]

这套切分逻辑里的关键点是约束标注集中正样本占比。把有标注正样本配到25%左右,模型在冷启动阶段就能区分出两类的大致边界,伪标签的质量会明显更高。测试集正负各200条,并且从切分那一刻起完全不触达训练流程。

test_size=200表示测试集数量,random_state保证可复现。一个容易被忽视的细节是:测试集也要保持和真实场景相似的类别比例。如果测试集人工做成1:1,评估指标会偏乐观;如果测试集是原始分布里5%的正样本,F1会更真实但波动也更大。建议单独留两份测试集,一份平衡版本用来调参,一份原分布版本用来汇报最终结果。

4. 基于PyTorch的半监督检测实现:伪标签与自训练完整步骤

4.1 模型搭建:GRU句向量与统计特征拼接的分类器

预训练语言模型在这个任务上确实效果好,但会把项目变成重型工程。一份常见的高分项目源码通常从轻量模型起步:文本用GRU编码,再和统计特征拼接,最后过一个两层全连接分类头。这样做的实际收益是训练速度快,能在一台普通显卡上迭代几十轮实验,把伪标签策略和超参数调明白后再考虑是否升级骨干。

import torch.nn as nn class ReviewDetector(nn.Module): def __init__(self, vocab_size, embed_dim=128, hidden_dim=128, n_stats_features=12): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.gru = nn.GRU(embed_dim, hidden_dim, num_layers=1, bidirectional=True, batch_first=True) self.classifier = nn.Sequential( nn.Linear(hidden_dim * 2 + n_stats_features, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 2) ) def forward(self, text_ids, text_len, stats_features): embedded = self.embedding(text_ids) gru_out, _ = self.gru(embedded) text_emb = gru_out[:, -1, :] combined = torch.cat([text_emb, stats_features], dim=-1) logits = self.classifier(combined) return logits

这里有个值得注意的细节:代码取gru_out[:, -1, :]作为句向量,这对变长序列来说并不严谨,正确做法应按照text_len取每个样本最后一个有效位置的隐状态。在文本长度都接近max_len且padding占少数的情况下影响不大,但如果是长评论分布差异很大,这个简化会带来误差。我会在完整代码里用torch.gather按长度索引来取真实最后一步。

embed_dim=128、hidden_dim=128是面向速度和稳定的参数,不是追求精度的配置。如果后续要刷分,把这两个数翻倍,同时把dropout调到0.5,通常会涨两三个点F1,代价是训练时间涨一倍。

4.2 伪标签生成:不是所有无标注数据都值得进训练池

半监督训练的第二阶段,模型会对无标注数据预测概率,然后把置信度超过阈值的样本连同预测标签一起加入训练集。这个阈值是项目里最灵敏的旋钮,直接影响最终分数。

@torch.no_grad() def generate_pseudo_labels(model, unlabeled_loader, threshold=0.9, device='cuda'): model.eval() pseudo_samples = [] for batch in unlabeled_loader: text_ids = batch['text_ids'].to(device) text_len = batch['text_len'].to(device) stats = batch['stats'].to(device) logits = model(text_ids, text_len, stats) probs = torch.softmax(logits, dim=-1) max_prob, pseudo_label = torch.max(probs, dim=-1) mask = max_prob >= threshold for i in torch.nonzero(mask).flatten().cpu().tolist(): pseudo_samples.append({ 'text_ids': text_ids[i].cpu(), 'text_len': text_len[i].cpu(), 'stats': stats[i].cpu(), 'pseudo_label': int(pseudo_label[i]), 'confidence': float(max_prob[i]) }) return pseudo_samples

这段代码在model.eval()模式下关闭了dropout,保证预测稳定。每次迭代结束后重新生成一次伪标签,模型权重更新,伪标签集合也更新。confidence字段要保留,后面调阈值时会用它排序。

threshold=0.9只是一个起点。0.95时可能只有几百条伪标签,0.85时可能涌进上万条,而且置信度分布通常不是线性的。所以项目里我会先跑一次完整预测,画出置信度直方图,看看样本量在哪个区间出现断崖,再把阈值选在断崖前三分之一的位置。这是避免阈值玄学的具体操作。

4.3 训练策略与关键超参数:阈值、权重、epoch怎么配合

完整训练分两阶段。第一阶段只在有标注数据上训练3到5个epoch,得到一个种子模型;第二阶段让种子模型生成伪标签,把有标注数据和高置信度伪标签混合起来联合训练。

import torch from torch.utils.data import DataLoader def train_stage2(model, labeled_loader, pseudo_loader, optimizer, epochs=5, lambda_u=0.5): loss_fn = nn.CrossEntropyLoss() for epoch in range(epochs): model.train() total_loss = 0.0 for labeled_batch, pseudo_batch in zip(labeled_loader, pseudo_loader): # 有监督部分:真实标注的交叉熵 logits_l = model(labeled_batch['text_ids'], labeled_batch['text_len'], labeled_batch['stats']) loss_l = loss_fn(logits_l, labeled_batch['label']) # 无监督部分:伪标签的交叉熵,权重为lambda_u logits_u = model(pseudo_batch['text_ids'], pseudo_batch['text_len'], pseudo_batch['stats']) loss_u = loss_fn(logits_u, pseudo_batch['pseudo_label']) loss = loss_l + lambda_u * loss_u optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"Stage2 Epoch {epoch+1}, Loss: {total_loss:.4f}")

这里zip会让两个循环在短的一侧结束,所以伪标签loader的batch数量必须不少于有标注loader,否则后面几个epoch实际只在训练有标注数据,半监督名存实亡。常见做法是给伪标签loader套一个无限循环迭代器,或者采样时保证伪标签数据量够多。

lambda_u是最值得调的第二个参数。阈值选得高,伪标签总体可靠,lambda_u可以放到1.0;阈值选得低,就必须降到0.3以下,防止噪声样本主导梯度。我的经验是先在固定阈值0.9下把lambda_u从0.5、1.0、2.0各跑一趟,选出最优后再回头调阈值。调参顺序反了会浪费大量时间。

参数建议范围对结果的影响
置信度阈值0.85 - 0.95决定伪标签数量与噪声比例
lambda_u0.3 - 1.0决定无监督损失占比
有标注预训练epoch3 - 5决定种子模型质量
联合训练epoch5 - 10太长会过拟合伪标签噪声
batch_size16 - 32影响BN统计量与训练稳定性

5. 避坑:半监督虚假评论检测的常见问题与排查路径

5.1 现象:伪标签噪声越滚越大,模型从只犯小错变成全盘崩溃

原因:伪标签阶段把模型的偏误当成正确答案重新喂给自己,模型对某些错误模式越来越自信,形成自我强化的恶性循环。这是自训练方法的通用毛病,在虚假评论这种类别不均衡数据上尤其明显。

解决:阈值设高只是缓兵之计。真正有效的做法是每轮只取置信度排序前一半的新伪标签,并且在上轮伪标签中剔除当前验证集表现明显变差的样本。更稳的变体是同一份文本用两次不同dropout的预测,两次预测类别一致的才进伪标签池。这种双预测策略能拦住相当一部分模型自身的系统性偏误。

5.2 现象:准确率97%,F1却只有0.12,模型几乎没抓到虚假评论

原因:原始分布里真实评论占绝大多数,模型全部判真实就能拿到高准确率,这是类别不均衡场景下最经典的指标假象。

解决:训练指标从准确率切到F1,并且以PR曲线作为调参依据。损失函数里给正样本加权,权重建议从标签分布的反频率开始试,比如负正比20:1时给正样本损失乘10左右。评估时只用平衡测试集做内部调参,最后再用原分布测试集汇报一次,两者都报才是完整结果。

5.3 现象:验证集F1漂亮,换一批商家数据立刻失效

原因:把user_id、business_id之类的主键直接放进特征,模型学会的是记住特定ID的真假,而不是识别虚假评论的一般规律。这在数据切分时往往看不出来,因为同批数据的主键模式被记住了,测试集里又出现了相同商家。

解决:主键字段一律不进特征。行为特征只保留聚合统计,比如用户历史评论数、历史平均星级、评论活跃天数。项目里做一次特征列名审计,凡是列名能直接对应到某条评论唯一身份字段的,全部去掉。这步听起来简单,但很多人因为贪图方便,在特征合并阶段就把主键悄悄带进去了。

5.4 现象:阈值从0.90调到0.95,F1直接掉了一半,变化毫无规律

原因:置信度分布不是均匀的,0.90到0.95之间的样本量可能突然从几万变成几百,伪标签集合整体被替换了,模型学到的边界自然完全不同。这不是玄学,是样本量断崖带来的正常波动。

解决:不要盲目试阈值。先跑一次全量无标注预测,把置信度保存下来画直方图,再在图上确认样本量变化最剧烈的区间。阈值选在“数量还够多、置信度已经明显集中”的位置,通常就在这个拐点附近。同时记录每个阈值下伪标签的正样本占比,如果0.90时正样本占3%、0.93时占17%,这个变化说明类别分布也在随阈值剧变,需要谨慎处理。

5.5 现象:加了半监督反而比纯监督更差,伪标签一点忙没帮上

原因:无标注数据和有标注数据分布差异过大,或者种子模型质量太差,生成的伪标签大多不可靠。半监督不是万能放大器,它在标注质量极低、无标注数据相关性弱的时候会反过来拖累模型。

解决:先做消融实验,办法是生成伪标签后人工抽检50条,肉眼看看这些高置信度预测到底靠不靠谱。如果抽检里有一半明显判错,先别急着调参,回到种子模型训练阶段把epoch加长、或者补充几十条标注。如果伪标签本身看着没问题,训练仍不涨点,那就检查无标注集合是否整体分布偏移,比如包含了大量不同语言的评论,这时就需要过滤后再用。半监督适合的是标注少且无标注数据与任务同分布的场景,硬上只会得到一个更复杂而且不稳定的模型。

6. 收尾技巧:用温度缩放与置信度阈值搜索把F1再提一档

模型训练结束后,直接拿默认的0.5阈值做判决是省事但吃亏的做法。很多高分项目拉开差距的地方,其实就是最后这一小步:先对验证集做置信度阈值搜索,再做温度缩放校准。

from sklearn.metrics import precision_recall_curve, f1_score # 验证集预测概率 probs_val = model.predict_proba(val_loader)[:, 1] precision, recall, thresholds = precision_recall_curve(y_val, probs_val) f1_scores = [f1_score(y_val, probs_val >= t) for t in thresholds] best_idx = int(torch.argmax(torch.tensor(f1_scores))) best_threshold = thresholds[best_idx] print(f"Best threshold: {best_threshold:.3f}, F1: {f1_scores[best_idx]:.4f}")

这段代码把验证集概率落下来,对每一个可能的阈值算一次F1,找到最优切分点。实测中这个阈值往往落在0.55到0.75之间,而不是默认的0.5。原因很简单,模型输出的概率在0.5附近聚集了大量模糊样本,把阈值往高调一点,丢掉部分真阳但能显著降低误报,F1往往会更好。

温度缩放是进一步的手段。做法是在验证集上学习一个温度参数T,把logits除以T再softmax,让输出的概率分布更接近真实置信度。温度缩放不会改变预测类别,但会改变置信度排序的分布,从而让伪标签阈值和最终判决阈值都更有解释力。校准集和测试集不能重叠,否则整体评估会虚高。

我自己养成的习惯是每次实验结束都额外抽500条阈值附近样本做人工检查,尤其是那些被判错但置信度很高的样本。看多了你会发现,机器坚持认为是虚假、人工也说不清的样本,往往指向标签噪声而不是模型缺陷。这种对错误样本的体感,比任何指标都更能帮你判断半监督策略是收敛还是失控。这条路走通之后,再遇到其他类别不均衡任务也能少走很多弯路,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询