在内容平台的实际业务里,垃圾评论治理通常要经历“发现—标注—过滤—追封”四个环节。很多团队一开始用关键词黑白名单,后来换成 TF-IDF 加逻辑回归,再往后开始上深度学习模型。但有一个问题始终存在:广告评论和正常评论的词重叠度非常高,单看一条评论很难判断它是不是垃圾。比如“加微信领优惠券”和“微信下单很方便”都包含“微信”,基于词的模型很容易误判。
GCN 解决的是另一个层次的问题:它不只看单条评论的文本,而是把整个语料库中“词与词、词与评论”的共现关系构造成一张图,让分类器学会“和大量垃圾广告词共现的词,大概率也处于广告语境”。这是图卷积神经网络在文本分类里的核心思路,TextGCN 是其中最有代表性的方法。本文将完成一条完整的落地链路:用 GCN 训练垃圾评论识别模型,用 Flask 把模型包装成 Web 接口,并给出训练、部署、验证和生产环境注意事项。
先说明本文的核心判断:在垃圾评论识别这个任务上,GCN 的价值不是追求 SOTA,而是提供一种不依赖预训练模型、训练成本低、结果可解释性强的替代方案。如果你想快速验证一个文本分类思路,又不想引入太重的外部依赖,Flask + GCN 是性价比很高的组合。读完本文,你能跑通一个包含数据预处理、文本构图、GCN 训练、模型保存与 Flask 在线预测的完整系统,也能避开我在实际工程中遇到过的几个关键坑。
1. 垃圾评论识别任务与 GCN 方案选型
垃圾评论识别从本质上看是一个文本二分类问题:输入一条评论,输出它是正常评论还是垃圾评论。这个任务看起来简单,但工程上非常麻烦。首先是数据分布极不均衡,垃圾评论可能只占全部评论的 1% 到 5%;其次是垃圾评论会主动变形,比如“加微信”写成“加 VX”,“优惠券”写成“优 惠 券”,关键词规则过几天就失效;第三是评论长度短,单条能提供的信息有限,容易误判。
传统方法中,TF-IDF + SVM/逻辑回归是最常见的基础方案,原理简单、训练快,但对语义和上下文不敏感。深度学习方法中,TextCNN、BiLSTM 都能捕捉局部和序列信息,效果更好,但仍把每条评论当作独立文本处理,没有利用整个语料库的结构信息。BERT 类模型效果最好,但对算力和推理延迟要求高,在低资源场景下不一定划算。
GCN 的方案和它们都不一样。它认为文档和词可以同时作为图上的节点,文档与词之间有边(权重为 TF-IDF),词与词之间也有边(权重为 PMI 点互信息)。经过两到三层的图卷积,每个文档节点的表示会聚合邻居节点信息,从而把“词在语料库中的共现结构”编码进分类特征。这意味着模型不仅能判断一条评论说了什么,还能判断它和哪些评论、哪些词一起出现,这对识别变形广告词特别有价值。
| 方案 | 数据利用方式 | 训练成本 | 推理延迟 | 适合场景 |
|---|---|---|---|---|
| 关键词规则 | 单条文本 | 极低 | 极低 | 强规则业务 |
| TF-IDF + LR | 单条文本 | 低 | 极低 | 基准模型 |
| TextCNN / BiLSTM | 单条文本序列 | 中 | 低 | 通用文本分类 |
| BERT | 单条文本 + 预训练知识 | 高 | 高 | 复杂语义场景 |
| TextGCN / GCN | 语料库构图 | 中 | 中低 | 低资源、可解释、反变体广告 |
从表里也能看出,GCN 的定位非常明确:它适合那些标注数据有限、不想依赖超大预训练模型、但希望模型能感知语料库整体结构的团队。它不是要取代 BERT,而是在“成本可控”和“效果够用”之间取得平衡。
2. GCN 与 TextGCN 的核心原理
2.1 什么是图卷积神经网络
图卷积神经网络(Graph Convolutional Network)是用于处理图结构数据的神经网络。传统卷积神经网络处理的是像素点排列整齐的图片,循环神经网络处理的是时间上有先后顺序的序列,而图卷积处理的是节点和边构成的图。
一句话概括 GCN 的传播公式:每个节点的新表示,等于自己和邻居节点的旧表示加权求和,再经过一次线性变换和激活函数。用数学公式表达就是:
H^(l+1) = σ( D^(-1/2) A D^(-1/2) H^(l) W^(l) )其中 A 是邻接矩阵,D 是度矩阵,W 是权重矩阵,H 是节点特征矩阵。D^(-1/2) A D^(-1/2) 这种归一化方式是为了防止节点度数差异导致数值不稳定。两层的 GCN 就已经能让每个节点看到二阶邻居的信息。
2.2 TextGCN 的构图方式
TextGCN 把整个训练语料库构造成一张异构图,包含两类节点和两类边:
- 文档节点:每条评论是一个节点。
- 词节点:词表中每个词是一个节点。
- 文档-词边:如果词 w 出现在文档 d 中,则连一条边,权重为 TF-IDF 值。
- 词-词边:如果两个词在同一个滑动窗口内共现,则连一条边,权重为 PMI 值。
PMI 的计算公式如下:
PMI(i, j) = log( p(i, j) / (p(i) * p(j)) )如果 PMI 大于 0,说明词 i 和词 j 在实际语料中倾向于共现;如果 PMI 小于 0,说明两者倾向于互斥。TextGCN 通常只保留 PMI 大于 0 的词-词边。
这样构图之后,一个文档节点的最终表示不仅包含自己的 TF-IDF 信息,还通过词节点间接聚合了与它相关的其他文档的信息。这就是 GCN 做评论识别时“看全局”的力量所在。
2.3 特征矩阵怎么选
TextGCN 原文中,节点特征矩阵直接使用单位矩阵,也就是每个节点用 one-hot 向量表示。为什么可以这样?因为图卷积的传播过程本身就完成了特征聚合,输入特征即使是最简单的 one-hot,经过多层传播后也能携带丰富的邻居信息。
不过这里有一个工程上的限制:如果节点数很大,单位矩阵的维度就是 N×N,内存和计算开销会快速增长。在小规模演示系统中这不是问题,但生产环境中建议用词嵌入或者降维后的特征作为输入。本文的示例代码为了清晰起见,继续使用 one-hot 思路,但会在工程建议章节说明优化方向。
3. 系统整体架构设计
整个系统分为模型训练子系统和 Web 服务子系统两部分。
模型训练子系统负责离线完成数据清洗、分词、构图、GCN 训练和模型持久化。Web 服务子系统基于 Flask 框架实现,接收前端或客户端提交的评论文本,调用已训练好的模型,返回分类结果。
实际项目中,由于 GCN 训练阶段需要把全部训练数据构图,而线上的单条预测不可能每次重新构建整张图,因此需要把“图上的文档节点分类”转换成“词节点嵌入 + 分类器”的落地思路。具体做法是:训练完成后,提取 GCN 第一层输出作为词节点嵌入;对于一条新评论,先分词,再对词节点嵌入做 TF-IDF 加权平均,得到评论级表示;最后用一个小型分类器完成预测。
这种转换是本文方案能够真正跑起来的关键。如果严格按照 TextGCN 原文的做法,每次预测都要把新评论加入全图重新推理一次,在实时接口场景下基本不可行。所以本文的做法是:训练阶段使用 GCN 学词级表示,部署阶段使用加权聚合和分类器完成预测。这样既保留了图卷积带来的语料库结构信息,又让 Flask 接口能做到毫秒级响应。
┌──────────────── 离线训练 ────────────────┐ │ 评论数据 → 分词清洗 → 词表构建 │ │ → 构图(TF-IDF + PMI) │ │ → GCN训练 → 词嵌入提取 │ │ → 分类器训练 → 模型文件保存 │ └─────────────────────────┬───────────────┘ │ 加载 ┌──────────────── 在线服务 ────────────────┐ │ Flask Web ─ /predict 接口 │ │ → 评论输入 → 分词 → 词嵌入加权聚合 │ │ → 分类器预测 → JSON 返回结果 │ └───────────────────────────────────────────┘注意:以上图中使用文字箭头描述流程,实际部署时还可以引入 Redis 缓存、消息队列、模型版本管理,但核心链路就是“离线训练、在线聚合预测”这条主线。
4. 环境准备与前置依赖
4.1 运行环境
本系统的开发环境建议使用 Python 3.8 及以上版本,操作系统不限,Windows、macOS、Linux 都可以。PyTorch 建议安装 CPU 版本即可完成演示,如果你的机器有 NVIDIA GPU 且安装了 CUDA,可以安装 GPU 版本加快训练。
4.2 依赖安装
创建一个虚拟环境并安装依赖,是工程化开发的第一步,可以避免多个项目之间的包版本冲突。
python -m venv venv source venv/bin/activate # Windows 下使用 venv\Scripts\activate pip install flask torch numpy pandas jieba scikit-learn这里梳理一下每个依赖包的用途:
| 依赖包 | 用途 |
|---|---|
| flask | 提供 Web 服务和 API 接口 |
| torch | 构建并训练 GCN 模型 |
| numpy | 矩阵计算 |
| pandas | 数据处理和 CSV 读取 |
| jieba | 中文分词 |
| scikit-learn | TF-IDF 计算、分类器训练、评估 |
版本以实际安装结果为准,本文演示的是通用思路。安装完成后可以用pip list查看装好的包,确认 torch 能正常导入。
4.3 项目结构
建议按下面的目录结构组织代码,把训练和 Web 服务分离,便于后续维护:
comment-gcn/ ├── app.py ├── requirements.txt ├── data/ │ └── comments.csv ├── model/ │ ├── __init__.py │ ├── preprocess.py │ ├── graph.py │ ├── gcn.py │ └── train.py ├── artifacts/ │ ├── vocab.json │ ├── word_emb.npy │ └── cls.pkl └── templates/ └── index.html其中artifacts目录用于保存训练产物,Flask 启动时从这里面加载模型文件。
5. 数据准备与预处理实现
5.1 示例数据集
为了方便演示,我们使用一个很小的 CSV 数据集。真实项目中你需要准备几千到几万条已标注评论,标注字段为text和label,其中 label 为 1 表示垃圾评论,0 表示正常评论。
创建一个data/comments.csv文件,内容如下:
text,label 这件衣服版型很好,穿上显瘦,物流也快,0 客服很耐心,问题都解答清楚了,0 质量不错,第二次回购了,好评,0 加微信领红包,点击链接注册,1 专业代刷好评,需要的联系我,1 免费领取优惠券,加群获取兼职,1 这个价格在同类商品里很划算,0 电话联系我,给你内部低价渠道,1 商品有异味,包装破损,差评,0 点击进入直播间领限量福利,1这个数据集只有 10 条样本,肯定不足以训练出高精度模型,但足够把整套流程跑通。如果你有真实业务数据,替换 CSV 文件即可。
5.2 文本清洗与分词
创建model/preprocess.py,统一处理清洗和分词逻辑。这里的关键点是训练和预测阶段必须使用完全相同的预处理代码,否则分词结果不一致,预测效果会大幅下降。
# model/preprocess.py import re import jieba STOPWORDS = set( "的了我在有人这是和就不一个上也很要到去会着没有好看自己他那" ) def clean_text(text): text = str(text).lower() text = re.sub(r"[\s]+", "", text) text = re.sub(r"http\S+|www\.\S+", "URL", text) return text def seg_words(text): words = jieba.lcut(clean_text(text)) return [w for w in words if w not in STOPWORDS and re.match(r"^[\u4e00-\u9fa5a-zA-Z0-9]+$", w)]清洗逻辑里做三件事:转小写、合并连续空白、替换 URL。第三点很重要,垃圾评论经常带链接,统一替换成URL这个符号可以减少特征稀疏。停用词表这里只是最小示例,实际项目建议用哈工大停用词表或自己沉淀的高频无意义词表。
5.3 构建词表
训练脚本启动时,需要读入全部评论,分词后构建词表。词表顺序一旦确定,后续构图和模型加载都必须保持同一份词表,所以要把词表保存到 JSON 文件中。
# 训练前构建词表,并保存到 artifacts/vocab.json import json from model.preprocess import seg_words corpus = [] # 每条评论的分词结果 labels = [] # 每条评论的标签 # 读取 CSV 后填充 corpus 和 labels # vocab 构建 vocab = [] for words in corpus: for w in words: if w not in vocab: vocab.append(w) with open("artifacts/vocab.json", "w", encoding="utf-8") as f: json.dump(vocab, f, ensure_ascii=False, indent=2)6. GCN 模型定义与训练流程
6.1 构建邻接矩阵
邻接矩阵是 GCN 的核心输入。我们先把文档和词的数量相加得到节点总数,然后分别填充文档-词边和词-词边。文档-词边的权重用 TF-IDF,词-词边的权重用 PMI。
创建model/graph.py,实现构图逻辑:
# model/graph.py import numpy as np from collections import Counter def build_adj(corpus, vocab, doc_tfidf, window=5): word2id = {w: i for i, w in enumerate(vocab)} doc_num = len(corpus) word_num = len(vocab) node_num = doc_num + word_num adj = np.zeros((node_num, node_num)) # 文档-词边,权重为 TF-IDF for d in range(doc_num): for w_idx, tfidf_val in enumerate(doc_tfidf[d]): if tfidf_val > 0: adj[d][doc_num + w_idx] = tfidf_val # 词-词边,权重为 PMI for words in corpus: for i in range(len(words)): if words[i] not in word2id: continue for j in range(max(0, i - window), min(len(words), i + window + 1)): if i == j or words[j] not in word2id: continue w1 = word2id[words[i]] w2 = word2id[words[j]] adj[doc_num + w1][doc_num + w2] += 1 # 计算 PMI 并加权 total_pair = max(np.sum(adj[doc_num:, doc_num:]), 1) word_freq = np.sum(adj[doc_num:, doc_num:], axis=1) for i in range(word_num): for j in range(word_num): if adj[doc_num + i][doc_num + j] == 0: continue p_ij = adj[doc_num + i][doc_num + j] / total_pair p_i = word_freq[i] / max(np.sum(word_freq), 1) p_j = word_freq[j] / max(np.sum(word_freq), 1) pmi = np.log((p_ij + 1e-8) / (p_i * p_j + 1e-8)) if pmi <= 0: adj[doc_num + i][doc_num + j] = 0 else: adj[doc_num + i][doc_num + j] = pmi return adj def normalize_adj(adj): d = np.sum(adj, axis=1) d_inv_sqrt = np.power(d, -0.5) d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0 d_mat_inv_sqrt = np.diag(d_inv_sqrt) return d_mat_inv_sqrt @ adj @ d_mat_inv_sqrt这段代码做了几件事:先统计词-词共现次数,再转成 PMI 权重,最后用对称归一化处理邻接矩阵。归一化这一步对应 GCN 公式中的 D^(-1/2) A D^(-1/2),是训练稳定性的关键。
6.2 GCN 网络定义
创建model/gcn.py,定义两层 GCN 网络:
# model/gcn.py import torch import torch.nn as nn import torch.nn.functional as F class GCNLayer(nn.Module): def __init__(self, in_features, out_features): super().__init__() self.fc = nn.Linear(in_features, out_features) nn.init.xavier_uniform_(self.fc.weight) nn.init.zeros_(self.fc.bias) def forward(self, x, adj): support = self.fc(x) return torch.spmm(adj, support) class GCN(nn.Module): def __init__(self, nfeat, nhid, nclass, dropout=0.5): super().__init__() self.gc1 = GCNLayer(nfeat, nhid) self.gc2 = GCNLayer(nhid, nclass) self.dropout = dropout def forward(self, x, adj): x = F.relu(self.gc1(x, adj)) x = F.dropout(x, self.dropout, training=self.training) x = self.gc2(x, adj) return F.log_softmax(x, dim=1)这里torch.spmm是稀疏矩阵乘法。但在演示代码中,我们直接传入稠密矩阵也可以,只需在训练前调用normalize_adj并把结果转成 PyTorch 张量。如果数据量很大,建议把邻接矩阵转成稀疏格式,具体可以查torch.sparse_coo_tensor的用法。
6.3 训练脚本
创建model/train.py,完成从数据读取到模型保存的完整流程:
# model/train.py import json import numpy as np import torch import torch.nn.functional as F from torch.optim import Adam from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.metrics import classification_report import joblib from model.preprocess import seg_words from model.graph import build_adj, normalize_adj from model.gcn import GCN def train_model(corpus, labels, epochs=200, lr=0.01): vocab = [] word2id = {} for words in corpus: for w in words: if w not in word2id: word2id[w] = len(vocab) vocab.append(w) doc_num = len(corpus) word_num = len(vocab) node_num = doc_num + word_num # TF-IDF 用于文档-词边 docs = [" ".join(words) for words in corpus] vectorizer = TfidfVectorizer(token_pattern=r"\S+") tfidf_mat = vectorizer.fit_transform(docs).toarray() doc_tfidf = tfidf_mat # 形状: doc_num x word_num # 邻接矩阵和特征矩阵 adj = build_adj(corpus, vocab, doc_tfidf) adj = normalize_adj(adj) adj = torch.FloatTensor(adj) x = np.eye(node_num) x = torch.FloatTensor(x) labels_tensor = torch.LongTensor(labels) # 模型 model = GCN(nfeat=node_num, nhid=128, nclass=2, dropout=0.5) optimizer = Adam(model.parameters(), lr=lr, weight_decay=5e-4) idx_train = list(range(doc_num)) idx_train = torch.LongTensor(idx_train) model.train() for epoch in range(epochs): optimizer.zero_grad() output = model(x, adj) loss = F.nll_loss(output[idx_train], labels_tensor[idx_train]) loss.backward() optimizer.step() if epoch % 20 == 0: pred = output[idx_train].argmax(dim=1) acc = (pred == labels_tensor[idx_train]).float().mean().item() print(f"epoch {epoch}, loss {loss.item():.4f}, train_acc {acc:.4f}") # 提取词节点嵌入 model.eval() with torch.no_grad(): emb = F.relu(model.gc1(x, adj)) word_emb = emb[doc_num:, :].numpy() # 训练一个逻辑回归分类器 train_vecs = [] for d in range(doc_num): vec = np.zeros(word_emb.shape[1]) total = sum(doc_tfidf[d]) for w_idx in range(word_num): if doc_tfidf[d][w_idx] > 0: vec += (doc_tfidf[d][w_idx] / total) * word_emb[w_idx] train_vecs.append(vec) train_vecs = np.array(train_vecs) clf = LogisticRegression(max_iter=1000) clf.fit(train_vecs, labels) pred = clf.predict(train_vecs) print(classification_report(labels, pred, target_names=["normal", "spam"])) # 保存产物 with open("artifacts/vocab.json", "w", encoding="utf-8") as f: json.dump(vocab, f, ensure_ascii=False, indent=2) np.save("artifacts/word_emb.npy", word_emb) joblib.dump(clf, "artifacts/cls.pkl") print("模型保存完成") return model, clf这个训练脚本有几个值得注意的细节:
第一,训练样本的文档节点索引就是从 0 到 doc_num-1,因此idx_train直接取前 doc_num 个节点。实际数据需要按比例划分验证集和测试集,但演示代码为了跑通流程没有划分,生产环境不能这么做。
第二,逻辑回归分类器建立在 GCN 词嵌入之上。这样做到好处是,线上预测时不需要重新构图,只需对评论分词、查词嵌入、做加权平均,就可以交给逻辑回归分类,解决了 GCN 上线难的问题。
6.4 运行训练
在项目根目录创建入口训练脚本train_entry.py:
# train_entry.py import pandas as pd from model.preprocess import seg_words from model.train import train_model df = pd.read_csv("data/comments.csv") labels = df["label"].tolist() corpus = [seg_words(text) for text in df["text"].tolist()] train_model(corpus, labels, epochs=100)运行命令:
python train_entry.py如果一切正常,你会看到类似如下的输出:
epoch 0, loss 0.6931, train_acc 0.6000 epoch 20, loss 0.5342, train_acc 0.8000 epoch 40, loss 0.4123, train_acc 0.9000 ... 模型保存完成7. Flask Web 服务与接口开发
7.1 加载模型产物
Flask 应用启动时,需要加载三类产物:词表 JSON、词嵌入矩阵、逻辑回归分类器。创建一个inference.py工具模块,专门负责加载和预测,保持app.py的代码简洁。
# inference.py import json import numpy as np import joblib from collections import Counter from model.preprocess import seg_words class CommentPredictor: def __init__(self, vocab_path, emb_path, cls_path): with open(vocab_path, "r", encoding="utf-8") as f: self.vocab = json.load(f) self.word2id = {w: i for i, w in enumerate(self.vocab)} self.word_emb = np.load(emb_path) self.clf = joblib.load(cls_path) def predict(self, text): words = seg_words(text) words = [w for w in words if w in self.word2id] if not words: return 0 vec = np.zeros(self.word_emb.shape[1]) tf = Counter(words) total = sum(tf.values()) for w, c in tf.items(): w_id = self.word2id[w] vec += (c / total) * self.word_emb[w_id] vec = vec.reshape(1, -1) return int(self.clf.predict(vec)[0])这个类的预测流程和训练时保持完全一致:先分词,再过滤词表中不存在的词,接着做 TF-IDF 风格的加权平均,最后交给逻辑回归分类。任何一边逻辑改动,另一边必须同步。
7.2 Flask 接口实现
创建app.py:
# app.py from flask import Flask, request, jsonify, render_template from inference import CommentPredictor app = Flask(__name__) predictor = CommentPredictor( vocab_path="artifacts/vocab.json", emb_path="artifacts/word_emb.npy", cls_path="artifacts/cls.pkl" ) @app.route("/") def index(): return render_template("index.html") @app.route("/predict", methods=["POST"]) def predict(): data = request.get_json() if not data or "text" not in data: return jsonify({"code": 400, "message": "缺少 text 字段"}), 400 text = data["text"] if len(text) > 500: return jsonify({"code": 400, "message": "评论长度超过限制"}), 400 label = predictor.predict(text) return jsonify({"code": 0, "label": label, "message": "垃圾评论" if label == 1 else "正常评论"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)接口层做了两件事:校验参数和长度限制,然后调用预测器。这里对超过 500 字的评论直接拒绝,主要考虑是防止有人用超长文本刷接口或者绕过限流。实际生产环境还应该加上接口鉴权、频率限制和监控日志。
7.3 前端测试页面
创建templates/index.html,提供一个最简的前端页面用于可视化测试:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>GCN 垃圾评论识别系统</title> </head> <body> <h2>评论识别测试</h2> <textarea id="comment" rows="4" cols="60" placeholder="请输入评论内容"></textarea> <br><br> <button onclick="submitComment()">开始识别</button> <h3 id="result"></h3> <script> async function submitComment() { const text = document.getElementById('comment').value; if (!text) return; const resp = await fetch('/predict', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: text}) }); const data = await resp.json(); document.getElementById('result').innerText = data.label === 1 ? '垃圾评论' : '正常评论'; } </script> </body> </html>前端代码很轻量,只做一件事:把文本提交到/predict接口,然后把返回结果展示到页面上。这样可以直观看到模型效果,但真正的业务接入一定是通过 API 完成的。
7.4 启动 Flask 服务
启动命令如下:
python app.py启动成功后终端会显示服务地址,通常是http://127.0.0.1:5000。打开浏览器访问该地址,在文本框中输入“加微信领红包点击链接注册”,点击识别,应该返回“垃圾评论”。
8. 运行结果与效果验证
8.1 用 curl 验证接口
除了页面上的手动测试,还可以直接用 curl 验证接口返回格式:
curl -X POST http://127.0.0.1:5000/predict \ -H "Content-Type: application/json" \ -d '{"text": "加微信领红包,点击链接注册"}'预期输出:
{"code":0,"label":1,"message":"垃圾评论"}再测试一条正常评论:
curl -X POST http://127.0.0.1:5000/predict \ -H "Content-Type: application/json" \ -d '{"text": "这件衣服版型很好,穿上显瘦"}'预期输出:
{"code":0,"label":0,"message":"正常评论"}通过这两条命令,能确认 Flask 路由、模型加载、预测逻辑和数据返回链路是通的。
8.2 效果评估的正确方式
上面的演示只是验证链路,不是评估模型效果。评估 GCN 垃圾评论识别模型,应该在训练前把数据划分成训练集、验证集、测试集。训练集用于学习参数,验证集用于调整超参数,测试集用于评估最终泛化能力。
评估指标建议看四点:
- 准确率:所有预测中预测正确的比例。
- 精确率:预测为垃圾评论的样本中真正是垃圾评论的比例。
- 召回率:真实垃圾评论中被成功识别出来的比例。
- F1 值:精确率和召回率的调和平均。
在垃圾评论场景中,召回率往往比准确率更重要。漏掉垃圾评论的成本通常高于把一条正常评论误判为垃圾。如果误判过多,可以调整逻辑回归的判决阈值,比如从默认的 0.5 调到 0.3,用召回率换精确率。
8.3 失败时的排查顺序
如果模型把广告词都识别成正常评论,先不要急着调模型,按以下顺序检查:
- 训练时和预测时用的分词代码是否一致?这是最常见的问题。
- 词表是否完整?新评论里的词,有没有大量出现在训练词表之外的 OOV 词?
- 训练样本量是否足够?10 条样本只能演示流程,没有任何业务参考价值。
- 类别是否均衡?垃圾评论占比太小,模型会倾向预测为正常类,需要做加权或重采样。
9. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 训练时邻接矩阵内存溢出 | 文档数和词数过多,稠密矩阵占用过大 | 打印节点数,检查矩阵尺寸 | 改用稀疏矩阵,或只对高频词建图 |
| 预测结果全部为正常评论 | 训练样本类别不平衡 | 查看训练集 label 分布 | 对少数类加权,或采集更多垃圾评论 |
| 同一个词训练和预测效果不一致 | 分词器版本或停用词表不一致 | 对比两边的分词结果 | 统一代码路径,固化分词产物 |
| 接口返回慢 | 每次加载模型或做了重复加载 | 检查日志中的耗时 | 启动时加载模型到全局变量 |
| 新词识别不了 | 词表未包含新出现的变形词 | 查看 vocab 中是否包含该词 | 定期用新数据重训,更新词表 |
| 中文乱码 | 编码设置不一致 | 检查终端和文件编码 | 统一使用 UTF-8 |
这里面最值得警惕的是第一行:稠密邻接矩阵。假设你有 1 万条评论、5000 个词,节点数是 1.5 万,邻接矩阵的稠密张量大小是 15000 × 15000,也就是 2.25 亿个 float,大约是 900MB 内存。这还不算特征矩阵。所以生产环境必须使用稀疏矩阵或分批构图。
10. 最佳实践与工程建议
10.1 训练与推理代码必须同源
我在多个项目里踩过的最大坑,就是训练脚本和部署脚本各写一套预处理逻辑。今天训练代码里加了“把 URL 替换成占位符”,部署代码却忘了改,结果线上效果断崖式下跌。解决办法是让训练和推理共用同一个预处理模块,并且把分词结果做成缓存,上线前用一批固定样本对比训练侧和推理侧输出。
10.2 词表要固化,不能动态增长
词表必须在训练阶段确定,线上推理时遇到词表外的词直接跳过或映射到<UNK>。如果你允许线上词表动态增长,那么词嵌入矩阵的维度就会变化,分类器的维度也会对不上,整个模型就会崩掉。新词的处理方式应该是定期离线重训,而不是在线热更新。
10.3 模型训练要有验证集和早停
演示代码只用训练集,因为目的是跑通流程。真实项目必须从数据中切出验证集,每个 epoch 结束后计算验证集损失,当验证集损失连续若干个 epoch 不下降时提前停止训练,然后把验证集上效果最好的模型保存下来。这能显著减少过拟合。
10.4 邻接矩阵尽量使用稀疏格式
当节点数超过几千时,稠密邻接矩阵的内存开销就开始让人头疼了。PyTorch 支持torch.sparse_coo_tensor,能高效存储稀疏矩阵。构图时也只保存非零位置,不要为每个节点对都分配空间。
10.5 Flask 生产部署要换服务器
app.run()是 Flask 自带的开发服务器,只适合本地调试,不适合直接暴露到生产环境。实际部署时建议使用 Gunicorn 或 uWSGI 作为 WSGI 服务器,外面再套一层 Nginx 做反向代理和静态文件处理。如果预测接口并发量高,还可以把模型预测封装成独立服务,再在 Flask 层做缓存。
10.6 接口安全和数据合规
垃圾评论识别系统本身的定位是内容治理,但如果接口被恶意调用,也会变成刷量工具。建议加上接口鉴权、IP 限流、评论长度限制和敏感词前置过滤。同时,训练数据中的评论属于用户数据,在采集和使用时要合法合规,不能把包含个人隐私的数据随意公开或用于未经授权的场景。
11. 总结与后续优化方向
本文从垃圾评论识别的实际痛点出发,实现了 Flask + GCN 的完整识别系统。核心链路包括:评论数据分词清洗、TextGCN 风格构图、两层 GCN 训练、词节点嵌入提取、逻辑回归分类器训练,以及 Flask Web 接口和前端页面的开发。最关键的设计决策是把训练阶段的 GCN 转化为部署阶段的“词嵌入加权聚合 + 轻量分类器”,让模型真正能用于在线推理。
如果继续做下去,有几个方向值得投入。第一个方向是用更大的标注数据集,把当前演示替换成真实业务数据,并补充验证集、测试集和完整的离线评估流程。第二个方向是把 GCN 的词嵌入换成更丰富的表示,比如融合预训练词向量,或者用 BERT 编码后再进入图卷积,提升模型的语义理解上限。第三个方向是工程化升级,包括模型版本管理、增量训练、上线回滚和接口监控。
最后提醒一句:垃圾评论是动态对抗的,广告文案会不断变形,词表和模型都需要周期性更新。把它当作一个持续迭代的内容治理系统来做,而不是一次性交付的模型,才能在实际业务中长期生效。