☰
用户-评论-电影异构图建模水军检测实战
2026/10/26 17:33:47 网站建设 项目流程

简介:本资源是一套完整的毕业设计级项目,聚焦于利用图卷积神经网络(GCN)识别电商影评平台中的虚假水军评论,适用于计算机、人工智能及相关专业本科生开展课程设计、期末大作业或毕业设计。项目代码结构清晰,含数据预处理、图构建、BERT文本编码、GCN模型训练与结果评估等完整流程,新手可快速上手部署运行。压缩包共26个文件,涵盖9个核心Python脚本(如model.py、train.py、data_loader.py)、8个CSV格式影评与电影元数据(如maoyan_long_comments.csv、电影类型.csv)、2个配置类文本文件及README说明文档等,整体大小为15.03MB。已有240人学习下载,资源包含实测有效的猫眼长评数据集、预训练BERT中文词表、训练日志与多版本结果CSV,还提供graph_section等模块化设计,便于理解图神经网络在文本欺诈检测中的实际建模逻辑。

1. 虚假影评水军检测不是“文本分类”:图卷积神经网络在这里真正解决的是用户-评论-电影三元关系建模问题

你手头那份标着“98分毕业设计”的虚假影评水军检测项目,表面看是用 Python 做了个二分类模型,但真正让它在答辩现场被导师当场点名表扬的,根本不是准确率数字——而是它没把“水军”当成孤立句子来判,而是把猫眼长评数据里2376个用户、412部电影、15893条评论构建成一张真实可计算的关系图。这张图里,一个刷评账号可能同时给《流浪地球2》打五星、给《深海》打一星,还批量复制粘贴“演技炸裂”“剧情太烂”,这种跨电影、跨情感倾向的异常行为模式,在纯BERT微调或LSTM里会被稀释成普通噪声;但在GCN里,它会通过图结构传播放大,成为节点嵌入里的尖锐异常信号。这个项目不是教你怎么调参,而是示范了如何把业务逻辑翻译成图结构语言:用户节点连向其评论节点,评论节点连向对应电影节点,再叠加评论文本相似度边、用户行为时序边——这才是水军识别的物理本质。适合正在做课程设计、毕设选题卡在“怎么体现创新性”的同学,也适合想补足GNN落地经验的算法岗新人:它不堆砌SOTA模型,但每一步构建都经得起追问——为什么边权重用Jaccard而不是余弦?为什么用户节点特征要拼接历史评分方差而非均值?这些细节,全藏在graph_section/utils.py和data_loader.py的37处注释里。


2. 从原始CSV到可训练图数据:四步构建用户-评论-电影异构图(含完整代码与参数说明)

2.1 数据清洗:为什么必须重写maoyan_long_comments.csv的时间字段解析逻辑?

原始猫眼长评数据中,comment_time字段存在三种格式混杂:2023-05-12、2023/05/12 14:30、刚刚。直接用pd.to_datetime()会强制填充 NaT,导致后续构建时序边时丢失关键信息。正确做法是先统一归一化为datetime64[ns],再提取小时粒度作为行为活跃度特征:

# data_utils.py 第42行起 def parse_comment_time(time_str): """处理三种时间格式并返回标准化datetime""" if pd.isna(time_str): return pd.Timestamp('2020-01-01') # 占位时间,避免NaN影响groupby time_str = str(time_str).strip() if '刚刚' in time_str: return pd.Timestamp.now() - pd.Timedelta(minutes=np.random.randint(1, 15)) elif '/' in time_str and len(time_str) <= 10: return pd.to_datetime(time_str, format='%Y/%m/%d', errors='coerce') else: return pd.to_datetime(time_str, errors='coerce') df['parsed_time'] = df['comment_time'].apply(parse_comment_time) df['hour_bin'] = df['parsed_time'].dt.hour # 后续用于构建用户活跃时段特征

提示:此处np.random.randint(1,15)是为了模拟“刚刚”发生的随机分布,避免所有“刚刚”评论集中在同一小时导致图结构失真。若需复现实验,建议固定random.seed(42)。

2.2 异构图构建:用networkx构建三层节点+双类型边的图结构

本项目核心创新在于显式建模用户-评论-电影三元关系,而非简单将评论文本向量化后输入GCN。图结构包含三类节点(user, comment, movie)和两类边(user→comment, comment→movie),边权重由业务逻辑决定:

边类型权重计算方式物理意义代码位置
user→comment固定为1.0用户发布该评论的动作graph_section/graph_model.pyL89
comment→movie1 / (1 + abs(rating - avg_movie_rating))评论打分偏离电影平均分越远,边权重越低(暗示可疑)data_loader.pyL156

构建代码需严格按节点ID映射顺序执行,否则GCN层会因邻接矩阵错位而崩溃:

# graph_section/graph_model.py 第73行 def build_hetero_graph(df): # 步骤1:生成唯一ID映射(必须全局唯一!) user2id = {u: i for i, u in enumerate(df['user_id'].unique())} movie2id = {m: i for i, m in enumerate(df['movie_id'].unique())} comment2id = {c: i for i, c in enumerate(df['comment_id'].unique())} # 步骤2:构建user→comment边列表(无向边转有向) user_comment_edges = [] for _, row in df.iterrows(): uid = user2id[row['user_id']] cid = comment2id[row['comment_id']] user_comment_edges.append([uid, cid]) # [source, target] # 步骤3:构建comment→movie边列表(注意方向!) comment_movie_edges = [] movie_avg_rating = df.groupby('movie_id')['rating'].mean().to_dict() for _, row in df.iterrows(): cid = comment2id[row['comment_id']] mid = movie2id[row['movie_id']] weight = 1 / (1 + abs(row['rating'] - movie_avg_rating.get(row['movie_id'], 5))) comment_movie_edges.append([cid, mid]) # 步骤4:拼接为完整邻接矩阵(COO格式) edges = torch.tensor(user_comment_edges + comment_movie_edges, dtype=torch.long).t() return Data(edge_index=edges, num_nodes=len(user2id)+len(comment2id)+len(movie2id))

参数说明:num_nodes必须等于三类节点总数,否则torch_geometric会报IndexError: index out of range;edge_index形状为[2, num_edges],第一行为源节点,第二行为目标节点——这是GCN消息传递的基础。

2.3 特征工程:为什么用户节点拼接“评分标准差”比“平均分”更有效?

水军账号典型行为是对不同电影打极端分(1星/5星)且无中间值,这反映在统计量上就是评分标准差显著高于普通用户。项目中用户节点特征向量维度为128,其中:

  • 前64维:BERT-base-chinese编码的用户历史评论平均向量(utils.pyL112)
  • 后64维:手工特征(data_utils.pyL203)
    • rating_std(标准差):直接计算该用户所有评分的标准差
    • comment_count(评论数):log1p归一化
    • time_span_days(首末评论时间跨度):单位为天
    • hour_entropy(活跃时段熵值):衡量评论时间分布均匀性

验证代码显示,仅用rating_std单一特征就能使F1-score提升3.2%(对比基线模型):

# train.py 第187行(验证特征有效性) user_features = torch.cat([ bert_user_emb, # [N_user, 64] torch.stack([ torch.tensor([np.std(df[df['user_id']==u]['rating']) for u in user2id.keys()]), # rating_std torch.log1p(torch.tensor([len(df[df['user_id']==u]) for u in user2id.keys()])), # comment_count # ...其余特征 ], dim=1) # [N_user, 64] ], dim=1)

注意:rating_std计算时需排除单条评论用户(len(comments)==1),否则标准差为0导致特征失效。项目在data_utils.pyL215 处做了if len(ratings) > 1 else 0.0判断。

2.4 图数据持久化:为什么必须用torch.save()而非pickle存储图对象?

torch_geometric.data.Data对象包含大量torch.Tensor,若用pickle.dump()保存,在多进程DataLoader中会触发RuntimeError: unable to open shared memory object。项目采用torch.save()并指定pickle_protocol=4,确保跨平台兼容性:

# train.py 第231行 torch.save(graph_data, 'data/processed/hetero_graph_v2.pt') # 加载时 graph_data = torch.load('data/processed/hetero_graph_v2.pt')

血泪经验:曾因用pickle保存导致在Ubuntu服务器上训练时GPU显存暴涨300%,排查3小时才发现是Tensor共享内存冲突。从此所有图数据一律torch.save。


3. GCN模型实现:三层异构图卷积+BERT文本编码的联合训练架构(含逐层代码解析)

3.1 模型整体架构:为什么采用“GCN编码器 + BERT解码器”而非端到端BERT?

单纯用BERT对单条评论分类,会忽略水军的群体协同行为(如多个账号在同一天给同一部电影刷差评)。本项目采用分层设计:

  • 底层:GCN聚合用户-评论-电影结构信息,输出用户节点嵌入h_user ∈ R^(N_user×128)
  • 顶层:BERT对每条评论文本编码,输出评论嵌入h_comment ∈ R^(N_comment×768)
  • 融合层:将用户嵌入h_user与对应评论嵌入h_comment拼接后送入MLP分类器

这种设计让模型既能捕捉图结构中的异常传播路径,又能保留文本语义细节。model.py中模型类继承自torch.nn.Module,而非torch_geometric.nn.MessagePassing,因为需要手动控制消息传递方向(user→comment→movie)。

3.2 GCN层实现:自定义HeteroGCNConv层处理异构边类型

PyG原生GCNConv仅支持同构图,项目重写了消息传递逻辑以支持三类节点:

# model.py 第56行 class HeteroGCNConv(torch.nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.lin_user = Linear(in_channels, out_channels) # 用户节点线性变换 self.lin_comment = Linear(in_channels, out_channels) # 评论节点线性变换 self.lin_movie = Linear(in_channels, out_channels) # 电影节点线性变换 def forward(self, x_dict, edge_index_dict): # x_dict = {'user': user_feat, 'comment': comment_feat, 'movie': movie_feat} # edge_index_dict = {'user__writes__comment': edge_tensor, 'comment__reviews__movie': edge_tensor} # 步骤1:user→comment消息传递 user_msg = self.lin_user(x_dict['user']) comment_agg = scatter_mean(user_msg[edge_index_dict['user__writes__comment'][0]], edge_index_dict['user__writes__comment'][1], dim=0, dim_size=x_dict['comment'].size(0)) # 步骤2:comment→movie消息传递 comment_msg = self.lin_comment(x_dict['comment'] + comment_agg) # 残差连接 movie_agg = scatter_mean(comment_msg[edge_index_dict['comment__reviews__movie'][0]], edge_index_dict['comment__reviews__movie'][1], dim=0, dim_size=x_dict['movie'].size(0)) return { 'user': x_dict['user'], 'comment': comment_agg, 'movie': movie_agg }

关键参数说明:scatter_mean中dim_size必须显式指定目标节点数量,否则会因索引越界报错;edge_index_dict的键名格式src__rel__dst是PyG异构图约定,不可修改。

3.3 文本编码模块:冻结BERT参数但微调[CLS]向量投影层

为防止文本特征淹没图结构信号,项目冻结BERT主干(requires_grad=False),仅训练最后的分类头:

# model.py 第129行 self.bert = AutoModel.from_pretrained('bert-base-chinese') for param in self.bert.parameters(): param.requires_grad = False # 冻结BERT主干 self.cls_proj = Sequential( Linear(768, 256), ReLU(), Dropout(0.3), Linear(256, 128) # 输出128维,与GCN用户嵌入维度对齐 ) def forward_text(self, input_ids, attention_mask): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) cls_output = outputs.last_hidden_state[:, 0, :] # [batch, 768] return self.cls_proj(cls_output) # [batch, 128]

玄学参数:Dropout(0.3)是经过5次消融实验确定的最佳值——低于0.2时过拟合严重,高于0.4时模型收敛变慢。项目在config.py中明确标注TEXT_DROPOUT = 0.3。

3.4 联合训练损失函数:为什么用加权二元交叉熵而非标准BCE?

水军样本占比仅12.7%(result(7.26).csv统计),直接使用BCELoss会导致模型偏向预测“正常”。项目采用加权损失:

# train.py 第298行 pos_weight = torch.tensor([(len(y_true)-y_true.sum())/y_true.sum()]) # 正样本权重 criterion = torch.nn.BCEWithLogitsLoss(pos_weight=pos_weight) loss = criterion(pred_logits, y_true.float())

计算得pos_weight ≈ 6.87,即每条水军样本的损失贡献是正常样本的6.87倍。该权重在train.pyL302 处硬编码,避免每次运行重新计算。

避坑:若在DataLoader中启用shuffle=True,y_true.sum()会随batch变化,导致pos_weight波动。项目在data_loader.pyL287 显式设置shuffle=False确保权重稳定。


4. 训练与推理全流程:从train.py启动到生成result1(7.25).csv的完整链路

4.1 配置文件config.py的12个关键参数及其物理含义

项目未使用YAML或JSON配置,所有超参集中于config.py,共12个必须修改的变量:

参数名默认值修改建议作用说明
BATCH_SIZE32GPU显存<8GB时设为16影响梯度累积步数,过大会OOM
LEARNING_RATE1e-4图结构复杂时降为5e-5GCN层对学习率敏感,过高易震荡
GCN_LAYERS2尝试3层但验证集F1下降0.8%深层GCN易过平滑(over-smoothing)
DROPOUT_RATE0.5图稀疏时可降至0.3防止节点特征过拟合
MAX_COMMENT_LEN128中文长评需≥256BERT截断长度,不足则丢失关键信息
NUM_EPOCHS50早停触发通常在32轮result1(7.25).csv记录每轮指标
PATIENCE7验证loss连续7轮不降则终止防止过拟合,节省GPU时间
DEVICE'cuda:0'无GPU时改为'cpu'必须与torch.cuda.is_available()一致
MODEL_SAVE_PATH'models/gcn_best.pth'建议按日期命名保存最佳验证模型
RESULT_CSV'result1(7.25).csv'每次运行改名避免覆盖包含epoch, train_loss, val_f1等列
SEED42复现实验必设相同值控制numpy/torch/random种子
USE_BERT_FEATURESTrue设为False测试纯图模型效果验证文本特征必要性

注意:GCN_LAYERS=2是项目作者实测最优值,3层时验证F1从0.892降至0.884,证明在本数据集上深层GCN并未带来收益。

4.2 训练脚本train.py的四阶段执行逻辑

train.py不是简单循环,而是严格分阶段:

  1. 数据加载阶段(L142-L178):

    • 读取maoyan_long_comments.csv→ 构建异构图 → 划分训练/验证/测试集(按用户ID,非随机)
    • 关键代码:train_mask, val_mask, test_mask = split_by_user_id(graph_data, test_ratio=0.2)
    • 为什么按用户划分?防止同一用户评论出现在不同集合,导致数据泄露。
  2. 模型初始化阶段(L181-L205):

    • 加载预训练BERT → 初始化GCN层 → 设置优化器(AdamW,weight_decay=0.01)
    • 关键代码:optimizer = AdamW(model.parameters(), lr=cfg.LEARNING_RATE, weight_decay=0.01)
  3. 训练循环阶段(L210-L275):

    • 每轮遍历训练集 → 计算加权BCE损失 → 反向传播 → 梯度裁剪(torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0))
    • 梯度裁剪阈值1.0是经验值:过大则无效,过小则抑制有效梯度。
  4. 验证与保存阶段(L278-L315):

    • 在验证集上计算F1-score → 若优于历史最佳则保存模型 → 记录指标到result1(7.25).csv
    • 关键代码:pd.DataFrame(results).to_csv(cfg.RESULT_CSV, index=False)
    • CSV列名固定为:epoch,train_loss,val_loss,val_precision,val_recall,val_f1,test_f1

4.3 推理脚本inference.py的三步部署流程

项目未提供独立推理脚本,需修改train.py实现:

# 在train.py末尾添加 if __name__ == '__main__': if cfg.MODE == 'train': train() elif cfg.MODE == 'infer': model = load_model(cfg.MODEL_SAVE_PATH) test_loader = create_test_loader() # 加载测试集 predictions = [] for batch in test_loader: pred = model(batch) predictions.extend(torch.sigmoid(pred).cpu().numpy()) # 保存预测结果 pd.DataFrame({'pred_prob': predictions}).to_csv('inference_result.csv', index=False)

部署技巧:生产环境需将inference.py独立,且create_test_loader()应支持流式加载(避免一次性加载全部数据)。

4.4 结果文件result1(7.25).csv的解读方法

该CSV记录50轮训练全过程,共50行,每行含8列:

列名示例值解读要点
epoch23当前训练轮次
train_loss0.321训练集加权BCE损失,应持续下降
val_loss0.412验证集损失,若连续上升则过拟合
val_precision0.842水军预测的精确率(查准率)
val_recall0.817水军预测的召回率(查全率)
val_f10.829F1-score = 2*(P*R)/(P+R),核心指标
test_f10.821测试集F1,最终报告值
lr1e-4当前学习率(若启用学习率衰减)

翻车预警:若val_f1在第15轮达0.892后开始波动(如16轮0.881、17轮0.889),说明模型已收敛,继续训练无益。项目在result1(7.25).csv第23行达到峰值0.892,之后缓慢下降。


5. 避坑指南:水军检测项目中5个真实踩过的坑及解决方案

5.1 现象:训练初期val_f1为0.0,train_loss不下降

原因:user2id和comment2id映射字典未对齐,导致GCN邻接矩阵edge_index中节点ID超出范围,消息传递失败。
解决:检查graph_section/graph_model.pyL75-L80,确认三类节点ID从0开始连续编号,且num_nodes等于len(user2id)+len(comment2id)+len(movie2id)。用print(edge_index.max())验证最大ID是否小于num_nodes。

5.2 现象:CUDA out of memory即使BATCH_SIZE=1

原因:torch_geometric在构建图时默认缓存所有中间张量,Data对象未及时释放。
解决:在train.py数据加载循环中添加del batch和torch.cuda.empty_cache(),并在每个epoch结束时调用gc.collect()。

5.3 现象:result1(7.25).csv中test_f1恒为0.5

原因:测试集标签未按用户划分,导致水军账号的评论分散在训练/测试集,模型从未见过该用户行为模式。
解决:修改data_loader.py中的划分逻辑,确保同一user_id的所有评论属于同一集合。参考split_by_user_id()函数实现。

5.4 现象:BERT编码耗时过长(单batch>30秒)

原因:AutoTokenizer未启用return_tensors='pt',导致返回Python list而非Tensor,后续需额外转换。
解决:在data_loader.pyL132 修改为tokenizer(text, truncation=True, padding=True, max_length=cfg.MAX_COMMENT_LEN, return_tensors='pt')。

5.5 现象:inference.py预测结果全是0或1

原因:模型输出未经过torch.sigmoid(),直接用logits做阈值判断(pred > 0.5)。
解决:在推理时添加prob = torch.sigmoid(logits),再用prob > 0.5判定。项目在train.pyL308 已实现,但独立推理脚本需自行补充。


6. 进阶技巧:用图注意力机制替换GCN层提升水军识别精度(附可运行代码)

6.1 为什么标准GCN在水军检测中存在局限?

GCN假设所有邻居贡献均等,但现实中:

  • 一个用户给《满江红》打1星,同时给《人生大事》打5星,这两条评论对“水军”判定的权重应不同
  • 电影《消失的她》的差评若来自高活跃度账号,比来自沉默账号的差评更具判别力

标准GCN无法区分邻居重要性,而图注意力网络(GAT)可通过注意力系数动态加权。

6.2 替换方案:三行代码将HeteroGCNConv升级为HeteroGATConv

PyG提供GATConv,但需适配异构图。项目作者在model.py新增HeteroGATConv类:

# model.py 新增(第201行) class HeteroGATConv(torch.nn.Module): def __init__(self, in_channels, out_channels, heads=2): super().__init__() self.gat_user2comment = GATConv(in_channels, out_channels, heads=heads, concat=True) self.gat_comment2movie = GATConv(in_channels*heads, out_channels, heads=1, concat=False) def forward(self, x_dict, edge_index_dict): # user→comment消息传递(带注意力) user_x = x_dict['user'] comment_x = x_dict['comment'] user_msg = self.gat_user2comment(user_x, edge_index_dict['user__writes__comment']) # comment→movie消息传递(带注意力) comment_msg = self.gat_comment2movie(comment_x + user_msg, edge_index_dict['comment__reviews__movie']) return {'user': user_x, 'comment': user_msg, 'movie': comment_msg}

参数说明:heads=2表示使用2个注意力头,输出维度为out_channels*2,需在后续层用Linear投影回out_channels。

6.3 效果对比:GAT vs GCN 在测试集上的F1-score提升

在相同训练配置下(BATCH_SIZE=32,LEARNING_RATE=1e-4),替换后结果:

模型val_f1test_f1训练时间(分钟)
GCN(原版)0.8920.82142.3
GAT(升级版)0.9070.84358.6

提升2.2个百分点,代价是训练时间增加38%。若追求精度优先,推荐使用;若需快速迭代,GCN仍足够鲁棒。

6.4 部署建议:如何在不重训的情况下评估GAT效果?

无需从头训练,只需修改train.py中模型初始化部分:

# train.py 第195行(原GCN初始化) # model = GCNModel(cfg.HIDDEN_DIM, cfg.NUM_CLASSES) # 替换为GAT from model import HeteroGATConv model = GCNModel(cfg.HIDDEN_DIM, cfg.NUM_CLASSES, conv_layer=HeteroGATConv)

后悔药:项目在config.py中预留CONV_LAYER = 'GCN'变量,设为'GAT'即可自动切换,无需修改模型代码。

从那以后我每次做图神经网络项目,都会先画出节点类型和边类型的UML图,再动手写edge_index_dict—— 因为90%的bug都源于对“谁连谁”的理解偏差。这份水军检测代码最珍贵的不是98分,而是它把抽象的GCN概念钉死在用户-评论-电影这个具体业务骨架上。希望帮到你。

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

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

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

立即咨询