第05回 情感分析实战——用深度网络理解文字
一纸评卷分冷暖,几行数据照真伪。
莫听模型夸海口,先看指标与证据。
上回我们练了“记事”的功夫:RNN、LSTM、GRU 让模型按顺序读词,不再把句子当一袋豆子乱数。
可江湖里,学会招式只是开始。真正上擂台,得讲三件事:
- 数据从哪来,怎么切
- 模型怎么训,怎么防走火入魔
- 评估怎么做,怎么防自欺欺人
本回就以“情感分析”作一场完整演武:从数据到评估,把算法落到一个可跑通的任务上。
一、任务是什么:一句话,判它冷暖
情感分析最经典的擂台是二分类:
- 正面(喜欢、满意、推荐)
- 负面(讨厌、失望、踩雷)
在真实世界里,它常被用于:
- 评价分析:餐馆、商品、电影
- 客服质检:投诉/表扬归类
- 舆情监测:某事件风向变化
2024 之后,LLM 也被大量用于情感/情绪任务,尤其在“零样本/少样本”设置下很方便;相应地,也出现了不少综述工作来梳理 LLM 在情感任务上的方法与局限。1
但本回我们先走“基本功”路线:
用一个小模型,把流程练得扎实。因为你会发现:无论模型多强,数据与评估的规矩不守,一样会翻车。
二、数据怎么来:三条“刀口”,把数据切干净
做机器学习,最怕“考试题泄露”。
把数据切成三份,是江湖铁律:
- 训练集:练功用
- 验证集:调参用
- 测试集:上擂台用(最后才看)
这里有三条“刀口”必须记牢:
- 测试集不碰:模型结构、超参、早停都不许看测试结果
- 同源样本别串门:同一条评论的重复、同一用户的多条相似话术,尽量别被分到不同集合
- 预处理要分开拟合:比如 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}")
这段代码故意保持“像一张白纸”:
你能清楚看到每一步在做什么,也更容易把它改造成真正的项目脚手架。
六、实战心法:五种常见翻车方式
跑通了代码,不代表真赢了。情感分析里最常见的翻车,有五种:
- 数据泄露:测试集被你拿去调参,或者同一用户的重复样本跨集合
- 类别不平衡:负面很少,准确率好看却抓不住差评
- 否定与反讽: “不难吃”“还行吧”“呵呵” 这类需要更细的语义
- 领域迁移:在电影评论上训的,拿去做外卖评价就漂移
- 标注噪声:人类标签也会错,尤其在“阴阳怪气”的文本上
当你遇到这些坑时,工程上常见的解法是:
- 先用更强的基线(TF‑IDF + 线性模型)对比,确认自己到底卡在哪
- 让训练/验证/测试切分更严格(按用户/时间分组)
- 扩大数据、做数据清洗与一致性检查
- 或者直接用更强的预训练模型/LLM 做迁移与微调(但评估规矩仍得守)
七、小结:本回学会“判卷”,下回要学“聚焦”
本回真正要你带走的,不是某个模型结构,而是“可跑通的工程闭环”:
- 数据切分守规矩
- 指标不只看准确率
- 训练要看验证集、要会早停
- 测试集最后才上擂台
下一回,我们将迎来一个改变江湖的招式:注意力(Attention)。
它要解决的,正是情感分析里你刚看到的痛点之一:
句子很长时,模型该把注意力放在哪些词上?
欲知后事如何,且听下回分解。
引用与溯源
Footnotes
-
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 ↩