《Python AI入门》第9章 让机器读懂文字——NLP基础与情感分析实战

章节导语

"图像是静止的像素矩阵,而语言是流动的河流。你无法只看'银行'这两个字就明白它的意思,因为在'河边的银行'和'存款的银行'中,它的含义截然不同。"

欢迎来到人工智能最迷人也最困难的领域------自然语言处理(Natural Language Processing, NLP)

在上一章,我们处理的是图像,它们是固定大小的网格(比如 224×224224 \times 224224×224)。但文字是序列(Sequence),它的长度不固定,且前后文之间存在强烈的依赖关系。这就好比阅读:你必须读完前半句,才能理解后半句的代词"它"指代的是什么。

本章,我们将学习如何把人类的语言翻译成计算机能懂的数字,并使用循环神经网络(RNN/LSTM) ------一种拥有"记忆"的网络结构,来完成一个经典的NLP任务:判断一句电影评论到底是赞美还是吐槽(情感分析)


9.1 学习目标

在学完本章后,你将能够:

  1. 文本预处理流水线 :掌握 分词(Tokenization)建立词表(Vocabulary)填充(Padding) 的标准流程。
  2. 理解词向量(Word Embedding):明白为什么我们不用 One-Hot 编码,而是用 Embedding 层把单词变成稠密的向量。
  3. 掌握序列模型 :理解 RNN 的原理以及 LSTM(长短期记忆网络) 如何解决"记不住"的问题。
  4. 工程化落地:不依赖复杂的第三方黑盒库,亲手构建一个从原始文本到情感分类的完整 PyTorch 模型。

9.2 计算机看不懂英文,它只认数字

我们不能直接把 "I love AI" 塞给神经网络。我们需要一个翻译过程,这个过程通常分为三步:

9.2.1 第一步:分词 (Tokenization)

把句子切成最小单位。

  • 句子:"I love AI!"
  • 分词后:["I", "love", "AI", "!"]

9.2.2 第二步:建立词表 (Vocabulary)

计算机喜欢整数索引。我们需要给每个单词发一个"身份证号"。

python 复制代码
# 模拟词表
vocab = {
    "<PAD>": 0,  # 填充位 (占位符)
    "<UNK>": 1,  # 未知词 (遇到没见过的词就用它代替)
    "I": 2,
    "love": 3,
    "AI": 4,
    "hate": 5,
    ...
}

那么 ["I", "love", "AI"] 就变成了 [2, 3, 4]

9.2.3 第三步:填充与截断 (Padding & Truncation)

这是工程上 最关键的一步。

神经网络训练时需要批量(Batch)输入。如果句子A有3个词,句子B有100个词,它们没法打包成一个矩阵。

我们需要强行把它们变成一样长:

  • 短的补 0(Padding)。
  • 长的切掉(Truncation)。

假设固定长度为 5:

  • "I love AI" -> [2, 3, 4, 0, 0]
  • "I really really ... hate movie" (太长) -> [2, 6, 6, ..., 5] (保留前5个或后5个)

9.3 核心概念:Word Embedding (词嵌入)

在第5章泰坦尼克号案例中,我们用 One-Hot 编码处理了性别。但在 NLP 中,单词可能有几万个。如果用 One-Hot,向量维度就是几万维,而且绝大多数都是0,这太浪费了。

更重要的是,One-Hot 无法表示单词之间的关系。在 One-Hot 空间里,"苹果"和"梨"的距离,与"苹果"和"汽车"的距离是一样的。

Embedding 解决了这个问题。它是一个查找表(Lookup Table),把每个整数索引映射为一个低维向量(比如100维)。

神奇的是,经过训练后,语义相似的词,在向量空间里的距离会非常近

【直观理解】

Embedding 层就像一个自适应的字典。一开始它里面的解释是乱写的,随着训练进行,它学会了:当输入 "King" 和 "Queen" 时,应该输出两个长得很像的向量。


9.4 模型架构:从 RNN 到 LSTM

9.4.1 RNN:带有循环的神经元

普通的神经元是:输入 -> 运算 -> 输出。

RNN 的神经元是:输入 + 上一次的状态 -> 运算 -> 输出 + 更新状态

这就好比你在看书:你现在的理解(当前状态),取决于你眼前看到的字(当前输入),以及你脑子里记住的前面章节的内容(上一时刻状态)。

9.4.2 LSTM:只有金鱼记忆?不!

普通的 RNN 有个致命缺陷:梯度消失。它很难记住很长距离之前的信息(就像金鱼只有7秒记忆)。读到段落结尾,它可能已经忘了开头的主语是谁。

LSTM (Long Short-Term Memory) 引入了三个"门控(Gate)"机制:

  1. 遗忘门:决定丢弃哪些旧信息(比如读到新的一章,该忘掉上一章的龙套角色了)。
  2. 输入门:决定记住哪些新信息。
  3. 输出门:决定当前输出什么。

虽然内部结构复杂,但在 PyTorch 中使用它非常简单,只需要一行代码:nn.LSTM


9.5 实战案例:IMDB 电影评论情感分析

我们将构建一个模型,输入一句英文评论,输出它是 Positive (正面) 还是 Negative (负面)

9.5.1 数据准备:手写一个即插即用的处理类

为了避免 torchtext 版本频繁更新带来的困扰,我们将使用 Python 原生库来实现一个稳健的数据管道。

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

# 模拟一些数据 (真实场景中你会从文件读取)
raw_data = [
    ("This movie is amazing I love it", 1), # 1 代表正面
    ("What a waste of time terrible acting", 0), # 0 代表负面
    ("Great story and fantastic visual effects", 1),
    ("I fell asleep halfway through boring", 0),
    ("The plot is confusing but the music is good", 1),
    ("Worst movie ever do not watch", 0)
] * 100 # 复制多一点以便跑得起来

# --- 1. 简易分词器 ---
def tokenizer(text):
    # 转小写,去掉非字母字符,按空格切分
    text = re.sub(r'[^a-zA-Z\s]', '', text.lower())
    return text.split()

# --- 2. 构建词表 ---
def build_vocab(data, min_freq=1):
    all_tokens = []
    for text, _ in data:
        all_tokens.extend(tokenizer(text))
    
    # 统计词频
    word_counts = Counter(all_tokens)
    
    # 定义特殊字符
    vocab = {"<PAD>": 0, "<UNK>": 1}
    idx = 2
    for word, count in word_counts.items():
        if count >= min_freq:
            vocab[word] = idx
            idx += 1
    return vocab

# 构建词表
vocab = build_vocab(raw_data)
print(f"词表大小: {len(vocab)}")
print(f"Token示例: 'movie' -> {vocab.get('movie')}")

# --- 3. 自定义 Dataset ---
class IMDBDataset(Dataset):
    def __init__(self, data, vocab, max_len=10):
        self.data = data
        self.vocab = vocab
        self.max_len = max_len

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):
        text, label = self.data[idx]
        tokens = tokenizer(text)
        
        # 文本转数字索引
        token_ids = [self.vocab.get(token, self.vocab["<UNK>"]) for token in tokens]
        
        # 填充或截断 (Padding / Truncation)
        if len(token_ids) < self.max_len:
            # 短了就补 0
            token_ids += [self.vocab["<PAD>"]] * (self.max_len - len(token_ids))
        else:
            # 长了就截断
            token_ids = token_ids[:self.max_len]
            
        return torch.tensor(token_ids), torch.tensor(label, dtype=torch.float32)

# 实例化 DataLoader
dataset = IMDBDataset(raw_data, vocab, max_len=10)
dataloader = DataLoader(dataset, batch_size=4, shuffle=True)

# 测试一下管道
inputs, labels = next(iter(dataloader))
print(f"Input Batch Shape: {inputs.shape}") # Should be [4, 10]

9.5.2 定义 LSTM 模型

这是一个标准的 NLP 分类模型架构:Embedding -> LSTM -> Linear (Classifier)

python 复制代码
class SentimentLSTM(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim):
        super(SentimentLSTM, self).__init__()
        
        # 1. Embedding 层: 把整数索引变成向量
        # padding_idx=0 告诉模型: 索引为0的是填充物,不要计算它的梯度,也没意义
        self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
        
        # 2. LSTM 层
        # batch_first=True 让输入格式变成 (batch, seq_len, feature)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
        
        # 3. 全连接层 (分类器)
        self.fc = nn.Linear(hidden_dim, output_dim)
        
        # 4. Sigmoid 激活 (因为是二分类,输出 0~1 的概率)
        self.sigmoid = nn.Sigmoid()
        
    def forward(self, text):
        # text shape: [batch_size, seq_len] -> [4, 10]
        
        # embedded shape: [batch_size, seq_len, emb_dim] -> [4, 10, 32]
        embedded = self.embedding(text)
        
        # lstm 输出: output (所有时刻的状态), (hidden, cell) (最后时刻的状态)
        # 我们只需要最后时刻的状态来代表整句话的意思
        _, (hidden, _) = self.lstm(embedded)
        
        # hidden shape: [1, batch_size, hidden_dim] -> Squeeze -> [batch_size, hidden_dim]
        # 取最后一层的 hidden state
        last_hidden = hidden[-1]
        
        # 全连接 + 激活
        return self.sigmoid(self.fc(last_hidden))

# 初始化模型
vocab_size = len(vocab)
embedding_dim = 32
hidden_dim = 64
output_dim = 1

model = SentimentLSTM(vocab_size, embedding_dim, hidden_dim, output_dim)
print(model)

9.5.3 训练与预测

这里的训练循环和第7章几乎一样,唯一的区别是 Loss 函数我们用 BCELoss (Binary Cross Entropy),因为这是二分类问题。

python 复制代码
import torch.optim as optim

criterion = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# --- 训练循环 ---
epochs = 5
for epoch in range(epochs):
    total_loss = 0
    for texts, labels in dataloader:
        optimizer.zero_grad()
        
        # 前向传播
        predictions = model(texts).squeeze(1) # 把 [4, 1] 变成 [4]
        
        loss = criterion(predictions, labels)
        
        # 反向传播
        loss.backward()
        optimizer.step()
        
        total_loss += loss.item()
    
    print(f"Epoch {epoch+1}, Loss: {total_loss/len(dataloader):.4f}")

# --- 预测新句子 ---
def predict_sentiment(sentence):
    model.eval()
    # 1. 预处理
    tokens = tokenizer(sentence)
    idx = [vocab.get(t, vocab["<UNK>"]) for t in tokens]
    # 简单的 padding 处理 (生产环境需要更严谨)
    if len(idx) < 10:
        idx += [0] * (10 - len(idx))
    else:
        idx = idx[:10]
        
    tensor = torch.LongTensor(idx).unsqueeze(0) # 加 batch 维度
    
    # 2. 推理
    with torch.no_grad():
        prediction = model(tensor).item()
        
    status = "正面 😄" if prediction > 0.5 else "负面 😡"
    print(f"评论: '{sentence}' -> {status} (概率: {prediction:.2f})")

print("\n--- 测试模型 ---")
predict_sentiment("This movie is amazing")
predict_sentiment("I hate this boring movie")

【小白避坑】维度地狱

在写 NLP 模型时,最容易报错的是维度不匹配。

  • Embedding 输入必须是整数 LongTensor
  • LSTM 的输出 hidden 包含三个维度 (num_layers, batch, hidden_size),千万别直接塞给全连接层,记得取 hidden[-1] 或者用 squeeze 去掉第一维。

9.6 章节小结

本章我们攻克了 AI 领域的另一座大山------自然语言处理。

  1. 数据预处理 :我们明白了机器不读字,只读数字。Tokenization -> Vocab -> Padding 是标准三板斧。
  2. Embedding:这是 NLP 的灵魂,它把离散的单词变成了连续的向量空间。
  3. LSTM:通过"门控"机制,它能像人类一样阅读,记住上下文的信息。
  4. 实战:我们从零手写了一个数据管道,完成了一个简单但五脏俱全的情感分析系统。

你可能会问:"LSTM 看起来很厉害,但为什么最近大家都在谈论 Transformer 和 ChatGPT?"

因为 LSTM 虽好,但它是一个字一个字读的(串行),速度慢,且长距离记忆能力依然有限。

Transformer 的出现改变了一切。它能一次性把整句话读进去(并行),并注意到句子中任何两个词之间的关联(Self-Attention)。这正是我们下一章要进入的领域------大模型时代


9.7 思考与扩展练习

  1. 双向 LSTM (Bi-LSTM)

    我们在读一句话时,不仅可以通过上文猜下文,也可以通过下文反推上文。PyTorch 的 LSTM 有一个参数 bidirectional=True。尝试开启它,注意开启后 hidden state 的维度会变大一倍,你的全连接层输入维度也需要 * 2

  2. 使用预训练词向量 (GloVe)

    本章的 Embedding 层是从零开始训练的。但在实际工程中,我们通常使用 Google 或 Stanford 训练好的词向量(如 GloVe 或 Word2Vec)。尝试搜索如何用 torchtext 或手动加载 GloVe 向量来初始化你的 nn.Embedding 层。

  3. 变长序列处理

    我们在代码中粗暴地用 0 填充了句子。其实 LSTM 可以处理变长序列,但这需要使用 PyTorch 的 pack_padded_sequence 工具。这是一个进阶技巧,能让模型忽略掉那些无意义的 0,从而提升训练速度和精度。有兴趣的读者可以查阅相关文档。

相关推荐
二川bro1 小时前
多模态AI开发:Python实现跨模态学习
人工智能·python·学习
张彦峰ZYF1 小时前
AI赋能原则1解读思考:超级能动性-AI巨变时代重建个人掌控力的关键能力
人工智能·ai·aigc·ai-native
2301_764441331 小时前
Python构建输入法应用
开发语言·python·算法
love530love1 小时前
【笔记】ComfUI RIFEInterpolation 节点缺失问题(cupy CUDA 安装)解决方案
人工智能·windows·笔记·python·插件·comfyui
Lucky小小吴1 小时前
Google《Prompt Engineering》2025白皮书——最佳实践十四式
人工智能·prompt
AI科技星1 小时前
为什么变化的电磁场才产生引力场?—— 统一场论揭示的时空动力学本质
数据结构·人工智能·经验分享·算法·计算机视觉
青瓷程序设计1 小时前
昆虫识别系统【最新版】Python+TensorFlow+Vue3+Django+人工智能+深度学习+卷积神经网络算法
人工智能·python·深度学习
咩图1 小时前
C#创建AI项目
开发语言·人工智能·c#
深蓝海拓1 小时前
opencv的模板匹配(Template Matching)学习笔记
人工智能·opencv·计算机视觉