GCN与BERT结合的水军检测:异构图构建与实战解析
2026/9/24 21:09:42 网站建设 项目流程

简介:针对虚假影评和水军干扰消费者决策的现实问题,这套Python源码以图卷积神经网络(GCN)为核心,构建了从数据清洗、图结构建模、模型训练到结果评估的完整检测流程。资源包共26个文件,大小约14.21MB,内置9个Python脚本,分别承担数据处理、图划分、模型定义与训练等任务;8个CSV文件提供猫眼长评、电影类型等实验数据;此外还有BERT预训练配置、词汇表、配置文件、说明文档及训练日志,目录层次清晰,便于对照源码理解GCN在文本关联分析中的具体应用。目前已有70人学习浏览,适合正在开展大创项目、毕业设计或对图神经网络感兴趣的高年级本科生和研究生。通过学习可以掌握用户—评论异质图的构建方法、邻接矩阵与特征表示的处理技巧,并能复现评估指标、调整超参数,为后续在社交水军识别、推荐系统等方向的研究提供可直接扩展的代码基础。

1. 水军检测为什么选中了图卷积神经网络

做虚假影评水军检测这个 Python 项目之前,我拿纯文本分类的路子试过一轮:BERT 对每条评论单独编码,然后过一个分类头,判断这条评论是不是水军写的。效果能跑,但瓶颈很明显——单条评论的文本特征太有限了。水军为了过审,话术通常模仿得很像正常人,单看一条评论,人和模型都很难判断它是不是刷出来的。真正暴露水军身份的,是评论之间的"关系":一个账号短时间内集中评论同一批电影、一批账号几乎同时给同一部影片打高分、评论时间和电影上映时间高度重合。这些信号天然是图结构信号,而不是文本信号。所以这个项目把用户、评论、电影建成一张异构图,用图卷积神经网络(GCN)去做节点分类,检测目标从"这条评论假不假"变成了"这个用户是不是水军"。项目源码完整覆盖了从猫眼长评 CSV 清洗、构图、BERT 抽取文本特征到 GCN 训练评估的全流程,适合做大创项目、毕业设计,以及想入门图神经网络实战的人。

2. 从猫眼长评到邻接矩阵:原始数据与构图细节

2.1 数据文件里到底有什么

项目解压后,数据侧主要落在 maoyan 目录下,核心文件是这几个:maoyan_long_comments.csv猫眼长评总表.csv电影名单.csv电影类型.csv。前两个是长评明细,后两个是电影维度的补充信息。maoyan_long_comments.csv猫眼长评总表.csv内容上有重叠,但字段不完全一致,我实际使用的时候以maoyan_long_comments.csv为主,另一个作为字段补全的来源。

常见的做法是,长评明细表里至少包含用户 ID、评论 ID、电影 ID、评分、评论文本、评论时间这几列。电影名单.csv提供电影名称、上映日期,电影类型.csv则把每部电影映射到类型标签上。这个类型标签在后面构造电影节点初始特征时有用,不能只当背景信息丢掉。

读文件的起点是data_utils.py,它负责把 CSV 读成 DataFrame,然后做字段筛选、去重、空值处理。实际数据里最常出现的脏数据是三种:用户 ID 为空、评论文本为纯空白字符、同一条评论因为爬虫重复抓取出现多次。第一种和第三种直接丢弃,第二种用长度过滤去掉内容过短的评论。

import pandas as pd def load_comments(csv_path): df = pd.read_csv(csv_path, encoding="utf-8-sig") # 只保留核心列,其他列在这个项目里用不到 df = df[["user_id", "comment_id", "movie_id", "rating", "content", "comment_time"]] df = df.dropna(subset=["user_id", "comment_id", "movie_id"]) df = df.drop_duplicates(subset=["comment_id"], keep="first") # 过滤掉过短的评论,这类文本特征太弱,构图后也是噪声 df = df[df["content"].str.strip().str.len() > 5] return df.reset_index(drop=True)

这段代码做的事很直接:选列、去空、去重、过滤短文本。drop_duplicates是按comment_id去重,因为同一条评论可能在不同时间被爬虫抓到两次;这里不按user_id + movie_id去重,因为同一个用户对同一部电影写多条长评是真实存在的行为,去掉了反而损失信息。过滤短文本是为了避免把"好看""不错"这种噪声纳入图里。

2.2 异构图怎么建:用户、评论、电影三类节点

这是全项目最核心的建模决策。常见的水军检测做法是直接建"用户-电影"二部图,用户和电影之间连边,边的权重是评分或者评论数。但这样做丢掉了评论本身的信息,而且用户给一部电影打高分和写长评,行为强度完全不一样。这个项目用的是异构图,节点分成三类:用户节点、评论节点、电影节点,边也分成三类。

构图方案是这样的。用户节点和评论节点之间连一条边,表示"这个用户写了这条评论";评论节点和电影节点之间连一条边,表示"这条评论在评论这部电影";用户节点和电影节点不直接相连,中间必须经过评论节点。这样的设计让每一条评论都成为用户和电影之间的"桥梁",GCN 在消息传递时,用户节点的邻居是它写过的评论,电影节点的邻居是它收到的评论,评论节点的邻居同时包含用户和电影两边。

graph_section目录和graph_model.py里就放着这部分逻辑。建图时通常用 networkx 或者直接手动维护邻接表,考虑到后续要转成大矩阵做 GCN 运算,我一般倾向于直接用字典维护边,避免数据类型转换的麻烦。

from collections import defaultdict class HeteroGraph: def __init__(self): self.user_comment = defaultdict(set) # user_id -> set(comment_id) self.comment_movie = {} # comment_id -> movie_id self.user_list = [] self.movie_list = [] self.comment_list = [] def add_comment(self, user_id, comment_id, movie_id): self.user_comment[user_id].add(comment_id) self.comment_movie[comment_id] = movie_id if user_id not in self.user_list: self.user_list.append(user_id) if movie_id not in self.movie_list: self.movie_list.append(movie_id) if comment_id not in self.comment_list: self.comment_list.append(comment_id)

这份代码展示了构图的最小数据结构:两个映射关系,三个节点列表。user_commentdefaultdict(set)是因为一个用户可能写多条评论,集合天然去重;comment_movie是字典,因为一条评论只属于一部电影,用不着存集合。user_listmovie_listcomment_list三个列表用于后续给节点编号,编号就是 GCN 里节点特征的索引。

2.3 节点编号与邻接矩阵生成

构图之后的下一步是把符号化的 ID 映射成连续的整数编号。因为 GCN 的输入特征矩阵是按行索引的,第 i 行对应编号为 i 的节点,所以必须先完成映射。data_utils.py里做了这件事:把用户、评论、电影混合编号,邻接矩阵的维度就是三类节点数量的总和。

这里有一个容易忽略的细节:邻接矩阵的规模。假设有 5000 个用户、8000 条评论、300 部电影,总节点数约 13300,邻接矩阵是约 13300 × 13300 的稀疏矩阵,直接构造成密集矩阵会直接把内存撑爆。所以代码里必须用稀疏矩阵格式存储,常见做法是scipy.sparse.coo_matrix或者直接存边列表喂给 PyTorch Geometric,这个项目不是 PyG 实现的,所以用前一种。

import numpy as np from scipy.sparse import coo_matrix def build_adjacency(graph, num_nodes): rows, cols = [], [] # 用户 -> 评论 边 for user, comments in graph.user_comment.items(): u_idx = node_id_map[user] for c in comments: c_idx = node_id_map[c] rows.append(u_idx); cols.append(c_idx) rows.append(c_idx); cols.append(u_idx) # 无向图,两边都要记录 # 评论 -> 电影 边 for c, m in graph.comment_movie.items(): c_idx = node_id_map[c] m_idx = node_id_map[m] rows.append(c_idx); cols.append(m_idx) rows.append(m_idx); cols.append(c_idx) data = np.ones(len(rows), dtype=np.float32) adj = coo_matrix((data, (rows, cols)), shape=(num_nodes, num_nodes)) return adj.tocsr()

coo_matrix接收三个参数:数值、行索引、列索引,然后转换成csr格式用于后续矩阵乘法。这里把边存成无向边,意味着信息可以在评论和用户之间双向传播。水军检测场景里双向传播是合理的:用户节点的特征能流向评论节点,反过来评论节点的特征也能更新用户节点的表示,两轮消息传递之后,一个用户是否与大量水军评论产生关联,就体现在他的节点嵌入里。

3. GCN + BERT 双塔特征:model.py 与 graph_model.py 的实现逻辑

3.1 三类节点的初始特征从哪来

GCN 的每一层都在做"聚合邻居信息"这件事,但第一层聚合需要一个起点,也就是每个节点的初始特征向量。项目里三类节点的初始特征来源完全不同,这是异构图和普通同构图最大的区别。

评论节点用 BERT 提取文本特征。BERT 部分在bert_pretrain目录下,包含bert_config.jsonbert-base-chinese-vocab.txt,调用的是预训练的中文 BERT 模型。常见做法是把评论文本送入 BERT,取[CLS]位置的输出向量作为整条评论的语义表示。也有一种做法是取所有 token 的均值池化,但实际效果差别不大,[CLS]更省事。这里有一个性能上的关键取舍:BERT 参数量很大,如果让 GCN 和 BERT 端到端联合训练,显存压力非常大,而且文本语义学习和图结构学习两个任务互相干扰。稳妥的方案是用 BERT 先把所有评论的语义向量一次性抽出来,保存在本地,GCN 训练时直接读取。

用户节点没有原始文本,它的初始特征来自他自己写过的评论的聚合。常见做法是对该用户所有评论的 BERT 向量取均值,这样用户节点的初始特征就携带了"这个用户平时说话风格"的信息。电影节点的初始特征来自电影类型,把类型做 one-hot 编码,再拼接票房、评分等属性,就构成了电影节点的特征向量。

graph_model.py里对应实现了这些特征装配逻辑。下面给出特征装配的核心片段:

def build_node_features(graph, comment_bert_vectors, movie_type_matrix): num_nodes = len(graph.user_list) + len(graph.comment_list) + len(graph.movie_list) feat_dim = comment_bert_vectors.shape[1] features = np.zeros((num_nodes, feat_dim), dtype=np.float32) # 评论节点直接取 BERT 向量 for c in graph.comment_list: features[node_id_map[c]] = comment_bert_vectors[c] # 用户节点取该用户评论向量的均值 for u, comments in graph.user_comment.items(): vecs = [comment_bert_vectors[c] for c in comments if c in comment_bert_vectors] if vecs: features[node_id_map[u]] = np.mean(vecs, axis=0) # 电影节点用类型 one-hot 向量,维度不够做零填充 for m in graph.movie_list: features[node_id_map[m]] = movie_type_matrix[m] return torch.from_numpy(features).float()

三个循环对应三类节点的特征初始化。用户节点取均值是最朴素的做法,它的假设是:一个用户的语言习惯在他写过的所有评论里是稳定的。这个假设对水军尤其成立,因为水军账号通常是机器或者低薪刷手在操作,话术模板高度重复,均值会让模板特征更突出。电影节点这里只有类型 one-hot,属于比较薄弱的特征,但它不是主要分类依据,GCN 更依赖图结构信号。

3.2 两层 GCN 的消息传递机制

model.py里的 GCN 模型是全项目的核心。标准的 GCN 层做的事情可以概括为:每个节点拿到邻居节点的特征,做归一化聚合,再经过一个线性变换和非线性激活。数学形式是 H^(l+1) = σ(D^(-1/2) A D^(-1/2) H^(l) W^(l)),其中 A 是带自环的邻接矩阵,D 是度矩阵。

这里的归一化方式用的是对称归一化,而不是直接对邻接矩阵的行做归一化。两者的区别在于:行归一化只考虑"我这个节点从邻居那拿多少信息",对称归一化同时考虑"我的邻居在被聚合时有多忙"。一个用户如果写了 100 条评论,他的特征更新时,每条评论分配到的权重就比只写了 2 条评论的用户小得多。对称归一化的好处是防止度数特别高的节点(比如狂热影评人)主导整个特征分布。

项目里常见的配置是两层 GCN,隐藏层维度 128 或 256,激活函数用 ReLU,最后一层输出维度为 2,接 softmax 做二分类。下面是 GCN 层的核心实现:

import torch import torch.nn as nn import torch.nn.functional as F class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.weight = nn.Parameter(torch.FloatTensor(in_dim, out_dim)) self.bias = nn.Parameter(torch.FloatTensor(out_dim)) nn.init.xavier_uniform_(self.weight) nn.init.zeros_(self.bias) def forward(self, x, adj_norm): # x: [num_nodes, in_dim], adj_norm: [num_nodes, num_nodes] support = torch.mm(x, self.weight) # 线性变换 output = torch.spmm(adj_norm, support) # 邻居聚合 output = output + self.bias return output class GCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, dropout=0.5): super().__init__() self.layer1 = GCNLayer(in_dim, hidden_dim) self.layer2 = GCNLayer(hidden_dim, out_dim) self.dropout = dropout def forward(self, x, adj_norm): h = F.relu(self.layer1(x, adj_norm)) h = F.dropout(h, p=self.dropout, training=self.training) out = self.layer2(h, adj_norm) return F.log_softmax(out, dim=1)

torch.spmm是稀疏矩阵乘稠密矩阵的高效算子,专门用来做邻接矩阵和特征矩阵的乘法。如果这里用torch.mm处理稠密邻接矩阵,几万节点的规模下显存会立刻爆掉。adj_norm在训练之前就一次性算好,不需要每个 epoch 重复计算,这也是节省时间的小技巧。

3.3 为什么选 GCN 而不是 TextCNN 或者 LSTM

这个选择背后有一个很重要的直觉:水军检测的核心困难在于单条文本语义上没有明显异常。TextCNN 和 LSTM 的处理单位是一条评论,它们再强,也只能捕捉文本内部的模式。水军区别于普通用户的关键信号是"群体行为",比如 50 个账号在同一天给同一部电影打五星,这 50 个账号之间存在强关联,而这种关联是文本模型看不到的。

GCN 的价值在于把分类信号从"文本内容"扩展到了"图上的位置关系"。假设一个用户只写过一条评论,文本内容看起来完全正常,但他的评论连接到了一部近期被大量集中好评的电影,同时与这部电影关联的评论节点中,有相当比例来自低活跃度的新账号,那么在一个两层的 GCN 里,这个用户的特征会在两轮传播后携带上邻居节点的大量信息,从而被识别为异常。graph_model.pymodel.py的分工就在这:graph_model.py负责把图数据变成张量,model.py负责定义 GCN 本身的网络结构。

4. 训练流程与参数设置:config.py 到 train.py 的完整链路

4.1 config.py 里的关键超参数

config.py集中管理训练参数,这是整个项目里第一个应该打开看的文件。它决定了模型的训练方式、数据划分比例、计算资源的占用方式。常见配置项包括学习率、训练轮数、隐藏层维度、dropout 比例、权重衰减系数、随机种子等。

我把这份资源里合理的默认参数整理成了下面的表格,实际运行时可以根据数据规模调整。

参数建议值说明
learning_rate0.005 ~ 0.01GCN 对学习率不算敏感,但大于 0.01 容易振荡
epochs200 ~ 500小数据集上 200 轮左右就收敛,再多会过拟合
hidden_dim128显存充足时可以提到 256,提升有限
dropout0.5默认 0.5,图结构中防止过拟合的关键
weight_decay5e-4对 GCN 有明显的正则效果,不建议去掉
train_ratio0.7训练集比例,按用户划分而不是按评论划分
random_seed42固定随机种子,保证每次结果可复现

train_ratio这里有一个重要的细节:划分数据时一定要按"用户"划分,而不是按"评论"划分。原因很简单,如果一个用户写过 5 条评论,其中 4 条进了训练集,1 条进了测试集,那么训练时模型已经见过这个用户的大部分评论,测试时对那条"漏网之鱼"的预测会异常准确,因为模型认识这个用户。这个准确率是虚高的,不是模型的真实泛化能力。

4.2 train.py 的完整训练管线

train.py把前面所有模块串起来,它的执行顺序是:加载 CSV → 清洗数据 → 构图 → 节点编号 → BERT 抽取评论特征 → 构造节点特征矩阵 → 归一化邻接矩阵 → 划分训练测试集 → 实例化 GCN 模型 → 进入训练循环。

训练循环本身并不复杂,PyTorch 的标准流程:前向传播拿到预测值,计算交叉熵损失,反向传播计算梯度,优化器更新参数。整个训练流程的代码可以浓缩成这样:

import torch import torch.nn as nn from torch.optim import Adam from config import Config from model import GCN from data_loader import load_training_data cfg = Config() features, adj_norm, labels, train_mask, test_mask = load_training_data(cfg) model = GCN( in_dim=features.shape[1], hidden_dim=cfg.hidden_dim, out_dim=2, dropout=cfg.dropout ) optimizer = Adam(model.parameters(), lr=cfg.learning_rate, weight_decay=cfg.weight_decay) criterion = nn.NLLLoss() for epoch in range(cfg.epochs): model.train() optimizer.zero_grad() output = model(features, adj_norm) loss = criterion(output[train_mask], labels[train_mask]) loss.backward() optimizer.step() if (epoch + 1) % 20 == 0: model.eval() with torch.no_grad(): pred = output.argmax(dim=1) train_acc = (pred[train_mask] == labels[train_mask]).float().mean() test_acc = (pred[test_mask] == labels[test_mask]).float().mean() print(f"Epoch {epoch+1:03d} | Loss {loss.item():.4f} | Train Acc {train_acc:.4f} | Test Acc {test_acc:.4f}")

NLLLoss对应模型输出层的log_softmax,两者是配套的。如果模型输出层换成softmax,损失函数也得换成CrossEntropyLoss,混用会导致数值不稳定。train_masktest_mask是布尔掩码向量,长度等于总节点数,对应位置为 True 表示该节点属于训练集或测试集。

data_loader.py里的load_training_data把 2.2 和 2.3 的所有步骤封装成了一个函数,返回的是已经构建好的特征矩阵、归一化邻接矩阵、标签和掩码。它的返回值顺序一旦改变,train.py里的解包也要同步修改,这是修改项目时最容易踩的坑。

4.3 训练日志与结果文件怎么看

项目根目录下有两个结果文件:result(7.26).csvresult1(7.25).csv,这是不同版本实验的输出。训练过程的 TensorBoard 日志在log目录下,文件是events.out.tfevents.1690784923.York.19112.0,可以用 TensorBoard 打开查看损失曲线和准确率曲线的变化。

从实验记录来看,7.25 到 7.26 的迭代,我推测主要改动在数据清洗策略或图的结构设计上。看这类对比文件时,建议同时比对三个维度:训练集准确率、测试集准确率、以及正负样本各自的召回率。只看测试集准确率是很危险的习惯,如果水军用户只占全部用户的 10%,那么模型把所有用户都预测为正常用户,准确率也有 90%,但这个模型毫无用处。

评估水军检测模型的正确指标组合是精确率、召回率和 F1。精确率回答的是"预测为水军的用户里,真的有多少是水军",召回率回答的是"真正的所有水军里,模型识别出了多少"。垃圾评论过滤场景里,精确率重要,因为误伤正常用户代价高;但在大创项目里,我通常会强调召回率优先,因为漏掉水军比误判正常用户更容易被质疑。

from sklearn.metrics import precision_score, recall_score, f1_score y_true = labels[test_mask].cpu().numpy() y_pred = pred[test_mask].cpu().numpy() print(f"Precision: {precision_score(y_true, y_pred):.4f}") print(f"Recall: {recall_score(y_true, y_pred):.4f}") print(f"F1: {f1_score(y_true, y_pred):.4f}")

这三行代码是评估的核心,精度、召回率、F1 三项指标一起看,才能对模型有一个完整判断。sklearn.metrics里的三个函数直接传入真实标签和预测标签即可,需要注意它们默认计算的是二分类指标,如果项目改成多分类,需要显式指定average参数。

5. 避坑:数据、显存、评估上的 5 个翻车现场

5.1 显存溢出:BERT 和 GCN 不能同时端到端训练

现象:把 BERT 文本编码和 GCN 接到一起训练,程序跑不到 10 步就报 CUDA out of memory,减小 batch size 也没什么用。

原因:BERT-base 有 1.1 亿参数,前向传播时中间激活值占用的显存极其惊人。GCN 本身显存占用不大,但 BERT 对每条评论做前向传播时,都要保存所有层的中间结果用于反向传播,几千条评论累积下来就是几十 GB 的占用。

解决:先把 BERT 作为特征提取器单独跑一遍,把每条评论的[CLS]向量保存成文件,然后彻底释放 BERT 的显存,再加载 GCN 开始训练。这样 GCN 训练时显存占用只有几百 MB,哪怕是在笔记本的 GTX 1650 上也跑得动。这就是"冻结特征"的思路,在这个项目里几乎是必须的。

5.2 邻接矩阵稠密化导致内存爆炸

现象:构图后调用.to_dense()想看数据,程序直接卡死或者内存报错。节点数 15000 左右,稠密邻接矩阵是 15000 × 15000,float32 类型存储需要约 900MB 内存。

原因:稠密矩阵把所有不存在的边也当作 0 存储,而图的边通常非常稀疏。这个图里真实存在的边只有几万条,稀疏存储只需要存储非零元素的位置和值。

解决:全程使用scipy.sparse格式和torch.spmm算子,不要转稠密矩阵。如果实在需要转成稠密做调试,只对子图操作,不要对整个图操作。

5.3 随机种子不固定导致结果不可复现

现象:在相同参数下跑两次训练,测试集准确率一次 0.87,一次 0.83,调整半天参数也不知道哪个改动真正起了作用。

原因:PyTorch 的模型初始化、数据加载器的打乱顺序、NumPy 的随机数生成分别由三个独立的随机流控制,只固定其中一个等于没固定。

解决:在config.pytrain.py开头统一设置三处随机种子。固定种子的标准写法是torch.manual_seed(seed)np.random.seed(seed)random.seed(seed),缺一不可。

5.4 标签泄漏:测试集用户混进了训练特征聚合

现象:测试集 F1 达到了 0.95,看起来效果惊人,但换了真实场景数据后效果大幅下降,怀疑是模型过拟合数据特征。

原因:构图之前没有按照用户维度做划分。同一个用户的评论,一部分在训练集、一部分在测试集,GCN 做消息传递时,测试集评论的特征会通过用户节点传播到训练集评论,模型相当于提前"看到了"测试数据的答案。

解决:先按用户 ID 划分用户集合,再把这些用户对应的所有评论整体划入训练集或测试集。这样测试集用户的任何信息在训练过程中都不会出现在消息传递路径里。

5.5 训练损失下降但测试集指标纹丝不动

现象:训练集 loss 逐轮降低,准确率接近 100%,但测试集准确率一直停在一个固定值附近,甚至随机猜测的表现都比它好。

原因:数据集正负样本严重不均衡,水军用户可能只占 5%~10%。模型学到的最优策略就是把所有节点都预测为负类,这样训练损失最小。测试集上表现出来的准确率其实只是负类占比。

解决:给损失函数加类别权重,常见做法是把正类的权重设为其样本占比的倒数,或者换用 Focal Loss。同时用 F1 作为主要监控指标,而不是准确率。阅读result(7.26).csv时,如果发现里面只有总准确率一列,那就说明实验设计还不完整,至少要把精确率和召回率一并输出。

6. 验证 GCN 学到了什么:消融实验与才艺展示

模型训练完,直接拿着准确率去答辩或者写报告,其实有点心虚。因为 GCN 这类模型是个黑匣子,你只知道它输出一个分类结果,但它到底学到了图结构信号还是文本信号,很难说清楚。我建议在收尾阶段做两组验证实验,一组是消融对比,一组是边扰动测试,两组做完,你对模型的理解会扎实很多。

第一组实验是消融对比。把 GCN 的邻居聚合过程去掉,只保留第一层线性变换和 softmax,等价于退化成一个逻辑回归模型。具体操作方式是修改model.py里的 GCN 类,注释掉第一层和第二层的adj_norm聚合,直接对原始特征做分类。这样训练出来的模型只能看到节点自身的特征,看不到任何邻居信息。对比它和完整 GCN 的 F1 差距,就能量化图结构对分类的贡献。如果两个模型的指标几乎一样,说明构图没起作用,问题多半出在边的定义或者特征装配上。

第二组实验是边扰动。随机打乱邻接矩阵中 10% 的边,把 A 矩阵的 10% 元素换成随机位置边,保持总边数不变,然后重新训练和评估。如果模型对图结构敏感,性能会有明显下降;如果性能几乎不变,说明 GCN 根本没在依赖图结构,之前的效果全靠文本特征撑起来。

def perturb_edges(adj, ratio=0.1, seed=42): rng = np.random.default_rng(seed) adj_coo = adj.tocoo() num_edges = len(adj_coo.row) num_perturb = int(num_edges * ratio) idx = rng.choice(num_edges, num_perturb, replace=False) new_row = adj_coo.row.copy() new_col = adj_coo.col.copy() for i in idx: new_row[i] = rng.integers(0, adj.shape[0]) new_col[i] = rng.integers(0, adj.shape[0]) # 去掉自环 mask = new_row != new_col return coo_matrix( (np.ones(mask.sum()), (new_row[mask], new_col[mask])), shape=adj.shape ).tocsr()

这段代码构造一个扰动后的邻接矩阵:随机挑 10% 的边,把它们的起点和终点换成随机节点。rng.choice选中要破坏的边的索引,然后原地替换。要注意去掉自环,因为节点自己连自己在 GCN 里会改变消息传递的模式,造成误导。

做完这两组实验,再配合 TensorBoard 里保存的节点嵌入可视化,比如对 GCN 倒数第二层的输出做 PCA 降维到二维,然后把水军用户和正常用户用不同颜色画出来,如果两类节点在二维空间里明显分成两簇,这是最有说服力的展示,答辩或者写大创结题报告时拿出来,比任何文字描述都管用。

从那以后,我每次跑图神经网络项目,都会强制自己走一遍"边扰动+消融对比"的组合测试,哪怕结果显示图结构并没有帮上忙,也好过带着一个黑匣子去交差。图卷积网络不是银弹,它的价值落在构图的质量上,验证清楚了,模型才算真的成立了。希望帮到你。

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

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

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

立即咨询