第05回 情感分析实战——用深度网络理解文字

一纸评卷分冷暖,几行数据照真伪。
莫听模型夸海口,先看指标与证据。

上回我们练了“记事”的功夫:RNN、LSTM、GRU 让模型按顺序读词,不再把句子当一袋豆子乱数。

可江湖里,学会招式只是开始。真正上擂台,得讲三件事:

  • 数据从哪来,怎么切
  • 模型怎么训,怎么防走火入魔
  • 评估怎么做,怎么防自欺欺人

本回就以“情感分析”作一场完整演武:从数据到评估,把算法落到一个可跑通的任务上。


一、任务是什么:一句话,判它冷暖

情感分析最经典的擂台是二分类:

  • 正面(喜欢、满意、推荐)
  • 负面(讨厌、失望、踩雷)

在真实世界里,它常被用于:

  • 评价分析:餐馆、商品、电影
  • 客服质检:投诉/表扬归类
  • 舆情监测:某事件风向变化

2024 之后,LLM 也被大量用于情感/情绪任务,尤其在“零样本/少样本”设置下很方便;相应地,也出现了不少综述工作来梳理 LLM 在情感任务上的方法与局限。1

但本回我们先走“基本功”路线:
用一个小模型,把流程练得扎实。因为你会发现:无论模型多强,数据与评估的规矩不守,一样会翻车。


二、数据怎么来:三条“刀口”,把数据切干净

做机器学习,最怕“考试题泄露”。

把数据切成三份,是江湖铁律:

  • 训练集:练功用
  • 验证集:调参用
  • 测试集:上擂台用(最后才看)

这里有三条“刀口”必须记牢:

  1. 测试集不碰:模型结构、超参、早停都不许看测试结果
  2. 同源样本别串门:同一条评论的重复、同一用户的多条相似话术,尽量别被分到不同集合
  3. 预处理要分开拟合:比如 TF‑IDF 的词表、归一化统计量,只能用训练集拟合,再应用到验证/测试

读到这里,你就能看出上回“玩具数据 acc=1.0”那种演示为什么只能当练拳:
数据太小、太简单,很容易“看着赢了,其实没学会”。


三、指标怎么选:别只看准确率

很多人一上来就报 Accuracy(准确率),可它有陷阱。

若数据极不平衡:1000 条里 950 条是正面。你全猜“正面”,准确率也有 95%,看似厉害,实则胡来。

因此常用四个指标:

  • Accuracy:总体猜对比例
  • Precision(精确率):你说“负面”的里面,有多少真负面
  • Recall(召回率):所有真负面里,你抓住了多少
  • F1:Precision 与 Recall 的折中

再配一个“判卷表”——混淆矩阵(confusion matrix),你就能直观看到:

  • 哪类错得最多
  • 是“错杀”(把好评当差评)还是“漏抓”(把差评当好评)

本回的代码就会把这些指标都算出来。


四、两条路线:传统基线与深度模型

情感分析常见有两条路,像两家门派:

1)基线派:TF‑IDF + 线性分类器

这派的优点是:

  • 训练快
  • 可解释(哪些词权重高,一看便知)
  • 常常是很强的“地基”

缺点是:

  • 不懂顺序
  • 对否定、反转语气敏感(“不太好”这类)

2)深度派:Embedding + LSTM/GRU + 分类头

这派的优点是:

  • 能吃序列,懂顺序
  • 能学到一些组合模式(“不 + 好吃”)

缺点是:

  • 更依赖数据量与训练技巧
  • 更容易过拟合,需要正则化与验证集早停

你会发现:这两派并非你死我活。真实工程里,常常是:

先用基线派跑通流程,再用深度派提升上限。


五、极简代码:完整训练 + 评估(PyTorch 可跑)

下面这段代码完成一整套“从数据到评估”的闭环:

  • 造一个小数据集(你可替换为真实数据)
  • 切 train/val/test
  • 训练一个 LSTM 分类器
  • 在 test 上输出 Accuracy、Precision、Recall、F1 与混淆矩阵
import random
from collections import Counter

import torch
import torch.nn as nn
import torch.nn.functional as F


def split_data(data, ratios=(0.7, 0.15, 0.15), seed=0):
    rnd = random.Random(seed)
    data = data[:]
    rnd.shuffle(data)
    n = len(data)
    n_train = int(n * ratios[0])
    n_val = int(n * ratios[1])
    train = data[:n_train]
    val = data[n_train : n_train + n_val]
    test = data[n_train + n_val :]
    return train, val, test


def build_vocab(samples, min_freq=1):
    counter = Counter()
    for text, _ in samples:
        counter.update(text.split())
    vocab = {"<pad>": 0, "<unk>": 1}
    for w, c in counter.items():
        if c >= min_freq and w not in vocab:
            vocab[w] = len(vocab)
    return vocab


def encode(text, vocab, max_len):
    ids = [vocab.get(w, vocab["<unk>"]) for w in text.split()]
    ids = ids[:max_len]
    ids += [vocab["<pad>"]] * (max_len - len(ids))
    return ids


class LSTMClassifier(nn.Module):
    def __init__(self, vocab_size, dim=64, hidden=96, num_classes=2):
        super().__init__()
        self.emb = nn.Embedding(vocab_size, dim, padding_idx=0)
        self.lstm = nn.LSTM(input_size=dim, hidden_size=hidden, batch_first=True)
        self.fc = nn.Linear(hidden, num_classes)

    def forward(self, x):
        e = self.emb(x)
        out, _ = self.lstm(e)
        last = out[:, -1, :]
        return self.fc(last)


def batchify(samples, vocab, max_len):
    x = torch.tensor([encode(t, vocab, max_len) for t, _ in samples], dtype=torch.long)
    y = torch.tensor([label for _, label in samples], dtype=torch.long)
    return x, y


def metrics_binary(y_true, y_pred):
    # label: 1=pos, 0=neg
    tp = sum((yt == 1 and yp == 1) for yt, yp in zip(y_true, y_pred))
    tn = sum((yt == 0 and yp == 0) for yt, yp in zip(y_true, y_pred))
    fp = sum((yt == 0 and yp == 1) for yt, yp in zip(y_true, y_pred))
    fn = sum((yt == 1 and yp == 0) for yt, yp in zip(y_true, y_pred))

    acc = (tp + tn) / max(1, tp + tn + fp + fn)
    precision = tp / max(1, tp + fp)
    recall = tp / max(1, tp + fn)
    f1 = 0.0 if (precision + recall) == 0 else 2 * precision * recall / (precision + recall)
    return {
        "acc": acc,
        "precision": precision,
        "recall": recall,
        "f1": f1,
        "cm": [[tn, fp], [fn, tp]],
    }


if __name__ == "__main__":
    torch.manual_seed(0)
    random.seed(0)

    # toy data: (text, label)  label: 1=pos, 0=neg
    data = [
        ("好吃 便宜 推荐", 1),
        ("味道 很 棒 还 会 再 来", 1),
        ("服务 也 很 好 满意", 1),
        ("太 难吃 了 不 会 再 来", 0),
        ("踩 雷 了 真 糟糕", 0),
        ("服务 很 差 失望", 0),
        ("不 太 好吃", 0),
        ("还 行 吧 一般", 0),
        ("非常 喜欢 这个 味道", 1),
        ("再 也 不 来 了", 0),
        ("我 很 喜欢 推荐 给 朋友", 1),
        ("贵 而且 难吃", 0),
    ]

    train, val, test = split_data(data, seed=0)
    vocab = build_vocab(train, min_freq=1)

    max_len = 10
    x_train, y_train = batchify(train, vocab, max_len)
    x_val, y_val = batchify(val, vocab, max_len)
    x_test, y_test = batchify(test, vocab, max_len)

    model = LSTMClassifier(vocab_size=len(vocab))
    opt = torch.optim.Adam(model.parameters(), lr=0.03)

    best_val = -1.0
    best_state = None
    patience = 10
    bad = 0

    for step in range(300):
        model.train()
        logits = model(x_train)
        loss = F.cross_entropy(logits, y_train)
        opt.zero_grad()
        loss.backward()
        opt.step()

        if step % 10 == 0:
            model.eval()
            with torch.no_grad():
                val_pred = model(x_val).argmax(dim=1).tolist()
            m = metrics_binary(y_val.tolist(), val_pred)
            if m["acc"] > best_val:
                best_val = m["acc"]
                best_state = {k: v.clone() for k, v in model.state_dict().items()}
                bad = 0
            else:
                bad += 1
                if bad >= patience:
                    break

    if best_state is not None:
        model.load_state_dict(best_state)

    model.eval()
    with torch.no_grad():
        test_pred = model(x_test).argmax(dim=1).tolist()
    m = metrics_binary(y_test.tolist(), test_pred)
    print("test:", {k: (round(v, 3) if isinstance(v, float) else v) for k, v in m.items()})
    print("confusion_matrix [[tn, fp],[fn, tp]] =", m["cm"])
    for (t, label), p in zip(test, test_pred):
        print(f"text={t}  label={label}  pred={p}")

这段代码故意保持“像一张白纸”:
你能清楚看到每一步在做什么,也更容易把它改造成真正的项目脚手架。


六、实战心法:五种常见翻车方式

跑通了代码,不代表真赢了。情感分析里最常见的翻车,有五种:

  1. 数据泄露:测试集被你拿去调参,或者同一用户的重复样本跨集合
  2. 类别不平衡:负面很少,准确率好看却抓不住差评
  3. 否定与反讽: “不难吃”“还行吧”“呵呵” 这类需要更细的语义
  4. 领域迁移:在电影评论上训的,拿去做外卖评价就漂移
  5. 标注噪声:人类标签也会错,尤其在“阴阳怪气”的文本上

当你遇到这些坑时,工程上常见的解法是:

  • 先用更强的基线(TF‑IDF + 线性模型)对比,确认自己到底卡在哪
  • 让训练/验证/测试切分更严格(按用户/时间分组)
  • 扩大数据、做数据清洗与一致性检查
  • 或者直接用更强的预训练模型/LLM 做迁移与微调(但评估规矩仍得守)

七、小结:本回学会“判卷”,下回要学“聚焦”

本回真正要你带走的,不是某个模型结构,而是“可跑通的工程闭环”:

  • 数据切分守规矩
  • 指标不只看准确率
  • 训练要看验证集、要会早停
  • 测试集最后才上擂台

下一回,我们将迎来一个改变江湖的招式:注意力(Attention)
它要解决的,正是情感分析里你刚看到的痛点之一:
句子很长时,模型该把注意力放在哪些词上?

欲知后事如何,且听下回分解。


引用与溯源

Footnotes

  1. Yang, H., et al. Large Language Models Meet Text-Centric Multimodal Sentiment Analysis: A Survey arXiv:2406.08068(2024-06)https://arxiv.org/abs/2406.08068