從RNN的“記憶崩潰”到LSTM的“三閘調(diào)控”:史上最詳細(xì)的LSTM教程(附PyTorch實戰(zhàn)項目)

你是不是也遇到過這種情況:教神經(jīng)網(wǎng)絡(luò)學(xué)說話,它總是“說完就忘”,前一秒提到“小明”,后一秒就不知道主語是誰了。這就是傳統(tǒng)RNN的“健忘癥”。今天,我們不堆公式,用人話 + 故事 + 完整代碼,把LSTM這個“記憶大師”徹底講明白。文末還附贈一個能判斷淘寶評論是好評還是差評的完整項目,拿來就能跑。


一、RNN為什么像個“金魚腦”?

想象你在玩一個傳話游戲:

第一個人說“小明的生日是5月20日”,第二個人重復(fù)并加一句“他喜歡踢足球”,第三個人再加一句“他家住在北京”……傳到第50個人的時候,第一個人說的“5月20日”早就丟了。

傳統(tǒng)的循環(huán)神經(jīng)網(wǎng)絡(luò)(RNN)就是這樣:它有一個“記憶盒子”(隱藏狀態(tài) h),每次看到新詞,就把盒子里的舊信息和新詞混在一起,再放回盒子。問題是,每次混合都會稀釋舊信息。傳到幾十步之后,最早的詞就像一滴墨水倒進大海,找不到了。

這就是梯度消失——數(shù)學(xué)上,反向傳播時,每往前傳一步,梯度就乘一個小于1的數(shù),乘幾十次就約等于0了。

二、LSTM的妙招:修一條“記憶高速公路”

LSTM(長短期記憶網(wǎng)絡(luò))換了個思路:不讓新信息把舊信息沖走,而是單獨修一條“記憶高速公路”(細(xì)胞狀態(tài) C),再裝三個“收費站”來控制什么車能上高速、什么車該下高速、什么車能出去。

這三個收費站就是:

  • 遺忘門

    :決定哪些舊記憶要扔掉(比如主語換了,舊主語就該忘)

  • 輸入門

    :決定哪些新信息值得記?。ū热缧鲁霈F(xiàn)的主角名)

  • 輸出門

    :決定此時此刻應(yīng)該說出什么(比如根據(jù)記憶回答“他喜歡什么”)

這樣一來,重要的信息可以順著高速公路一直傳下去,不會因為新詞進來就被稀釋。

三、一張生活場景圖,秒懂三扇門

場景:讀一段關(guān)于“小美”的評論

假設(shè)LSTM已經(jīng)讀了“小美很喜歡吃榴蓮”,現(xiàn)在讀到“但是她的男朋友受不了那個味道”。

遺忘門:看了一眼舊記憶“小美喜歡榴蓮”,又看了看新輸入“男朋友受不了”,心想:“男朋友的感受跟小美的喜好關(guān)系不大,還是保留‘小美喜歡榴蓮’這個事實吧。”于是遺忘門輸出一個接近1的值,表示大部分舊記憶都要留著。

輸入門:從“男朋友受不了”里提取新信息“男朋友討厭榴蓮味”,覺得這個值得記下來,于是輸入門輸出接近1,候選記憶是“男朋友討厭榴蓮味”。兩者相乘后存入高速公路。

細(xì)胞狀態(tài)更新:高速公路上的舊記憶(小美喜歡榴蓮)乘以遺忘門(≈1,幾乎全留),加上新記憶(男朋友討厭)乘以輸入門(≈1,全存)?,F(xiàn)在高速公路上既有“小美喜歡榴蓮”,又有“男朋友討厭榴蓮”。

輸出門:如果要預(yù)測下一個詞(比如“所以,他們常常因為吃榴蓮吵架”),輸出門會從高速公路里提取相關(guān)信息。如果問題是“誰喜歡榴蓮?”,輸出門會重點取出“小美”那部分;如果問題是“男朋友怎么看?”,會取出“討厭”那部分。

你看,LSTM不是把舊信息覆蓋掉,而是并排存放,需要哪個取哪個。

四、為什么LSTM不會“健忘”?——一個不燒腦的解釋

在RNN里,記憶的傳遞是“加加減減”,每次乘一個小數(shù)。而在LSTM里,記憶高速公路的更新公式是:

新記憶 = 舊記憶 × 遺忘門 + 新知識 × 輸入門

反向傳播時,舊記憶的梯度 = 新記憶的梯度 × 遺忘門。因為遺忘門在大多數(shù)情況下接近1(模型更愿意保留信息而不是忘記),所以梯度幾乎不會衰減。就算傳100步,0.99的100次方還有0.366,遠(yuǎn)好于RNN的0.25的100次方≈10的-60次方。

簡單說:LSTM給梯度留了一條VIP通道,幾乎不用排隊損耗。

五、PyTorch中的LSTM:一行代碼就能用

PyTorch已經(jīng)幫我們實現(xiàn)好了,我們只需要學(xué)會怎么用。

import torch
import torch.nn as nn
 
# 創(chuàng)建LSTM層
lstm = nn.LSTM(
? ? input_size=64, ? ? # 每個詞用64個數(shù)字表示(詞向量維度)
? ? hidden_size=128, ? # 記憶盒子的尺寸(隱藏狀態(tài)維度)
? ? num_layers=2, ? ? ?# 疊兩層LSTM,效果更好
? ? batch_first=True, ?# 輸入形狀:(批次, 序列長度, 特征)
? ? bidirectional=True # 雙向LSTM(能看上下文)
)

輸入和輸出長什么樣?

# 假設(shè)有32條評論,每條評論有10個詞,每個詞用64維向量表示
input = torch.randn(32, 10, 64)
 
# 初始化隱藏狀態(tài)和細(xì)胞狀態(tài)(全0)
h0 = torch.zeros(2, 32, 128) ? # 2層×單向=2
c0 = torch.zeros(2, 32, 128)
 
output, (hn, cn) = lstm(input, (h0, c0))
 
# output形狀:(32, 10, 128) ?每個時間步的隱藏狀態(tài)
# hn形狀:(2, 32, 128) ? ? ? 最后時間步每層的隱藏狀態(tài)
# cn形狀:(2, 32, 128) ? ? ? 最后時間步每層的細(xì)胞狀態(tài)

重點:batch_first=True會讓輸入輸出都是(batch, seq_len, feature),更符合直覺。

六、實戰(zhàn):從零搭建一個評論情感分類器

我們用一個真實的電商評論數(shù)據(jù)集(京東/淘寶評論),訓(xùn)練一個LSTM模型,讓它學(xué)會分辨“好評”和“差評”。

項目文件結(jié)構(gòu)

sentiment_lstm/
├── data/
│ ? ├── raw/ ? ? ? ? ? ? ?# 原始CSV文件
│ ? └── processed/ ? ? ? ?# 處理后數(shù)據(jù)
├── models/ ? ? ? ? ? ? ? # 保存模型
├── src/
│ ? ├── config.py ? ? ? ? # 配置文件
│ ? ├── tokenizer.py ? ? ?# 中文分詞器
│ ? ├── dataset.py ? ? ? ?# 數(shù)據(jù)加載器
│ ? ├── model.py ? ? ? ? ?# LSTM模型
│ ? ├── train.py ? ? ? ? ?# 訓(xùn)練代碼
│ ? └── predict.py ? ? ? ?# 交互式預(yù)測

第一步:配置文件(config.py)

from pathlib import Path
 
# 路徑
BASE = Path(__file__).parent.parent
RAW_DATA = BASE / 'data' / 'raw'
PROCESSED = BASE / 'data' / 'processed'
MODELS = BASE / 'models'
 
# 超參數(shù)
SEQ_LEN = 100 ? ? ? ? ? ?# 每條評論最多取100個詞
BATCH_SIZE = 64 ? ? ? ? ?# 一次喂64條
EMBED_SIZE = 64 ? ? ? ? ?# 詞向量維度
HIDDEN_SIZE = 128 ? ? ? ?# LSTM隱藏層大小
NUM_LAYERS = 2 ? ? ? ? ? # 2層LSTM
LR = 0.001 ? ? ? ? ? ? ? # 學(xué)習(xí)率
EPOCHS = 20 ? ? ? ? ? ? ?# 訓(xùn)練20輪

第二步:分詞器(tokenizer.py)

import jieba
from collections import Counter
 
class Tokenizer:
? ? PAD = '<PAD>'
? ? UNK = '<UNK>'
 
? ? @classmethod
? ? def build_vocab(cls, sentences, min_freq=2):
? ? ? ? """從句子列表構(gòu)建詞表,只保留出現(xiàn)次數(shù)>=min_freq的詞"""
? ? ? ? counter = Counter()
? ? ? ? for sent in sentences:
? ? ? ? ? ? words = jieba.lcut(sent)
? ? ? ? ? ? counter.update(words)
? ? ? ? # 按頻率排序,低頻詞扔掉
? ? ? ? vocab = [cls.PAD, cls.UNK] + [w for w, c in counter.items() if c >= min_freq]
? ? ? ? return vocab
 
? ? def __init__(self, vocab):
? ? ? ? self.word2idx = {w: i for i, w in enumerate(vocab)}
? ? ? ? self.idx2word = {i: w for w, i in self.word2idx.items()}
? ? ? ? self.pad_idx = self.word2idx[cls.PAD]
? ? ? ? self.unk_idx = self.word2idx[cls.UNK]
 
? ? def encode(self, sentence, max_len):
? ? ? ? """把句子變成數(shù)字列表,并截斷/填充到固定長度"""
? ? ? ? words = jieba.lcut(sentence)
? ? ? ? ids = [self.word2idx.get(w, self.unk_idx) for w in words]
? ? ? ? if len(ids) > max_len:
? ? ? ? ? ? ids = ids[:max_len]
? ? ? ? else:
? ? ? ? ? ? ids += [self.pad_idx] * (max_len - len(ids))
? ? ? ? return ids

第三步:數(shù)據(jù)預(yù)處理

假設(shè)原始CSV有兩列:review(評論文本)和label(1=好評,0=差評)。

import pandas as pd
from sklearn.model_selection import train_test_split
from tokenizer import Tokenizer
import config
 
# 讀取數(shù)據(jù)
df = pd.read_csv(config.RAW_DATA / 'comments.csv', usecols=['review', 'label'])
df = df.dropna()
df = df[df['review'].str.strip() != '']
 
# 劃分訓(xùn)練集和測試集
train_df, test_df = train_test_split(df, test_size=0.2, random_state=42)
 
# 構(gòu)建詞表(只用訓(xùn)練集)
vocab = Tokenizer.build_vocab(train_df['review'].tolist(), min_freq=3)
tokenizer = Tokenizer(vocab)
 
# 編碼文本
train_df['ids'] = train_df['review'].apply(lambda x: tokenizer.encode(x, config.SEQ_LEN))
test_df['ids'] = test_df['review'].apply(lambda x: tokenizer.encode(x, config.SEQ_LEN))
 
# 保存處理后的數(shù)據(jù)
train_df[['ids', 'label']].to_json(config.PROCESSED / 'train.json', orient='records', lines=True)
test_df[['ids', 'label']].to_json(config.PROCESSED / 'test.json', orient='records', lines=True)

第四步:模型定義(model.py)

import torch
import torch.nn as nn
import config
 
class SentimentLSTM(nn.Module):
? ? def __init__(self, vocab_size, pad_idx):
? ? ? ? super().__init__()
? ? ? ? # 把詞ID轉(zhuǎn)成稠密向量
? ? ? ? self.embedding = nn.Embedding(vocab_size, config.EMBED_SIZE, padding_idx=pad_idx)
? ? ? ? # LSTM核心
? ? ? ? self.lstm = nn.LSTM(
? ? ? ? ? ? input_size=config.EMBED_SIZE,
? ? ? ? ? ? hidden_size=config.HIDDEN_SIZE,
? ? ? ? ? ? num_layers=config.NUM_LAYERS,
? ? ? ? ? ? batch_first=True,
? ? ? ? ? ? dropout=0.3 ?# 防止過擬合
? ? ? ? )
? ? ? ? # 分類器:把隱藏狀態(tài)轉(zhuǎn)成1個分?jǐn)?shù)
? ? ? ? self.classifier = nn.Linear(config.HIDDEN_SIZE, 1)
 
? ? def forward(self, x):
? ? ? ? # x形狀: (batch, seq_len)
? ? ? ? emb = self.embedding(x) ? ? ? ? ? ? ? ? # (batch, seq_len, embed_size)
? ? ? ? lstm_out, (hidden, cell) = self.lstm(emb) ?# hidden: (layers, batch, hidden_size)
? ? ? ? # 取最后一層的最后一個時間步的隱藏狀態(tài)
? ? ? ? last_hidden = hidden[-1] ? ? ? ? ? ? ? ?# (batch, hidden_size)
? ? ? ? logits = self.classifier(last_hidden).squeeze(1) ?# (batch,)
? ? ? ? return logits ?# 注意:沒有sigmoid,因為后面會用BCEWithLogitsLoss

第五步:訓(xùn)練代碼(train.py)

import torch
from torch.utils.data import DataLoader, Dataset
import jsonlines
from model import SentimentLSTM
from tokenizer import Tokenizer
import config
 
class ReviewDataset(Dataset):
? ? def __init__(self, jsonl_file):
? ? ? ? self.data = []
? ? ? ? with jsonlines.open(jsonl_file) as reader:
? ? ? ? ? ? for item in reader:
? ? ? ? ? ? ? ? self.data.append((item['ids'], item['label']))
 
? ? def __len__(self):
? ? ? ? return len(self.data)
 
? ? def __getitem__(self, idx):
? ? ? ? ids, label = self.data[idx]
? ? ? ? return torch.tensor(ids, dtype=torch.long), torch.tensor(label, dtype=torch.float32)
 
def train():
? ? device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
? ? print(f"用 {device} 訓(xùn)練")
 
? ? # 加載詞表
? ? tokenizer = Tokenizer.from_vocab(config.PROCESSED / 'vocab.txt') ?# 需實現(xiàn)from_vocab
? ? vocab_size = len(tokenizer.word2idx)
 
? ? # 加載數(shù)據(jù)
? ? train_dataset = ReviewDataset(config.PROCESSED / 'train.jsonl')
? ? test_dataset = ReviewDataset(config.PROCESSED / 'test.jsonl')
? ? train_loader = DataLoader(train_dataset, batch_size=config.BATCH_SIZE, shuffle=True)
? ? test_loader = DataLoader(test_dataset, batch_size=config.BATCH_SIZE)
 
? ? # 創(chuàng)建模型
? ? model = SentimentLSTM(vocab_size, tokenizer.pad_idx).to(device)
? ? loss_fn = torch.nn.BCEWithLogitsLoss()
? ? optimizer = torch.optim.Adam(model.parameters(), lr=config.LR)
 
? ? best_acc = 0
? ? for epoch in range(1, config.EPOCHS+1):
? ? ? ? # 訓(xùn)練一個epoch
? ? ? ? model.train()
? ? ? ? total_loss = 0
? ? ? ? for ids, labels in train_loader:
? ? ? ? ? ? ids, labels = ids.to(device), labels.to(device)
? ? ? ? ? ? optimizer.zero_grad()
? ? ? ? ? ? outputs = model(ids)
? ? ? ? ? ? loss = loss_fn(outputs, labels)
? ? ? ? ? ? loss.backward()
? ? ? ? ? ? # 梯度裁剪,防止爆炸
? ? ? ? ? ? torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
? ? ? ? ? ? optimizer.step()
? ? ? ? ? ? total_loss += loss.item()
 
? ? ? ? # 驗證
? ? ? ? model.eval()
? ? ? ? correct = 0
? ? ? ? total = 0
? ? ? ? with torch.no_grad():
? ? ? ? ? ? for ids, labels in test_loader:
? ? ? ? ? ? ? ? ids, labels = ids.to(device), labels.to(device)
? ? ? ? ? ? ? ? outputs = model(ids)
? ? ? ? ? ? ? ? preds = (torch.sigmoid(outputs) > 0.5).int()
? ? ? ? ? ? ? ? correct += (preds == labels.int()).sum().item()
? ? ? ? ? ? ? ? total += labels.size(0)
? ? ? ? acc = correct / total
? ? ? ? print(f"Epoch {epoch}: 訓(xùn)練損失={total_loss/len(train_loader):.4f}, 驗證準(zhǔn)確率={acc:.4f}")
 
? ? ? ? if acc > best_acc:
? ? ? ? ? ? best_acc = acc
? ? ? ? ? ? torch.save(model.state_dict(), config.MODELS / 'best_model.pt')
? ? ? ? ? ? print(f"保存模型,準(zhǔn)確率{acc:.4f}")
 
? ? print(f"訓(xùn)練完成,最佳準(zhǔn)確率: {best_acc:.4f}")
 
if __name__ == '__main__':
? ? train()

第六步:預(yù)測腳本(predict.py)

def predict_single(text, model, tokenizer, device):
? ? ids = tokenizer.encode(text, config.SEQ_LEN)
? ? input_tensor = torch.tensor([ids], dtype=torch.long).to(device)
? ? with torch.no_grad():
? ? ? ? logit = model(input_tensor).item()
? ? ? ? prob = 1 / (1 + torch.exp(-logit)) ?# sigmoid
? ? return prob
 
# 交互循環(huán)
while True:
? ? text = input("輸入評論:")
? ? if text == 'q': break
? ? prob = predict_single(text, model, tokenizer, device)
? ? print("正面" if prob > 0.5 else "負(fù)面", f"置信度:{prob if prob>0.5 else 1-prob:.2f}")

完整代碼下載:https://pan.baidu.com/s/1P5dRbXc12u_g8ViMBnToBA?pwd=rvge

七、讓LSTM更強大:堆疊和雙向

1. 堆疊多層LSTM(就像蓋樓)

單層LSTM學(xué)到的可能只是詞與詞之間的局部關(guān)系。你再在上面加一層LSTM,它就能學(xué)習(xí)短語級別的模式。再加一層,可能學(xué)句子結(jié)構(gòu)。一般2~3層就夠用了,太深容易過擬合且訓(xùn)練慢。

代碼:nn.LSTM(..., num_layers=2)

2. 雙向LSTM(既能看過去,又能看未來)

在很多情況下,一個詞的意思取決于它后面的詞。比如“這部電影不怎么樣,但是演員演得很好”——只看前半句是差評,看了后半句才知道是好評。雙向LSTM就是讓兩個LSTM同時讀:一個從左往右,一個從右往左,最后把兩個方向的信息拼在一起。

代碼:nn.LSTM(..., bidirectional=True)

此時輸出維度會變成hidden_size * 2。

3. 多層雙向

把兩個結(jié)合起來:num_layers=2, bidirectional=True。注意此時隱藏狀態(tài)的數(shù)量是num_layers * 2。

八、LSTM的缺點(它也不是萬能的)

問題

為什么

怎么辦

訓(xùn)練慢

必須一個詞一個詞地算,不能并行

用Transformer

參數(shù)多

4倍于RNN,手機跑不動

用GRU(少一個門)

太長的序列還是會忘

1000步以上,梯度還是會衰

加注意力機制

調(diào)參麻煩

門控多,學(xué)習(xí)率、初始化都要小心

用現(xiàn)成預(yù)訓(xùn)練模型(BERT)

目前,在機器翻譯、聊天機器人等大任務(wù)上,Transformer(就是ChatGPT用的那種架構(gòu))已經(jīng)取代了LSTM。但LSTM在時間序列預(yù)測、小規(guī)模文本分類、邊緣設(shè)備上仍然很好用。

九、總結(jié):一張圖記住LSTM

  • 遺忘門

    :保留舊記憶的比例(像篩子)

  • 輸入門

    :寫入新記憶的比例(像筆)

  • 輸出門

    :讀出記憶的比例(像嘴)

  • 細(xì)胞狀態(tài)

    :長時記憶高速公路

  • 隱藏狀態(tài)

    :短時工作記憶 + 輸出

一句話:LSTM通過給信息流裝上三個智能閘門,解決了RNN的梯度消失問題,讓它能記住幾百步之前的信息。雖然現(xiàn)在Transformer很火,但LSTM依然是每個AI工程師的必修課。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容