引子

给一条评论,判断是好评还是差评——就这么简单的一个二分类问题。

但真正动手做的时候,你会发现:

  • 关键词匹配:”这个产品不错”→ 好评,”质量很差”→ 差评 → 碰上”错”就挂了
  • 标点符号:”好吃!”→ 好评,”好吃?”→ 饿了吗?

语言的复杂性,让”简单”的二分类没那么简单。

这篇带你完整走一遍:从最简单的词袋模型,到 LSTM 序列模型,再到 BERT 预训练模型——看看每一代模型到底强在哪。


前置知识


一、数据准备

用 IMDB 电影评论数据集(25,000 条训练 + 25,000 条测试,二分类)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from torch.utils.data import Dataset, DataLoader
import torch
from torch import nn
import re
from collections import Counter
import numpy as np

# 用 torchtext 加载
from torchtext.datasets import IMDB

train_iter, test_iter = IMDB(split=('train', 'test'))

# 看一眼数据
for i, (label, text) in enumerate(train_iter):
print(f"标签: {'正面' if label == 2 else '负面'}")
print(f"文本: {text[:200]}...")
if i == 0: break

输出:

1
2
标签: 正面
文本: This film is just brilliant. The acting is superb and the story ...

二、方案一:词袋模型(Bag of Words)

2.1 构建词表

1
2
3
4
5
6
7
8
9
10
11
12
13
def tokenize(text):
"""简单分词:转小写,去标点"""
text = text.lower()
text = re.sub(r'[^a-z\s]', '', text)
return text.split()

# 统计词频,保留最常见的 10,000 个词
counter = Counter()
for label, text in train_iter:
counter.update(tokenize(text))

vocab = ['<PAD>', '<UNK>'] + [word for word, _ in counter.most_common(10000)]
word2idx = {word: i for i, word in enumerate(vocab)}

2.2 文本转向量

词袋的意思就是:统计每个词出现了多少次

1
2
3
4
5
6
def bow_vectorize(text, vocab_size):
vec = np.zeros(vocab_size, dtype=np.float32)
for word in tokenize(text):
idx = word2idx.get(word, 1) # 1 = <UNK>
vec[idx] += 1
return vec

2.3 模型与训练

1
2
3
4
5
6
7
8
9
class BOWClassifier(nn.Module):
def __init__(self, vocab_size, hidden=128):
super().__init__()
self.fc1 = nn.Linear(vocab_size, hidden)
self.fc2 = nn.Linear(hidden, 2)

def forward(self, x):
x = torch.relu(self.fc1(x))
return self.fc2(x)

受限于篇幅,这里不展开完整训练循环。核心结论:

方案 测试精度 训练时间
词袋 + 逻辑回归 ~68% 快(秒级)
词袋 + 神经网络 ~72% 中等(分钟级)

68% 是什么概念?比瞎猜(50%)好一点,但离可用还很远。

问题出在哪? 词袋丢了词序——“not good”和”good”在 BoW 眼里几乎一模一样。


三、方案二:LSTM 序列模型

LSTM 的核心能力:记住上下文

“这部电影不怎么样,但比上一部好”——LSTM 能感受到从消极到积极的转折。BoW 只能看到一堆词。

3.1 文本转索引序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class IMDBDataset(Dataset):
def __init__(self, data_iter, word2idx, max_len=200):
self.data = []
for label, text in data_iter:
tokens = tokenize(text)
indices = [word2idx.get(w, 1) for w in tokens[:max_len]]
if len(indices) < max_len:
indices += [0] * (max_len - len(indices))
self.data.append((torch.tensor(indices, dtype=torch.long),
torch.tensor(1 if label == 2 else 0, dtype=torch.long)))
def __len__(self): return len(self.data)
def __getitem__(self, i): return self.data[i]

train_dataset = IMDBDataset(train_iter, word2idx)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)

3.2 LSTM 模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim=100, hidden_dim=128, num_layers=2):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers,
batch_first=True, dropout=0.3, bidirectional=True)
self.fc = nn.Linear(hidden_dim * 2, 2) # *2 因为双向
self.dropout = nn.Dropout(0.3)

def forward(self, x):
x = self.embedding(x)
_, (hidden, _) = self.lstm(x)
h = torch.cat((hidden[-2], hidden[-1]), dim=1)
h = self.dropout(torch.relu(h))
return self.fc(h)

关键设计选择:

  • 双向 LSTM:不仅看左边的词,还看右边的词——“这种质量的产品,我给不好评
  • Embedding 层:把离散的词 ID 映射成稠密向量,相似含义的词向量距离更近
  • Dropout=0.3:防止过拟合

3.3 训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = LSTMClassifier(len(vocab)).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

for epoch in range(1, 6):
model.train()
total_loss = 0
for inputs, labels in train_loader:
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
print(f"Epoch {epoch}: loss={total_loss/len(train_loader):.4f}")

预期结果:

1
2
3
4
5
Epoch 1: loss=0.5472
Epoch 2: loss=0.3228
Epoch 3: loss=0.2183
Epoch 4: loss=0.1531
Epoch 5: loss=0.1127 # 测试集精度 ~86%

86%,比 BoW 涨了 14 个点。

但 LSTM 有一个上限问题: 它逐词处理,长距离依赖仍然吃力。”虽然……但是……可惜……” 这种句式,LSTM 只能记住最近的几个转折。


四、方案三:BERT 预训练模型

BERT 最大的贡献是双向上下文——“银行”在”去银行取钱”和”中央银行政策”里有完全不同的含义,BERT 能区分。

4.1 加载预训练 BERT

1
2
3
4
5
from transformers import BertTokenizer, BertForSequenceClassification

model_name = 'bert-base-uncased'
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForSequenceClassification.from_pretrained(model_name, num_labels=2)

就两行。297M 参数,预训练在 3.3B 词上。你只需要在 IMDB 上微调。

4.2 数据加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class BERTDataset(Dataset):
def __init__(self, data_iter, tokenizer, max_len=256):
self.input_ids = []
self.attention_masks = []
self.labels = []
for label, text in data_iter:
encoded = tokenizer(
text,
truncation=True,
padding='max_length',
max_length=max_len,
return_tensors='pt'
)
self.input_ids.append(encoded['input_ids'][0])
self.attention_masks.append(encoded['attention_mask'][0])
self.labels.append(1 if label == 2 else 0)
def __len__(self): return len(self.input_ids)
def __getitem__(self, i):
return {
'input_ids': self.input_ids[i],
'attention_mask': self.attention_masks[i],
'labels': torch.tensor(self.labels[i], dtype=torch.long)
}

train_dataset = BERTDataset(train_iter, tokenizer)
train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)

注意 batch_size=16。 BERT 有 3 亿参数,显存吃得很厉害——GPU 至少 8GB 才能跑。

4.3 微调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from transformers import AdamW

device = torch.device('cuda')
model.to(device)
optimizer = AdamW(model.parameters(), lr=2e-5) # 必须用很小的学习率

for epoch in range(1, 4): # BERT 微调 3 轮就能收敛
model.train()
total_loss = 0
for batch in train_loader:
batch = {k: v.to(device) for k, v in batch.items()}
optimizer.zero_grad()
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
total_loss += loss.item()
print(f"Epoch {epoch}: loss={total_loss/len(train_loader):.4f}")

预期精度:93%+


五、三方案对比

方案 参数量 精度 训练时间(GPU) 训练时间(CPU)
词袋 + NN ~1.2M 68-72% 1min 5min
双向 LSTM ~3.0M ~86% 15min 2h
BERT 微调 297M ~93% 30min ❌ 基本不可行

选型建议

  • 能接受 70% 精度? → 词袋模型 + 正则化(适合资源极度受限的场景)
  • 需要 85%+ 并且有 GPU? → LSTM 是性价比之王
  • 必须 90%+ 并且有 GPU? → BERT 或它的蒸馏版(DistilBERT,一半大小,95% 效果)

为什么要学前两个方案?

BERT 虽然强,但你控制不了它——遇到 OOV 词怎么处理?长文本拆分策略?领域迁移要怎么继续预训练?不懂底层原理,你连 BERT 的 tokenizer 参数都调不明白。


六、完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
"""sentiment_lstm.py - LSTM 情感分析"""
import torch, re
from torch import nn
from torch.utils.data import Dataset, DataLoader
from torchtext.datasets import IMDB
from collections import Counter

def tokenize(text):
text = text.lower()
text = re.sub(r'[^a-z\s]', '', text)
return text.split()

counter = Counter()
for label, text in IMDB(split='train'):
counter.update(tokenize(text))
vocab = ['<PAD>', '<UNK>'] + [w for w, _ in counter.most_common(10000)]
word2idx = {w: i for i, w in enumerate(vocab)}

class IMDBDataset(Dataset):
def __init__(self, split, word2idx, max_len=200):
self.data = []
for label, text in IMDB(split=split):
indices = [word2idx.get(w, 1) for w in tokenize(text)[:max_len]]
if len(indices) < max_len:
indices += [0] * (max_len - len(indices))
self.data.append((torch.tensor(indices, dtype=torch.long),
torch.tensor(1 if label == 2 else 0, dtype=torch.long)))
def __len__(self): return len(self.data)
def __getitem__(self, i): return self.data[i]

train_loader = DataLoader(IMDBDataset('train', word2idx), batch_size=64, shuffle=True)
test_loader = DataLoader(IMDBDataset('test', word2idx), batch_size=256, shuffle=False)

class LSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed=100, hidden=128, num_layers=2):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed, padding_idx=0)
self.lstm = nn.LSTM(embed, hidden, num_layers, batch_first=True,
dropout=0.3, bidirectional=True)
self.fc = nn.Linear(hidden*2, 2)
self.dropout = nn.Dropout(0.3)

def forward(self, x):
x = self.embedding(x)
_, (h, _) = self.lstm(x)
h = torch.cat((h[-2], h[-1]), dim=1)
return self.fc(self.dropout(torch.relu(h)))

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = LSTMClassifier(len(vocab)).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()

for epoch in range(1, 6):
model.train()
total = 0
for inputs, labels in train_loader:
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
criterion(model(inputs), labels).backward()
optimizer.step()
total += criterion(model(inputs), labels).item()

model.eval()
correct = sum((model(x.to(device)).argmax(1) == y.to(device)).sum().item()
for x, y in test_loader)
print(f"Epoch {epoch}: acc={correct/25000:.4f}")

保存为 sentiment_lstm.py 直接跑(需要先 pip install torchtext)。


总结

情感分析三类方案准确度跨度 68% → 86% → 93%,每个增长台阶的背后都是一个核心技术创新:

  • 词袋 → LSTM:从”丢词序”到”记住上下文”(+14%)
  • LSTM → BERT:从”单向记忆”到”预训练大模型”(+7%)

后面的增长会更难。96% 往上需要对抗生成、领域自适应、多模态融合——这就是顶会论文的方向了。

建议行动:

  1. 在 Kaggle 上跑 BERT 微调,完整体验训练流程
  2. 用你自己的产品评论跑一遍,看看精度变化
  3. 尝试 DistilBERT,看速度和精度的 trade-off