一句话总结
单条消息分类只是起点,群聊对骂检测需要上下文窗口,对话级特征,和一条完整的 pipeline.
本篇把前几篇的分类器组装成一个可上线的检测系统.
前置回顾
第 05 篇定义了对骂检测的分类任务和评估指标(P/R/F1).
第 07 篇用 TF-IDF + LR 和 FastText 跑出了 baseline.
第 08 篇引入了 TextCNN 和 LSTM,能检测隐晦表达.
但这些模型都在单条消息上做分类--群聊是多人对话流,单条消息脱离上下文经常判不准.
为什么单条消息分类不够
先看几个实际案例:
消息
单条分类结果
实际情况
"你说得对"
正常(0.12)
前面是一连串争吵 → 阴阳怪气
"行行行都是你对"
正常(0.35)
对话中反复出现 → 赌气对骂的一部分
"sb"
对骂(0.97)
前文在讨论"sb = somebody" → 误报
"哈哈哈哈"
正常(0.05)
嘲笑对方犯错,属于挑衅 → 漏报
共同点:这些错误只有结合上下文才能修正 . 单条分类器看不到前后消息,无法理解对话走向.
多分类 vs 多标签:标签体系升级
第 05 篇选择了二分类(对骂/正常)作为起步. 现在数据量和模型能力都够了,可以考虑更细的标签体系.
多分类:每条消息只有一个标签
标签体系(互斥): 0 - 正常 1 - 显式对骂(含脏话) 2 - 阴阳怪气(隐晦攻击) 3 - 人身攻击(针对个人特征) 4 - 引战/带节奏
用 softmax 输出,所有类别概率加和为 1,取最大的作为预测.
多标签:一条消息可以有多个标签
标签体系(可共存): 对骂 ✓/✗ 人身攻击 ✓/✗ 带节奏 ✓/✗ 性别歧视 ✓/✗ 地域歧视 ✓/✗
"你这种 xx 地方来的垃圾" 同时命中对骂 + 人身攻击 + 地域歧视. 每个标签独立做二分类,用 sigmoid 分别输出概率.
class MultiLabelClassifier (nn.Module): def __init__ (self, input_dim, num_labels ): super ().__init__() self .fc = nn.Linear(input_dim, num_labels) def forward (self, x ): return self .fc(x) criterion = nn.BCEWithLogitsLoss()
多标签的标注成本
多标签需要标注员对每条消息判断所有标签维度,标注时间约为二分类的 3-5 倍.
建议路径:先用二分类上线 → 积累数据和标注经验 → 再扩展为多标签.
上下文窗口:用前后消息辅助判断
核心思路:不只看当前消息,还把前后 N 条消息拼接进来作为输入.
窗口设计
时间线: t-3: [用户A] 你做的什么玩意 t-2: [用户B] 怎么了? t-1: [用户A] 一堆 bug 还好意思提测 t : [用户B] 你行你来 ← 当前消息,需要判断 t+1: [用户A] 就你这水平还写代码 上下文窗口(window=2): 输入 = concat(t-2, t-1, [SEP], t, [SEP], t+1) 模型看到: "怎么了? [SEP] 一堆 bug 还好意思提测 [SEP] 你行你来 [SEP] 就你这水平还写代码"
[SEP] 是分隔符,告诉模型哪些是不同消息的边界.
实现方式
def build_context_window (messages, idx, window=2 ): """构建第 idx 条消息的上下文窗口""" start = max (0 , idx - window) end = min (len (messages), idx + window + 1 ) parts = [] for i in range (start, end): speaker = messages[i]["user" ] text = messages[i]["text" ] if i == idx: parts.append(f"[CUR] {speaker} : {text} " ) else : parts.append(f"{speaker} : {text} " ) return " [SEP] " .join(parts)
窗口大小选择
window=2(前后各 2 条,共 5 条)是群聊场景的经验值.
太小(window=0)等于单条分类,上下文信息丢失.
太大(window=5+)引入太多无关消息,反而成噪声,且超出短文本模型的 max_len.
对话级特征:超越文本内容
群聊对骂不只体现在文本内容上,还体现在对话行为模式 中. 这些特征用传统方法就能提取,拼接到模型输入中效果显著.
特征清单
特征
计算方式
对骂信号
回复频率
同一用户在 5 分钟内的消息数
高频 → 情绪激动
回复速度
两条消息的时间间隔
极快(< 3 秒) → 在吵架
对话密度
窗口内的消息总数 / 时间跨度
密集对话 → 可能在争论
@频率
窗口内 @某人 的次数
反复 @同一人 → 针对性攻击
用户交互比
A→B 和 B→A 的消息数量比
接近 1:1 → 互相对骂
历史标记
该用户过去 7 天被标记为对骂的次数
惯犯概率高
def extract_dialog_features (messages, idx, window=2 ): """提取对话级别的统计特征""" start = max (0 , idx - window) end = min (len (messages), idx + window + 1 ) context = messages[start:end] current = messages[idx] features = {} user = current["user" ] features["user_msg_count" ] = sum ( 1 for m in context if m["user" ] == user ) if idx > 0 : delta = current["timestamp" ] - messages[idx - 1 ]["timestamp" ] features["reply_speed" ] = delta.total_seconds() else : features["reply_speed" ] = 999 if len (context) > 1 : time_span = (context[-1 ]["timestamp" ] - context[0 ]["timestamp" ]).total_seconds() features["dialog_density" ] = len (context) / max (time_span, 1 ) else : features["dialog_density" ] = 0 features["at_count" ] = sum ( m["text" ].count("@" ) for m in context ) return features
特征融合:两路输入
把文本特征(模型学出的)和对话特征(手工提取的)拼在一起做最终分类:
┌─────────────┐ 上下文文本 ──→ │ TextCNN/LSTM │──→ 文本向量(128 维)──┐ └─────────────┘ │ ├──→ concat ──→ FC ──→ 对骂/正常 ┌─────────────┐ │ 对话级特征 ──→ │ 标准化 + FC │──→ 对话向量(16 维)──┘ └─────────────┘
class HybridClassifier (nn.Module): def __init__ (self, text_model, dialog_feat_dim, num_classes ): super ().__init__() self .text_model = text_model self .dialog_fc = nn.Linear(dialog_feat_dim, 16 ) self .classifier = nn.Linear(128 + 16 , num_classes) def forward (self, text_input, dialog_features ): text_vec = self .text_model.get_features(text_input) dialog_vec = torch.relu(self .dialog_fc(dialog_features)) combined = torch.cat([text_vec, dialog_vec], dim=1 ) return self .classifier(combined)
文本特征 = 请求体内容检查 (看用户发了什么).
对话特征 = 请求元数据检查 (看请求频率,来源 IP,历史行为).
两路信息合在一起才能做出准确判断,跟 WAF 的检测逻辑一样.
检测 Pipeline:从消息到动作
把所有组件串成一条完整的处理流水线:
新消息进入 │ ▼ 1. 预处理 ├── 分词(jieba / 字级别) ├── 去除无意义字符 └── 构建上下文窗口 │ ▼ 2. 特征提取 ├── 文本编码(词ID序列 → 模型输入) └── 对话级特征(频率,速度,密度) │ ▼ 3. 模型推理 ├── TextCNN/LSTM → 对骂概率 └── 融合对话特征 → 最终概率 │ ▼ 4. 后处理 ├── 阈值判断(概率 > 0.7 → 高置信度) ├── 规则兜底(命中脏话词表 → 强制标记) └── 上下文确认(连续 2 条高概率 → 升级处置) │ ▼ 5. 执行动作 ├── 概率 > 0.9 → 直接禁言 + 通知群主 ├── 0.7-0.9 → 发送警告消息 └── 0.5-0.7 → 记录日志,不干预
后处理:弥补模型的不足
模型输出概率后,后处理层用规则补充模型覆盖不到的情况:
def post_process (msg, model_prob, context_probs ): """后处理:结合规则和上下文做最终判断""" if hit_abuse_keyword(msg["text" ]): return max (model_prob, 0.85 ) recent_abuse = sum (1 for p in context_probs if p > 0.6 ) if recent_abuse >= 2 : return min (model_prob * 1.3 , 1.0 ) if msg["text" ].startswith(("我真是" , "我好" )) and model_prob < 0.8 : return model_prob * 0.5 return model_prob
规则和模型的边界
后处理规则是补丁,不是主力. 规则太多会让系统难以维护且脆弱.
原则:模型负责 90% 的判断,规则只处理"模型系统性犯错"的 case.
每加一条规则,要问:能不能靠加标注数据让模型自己学会?
真实数据的挑战
训练集通常是干净的标注数据. 真实群聊消息要混乱得多:
噪声类型
噪声
例子
影响
错别字/谐音
"你是不是沙比","nǐ shǎ ma"
词表命中率下降
表情包/图片
[图片消息]
纯文本模型无法处理
方言/黑话
"格老子","你个瓜皮"
词向量无法覆盖
混合语言
"你真 trash","bruh 别装了"
中英混合分词困难
撤回消息
消息被撤回,上下文断裂
上下文窗口不完整
多话题交叉
A 和 B 在吵架,C 在聊别的
窗口内混入无关消息
应对策略
def preprocess_real_message (text ): """真实消息预处理:处理常见噪声""" text = opencc.convert(text, "t2s" ) replacements = { "沙比" : "傻逼" , "沙b" : "傻逼" , "sha bi" : "傻逼" , "nmsl" : "你妈死了" , "nmsle" : "你妈死了" , "nt" : "脑瘫" , "nc" : "脑残" , } for variant, canonical in replacements.items(): text = text.lower().replace(variant, canonical) if text.startswith("[" ) and text.endswith("]" ): return None return text[:256 ]
变体映射表的维护成本
谐音梗和缩写每天都在变化,人工维护映射表是无底洞.
这张表只覆盖最高频的 20-30 个变体,做 80/20 就够了.
剩下的长尾变体等阶段三用 BERT 解决--BERT 的子词切分天然能处理一部分未见过的变体.
评估:上线前的质量把关
离线评估
在测试集上看整体指标:
print (classification_report(y_true, y_pred, target_names=labels))from sklearn.metrics import confusion_matrix cm = confusion_matrix(y_true, y_pred)print (f"漏报(FN): {cm[1 ][0 ]} 条" )print (f"误报(FP): {cm[0 ][1 ]} 条" )
分层评估:按子类型看
整体 F1 = 0.85 不代表每种对骂都检测得好. 分层看:
子类型
样本数
Precision
Recall
F1
显式脏话
150
0.95
0.97
0.96
阴阳怪气
80
0.72
0.58
0.64
人身攻击
60
0.81
0.75
0.78
引战带节奏
40
0.65
0.45
0.53
显式脏话检测已经很好,但阴阳怪气和引战的 Recall 明显偏低--这些需要更强的语义理解能力.
上线标准参考
显式对骂 F1 > 0.90 → 基本可用,少量漏报可接受.
隐晦对骂 F1 > 0.70 → 及格线,低于此需要更多标注数据或更强模型.
误报率 < 5% → 正常用户很少被误伤,体验可接受.
线上评估:灰度上线
模型离线效果好不等于线上效果好. 灰度策略:
影子模式(shadow) :模型在线上跑,但只记录判断结果,不执行动作. 人工抽查 100-200 条,统计真实 P/R
低阈值试运行 :只对概率 > 0.95 的执行禁言(高 Precision),其余只发警告. 观察投诉量
逐步放开 :阈值从 0.95 逐步降到 0.80,每降一步观察误报增量
THRESHOLDS = { "shadow" : {"action" : "log_only" , "min_prob" : 0.0 }, "cautious" : {"action" : "warn" , "min_prob" : 0.95 }, "normal" : {"action" : "mute_5min" , "min_prob" : 0.85 }, "aggressive" : {"action" : "mute_30min" , "min_prob" : 0.70 }, }
阶段二小结与下一步
到这里,一个"基本能用"的群聊对骂检测系统搭起来了:任务定义(第 05 篇) → 标注数据(第 06 篇) → 传统 baseline(第 07 篇) → 深度模型(第 08 篇) → 上下文 pipeline + 上线流程(本篇).
但分层评估表暴露了瓶颈:隐晦对骂和引战的 Recall 还不够高. 这些场景需要更强的语义理解能力--阶段三会引入 BERT 预训练模型,它在大规模语料上学过"什么是语言",微调后能大幅提升隐晦表达的识别率.
快速回顾
单条分类不够 :群聊对骂依赖上下文,脱离语境的判断错误率高
多标签建模 :一条消息可以同时是对骂 + 人身攻击 + 地域歧视,用 sigmoid + BCELoss
上下文窗口 :前后各 2 条消息拼接输入,[SEP] 分隔,显著减少误判
对话级特征 :回复频率,速度,@次数等元数据拼接到文本特征中
完整 pipeline :预处理 → 特征提取 → 模型推理 → 后处理规则 → 执行动作
灰度上线 :shadow → 高阈值 → 逐步放开,每步都验证误报率
下一步 :BERT 微调,解决隐晦表达的语义理解瓶颈
动手练习
实现上下文窗口 :写 build_context_window() 函数,把单条消息扩展为窗口输入,用 [SEP] 拼接
提取对话特征 :实现 extract_dialog_features(),提取至少 4 个对话级特征,在 baseline 上看 F1 变化
搭建 HybridClassifier :把 TextCNN 和对话特征融合,对比纯文本模型的 F1
实现后处理规则 :写 3 条以上的后处理规则(脏话词表,上下文升级,自嘲降级),测量规则对 P/R 的影响
做分层评估 :按对骂子类型分别计算 F1,找出最薄弱的子类型,分析原因
模拟灰度上线 :设置不同阈值(0.7 / 0.8 / 0.9 / 0.95),分别计算 Precision 和 Recall,画出 P-R 曲线