阶段二 · 文本分类与情感分析

情感分析实战:群聊对骂检测

一句话总结

单条消息分类只是起点,群聊对骂检测需要上下文窗口,对话级特征,和一条完整的 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 分别输出概率.

# 多标签分类:sigmoid + BCEWithLogitsLoss
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) # 不加 softmax,由 loss 函数处理

# 每个标签独立判断是否命中
criterion = nn.BCEWithLogitsLoss() # 内含 sigmoid
# 标签格式: [1, 1, 0, 0, 1] → 对骂✓ 人身攻击✓ 带节奏✗ 性别歧视✗ 地域歧视✓

多标签的标注成本

多标签需要标注员对每条消息判断所有标签维度,标注时间约为二分类的 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)

# 示例输出:
# "用户B: 怎么了? [SEP] 用户A: 一堆 bug 还好意思提测 [SEP] [CUR] 用户B: 你行你来"

窗口大小选择

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 # TextCNN 或 LSTM
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) # 128 维
dialog_vec = torch.relu(self.dialog_fc(dialog_features)) # 16 维
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):
"""后处理:结合规则和上下文做最终判断"""

# 规则 1:脏话词表强制命中
if hit_abuse_keyword(msg["text"]):
return max(model_prob, 0.85)

# 规则 2:上下文升级--前后消息也被判为对骂,置信度提升
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) # 上下文加持,但不超过 1

# 规则 3:自嘲降级--主语是"我"的负面表达
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):
"""真实消息预处理:处理常见噪声"""

# 1. 繁简转换
text = opencc.convert(text, "t2s")

# 2. 谐音/变体还原(维护映射表)
replacements = {
"沙比": "傻逼", "沙b": "傻逼", "sha bi": "傻逼",
"nmsl": "你妈死了", "nmsle": "你妈死了",
"nt": "脑瘫", "nc": "脑残",
}
for variant, canonical in replacements.items():
text = text.lower().replace(variant, canonical)

# 3. 过滤纯表情/图片消息(返回空,跳过分类)
if text.startswith("[") and text.endswith("]"):
return None

# 4. 截断过长消息
return text[:256]

变体映射表的维护成本

谐音梗和缩写每天都在变化,人工维护映射表是无底洞.
这张表只覆盖最高频的 20-30 个变体,做 80/20 就够了.
剩下的长尾变体等阶段三用 BERT 解决--BERT 的子词切分天然能处理一部分未见过的变体.

评估:上线前的质量把关

离线评估

在测试集上看整体指标:

# 按类别看 F1
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% → 正常用户很少被误伤,体验可接受.

线上评估:灰度上线

模型离线效果好不等于线上效果好. 灰度策略:

  1. 影子模式(shadow):模型在线上跑,但只记录判断结果,不执行动作. 人工抽查 100-200 条,统计真实 P/R
  2. 低阈值试运行:只对概率 > 0.95 的执行禁言(高 Precision),其余只发警告. 观察投诉量
  3. 逐步放开:阈值从 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 微调,解决隐晦表达的语义理解瓶颈

动手练习

  1. 实现上下文窗口:写 build_context_window() 函数,把单条消息扩展为窗口输入,用 [SEP] 拼接
  2. 提取对话特征:实现 extract_dialog_features(),提取至少 4 个对话级特征,在 baseline 上看 F1 变化
  3. 搭建 HybridClassifier:把 TextCNN 和对话特征融合,对比纯文本模型的 F1
  4. 实现后处理规则:写 3 条以上的后处理规则(脏话词表,上下文升级,自嘲降级),测量规则对 P/R 的影响
  5. 做分层评估:按对骂子类型分别计算 F1,找出最薄弱的子类型,分析原因
  6. 模拟灰度上线:设置不同阈值(0.7 / 0.8 / 0.9 / 0.95),分别计算 Precision 和 Recall,画出 P-R 曲线