简介:图神经网络是一类针对非结构化关系数据的深度学习模型,擅长从节点和边的拓扑结构中提取群体行为特征。虚假影评水军往往以协同方式批量操作,单条文本很难识别,但账号间的评分行为、时间规律和网络关联会留下明显痕迹。将用户与电影建模成二部图,并利用图卷积神经网络(GCN)做节点分类,只需少量标注即可推断剩余节点是否为水军。这种半监督思路不仅降低了人工标注成本,也贴合真实业务中“群体识别”的需求。本文从数据清洗、邻接矩阵构建、节点特征设计到两层GCN最小实现,给出了一套基于Python和PyTorch的完整工程路径,并总结了数据泄漏、过平滑、内存溢出等常见坑点。适合大创、毕设或想快速上手图挖掘项目的读者参考。
1. 用图卷积神经网络检测虚假影评水军:把问题建模成节点分类
一条影评单独拿在手上,A/B 测试很难看出毛病;一万条影评同时涌向同一部电影,账号注册时间、打分极端值、发布间隔这些信号开始变成一张网。虚假影评水军检测真正要处理的是“群体行为”,而 Python 里的图卷积神经网络刚好擅长把一个群体的关系结构变成可学习的特征。这个标题背后的大创项目,核心工作并不是把 GCN 写得有多深,而是先把影评数据组织成“用户—电影”关系图,再把水军识别当作图上的节点分类任务。对于正在做大创、毕设或者想拿一份能讲清楚的数据挖掘项目的同学,这个方向的好处是:标签可以只标一小部分,模型也能把剩余节点推断出来。
2. 图卷积神经网络不是堆层数:邻居结构与水军信号的关系
2.1 虚假影评水军检测的本质:不是文本分类,是用户节点分类
很多刚上手的人会把“虚假影评检测”理解成 NLP 任务,觉得应该对评论文本做情感分析或者用 BERT 做二分类。这个理解不能说错,但和实际场景有偏差。水军评论里大量是搬运文案、通用好评模板,单条文本的词汇分布可能和普通好评没有显著差别;真正能区分的是行为模式——水军会在电影上映后短时间内集体打分,账号之间共享设备、互相点赞、评分极端。
所以更常见的建模方式是把影评数据里的每个用户当作一个节点,把用户与电影之间的评分关系当作边,然后给少量已知水军账号和正常账号打标签,让图卷积神经网络去推断其余节点的类别。这样处理后,模型判断的不是“这句话假不假”,而是“这个账号的行为模式像不像水军”。图卷积神经网络的优势就出来了:它能让同属于一个可疑群体的节点通过邻居关系互相传递特征,即使某条评论本身看不出问题,只要它的邻居大量是水军,当前节点也更容易被识别出来。
这种半监督思路特别贴合大创项目的实际约束。完整标注水军账号成本很高,人工逐个审核几千个账号不现实;GCN 允许只标注几十个到几百个节点,剩余标签靠图结构和特征传播补上。这比纯监督的文本分类更贴近真实部署场景,也更容易在答辩时讲清楚“为什么用图”。
2.2 GCN 做了什么事:一跳邻居聚合与被展开的显式表达
标准图卷积层的更新可以写成一句话:每个节点的新特征,等于它自己和邻居节点特征的加权平均,再经过一个线性层和激活函数。用公式表达就是 H^(l+1) = σ(D^(-1/2) A D^(-1/2) H^(l) W^(l)),其中 A 是邻接矩阵,D 是度对角矩阵,W 是可学习的权重矩阵。
这个公式看着绕,但拆开很直观。D^(-1/2) A D^(-1/2) 是对邻接矩阵做对称归一化,目的是避免“大V用户或者热门电影”因为邻居数量太多而把其他节点淹没;拿到归一化邻接矩阵后,一次矩阵乘法就完成了所有节点对一跳邻居的特征聚合。如果你想让节点看到更远的群体结构,再叠一层卷积,它就能看到两跳邻居。层数不需要深,这是 GCN 和传统神经网络在结构上最大的区别。
放到影评水军场景里,一条典型链路是这样的:用户 A 是水军,给五部电影打了五星短评;用户 B 也在这五部电影下打过相近时间段的五星短评。经过第一层 GCN 后,A 和 B 的特征里都混入了彼此的行为统计量;经过第二层后,和 A 同一批注册的 C、D 也会被拉近。所谓“物以类聚”在嵌入空间里被显式表达成了特征向量的距离。
这里有一个常见误用:有人为了提升效果,把 GCN 叠到四五层。实际在大几十万节点、边权稀疏的行为图上,三四层以上的 GCN 会出现过平滑——所有节点特征趋于一致,区分度迅速下降。后面避坑章节会再展开,但选型阶段就要记住:两层 GCN 是这类项目的默认起点。
2.3 选 GCN 而不是 GraphSAGE 或 GAT:大创级项目的第一版模型
同系列图算法里,GraphSAGE 通过采样邻居来聚合特征,适合超大规模图;GAT 给每条边引入注意力权重,适合边重要性差异明显的场景。GCN 的定位是三者中最简单、最容易训练、依赖最少的基础模型。对大创项目来说,第一版应该用 GCN,原因有三个。
第一,数据量通常不足以让注意力机制发挥优势。GAT 多了 attention 参数,在只有几百个有标签节点的半监督任务里容易过拟合。第二,GraphSAGE 的邻居采样逻辑在数据量不大时收益不明显,反而增加工程复杂度。第三,GCN 的实现可以用一个稀疏矩阵乘法完成,方便你把“邻居聚合”作为一条基线和后期改进方向对比。
如果答辩需要体现模型对比,合理的梯度是:先跑通两层 GCN,再用 GAT 替换卷积层,观察 macro-F1 是否有提升。经验上手写 GCN 基线大约 50 行左右,换 GAT 则推荐直接用 PyTorch Geometric 的 GATConv,不要自己实现多头注意力。
模型选型可以用这张表辅助决策:
| 模型 | 邻居聚合方式 | 适合场景 | 大创项目适配度 |
|---|---|---|---|
| GCN | 等权加权平均 | 小规模、半监督、快速出基线 | 高 |
| GraphSAGE | 随机采样邻居再聚合 | 节点规模百万以上 | 低 |
| GAT | 学习每条边的注意力权重 | 边重要性差异化明显 | 中,作为改进版 |
3. 数据落地:从评论表到邻接矩阵与节点特征的三个决定
3.1 原始表清洗:哪些字段留下,哪些字段是噪声
图卷积网络本身不挑数据格式,但你的原始影评表里能留哪些字段,直接决定后续构图方式。常见做法是先把影评数据整理成至少包含 user、movie、rating、text、timestamp 五列的结构。用户昵称要去空格、转小写并去重;电影名称同样要归一化,否则“《流浪地球》”和“流浪地球”会被当成两个节点,导致图分裂成碎片。
数据清洗是我最不建议跳过的一步。有人拿到的开源数据可能已经带 label 字段,但 label 经常是空的或者打错位置;有人是自己爬的数据,字段名不统一。落到代码层面,第一步这样处理:
import pandas as pd df = pd.read_csv("movie_reviews.csv") df = df.dropna(subset=["user", "movie", "rating"]) df["user"] = df["user"].str.strip().str.lower() df["movie"] = df["movie"].str.strip() df = df.drop_duplicates(subset=["user", "movie", "timestamp"])这段代码的作用是去空值、统一用户和电影名称格式、删除同一用户在完全相同时间点对同一电影的重复评论。注意这里没有去掉同一用户对同一电影的正常多次评分,因为真实平台允许修改评论,时间戳不同就是两次行为,这部分信息对水军检测有价值。如果你发现数据里大量存在“同一用户同一电影但时间完全相同”的记录,那大概率是采集时的重复请求,删除是安全的。
Python 在这个场景下的优势很明显:pandas 做字段清洗、scipy 做稀疏矩阵、PyTorch 做模型训练,全部在一个语言环境里完成,不需要跨语言搬数据。相比用 Java 或者 C++ 组织数据,Python 生态里每一步都有现成轮子,这也是这个项目选 Python 最现实的原因。
3.2 构建邻接矩阵:对称二部图、自环与归一化
清洗完成之后进入核心步骤:构建邻接矩阵。用户和电影不是同一类实体,常见做法是构造一个对称的二部图。先把用户编号从 0 到 N_user-1,电影编号从 N_user 到 N_user+N_movie-1,然后在“用户—电影”评分关系上建边。
边权不是只能用 0/1。一个比较有效的做法是用极端评分程度作为权重:评分 3 分权重接近 0,评分 1 分或 5 分权重接近 1。理由是普通观众给中评的权重不该和水军极端好评一样大。代码可以这样落地:
import numpy as np from scipy import sparse users = df["user"].astype("category") movies = df["movie"].astype("category") user_codes = users.cat.codes.values movie_codes = movies.cat.codes.values + len(users.cat.categories) n_nodes = len(users.cat.categories) + len(movies.cat.categories) rows = np.concatenate([user_codes, movie_codes]) cols = np.concatenate([movie_codes, user_codes]) weight = np.abs(df["rating"].values - 3) / 2 vals = np.concatenate([weight, weight]) A = sparse.coo_matrix((vals, (rows, cols)), shape=(n_nodes, n_nodes)).tocsr()构建完原始邻接矩阵之后,有两步必须做。第一步是加自环,把 A 加上单位矩阵,否则一个仅有一条评论的普通用户在自己身上没有特征传递路径。第二步是对称归一化,公式就是用节点度的负二分之一次方乘邻接矩阵再乘一次,避免热门电影拥有过高聚合权重。
A = A + sparse.eye(n_nodes) deg = np.array(A.sum(axis=1)).flatten() deg[deg == 0] = 1 deg_inv_sqrt = 1.0 / np.sqrt(deg) D_inv_sqrt = sparse.diags(deg_inv_sqrt) A_norm = D_inv_sqrt @ A @ D_inv_sqrt这里deg[deg == 0] = 1是防御性写法。理论上加了自环后不存在零度节点,但总会在某些脏数据里出现节点索引悬空,这一步能防止后续算出 NaN。归一化后的 A_norm 是稀疏矩阵,训练前还需要转成 PyTorch 的稀疏张量,推荐用 COO 格式转换:
import torch A_coo = A_norm.tocoo() edge_index = torch.LongTensor(np.vstack([A_coo.row, A_coo.col])) edge_attr = torch.FloatTensor(A_coo.data) A_t = torch.sparse_coo_tensor(edge_index, edge_attr, torch.Size(A_coo.shape))大创项目常见的数据规模是:用户数两万到五万、电影数五千到一万、评论数二十万到五十万。如果直接构造稠密矩阵,内存占用会达到几个 GB 甚至几十 GB,所以必须全程保持稀疏存储。这也是为什么题目里“构建邻接矩阵”是关键步骤的原因——很多复现失败不是模型写错,而是第一步矩阵就爆了内存。
3.3 节点特征:先用数字统计特征,不要第一版就上 BERT
邻接矩阵解决了“谁和谁有关系”,节点特征解决“每个节点本身长什么样”。这两者缺一不可:如果节点特征全是 0 向量,GCN 学到的只有结构信息,效果会比较有限。对用户节点,我一般建议先做五个统计特征:评论数量、平均评分、评分标准差、评论时间跨度、文本长度均值。
user_stats = df.groupby("user").agg( review_count=("rating", "size"), rating_mean=("rating", "mean"), rating_std=("rating", "std"), text_len_mean=("text", lambda x: x.str.len().mean()), rating_std_abs=("rating", lambda x: np.abs(x - 3).mean()), ).reset_index()靠这五维特征加邻接矩阵,通常已经能跑出比随机猜测显著更高的结果。文本信息暂时不用直接进入图模型,因为对每条评论做 BERT 编码不仅耗时,而且评论级向量在图结构里和用户节点对齐很麻烦。更实用的做法是先把文本长度、以及文本里是否有“好评返现”这类关键词做成用户侧聚合统计,后期再单独尝试文本向量作为额外特征。
对电影节点,可以用该电影获得的平均评分、评论数量、以及是否存在集中式灌水时段作为特征。如果你把电影节点纳入节点分类范围,那它的特征矩阵要和用户节点拼接在一起。实际操作中,可以只对用户节点做分类,电影节点只作为桥梁来传递邻居信息,这样特征矩阵构造更简单,分类目标也更集中。第一版项目建议采用后一种方案。
4. 复现一个最小可用 GCN 检测代码:网络结构、损失函数与超参设置
4.1 两层 GCN 为什么是默认选择
第一版模型不用刻意追求复杂。两层 GCN 意味着每个节点最多聚合两跳邻居的信息,对影评水军来说,两跳已经能覆盖“我给某电影打分,你也给某电影打分,我们同时与一批可疑用户相邻”的群体线索。第三层会引入大量弱相关邻居,噪声占比上升,叠加过平滑问题,验证集指标反而可能下降。
实现时可以自己写一个极简的图卷积层,也可以用 PyTorch Geometric。如果你只是想快速验证数据建得对不对,我更推荐先手写一个两层 GCN,逻辑透明、依赖少,出了问题也容易定位。一个可以运行的最小定义如下:
import torch import torch.nn as nn import torch.nn.functional as F class GraphConv(nn.Module): def __init__(self, in_dim, out_dim, activation=True): super().__init__() self.linear = nn.Linear(in_dim, out_dim, bias=False) self.activation = activation def forward(self, x, adj): # adj @ x 做邻居特征聚合,再经过线性变换 h = adj @ x h = self.linear(h) if self.activation: h = F.relu(h) return h class ReviewGCN(nn.Module): def __init__(self, in_dim, hidden_dim): super().__init__() self.conv1 = GraphConv(in_dim, hidden_dim, activation=True) self.conv2 = GraphConv(hidden_dim, 2, activation=False) self.dropout = nn.Dropout(p=0.5) def forward(self, x, adj): x = self.conv1(x, adj) x = self.dropout(x) return self.conv2(x, adj)模型定义里有两个细节需要注意。第一,第二层不再加 ReLU,因为输出直接进交叉熵损失函数,中间不需要约束为正值。第二,adj @ x用的是稀疏邻接矩阵与稠密特征矩阵相乘,如果adj是 PyTorch 稀疏张量而不是稠密矩阵,这一行代码的显存占用会低一个量级。我在数据章节强调转 COO 稀疏张量,就是为了这里能顺利跑起来。
4.2 半监督 masked loss 与 early stopping
半监督分类体现在损失函数上:模型对所有节点做前向计算,但反向传播只考虑有标签节点上的损失。这个操作在 PyTorch 里用布尔掩码实现,非常直接。训练循环的核心只有十几行:
model = ReviewGCN(in_dim=X.shape[1], hidden_dim=128) optimizer = torch.optim.Adam(model.parameters(), lr=0.005, weight_decay=5e-4) for epoch in range(200): model.train() logits = model(X, A_t) loss = F.cross_entropy(logits[train_mask], y[train_mask]) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 10 == 0: model.eval() with torch.no_grad(): val_logits = model(X, A_t) val_loss = F.cross_entropy(val_logits[val_mask], y[val_mask]) print(f"{epoch:03d} train_loss={loss:.4f} val_loss={val_loss:.4f}")训练参数里最关键的是三个:学习率、隐藏层维度、weight_decay。学习率默认可以设 0.005,比图像任务的 0.001 略高,因为图卷积层数浅,梯度传播路径短;如果 loss 发散就下调到 0.001。隐藏层维度我一般取 64 或 128,因为节点特征维度本来不高,256 维在几万节点的图上会明显拖慢训练且收益不明显。weight_decay 设 5e-4 对避免自环带来的自传播过拟合有一定帮助,但不能设太大,否则所有节点特征会被严重压平。
标签掩码的划分方式比学习率更容易翻车。正确做法是先把用户按 user_id 做分层抽样,再根据用户的索引构造训练、验证、测试掩码。换句话说,同一个用户的所有行为数据必须保持在同一个集合内。后面避坑章节会详细说误划分造成的泄漏问题,这里先记住结论:按用户划分,不按评论划分。
4.3 超参数表与第一版调试路径
超参数的初始值可以参照一张简表,然后按验证集 macro-F1 微调:
| 参数 | 建议起始值 | 调整方向 |
|---|---|---|
| hidden_dim | 128 | 过拟合时降到 64 |
| lr | 0.005 | 发散降到 0.001 |
| weight_decay | 5e-4 | 验证集会降时调到 1e-3 |
| dropout | 0.5 | 标签少时提到 0.6 |
| epochs | 200 | 配合早停,不要盲目加 |
第一次调试时不要一次性调所有参数。我一般建议的顺序是:先固定隐藏层 128,观察 loss 是否正常下降;如果 loss 降不下来,加自环检查邻接矩阵;如果训练 loss 能降到接近 0 但验证集指标差,再调 dropout 和数据划分;最后才动学习率。这样可以避免多个参数同时抖动时的“调参玄学”。
5. 虚假影评项目避坑排查:五个会导致复现崩溃的常见问题
5.1 现象一:loss 第一轮就出现 NaN 或不下降
最常见的原因是邻接矩阵里有孤立节点,或者归一化时产生了除零错误。加了自环后通常不会出 NaN,但如果你是在归一化之后才加的自环,那么度矩阵里存在值为 0 的项,1/sqrt(deg)就变成了无穷大,矩阵乘法一算全是 NaN。
解决方法是严格按顺序操作:先建原始邻接矩阵,再加自环,最后做对称归一化。另外在计算deg_inv_sqrt之后,显式把非有限值替换为 0。这一步能防止脏数据导致的节点索引断裂,跑实证时你会感谢这行防御代码。检查方法很简单:打印A_norm的前几个元素,再打印A_norm.sum(axis=1)是否全为 1。
5.2 现象二:准确率特别高,但水军召回率几乎为 0
这是我见过最多的情况。正常用户占比 90% 以上,模型只要把所有人都预测成正常用户,准确率就已经是 90%。如果你只用准确率当指标,看起来效果很好,但实际一个水军都抓不出来。
解决方法是把评估指标换成 macro-F1,并重点关注“水军”这一类别的召回率。模型层面可以考虑给交叉熵损失加上类别权重,让少样本类别错判的惩罚更大。在 PyTorch 里F.cross_entropy支持传入weight参数,比如正样本权重设为正常样本数的反比。另一个做法是采样训练集时让水军和正常用户的比例接近 1:1,但验证集必须保持真实分布。
5.3 现象三:数据泄漏,验证指标虚高,换新数据就崩
数据泄漏多发生在处理评论表时直接把行切分成训练集和验证集。同一个用户的第一条评论进了训练集、第二条评论进了验证集,模型在训练时就见过这个用户的行为模式,验证集自然表现得很好。这种虚高的指标一旦到了真实场景,同一个用户的全部评论一起进来,泛化马上露馅。
解决方法是按用户划分数据集:先用df["user"].unique()取出所有用户,把用户列表随机分成训练用户、验证用户、测试用户,再根据 user 字段回头过滤评论行。代码上大概长这样:
users = df["user"].unique() rng = np.random.RandomState(42) perm = rng.permutation(users) n = len(perm) train_users = set(perm[:int(n*0.7)]) val_users = set(perm[int(n*0.7):int(n*0.85)]) test_users = set(perm[int(n*0.85):]) df_train = df[df["user"].isin(train_users)] df_val = df[df["user"].isin(val_users)] df_test = df[df["user"].isin(test_users)]注意测试集用户必须在构图时也存在图中,只不过它的标签不参与训练。如果某个测试用户和训练用户之间没有电影交集,它在图上就成了孤立点,这会导致测试场景和训练场景不一致。所以划分完集合后,要看一眼测试用户里有多少在图中没有边,尽量把测试集放到数据主要连通分量里。
5.4 现象四:邻接矩阵太大,直接用稠密矩阵训练内存溢出
很多教程为了简化,直接写adj @ x,默认 adj 是普通矩阵。当节点数达到三万以上,稠密邻接矩阵就是 3 万乘 3 万,光矩阵本体就有 3.6GB,这还没算中间变量。解决方法是全程保持 scipy 稀疏格式,并在转换 PyTorch 张量时使用torch.sparse_coo_tensor。转换后矩阵乘法的实现会自动走稀疏路径,内存占用降到原来的几十分之一。
如果你用的是 PyTorch Geometric,它内部就是用edge_index加edge_attr表示稀疏邻接关系,不需要手动构造完整的邻接矩阵。这个套路也可以作为自查标准:模型 forward 里如果出现了torch.zeros(n, n)这类代码,基本就是走偏了。
5.5 现象五:半监督标签太少,模型把所有节点都预测成多数类
手头只有二三十个水军标签时,GCN 容易把所有节点都推向“正常用户”,因为标签监督信号太弱。这不一定说明模型有问题,而是标签量撑不起判别边界。常见的做法是先做规则筛选再人工复核,用“高置信度样本”扩充训练标签:注册时长短、极端评分比高、同一时间段大量发评的账号先标成水军;长期活跃、评分分布接近电影平均分的账号标成正常。这些伪标签不需要完全准确,只要正确率明显高于随机即可,然后通过 GCN 的半监督机制去修正边缘节点。
如果标签量实在上不去,还有一个比较实际的处理方式:把问题从“二分类”改成“排序”。让模型输出水军概率分,然后按分数从高到低抽检,再结合人工审核结果调整标签。这样训练集即使比较小,也能迭代出可用的第一版结果。
6. 用 macro-F1 验证效果:以及值得再试一版的两种图信号
6.1 验证指标:为什么盯住 macro-F1,以及怎么报告结果
水军检测里直接看准确率没有实际意义,因为负样本占了绝大多数。macro-F1 把“水军”和“正常用户”两类各自的 F1 单独算出来再取平均,这样不会让多数类主宰指标。一行代码就能得到:
from sklearn.metrics import f1_score pred = logits.argmax(dim=1).numpy() macro_f1 = f1_score(y_test, pred, average="macro")报告结果时我建议至少跑三次以上,每次换一个随机种子,然后报告平均 macro-F1 和标准差。很多人只跑一次就把数字写进结题报告,结果换一次随机种子效果波动 5 个点左右,答辩时被追问一次就露怯。多种子取平均是成本最低的稳定性证明。
6.2 值得再试一版的两种图信号:时间行为边与评论语义相似边
第一版跑通后,往图上加信号比换模型更划算。第一种是时间信号:水军往往集中在电影上映后的短时间窗口内发评。可以把时间戳按小时分桶,在用户之间建立“同一天对同一部电影打过分”的连边,或者把时间熵作为额外节点特征。时间特征在本质上和 GCN 的结构聚合是不同的信号,叠加后通常能有效提升水军召回率。
第二种是评论语义相似边:用 TF-IDF 或者轻量文本向量计算用户之间评论的相似度,将相似度超过阈值的用户连一条边,让语义相似的节点在图中互相传递信息。这个做法不需要引入 BERT,计算成本可控,但能把“同模板文案”的群体抓出来。
我自己的亲身体会是,第一版往往死在“接邻接矩阵只用了 0/1 边权”这件事上。把边权换成极端评分程度、加入时间行为边之后,效果提升比换 GAT 更明显。做这个方向不要一上来追求模型酷炫,先把图建得贴近真实水军行为模式,模型才能学到真正可迁移的信号。希望这些记录能帮你在自己的数据上少走几步弯路。
本文还有配套的精品资源,点击获取