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.

✅ Bạn cần gì trước khi bắt đầu
Thư viện: 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$:

📐 Công thức toán học của RNN cơ bản (Vanilla RNN)
Trạng thái ẩn $h_t$ tại bước thời gian $t$ được cập nhật thông qua hàm kích hoạt phi tuyến tính $\tanh$ (hoặc $\text{ReLU}$): $$h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b_h)$$ Trong đó:
  • $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).
Sau đó, đầu ra dự đoán $y_t$ (nếu có) được tính từ trạng thái ẩn hiện tại: $$y_t = \text{Softmax}(W_{hy} h_t + b_y)$$

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.

🔢 Con số thật: tốc độ tiêu biến của $(0.9)^N$
Giới hạn $\lim_{N \to \infty} (0.9)^N = 0$ nghe trừu tượng, nhưng tốc độ hội tụ về 0 nhanh đến mức đáng ngạc nhiên ngay cả với trị riêng "hiền lành" như $0.9$ (rất gần 1):
  • $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.
Một đoạn văn 100 từ là chuyện rất bình thường trong xử lý ngôn ngữ tự nhiên, nên chỉ với trị riêng $0.9$ (chưa hề "nguy hiểm" theo trực giác), gradient của từ đầu câu gần như không còn tác động gì đến việc cập nhật trọng số nữa — đây là lý do Vanilla RNN gần như vô dụng với ngữ cảnh dài trong thực tế.
⚠️ Cạm bẫy: Hiện tượng bùng nổ Gradient (Exploding Gradient)
Ngược lại với tiêu biến, nếu các giá trị riêng trị của ma trận $W_{hh}$ lớn hơn 1, đạo hàm sẽ tăng lên theo cấp số nhân khiến mô hình bị lỗi tràn số 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ụ.

⚠️ Cạm bẫy: Điểm nghẽn tính toán tuần tự của RNN/LSTM
Do bản chất toán học của lớp tuần hoàn: bước tính toán tiếp theo bắt buộc phải đợi kết quả trạng thái ẩn của bước trước đó ($h_{t-1}$), chúng ta không thể song song hóa việc huấn luyện RNN trên phần cứng GPU hiện đại. Đây là điểm nghẽn chí mạng khiến RNN/LSTM bị lép vế hoàn toàn trước kiến trúc CNN và Transformer trong kỷ nguyên dữ liệu lớn.

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:

🧠 Thuật toán Dot-product Attention sơ khởi
Giả sử Decoder đang ở trạng thái ẩn hiện tại là $s_t$, và toàn bộ các trạng thái ẩn đầu ra của Encoder là $h_1, h_2, \dots, h_T$:
  1. 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$$
  2. 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))}$$
  3. 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$$
Decoder sử dụng vector ngữ cảnh động này để đưa ra từ dịch chính xác nhất.
🔢 Ví dụ tính tay Dot-product Attention (đã kiểm chứng bằng số thật)
Giả sử câu gốc chỉ có 3 từ, Encoder tạo ra 3 trạng thái ẩn 2 chiều: $h_1 = (1.0, 0.0)$, $h_2 = (0.0, 1.0)$, $h_3 = (0.8, 0.6)$. Decoder đang ở trạng thái $s_t = (1.0, 0.5)$.
  1. Đ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.
  2. 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.
  3. 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.

⚠️ Sáu câu huấn luyện: mô hình học vẹt TỪ, không học cảm xúc
Tập huấn luyện có đúng 6 câu, từ vựng 43 token. Với lượng dữ liệu đó, mạng không thể học được khái niệm "cảm xúc" — nó chỉ có thể ghi nhớ từ nào đi với nhãn nào. Và đó chính là điều nó làm.

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
# 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:

Terminal
=== 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ũ.

💡 Rút ra được gì thật sự dùng được
Một: luôn kiểm tra trên dữ liệu có từ vựng khác, không chỉ có câu khác. Đổi câu mà giữ từ thì bạn vẫn đang kiểm tra trên tập huấn luyện.

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

  1. Cài: pip install torch.
  2. Tải sentiment_lstm.py ở cuối bài, hoặc gõ lại đoạn code trên.
  3. 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 được sorted() trước khi gán ID. Không sort thì thứ tự lặp của set đổ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.
  4. 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.LSTM thành nn.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.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt đượ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 về sentiment_lstm.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 8: Xử lý Văn bản & Word Embeddings Bài 10: Cơ chế Self-Attention & Kỷ nguyên Transformer Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận