章節導語

“圖像是靜止的像素矩陣,而語言是流動的河流。你無法只看‘銀行’這兩個字就明白它的意思,因為在‘河邊的銀行’和‘存款的銀行’中,它的含義截然不同。”

歡迎來到人工智能最迷人也最困難的領域——自然語言處理(Natural Language Processing, NLP)

在上一章,我們處理的是圖像,它們是固定大小的網格(比如 機器學習算法--python實現應用機器學習於情感分析-訓練文檔分類_糯米君_#人工智能)。但文字是序列(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)

計算機喜歡整數索引。我們需要給每個單詞發一個“身份證號”。

# 模擬詞表
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 原生庫來實現一個穩健的數據管道。

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)

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),因為這是二分類問題。

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,從而提升訓練速度和精度。有興趣的讀者可以查閲相關文檔。