Danh sách bài viết

Bài 35: Vanilla RNN — cơ chế lan truyền trạng thái ẩn

Vanilla RNN là kiến trúc đầu tiên được thiết kế cho sequence: tại mỗi time step \( t \), hidden state \( h_t \) được cập nhật từ input hiện tại \( x_t \) và hidden state trước đó \( h_{t-1} \) theo công thức \( h_t = \tanh(W_{xh} x_t + W_{hh} h_{t-1} + b_h) \). Cùng một bộ weight \( (W_{xh}, W_{hh}, W_{hy}) \) được áp dụng cho mọi step (weight sharing through time), nên số parameter độc lập với độ dài chuỗi. Bài này dẫn ra công thức forward, đếm tham số, viết RNN bằng tay với PyTorch tensor, dùng nn.RNN với batch_first, mở rộng stacked / bidirectional / packed sequence, phân tích BPTT và liệt kê limitations để vào B36 (vanishing gradient).

24/05/2026
14 phút đọc
3 lượt xem
1

Mục tiêu bài học

Sau bài học, bạn sẽ:

  • Viết được công thức forward của Vanilla RNN cho 1 time step và cả chuỗi.
  • Hiểu vì sao RNN cần weight sharing through time và số parameter độc lập với sequence length.
  • Đếm chính xác parameter của một nn.RNN bất kỳ.
  • Implement RNN bằng tay với PyTorch tensor (tự loop time step) và đối chiếu với nn.RNN.
  • Phân biệt 3 use case theo cách dùng output: many-to-one, many-to-many same length, many-to-many different length.
  • Mở rộng sang stacked, bidirectional, packed sequence trong PyTorch.
  • Nắm sơ lược BPTT và biết vì sao gradient \( W_{hh} \) là nguồn vanishing / exploding (chi tiết B36).

Bài này nối Bài 34 — Sequential Data. Bài Bài 36 — Vanishing Gradient sẽ đào sâu vấn đề gradient của RNN khi chuỗi dài.

2

Vanilla RNN là gì

Vanilla RNN (recurrent neural network "trần", không cổng) là kiến trúc đầu tiên được thiết kế cho dữ liệu chuỗi. "Vanilla" để phân biệt với các biến thể có cổng (gating): LSTM (B37), GRU (B38). "Recurrent" vì cùng một cell được áp dụng lặp lại qua từng time step, lan thông tin về trước bằng một hidden state.

Ý tưởng được Elman (1990) phát biểu rõ trong "Finding Structure in Time": thêm một tập hidden unit ghi nhớ trạng thái của step trước, dùng làm input cho step kế tiếp. Sau đó được dùng phổ biến cho language model thập niên 1990s–2000s trước khi LSTM (Hochreiter & Schmidhuber, 1997) thay thế trong các ứng dụng chuỗi dài.

Một RNN cell ở mỗi time step nhận 2 input:

  • Input hiện tại \( x_t \) (vector \( \in \mathbb{R}^{d_x} \)).
  • Hidden state cũ \( h_{t-1} \) (vector \( \in \mathbb{R}^{d_h} \)).

Và xuất 2 thứ:

  • Hidden state mới \( h_t \in \mathbb{R}^{d_h} \) (truyền sang step kế).
  • (Tuỳ chọn) Output \( y_t \in \mathbb{R}^{d_y} \) tại step đó.
3

Ý tưởng cốt lõi — hidden state qua time

Cho một input sequence \( x_1, x_2, \ldots, x_T \) (mỗi \( x_t \) là vector). RNN duy trì một hidden state \( h_t \) được update tuần tự:

\[ h_t = f(x_t, h_{t-1}) \]

Hàm \( f \) cố định (cùng parameter) cho mọi \( t \). Hidden state \( h_t \) đóng vai trò "memory" — ghi nhớ thông tin của tất cả step từ 1 đến \( t \) trong một vector kích thước \( d_h \) cố định.

Tính chất quan trọng:

  • Memory có kích thước cố định: dù chuỗi dài 10 hay 10 000, \( h_t \) vẫn là vector \( d_h \) chiều. Đây là điểm khác biệt với "concat mọi step lại" — không khả thi vì \( T \) thay đổi.
  • Compression bottleneck: phải nhồi toàn bộ quá khứ vào \( d_h \) số float. Nếu \( d_h \) nhỏ, thông tin xa bị mất.
  • Tính tuần tự: \( h_t \) phụ thuộc \( h_{t-1} \) — không thể tính song song qua time. Đây là điểm yếu cốt lõi so với Transformer (Module sau).
4

Công thức forward

Công thức cốt lõi của một Vanilla RNN cell:

\[ h_t = \tanh(W_{xh}\, x_t + W_{hh}\, h_{t-1} + b_h) \]

\[ y_t = W_{hy}\, h_t + b_y \]

Các thành phần:

  • \( W_{xh} \in \mathbb{R}^{d_h \times d_x} \): chiếu input \( x_t \) vào không gian hidden.
  • \( W_{hh} \in \mathbb{R}^{d_h \times d_h} \): chiếu hidden cũ \( h_{t-1} \) vào hidden mới (đây là phần "recurrent").
  • \( W_{hy} \in \mathbb{R}^{d_y \times d_h} \): chiếu hidden \( h_t \) sang output \( y_t \).
  • \( b_h \in \mathbb{R}^{d_h}, b_y \in \mathbb{R}^{d_y} \): bias.
  • Activation \( \tanh \) — phổ biến nhất cho Vanilla RNN (B7). Có thể thay bằng ReLU nhưng dễ exploding hơn.

Ghi chú: trong một số tài liệu, hai linear bên trong tanh được viết gộp:

\[ h_t = \tanh\!\left( W \begin{bmatrix} x_t \\ h_{t-1} \end{bmatrix} + b_h \right) \]

với \( W = [W_{xh} \ \ W_{hh}] \in \mathbb{R}^{d_h \times (d_x + d_h)} \). Hai cách viết tương đương, PyTorch lưu riêng \( W_{ih} \) và \( W_{hh} \).

5

Initial hidden state \( h_0 \)

Cần một giá trị khởi đầu cho \( h_0 \) trước step 1. Hai lựa chọn thường gặp:

  • Zero init: \( h_0 = \mathbf{0} \in \mathbb{R}^{d_h} \). Mặc định của nn.RNN khi không truyền hidden vào.
  • Learnable: \( h_0 \) là một parameter của model, optimizer cập nhật cùng với weight khác.

Trong streaming / online inference (xử lý chuỗi rất dài chia thành chunk), \( h_0 \) của chunk \( k+1 \) thường được set bằng \( h_T \) của chunk \( k \) (truncated BPTT). Khi đó forward "nối" qua chunk, backward chỉ truyền trong phạm vi 1 chunk.

6

Unrolled view qua time step

RNN được vẽ theo hai dạng:

  • Compact: một cell với mũi tên vòng (self-loop) — gọn nhưng khó hình dung gradient.
  • Unrolled: "trải" cell ra theo time step thành một feed-forward network sâu \( T \) layer, mỗi layer là một bản copy của cell.

Mô tả text unrolled view cho \( T = 4 \):

x_1 ─► [cell] ─► h_1 ─► [cell] ─► h_2 ─► [cell] ─► h_3 ─► [cell] ─► h_4
       h_0 ─┘        x_2 ─┘        x_3 ─┘        x_4 ─┘
        │            │            │            │
        ▼            ▼            ▼            ▼
       y_1          y_2          y_3          y_4

Quan sát: mọi ô [cell] dùng chung một bộ \( (W_{xh}, W_{hh}, W_{hy}, b_h, b_y) \). Đây là cùng một function được gọi 4 lần, không phải 4 layer khác nhau. Khi viết code, một vòng for t in range(T) đủ — không cần khai báo 4 layer.

Unrolled view giúp thấy rõ:

  • Forward = stack \( T \) layer.
  • Backward = đi ngược qua \( T \) layer (BPTT, mục 13).
  • Khi \( T \) lớn, network "ảo" này rất sâu — đó là gốc của vanishing gradient (B36).
7

Weight sharing through time

Weight sharing qua time là quyết định thiết kế cốt lõi của RNN, song song với weight sharing qua không gian của CNN (B27).

Hệ quả:

  • Param count độc lập sequence length: train trên chuỗi 50 step, có thể inference trên chuỗi 500 step với cùng model.
  • Inductive bias "stationarity": pattern xuất hiện ở step 3 có thể xuất hiện y hệt ở step 30 — vì cùng cell xử lý. Phù hợp với language, time series, audio.
  • Backward phải cộng dồn gradient — vì cùng \( W_{hh} \) xuất hiện ở mọi step, gradient của \( W_{hh} \) là tổng đóng góp qua mọi \( t \) (mục 14).

So sánh nhanh:

  • MLP: không weight share — input position fixed.
  • CNN: weight share qua không gian — translation equivariance cho ảnh.
  • RNN: weight share qua time — time-translation equivariance cho chuỗi.
8

Đếm parameter

Đặt \( d_x = \) input dim, \( d_h = \) hidden dim, \( d_y = \) output dim. Cell Vanilla RNN có:

  • \( W_{xh} \): \( d_h \times d_x \).
  • \( W_{hh} \): \( d_h \times d_h \).
  • \( W_{hy} \): \( d_y \times d_h \).
  • \( b_h \): \( d_h \).
  • \( b_y \): \( d_y \).

Tổng:

\[ P = d_x \cdot d_h + d_h^2 + d_h \cdot d_y + d_h + d_y \]

Chú ý: nn.RNN trong PyTorch không bao gồm phần output projection (\( W_{hy}, b_y \)) — chỉ trả về hidden state. Output projection được thêm sau bằng nn.Linear. Param của riêng nn.RNN mặc định:

\[ P_{\text{nn.RNN}} = d_x \cdot d_h + d_h^2 + 2 d_h \]

(2 bias \( b_{ih}, b_{hh} \) — PyTorch lưu riêng, tổng vẫn tương đương \( b_h \)).

Ví dụ kiểm chứng: \( d_x = 10, d_h = 20 \).

\( P_{\text{nn.RNN}} = 10 \cdot 20 + 20^2 + 2 \cdot 20 = 200 + 400 + 40 = 640 \).

import torch.nn as nn
rnn = nn.RNN(input_size=10, hidden_size=20, num_layers=1)
print(sum(p.numel() for p in rnn.parameters()))  # 640
9

Forward bằng PyTorch tensor

Viết RNN từ đầu bằng torch để hiểu rõ vòng lặp time:

import torch
import torch.nn as nn

class VanillaRNN(nn.Module):
    def __init__(self, d_x, d_h, d_y):
        super().__init__()
        self.d_h = d_h
        self.W_xh = nn.Linear(d_x, d_h, bias=False)
        self.W_hh = nn.Linear(d_h, d_h, bias=True)   # bias gộp vào đây
        self.W_hy = nn.Linear(d_h, d_y, bias=True)

    def forward(self, x, h0=None):
        # x: (batch, T, d_x)
        B, T, _ = x.shape
        h = torch.zeros(B, self.d_h, device=x.device) if h0 is None else h0
        outputs = []
        for t in range(T):
            h = torch.tanh(self.W_xh(x[:, t, :]) + self.W_hh(h))
            y = self.W_hy(h)
            outputs.append(y)
        y_seq = torch.stack(outputs, dim=1)          # (B, T, d_y)
        return y_seq, h                              # hidden cuối h_T

# Demo
model = VanillaRNN(d_x=4, d_h=8, d_y=3)
x = torch.randn(2, 5, 4)        # batch 2, T = 5, input 4
y_seq, h_T = model(x)
print(y_seq.shape, h_T.shape)   # torch.Size([2, 5, 3]) torch.Size([2, 8])

Hai điểm cần để ý:

  • Vòng for t in range(T) là sequential — đây là tính chất bản chất của RNN, không tránh được trong forward "đúng nghĩa".
  • Cùng self.W_xh, self.W_hh được gọi \( T \) lần — autograd tự xử lý weight sharing, gradient cộng dồn về cùng parameter (mục 14).
10

PyTorch nn.RNN API

Trong code production dùng nn.RNN — được tối ưu CUDA, gọi cuDNN khi có thể.

import torch
import torch.nn as nn

rnn = nn.RNN(
    input_size=10,
    hidden_size=20,
    num_layers=1,
    nonlinearity="tanh",      # mặc định; có thể đổi "relu"
    bias=True,
    batch_first=True,         # khuyên dùng True
    dropout=0.0,              # áp giữa các layer khi num_layers > 1
    bidirectional=False,
)

x = torch.randn(32, 100, 10)              # (batch=32, T=100, input=10)
output, h_n = rnn(x)
print(output.shape)   # torch.Size([32, 100, 20])  — hidden mọi step
print(h_n.shape)      # torch.Size([1, 32, 20])    — hidden state cuối

Hai output:

  • output: hidden state của mọi time step ở layer trên cùng — shape \( (B, T, d_h) \) khi batch_first=True.
  • h_n: hidden state ở time step cuối, cho mọi layer — shape \( (\text{num\_layers} \cdot \text{num\_directions}, B, d_h) \).

Khi muốn truyền hidden state qua chunk (TBPTT), pass argument h_0:

output, h_n = rnn(x, h_0)        # h_0 shape: (num_layers, B, d_h)
11

batch_first — convention shape

PyTorch để mặc định batch_first=False — tức input shape là \( (T, B, d_x) \). Convention này xuất phát từ cuDNN, tối ưu memory layout cho RNN trên GPU.

Đặt batch_first=True đổi sang \( (B, T, d_x) \) — phổ biến hơn trong code DL hiện đại (giống convention CNN \( (B, C, H, W) \)), dễ debug và dễ kết hợp với nn.Linear, nn.Embedding.

Lưu ý: chỉ outputinput đổi theo flag này. h_n luôn giữ shape \( (\text{num\_layers}, B, d_h) \) bất kể batch_first.

Quy ước thực tế: chọn batch_first=True để consistency toàn pipeline. Performance khác biệt không đáng kể trên hardware hiện tại.

12

3 use case theo output

Cùng một RNN có thể ghép với head khác nhau, tạo ra 3 dạng task phổ biến:

Dạng Dùng output gì Ví dụ
Many-to-one Chỉ \( h_T \) (hoặc trung bình mọi \( h_t \)) làm representation chuỗi, đưa qua 1 classifier. Sentiment analysis, classification câu, phân loại signal time series.
Many-to-many same length Dùng \( y_t \) tại mọi step (output có cùng số phần tử input). POS tagging, NER, frame-level audio labeling.
Many-to-many different length Cần kiến trúc encoder–decoder (B40), không chỉ một RNN đơn. Dịch máy (NMT), summarization.

Code 2 dạng đầu trên nn.RNN:

output, h_n = rnn(x)             # output: (B, T, d_h),  h_n: (1, B, d_h)

# Many-to-one: chỉ dùng hidden cuối
last_hidden = h_n[-1]            # (B, d_h)
logits = classifier(last_hidden) # (B, num_classes)

# Many-to-many same length: dùng output mọi step
logits_seq = tag_head(output)    # (B, T, num_tags)
13

Backprop Through Time (BPTT)

BPTT = backpropagation áp dụng cho unrolled RNN. Cơ chế giống backprop chuẩn (B11) — chỉ là chạy ngược qua \( T \) layer "ảo" tương ứng \( T \) time step.

Trình tự backward (loss tính ở mọi step để đơn giản):

  1. Forward tính \( h_1, \ldots, h_T \) và loss \( L = \sum_t L_t \), cache mọi \( h_t \).
  2. Backward bắt đầu từ \( t = T \): tính \( \partial L_T / \partial h_T \).
  3. Lan ngược về \( h_{T-1} \) qua \( W_{hh} \) và qua nonlinearity \( \tanh \).
  4. Cộng dồn với \( \partial L_{T-1} / \partial h_{T-1} \) (do \( h_{T-1} \) ảnh hưởng cả \( L_{T-1} \) tại step đó và \( L_T \) qua \( h_T \)).
  5. Lặp về \( t = 1 \).

Vấn đề chi phí: BPTT giữ activation của mọi step trong memory để backward → memory tỷ lệ thuận với \( T \). Với \( T = 1000 \), batch 32, hidden 512 → đã hàng trăm MB chỉ riêng activation.

Truncated BPTT (TBPTT): chia chuỗi dài thành chunk độ dài \( k \) (vd 32, 64). Forward chạy qua toàn chuỗi, backward chỉ lan trong \( k \) step gần nhất. Đổi accuracy của long-range gradient lấy memory và tốc độ.

k = 32
h = None
for chunk in chunks_of_length_k(stream):
    y, h = rnn(chunk, h)
    loss = criterion(y, target)
    loss.backward()                  # chỉ backprop trong chunk này
    h = h.detach()                   # cắt graph để không backprop xuyên qua
    optimizer.step(); optimizer.zero_grad()
14

Gradient cho \( W_{hh} \)

Vì cùng \( W_{hh} \) xuất hiện ở mọi step, gradient là tổng đóng góp qua mọi \( t \):

\[ \frac{\partial L}{\partial W_{hh}} = \sum_{t=1}^{T} \frac{\partial L}{\partial h_t} \cdot \frac{\partial h_t}{\partial W_{hh}} \]

Để truyền \( \partial L / \partial h_t \) ngược từ \( T \) về \( t \), chain rule sinh ra một tích Jacobian:

\[ \frac{\partial h_T}{\partial h_t} = \prod_{k=t+1}^{T} \frac{\partial h_k}{\partial h_{k-1}} = \prod_{k=t+1}^{T} \mathrm{diag}\!\left(\tanh'(z_k)\right) \cdot W_{hh} \] với \( z_k = W_{xh}\, x_k + W_{hh}\, h_{k-1} + b_h \).

Tích này có \( (T - t) \) factor — mỗi factor có magnitude phụ thuộc spectral radius của \( W_{hh} \) và \( \tanh'(z_k) \in (0, 1] \).

  • Nếu spectral radius \( W_{hh} < 1 \): tích \( \to 0 \) khi \( T - t \) lớn → vanishing gradient. Step xa không truyền tín hiệu học được về step gần.
  • Nếu spectral radius \( W_{hh} > 1 \): tích bùng nổ → exploding gradient. NaN, training crash.

B36 đào sâu hiện tượng này, LSTM (B37) và GRU (B38) là giải pháp thiết kế cell tránh nhân tích Jacobian trên một con đường dài.

15

Stacked / Deep RNN

Stack nhiều RNN layer lên nhau: output (hidden state mọi step) của layer 1 trở thành input của layer 2, v.v.

\[ h_t^{(1)} = \tanh(W_{xh}^{(1)} x_t + W_{hh}^{(1)} h_{t-1}^{(1)} + b_h^{(1)}) \] \[ h_t^{(\ell)} = \tanh(W_{xh}^{(\ell)} h_t^{(\ell-1)} + W_{hh}^{(\ell)} h_{t-1}^{(\ell)} + b_h^{(\ell)}), \quad \ell \ge 2 \]

Hidden state đi hai chiều: theo time (qua \( W_{hh}^{(\ell)} \)) và theo depth (qua \( W_{xh}^{(\ell)} \) của layer trên).

rnn = nn.RNN(input_size=64, hidden_size=128, num_layers=3,
             batch_first=True, dropout=0.2)
# dropout áp giữa các layer (không áp time)

Khi nào nên stack:

  • Task có pattern phức tạp đa cấp (vd ngữ pháp + ngữ nghĩa cho NLP).
  • Dữ liệu đủ lớn để tránh overfit.
  • Thực nghiệm thường 2–3 layer là biên — sâu hơn ít cải thiện do vanishing gradient theo depth lẫn time.
16

Bidirectional RNN

BiRNN (Schuster & Paliwal, 1997): chạy 2 RNN độc lập, một forward (\( 1 \to T \)) và một backward (\( T \to 1 \)). Hidden state tại step \( t \) là concat:

\[ h_t = [\overrightarrow{h_t} \, ; \, \overleftarrow{h_t}] \in \mathbb{R}^{2 d_h} \]

BiRNN dùng được khi cả chuỗi đã có sẵn tại inference (offline) — không phù hợp cho streaming / autoregressive generation.

birnn = nn.RNN(input_size=64, hidden_size=128, num_layers=1,
               batch_first=True, bidirectional=True)
x = torch.randn(32, 50, 64)
output, h_n = birnn(x)
print(output.shape)   # (32, 50, 256) — concat 2 chiều
print(h_n.shape)      # (2, 32, 128) — (num_directions, B, d_h)

Use case điển hình:

  • POS tagging, NER, dependency parsing — token cần cả context trước và sau.
  • Speech recognition offline.
  • Encoder trong seq2seq dịch máy (decoder vẫn unidirectional vì sinh từng token).
17

Variable length & PackedSequence

Một batch chuỗi thực tế có độ dài khác nhau (câu 5 từ vs 50 từ). Hai cách xử lý:

  • Pad mọi chuỗi về độ dài max của batch (pad_sequence). Đơn giản, nhưng RNN sẽ tính trên padding token vô nghĩa → tốn compute + hidden state cuối "trôi" theo padding.
  • PackedSequence: PyTorch cung cấp wrapper báo cho RNN độ dài thật từng chuỗi, RNN bỏ qua padding step.
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence

# x: (B, T_max, d_x) đã pad; lengths: tensor[int] độ dài thật từng sample
packed = pack_padded_sequence(x, lengths.cpu(), batch_first=True,
                              enforce_sorted=False)
packed_out, h_n = rnn(packed)
output, _ = pad_packed_sequence(packed_out, batch_first=True)
# h_n đã là hidden tại step CUỐI THẬT của mỗi chuỗi — đúng cho many-to-one

Lợi ích:

  • Tiết kiệm compute (không chạy cell trên padding).
  • h_n đúng là hidden tại step cuối thật, không phụ thuộc padding length.
  • Mask khi tính loss many-to-many cần tự xử lý ngoài (vd ignore_index của loss).
18

Train RNN char-level next-char

Bài toán toy kinh điển: dự đoán ký tự kế tiếp trên một string ngắn. Mỗi ký tự là 1 token, one-hot input, output là logit qua vocab.

import torch
import torch.nn as nn

text = "hello world hello world hello world "
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
V = len(chars)

# Tạo input / target pair: input là chuỗi, target lệch 1 ký tự
seq_len = 16
data = torch.tensor([stoi[c] for c in text])

def get_batch(batch_size=32):
    idx = torch.randint(0, len(data) - seq_len - 1, (batch_size,))
    x = torch.stack([data[i:i+seq_len] for i in idx])           # (B, T)
    y = torch.stack([data[i+1:i+seq_len+1] for i in idx])       # (B, T)
    return x, y

class CharRNN(nn.Module):
    def __init__(self, V, d_h=64):
        super().__init__()
        self.embed = nn.Embedding(V, d_h)
        self.rnn = nn.RNN(d_h, d_h, batch_first=True)
        self.head = nn.Linear(d_h, V)

    def forward(self, x):
        e = self.embed(x)                  # (B, T, d_h)
        h, _ = self.rnn(e)                 # (B, T, d_h)
        return self.head(h)                # (B, T, V)

model = CharRNN(V)
optim = torch.optim.Adam(model.parameters(), lr=1e-2)
loss_fn = nn.CrossEntropyLoss()

for step in range(2000):
    x, y = get_batch(32)
    logits = model(x)                      # (B, T, V)
    loss = loss_fn(logits.reshape(-1, V), y.reshape(-1))
    optim.zero_grad(); loss.backward(); optim.step()
    if step % 200 == 0:
        print(f"step {step:4d}  loss = {loss.item():.4f}")

Trên text lặp này, loss tụt nhanh về gần 0 — model học pattern "ello world h" lặp lại. Cùng skeleton dùng cho dataset thật (tiny Shakespeare, vd) bằng cách thay text.

19

Limitations của Vanilla RNN

  • Vanishing gradient — không học được dependency cách xa > 10–20 step trong thực tế (Bengio và cộng sự, 1994). Chi tiết B36.
  • Exploding gradient — phải clip norm gradient (torch.nn.utils.clip_grad_norm_) để tránh NaN khi train.
  • Tuần tự — không thể parallel qua time. Train trên GPU dài chuỗi rất chậm so với Transformer attention parallel hoàn toàn.
  • Memory bottleneck — toàn bộ quá khứ nén vào \( d_h \) số float; thông tin xa bị overwrite.
  • Khó tận dụng pre-training scale — RNN khó train lớn (>1B param) hiệu quả do bottleneck tuần tự + vanishing.

Các vấn đề này dẫn tới: LSTM / GRU (giảm vanishing, B37–B38) → Attention (Bahdanau, 2014; B40 intuition) → Transformer (Vaswani và cộng sự, 2017; sau Module này) thay thế hầu hết use case sequence ở scale lớn.

20

Khi nào dùng RNN vs Transformer

RNN không "chết" — vẫn có ngữ cảnh phù hợp:

  • Streaming inference: token đến từng cái một, latency thấp, không cần re-attend toàn lịch sử. RNN/LSTM dùng phổ biến trong on-device ASR, keyboard prediction, sensor processing.
  • Chuỗi ngắn (≤ 50 step): RNN nhỏ, ít param, train nhanh hơn Transformer cùng dung lượng.
  • Low-resource edge: GRU/LSTM 1–2 layer chạy nhẹ trên MCU; Transformer khó fit.
  • Time series classification / forecasting: LSTM/GRU vẫn baseline cạnh tranh trên nhiều benchmark khi dữ liệu hạn chế.

Transformer ưu thế khi:

  • Chuỗi dài (hàng nghìn token), cần dependency cách xa.
  • Có thể train parallel — dữ liệu lớn, GPU cluster.
  • Cần fine-tune từ pre-trained model lớn (BERT, GPT, T5).

Trong roadmap AI Engineer hiện nay, RNN là kiến thức nền — phải hiểu để hiểu LSTM/GRU, đọc paper cũ, debug code legacy, và đặc biệt là hiểu vì sao Transformer được thiết kế khác.

21

Bài tập

  1. Implement forward pass Vanilla RNN bằng NumPy thuần (không dùng PyTorch). Input \( x \in \mathbb{R}^{T \times d_x} \), tự khởi tạo \( W_{xh}, W_{hh}, W_{hy}, b_h, b_y \), trả về \( h_T \) và \( y_{1:T} \). So sánh với module PyTorch tự viết ở mục 9 trên cùng input — sai lệch numeric phải \( \le 10^{-5} \).
  2. Đếm tổng param của nn.RNN(input_size=10, hidden_size=20, num_layers=2, bidirectional=True) bằng tay rồi verify bằng sum(p.numel() for p in rnn.parameters()). Layer 2 nhận input \( 2 \cdot 20 = 40 \) vì bidirectional layer 1 concat 2 chiều.
  3. Train RNN dự đoán phần tử kế tiếp trong dãy Fibonacci modulo 23 (tránh số quá lớn). Input là 5 phần tử liên tiếp, target là phần tử thứ 6. So sánh accuracy giữa hidden size 8, 32, 128 — khi nào model bão hoà?
  4. Train một model nn.RNN(bidirectional=False) và một bidirectional=True trên POS tagging toy (gán nhãn V/N/A cho chuỗi từ giả lập). So accuracy. Giải thích vì sao bidirectional có lợi.
  5. Áp torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) vào training loop ở mục 18. Bỏ activation tanh, dùng ReLU trong cell (viết tay phiên bản mục 9). Train có ổn định không? Vì sao ReLU + RNN dễ exploding hơn?
  6. Dùng pack_padded_sequence trên batch chuỗi độ dài \( [5, 3, 8, 2] \), so sánh thời gian forward với phiên bản pad-only trên 1000 batch random — speedup bao nhiêu?
  7. Chứng minh bằng tay: với 2 step \( h_2 = \tanh(W_{xh} x_2 + W_{hh} h_1) \) và \( h_1 = \tanh(W_{xh} x_1 + W_{hh} h_0) \), \( \partial h_2 / \partial W_{hh} \) gồm bao nhiêu hạng tử? Vì sao gradient \( W_{hh} \) tổng từ \( T \) hạng tử (mục 14)?
Gợi ý đáp án ngắn
  1. Sai lệch chủ yếu đến từ thứ tự tổng float — phải dùng cùng dtype float32 và cùng init. Sai lệch \( \le 10^{-5} \) là pass.
  2. Tổng: layer 1 (bi) = \( 2 \cdot (10 \cdot 20 + 20^2 + 40) = 1280 \). Layer 2 (bi) = \( 2 \cdot (40 \cdot 20 + 20^2 + 40) = 2480 \). Total = \( 3760 \).
  3. Hidden 8 không đủ "nhớ" công thức \( F_n = F_{n-1} + F_{n-2} \) mod 23. Từ \( d_h \approx 32 \) trở lên accuracy bão hoà ở mức cao.
  4. Bidirectional thấy được từ sau (vd "the big ? dog" giúp xác định danh từ qua mạo từ + động từ phía sau).
  5. ReLU không saturate phía dương → gradient \( \tanh'(z) \in (0, 1] \) không còn nén factor, dễ exploding. Clip norm bắt buộc.
  6. Speedup tỷ lệ \( T_{\max} / \bar{T} \). Với \( [5, 3, 8, 2] \), \( T_{\max} = 8, \bar{T} = 4.5 \) → tiết kiệm ~44% compute, thực tế ~25–35% wall-time do overhead packing.
  7. \( \partial h_2 / \partial W_{hh} \) gồm 2 hạng tử: trực tiếp tại step 2 (qua \( h_1 \) trong công thức \( h_2 \)) và gián tiếp qua \( \partial h_2 / \partial h_1 \cdot \partial h_1 / \partial W_{hh} \). Tổng quát \( T \) step → \( T \) hạng tử.
22

Tóm tắt

  • Vanilla RNN duy trì hidden state \( h_t \) cập nhật theo \( h_t = \tanh(W_{xh} x_t + W_{hh} h_{t-1} + b_h) \), output tuỳ chọn \( y_t = W_{hy} h_t + b_y \).
  • \( h_0 \) thường là zero, có thể là learnable parameter.
  • Unrolled view biến RNN thành feed-forward sâu \( T \) layer cùng weight — đây là weight sharing through time.
  • Param count không phụ thuộc \( T \): \( d_x d_h + d_h^2 + d_h d_y + d_h + d_y \).
  • PyTorch nn.RNN(input_size, hidden_size, num_layers, batch_first, bidirectional) trả về output (hidden mọi step) và h_n (hidden cuối).
  • Convention batch_first=True đổi shape sang \( (B, T, d_x) \), khuyến nghị dùng.
  • 3 use case: many-to-one (dùng \( h_T \)), many-to-many same length (dùng \( y_t \) mọi step), many-to-many different length (encoder–decoder, B40).
  • BPTT = backprop qua unrolled graph; chi phí memory \( O(T) \). TBPTT cắt chuỗi thành chunk + detach() hidden state.
  • Gradient \( W_{hh} \) là tổng \( T \) hạng tử có chứa tích \( \prod \mathrm{diag}(\tanh'(z_k)) W_{hh} \) — nguồn của vanishing / exploding gradient (B36).
  • Stacked RNN: num_layers=2+; bidirectional: bidirectional=True, concat 2 chiều.
  • PackedSequence (pack_padded_sequence / pad_packed_sequence) tránh tính trên padding.
  • Limitations: vanishing, sequential, memory bottleneck, khó scale lớn — dẫn tới LSTM/GRU và Transformer.
  • RNN vẫn hữu ích cho streaming, chuỗi ngắn, edge low-resource; Transformer dominate ở chuỗi dài + data lớn.