
简介本资源为基于图卷积神经网络GCN的虚假影评水军检测研究项目面向计算机、人工智能相关专业的本科生与研究生适用于毕业设计、期末大作业与课程设计场景。项目以猫眼电影长评数据为基础构建评论与用户之间的图结构利用GCN完成水军识别建模帮助读者理解图神经网络在文本风控中的落地思路。压缩包共26个文件约15.03MB包含9个Python源码文件模型定义、图构建、数据加载与训练脚本、8个CSV数据集影评、电影名单、类型与结果表、BERT预训练配置与词表、readme说明及项目配置文件结构清晰、便于二次开发。目前已有240人学习下载。项目附带完整数据集与可运行代码新手也能按目录逐步调试快速复现训练与评估流程并在此基础上替换数据或调整图结构完成自己的课题。1. 虚假影评水军检测为什么图卷积神经网络比文本分类更值得做电商评论区里一条五星好评可能来自真实买家也可能来自一个批量注册、复制粘贴、互相点赞的水军账号。传统做法是把评论当成独立文本丢给朴素贝叶斯或 LSTM 做二分类准确率看着不错一上线就翻车——因为水军最擅长的不是写一条假评论而是让一批账号在同一时间、对同一商品、用相似话术互相配合。孤立地看每条评论都像真的只有把「谁评论了谁、谁和谁互动、谁在什么时间扎堆出现」这些关系连起来水军结构才会暴露。这就是 Python 基于图卷积神经网络的虚假影评水军检测要解决的问题把用户、评论、商品建模成一张异构图用 GCN 在图上做节点分类判断哪些用户是水军、哪些评论是虚假评论。它适合两类人一是手里已经有评论数据、想从「文本分类」升级到「关系检测」的算法工程师二是做电商风控、内容审核需要一套能复现、能改、能接自己数据的方案。下面从数据建模一路讲到训练、调参和踩坑源码和数据集按常见开源结构组织你可以直接照着搭。2. 把评论数据建成图节点、边和特征怎么定2.1 为什么必须用图结构而不是一张宽表宽表思路是把每条评论压成一行特征文本长度、情感分、发布时间、用户注册天数。问题是它丢掉了关系。水军检测里最有判别力的信号恰恰是关系性的一个用户给 50 个不同商品打五星、这 50 条评论的文本相似度极高、且集中在 3 天内发布——这些信息在宽表里被拆散模型学不到「团伙」这个概念。图结构把每个实体当节点把交互当边。用户和评论之间是「发表」边评论和商品之间是「评价」边用户和用户之间可以有「共同评论同一商品」边。GCN 的核心操作是邻居聚合每个节点的新表示 自己的特征 邻居特征的加权和。水军账号因为互相点赞、共同评论会在图上形成稠密子图几层聚合之后它们的向量会趋同而正常用户分散在稀疏区域分类器一刀就能切开。常见做法是建一张异构图heterogeneous graph节点类型至少三种user、review、product。如果你只想快速跑通可以先做同构简化版——只保留 user 节点边表示「两个用户评论过同一商品」节点特征用该用户所有评论的文本 embedding 均值。这个简化版能跑出效果也最容易复现建议作为第一版。2.2 用 PyTorch Geometric 搭一张评论异构图下面这段代码把一份评论表字段user_id、product_id、review_text、label转成 PyG 的 HeteroData。文本特征用 TF-IDF 或 sentence-transformers 都行这里用 TF-IDF 保证零额外依赖。import pandas as pd import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from torch_geometric.data import HeteroData import torch # df 列: user_id, product_id, review_text, label(1水军,0正常) df pd.read_csv(reviews.csv) # 1. 建立 ID 映射保证节点编号连续 users df[user_id].astype(category) prods df[product_id].astype(category) df[u_idx] users.cat.codes df[p_idx] prods.cat.codes num_users, num_prods len(users.cat.categories), len(prods.cat.categories) # 2. 评论文本向量化作为 user 节点特征 vec TfidfVectorizer(max_features2000, ngram_range(1, 2)) tfidf vec.fit_transform(df[review_text]).toarray() user_feat np.zeros((num_users, tfidf.shape[1]), dtypenp.float32) for u, row in zip(df[u_idx], tfidf): user_feat[u] row # 同一用户多条评论累加 # 行归一化避免活跃用户特征爆炸 norm np.linalg.norm(user_feat, axis1, keepdimsTrue) 1e-8 user_feat user_feat / norm data HeteroData() data[user].x torch.tensor(user_feat) data[product].x torch.eye(num_prods) # 商品用 one-hot 占位 # 3. 边: user - product (评价关系) data[user, rates, product].edge_index torch.tensor( np.vstack([df[u_idx].values, df[p_idx].values]), dtypetorch.long) # 4. 标签只挂在 user 节点上 data[user].y torch.zeros(num_users, dtypetorch.long) for u, l in zip(df[u_idx], df[label]): data[user].y[u] int(l) print(data)逻辑说明先把 user_id、product_id 转成从 0 开始的连续整数这是 PyG 的硬性要求否则 edge_index 会越界。文本特征按用户聚合而不是按评论是因为我们要分类的对象是「用户是不是水军」评论只是证据。行归一化那一步很关键一个刷了 500 条评论的账号如果不归一化特征模长会是普通用户的几十倍训练时梯度直接被它带偏。参数说明max_features2000控制特征维度评论量大时可以提到 5000但要注意显存ngram_range(1,2)让「好评」「非常好」这类二元短语进入特征对识别模板化话术有用。data[product].x用 one-hot 只是占位如果商品有类目、价格等属性换成真实特征效果更好。2.3 划分训练集时最容易犯的错按评论随机划分训练/测试集是新手最常踩的坑。同一个用户的多条评论会被拆到两边模型在训练时见过这个用户的一部分行为测试时相当于开卷考试指标虚高十几个点。正确做法是按用户划分先随机选 80% 的用户进训练集剩下 20% 的用户整块进测试集保证测试集里的用户训练时完全没见过。from sklearn.model_selection import train_test_split uids np.arange(num_users) train_u, test_u train_test_split(uids, test_size0.2, random_state42) train_mask torch.zeros(num_users, dtypetorch.bool) test_mask torch.zeros(num_users, dtypetorch.bool) train_mask[train_u] True test_mask[test_u] True data[user].train_mask train_mask data[user].test_mask test_mask这样划分后指标会掉但掉下来的才是真实水平。如果按用户划分后 AUC 从 0.95 掉到 0.7说明模型之前主要在记用户 ID而不是学水军行为模式这时候要回头检查特征里有没有泄漏用户身份的信息。3. 两层 GCN 跑通训练前向、损失和评估3.1 模型定义与邻居聚合的层数选择GCN 层数不是越多越好。两层意味着每个用户聚合到「邻居的邻居」在评论图里就是「和我评论过同一商品的人他们还评论过什么」。三层以上会出现过平滑over-smoothing所有节点向量趋同分类器失效。水军检测场景我一般用两层最多三层并且加残差连接缓解平滑。import torch.nn.functional as F from torch_geometric.nn import SAGEConv, to_hetero class UserClassifier(torch.nn.Module): def __init__(self, hidden64, out2): super().__init__() self.conv1 SAGEConv((-1, -1), hidden) # -1 表示自动推断输入维度 self.conv2 SAGEConv((-1, -1), hidden) self.lin torch.nn.Linear(hidden, out) def forward(self, x, edge_index): x self.conv1(x, edge_index).relu() x F.dropout(x, p0.3, trainingself.training) x self.conv2(x, edge_index).relu() return self.lin(x) # 异构包装不同边类型共享或独立参数 model to_hetero(UserClassifier(), data.metadata(), aggrsum)逻辑说明这里用 GraphSAGE 卷积而不是原始 GCN因为 SAGE 对邻居做采样聚合在度数分布极不均匀的评论图上更稳——水军账号度数可能上百普通用户只有个位数原始 GCN 的对称归一化会让高度节点被过度压制。to_hetero把同构模型自动扩展到异构图上每种边类型有独立参数。aggrsum比 mean 更能保留「邻居数量」这个信号而邻居数量本身就是水军的重要特征。参数说明hidden64是起点数据量大可以到 128dropout0.3防止过拟合如果训练集远大于测试集可以提到 0.5。注意SAGEConv((-1,-1), hidden)里的 -1 是 PyG 的懒初始化写法第一次前向时自动推断维度异构场景下必须这么写。3.2 训练循环与类别不平衡处理水军样本通常只占 5% 到 15%直接训练模型会倾向于全预测为正常。用带权重的交叉熵权重取类别频率的倒数。device torch.device(cuda if torch.cuda.is_available() else cpu) data data.to(device) model model.to(device) y data[user].y train_mask data[user].train_mask test_mask data[user].test_mask # 类别权重: 水军少 - 权重大 cnt torch.bincount(y[train_mask], minlength2).float() weight (cnt.sum() / (2 * cnt)).to(device) opt torch.optim.Adam(model.parameters(), lr1e-3, weight_decay5e-4) for epoch in range(200): model.train() opt.zero_grad() out model(data.x_dict, data.edge_index_dict)[user] loss F.cross_entropy(out[train_mask], y[train_mask], weightweight) loss.backward() opt.step() if epoch % 20 0: model.eval() with torch.no_grad(): pred model(data.x_dict, data.edge_index_dict)[user].argmax(1) acc (pred[test_mask] y[test_mask]).float().mean() # 水军类召回率比整体准确率更重要 tp ((pred[test_mask] 1) (y[test_mask] 1)).sum() fn ((pred[test_mask] 0) (y[test_mask] 1)).sum() rec tp / (tp fn 1e-8) print(fepoch {epoch} loss {loss:.4f} acc {acc:.4f} recall {rec:.4f})逻辑说明weight的计算让少数类水军获得更大梯度这是处理不平衡最直接的办法。评估时我盯的是水军类召回率而不是整体准确率——漏掉一个水军账号它可能继续刷几百条评论代价远大于误封一个正常用户误封可以人工复核。weight_decay5e-4是图神经网络的常用正则能明显缓解小数据集上的过拟合。参数说明lr1e-3配 Adam 是安全起点loss 震荡就降到 5e-4epoch200对几千节点的图足够图更大要相应增加。如果召回率长期低于 0.5先检查类别权重是否生效再检查特征里有没有把水军信号洗掉。3.3 用 AUC 和 PR 曲线判断模型是否真的可用准确率和召回率都依赖阈值 0.5而水军检测的阈值应该按业务成本调。输出概率后画 PR 曲线看在不同召回率下的精确率再决定阈值。from sklearn.metrics import roc_auc_score, precision_recall_curve, average_precision_score model.eval() with torch.no_grad(): logits model(data.x_dict, data.edge_index_dict)[user] prob F.softmax(logits, dim1)[:, 1].cpu().numpy() y_true y.cpu().numpy() mask test_mask.cpu().numpy() auc roc_auc_score(y_true[mask], prob[mask]) ap average_precision_score(y_true[mask], prob[mask]) print(fAUC {auc:.4f} AP {ap:.4f}) prec, rec, thr precision_recall_curve(y_true[mask], prob[mask]) # 找召回0.8时精确率最高的阈值 idx np.where(rec 0.8)[0] best idx[np.argmax(prec[idx])] print(frecall0.8 时阈值 {thr[best]:.3f} 精确率 {prec[best]:.3f})逻辑说明AUC 衡量排序能力AP平均精确率在不平衡数据上比 AUC 更敏感。真正上线时用thr[best]这个阈值而不是默认 0.5。如果 AUC 0.9 但 AP 只有 0.4说明模型能把水军排前面但绝对概率不可信这时候要么加特征要么接受高误报靠人工兜底。4. 避坑与排查图神经网络做水军检测的五个翻车现场4.1 现象训练 loss 正常下降测试 AUC 只有 0.5原因特征泄漏或标签泄漏。最常见的是把 user_id 的哈希、注册时间戳直接当特征模型记住了训练集用户测试集用户完全陌生。另一个隐蔽原因是构图时用了全量数据计算 TF-IDF测试集用户的词频统计混进了训练。解决所有特征工程只在训练集上 fit再 transform 测试集。用户 ID 类特征一律不进模型让图结构去表达身份关系。检查方法是把测试集用户的特征单独看如果它们的分布和训练集明显不同说明有泄漏。4.2 现象加了商品节点后效果反而变差原因商品节点用 one-hot 且数量上万稀疏高维特征在聚合时引入大量噪声把用户节点原本清晰的信号稀释了。解决商品节点要么给低维稠密特征类目 embedding、价格分桶要么干脆去掉只保留 user-review 二部图。我一般先跑纯用户图拿到 baseline再逐个加节点类型每次加完对比 AP涨了才留。4.3 现象显存爆掉报 CUDA out of memory原因全图训练时 edge_index 和中间激活都放在 GPU 上评论上百万条时邻接聚合的中间张量是节点数的平方级。解决改用 NeighborLoader 做小批量训练每批只采样每个目标节点的 K 跳邻居。K 取 2、每跳采样 10 到 15 个邻居显存立刻降下来。代价是每个 epoch 变慢但能训更大的图。from torch_geometric.loader import NeighborLoader loader NeighborLoader( data, num_neighbors[15, 10], # 两跳分别采样 15 和 10 个邻居 batch_size64, input_nodes(user, train_mask), shuffleTrue, )4.4 现象水军召回率忽高忽低换随机种子结果差很多原因水军样本太少测试集里可能只有几十个正样本指标方差极大。另外图划分时如果某个水军团伙被整体分到测试集模型没见过这种结构召回骤降。解决用 5 折交叉验证按用户分层报告均值和标准差。如果标准差超过 0.1说明数据量不够要么补数据要么降低模型复杂度hidden 降到 32层数降到 1。4.5 现象模型在公开数据集上很好接自己数据就崩原因不同平台的评论风格、水军策略差异巨大。公开数据集里的水军可能是几年前的手法你面对的是新话术。解决留一批自己平台的最新数据做领域适配。简单做法是用自己数据微调最后两层前面卷积层冻结。更彻底的是重新构图、重新训把公开数据集只当预训练。别指望一个模型跨平台通用这是血泪经验。5. 从跑通到能用阈值调优和团伙发现的一个实用技巧模型输出每个用户的概率之后别急着按 0.5 一刀切。真正上线时我会做两件事。第一件是分层阈值新注册用户注册天数小于 7用更低的阈值比如 0.4因为新号是水军的先验概率高老用户用 0.6避免误伤。这个分层规则不需要重新训练只是在推理后处理加几行判断但能把误报压下去一大截。第二件是团伙发现。GCN 给出的是节点级概率但业务方更想要「这一批账号是一伙的」这种结论。做法是在预测为水军的子图上跑连通分量或 Louvain 社区发现把互相连接紧密的账号聚成团。一个团里如果超过 70% 的节点被判为水军整个团一起处理比逐个账号处理更稳也更容易向审核团队解释。import networkx as nx from torch_geometric.utils import to_networkx model.eval() with torch.no_grad(): prob F.softmax(model(data.x_dict, data.edge_index_dict)[user], 1)[:, 1] # 取水军概率高的用户子图 suspect (prob 0.5).nonzero(as_tupleTrue)[0] G to_networkx(data, to_undirectedTrue) sub G.subgraph(suspect.cpu().numpy()) # 社区发现找出团伙 communities nx.community.louvain_communities(sub, seed42) for i, c in enumerate(communities): if len(c) 3: # 只关注 3 人以上的团 avg_p prob[list(c)].mean().item() print(f团伙 {i}: {len(c)} 个账号, 平均水军概率 {avg_p:.3f})这段代码的价值在于把「节点分类」升级成「团伙识别」。实际用的时候团伙规模、平均概率、内部连接密度这三个指标一起看比单看概率可靠得多。我一般会把团伙结果导出成表交给审核同学抽样验证用他们的反馈反过来调阈值——模型和人工形成闭环比一次性调参管用。最后说个习惯每次改完特征或模型结构我都会固定跑一遍按用户划分的测试集记录 AUC、AP、召回率三个数写在一张表里。图神经网络这玩意儿玄学成分不小同一份代码换个随机种子结果能差几个点不记录根本不知道哪次改动是真的有用。数据量少于五千用户时别追求复杂模型两层 GCN 加好特征往往比四层异构图更实在。希望帮到你。本文还有配套的精品资源点击获取