查看全课大纲(第 61 / 69 节)
  1. 1.NLP 的研究领域、应用与学习路径
  2. 2.自然语言的基本术语
  3. 3.信息论基础:信息熵与交叉熵
  4. 4.语言的进化与语义变迁
  5. 5.主观概率、客观概率与条件概率
  6. 6.贝叶斯原理与推理
  7. 7.随机变量:二项式、期望方差与联合概率
  8. 8.常见概率分布
  9. 9.辛普森悖论与贝叶斯解题实例
  10. 10.语言模型:语言概率与词袋模型
  11. 11.n-gram 语言模型(二元 / 三元 / N-Gram)
  12. 12.语言模型评价:交叉熵、困惑度与插值
  13. 13.朴素贝叶斯文本分类:垃圾邮件
  14. 14.拼写检查
  15. 15.中文分词:构词法、最大匹配、最优路径与 Jieba
  16. 16.词表示问题与语义相似度
  17. 17.TF-IDF 权重处理
  18. 18.One-Hot 表达与词表示的局限
  19. 19.Word2Vec 核心思想
  20. 20.Word2Vec 训练过程
  21. 21.Skip-gram 模型详解
  22. 22.词性标注任务与标注方法(规则 + N-Gram)
  23. 23.EM 算法与混合模型
  24. 24.高斯混合模型 GMM
  25. 25.从混合模型到隐马尔可夫模型
  26. 26.隐马尔可夫模型(HMM)推导与维特比解码
  27. 27.线性链条件随机场 CRF
  28. 28.贝叶斯网络与图模型
  29. 29.隐马尔可夫链与 HMM 推导
  30. 30.PLSA 主题模型
  31. 31.LSI 与 SVD 向量表达
  32. 32.LDA 与狄利克雷分布
  33. 33.主题模型实验复现(困惑度 / 分类 / 相似度 / 延展)
  34. 34.上下文无关文法(CFG)与句法分析
  35. 35.概率上下文无关文法 PCFG
  36. 36.统计机器翻译:词对齐与翻译模型
  37. 37.统计机器翻译中的对齐模型与参数估计
  38. 38.从统计机器翻译到神经机器翻译
  39. 39.神经元与反向传播速览(指向深度学习课)
  40. 40.CNN 原理及其在 NLP 中的应用
  41. 41.循环神经网络的基本原理与 Elman / Jordan 网络
  42. 42.LSTM:长短期记忆网络的核心思想与分步实现
  43. 43.Encoder-Decoder 框架
  44. 44.Seq2Seq 与早期注意力机制
  45. 45.注意力机制的背景与动机
  46. 46.注意力模型实现原理与匹配函数
  47. 47.记忆网络:组成与计算
  48. 48.注意力机制的应用与历史延展
  49. 49.Transformer 结构:Encoder-Decoder 栈与 Self-Attention
  50. 50.Multi-Head Attention、FFNN 与位置编码
  51. 51.层归一化、损失函数与 Universal Transformer 延展
  52. 52.预训练范式:从 ELMo 到 BERT
  53. 53.BERT 与掩码语言模型
  54. 54.BERT 下游任务微调与 GLUE 口径
  55. 55.GPT 与自回归语言建模
  56. 56.GPT-1 到 GPT-4 的演进
  57. 57.指令微调与 RLHF
  58. 58.分词器:BPE、WordPiece 与中文词表
  59. 59.上下文窗口与长文本处理
  60. 60.能力边界、成本与适用场景
  61. 61.文本分类实战:从朴素贝叶斯到逻辑回归
  62. 62.命名实体识别实战
  63. 63.文本生成与摘要实战
  64. 64.语义检索与向量库实战
  65. 65.RAG 知识库问答
  66. 66.NLP 评估指标:BLEU / ROUGE / BERTScore 与人工评估
  67. 67.幻觉与事实性问题
  68. 68.文本与知识结合(知识图谱压缩)
  69. 69.跨媒体信息检索与未来展望

文本分类实战:从朴素贝叶斯到逻辑回归

小象实战讲义 · 自然语言处理

文本分类是自然语言处理最基础、应用最广泛的任务之一,从垃圾邮件过滤、情感分析到工单自动分派,其核心都是将一段文本归入预定义的类别。本节我们将通过一个完整的实战微例,亲手实现两个经典的文本分类模型——多项式朴素贝叶斯与逻辑回归,并深入探讨从数据标注、特征工程到模型评估、阈值调优的完整实战流程。学完本节,你将掌握构建一个可靠文本分类系统的核心方法论,并理解不同模型在小样本场景下的行为差异。

💡 核心导读

  • 实战起点是标注:类目定义的可判定性决定了标注一致性,而标注一致性是模型性能的上限。
  • 两个经典模型对照:实现多项式朴素贝叶斯(带拉普拉斯平滑)与逻辑回归(批梯度下降),观察它们在小样本、高维稀疏特征下的表现。
  • 评估不止于准确率:深入理解类别不均衡下的宏平均F1、训练/验证/测试集的正确划分、以及业务代价驱动的阈值调优。
  • 特征工程与模型选择:对比字符级n-gram特征的不同表示(计数 vs. 二值(布尔)特征),理解特征独立假设被违反时模型的行为。
  • 从微例到真实项目:提炼出项目中“不变”的评估纪律与“会变”的技术栈,建立可迁移的实战框架。

一、问题定义与数据准备

我们模拟一个工单标题分类场景:将用户提交的短文本标题自动分类为“咨询”或“投诉”。这是典型的二分类问题。首先,我们必须明确类目定义:

  • 咨询:用户询问产品功能、价格、使用方法、政策条款等,核心意图是获取信息。
  • 投诉:用户表达对产品、服务或人员的不满,核心意图是申诉问题并要求解决。

定义必须可判定,例如“涉及‘怎么’、‘多少钱’、‘能否’等疑问词且无负面情绪词归为咨询;包含‘差’、‘慢’、‘坏’、‘不满意’等负面词且表达诉求归为投诉”。清晰的界定是后续标注一致性的基础。

接下来,我们在代码块内硬编码一个极小的数据集,包含12条标题及其人工标注的标签(0代表“咨询”,1代表“投诉”)。这个规模足以演示核心流程,且结果可手算验证。

import numpy as np

# 硬编码数据:12条工单标题,标签 0=咨询, 1=投诉
texts = [
    "怎么开通会员",           # 0
    "会员价格是多少",         # 0
    "退款流程怎么操作",       # 0
    "客服态度差",            # 1
    "物流太慢了",            # 1
    "商品质量有问题",         # 1
    "如何修改收货地址",       # 0
    "优惠券如何使用",         # 0
    "订单一直不发货",         # 1
    "app经常闪退",           # 1
    "会员续费有优惠吗",       # 0
    "收到的商品是坏的",       # 1
]
labels = np.array([0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1])

print("数据集概览:")
for i, (t, l) in enumerate(zip(texts, labels)):
    print(f"{i:2d}: [{l}] {t}")
print(f"\n类别分布: 咨询({np.sum(labels==0)}条), 投诉({np.sum(labels==1)}条)")
数据集概览:
 0: [0] 怎么开通会员
 1: [0] 会员价格是多少
 2: [0] 退款流程怎么操作
 3: [1] 客服态度差
 4: [1] 物流太慢了
 5: [1] 商品质量有问题
 6: [0] 如何修改收货地址
 7: [0] 优惠券如何使用
 8: [1] 订单一直不发货
 9: [1] app经常闪退
10: [0] 会员续费有优惠吗
11: [1] 收到的商品是坏的

类别分布: 咨询(6条), 投诉(6条)

二、特征工程:字符级Bigram词袋

对于短文本,字符级bigram(相邻两个字符)能较好地捕捉中文的局部组合信息,且比分词更稳定。我们将文本转化为词袋(Bag-of-Words)向量。这里实现两个版本:

  1. 计数特征:直接统计每个bigram出现的次数,适用于朴素贝叶斯。
  2. 二值特征:只记录bigram是否出现(0或1),有时对逻辑回归更稳定。
import numpy as np

texts = [
    "怎么开通会员", "会员价格是多少", "退款流程怎么操作", "客服态度差",
    "物流太慢了", "商品质量有问题", "如何修改收货地址", "优惠券如何使用",
    "订单一直不发货", "app经常闪退", "会员续费有优惠吗", "收到的商品是坏的",
]
labels = np.array([0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1])

def build_char_bigram_vocab(text_list):
    vocab = {}
    for text in text_list:
        chars = list(text)
        for i in range(len(chars) - 1):
            bigram = chars[i] + chars[i + 1]
            if bigram not in vocab:
                vocab[bigram] = len(vocab)
    return vocab

def texts_to_count_matrix(text_list, vocab):
    X = np.zeros((len(text_list), len(vocab)), dtype=int)
    for i, text in enumerate(text_list):
        chars = list(text)
        for j in range(len(chars) - 1):
            bigram = chars[j] + chars[j + 1]
            if bigram in vocab:
                X[i, vocab[bigram]] += 1
    return X

def texts_to_binary_matrix(text_list, vocab):
    X = np.zeros((len(text_list), len(vocab)), dtype=int)
    for i, text in enumerate(text_list):
        chars = list(text)
        for j in range(len(chars) - 1):
            bigram = chars[j] + chars[j + 1]
            if bigram in vocab:
                X[i, vocab[bigram]] = 1
    return X

vocab = build_char_bigram_vocab(texts)
X_count = texts_to_count_matrix(texts, vocab)
X_binary = texts_to_binary_matrix(texts, vocab)

print(f"特征词典大小(唯一bigram数): {len(vocab)}")
print("前10个bigram特征:", sorted(vocab.keys())[:10])
nz = [(b, i, int(X_count[0, i])) for b, i in vocab.items() if X_count[0, i] > 0]
nz.sort(key=lambda x: x[1])
print("\n第一条样本的计数特征向量(非零部分):")
total = 0
for b, i, c in nz:
    print(f"  '{b}'(索引{i}): 计数={c}")
    total += c
print(f"  合计 token 数: {total}")
print(f"\n特征矩阵形状: 计数版 {X_count.shape}, 二值版 {X_binary.shape}")
特征词典大小(唯一bigram数): 65
前10个bigram特征: ['ap', 'pp', 'p经', '一直', '不发', '么开', '么操', '价格', '优惠', '会员']

第一条样本的计数特征向量(非零部分):
  '怎么'(索引0): 计数=1
  '么开'(索引1): 计数=1
  '开通'(索引2): 计数=1
  '通会'(索引3): 计数=1
  '会员'(索引4): 计数=1
  合计 token 数: 5

特征矩阵形状: 计数版 (12, 65), 二值版 (12, 65)

三、模型A:多项式朴素贝叶斯(Multinomial Naive Bayes)

朴素贝叶斯基于贝叶斯定理与特征条件独立性假设。对于多项式模型,我们使用计数特征 XcountX^{\text{count}}。给定类别 yy,特征 xix_i(此处为bigram)的似然服从多项式分布。引入拉普拉斯平滑(参数 α=1\alpha=1)避免零概率问题。

预测时计算对数后验概率以避免下溢: log⁡P(y∣x)∝log⁡P(y)+∑i=1dxi⋅log⁡P(xi∣y)\log P(y | \mathbf{x}) \propto \log P(y) + \sum_{i=1}^{d} x_i \cdot \log P(x_i | y) 其中 P(y)P(y) 为类先验,P(xi∣y)P(x_i | y) 为条件概率。

import numpy as np

texts = [
    "怎么开通会员", "会员价格是多少", "退款流程怎么操作", "客服态度差",
    "物流太慢了", "商品质量有问题", "如何修改收货地址", "优惠券如何使用",
    "订单一直不发货", "app经常闪退", "会员续费有优惠吗", "收到的商品是坏的",
]
labels = np.array([0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1])

def build_vocab(text_list):
    v = {}
    for t in text_list:
        cs = list(t)
        for i in range(len(cs) - 1):
            bg = cs[i] + cs[i + 1]
            v.setdefault(bg, len(v))
    return v

def to_matrix(text_list, vocab, binary=False):
    X = np.zeros((len(text_list), len(vocab)), dtype=int)
    for i, t in enumerate(text_list):
        cs = list(t)
        for j in range(len(cs) - 1):
            bg = cs[j] + cs[j + 1]
            if bg in vocab:
                X[i, vocab[bg]] = 1 if binary else X[i, vocab[bg]] + 1
    return X

vocab = build_vocab(texts)
X = to_matrix(texts, vocab)

alpha = 1.0
classes = [0, 1]
n_samples, n_features = X.shape
class_logprior = np.array([np.log(np.sum(labels == c) / n_samples) for c in classes])
logp = np.zeros((len(classes), n_features))
for k, c in enumerate(classes):
    Nc_i = X[labels == c].sum(axis=0)
    Nc = Nc_i.sum()
    logp[k] = np.log((Nc_i + alpha) / (Nc + alpha * n_features))

scores = class_logprior[None, :] + X @ logp.T
pred = np.array(classes)[scores.argmax(axis=1)]

def metrics(y_true, y_pred, cs):
    out = {}
    for c in cs:
        tp = int(np.sum((y_true == c) & (y_pred == c)))
        fp = int(np.sum((y_true != c) & (y_pred == c)))
        fn = int(np.sum((y_true == c) & (y_pred != c)))
        p = tp / (tp + fp) if tp + fp else 0.0
        r = tp / (tp + fn) if tp + fn else 0.0
        f = 2 * p * r / (p + r) if p + r else 0.0
        out[c] = (p, r, f, tp, fp, fn)
    return out

acc = float(np.mean(pred == labels))
m = metrics(labels, pred, classes)
macro_f1 = np.mean([m[c][2] for c in classes])

print("多项式朴素贝叶斯(alpha=1.0,训练集):")
print(f"准确率: {acc:.4f}")
print(f"宏平均F1: {macro_f1:.4f}")
for c in classes:
    p, r, f, tp, fp, fn = m[c]
    name = "咨询" if c == 0 else "投诉"
    print(f"  {name}: P={p:.4f} R={r:.4f} F1={f:.4f} (TP={tp} FP={fp} FN={fn})")
print("预测标签:", pred.tolist())

# 首样本的判别分数(可手算复核)
print("\n样本0《怎么开通会员》的两类对数分数:")
for k, c in enumerate(classes):
    name = "咨询" if c == 0 else "投诉"
    print(f"  {name}: log先验={class_logprior[k]:.4f} + 似然和={(X[0] @ logp[k]):.4f} = {scores[0, k]:.4f}")
print(f"  判别: {'咨询' if scores[0, 0] > scores[0, 1] else '投诉'}")
多项式朴素贝叶斯(alpha=1.0,训练集):
准确率: 1.0000
宏平均F1: 1.0000
  咨询: P=1.0000 R=1.0000 F1=1.0000 (TP=6 FP=0 FN=0)
  投诉: P=1.0000 R=1.0000 F1=1.0000 (TP=6 FP=0 FN=0)
预测标签: [0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1]

样本0《怎么开通会员》的两类对数分数:
  咨询: log先验=-0.6931 + 似然和=-18.6093 = -19.3024
  投诉: log先验=-0.6931 + 似然和=-22.9248 = -23.6180
  判别: 咨询

手算验证点:以第一条样本“怎么开通会员”为例,其特征为‘怎么’、‘么开’、‘开通’、‘通会’、‘会员’。我们可以近似验证:在“咨询”类中,这些bigram的对数概率之和加上类先验对数,应大于在“投诉”类中的对应和。由于数据完全可分,模型在训练集上达到了100%准确率。

四、模型B:逻辑回归(Logistic Regression)

逻辑回归直接对条件概率 P(y=1∣x)P(y=1 | \mathbf{x}) 建模,使用sigmoid函数: P(y=1∣x)=σ(wTx+b)=11+e−(wTx+b)P(y=1 | \mathbf{x}) = \sigma(\mathbf{w}^T \mathbf{x} + b) = \frac{1}{1 + e^{-(\mathbf{w}^T \mathbf{x} + b)}} 我们使用批梯度下降最小化交叉熵损失函数,迭代 200 步、学习率 0.1。这里如实说明一个取舍:文本的二值特征本身尺度就只有 0 和 1,本例不做标准化,直接拿原始二值特征训——这样每一步的权重变化读者可以手工复核。真实项目里若特征尺度差异大(比如把”文本长度”和”某个 bigram 是否出现”混在一起),才必须先标准化,否则梯度会被大量纲特征主导。

import numpy as np

texts = [
    "怎么开通会员", "会员价格是多少", "退款流程怎么操作", "客服态度差",
    "物流太慢了", "商品质量有问题", "如何修改收货地址", "优惠券如何使用",
    "订单一直不发货", "app经常闪退", "会员续费有优惠吗", "收到的商品是坏的",
]
labels = np.array([0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1])

def build_vocab(text_list):
    v = {}
    for t in text_list:
        cs = list(t)
        for i in range(len(cs) - 1):
            v.setdefault(cs[i] + cs[i + 1], len(v))
    return v

def to_binary(text_list, vocab):
    X = np.zeros((len(text_list), len(vocab)), dtype=float)
    for i, t in enumerate(text_list):
        cs = list(t)
        for j in range(len(cs) - 1):
            bg = cs[j] + cs[j + 1]
            if bg in vocab:
                X[i, vocab[bg]] = 1.0
    return X

vocab = build_vocab(texts)
X = to_binary(texts, vocab)
y = labels.astype(float)

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))

lr, n_iter = 0.1, 200
w, b = np.zeros(X.shape[1]), 0.0
loss_hist = []
for it in range(n_iter):
    p = sigmoid(X @ w + b)
    pc = np.clip(p, 1e-15, 1 - 1e-15)
    loss = -np.mean(y * np.log(pc) + (1 - y) * np.log(1 - pc))
    loss_hist.append(loss)
    w -= lr * (X.T @ (p - y) / len(y))
    b -= lr * np.mean(p - y)

print(f"逻辑回归(lr={lr}, 迭代={n_iter}, 二值特征, d={X.shape[1]}):")
print("损失轨迹:")
for k in [0, 1, 2, 3, 4, 49, 99, 149, 198, 199]:
    print(f"  第{k + 1:>3}步: loss = {loss_hist[k]:.4f}")

probes = ["会员商品退款", "会员怎么退款"]
probe_X = to_binary(probes, vocab)
probe_p = sigmoid(probe_X @ w + b)
print("\n两条边界样本的概率(含“会员”这个咨询侧特征,也含投诉侧词汇):")
for name, pv in zip(probes, probe_p):
    print(f"  《{name}》: P(投诉) = {pv:.4f}")
    for th in (0.3, 0.5, 0.7):
        print(f"    阈值 {th:.1f} → {'投诉' if pv >= th else '咨询'}")

w_sorted = np.argsort(-np.abs(w))[:5]
key_by_idx = {v: k for k, v in vocab.items()}
print("\n权重绝对值最大的5个bigram:")
for idx in w_sorted:
    print(f"  '{key_by_idx[idx]}': w = {w[idx]:.4f}")
print(f"偏置 b = {b:.4f}")
逻辑回归(lr=0.1, 迭代=200, 二值特征, d=65):
损失轨迹:
  第  1步: loss = 0.6931
  第  2步: loss = 0.6785
  第  3步: loss = 0.6643
  第  4步: loss = 0.6506
  第  5步: loss = 0.6372
  第 50步: loss = 0.3052
  第100步: loss = 0.1811
  第150步: loss = 0.1259
  第199步: loss = 0.0961
  第200步: loss = 0.0956

两条边界样本的概率(含“会员”这个咨询侧特征,也含投诉侧词汇):
  《会员商品退款》: P(投诉) = 0.3996
    阈值 0.3 → 投诉
    阈值 0.5 → 咨询
    阈值 0.7 → 咨询
  《会员怎么退款》: P(投诉) = 0.1579
    阈值 0.3 → 咨询
    阈值 0.5 → 咨询
    阈值 0.7 → 咨询

权重绝对值最大的5个bigram:
  '会员': w = -0.9318
  '怎么': w = -0.6527
  '如何': w = -0.6527
  '商品': w = 0.6138
  '优惠': w = -0.6039
偏置 b = 0.2355

这份输出要连着看三处:

  1. loss 从 0.6931 降到 0.0956:0.6931 就是 ln2,也就是”两眼一抹黑、两类各一半”的起点;200 步后已经明显分开,但没到 0(数据虽可分,梯度下降是渐近逼近)。
  2. 权重的正负就是”词的立场”:会员、怎么、如何、优惠 都是负权重(推向”咨询”),商品 是正权重(推向”投诉”)。这不是人工指定的,是数据推出来的——和上一节 BPE 用频次长出子词是同一个道理。
  3. 阈值真的会改变结论:《会员商品退款》的 P(投诉)=0.3996,夹在 0.3 和 0.5 之间,所以阈值取 0.3 判投诉、取 0.5 判咨询。同一句话、同一个模型、只改一个数字,业务结论就变了——这就是下一节要讲的”阈值按代价调”,不是调参洁癖。

五、实战要点剖析与逻辑映射

1. 标注口径先于模型

我们的微例假设标注完全一致。真实项目中,必须为每个类目撰写可执行的判定规则,并进行抽样双标计算一致率(如Cohen‘s Kappa)。标注一致性是模型性能的上限,不一致的标注会导致模型学习到噪声。

2. 类别不均衡与评估指标

本例两类样本均衡(6:6)。若不均衡(如咨询:投诉 = 9:1),准确率会被多数类绑架(即使全预测为咨询也有90%准确率)。此时必须同时看每类的精确率、召回率、F1,并以宏平均F1(Macro-F1,对各类F1取平均)作为核心指标,因为它平等看待每个类别,不受样本量影响。Micro-F1会被大类别主导,在此场景不适用。

3. 划分与泄漏

本例未划分训练/测试集,仅为演示。真实项目必须严格分离。关键原则:同一来源的近似重复样本(如同一用户短时间内提交的相同工单)不能分散到训练集和测试集,否则会因数据泄漏导致分数虚高。小数据建议使用分层K折交叉验证,报告均值与标准差。

4. 特征与归一化

我们对比了字符 bigram 的计数特征(用于 NB)和二值特征(用于 LR)。NB 的似然模型假设特征之间条件独立,用的是计数;LR 没有这个假设,靠权重把共现关系学进去。本例刻意没做标准化:二值特征尺度一致,标准化只是把 0/1 换成两个对称值,换来的是每步更新量翻倍、读者不好手算。真实项目里如果混入长度类、计数类特征,标准化就必须做;文本场景更常见的是 TF-IDF 加权,把高频常见词的权重压下去。

5. 阈值与业务代价

默认阈值0.5假设“漏检投诉”和“误判投诉”代价相同。现实中,漏检投诉(本该是投诉却判为咨询)可能导致用户不满升级,代价更高。我们可以通过调整阈值来权衡:

  • 降低阈值(如0.3):更多样本被预测为投诉,召回率上升(找到更多真实投诉),但精确率下降(混入更多咨询)。
  • 提高阈值(如0.7):相反,精确率上升,召回率下降。

调整依据应是业务代价矩阵。例如,若漏检投诉的代价是误判投诉的5倍,则应倾向于选择高召回率的阈值。

6. 上线后的监控与漂移

模型上线后,语言会随时间变化(新词、新话术)。监控需分两层:

  • 输入分布监控:词表外(OOV)比例、文本长度分布、高频词变化。
  • 输出分布监控:预测类别占比的偏移、预测置信度的分布变化(如原本高置信度的样本比例下降)。

若线上指标掉点,应先检查输入数据是否发生漂移,再考虑重新训练模型。

逻辑映射:从微例到真实项目

  • 不变的核心纪律:

    1. 评估口径:严格的训练/验证/测试分离,报告每类指标与宏平均F1。
    2. 标注决定上限:清晰的类目定义与标注一致性校验。
    3. 阈值按业务代价调整,而非默认0.5。
  • 会变的技术要素:

    1. 数据规模:从十几条到上万条 → 需用交叉验证替代手算,并可能引入主动学习策略来高效挑选标注样本。
    2. 模型选择:从NB/LR到预训练模型(如BERT)微调 → 特征工程消失,模型能力大幅提升,但标注与评估的纪律不变。
    3. 类别数量:从2类到几十类长尾分布 → 需要分层抽样确保每类有足够样本,并关注尾部类别的F1。
    4. 服务模式:从离线批处理到在线实时服务 → 需考虑延迟预算、模型版本管理、A/B测试与灰度发布机制。

一句话结论:换模型、换数据规模,改变的是模型性能“上限的斜率”;而不变的是“评估与标注的地基”,它决定了你的项目能否可靠落地。

📝 动手练一练

  1. 概念辨析:在类别不均衡的场景下(例如90%咨询,10%投诉),为什么准确率(Accuracy)不是一个可靠的评估指标?宏平均F1(Macro-F1)为什么更适合?
👉 点击查看参考答案

准确率计算的是正确预测的样本占总样本的比例。在极度不均衡的场景下(如90%咨询,10%投诉),一个简单的“将所有样本预测为咨询”的模型,就能获得90%的准确率,但这显然没有识别出任何投诉样本,模型是无效的。准确率被多数类“绑架”了。

宏平均F1先为每个类别单独计算F1分数(综合考虑了该类别的精确率和召回率),然后对所有类别的F1分数取算术平均值。这意味着每个类别无论样本多少,对最终指标的贡献是平等的。因此,即使投诉类只占10%,它的F1分数仍然被同等看待,模型必须努力识别出少数类才能获得高的宏平均F1。这使其成为不均衡分类任务中更合适的评估指标。

  1. 手算验证:根据我们训练的朴素贝叶斯模型,对于bigram特征‘客服’(假设它在词典中的索引是idx),请写出计算 P(‘客服’∣y=投诉)P(\text{‘客服’} | y=\text{投诉}) 和 P(‘客服’∣y=咨询)P(\text{‘客服’} | y=\text{咨询}) 的公式(包含拉普拉斯平滑 α=1\alpha=1),并定性比较哪个类别的条件概率会更高?为什么?
👉 点击查看参考答案

计算公式(基于计数特征): P(‘客服’∣y=c)=Nc,‘客服’+αNc+α×dP(\text{‘客服’} | y=c) = \frac{N_{c,\text{‘客服’}} + \alpha}{N_c + \alpha \times d} 其中:

  • Nc,‘客服’N_{c,\text{‘客服’}} 是类别 cc 中‘客服’这个bigram出现的总次数。
  • NcN_c 是类别 cc 中所有bigram的总次数。
  • dd 是特征总数(本例中为 65,即去重后的字符 bigram 数)。
  • α\alpha 是平滑参数(本例为1.0)。

定性比较:‘客服’这个词出现在标题“客服态度差”中,该标题属于投诉类。因此,在投诉类中 N投诉,‘客服’>0N_{\text{投诉},\text{‘客服’}} > 0,而在咨询类中 N咨询,‘客服’=0N_{\text{咨询},\text{‘客服’}} = 0。代入公式后,分子部分投诉类更大,而分母 NcN_c 在两个类别中相差不会特别悬殊(因为总样本数相同)。因此,P(‘客服’∣y=投诉)P(\text{‘客服’} | y=\text{投诉}) 会显著高于 P(‘客服’∣y=咨询)P(\text{‘客服’} | y=\text{咨询})。这也符合直觉:“客服”一词在与负面情绪相关的投诉中更可能出现。

本章小结

本节通过一个完整的文本分类微例,实战演练了从数据准备、特征工程(字符bigram词袋)到实现两个经典模型(多项式朴素贝叶斯与逻辑回归)的全过程。我们深入探讨了文本分类实战中的六个关键要点:标注口径的决定性作用、不均衡数据的评估指标选择、数据划分与泄漏预防、特征表示与归一化、基于业务代价的阈值调优以及上线后的监控策略。最后,我们提炼了从微例到真实项目的逻辑映射,强调了无论技术栈如何演进,严谨的评估与标注纪律始终是项目成功的基石。

📋 行动清单

  • 亲手运行本节四个自包含代码块(数据、特征、NB、LR),观察两个模型在小数据集上的差异,并改一条数据(比如把”会员价格是多少”硬标成投诉)看指标怎么塌。
  • 设计一个评估方案:假设你有一个1000条、类别比例为85%咨询 vs. 15%投诉的数据集,请描述你将如何划分数据集、选择核心评估指标,并说明理由。
  • 思考业务代价:针对工单分类场景,你认为“漏检投诉”和“误判投诉”哪一项业务代价更高?如果前者代价是后者的3倍,你会倾向于调整分类阈值向哪个方向移动?

延伸阅读

  1. 朴素贝叶斯文本分类的经典论文:Text Classification from Labeled and Unlabeled Documents using EM (Nigam et al., 2000)。该论文深入探讨了EM算法在半监督场景下提升朴素贝叶斯性能的方法。
  2. 逻辑回归与最大熵模型在NLP中的应用综述:A Maximum Entropy Approach to Natural Language Processing (Berger et al., 1996)。最大熵模型是逻辑回归在多分类上的推广,曾是统计NLP的基石模型之一。
  3. 文本分类特征选择方法实证比较:An Extensive Empirical Study of Feature Selection Metrics for Text Classification (Forman, G., Journal of Machine Learning Research 3: 1289–1305, 2003)。其中实证比较了十二种特征选择方法(如卡方检验、互信息等),至今仍有参考价值。

—— 小象教研组