Hình ảnh là dữ liệu có cấu trúc lưới cố định không thay đổi. Thế nhưng, ngôn ngữ tự nhiên hay tín hiệu âm thanh lại có tính chất hoàn toàn khác: chúng là dữ liệu chuỗi (Sequential Data) có độ dài biến thiên liên tục và mang tính thứ tự thời gian chặt chẽ.
Nếu ta đổi vị trí hai từ trong câu, nghĩa của câu có thể đảo ngược hoặc biến mất hoàn toàn. Để giải quyết bài toán xử lý chuỗi này, chúng ta cần tìm hiểu Mạng nơ-ron tuần hoàn (Recurrent Neural Network - RNN). Trong bài học này, ta sẽ đi sâu vào cấu trúc toán học của RNN, tìm hiểu lý do gây tiêu biến gradient (Vanishing Gradient), cách các bộ nhớ cổng như LSTM/GRU khắc phục lỗi này, và bước đệm vĩ đại nhất hướng tới các mô hình AI hiện đại: cơ chế tập trung chú ý Attention Mechanism.
pip install torch. Không tải dữ liệu, không cần mạng.
Kiến thức cần có: Bài 8 —
nn.Embedding, token ID và [UNK]. Dự
án bài này dựa trực tiếp lên đó, và cái bẫy lớn nhất của nó chính là [UNK]. Bài 6 cho vòng
lặp huấn luyện, Bài 2 cho tích vô hướng — mục 9.3 dùng lại tích vô hướng để tính điểm chú ý.
9.1 Cơ chế nhớ chuỗi thời gian của Recurrent Neural Network (RNN)
Mạng MLP hay CNN thông thường chỉ có một hướng truyền dữ liệu duy nhất từ đầu vào qua các lớp ẩn đến đầu ra (Feedforward). Chúng không hề có khái niệm thời gian hay ký ức: mỗi mẫu dữ liệu nạp vào được xử lý độc lập và không liên quan gì đến mẫu trước đó.
Mạng RNN khắc phục điều này bằng cách tạo ra một vòng lặp phản hồi (recurrent loop) bên trong tế bào nơ-ron. Tại mỗi bước thời gian $t$, mạng nơ-ron nhận đầu vào hiện tại $x_t$, đồng thời nhận lại trạng thái ẩn ở bước thời gian trước đó $h_{t-1}$ (được ví như ký ức của mạng từ quá khứ) để tính toán ra trạng thái ẩn mới $h_t$:
- $x_t$: Vector đặc trưng đầu vào tại bước thời gian $t$.
- $h_{t-1}$: Vector trạng thái ẩn tại bước thời gian trước đó $t-1$.
- $W_{hh}$: Ma trận trọng số kết nối giữa các trạng thái ẩn liên tiếp.
- $W_{xh}$: Ma trận trọng số kết nối từ đầu vào đến trạng thái ẩn.
- $b_h$: Vector chệch (bias).
Tuy nhiên, khi huấn luyện RNN trên các chuỗi dài (ví dụ: đoạn văn có hơn 50 từ), chúng ta vấp phải hiện tượng vật lý toán học cực kỳ nghiêm trọng: Tiêu biến Gradient (Vanishing Gradient).
Để tính đạo hàm cho các bước thời gian ở đầu chuỗi nhằm cập nhật trọng số, thuật toán BPTT (Backpropagation Through Time) phải liên tục nhân ma trận trọng số $W_{hh}^T$ qua từng bước thời gian. Nếu các giá trị riêng trị (eigenvalues) của ma trận $W_{hh}$ nhỏ hơn 1, việc nhân liên tục $N$ lần một số nhỏ hơn 1 sẽ khiến đạo hàm tiến dần về 0 theo hàm mũ: $$\lim_{N \to \infty} (0.9)^N = 0$$ Hậu quả là mạng nơ-ron bị mất khả năng học các mối quan hệ ngữ cảnh xa nhau (Long-term dependencies) và chỉ còn nhớ được các từ sát gần kề cuối câu.
- $N = 5$ bước: $(0.9)^5 \approx 0.5905$ — đạo hàm còn hơn một nửa.
- $N = 10$ bước: $(0.9)^{10} \approx 0.3487$.
- $N = 20$ bước: $(0.9)^{20} \approx 0.1216$.
- $N = 50$ bước: $(0.9)^{50} \approx 0.00515$ — chỉ còn nửa phần nghìn.
- $N = 100$ bước: $(0.9)^{100} \approx 0.0000266$ — gần như bằng 0 tuyệt đối.
NaN (Not a Number) lúc huấn luyện. Để sửa lỗi
này, kỹ sư AI luôn áp dụng kỹ thuật xén đạo hàm Gradient Clipping (ép giá trị đạo hàm
không vượt quá một ngưỡng nhất định trước khi cập nhật trọng số).
9.2 Lưới chọn lọc thông tin: Bộ nhớ dài-ngắn hạn LSTM & GRU
Để giải quyết triệt để lỗi tiêu biến gradient, Hochreiter và Schmidhuber đã đề xuất kiến trúc LSTM (Long Short-Term Memory). Thay vì chỉ duy trì một trạng thái ẩn $h_t$ đơn điệu, LSTM giới thiệu một luồng thông tin chạy song song gọi là Cell State ($c_t$) (được ví như đường cao tốc thông tin chạy suốt chuỗi) và sử dụng các cổng kiểm soát thông tin bằng hàm kích hoạt sigmoid $\sigma$:
- Cổng quên (Forget Gate - $f_t$): Quyết định lượng thông tin cũ nào từ quá khứ cần xóa bỏ khỏi Cell State: $$f_t = \sigma(W_f [h_{t-1}, x_t] + b_f)$$
- Cổng nạp (Input Gate - $i_t$): Quyết định lượng thông tin mới nào từ đầu vào sẽ được ghi thêm vào Cell State: $$i_t = \sigma(W_i [h_{t-1}, x_t] + b_i)$$ $$\tilde{c}_t = \tanh(W_c [h_{t-1}, x_t] + b_c)$$
- Cập nhật Cell State ($c_t$): Lấy thông tin cũ nhân chập với cổng quên, rồi cộng thêm thông tin mới: $$c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t$$
- Cổng xuất (Output Gate - $o_t$): Quyết định thông tin nào của Cell State sẽ được xuất ra làm trạng thái ẩn $h_t$ tiếp theo: $$o_t = \sigma(W_o [h_{t-1}, x_t] + b_o)$$ $$h_t = o_t \odot \tanh(c_t)$$
Nhờ việc cập nhật Cell State bằng phép cộng tuyến tính ($+$) thay vì phép nhân ma trận dồn dập, đạo hàm có thể truyền thẳng ngược lại quá khứ mà không bị tiêu biến, giúp LSTM học được các mối ngữ cảnh rất dài.
GRU (Gated Recurrent Unit) là phiên bản tinh gọn của LSTM. Nó gộp Cell State và Hidden State làm một, đồng thời chỉ sử dụng 2 cổng: Cổng cập nhật (Update Gate) và Cổng thiết lập lại (Reset Gate). GRU tính toán nhanh hơn và tiết kiệm bộ nhớ hơn nhưng vẫn đạt được hiệu năng tương đương LSTM trên nhiều tác vụ.
9.3 Khái niệm Attention: Cơ chế chú ý tập trung
Trong mô hình Sequence-to-Sequence truyền thống sử dụng để dịch thuật (ví dụ: LSTM Encoder-Decoder), Encoder có nhiệm vụ nén toàn bộ câu văn dài đầu vào thành một vector trạng thái ẩn cuối cùng duy nhất (gọi là Context Vector), rồi đưa cho Decoder dịch ra câu mới.
Cách làm này giống như bạn bắt ép bộ não học thuộc lòng toàn bộ cuốn sách rồi viết lại bản dịch mà không cho phép lật lại trang sách cũ để tra cứu. Khi câu văn đầu vào quá dài, Context Vector sẽ bị quá tải thông tin, gây suy giảm nghiêm trọng chất lượng dịch.
Cơ chế Attention (Sự chú ý) ra đời để giải quyết điểm nghẽn này. Triết lý của Attention cực kỳ đơn giản: Cho phép Decoder nhìn lại toàn bộ các trạng thái ẩn của Encoder ở mọi bước thời gian, tự động tính toán xem từ nào ở câu gốc đóng vai trò quan trọng nhất đối với từ sắp được dịch ra:
- Tính điểm tương đồng (Attention Scores): Đo góc lệch hướng giữa trạng thái ẩn của Decoder và từng từ của Encoder: $$\text{score}(s_t, h_i) = s_t^T h_i$$
- Tính trọng số chú ý (Attention Weights): Chuẩn hóa điểm số bằng hàm Softmax để có một phân phối xác suất tổng bằng 1: $$\alpha_{ti} = \frac{\exp(\text{score}(s_t, h_i))}{\sum_{k=1}^{T} \exp(\text{score}(s_t, h_k))}$$
- Tính toán Context Vector ($c_t$): Nhân chập trọng số chú ý với các vector ẩn của Encoder để tạo ra một vector ngữ cảnh động tích tụ thông tin quan trọng nhất: $$c_t = \sum_{i=1}^{T} \alpha_{ti} h_i$$
- Điểm số (tích vô hướng): $\text{score}(s_t, h_1) = 1.0$, $\text{score}(s_t, h_2) = 0.5$, $\text{score}(s_t, h_3) = 1.1$ — từ $h_3$ có điểm cao nhất vì hướng vector gần với $s_t$ nhất.
- Trọng số Softmax: $\alpha = (0.3688,\ 0.2237,\ 0.4076)$ — tổng đúng bằng $1$, và $h_3$ (điểm cao nhất) nhận trọng số chú ý lớn nhất, đúng như kỳ vọng.
- Context Vector: $c_t = 0.3688 \cdot h_1 + 0.2237 \cdot h_2 + 0.4076 \cdot h_3 = (0.6948,\ 0.4682)$ — một vector pha trộn, nghiêng nhiều nhất về phía $h_3$ nhưng vẫn giữ lại một phần thông tin của $h_1$ và $h_2$, thay vì phải chọn "một hoặc không gì cả" như Context Vector tĩnh truyền thống.
9.4 Dự án thực hành bài 9: LSTM phân loại cảm xúc — và giới hạn của nó
Dự án của bài này huấn luyện một mạng LSTM phân loại đánh giá dịch vụ thành tích cực hoặc tiêu cực. Nó chạy được, cho kết quả trông rất thuyết phục — và phần đáng học nhất lại nằm ở chỗ nó thất bại.
Vì sao hai câu kiểm tra trông như thành công: chúng dùng lại từ của tập huấn luyện. Câu "đồ ăn ngon phục vụ nhanh tuyệt vời" có 4 từ (ngon, nhanh, tuyệt, vời) chỉ xuất hiện ở các câu tích cực và 0 từ riêng của phía tiêu cực. Câu còn lại có 5 từ chỉ ở phía tiêu cực. Không một từ nào là mới. Bài kiểm tra được dựng sao cho không thể sai.
Nên script có thêm một phép thử trung thực: hai câu cùng nghĩa nhưng toàn bộ từ đều mới. Kết quả ở phần dưới cho thấy điều thực sự xảy ra khi mô hình không còn gì để tra cứu.
Nói rõ để tránh hiểu sai: LSTM không yếu. Kiến trúc ở đây đúng và là kiến trúc thật. Vấn đề là lượng dữ liệu, và bài này cố tình để bạn nhìn thấy ranh giới đó bằng số.
# sentiment_lstm.py
# Lesson 9: Recurrent networks (RNN) and the rise of attention
# Practical AI Engineer series
#
# Run it with: python sentiment_lstm.py
# Requires: pip install torch
#
# READ THIS BEFORE BELIEVING THE OUTPUT.
# Six training sentences is far too few to learn sentiment. What the model can do
# with six sentences is memorise which specific WORDS go with which label — and
# that is exactly what it does. The script therefore ends with a test on entirely
# unseen words, where it scores about 50/50: a coin flip. That contrast is the
# point of the project, not an accident.
#
# The Vietnamese review text stays Vietnamese: it is the DATA being classified,
# and it is what makes the word-memorisation effect visible.
import torch
import torch.nn as nn
import torch.optim as optim
# Seed everything, so the numbers printed in the lesson can actually be compared.
torch.manual_seed(42)
# 1. A tiny sample dataset of Vietnamese service reviews.
dataset = [
("dịch vụ xuất sắc nhân viên thân thiện", 1), # 1: Tích cực
("đồ ăn ngon phục vụ rất nhanh", 1),
("sản phẩm tuyệt vời đóng gói cẩn thận", 1),
("quá tệ đồ ăn nguội lạnh phục vụ kém", 0), # 0: Tiêu cực
("giao hàng chậm trễ chất lượng tồi tệ", 0),
("thái độ nhân viên rất lồi lõm không mua lại", 0)
]
# 2. A crude word-level tokenizer.
words = set()
for text, _ in dataset:
words.update(text.split())
# sorted() matters: a Python set iterates in an order that changes between runs
# (string hashing is randomised), which would give the words different IDs every
# run and make the output impossible to reproduce.
vocab = {word: idx + 2 for idx, word in enumerate(sorted(words))} # 0: padding, 1: OOV
vocab["[PAD]"] = 0
vocab["[UNK]"] = 1
inverse_vocab = {v: k for k, v in vocab.items()}
# Turn text into a fixed-length sequence of integers, padding the short ones.
def text_to_sequence(text, max_len=8):
tokens = text.split()
seq = []
for token in tokens:
seq.append(vocab.get(token, 1))
# Padding hoặc Truncate
if len(seq) < max_len:
seq += [0] * (max_len - len(seq))
else:
seq = seq[:max_len]
return seq
# Build the input tensors.
x_data = torch.tensor([text_to_sequence(text) for text, _ in dataset], dtype=torch.long)
y_data = torch.tensor([label for _, label in dataset], dtype=torch.float32).unsqueeze(1)
# 3. The SentimentLSTM architecture.
class SentimentLSTM(nn.Module):
def __init__(self, vocab_size, embedding_dim, hidden_dim):
super(SentimentLSTM, self).__init__()
# padding_idx=0 tells the layer to keep the padding vector at zero and never train it.
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
# The recurrent layer.
# batch_first=True gives the input shape (batch, sequence length, features).
self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
# Binary classifier head; Sigmoid so BCELoss can read it.
self.classifier = nn.Linear(hidden_dim, 1)
self.sigmoid = nn.Sigmoid()
def forward(self, x):
# x shape: (Batch, Sequence Length)
embedded = self.embedding(x) # shape: (Batch, Sequence Length, Embedding Dim)
# Run the sequence through the LSTM.
# out: the hidden state at EVERY time step
# (hn, cn): the hidden state and cell state at the LAST time step only
out, (hn, cn) = self.lstm(embedded)
# Take the final hidden state as a summary of the whole sentence. This single
# vector is the bottleneck that attention (section 9.3) exists to remove.
last_hidden = hn[-1] # shape: (Batch, Hidden Dim)
logits = self.classifier(last_hidden)
predictions = self.sigmoid(logits)
return predictions
if __name__ == "__main__":
print("=== Preparing the training data ===")
print(f"vocabulary size: {len(vocab)}")
print(f"input tensor shape: {x_data.shape}\n")
# Build the model.
model = SentimentLSTM(vocab_size=len(vocab), embedding_dim=16, hidden_dim=8)
criterion = nn.BCELoss() # binary cross-entropy, for a two-class problem
optimizer = optim.Adam(model.parameters(), lr=0.01)
epochs = 40
print("=== Training the SentimentLSTM ===")
for epoch in range(1, epochs + 1):
model.train()
optimizer.zero_grad()
predictions = model(x_data)
loss = criterion(predictions, y_data)
loss.backward()
optimizer.step()
if epoch % 10 == 0:
# Training accuracy only — there is no held-out set here, by design.
binary_predictions = (predictions >= 0.5).float()
accuracy = (binary_predictions == y_data).sum().item() / len(y_data) * 100
print(f"Epoch {epoch:02d}/{epochs} | Loss: {loss.item():.4f} | Accuracy: {accuracy:.1f}%")
print("\n=== Inference on words the model HAS seen ===")
model.eval()
def predict(comment):
seq = torch.tensor([text_to_sequence(comment)], dtype=torch.long)
with torch.no_grad():
p = model(seq).item()
unknown = [w for w in comment.split() if w not in vocab]
label = "positive" if p >= 0.5 else "negative"
print(f" \"{comment}\"")
print(f" {p * 100:6.2f}% positive -> {label:8} | {len(unknown)}/{len(comment.split())} words unknown")
return p
# Every word in these two sentences already appears in the training data.
predict("đồ ăn ngon phục vụ nhanh tuyệt vời")
predict("phục vụ quá tệ chất lượng tồi")
print("\n=== The honest test: words the model has NEVER seen ===")
# Same sentiment, completely different vocabulary. Every token becomes [UNK],
# so the model has nothing memorised to fall back on.
p_good = predict("bánh mì thơm giòn lịch sự")
p_bad = predict("nhà hàng bẩn thỉu hôi hám")
print("\n=== What that means ===")
good_label = "positive" if p_good >= 0.5 else "negative"
bad_label = "positive" if p_bad >= 0.5 else "negative"
if good_label == bad_label:
print(f" Both sentences came out {good_label}, even though one praises and one")
print(" complains. Every one of their words is [UNK], so the model has nothing")
print(" memorised to go on and simply collapses to one side.")
print(" With 6 training sentences the model memorised which WORDS carry which")
print(" label — it learned nothing about sentiment itself. Swap the words and the")
print(" knowledge is gone. That is the real lesson of this project.")
print(" Real sentiment analysis needs thousands of examples, or embeddings")
print(" pretrained on a large corpus. Lesson 14 uses the pretrained route.")
Chạy nó ra như sau:
=== Preparing the training data ===
vocabulary size: 43
input tensor shape: torch.Size([6, 8])
=== Training the SentimentLSTM ===
Epoch 10/40 | Loss: 0.5675 | Accuracy: 100.0%
Epoch 40/40 | Loss: 0.0536 | Accuracy: 100.0%
=== Inference on words the model HAS seen ===
"đồ ăn ngon phục vụ nhanh tuyệt vời"
78.68% positive -> positive | 0/8 words unknown
"phục vụ quá tệ chất lượng tồi"
17.18% positive -> negative | 0/7 words unknown
=== The honest test: words the model has NEVER seen ===
"bánh mì thơm giòn lịch sự"
36.33% positive -> negative | 6/6 words unknown
"nhà hàng bẩn thỉu hôi hám"
31.46% positive -> negative | 5/6 words unknown
=== What that means ===
Both sentences came out negative, even though one praises and one
complains. Every one of their words is [UNK], so the model has nothing
memorised to go on and simply collapses to one side.
Hãy đọc bốn con số cuối cạnh nhau, vì đó là toàn bộ bài học. Với từ đã thấy: 78,68% và 17,18% — tách bạch, trông như một bộ phân loại cảm xúc hoạt động tốt. Với từ hoàn toàn mới: 36,33% và 31,46% — cả hai câu đều bị xếp là tiêu cực, dù một câu khen và một câu chê.
Mô hình không hề học được cảm xúc. Nó học được rằng token số 12 đi với nhãn 1. Khi mọi token đều
thành [UNK], nó không còn gì để tra cứu nên dồn hết về một phía. Độ chính xác 100% trên tập
huấn luyện là học vẹt 6 câu — đúng dấu hiệu đã gặp ở Bài 7, chỉ khác là ở đây nó được che kín hơn vì hai
câu kiểm tra dùng lại từ cũ.
Hai: đếm số token
[UNK] trong mỗi đầu vào lúc suy luận, và ghi nhật ký nó.
Tỉ lệ [UNK] cao là dấu hiệu dự đoán đó không đáng tin — chính là lý do mảnh từ (BPE ở Bài
8) được sinh ra. Ba: LSTM ở đây không có lỗi. Kiến trúc đúng, vòng lặp đúng, chỉ dữ liệu là quá ít. Cách thoát không phải mạng lớn hơn mà là embedding đã được huấn luyện sẵn trên kho ngữ liệu lớn — Bài 14 đi theo hướng đó.
Cách chạy dự án này trên máy bạn
- Cài:
pip install torch. - Tải
sentiment_lstm.pyở cuối bài, hoặc gõ lại đoạn code trên. -
Chạy:
python3 sentiment_lstm.py. Bạn sẽ ra đúng những con số trên, nhờtorch.manual_seed(42)và một chi tiết nữa: từ vựng đượcsorted()trước khi gán ID. Không sort thì thứ tự lặp củasetđổi mỗi lần chạy (Python ngẫu nhiên hoá hash chuỗi), ID từ đổi theo, và kết quả không bao giờ giống lần trước. -
Rồi thử hai việc:
-
Thêm 6 câu nữa vào
dataset— tự viết, đủ hai phía. Chạy lại và xem phép thử "từ hoàn toàn mới" có nhích lên không. Bạn sẽ thấy cần nhiều dữ liệu tới mức nào để nó bắt đầu có ý nghĩa. -
Đổi
nn.LSTMthànhnn.RNN(giữ nguyên mọi tham số khác). Trên câu ngắn 8 token thì gần như không khác — đúng như mục 9.1 và 9.2 đã nói: vanishing gradient chỉ trở thành vấn đề với chuỗi dài.
-
Thêm 6 câu nữa vào
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: cơ chế nhớ chuỗi của RNN/LSTM, ba cổng của LSTM, và vì sao tiêu biến gradient chỉ thành vấn đề với chuỗi dài.
- Đạt được: ý tưởng của Attention — mô hình tự chọn từ nào đáng chú ý, thay vì nén cả câu vào một vector duy nhất.
- Đạt được: một phản xạ kiểm thử: kiểm tra trên từ vựng khác, không chỉ câu khác. Đổi câu mà giữ từ thì vẫn đang kiểm tra trên tập huấn luyện.
-
Đạt được: đo tỉ lệ
[UNK]lúc suy luận như một chỉ báo độ tin cậy — và hiểu vì sao mảnh từ (BPE, Bài 8) ra đời. - Đạt được: phân biệt "kiến trúc sai" với "dữ liệu quá ít". Dự án bài này có kiến trúc đúng và vẫn thất bại trên từ mới — cách thoát là embedding huấn luyện sẵn, không phải mạng lớn hơn.
Cầu nối bài tiếp theo: Loại bỏ hoàn toàn tính tuần hoàn chậm chạp của RNN, cơ chế Attention đã khai sinh ra kiến trúc Transformer huyền thoại - động cơ của mọi LLM lớn hiện nay - mà ta sẽ giải phẫu ở Bài số 10.
Tải file code thực hành minh họa bài học
File Python sentiment_lstm.py — mã nguồn xây dựng mạng LSTM phân loại cảm xúc câu đánh giá
tiếng Việt (chạy python sentiment_lstm.py, yêu cầu cài đặt thư viện torch):
📖 Tài liệu tham khảo
- Understanding LSTM Networks — Bài viết trực quan hóa xuất sắc nhất về cơ chế hoạt động của các cổng LSTM (Chris Olah's Blog)
- PyTorch nn.LSTM Documentation — Hướng dẫn kỹ thuật và mô tả đầu vào/đầu ra của lớp LSTM (Official Docs)
- Neural Machine Translation by Jointly Learning to Align and Translate — Bài báo khoa học gốc đề xuất cơ chế Attention (Bahdanau et al., 2014)
Bình luận