网络欺凌检测项目实战:从数据清洗到TextCNN与BERT模型落地

发布时间:2026/10/25 9:08:31
网络欺凌检测项目实战:从数据清洗到TextCNN与BERT模型落地

简介:这份资源面向希望入门或实践文本分类的开发者与安全方向学习者,提供了一套基于深度学习的网络欺凌(网络暴力)检测完整项目,可用于识别带有攻击性、侮辱性的言论,适合作为课程设计、毕业设计或NLP实战练手素材。压缩包共8个文件,约2.66MB,包含2个ipynb笔记本、2个json数据与词表文件、1个py脚本、1个h5模型、1个md说明及1个license,覆盖从数据、训练到推理的完整链路。其中训练数据集以逐行json对象组织,训练笔记本展示模型构建与训练流程,纯Python脚本与笔记本两种形式给出实际运行示例,并附带已训练好的模型文件与构建好的词表,开箱即可对指定文本进行预测。目前已有200人学习下载。读者可借此理解文本预处理、词表构建、模型训练与推理的完整流程,并在此基础上替换数据、调整网络结构或迁移到其他有害内容识别任务,具备较强的可扩展性与参考价值。

1. 网络欺凌检测项目落地:从标题拆解到能跑起来的第一条基线

社交平台每天产生海量文本,人工审核根本扛不住,网络欺凌检测就成了一个典型的 NLP 落地场景。这个标题里其实藏了四件事:Python 工程、深度学习模型、源代码、数据集,缺一个都跑不通。我见过太多人卡在第一步——数据拿到手是 CSV,标签列叫label还是toxic都不确定,更别说后面训练了。这篇笔记按真实落地顺序走:先把数据格式和标签体系定死,再搭一个能跑通的基线模型,然后讲清楚参数怎么调、坑在哪,最后给一套验证和进阶的路子。适合两类人:刚入门想拿一个完整项目练手的,以及已经做过文本分类但没碰过欺凌检测这个细分场景的。全程不依赖任何不存在的官方文档,所有代码和参数都是我实际跑过或验证过的常见做法。

2. 数据准备与标签体系:欺凌检测和普通情感分类差在哪

2.1 为什么不能直接拿情感分析数据集凑合

网络欺凌检测和普通情感二分类最大的区别在于:负面情感不等于欺凌。一句“我讨厌这个电影”是负面,但不是欺凌;而“你这种人就该消失”才是。欺凌的核心特征是针对性、重复性、伤害意图,这三个维度在普通情感数据集里根本没有标注。所以如果你拿 IMDB 影评或电商评论去训一个欺凌检测模型,准确率可能虚高到 90% 以上,但一上真实社交文本就翻车——这就是典型的“数据集选错,后面全白做”。

常见做法是找公开的欺凌标注数据集,比如 Formspring、Twitter 欺凌语料、Jigsaw 有毒评论挑战赛数据。这些数据集通常包含text和label两列,label 可能是二分类(欺凌/非欺凌)也可能是多标签(种族、性别、宗教等)。我一般会先做一件事:统计标签分布。如果正负样本比例超过 1:10,后面训练必须处理类别不平衡,否则模型会学会“全预测为负”来刷准确率。

2.2 数据清洗与标签对齐的实操步骤

拿到原始数据后,别急着喂模型。先跑一遍清洗脚本,把 URL、@提及、重复字符、HTML 标签处理掉。下面这段代码是我常用的清洗模板:

import re import pandas as pd def clean_text(text): # 转小写 text = text.lower() # 去掉 URL text = re.sub(r'http\S+|www\.\S+', '', text) # 去掉 @提及,但保留被提及者信息可另存 text = re.sub(r'@\w+', '', text) # 压缩重复字符,比如 "sooooo" -> "soo" text = re.sub(r'(.)\1{2,}', r'\1\1', text) # 去掉 HTML 标签 text = re.sub(r'<.*?>', '', text) # 去掉多余空格 text = re.sub(r'\s+', ' ', text).strip() return text df = pd.read_csv('bullying_data.csv') df['clean_text'] = df['text'].apply(clean_text) # 标签对齐:确保 label 是 0/1 或统一的多标签格式 df['label'] = df['label'].astype(int) print(df['label'].value_counts())

逻辑说明:re.sub(r'(.)\1{2,}', r'\1\1', text)这行是把三个以上连续相同字符压成两个,既保留了强调语气,又避免模型把“sooooo”和“sooo”当成两个词。参数上,\1{2,}里的 2 表示至少重复两次才触发压缩,你可以根据数据噪声程度改成 3。标签对齐那步必须做,我见过 label 列里混着 “yes/no” 和 “1/0” 的情况,不统一后面全乱。

清洗完还要做一步:去重。社交文本里转发和复制粘贴极多,重复样本会让模型过拟合。用df.drop_duplicates(subset=['clean_text'])去重后,再统计一次标签分布。如果正样本太少,可以用sklearn的class_weight='balanced'或简单过采样,但别用 SMOTE 生成文本——文本不像数值特征,插值出来的句子根本不通顺。

2.3 数据集划分的坑:时间泄漏和用户泄漏

普通做法是随机划分 train/test,但欺凌检测有个隐蔽问题:同一个用户的多条文本可能同时出现在训练集和测试集里。模型会记住这个用户的说话风格,测试准确率虚高。正确做法是按用户 ID 划分,或者至少按时间划分——用早期数据训练,后期数据测试。如果你拿到的数据集没有用户 ID,那就退而求其次,按文本哈希去重后再随机划分,并在论文或报告里注明这个局限。

划分比例我一般用 70/15/15,训练/验证/测试。验证集用来调参和早停,测试集只在最后跑一次。别用测试集调参,这是血泪经验——调着调着模型就“记住”测试集了,上线必翻车。

3. 模型选型与基线搭建:从 TextCNN 到 BERT 的取舍

3.1 为什么先跑 TextCNN 而不是直接上 BERT

很多人一上来就from transformers import BertModel,结果显存不够、训练慢、调参复杂,三天后放弃。我的建议是:先用 TextCNN 跑一个基线,确认数据管道和标签没问题,再换 BERT 做提升。TextCNN 结构简单,训练快,在几千到几万条数据上就能看出数据质量好坏。如果 TextCNN 的 F1 连 0.6 都不到,换 BERT 也救不了——问题在数据,不在模型。

TextCNN 的核心就三层:嵌入层、多尺寸卷积核、最大池化。卷积核尺寸一般取 2、3、4,分别捕捉二元、三元、四元词组特征。下面是一个可复现的 PyTorch 实现:

import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim=128, num_classes=2, kernel_sizes=[2,3,4], num_filters=100): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.convs = nn.ModuleList([ nn.Conv2d(1, num_filters, (k, embed_dim)) for k in kernel_sizes ]) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): # x: [batch, seq_len] x = self.embedding(x) # [batch, seq_len, embed_dim] x = x.unsqueeze(1) # [batch, 1, seq_len, embed_dim] x = [F.relu(conv(x)).squeeze(3) for conv in self.convs] x = [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] x = torch.cat(x, 1) x = self.dropout(x) return self.fc(x)

逻辑说明:unsqueeze(1)是把文本序列当成单通道图像,卷积核宽度等于嵌入维度,所以每个卷积核实际上是在滑动窗口上做 n-gram 特征提取。max_pool1d对每个特征图取最大值,保留最强信号。参数上,embed_dim=128适合小数据集,数据量上万后可以加到 256;num_filters=100是每个卷积核的输出通道数,太小欠拟合,太大过拟合,100 到 200 之间比较稳;dropout=0.5是防过拟合的常规操作,如果训练损失下降但验证损失上升,可以加到 0.6 或 0.7。

3.2 训练循环里的三个必调参数

训练代码网上一搜一大把,但真正影响结果的就三个:学习率、批次大小、早停耐心值。学习率我一般从 1e-3 开始,用 Adam 优化器,如果损失震荡就降到 1e-4。批次大小 32 或 64,显存够就 64,不够就 16,别用 1——梯度太吵。早停耐心值设 3 到 5,意思是验证集 F1 连续 3 个 epoch 不提升就停,防止过拟合。

还有一个容易被忽略的:最大序列长度。社交文本大多很短,统计一下 95% 分位数,如果 95% 的文本在 50 个词以内,就设max_len=64,别设 512。设大了不仅浪费计算,还会让 padding 淹没真实信号。我见过有人把 max_len 设成 256 跑 TextCNN,结果 F1 比设 64 还低——就是因为 padding 太多,卷积核滑到 padding 区域提取的都是噪声。

3.3 从 TextCNN 换到 BERT 的时机和注意事项

当 TextCNN 的验证集 F1 稳定在 0.75 以上,且你还有提升需求时,再换 BERT。换的时候注意三点:第一,BERT 的 tokenizer 和 TextCNN 的词表不通用,必须重新处理数据;第二,BERT 学习率要小得多,一般 2e-5 到 5e-5,用 AdamW 优化器;第三,BERT 训练慢,建议先冻结底层,只训分类头,再解冻全部微调。如果数据量少于 5000 条,BERT 很容易过拟合,这时候 TextCNN 加预训练词向量反而是更稳的选择。

4. 避坑与排查:欺凌检测项目里最容易翻车的五件事

4.1 准确率 95% 但上线就废:类别不平衡的锅

现象:训练完一看准确率 95%,兴冲冲上线,结果用户反馈“什么都判成正常”。原因:正样本只占 5%,模型学会全预测为负就能拿 95% 准确率。解决:别只看准确率,看 F1 和召回率。训练时加class_weight='balanced',或者用 focal loss 让模型关注难样本。评估时用混淆矩阵,确认正样本召回率是否可接受。

4.2 模型把“你”和“你这种人”判成一样:分词和 OOV 问题

现象:验证集上“你这种人真恶心”被漏判,但“你这种人真恶心啊”能判对。原因:分词后“你这种人”可能被切成 OOV 或低频词,嵌入向量没学好。解决:用子词分词(BERT 的 WordPiece 或 BPE),或者对 TextCNN 加字符级嵌入。另外,把训练集里出现次数少于 3 的词统一替换成<UNK>,减少噪声。

4.3 训练损失正常但验证损失爆炸:学习率太大或批次太小

现象:训练 loss 稳步下降,验证 loss 从第 2 个 epoch 开始飙升。原因:学习率太大,模型在验证集上震荡;或者批次太小,梯度噪声大。解决:学习率降 10 倍,批次加到 64,加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。如果还不行,检查数据里有没有标签错误——我遇到过 3% 的样本标签标反了,清理后验证 loss 立刻正常。

4.4 推理时单条预测和批量预测结果不一致

现象:用model.predict()单条跑和用model(batch)批量跑,同一条文本结果不同。原因:忘了设model.eval(),dropout 和 batch norm 在训练模式下行为不同。解决:推理前必须model.eval(),并用torch.no_grad()包住。另外,单条预测时序列长度是 1,批量时 padding 到 max_len,如果模型对 padding 敏感,结果就会飘。统一用批量推理,或者单条也 padding 到固定长度。

4.5 换数据集后 F1 掉 20 个点:领域偏移没处理

现象:在 Twitter 数据上训到 F1 0.85,换到 Reddit 数据直接掉到 0.65。原因:两个平台的用语风格、欺凌表达方式差异大,模型没泛化能力。解决:做领域自适应,比如在目标领域数据上继续微调,或者用对抗训练让特征跨领域不变。简单做法是:把两个领域数据混在一起训练,但给领域标签,让模型学领域无关特征。如果目标领域数据太少,至少做一轮在目标数据上的微调,学习率设小一点。

5. 进阶技巧:用注意力可视化验证模型到底学到了什么

模型跑通之后,别只看 F1。欺凌检测是个敏感场景,你需要知道模型是“真的学到了欺凌特征”还是“学到了数据集的偏见”。比如如果数据集里所有带“stupid”的句子都被标为欺凌,模型可能只是学会了这个词,而不是理解语境。验证方法是用注意力权重可视化,看模型在预测时关注了哪些词。

对于 BERT 模型,可以用transformers的output_attentions=True拿到注意力矩阵,然后对最后一层所有头取平均,画成热力图。下面是一个最小示例:

from transformers import BertTokenizer, BertForSequenceClassification import torch tokenizer = BertTokenizer.from_pretrained('bert-base-uncased') model = BertForSequenceClassification.from_pretrained('bert-base-uncased', num_labels=2) model.eval() text = "you are so stupid and nobody likes you" inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=64) with torch.no_grad(): outputs = model(**inputs, output_attentions=True) # 取最后一层注意力,平均所有头 attn = outputs.attentions[-1].mean(dim=1).squeeze(0) # [seq_len, seq_len] # 取 [CLS] 对所有 token 的注意力 cls_attn = attn[0].tolist() tokens = tokenizer.convert_ids_to_tokens(inputs['input_ids'][0]) for token, weight in zip(tokens, cls_attn): print(f"{token}: {weight:.4f}")

逻辑说明:outputs.attentions是一个元组,每个元素对应一层的注意力张量,形状是[batch, num_heads, seq_len, seq_len]。取[-1]是最后一层,mean(dim=1)是对所有注意力头取平均。attn[0]是[CLS]对所有 token 的注意力,权重越高说明模型越关注那个词。参数上,max_length=64要和训练时一致,否则位置编码对不上。

如果发现模型只关注“stupid”而忽略了“you are”,说明它学的是词级特征而非语境。这时候可以尝试:在训练数据里加入反事实样本,比如“you are so smart”标为非欺凌,让模型学会区分形容词的极性。另一个技巧是加一个辅助任务,让模型同时预测文本的情感极性和欺凌标签,多任务学习能迫使模型学到更鲁棒的特征。

我自己的习惯是:每次模型上线前,随机抽 50 条测试样本,人工看注意力热力图,确认没有明显的偏见模式。这个步骤花不了半小时,但能避免很多“模型看起来准但实际不可靠”的问题。希望帮到你。

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