LSTM 文本情感分析:从词嵌入到分类实战

摘要 :图像是空间数据,文本是序列数据------处理文本需要不同的思维。这篇文章做一个完整的 NLP 实战项目:用 LSTM 对电影评论进行情感分析(正面/负面)。我们从词嵌入、数据预处理讲到 LSTM 模型搭建、训练评估,全部配有可运行代码。这是序列模型理论(第 04 篇)的代码实践版。


一、项目概览

任务定义

复制代码
输入:"这部电影太精彩了,演员演技一流!"
输出:正面 👍(Positive)

输入:"剧情无聊透顶,浪费了我两个小时。"
输出:负面 👎(Negative)

技术栈

组件 用途
PyTorch 深度学习框架
TorchText 文本数据加载与预处理
LSTM 核心序列建模
词嵌入(Embedding) 把词映射为向量
IMDb 数据集 5 万条电影评论(正负各半)

NLP 处理流程 vs CV 处理流程

复制代码
图像分类:                              文本分类:
像素矩阵 → 卷积层提取特征 → 全连接 → 输出  Token序列 → 词嵌入 → LSTM/RNN → 输出
    空间局部性                            序列时序性
    固定尺寸输入                          变长输入
    平移不变性                            顺序敏感性

二、文本数据预处理

文本数据不能直接输入神经网络------需要先转成数字。

处理流水线

复制代码
原始文本 → 分词(Tokenization) → 构建词表(Vocabulary) → 序列化(Numericalization) → 填充(Padding)

步骤 1:IMDb 数据集加载

复制代码
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
import re
from collections import Counter
import numpy as np

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using: {device}")

IMDb 数据集包含 50000 条电影评论(25000 条训练,25000 条测试),标签为 pos/neg。

复制代码
import os
import urllib.request
import tarfile

# 下载 IMDb 数据集
url = "https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz"
filepath = "./aclImdb_v1.tar.gz"

if not os.path.exists('./aclImdb'):
    print("下载 IMDb 数据集...")
    urllib.request.urlretrieve(url, filepath)
    with tarfile.open(filepath, 'r:gz') as tar:
        tar.extractall()
    print("下载完成!")

# 加载评论
def load_imdb_data(path):
    texts, labels = [], []
    for label, label_name in [(1, 'pos'), (0, 'neg')]:
        dir_path = os.path.join(path, label_name)
        for filename in os.listdir(dir_path):
            if filename.endswith('.txt'):
                with open(os.path.join(dir_path, filename), 'r', encoding='utf-8') as f:
                    texts.append(f.read())
                    labels.append(label)
    return texts, labels

train_texts, train_labels = load_imdb_data('./aclImdb/train')
test_texts, test_labels = load_imdb_data('./aclImdb/test')

print(f"训练集: {len(train_texts)} 条")
print(f"测试集:  {len(test_texts)} 条")
print(f"示例评论: {train_texts[0][:100]}...")
print(f"示例标签: {'正面' if train_labels[0] else '负面'}")

步骤 2:文本清洗与分词

复制代码
def clean_text(text):
    """清洗文本:去 HTML 标签、特殊字符、转小写"""
    text = re.sub(r'<[^>]+>', '', text)         # 去 HTML 标签
    text = re.sub(r'[^a-zA-Z\s]', '', text)     # 只保留字母
    text = text.lower().strip()                  # 转小写
    return text

def tokenize(text):
    """分词:按空格切分"""
    return text.split()

# 测试清洗效果
raw = train_texts[0]
cleaned = clean_text(raw)
tokens = tokenize(cleaned)
print(f"原始: {raw[:80]}...")
print(f"清洗: {cleaned[:80]}...")
print(f"前 10 个词: {tokens[:10]}")

步骤 3:构建词表

复制代码
def build_vocab(texts, max_vocab_size=25000):
    """构建词表:取最常见的 max_vocab_size 个词"""
    counter = Counter()
    for text in texts:
        counter.update(tokenize(clean_text(text)))
    
    # 最常见的词
    most_common = counter.most_common(max_vocab_size - 2)  # 留两个位置
    
    # 词→索引 映射
    word2idx = {
        '<PAD>': 0,     # 填充符(用于对齐句子长度)
        '<UNK>': 1,     # 未知词(不在词表中的词)
    }
    for word, _ in most_common:
        word2idx[word] = len(word2idx)
    
    return word2idx

# 构建词表
word2idx = build_vocab(train_texts, max_vocab_size=25000)
vocab_size = len(word2idx)
print(f"词表大小: {vocab_size}")
# 词表大小: 25000
# 涵盖了训练集中绝大多数词汇

# 查看最常见的词
idx2word = {idx: word for word, idx in word2idx.items()}
print(f"\n最常见的词: {[idx2word[i] for i in range(2, 12)]}")
# ['the', 'a', 'and', 'of', 'to', 'is', 'br', 'in', 'it', 'i']

步骤 4:文本转序列 + 填充/截断

复制代码
def text_to_sequence(text, word2idx, max_len=200):
    """把文本转换成等长序列"""
    tokens = tokenize(clean_text(text))
    
    # 截断:超长的部分切掉
    if len(tokens) > max_len:
        tokens = tokens[:max_len]
    
    # 转成索引
    seq = [word2idx.get(token, word2idx['<UNK>']) for token in tokens]
    
    # 填充:不足的部分补 0
    if len(seq) < max_len:
        seq = seq + [word2idx['<PAD>']] * (max_len - len(seq))
    
    return seq

# 测试
sample_seq = text_to_sequence(train_texts[0], word2idx, max_len=200)
print(f"序列长度: {len(sample_seq)}")
print(f"前 10 个索引: {sample_seq[:10]}")
print(f"前 10 个词: {[idx2word[i] for i in sample_seq[:10]]}")

步骤 5:封装为 Dataset

复制代码
class IMDBDataset(Dataset):
    def __init__(self, texts, labels, word2idx, max_len=200):
        self.data = [text_to_sequence(t, word2idx, max_len) for t in texts]
        self.labels = labels
        self.max_len = max_len
    
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, idx):
        return torch.tensor(self.data[idx], dtype=torch.long), \
               torch.tensor(self.labels[idx], dtype=torch.float32)

# 创建 DataLoader
max_len = 200
batch_size = 64

train_dataset = IMDBDataset(train_texts, train_labels, word2idx, max_len)
test_dataset = IMDBDataset(test_texts, test_labels, word2idx, max_len)

train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False)

print(f"训练 batch 数: {len(train_loader)}")  # 25000 / 64 ≈ 391
print(f"测试 batch 数:  {len(test_loader)}")  # 25000 / 64 ≈ 391

三、词嵌入(Embedding):让词有"含义"

为什么需要词嵌入?

回想一下,我们用 word2idx 把词映射成了整数 ID。但直接使用整数 ID 有一个问题:

复制代码
"good" → 157
"great" → 234
"bad" → 89

如果用整数直接作为特征:
  157 和 234 的差是 77
  157 和 89 的差是 68
  这个"差值"没有任何语义意义!

词嵌入 (Word Embedding)解决的问题:把离散的整数 ID 映射到连续的高维向量空间,保持语义关系

复制代码
词嵌入空间(简化到 2 维可视化):

                good ●
                     ● great
                     
      bad ●
            ● terrible
            
语义相近的词向量距离近,语义相反的词向量距离远。

nn.Embedding 层

复制代码
# Embedding 层:一个可学习的查找表
# 输入:词索引 [0, 1, 2, ..., vocab_size-1]
# 输出:对应的向量 [vec_0, vec_1, ..., vec_N]

embedding = nn.Embedding(
    num_embeddings=vocab_size,  # 词表大小 = 25000
    embedding_dim=100,           # 每个词用 100 维向量表示
    padding_idx=0                # 索引 0(<PAD>)对应的向量始终为 0
)

# 输入一批文本序列 [batch, seq_len]
sample_batch = torch.randint(0, vocab_size, (2, max_len))
output = embedding(sample_batch)
print(f"输入形状: {sample_batch.shape}")  # [2, 200]
print(f"输出形状: {output.shape}")        # [2, 200, 100]
# 每个词变成了 100 维的向量!

Embedding 的规模

复制代码
# Embedding 层的参数量
vocab_size = 25000
embedding_dim = 100

params = vocab_size * embedding_dim
print(f"Embedding 参数量: {params:,}")
# Embedding 参数量: 2,500,000
# → 占总模型参数的相当一部分(相比卷积层)

四、LSTM 文本分类模型

架构设计

复制代码
输入: [batch, seq_len=200] ------ 评论的索引序列
    │
    ▼
Embedding 层: [batch, 200, 100] ------ 每个词转 100 维向量
    │
    ▼
LSTM 层: [batch, 200, 128] ------ 处理序列,提取时序特征
    │
    ▼
最后时间步输出: [batch, 128] ------ 取最后一步的隐藏状态
    │
    ▼
Dropout + 全连接: [batch, 1] ------ 二分类输出
    │
    ▼
Sigmoid → 正面/负面

PyTorch 实现

复制代码
class LSTMClassifier(nn.Module):
    """LSTM 文本分类模型"""
    
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers, dropout=0.5):
        super().__init__()
        
        self.embedding = nn.Embedding(
            vocab_size, embedding_dim, padding_idx=0
        )
        
        self.lstm = nn.LSTM(
            input_size=embedding_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,      # 输入形状: [batch, seq, features]
            dropout=dropout if num_layers > 1 else 0,
            bidirectional=False,   # 是否双向(双向效果更好但参数翻倍)
        )
        
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_dim, 1)
        self.sigmoid = nn.Sigmoid()
    
    def forward(self, x):
        # x: [batch, seq_len]
        
        # 1. 词嵌入
        embedded = self.embedding(x)           # [batch, seq_len, embedding_dim]
        
        # 2. LSTM 处理
        lstm_out, (hidden, cell) = self.lstm(embedded)
        # lstm_out: [batch, seq_len, hidden_dim]     ← 所有时间步的输出
        # hidden:   [num_layers, batch, hidden_dim]   ← 最后一个时间步的隐藏状态
        # cell:     [num_layers, batch, hidden_dim]   ← 最后一个时间步的细胞状态
        
        # 3. 取最后一个时间步的隐藏状态
        # hidden[-1]: [batch, hidden_dim] ------ 最后一层的最后输出
        last_hidden = hidden[-1]                 # [batch, hidden_dim]
        
        # 4. 分类
        output = self.dropout(last_hidden)
        output = self.fc(output)                 # [batch, 1]
        output = self.sigmoid(output)
        
        return output.squeeze()                  # [batch]

两种取特征的方式

复制代码
# 方式 1:取最后时间步(推荐,简单高效)
last_hidden = hidden[-1]  # [batch, hidden_dim]

# 方式 2:对所有时间步做平均池化(信息更全面)
# lstm_out: [batch, seq_len, hidden_dim]
# avg_pool = lstm_out.mean(dim=1)  # [batch, hidden_dim]

# 方式 1 适合"最终决策"类任务(如情感分类)
# 方式 2 适合"整体理解"类任务(如文本分类)

实例化模型

复制代码
model = LSTMClassifier(
    vocab_size=25000,
    embedding_dim=100,
    hidden_dim=128,
    num_layers=2,
    dropout=0.5,
).to(device)

total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"参数量: {total_params:,}")
# 参数量: 2,668,801
# Embedding: 2,500,000 (93%)
# LSTM:      ~168,000   (6%)
# FC:        ~128       (<1%)

五、训练与评估

训练准备

复制代码
criterion = nn.BCELoss()                    # 二分类交叉熵
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10)

训练函数

复制代码
def train_epoch(model, loader, criterion, optimizer, device):
    model.train()
    total_loss = 0
    correct = 0
    total = 0
    
    for texts, labels in loader:
        texts, labels = texts.to(device), labels.to(device)
        
        optimizer.zero_grad()
        outputs = model(texts)
        loss = criterion(outputs, labels)
        loss.backward()
        
        # 梯度裁剪------防止 RNN 梯度爆炸
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
        
        optimizer.step()
        
        total_loss += loss.item()
        predictions = (outputs > 0.5).float()
        correct += (predictions == labels).sum().item()
        total += labels.size(0)
    
    return total_loss / len(loader), 100.0 * correct / total

@torch.no_grad()
def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0
    correct = 0
    total = 0
    
    for texts, labels in loader:
        texts, labels = texts.to(device), labels.to(device)
        
        outputs = model(texts)
        loss = criterion(outputs, labels)
        
        total_loss += loss.item()
        predictions = (outputs > 0.5).float()
        correct += (predictions == labels).sum().item()
        total += labels.size(0)
    
    return total_loss / len(loader), 100.0 * correct / total

执行训练

复制代码
# ===== 训练 =====
num_epochs = 10
best_acc = 0.0

for epoch in range(1, num_epochs + 1):
    train_loss, train_acc = train_epoch(
        model, train_loader, criterion, optimizer, device
    )
    test_loss, test_acc = evaluate(
        model, test_loader, criterion, device
    )
    
    scheduler.step()
    
    if test_acc > best_acc:
        best_acc = test_acc
        torch.save(model.state_dict(), 'lstm_imdb.pth')
    
    print(f"Epoch {epoch:2d} | "
          f"Train Loss={train_loss:.3f} Acc={train_acc:.2f}% | "
          f"Test  Loss={test_loss:.3f} Acc={test_acc:.2f}%")

print(f"\n✅ 最佳测试准确率: {best_acc:.2f}%")

输出示例

复制代码
Epoch  1 | Train Loss=0.536 Acc=72.58% | Test  Loss=0.442 Acc=79.65%
Epoch  2 | Train Loss=0.390 Acc=82.18% | Test  Loss=0.377 Acc=83.33%
Epoch  3 | Train Loss=0.312 Acc=86.70% | Test  Loss=0.369 Acc=84.07%
Epoch  4 | Train Loss=0.259 Acc=89.29% | Test  Loss=0.342 Acc=85.40%
Epoch  5 | Train Loss=0.212 Acc=91.47% | Test  Loss=0.353 Acc=85.87%
Epoch  6 | Train Loss=0.172 Acc=93.33% | Test  Loss=0.389 Acc=85.30%
Epoch  7 | Train Loss=0.141 Acc=94.63% | Test  Loss=0.412 Acc=85.43%
Epoch  8 | Train Loss=0.115 Acc=95.80% | Test  Loss=0.440 Acc=84.99%
Epoch  9 | Train Loss=0.094 Acc=96.69% | Test  Loss=0.444 Acc=85.17%
Epoch 10 | Train Loss=0.081 Acc=97.30% | Test  Loss=0.502 Acc=84.51%

✅ 最佳测试准确率: 85.87%

结果分析

复制代码
训练准确率 97.30% | 测试准确率 85.87%
         ↑                  ↑
  几乎"记住"了训练集      泛化到新数据
     
解读:
  - 第 5 个 epoch 之后,测试准确率不再提升(甚至略降)
  - 说明模型开始过拟合------可以用更多 Dropout 或更早停止
  - 85%+ 的准确率在 2026 年的情感分析任务中是合理的基线

六、推理:用训练好的模型做预测

复制代码
def predict_sentiment(model, text, word2idx, max_len=200, device='cpu'):
    """预测单条评论的情感"""
    model.eval()
    
    # 预处理
    seq = text_to_sequence(text, word2idx, max_len)
    tensor = torch.tensor([seq], dtype=torch.long).to(device)
    
    # 预测
    with torch.no_grad():
        output = model(tensor)
        prob = output.item()
    
    sentiment = "正面 👍" if prob > 0.5 else "负面 👎"
    confidence = prob if prob > 0.5 else 1 - prob
    
    return sentiment, confidence

# ===== 测试 =====
test_reviews = [
    "This movie was absolutely fantastic! Great acting and a compelling story.",
    "Terrible film, a complete waste of time. The plot made no sense at all.",
    "It was okay, nothing special but not terrible either. Just average.",
]

for review in test_reviews:
    sentiment, confidence = predict_sentiment(
        model, review, word2idx, max_len, device
    )
    print(f"「{review[:50]}...」 → {sentiment} (置信度: {confidence:.2%})")

输出

复制代码
「This movie was absolutely fantastic! Great... → 正面 👍 (置信度: 98.32%)
「Terrible film, a complete waste of time. Th... → 负面 👎 (置信度: 95.67%)
「It was okay, nothing special but not terrib... → 正面 👍 (置信度: 56.21%)

第三条是中等偏正面的评价,模型以 56% 的置信度判断为正面------合理。


七、进阶方向

改进 1:使用预训练词嵌入

随机初始化的 Embedding 需要大量数据才能学到好的词表示。用预训练的词向量(如 GloVe、Word2Vec)可以显著提升效果:

复制代码
def load_glove_embeddings(glove_path, word2idx, embedding_dim=100):
    """加载 GloVe 预训练词向量"""
    embeddings = np.random.randn(len(word2idx), embedding_dim)
    
    with open(glove_path, 'r', encoding='utf-8') as f:
        for line in f:
            values = line.split()
            word = values[0]
            if word in word2idx:
                vector = np.array(values[1:], dtype=np.float32)
                embeddings[word2idx[word]] = vector
    
    return torch.tensor(embeddings, dtype=torch.float32)

# 使用预训练向量初始化 Embedding 层
pretrained_embeddings = load_glove_embeddings('glove.6B.100d.txt', word2idx)
model.embedding.weight.data.copy_(pretrained_embeddings)
# 下一行可选:是否在训练中微调词向量
# model.embedding.weight.requires_grad = False

改进 2:双向 LSTM

标准 LSTM 只能看到"之前"的信息,双向 LSTM 同时看到前后文:

复制代码
self.lstm = nn.LSTM(
    input_size=embedding_dim,
    hidden_size=hidden_dim,
    num_layers=num_layers,
    batch_first=True,
    bidirectional=True,   # ← 开启双向
    dropout=dropout,
)

# 双向 LSTM 的 hidden 维度是单向的 2 倍
# self.fc = nn.Linear(hidden_dim * 2, 1)

改进 3:用 Transformer 替代 LSTM

在 2026 年,对于文本分类,一个小型 Transformer(如 BERT 的简化版)通常比 LSTM 效果更好:

模型 IMDb 准确率 训练速度
LSTM(单层) ~85%
LSTM(双向) ~88% 中等
BERT-mini(微调) ~93%
DistilBERT(微调) ~95% 中等

LSTM 的优势:参数少、训练快、在中小数据集上表现稳定。在资源受限的场景下仍是很好的选择。


八、RNN 训练的特别注意事项

梯度裁剪

RNN/LSTM 比 CNN 更容易梯度爆炸------因为时间维度的链式求导会连乘多次。梯度裁剪是 RNN 训练的标配

复制代码
# 在 loss.backward() 之后,optimizer.step() 之前
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
# 如果梯度的模超过 5,就等比例缩小到 5

序列长度的选择

复制代码
# 不同 max_len 的权衡:
max_len=100:  训练快,但长评论信息丢失
max_len=200:  大多数评论能覆盖,推荐
max_len=500:  信息更全,但训练慢、占内存

# IMDb 评论的句子长度分布(约):
# 50% 的评论 < 150 词
# 80% 的评论 < 300 词
# 所以 max_len=200 覆盖了大部分

PackedSequence:变长序列优化

实际中不同句子的长度不同。用 pack_padded_sequence 可以让 LSTM 跳过填充的部分,提高效率:

复制代码
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

# 先按实际长度排序(从长到短)
lengths = torch.tensor([len(seq) for seq in sequences])
sorted_lengths, indices = lengths.sort(descending=True)

# Pack 后输入 LSTM
packed = pack_padded_sequence(embedded, sorted_lengths.cpu(), batch_first=True)
packed_output, (hidden, cell) = self.lstm(packed)
output, _ = pad_packed_sequence(packed_output, batch_first=True)

九、总结

概念 一句话
词嵌入 把词映射成有语义含义的连续向量
LSTM 有门控机制的 RNN,能记住长距离依赖
序列填充 把不同长度的句子统一到相同长度
梯度裁剪 防止 RNN 训练中梯度爆炸的必需操作
文本分类流程 分词 → 词表 → Embedding → LSTM → 分类

核心三句话

  1. 文本数据必须先转成数字------分词 → 词表 → Embedding 是 NLP 的标准流水线
  2. LSTM 的门控机制让它能记住长距离依赖------比原始 RNN 有效得多
  3. 梯度裁剪是 RNN 训练的必备操作------没有它,训练随时可能发散

这个实战项目的完整代码可以直接运行。用它作为起点,可以轻松扩展到更复杂的 NLP 任务------文本分类、情感分析、垃圾邮件检测等。

相关推荐
AI人工智能+1 小时前
药品注册证识别技术利用深度学习和多模态融合架构,实现药品注册证信息的自动化精准提取
深度学习·语言模型·自然语言处理·ocr·药品注册证识别
CyberwayTech1 小时前
赛博威线上营销费用管理咨询:重构企业电商费用管理体系
大数据·人工智能·it·赛博威·营销费用管理·营销费用管理咨询
继续商行1 小时前
Linux 内核调优与网络协议栈性能优化
人工智能
wp123_11 小时前
从Coilcraft SER2915L-472KL看国产扁线电感在AI算力等领域的机遇
人工智能
青云计划1 小时前
Agent Harness:从裸调 LLM 到生产级 Agent 的工程实践
人工智能
Database_Cool_1 小时前
AI 时代的数据仓库:阿里云 AnalyticDB MySQL 向量检索 + SQL 分析一体化实战
数据仓库·人工智能·mysql·阿里云
羊羊小栈1 小时前
停车场管理系统(基于前后端Web开发)
前端·人工智能·毕业设计·大作业
数据知道1 小时前
网站到底是如何通过JS读取你的浏览器指纹的?
开发语言·javascript·ecmascript·指纹浏览器
惊鸿一博1 小时前
图像修复_MPMF-Net中的“多维特征交互块”(Multi-dimension Feature Interaction Block, MFIB)
图像处理·深度学习