引子 给一条评论,判断是好评还是差评——就这么简单的一个二分类问题。
但真正动手做的时候,你会发现:
关键词匹配:”这个产品不错”→ 好评,”质量很差”→ 差评 → 碰上”不 错”就挂了
标点符号:”好吃!”→ 好评,”好吃?”→ 饿了吗?
语言的复杂性,让”简单”的二分类没那么简单。
这篇带你完整走一遍:从最简单的词袋模型,到 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, DataLoaderimport torchfrom torch import nnimport refrom collections import Counterimport numpy as npfrom torchtext.datasets import IMDBtrain_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() 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 ) 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 ) 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):.4 f} " )
预期结果:
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, BertForSequenceClassificationmodel_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 AdamWdevice = torch.device('cuda' ) model.to(device) optimizer = AdamW(model.parameters(), lr=2e-5 ) for epoch in range (1 , 4 ): 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):.4 f} " )
预期精度: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, refrom torch import nnfrom torch.utils.data import Dataset, DataLoaderfrom torchtext.datasets import IMDBfrom collections import Counterdef 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 :.4 f} " )
保存为 sentiment_lstm.py 直接跑(需要先 pip install torchtext)。
总结 情感分析三类方案准确度跨度 68% → 86% → 93% ,每个增长台阶的背后都是一个核心技术创新:
词袋 → LSTM :从”丢词序”到”记住上下文”(+14%)
LSTM → BERT :从”单向记忆”到”预训练大模型”(+7%)
后面的增长会更难。96% 往上需要对抗生成、领域自适应、多模态融合——这就是顶会论文的方向了。
建议行动:
在 Kaggle 上跑 BERT 微调,完整体验训练流程
用你自己的产品评论跑一遍,看看精度变化
尝试 DistilBERT,看速度和精度的 trade-off