Skip to content

Mạng nơ-ron hồi tiếp (RNN)

This content is not available in your language yet.

Sau bài này, bạn sẽ:

  • Hiểu vì sao dữ liệu chuỗi cần một kiến trúc riêng, và cách mạng nơ-ron hồi tiếp (recurrent neural network, RNN) dùng trạng thái ẩn (hidden state) để mang thông tin qua từng bước.
  • Mở rộng một RNN theo thời gian, thấy được vì sao mọi bước dùng chung một bộ trọng số, và nhận ra các dạng bài toán một → nhiều, nhiều → một, nhiều → nhiều.
  • Tự dẫn được lan truyền ngược theo thời gian (backpropagation through time, BPTT), và giải thích được vì sao gradient tiêu biến hay bùng nổ theo cấp số nhân của khoảng cách.
  • Cài đặt được lan truyền xuôi của RNN bằng NumPy, và huấn luyện một RNN bằng PyTorch với truncated BPTT và gradient clipping.

Hãy đọc hai câu: “chó cắn người” và “người cắn chó”. Cùng ba từ, nhưng nghĩa ngược hẳn. Hay nhìn nhiệt độ đo mỗi giờ: 24, 25, 27, 29… Muốn đoán giờ tới, bạn cần biết các số đi theo thứ tự nào, không chỉ biết có những số nào.

Văn bản, chuỗi thời gian (time series), âm thanh, chuỗi ADN đều là dữ liệu chuỗi (sequential data): một dãy x1,x2,…,xL\mathbf{x}_1, \mathbf{x}_2, \dots, \mathbf{x}_L, trong đó thứ tự mang thông tin, và độ dài LL thay đổi từ mẫu này sang mẫu khác. Một câu bình luận có thể dài 5 từ hoặc 500 từ.

MLP ở bài Mạng nơ-ron nhân tạo nhận một vectơ có số chiều cố định. Có hai cách ép một chuỗi vào đó, và cả hai đều vướng:

  • Ghép các bước thành một vectơ dài. Phải chọn trước độ dài tối đa, chuỗi ngắn hơn thì đệm thêm số 0, chuỗi dài hơn thì cắt. Tệ hơn, mỗi vị trí có trọng số riêng. Mạng học “chữ hay ở vị trí 2 là lời khen” không giúp gì cho chữ hay ở vị trí 7: nó phải học lại từ đầu cho từng vị trí.
  • Cộng hay lấy trung bình mọi bước. Số chiều thì cố định, nhưng mất hẳn thứ tự. “Chó cắn người” và “người cắn chó” cho ra cùng một vectơ.

Bạn đọc câu này không phải bằng cách nhìn cả câu một lúc. Bạn đọc từng từ, và trong đầu luôn có một “bản tóm tắt” những gì đã đọc. Gặp từ mới, bạn cập nhật bản tóm tắt. Cách cập nhật thì giống nhau ở mọi từ: bạn không có một kỹ năng đọc riêng cho từ thứ 3 và một kỹ năng khác cho từ thứ 30.

RNN làm đúng như vậy. Nó giữ một vectơ gọi là trạng thái ẩn. Mỗi bước, nó lấy trạng thái cũ và đầu vào mới, qua cùng một công thức với cùng một bộ trọng số, để ra trạng thái mới. Nhờ thế, chuỗi dài bao nhiêu cũng đọc được, và điều học được ở vị trí này dùng được ở mọi vị trí khác.

Bài toán của bài này là: xây một mạng đọc chuỗi có độ dài bất kỳ theo đúng thứ tự, nhớ lại những gì đã đọc trong một vectơ trạng thái, và tìm cách huấn luyện nó; rồi hiểu vì sao nó vẫn khó nhớ những gì đã đọc từ rất lâu.

Ở bước tt (từ 11 tới LL), RNN nhận đầu vào xt∈Rdx\mathbf{x}_t \in \mathbb{R}^{d_x} và trạng thái của bước trước ht−1∈Rdh\mathbf{h}_{t-1} \in \mathbb{R}^{d_h}, rồi tính trạng thái mới:

ht=tanh⁡(Wxhxt+Whhht−1+bh)\mathbf{h}_t = \tanh\left(\mathbf{W}_{xh} \mathbf{x}_t + \mathbf{W}_{hh} \mathbf{h}_{t-1} + \mathbf{b}_h\right)
  • dxd_x là số chiều của đầu vào, dhd_h là số chiều của trạng thái ẩn (một siêu tham số, ví dụ 32 hay 256).
  • Wxh∈Rdh×dx\mathbf{W}_{xh} \in \mathbb{R}^{d_h \times d_x} đưa đầu vào mới vào không gian trạng thái.
  • Whh∈Rdh×dh\mathbf{W}_{hh} \in \mathbb{R}^{d_h \times d_h} là trọng số hồi tiếp (recurrent weight): nó quyết định trạng thái cũ ảnh hưởng tới trạng thái mới ra sao. Đây là chỗ “hồi tiếp” trong tên gọi.
  • bh∈Rdh\mathbf{b}_h \in \mathbb{R}^{d_h} là hệ số tự do.
  • tanh⁡\tanh đưa mỗi thành phần về khoảng (−1,1)(-1, 1), nên trạng thái không tự phình to dù đi qua rất nhiều bước.
  • Trạng thái đầu h0\mathbf{h}_0 thường đặt bằng vectơ 0.

Cách đặt tên ma trận đọc như một mũi tên: chữ đầu là thứ được nhân vào, chữ sau là thứ được tính ra. Wxh\mathbf{W}_{xh} đi từ x\mathbf{x} sang h\mathbf{h}, Whh\mathbf{W}_{hh} đi từ h\mathbf{h} cũ sang h\mathbf{h} mới. Các bài GRU và LSTM và BiLSTM giữ quy ước này, chỉ thay chữ sau bằng tên cổng, ví dụ Wxf\mathbf{W}_{xf}, Whf\mathbf{W}_{hf} cho cổng quên.

Nếu cần một dự đoán ở bước tt, ta đưa ht\mathbf{h}_t qua một lớp tuyến tính:

y^t=Whyht+by\hat{\mathbf{y}}_t = \mathbf{W}_{hy} \mathbf{h}_t + \mathbf{b}_y

Với bài toán phân loại, y^t\hat{\mathbf{y}}_t là các logit, và ta áp softmax như ở lớp ra của MLP. Với bài toán hồi quy, như đoán giá trị tiếp theo của một chuỗi số, y^t\hat{\mathbf{y}}_t chính là dự đoán.

Hãy để ý: ht\mathbf{h}_t phụ thuộc vào ht−1\mathbf{h}_{t-1}, mà ht−1\mathbf{h}_{t-1} lại phụ thuộc vào ht−2\mathbf{h}_{t-2}, cứ thế về tới x1\mathbf{x}_1. Vì vậy ht\mathbf{h}_t là một bản tóm tắt của toàn bộ x1,…,xt\mathbf{x}_1, \dots, \mathbf{x}_t, nén vào dhd_h con số. Nó chỉ phụ thuộc vào các bước đã qua, không nhìn thấy các bước sau.

Mở rộng theo thời gian và chia sẻ trọng số

Phần tiêu đề “Mở rộng theo thời gian và chia sẻ trọng số”

Công thức trên có một vòng lặp: đầu ra ht\mathbf{h}_t quay lại làm đầu vào của bước sau. Để tính toán và lấy đạo hàm, ta mở rộng theo thời gian (unrolling): viết ra một bản sao của ô RNN cho mỗi bước, nối h1→h2→⋯→hL\mathbf{h}_1 \to \mathbf{h}_2 \to \dots \to \mathbf{h}_L.

h0→ x1 h1→ x2 h2→⋯→ xL hL\mathbf{h}_0 \xrightarrow{\ \mathbf{x}_1\ } \mathbf{h}_1 \xrightarrow{\ \mathbf{x}_2\ } \mathbf{h}_2 \to \cdots \xrightarrow{\ \mathbf{x}_L\ } \mathbf{h}_L

Sau khi mở rộng, RNN trông như một mạng truyền thẳng có LL lớp. Khác biệt quan trọng: mọi “lớp” dùng chung Whh\mathbf{W}_{hh}, Wxh\mathbf{W}_{xh}, bh\mathbf{b}_h (chia sẻ trọng số, weight sharing). Ba hệ quả:

  1. Số tham số không phụ thuộc LL. Lớp RNN có dh2+dhdx+dhd_h^2 + d_h d_x + d_h tham số, dù chuỗi dài 10 hay 10 000 bước.
  2. Học một lần, dùng ở mọi vị trí. Quy tắc “gặp chữ hay thì đẩy trạng thái về phía khen” nằm trong Wxh\mathbf{W}_{xh}, áp ở bước nào cũng được.
  3. Đọc được chuỗi dài hơn mọi chuỗi đã thấy. Chỉ cần chạy công thức thêm vài bước.

Bạn sẽ thấy hình của ô RNN và bản mở rộng của nó ở mục Minh họa.

Cùng một RNN, tùy chỗ ta đưa đầu vào và lấy đầu ra, có các dạng sau:

DạngĐầu vàoĐầu raVí dụ
Một → nhiềuMột vectơ (thường làm h0\mathbf{h}_0 hoặc x1\mathbf{x}_1)y^1,…,y^L\hat{\mathbf{y}}_1, \dots, \hat{\mathbf{y}}_LNhìn ảnh, sinh câu mô tả
Nhiều → mộtx1,…,xL\mathbf{x}_1, \dots, \mathbf{x}_LChỉ y^L\hat{\mathbf{y}}_L, tính từ hL\mathbf{h}_LPhân loại cảm xúc của bình luận
Nhiều → nhiều, cùng độ dàix1,…,xL\mathbf{x}_1, \dots, \mathbf{x}_Ly^t\hat{\mathbf{y}}_t ở mọi bướcGán từ loại cho từng từ; đoán giá trị kế tiếp ở mỗi bước
Nhiều → nhiều, khác độ dàiChuỗi nguồnChuỗi đích dài khácDịch máy

Dạng cuối cần hai RNN: một mạng đọc hết câu nguồn (encoder), một mạng sinh câu đích (decoder). Đó là chủ đề của bài Mô hình chuỗi sang chuỗi (Seq2Seq).

Với dạng nhiều → nhiều, hàm mất mát là tổng các hàm mất mát ở từng bước, L=∑t=1LLt\mathcal{L} = \sum_{t=1}^{L} \mathcal{L}_t, trong đó Lt\mathcal{L}_t so y^t\hat{\mathbf{y}}_t với nhãn ở bước tt. Với dạng nhiều → một, chỉ có LL\mathcal{L}_L. Trong code người ta thường lấy trung bình thay cho tổng; điều đó chỉ nhân gradient với một hằng số.

Muốn huấn luyện, ta cần gradient của L\mathcal{L} theo Whh\mathbf{W}_{hh}, Wxh\mathbf{W}_{xh}, bh\mathbf{b}_h. Sau khi mở rộng, RNN là một mạng truyền thẳng sâu LL lớp, nên ta dùng đúng lan truyền ngược ở bài Huấn luyện mạng nơ-ron, đi từ bước LL lùi về bước 11. Cách làm này có tên riêng: lan truyền ngược theo thời gian (backpropagation through time, BPTT). Có hai điều mới so với MLP: ht\mathbf{h}_t ảnh hưởng tới loss theo hai đường, và trọng số được dùng lại ở mọi bước.

Để gọn, viết δt=∂L/∂ht\boldsymbol{\delta}_t = \partial \mathcal{L} / \partial \mathbf{h}_t, vectơ gradient của loss theo trạng thái ở bước tt, cùng kích thước với ht\mathbf{h}_t.

Bước 1: gradient theo trạng thái. ht\mathbf{h}_t tác động lên loss theo hai đường: trực tiếp qua Lt\mathcal{L}_t (qua y^t\hat{\mathbf{y}}_t), và gián tiếp qua ht+1\mathbf{h}_{t+1}, rồi từ đó tới mọi bước sau. Quy tắc dây chuyền cộng hai phần đó lại:

δt=∂Lt∂ht+(∂ht+1∂ht) ⁣⊤δt+1\boldsymbol{\delta}_t = \frac{\partial \mathcal{L}_t}{\partial \mathbf{h}_t} + \left(\frac{\partial \mathbf{h}_{t+1}}{\partial \mathbf{h}_t}\right)^{\!\top} \boldsymbol{\delta}_{t+1}

Ở bước cuối không có hL+1\mathbf{h}_{L+1}, nên δL=∂LL/∂hL\boldsymbol{\delta}_L = \partial \mathcal{L}_L / \partial \mathbf{h}_L. Từ đó ta tính lùi được δL−1,δL−2,…,δ1\boldsymbol{\delta}_{L-1}, \boldsymbol{\delta}_{L-2}, \dots, \boldsymbol{\delta}_1.

Bước 2: Jacobian một bước. Thừa số ∂ht+1/∂ht\partial \mathbf{h}_{t+1} / \partial \mathbf{h}_t là một ma trận dh×dhd_h \times d_h, gọi là ma trận Jacobian: phần tử hàng ii, cột jj là đạo hàm của thành phần ii của ht+1\mathbf{h}_{t+1} theo thành phần jj của ht\mathbf{h}_t. Ta có ht+1=tanh⁡(Whhht+… )\mathbf{h}_{t+1} = \tanh(\mathbf{W}_{hh} \mathbf{h}_t + \dots), và đạo hàm của tanh⁡\tanh là tanh⁡′(u)=1−tanh⁡2(u)\tanh'(u) = 1 - \tanh^2(u). Mỗi thành phần đi qua tanh⁡\tanh riêng, nên quy tắc dây chuyền cho:

∂ht+1∂ht=Dt+1Whh,Dt+1=diag(1−ht+1⊙ht+1)\frac{\partial \mathbf{h}_{t+1}}{\partial \mathbf{h}_t} = \mathbf{D}_{t+1} \mathbf{W}_{hh}, \qquad \mathbf{D}_{t+1} = \mathrm{diag}\left(\mathbf{1} - \mathbf{h}_{t+1} \odot \mathbf{h}_{t+1}\right)

Dt+1\mathbf{D}_{t+1} là ma trận đường chéo chứa đạo hàm tanh⁡\tanh của từng thành phần, tính lại được từ chính ht+1\mathbf{h}_{t+1} đã lưu khi lan truyền xuôi. Mọi số trên đường chéo nằm trong (0,1](0, 1].

Bước 3: gradient theo trọng số. Ở bước tt, Whh\mathbf{W}_{hh} nhân với ht−1\mathbf{h}_{t-1}, giống một lớp tuyến tính nhận đầu vào ht−1\mathbf{h}_{t-1}. Gradient theo đầu vào của tanh⁡\tanh ở bước đó (nhân δt\boldsymbol{\delta}_t với đạo hàm tanh⁡\tanh, như Bước 2) là Dtδt\mathbf{D}_t \boldsymbol{\delta}_t, nên phần đóng góp của bước tt là tích ngoài (Dtδt) ht−1⊤(\mathbf{D}_t \boldsymbol{\delta}_t)\, \mathbf{h}_{t-1}^\top, như gradient của lớp tuyến tính trong MLP. Nhưng Whh\mathbf{W}_{hh} được dùng ở mọi bước, nên gradient của nó là tổng các phần đóng góp:

∂L∂Whh=∑t=1L(Dtδt) ht−1⊤∂L∂Wxh=∑t=1L(Dtδt) xt⊤∂L∂bh=∑t=1LDtδt\begin{aligned} \frac{\partial \mathcal{L}}{\partial \mathbf{W}_{hh}} &= \sum_{t=1}^{L} (\mathbf{D}_t \boldsymbol{\delta}_t)\, \mathbf{h}_{t-1}^\top \\ \frac{\partial \mathcal{L}}{\partial \mathbf{W}_{xh}} &= \sum_{t=1}^{L} (\mathbf{D}_t \boldsymbol{\delta}_t)\, \mathbf{x}_t^\top \\ \frac{\partial \mathcal{L}}{\partial \mathbf{b}_h} &= \sum_{t=1}^{L} \mathbf{D}_t \boldsymbol{\delta}_t \end{aligned}

Cộng dồn qua các bước là điều duy nhất mà chia sẻ trọng số thêm vào. Gradient của Why\mathbf{W}_{hy}, by\mathbf{b}_y thì như lớp ra của MLP, cũng cộng qua các bước có loss.

Giả sử nhãn ở bước LL phụ thuộc vào đầu vào ở một bước tt rất sớm. Để học được điều đó, gradient của LL\mathcal{L}_L phải đi ngược về tận ht\mathbf{h}_t. Trải công thức ở Bước 1 ra, phần đi theo đường từ hL\mathbf{h}_L về ht\mathbf{h}_t là:

∂LL∂ht=(∂hL∂ht) ⁣⊤∂LL∂hL,∂hL∂ht=DLWhh DL−1Whh⋯Dt+1Whh\frac{\partial \mathcal{L}_L}{\partial \mathbf{h}_t} = \left(\frac{\partial \mathbf{h}_L}{\partial \mathbf{h}_t}\right)^{\!\top} \frac{\partial \mathcal{L}_L}{\partial \mathbf{h}_L}, \qquad \frac{\partial \mathbf{h}_L}{\partial \mathbf{h}_t} = \mathbf{D}_L \mathbf{W}_{hh}\, \mathbf{D}_{L-1} \mathbf{W}_{hh} \cdots \mathbf{D}_{t+1} \mathbf{W}_{hh}

Vế phải là tích của L−tL - t Jacobian một bước, mỗi bước một thừa số DkWhh\mathbf{D}_k \mathbf{W}_{hh}. Đây là chỗ khác hẳn MLP: ở MLP mỗi lớp có ma trận riêng, còn ở đây cùng một ma trận Whh\mathbf{W}_{hh} bị nhân lặp lại L−tL - t lần.

Trường hợp một chiều. Hãy xem dh=1d_h = 1 và trạng thái luôn gần 0, nên tanh⁡′≈1\tanh' \approx 1. Mỗi Jacobian chỉ là một số ww, và ∂hL/∂ht≈wL−t\partial h_L / \partial h_t \approx w^{L-t}. Với L−t=50L - t = 50: nếu w=0.9w = 0.9 thì 0.950≈0.0050.9^{50} \approx 0.005; nếu w=1.1w = 1.1 thì 1.150≈1171.1^{50} \approx 117. Chỉ lệch 0.1 quanh số 1 mà kết quả chênh nhau hơn hai vạn lần.

Trường hợp tổng quát. Dùng tính chất của chuẩn ma trận ∥AB∥≤∥A∥∥B∥\lVert \mathbf{A}\mathbf{B} \rVert \le \lVert \mathbf{A} \rVert \lVert \mathbf{B} \rVert, với ∥⋅∥\lVert \cdot \rVert là chuẩn phổ (spectral norm), tức giá trị kỳ dị (singular value) lớn nhất σmax⁡\sigma_{\max} của ma trận: hệ số lớn nhất mà ma trận có thể kéo dài một vectơ. Ma trận đường chéo Dk\mathbf{D}_k có các số trong (0,1](0, 1], nên ∥Dk∥≤1\lVert \mathbf{D}_k \rVert \le 1. Từ đó:

∥∂hL∂ht∥≤∏k=t+1L∥Dk∥ ∥Whh∥≤σmax⁡(Whh) L−t\left\lVert \frac{\partial \mathbf{h}_L}{\partial \mathbf{h}_t} \right\rVert \le \prod_{k=t+1}^{L} \lVert \mathbf{D}_k \rVert \, \lVert \mathbf{W}_{hh} \rVert \le \sigma_{\max}(\mathbf{W}_{hh})^{\,L-t}
  • Gradient tiêu biến (vanishing gradient). Nếu σmax⁡(Whh)<1\sigma_{\max}(\mathbf{W}_{hh}) < 1, chặn trên co về 0 theo cấp số nhân của khoảng cách L−tL - t. Đây là điều kiện đủ: chắc chắn tiêu biến. tanh⁡\tanh bão hòa (trạng thái gần ±1\pm 1, đạo hàm gần 0) còn làm co nhanh hơn.
  • Gradient bùng nổ (exploding gradient). Muốn tích này lớn dần thì σmax⁡(Whh)>1\sigma_{\max}(\mathbf{W}_{hh}) > 1 là điều kiện cần, nhưng chưa đủ, vì các Dk\mathbf{D}_k có thể kéo nó xuống. Khi nó xảy ra, một vài bước cập nhật có gradient cực lớn đẩy trọng số đi rất xa, loss nhảy vọt hoặc ra NaN.

Pascanu, Mikolov và Bengio (2013) chứng minh hai điều kiện này (cho trường hợp tanh⁡\tanh; với sigmoid, ngưỡng là 4 thay cho 1, vì đạo hàm sigmoid không quá 1/41/4). Trước đó, Bengio, Simard và Frasconi (1994) đã chỉ ra rằng phụ thuộc càng xa thì gradient descent càng khó học nó. Hệ quả thực tế dễ thấy từ công thức BPTT: gradient theo Whh\mathbf{W}_{hh} là tổng các số hạng ứng với mọi khoảng cách, và số hạng của khoảng cách xa nhỏ hơn số hạng gần theo cấp số nhân. Tín hiệu của các phụ thuộc gần lấn át hẳn, nên mô hình chủ yếu học được quan hệ giữa các bước gần nhau.

Bài Huấn luyện mạng nơ-ron đã gặp vanishing gradient ở MLP sâu. Ở RNN, “độ sâu” là độ dài chuỗi, và một câu văn hay một chuỗi đo đạc dễ dài hàng trăm bước.

BPTT đầy đủ trên một chuỗi rất dài (cả một cuốn sách, hay một năm số liệu theo giờ) vừa tốn bộ nhớ, vì phải lưu mọi ht\mathbf{h}_t, vừa chỉ cập nhật trọng số một lần sau khi đọc hết. Truncated BPTT chia chuỗi thành các đoạn liên tiếp dài KK bước:

  1. Chạy xuôi trên đoạn đầu, bắt đầu từ h0\mathbf{h}_0, tính loss, lan truyền ngược chỉ trong đoạn đó, rồi cập nhật trọng số.
  2. Lấy trạng thái cuối đoạn làm trạng thái đầu của đoạn sau, nhưng cắt đường gradient đi qua nó: coi nó là một hằng số.
  3. Lặp lại cho tới hết chuỗi.

Lượt xuôi vẫn liền mạch, nên h\mathbf{h} vẫn mang thông tin từ các đoạn trước. Đổi lại, gradient không bao giờ đi xa quá KK bước, nên mô hình không được dạy trực tiếp để giữ thông tin lâu hơn KK bước. Bộ nhớ chỉ tăng theo KK, không theo LL. Trong PyTorch, bước 2 là h = h.detach(), như trong ô code thứ ba.

Với gradient bùng nổ, cách chữa đơn giản nhất là cắt chuẩn gradient (gradient clipping). Gọi g\mathbf{g} là vectơ ghép mọi gradient của mô hình, τ\tau là một ngưỡng chọn trước. Trước mỗi bước cập nhật:

neˆˊu ∥g∥>τ thıˋ g←τ∥g∥ g\text{nếu } \lVert \mathbf{g} \rVert > \tau \text{ thì } \mathbf{g} \leftarrow \frac{\tau}{\lVert \mathbf{g} \rVert}\, \mathbf{g}

Vectơ mới có chuẩn đúng bằng τ\tau và giữ nguyên hướng. Gradient vẫn chỉ hướng giảm loss, chỉ có bước nhảy bị giới hạn, nên một lô dữ liệu xấu không hất trọng số đi quá xa. Pascanu và cộng sự gợi ý chọn τ\tau bằng cách xem chuẩn gradient trung bình qua nhiều bước cập nhật; theo họ, ngưỡng từ một nửa tới mười lần mức trung bình đó vẫn hội tụ. Trong PyTorch, đó là torch.nn.utils.clip_grad_norm_.

Clipping chỉ thu nhỏ gradient quá lớn. Nó không làm gradient đã tiêu biến lớn lên, nên không giúp RNN nhớ xa hơn.

  • Khó nhớ xa. Gradient tiêu biến làm RNN thường chỉ dùng được ngữ cảnh gần. Truncated BPTT và clipping giúp huấn luyện ổn định hơn, nhưng không chữa được điều này. Ô code thứ hai cho bạn thấy tốc độ co của gradient.
  • Phải chạy tuần tự. ht\mathbf{h}_t cần ht−1\mathbf{h}_{t-1}, nên không tính song song theo các bước được. Bài Transformers bỏ hẳn vòng lặp này.
  • Mọi thứ nén vào một vectơ. Toàn bộ quá khứ phải đi qua ht\mathbf{h}_t có dhd_h chiều. Bài Mô hình chuỗi sang chuỗi (Seq2Seq) cho thấy vì sao điều này thành nút thắt khi dịch câu dài.

Hình dưới đặt ô RNN và bản mở rộng của nó cạnh nhau. Hãy nhìn vào các nhãn trọng số ở bản mở rộng: ba bước t−1t-1, tt, t+1t+1 dùng đúng một bộ Wxh\mathbf{W}_{xh}, Whh\mathbf{W}_{hh}, không phải ba bộ khác nhau. Mũi tên ngang nối ht−1→ht→ht+1\mathbf{h}_{t-1} \to \mathbf{h}_t \to \mathbf{h}_{t+1} chính là đường mà gradient phải đi ngược lại khi lan truyền ngược theo thời gian.

Bảng dưới cho thấy tốc độ co giãn của tích các Jacobian trong trường hợp một chiều, ∂hL/∂ht≈wL−t\partial h_L / \partial h_t \approx w^{L-t}. Mỗi cột là một khoảng cách L−tL - t.

wwL−t=1L - t = 1L−t=10L - t = 10L−t=20L - t = 20L−t=50L - t = 50
0.50.50.000989.5e-078.9e-16
0.90.90.350.120.0052
1.01111
1.11.12.66.7120

Chỉ hàng w=1w = 1 giữ được gradient qua 50 bước, và đó là một điểm cân bằng rất mỏng: lệch một chút là tiêu biến hoặc bùng nổ. Ô code thứ hai đo đúng đại lượng này trên một RNN thật có tanh⁡\tanh.

Code trong bài chạy thử lần cuối tháng 11/2026: hai ô NumPy với Python 3.14 và NumPy 2.4 (bản có trong Pyodide), ô PyTorch với Python 3.14 và PyTorch 2.15 trên CPU. Hai ô NumPy chạy ngay trên trang và chạy độc lập: ô nào cũng tự import và tự tạo dữ liệu. Ô PyTorch chạy trên máy bạn.

Ô dưới cài công thức một bước, rồi chạy nó trên một chuỗi 4 bước với dx=2d_x = 2, dh=3d_h = 3. Trọng số lấy ngẫu nhiên, chưa huấn luyện: ở đây ta chỉ xem trạng thái ẩn đi qua các bước ra sao. Cuối ô, cùng bộ tham số được chạy trên hai chuỗi khác: 2 bước đầu của chuỗi cũ, và chuỗi cũ đảo ngược thứ tự.

RNN lan truyền xuôi trên một chuỗi ngắn

import numpy as np
rng = np.random.default_rng(0)
d_x, d_h = 2, 3 # số chiều đầu vào, số chiều trạng thái ẩn
# Một bộ tham số duy nhất, dùng lại ở mọi bước thời gian
W_xh = rng.normal(0, 0.5, (d_h, d_x))
W_hh = rng.normal(0, 0.5, (d_h, d_h))
b_h = np.zeros(d_h)
W_hy = rng.normal(0, 0.5, (1, d_h))
b_y = np.zeros(1)
def rnn_forward(xs):
"""Chạy RNN trên chuỗi xs (L hàng, d_x cột), trả về mọi h_t."""
h = np.zeros(d_h) # h_0 = 0
hs = []
for x_t in xs: # đọc lần lượt từng bước
h = np.tanh(W_xh @ x_t + W_hh @ h + b_h)
hs.append(h)
return np.array(hs)
xs = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0], [0.5, -1.0]])
hs = rnn_forward(xs)
for t, h in enumerate(hs, start=1):
print(f"t={t} h = {np.round(h, 3)}")
# Đầu ra từ trạng thái cuối (bài toán nhiều -> một)
y_hat = W_hy @ hs[-1] + b_y
print(f"y_hat (from h_L) = {y_hat[0]:.3f}")
# Cùng tham số, chuỗi dài 2 và chuỗi bị đảo thứ tự
print(f"L=2 last h = {np.round(rnn_forward(xs[:2])[-1], 3)}")
print(f"reversed h = {np.round(rnn_forward(xs[::-1])[-1], 3)}")
n_params = W_xh.size + W_hh.size + b_h.size + W_hy.size + b_y.size
print(f"parameters = {n_params}")

The first run downloads Python, about 10 MB.

Đọc kết quả:

  • Bốn trạng thái h1,…,h4\mathbf{h}_1, \dots, \mathbf{h}_4 đều nằm trong (−1,1)(-1, 1) nhờ tanh⁡\tanh. Trạng thái cuối h4=[0.365,0.01,−0.084]\mathbf{h}_4 = [0.365, 0.01, -0.084] là bản tóm tắt của cả chuỗi. Từ nó, lớp ra cho một dự đoán y_hat = -0.123, đúng dạng nhiều → một.
  • Chuỗi dài 2 cho trạng thái cuối [0.21,−0.089,0.233][0.21, -0.089, 0.233], đúng bằng h2\mathbf{h}_2 của chuỗi dài 4. Cùng một hàm đọc được chuỗi dài bao nhiêu cũng được, và ht\mathbf{h}_t chỉ phụ thuộc các bước đã qua.
  • Chuỗi đảo ngược cho [0.146,0.236,−0.409][0.146, 0.236, -0.409], khác hẳn h4\mathbf{h}_4, dù có đúng bốn đầu vào như cũ. RNN phân biệt được thứ tự, điều mà phép lấy trung bình ở mục Trực giác không làm được.
  • 22 tham số: Wxh\mathbf{W}_{xh} có 6, Whh\mathbf{W}_{hh} có 9, bh\mathbf{b}_h có 3, Why\mathbf{W}_{hy} có 3, by\mathbf{b}_y có 1. Con số này không đổi khi chuỗi dài hơn.

Ô dưới đo đúng đại lượng ở mục “Vì sao RNN khó nhớ xa”: chuẩn phổ ∥∂hL/∂ht∥\lVert \partial \mathbf{h}_L / \partial \mathbf{h}_t \rVert theo khoảng cách L−tL - t, với dh=16d_h = 16 và L=50L = 50. Để điều khiển được độ lớn của Whh\mathbf{W}_{hh}, ô lấy Whh=c Q\mathbf{W}_{hh} = c\,\mathbf{Q}, với Q\mathbf{Q} là một ma trận trực giao (orthogonal matrix): nhân với nó chỉ xoay vectơ, không đổi độ dài. Vì vậy mọi giá trị kỳ dị của Whh\mathbf{W}_{hh} đều bằng đúng cc. Đầu vào là một chuỗi số nhỏ, và để gọn, ô bỏ hệ số tự do bh\mathbf{b}_h.

Code đi lùi từ bước LL, mỗi bước nhân thêm một Jacobian DkWhh\mathbf{D}_k \mathbf{W}_{hh} như ở Bước 2, rồi in chuẩn phổ của tích ở một số khoảng cách.

Chuẩn của tích các Jacobian theo khoảng cách

import numpy as np
d_h, L = 16, 50 # số chiều trạng thái ẩn, độ dài chuỗi
distances = [1, 10, 20, 30, 40] # các khoảng cách L - t cần in
print("L-t " + " ".join(f"{k:8d}" for k in distances))
for c in [0.5, 1.0, 2.0]: # độ lớn của W_hh
rng = np.random.default_rng(0)
# W_hh = c * ma trận trực giao: mọi giá trị kỳ dị bằng đúng c
Q, _ = np.linalg.qr(rng.normal(size=(d_h, d_h)))
W_hh = c * Q
w_xh = rng.normal(size=d_h)
xs = 0.1 * rng.normal(size=L) # chuỗi vào vô hướng, nhỏ
# Lan truyền xuôi, giữ lại mọi h_t
h = np.zeros(d_h)
hs = []
for x_t in xs:
h = np.tanh(w_xh * x_t + W_hh @ h)
hs.append(h)
# Đi ngược: J = dh_L/dh_t = tích các Jacobian một bước
J = np.eye(d_h)
norms = {}
for k in range(1, L): # k = L - t
h_k = hs[L - k] # chính là h_{t+1}, với t = L - k
J = J @ (np.diag(1 - h_k**2) @ W_hh)
norms[k] = np.linalg.norm(J, 2) # chuẩn phổ
row = " ".join(f"{norms[k]:8.1e}" for k in distances)
print(f"c={c:3.1f} {row}")

The first run downloads Python, about 10 MB.

Đọc kết quả:

  • c=0.5c = 0.5: tiêu biến. Ở khoảng cách 10 còn 9.5×10−49.5 \times 10^{-4}, rất sát chặn trên 0.510≈9.8×10−40.5^{10} \approx 9.8 \times 10^{-4}, vì trạng thái nhỏ nên Dk\mathbf{D}_k gần ma trận đơn vị. Ở khoảng cách 40 chỉ còn 7.1×10−137.1 \times 10^{-13}: đầu vào cách 40 bước gần như không gửi được tín hiệu nào về trọng số.
  • c=1.0c = 1.0: co chậm. Chặn trên là 1, nhưng tanh⁡′<1\tanh' < 1 vẫn kéo xuống từ từ: 0.73 ở khoảng cách 10, 0.14 ở khoảng cách 40. Đây là vùng mà RNN học dễ nhất, và cũng là vùng hẹp nhất.
  • c=2.0c = 2.0: bùng nổ. Từ 1.9 ở khoảng cách 1 lên 3.2×1013.2 \times 10^{1} ở khoảng cách 10 và 6.7×1036.7 \times 10^{3} ở khoảng cách 40. Con số này còn xa chặn trên 240≈10122^{40} \approx 10^{12}, vì trạng thái đã lớn và tanh⁡\tanh bão hòa kéo bớt xuống. Điều kiện σmax⁡>1\sigma_{\max} > 1 chỉ là cần, không đủ, như mục Lý thuyết đã nói.

Bạn có thể đổi c thành 0.9 hoặc 1.1, hay tăng hệ số 0.1 của xs lên 1.0, rồi chạy lại để thấy tanh⁡\tanh bão hòa làm gradient co nhanh hơn ra sao.

RNN bằng PyTorch đoán giá trị tiếp theo của sóng sin

Phần tiêu đề “RNN bằng PyTorch đoán giá trị tiếp theo của sóng sin”

File dưới huấn luyện nn.RNN cho một bài nhiều → nhiều cùng độ dài: ở mỗi bước, đọc giá trị hiện tại của một sóng sin có nhiễu nhỏ, đoán giá trị kế tiếp.

  • Dữ liệu tự sinh: sin⁡(0.1 t)\sin(0.1\,t) cộng nhiễu chuẩn độ lệch 0.05, dài 2000 bước. Đầu vào là xtx_t, nhãn là xt+1x_{t+1}.
  • Mô hình: nn.RNN(1, 32) (dx=1d_x = 1, dh=32d_h = 32, hàm tanh⁡\tanh) và một lớp nn.Linear(32, 1) cho y^t=Whyht+by\hat{y}_t = \mathbf{W}_{hy} \mathbf{h}_t + b_y. PyTorch tính ht\mathbf{h}_t với hai vectơ hệ số tự do, bias_ih và bias_hh; tổng của chúng đóng vai bh\mathbf{b}_h trong công thức của bài.
  • Truncated BPTT: chuỗi chia thành 40 đoạn, mỗi đoạn K=50K = 50 bước. Trạng thái cuối đoạn được chuyển sang đoạn sau bằng h.detach().
  • Gradient clipping với ngưỡng τ=1\tau = 1. Hàm clip_grad_norm_ trả về chuẩn trước khi cắt; code in chuẩn lớn nhất trong mỗi epoch.
  • Huấn luyện 15 epoch bằng Adam, tức 600 bước cập nhật. Đánh giá trên 500 bước của một đoạn sóng mới, so với cách đoán đơn giản nhất: “giá trị kế tiếp bằng giá trị hiện tại”.

Cách cài PyTorch bản chỉ chạy CPU có ở bài Xây dựng LLM đầu tiên.

Run on your machine

nn.RNN đoán giá trị tiếp theo của sóng sin

Install
pip install torch
Hardware
CPU là đủ, không cần GPU. Khoảng 40 giây tới 1 phút trên máy phát triển, nhanh hơn khi máy rảnh
import torch
from torch import nn
torch.manual_seed(0)
K = 50 # độ dài mỗi đoạn của truncated BPTT
def make_series(start, n):
"""Sóng sin cộng nhiễu nhỏ, bước thời gian 0.1."""
t = (start + torch.arange(n + 1)) * 0.1
s = torch.sin(t) + 0.05 * torch.randn(n + 1)
# Đầu vào: giá trị hiện tại; nhãn: giá trị kế tiếp
return s[:-1].view(1, n, 1), s[1:].view(1, n, 1)
class SineRNN(nn.Module):
def __init__(self, hidden=32):
super().__init__()
self.rnn = nn.RNN(1, hidden, batch_first=True) # tanh
self.head = nn.Linear(hidden, 1) # y_hat_t = W_hy h_t + b_y
def forward(self, x, h=None):
out, h = self.rnn(x, h) # out chứa mọi h_t
return self.head(out), h
x_all, y_all = make_series(0, 2000)
model = SineRNN()
opt = torch.optim.Adam(model.parameters(), lr=0.005)
mse = nn.MSELoss()
for epoch in range(1, 16):
h, total, n_chunks, max_norm = None, 0.0, 0, 0.0
for s in range(0, x_all.shape[1], K):
pred, h = model(x_all[:, s:s + K], h)
loss = mse(pred, y_all[:, s:s + K])
opt.zero_grad()
loss.backward() # BPTT chỉ trong đoạn K bước này
norm = nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
h = h.detach() # giữ giá trị h, cắt đường gradient
total, n_chunks = total + loss.item(), n_chunks + 1
max_norm = max(max_norm, norm.item())
if epoch in (1, 5, 10, 15):
print(f"epoch {epoch:2d} train MSE {total / n_chunks:.4f}"
f" max grad norm {max_norm:.2f}")
# Đánh giá trên một đoạn mới, chưa thấy khi huấn luyện
x_test, y_test = make_series(5000, 500)
with torch.no_grad():
pred, _ = model(x_test)
print(f"test MSE (RNN) {mse(pred, y_test).item():.4f}")
print(f"test MSE (copy x_t) {mse(x_test, y_test).item():.4f}")

Sample output

From one real run.

epoch 1 train MSE 0.0779 max grad norm 1.66
epoch 5 train MSE 0.0045 max grad norm 0.15
epoch 10 train MSE 0.0042 max grad norm 0.26
epoch 15 train MSE 0.0042 max grad norm 0.16
test MSE (RNN) 0.0038
test MSE (copy x_t) 0.0091

Đọc kết quả:

  • MSE huấn luyện giảm từ 0.0779 ở epoch 1 xuống 0.0042. Nhiễu trong nhãn có phương sai 0.052=0.00250.05^2 = 0.0025, phần này không đoán được, nên loss không thể xuống dưới mức đó.
  • Clipping chỉ làm việc lúc đầu. Ở epoch 1, chuẩn gradient lớn nhất là 1.66, vượt ngưỡng 1, nên đã bị cắt. Từ epoch 5, chuẩn lớn nhất chỉ 0.15 tới 0.26, clipping không còn tác động. Đây là cách clipping thường làm việc: một tấm lưới an toàn cho vài bước xấu, không phải một phần của mọi bước.
  • Trên đoạn mới, MSE của RNN là 0.0038, so với 0.0091 của cách chép giá trị hiện tại, tức nhỏ hơn khoảng 2.4 lần. RNN đã học được hướng đi của sóng từ các bước trước: biết sóng đang lên hay xuống thì đoán giá trị kế tiếp tốt hơn chép lại.

Seed cố định nên mỗi lần chạy trên cùng máy cho cùng kết quả; trên máy khác hoặc bản PyTorch khác, số có thể lệch đôi chút.

Question 1 of 5 Một lớp RNN có dx=10d_x = 10 (số chiều đầu vào) và dh=20d_h = 20 (số chiều trạng thái ẩn). Lớp này có bao nhiêu tham số khi đọc chuỗi dài L=100L = 100, và khi đọc chuỗi dài L=1000L = 1000?
Question 2 of 5 Xét RNN một chiều ht=tanh⁡(w ht−1+u xt)h_t = \tanh(w\,h_{t-1} + u\,x_t) với w=0.8w = 0.8, và giả sử hh luôn gần 0 nên đạo hàm của tanh⁡\tanh gần bằng 1. Độ lớn của ∂h60/∂h10\partial h_{60} / \partial h_{10} vào khoảng bao nhiêu?
Question 3 of 5 Bạn huấn luyện RNN trên chuỗi rất dài bằng truncated BPTT với đoạn dài K=50K = 50, và vẫn chuyển h\mathbf{h} (đã cắt gradient) từ đoạn này sang đoạn sau. Nhãn ở một bước phụ thuộc vào một đầu vào cách đó 200 bước. Điều gì đúng?
Question 4 of 5 RNN của bạn đọc các bài đánh giá dài khoảng 300 từ, và chỉ dựa vào vài chục từ cuối để phân loại. Loss giảm đều, không có bước nào nhảy vọt hay ra NaN. Bạn bật gradient clipping với ngưỡng 1. Điều gì sẽ xảy ra?
Question 5 of 5 Bài toán nào là dạng một → nhiều?

Ý chính:

  • RNN đọc chuỗi từng bước và cập nhật trạng thái ẩn ht=tanh⁡(Wxhxt+Whhht−1+bh)\mathbf{h}_t = \tanh(\mathbf{W}_{xh} \mathbf{x}_t + \mathbf{W}_{hh} \mathbf{h}_{t-1} + \mathbf{b}_h), một bản tóm tắt của mọi bước đã qua; dự đoán ở bước tt là y^t=Whyht+by\hat{\mathbf{y}}_t = \mathbf{W}_{hy} \mathbf{h}_t + \mathbf{b}_y.
  • Mở rộng theo thời gian, RNN là một mạng sâu LL lớp dùng chung trọng số: số tham số không phụ thuộc LL, đọc được chuỗi dài bất kỳ, phân biệt được thứ tự. Tùy chỗ lấy đầu ra, ta có các dạng một → nhiều, nhiều → một, nhiều → nhiều.
  • BPTT là lan truyền ngược trên mạng đã mở rộng; gradient theo trọng số là tổng phần đóng góp của mọi bước. Gradient từ bước LL về bước tt đi qua tích L−tL - t Jacobian DkWhh\mathbf{D}_k \mathbf{W}_{hh}, nên co hoặc giãn theo cấp số nhân của khoảng cách: σmax⁡(Whh)<1\sigma_{\max}(\mathbf{W}_{hh}) < 1 chắc chắn tiêu biến, σmax⁡(Whh)>1\sigma_{\max}(\mathbf{W}_{hh}) > 1 mới có thể bùng nổ.
  • Truncated BPTT giới hạn lan truyền ngược trong đoạn KK bước để tiết kiệm bộ nhớ; gradient clipping co gradient quá lớn về đúng ngưỡng τ\tau. Cả hai giúp huấn luyện ổn định nhưng không giúp nhớ xa.

Câu hỏi còn lại là: làm sao để gradient đi được qua hàng trăm bước mà không tiêu biến? Bài GRU trả lời bằng cổng: thay vì luôn ghi đè trạng thái bằng tanh⁡(… )\tanh(\dots), mạng học cách quyết định giữ lại bao nhiêu phần trạng thái cũ ở mỗi bước, tạo ra một đường đi gần như thẳng cho cả thông tin lẫn gradient.

Đọc thêm:

  • Zhang, A., Lipton, Z. C., Li, M., Smola, A. J. (bản dịch tiếng Việt của nhóm Đắm mình vào Học Sâu). Đắm mình vào Học Sâu, chương 8, mục 8.4 tới 8.7. Bản tiếng Việt của Dive into Deep Learning: RNN từ đầu bằng code, có mục gọt gradient (gradient clipping) và BPTT chi tiết.
  • Zhang, A., Lipton, Z. C., Li, M., Smola, A. J. (2023). Dive into Deep Learning, mục 9.7 Backpropagation Through Time. Bản tiếng Anh mới hơn; mục 9.7 so BPTT đầy đủ, truncated BPTT thông thường và truncated BPTT ngẫu nhiên.
  • Goodfellow, I., Bengio, Y., Courville, A. (2016). Deep Learning, chương 10 Sequence Modeling: Recurrent and Recursive Nets, mục 10.1, 10.2, 10.7 và 10.11. Mở rộng đồ thị tính toán, BPTT, thử thách của phụ thuộc xa và cắt gradient. Sách dùng τ\tau cho độ dài chuỗi, chỗ bài này viết LL; trong bài này τ\tau là ngưỡng cắt gradient.
  • Pascanu, R., Mikolov, T., Bengio, Y. (2013). On the difficulty of training recurrent neural networks, ICML 2013, mục 2 và 3. Điều kiện đủ cho tiêu biến, điều kiện cần cho bùng nổ, và thuật toán cắt chuẩn gradient kèm cách chọn ngưỡng.
  • Bengio, Y., Simard, P., Frasconi, P. (1994). Learning long-term dependencies with gradient descent is difficult, IEEE Transactions on Neural Networks 5(2), 157–166. Bài kinh điển chỉ ra vì sao gradient descent khó học phụ thuộc xa trong mạng hồi tiếp.

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About