基于GCN的垃圾评论识别系统:Flask与图神经网络实战 在内容平台的后台审核场景里垃圾评论识别是一个持续存在的难题。如果只靠关键词过滤很难解决批量注册、文本变体和抱团刷评的问题。用 Flask 搭建一个基于 GCN 的垃圾评论识别系统可以把评论之间的关联关系纳入模型从结构上发现异常。这篇文章会围绕系统设计、数据建图、模型训练、Flask 接口封装和常见问题展开。读完可以动手做出一个最小可运行版本也能理解为什么图卷积网络适合这类任务。这里要说明定位示例代码用于说明思路实际项目需要根据自己的包名、路径、数据格式和依赖版本调整。我会采用偏工程化的写法而不是只给一个模型训练脚本。1. 先弄明白垃圾评论识别为什么需要图卷积网络1.1 关键词分类和文本分类的局限很多初版审核系统都从关键词开始。广告词、涉黄涉赌词、引流词命中后直接拦截。这种方案实现简单但问题也很明显垃圾评论会变化比如“VX”“薇❤”“加我扣”这类变体正则写不完。后来大家普遍使用文本分类模型把评论文本转换成向量再用 LSTM、CNN 或 BERT 做二分类。这类模型能学到文本语义但对“评论之间的关系”是盲区。实际问题里垃圾评论往往不是孤立出现的。同一个黑灰产团伙会注册大量账号在短时间内对同一批内容发布相似评论或者一批正常账号被盗后集中发 spam。这些评论单独看可能很隐蔽但如果把“谁发的、什么时候发、文本是否高度相似”放在一起异常模式就非常明显。传统文本分类只输入一条评论文本没有使用这些结构化信息。1.2 评论数据如何变成一张图图神经网络的处理方式是把评论数据建模成图结构。图由节点和边组成节点一条评论、一个用户或一个内容。边评论之间的关联关系比如同一用户发布、文本相似度超过阈值、发布时间相近、引用关系、IP 相同。节点特征评论文本的向量表示比如 TF-IDF、词向量或句子向量。节点标签是否垃圾评论。以评论为节点设计时如果两条评论来自同一个 user_id或者在向量空间里余弦相似度很高就给它们建一条边。这样垃圾评论会因为在图上形成密集的小团体而被 GCN 感知到。除了评论节点还可以把用户节点和内容节点混入同一张异构图。但这会明显增加建模复杂度。最小可行版本先使用“评论节点 关系边”的同构图场景理解起来更直接。1.3 GCN 在图结构中做了什么GCN 全称是 Graph Convolutional Network中文一般叫图卷积神经网络。它的核心思想是每个节点的表示不只由自身特征决定还会不断聚合邻居节点的特征。一层图卷积可以简化为H^(l1) σ( D^(-1/2) A D^(-1/2) H^(l) W^(l) )其中A 是邻接矩阵表示节点之间是否有边。D 是度矩阵D^(-1/2) 用于归一化避免度数高的节点特征被过度放大。H^(l) 是第 l 层的节点特征矩阵。W^(l) 是这一层可学习的权重。σ 是激活函数常用 ReLU。一句话概括GCN 让一条评论学习到“我和谁连接我的邻居大概是什么类型”的上下文信息。堆叠两层时第二层能聚合两跳邻居的信息也就是“朋友的邻居”。对垃圾评论检测来说这正好可以帮助识别“抱团刷评”结构。容易误解的是GCN 并不是把所有评论简单平均。每条评论仍然有独立向量表示只是这个表示在传播过程中融入了局部结构信息。边定义得是否合理比模型结构本身对效果影响更大。2. 系统架构和技术选型2.1 整体分层整个系统可以分成五个部分数据层保存评论内容、用户、时间、标签最少需要 CSV 或 MySQL。图构建层从评论数据中提取节点特征和边关系生成邻接矩阵。模型层训练 GCN 模型输出每条评论是垃圾的概率。服务层使用 Flask 提供 HTTP 接口接收新评论文本并返回识别结果。调用层后台管理端、审核界面或命令行工具。训练和服务可以分离。训练时离线构建整张图跑 GCN 模型预测时加载训练好的模型通过 Flask 对外暴露接口。2.2 技术栈和依赖Python 是最稳妥的选择生态完善。依赖可以这样设计组件用途推荐方式Python运行环境3.8 以上建议 3.9 或 3.10FlaskWeb 服务Flask 2.xPyTorch深度学习框架CPU 版本可用于学习环境PyTorch Geometric图神经网络算子可选安装失败时可自实现 GCNscikit-learnTF-IDF、评估指标稳定常用NetworkX图结构调试小规模图可视化或检查连通性scipy稀疏矩阵运算配合 PyTorch 稀疏张量依赖版本需要以实际安装环境为准。尤其是 PyTorch 和 PyTorch Geometric两者版本必须匹配否则会出现导入错误。2.3 学习环境与生产环境的差异学习环境只需要能跑通小规模数据。可以全部在本地执行用 CPU 训练几分钟甚至几秒数据量控制在几百到几千条评论。生产环境要求高很多评论数据量会持续增长图不能每次请求都重建。需要周期性定时建图、训练、评估和发布模型。Flask 服务需要加超时、鉴权、限流和日志。模型预测可能被高并发调用需要评估 QPS 和响应时间。训练数据和线上数据的分布可能偏移需要监控。这篇文章的示例以学习环境为主最后再给出生产化建议。3. 数据准备与图构建3.1 评论数据字段设计先准备一份最小数据集。使用 CSV 或数据库表保存评论建议包含以下字段字段类型说明comment_idstring评论唯一 IDuser_idstring发布者 IDcontentstring评论正文labelint0 正常1 垃圾create_timedatetime发布时间示例 CSVcomment_id,user_id,content,label,create_time c001,u001,这篇内容写得很实用,0,2025-01-01 10:00:00 c002,u002,加我微信领资料,1,2025-01-01 10:01:00 c003,u002,加我微信领资料,1,2025-01-01 10:02:00 c004,u003,学到了谢谢分享,0,2025-01-01 10:03:00读取数据时要注意统一编码。中文环境推荐统一使用 UTF-8否则会出现乱码或分词异常。3.2 节点特征向量怎么来GCN 的输入特征矩阵是一个N x D的矩阵N 是评论节点数量D 是特征维度。最简单稳定的特征来自 TF-IDF。scikit-learn 提供了现成实现。先用全量评论文本拟合TfidfVectorizer再转换成特征矩阵from sklearn.feature_extraction.text import TfidfVectorizer vectorizer TfidfVectorizer( max_features512, ngram_range(1, 2), stop_wordsNone ) X vectorizer.fit_transform(df[content]).toarray()这里把特征维度限制在 512 维是为了让模型训练更快。ngram_range(1, 2)能同时考虑单词和连续两个词。中文场景下可以结合 jieba 分词但TfidfVectorizer默认按字符或空格切分中文效果一般。实际项目中可以做自定义分词器。如果数据量大且算力允许也可以使用 sentence-transformers 生成句向量。这样向量能表达更多语义但依赖模型下载和 GPU。最小版本用 TF-IDF 就够了。3.3 边关系构建边的定义决定了 GCN 能看到什么样的结构信息。给出三种常用规则相同 user_id同一用户连续或重复发布多条评论时建边。文本相似度超过阈值两条评论内容高度相似时建边。发布时间窗口内关联同一内容下短时间内发布的多条评论建边。可以用 NetworkX 先构建图方便检查再转换为稀疏邻接矩阵。import networkx as nx G nx.Graph() for _, row in df.iterrows(): G.add_node(row[comment_id]) # 规则1同一用户 user_groups df.groupby(user_id)[comment_id].apply(list).to_dict() for ids in user_groups.values(): for i in range(len(ids)): for j in range(i 1, len(ids)): G.add_edge(ids[i], ids[j]) # 规则2文本相似度 from sklearn.metrics.pairwise import cosine_similarity sim cosine_similarity(X) for i in range(len(df)): for j in range(i 1, len(df)): if sim[i][j] 0.8: G.add_edge(df.iloc[i][comment_id], df.iloc[j][comment_id])这段代码用于说明思路。数据量变大后双层循环计算相似度会非常慢。实际项目应该使用近似最近邻算法如 faiss或先按用户、IP 分组缩减候选对。3.4 构建邻接矩阵与归一化把 NetworkX 的边转换成 scipy 稀疏矩阵再加上自环最后做对称归一化。import numpy as np from scipy import sparse nodes list(G.nodes()) node_index {node: i for i, node in enumerate(nodes)} n len(nodes) adj nx.to_scipy_sparse_array(G, nodelistnodes, dtypenp.float32) # 加自环 adj adj sparse.eye(n, dtypenp.float32) # 对称归一化D^-1/2 * A * D^-1/2 degree np.array(adj.sum(axis1)).flatten() degree_inv_sqrt 1.0 / np.sqrt(degree 1e-8) degree_mat_inv_sqrt sparse.diags(degree_inv_sqrt) adj_norm degree_mat_inv_sqrt adj degree_mat_inv_sqrt注意加自环的目的是让节点在聚合时保留自身信息。nx.to_scipy_sparse_array返回的矩阵要保证nodelist与特征矩阵的行顺序一致。节点顺序一旦错位训练时特征和标签就会对不上。4. GCN 模型实现与训练4.1 用 PyTorch 实现一个 GCN 模块为了减小安装复杂度可以不依赖 PyTorch Geometric直接用 PyTorch 实现两层 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.randn(in_dim, out_dim)) def forward(self, x, adj_norm): # x: [N, in_dim] # adj_norm: [N, N] 稀疏或稠密矩阵 out torch.mm(adj_norm, x) out torch.mm(out, self.weight) return out class GCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, dropout0.5): super().__init__() self.layer1 GCNLayer(in_dim, hidden_dim) self.layer2 GCNLayer(hidden_dim, out_dim) self.dropout nn.Dropout(dropout) def forward(self, x, adj_norm): x self.layer1(x, adj_norm) x F.relu(x) x self.dropout(x) x self.layer2(x, adj_norm) return x这里adj_norm可以预先计算好训练时直接传入。如果节点数量不大直接使用稠密矩阵也可以。节点多时要把adj_norm转成 PyTorch 稀疏张量减少内存占用。4.2 训练流程训练时只需要准备特征矩阵X、归一化邻接矩阵adj_norm和标签y然后划分训练集和验证集。由于图节点之间存在依赖随机划分会带来信息泄漏训练集和验证集的节点仍然可能通过边相连。简单做法是先按时间或用户划分保证训练集中出现的用户不会出现在验证集。最小训练代码如下import torch.optim as optim model GCN( in_dimX.shape[1], hidden_dim32, out_dim2 ) optimizer optim.Adam(model.parameters(), lr0.01, weight_decay5e-4) loss_fn nn.CrossEntropyLoss(weighttorch.tensor([1.0, 3.0])) X_tensor torch.tensor(X, dtypetorch.float32) adj_tensor torch.tensor(adj_norm.toarray(), dtypetorch.float32) y_tensor torch.tensor(df[label].values, dtypetorch.long) train_mask torch.tensor(df[split].values train, dtypetorch.bool) val_mask torch.tensor(df[split].values val, dtypetorch.bool) for epoch in range(100): model.train() optimizer.zero_grad() output model(X_tensor, adj_tensor) loss loss_fn(output[train_mask], y_tensor[train_mask]) loss.backward() optimizer.step() if epoch % 10 0: model.eval() with torch.no_grad(): pred output.argmax(dim1) val_acc (pred[val_mask] y_tensor[val_mask]).float().mean() print(fepoch {epoch}, loss {loss.item():.4f}, val_acc {val_acc.item():.4f})垃圾评论样本通常偏少直接用标准交叉熵可能导致模型倾向预测正常类。可以在CrossEntropyLoss中给垃圾评论类别更高的权重比如上面示例中的[1.0, 3.0]。具体权重需要根据训练集统计得到。4.3 模型评估与保存只看准确率不够。垃圾评论场景中如果垃圾评论只占 5%模型把所有评论都判断为正常准确率也有 95%但毫无实用性。需要重点看精确率预测为垃圾的样本里真正是垃圾的比例。召回率真实垃圾评论里被模型找出来的比例。F1精确率和召回率的调和平均。from sklearn.metrics import classification_report, f1_score model.eval() with torch.no_grad(): output model(X_tensor, adj_tensor) pred output.argmax(dim1) print(classification_report(y_tensor[val_mask], pred[val_mask], target_names[正常, 垃圾]))训练完成后保存模型参数同时保存特征器。模型参数只包含网络权重不包含 TF-IDF 词典因此两者必须一起保存。torch.save(model.state_dict(), gcn_model.pt) import joblib joblib.dump(vectorizer, tfidf_vectorizer.joblib)更完善的方案是把模型版本、特征版本、图构建参数都写进元信息避免线上加载时出现模型和特征不匹配。4.4 模型预测的限制GCN 默认是一个直推式模型。训练时模型只对图中已有的节点学习表示。当系统收到一条全新评论时它不在原来的邻接矩阵里不能直接套用训练好的adj_norm。学习环境里一个简单做法是在服务启动时加载训练图预测前把新评论加入图计算它与已有节点的边重新执行一次前向传播取新节点的输出。这个方案只适合小数据量生产环境需要改用支持归纳学习的模型如 GraphSAGE或者周期性离线重建图并批量预测。后面会讲具体处理方式。5. Flask 服务封装与接口设计5.1 项目目录结构建议按以下结构组织代码comment_gcn/ ├── app.py ├── model.py ├── graph_builder.py ├── train.py ├── requirements.txt ├── models/ │ ├── gcn_model.pt │ └── tfidf_vectorizer.joblib ├── data/ │ └── comments.csv └── templates/ └── index.htmltrain.py负责离线训练graph_builder.py负责建图model.py定义网络结构app.py是 Flask 入口。5.2 加载模型和词向量工具Flask 服务启动时加载模型和 TF-IDF 向量器避免每次请求都重新加载import joblib import torch from model import GCN device torch.device(cpu) model GCN(in_dim512, hidden_dim32, out_dim2) model.load_state_dict(torch.load(models/gcn_model.pt, map_locationdevice)) model.to(device) model.eval() vectorizer joblib.load(models/tfidf_vectorizer.joblib)in_dim必须与训练时一致。如果训练时设定了max_features512那么加载时也要使用同一个维度。5.3 单条预测接口为了让模型处理新评论需要把新评论转换成特征并临时与训练集中的相似评论建立图关系。下面是一个简化实现核心是先在训练集文本里找相似评论再构造一个小图。import numpy as np from scipy import sparse from sklearn.metrics.pairwise import cosine_similarity def build_predict_graph(text, train_texts, train_labels, top_k5): new_vec vectorizer.transform([text]).toarray() train_vec vectorizer.transform(train_texts).toarray() sim cosine_similarity(new_vec, train_vec)[0] top_indices np.argsort(sim)[::-1][:top_k] n top_k 1 row, col [], [] # 新节点 0 与 top_k 个训练节点相连 for idx in top_indices: # 这里使用节点的临时编号0 表示新评论idx1 表示训练评论 row.append(0) col.append(idx 1) row.append(idx 1) col.append(0) # 只有新评论有标签训练节点不需要参与预测但可以作为邻居特征输入 x np.vstack([new_vec, train_vec[top_indices]]) adj sparse.coo_matrix((np.ones(len(row)), (row, col)), shape(n, n), dtypenp.float32) adj adj sparse.eye(n, dtypenp.float32) degree np.array(adj.sum(axis1)).flatten() degree_inv_sqrt 1.0 / np.sqrt(degree 1e-8) adj_norm sparse.diags(degree_inv_sqrt) adj sparse.diags(degree_inv_sqrt) return torch.tensor(x, dtypetorch.float32), torch.tensor(adj_norm.toarray(), dtypetorch.float32)Flask 接口from flask import Flask, request, jsonify app Flask(__name__) app.route(/predict, methods[POST]) def predict(): data request.get_json(forceTrue) text data.get(text, ).strip() if not text: return jsonify({error: text is required}), 400 x, adj_norm build_predict_graph(text, train_texts, train_labels) with torch.no_grad(): logits model(x, adj_norm) prob torch.softmax(logits, dim1)[0] label int(torch.argmax(prob).item()) score float(prob[label]) return jsonify({ text: text, label: label, is_spam: bool(label 1), score: score })这段代码把训练集评论文本放在内存中只适用于小规模演示。真实系统如果训练集有几百万条每次请求都要计算余弦相似度性能会非常差。生产环境更推荐的做法是定期离线重建图并训练模型。服务端保存最近一段时间的高危评论索引用向量检索召回相似节点。或者接入支持在线推理的 GraphSAGE 模型。5.4 批量预测接口批量接口适合后台审核任务。一次接收多条文本对每一条执行相同逻辑app.route(/predict_batch, methods[POST]) def predict_batch(): data request.get_json(forceTrue) texts data.get(texts, []) if not isinstance(texts, list) or len(texts) 0: return jsonify({error: texts must be a non-empty list}), 400 results [] for text in texts: if not text: results.append({text: text, error: empty text}) continue x, adj_norm build_predict_graph(text, train_texts, train_labels) with torch.no_grad(): logits model(x, adj_norm) prob torch.softmax(logits, dim1)[0] label int(torch.argmax(prob).item()) score float(prob[label]) results.append({ text: text, label: label, is_spam: bool(label 1), score: score }) return jsonify({results: results})批量接口要注意控制单次请求的文本数量。文本过多会导致内存和响应时间直线上升建议一次不超过 100 条。5.5 命令行与页面调用接口写好后可以用 curl 快速验证curl -X POST http://127.0.0.1:5000/predict \ -H Content-Type: application/json \ -d {text: 加微信免费领取资料}也可以在 Flask 里返回一个简单 HTML 页面方便交互演示。这里不展开前端逻辑只说明templates/index.html里放一个表单点击提交后请求/predict即可。6. 运行验证与结果分析6.1 启动服务安装依赖后先运行训练脚本python train.py训练脚本会生成models/gcn_model.pt和models/tfidf_vectorizer.joblib。然后启动 Flaskpython app.py正常情况会看到 Flask 启动日志监听在127.0.0.1:5000。6.2 调用验证使用正常评论测试curl -X POST http://127.0.0.1:5000/predict \ -H Content-Type: application/json \ -d {text: 这篇文章很实用学到了}使用垃圾评论测试curl -X POST http://127.0.0.1:5000/predict \ -H Content-Type: application/json \ -d {text: 加我微信领红包}6.3 预期输出如果模型和数据正常预计输出类似{ text: 加我微信领红包, label: 1, is_spam: true, score: 0.93 }正常评论更可能得到{ text: 这篇文章很实用学到了, label: 0, is_spam: false, score: 0.87 }这里需要提醒判断是否垃圾取决于训练数据分布、边定义和特征质量。如果训练数据里垃圾评论比例很低且没有指定类别权重模型可能把所有评论都预测为正常。验证时不能只看一两条例子要在验证集上算整体指标。6.4 在真实部署中怎么更新图在线评论不断增长图结构必须持续更新。学习环境可以手工重建图生产环境需要设计定时任务每天或每小时从数据库拉取新增评论。重新构建图更新节点特征和邻接矩阵。增量或全量训练 GCN。评估后发布新模型文件。Flask 服务平滑加载新模型。如果是低延迟在线预测最好使用支持归纳学习的模型或者把预测改为“特征 相似子图召回”而不是每次请求都重建大图。7. 常见问题排查7.1 训练阶段常见问题问题现象常见原因检查方式处理建议训练 loss 不下降边太稀疏或特征没归一化检查邻接矩阵是否有边查看特征范围调整建边阈值增加相似度阈值边所有预测都为正常类数据不平衡权重未设置打印训练集类别统计使用加权交叉熵或过采样验证集指标很高线上效果很差节点划分泄漏检查训练集和验证集是否存在共同用户按时间或用户 ID 划分数据安装 PyTorch Geometric 失败Python 或 CUDA 版本不匹配查看 pip 安装日志改用自实现 GCN或安装对应 CPU 版本7.2 Flask 部署阶段常见问题问题现象常见原因检查方式处理建议接口返回 500模型路径错误或 input 维度不匹配看 Flask 控制台日志确认 model 文件存在特征维度与训练一致每次请求都很慢每次重建大图或重新加载模型打印函数耗时使用缓存限制候选集大小中文乱码请求或文件编码不一致检查返回的 JSON 和控制台统一 UTF-8请求头设置application/json; charsetutf-8报错 shape mismatch新评论构建的邻接矩阵尺寸与模型输入不一致打印 x 和 adj 的 shape保证新节点编号从 0 开始邻居节点编号连续7.3 预测结果异常如果正常评论被频繁判为垃圾优先检查训练数据中是否存在大量误标注。简单试一下把训练数据里的垃圾评论按用户聚类看这些用户是否集中在少数账号。垃圾评论的图特征应该表现为同一用户节点之间边密集。文本相似度高的节点聚成小团体。这些团体的节点标签以垃圾为主。如果发现垃圾评论在图上是完全孤立的节点说明边规则没有捕捉到有效结构模型退化成普通文本分类器。8. 最佳实践与扩展方向8.1 模型上线前检查清单上线前至少确认以下内容训练数据和将要预测的数据分布是否一致。是否已经按时间或用户划分验证集。是否记录了模型版本、特征版本和图构建参数。是否评估过精确率、召回率和 F1而不只是准确率。预测接口是否有超时、异常捕获和请求日志。是否有对新评论的处理逻辑而不是只能预测训练集中的节点。是否考虑过数据偏差和标注噪声。这套清单也适用于一般的图神经网络服务化项目。8.2 关键参数选型参数作用建议节点特征维度影响模型参数量和训练速度TF-IDF 场景可以先取 256 到 512 维GCN 层数控制邻居聚合范围通常 2 层足够层数过多容易过平滑隐藏维度模型表达能力32 到 128根据数据量调整dropout防止过拟合0.5 是比较常见的初始化建边相似度阈值决定图结构密度过高会损失关系过低会导致图太密建议从 0.8 调起类别权重缓解样本不平衡按训练集反比设置或使用Focal Loss8.3 可扩展方向有了最小版本之后可以从以下方向继续推进一是换更合适的图模型。GraphSAGE 和 GAT 都比基础 GCN 更适合在线预测GraphSAGE 能引入采样邻居机制GAT 能给不同邻居分配不同注意力权重。二是引入更多异构图信息。把用户节点、内容节点、IP 节点加入图形成异构图形这样能识别同一 IP 下不同用户抱团评论的行为。三是和 Web 管理端结合。Flask 服务可以只做后端接口前端使用 Vue 或简单管理系统数据存储使用 MySQL。这套结构适合课程设计也适合小团队内部工具。四是增加人工审核交接。模型输出概率落在 0.4 到 0.8 之间时不直接拦截而是进入人工审核队列。这比设置单一阈值更安全。五是持续迭代。垃圾评论会随时间变化只训练一次是不够的。可以把被误判的样本收集起来标注后作为下一轮训练数据。实际项目里最值得花时间的不是模型结构而是数据标注和图边定义。把“谁在什么时间、用什么账号、发布了什么内容”这些信息组织好GCN 才能发挥结构建模的优势。如果只把文本向量丢进模型图结构反而会成为噪声来源。先跑通最小系统再逐步增加关系维度是更稳妥的路线。