Mạng nơ-ron hồi tiếp (RNN)
Mục tiêu
Phần tiêu đề “Mục tiêu”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.
Cần biết trước
Phần tiêu đề “Cần biết trước”- Huấn luyện mạng nơ-ron: vanishing/exploding gradient, BatchNorm, Dropout, thuộc phần Học sâu (Đã học)
Trực giác
Phần tiêu đề “Trực giác”Dữ liệu có thứ tự
Phần tiêu đề “Dữ liệu có thứ tự”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 , trong đó thứ tự mang thông tin, và độ dài 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ừ.
Vì sao MLP không hợp
Phần tiêu đề “Vì sao MLP không hợp”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ơ.
Đọc từng từ, giữ một bản ghi nhớ
Phần tiêu đề “Đọc từng từ, giữ một bản ghi nhớ”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.
Lý thuyết
Phần tiêu đề “Lý thuyết”Trạng thái ẩn và công thức một bước
Phần tiêu đề “Trạng thái ẩn và công thức một bước”Ở bước (từ tới ), RNN nhận đầu vào và trạng thái của bước trước , rồi tính trạng thái mới:
- là số chiều của đầu vào, là số chiều của trạng thái ẩn (một siêu tham số, ví dụ 32 hay 256).
- đưa đầu vào mới vào không gian trạng thái.
- 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.
- là hệ số tự do.
- đưa mỗi thành phần về khoảng , 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 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. đi từ sang , đi từ cũ sang 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ụ , cho cổng quên.
Nếu cần một dự đoán ở bước , ta đưa qua một lớp tuyến tính:
Với bài toán phân loại, 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ố, chính là dự đoán.
Hãy để ý: phụ thuộc vào , mà lại phụ thuộc vào , cứ thế về tới . Vì vậy là một bản tóm tắt của toàn bộ , nén vào 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 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 .
Sau khi mở rộng, RNN trông như một mạng truyền thẳng có lớp. Khác biệt quan trọng: mọi “lớp” dùng chung , , (chia sẻ trọng số, weight sharing). Ba hệ quả:
- Số tham số không phụ thuộc . Lớp RNN có tham số, dù chuỗi dài 10 hay 10 000 bước.
- 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 , áp ở bước nào cũng được.
- Đọ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ác dạng bài toán
Phần tiêu đề “Các dạng bài toán”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 ra | Ví dụ |
|---|---|---|---|
| Một → nhiều | Một vectơ (thường làm hoặc ) | Nhìn ảnh, sinh câu mô tả | |
| Nhiều → một | Chỉ , tính từ | Phân loại cảm xúc của bình luận | |
| Nhiều → nhiều, cùng độ dài | ở mọi bước | Gá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ài | Chuỗi nguồn | Chuỗi đích dài khác | Dị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, , trong đó so với nhãn ở bước . Với dạng nhiều → một, chỉ có . 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ố.
Lan truyền ngược theo thời gian (BPTT)
Phần tiêu đề “Lan truyền ngược theo thời gian (BPTT)”Muốn huấn luyện, ta cần gradient của theo , , . Sau khi mở rộng, RNN là một mạng truyền thẳng sâu 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 lùi về bước . 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: ả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 , vectơ gradient của loss theo trạng thái ở bước , cùng kích thước với .
Bước 1: gradient theo trạng thái. tác động lên loss theo hai đường: trực tiếp qua (qua ), và gián tiếp qua , rồi từ đó tới mọi bước sau. Quy tắc dây chuyền cộng hai phần đó lại:
Ở bước cuối không có , nên . Từ đó ta tính lùi được .
Bước 2: Jacobian một bước. Thừa số là một ma trận , gọi là ma trận Jacobian: phần tử hàng , cột là đạo hàm của thành phần của theo thành phần của . Ta có , và đạo hàm của là . Mỗi thành phần đi qua riêng, nên quy tắc dây chuyền cho:
là ma trận đường chéo chứa đạo hàm của từng thành phần, tính lại được từ chính đã lưu khi lan truyền xuôi. Mọi số trên đường chéo nằm trong .
Bước 3: gradient theo trọng số. Ở bước , nhân với , giống một lớp tuyến tính nhận đầu vào . Gradient theo đầu vào của ở bước đó (nhân với đạo hàm , như Bước 2) là , nên phần đóng góp của bước là tích ngoài , như gradient của lớp tuyến tính trong MLP. Nhưng được dùng ở mọi bước, nên gradient của nó là tổng các phần đóng góp:
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 , thì như lớp ra của MLP, cũng cộng qua các bước có loss.
Vì sao RNN khó nhớ xa
Phần tiêu đề “Vì sao RNN khó nhớ xa”Giả sử nhãn ở bước phụ thuộc vào đầu vào ở một bước rất sớm. Để học được điều đó, gradient của phải đi ngược về tận . Trải công thức ở Bước 1 ra, phần đi theo đường từ về là:
Vế phải là tích của Jacobian một bước, mỗi bước một thừa số . Đâ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 bị nhân lặp lại lần.
Trường hợp một chiều. Hãy xem và trạng thái luôn gần 0, nên . Mỗi Jacobian chỉ là một số , và . Với : nếu thì ; nếu thì . 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 , với là chuẩn phổ (spectral norm), tức giá trị kỳ dị (singular value) lớn nhất 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 có các số trong , nên . Từ đó:
- Gradient tiêu biến (vanishing gradient). Nếu , chặn trên co về 0 theo cấp số nhân của khoảng cách . Đây là điều kiện đủ: chắc chắn tiêu biến. bão hòa (trạng thái gần , đạ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ì là điều kiện cần, nhưng chưa đủ, vì các 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 ; với sigmoid, ngưỡng là 4 thay cho 1, vì đạo hàm sigmoid không quá ). 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 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.
Truncated BPTT
Phần tiêu đề “Truncated BPTT”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 , 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 bước:
- Chạy xuôi trên đoạn đầu, bắt đầu từ , tính loss, lan truyền ngược chỉ trong đoạn đó, rồi cập nhật trọng số.
- 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ố.
- Lặp lại cho tới hết chuỗi.
Lượt xuôi vẫn liền mạch, nên vẫn mang thông tin từ các đoạn trước. Đổi lại, gradient không bao giờ đi xa quá bước, nên mô hình không được dạy trực tiếp để giữ thông tin lâu hơn bước. Bộ nhớ chỉ tăng theo , không theo . Trong PyTorch, bước 2 là h = h.detach(), như trong ô code thứ ba.
Gradient clipping
Phần tiêu đề “Gradient clipping”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 là vectơ ghép mọi gradient của mô hình, là một ngưỡng chọn trước. Trước mỗi bước cập nhật:
Vectơ mới có chuẩn đúng bằng 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 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.
Giới hạn
Phần tiêu đề “Giới 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ự. cần , 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 có 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.
Minh họa
Phần tiêu đề “Minh họa”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 , , dùng đúng một bộ , , không phải ba bộ khác nhau. Mũi tên ngang nối 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, . Mỗi cột là một khoảng cách .
| 0.5 | 0.5 | 0.00098 | 9.5e-07 | 8.9e-16 |
| 0.9 | 0.9 | 0.35 | 0.12 | 0.0052 |
| 1.0 | 1 | 1 | 1 | 1 |
| 1.1 | 1.1 | 2.6 | 6.7 | 120 |
Chỉ hàng 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ó .
Ví dụ code
Phần tiêu đề “Ví dụ code”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.
Lan truyền xuôi bằng NumPy
Phần tiêu đề “Lan truyền xuôi bằng NumPy”Ô 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 , . 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 gianW_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_yprint(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.sizeprint(f"parameters = {n_params}")Lần chạy đầu cần tải Python, khoảng 10 MB.
Đọc kết quả:
- Bốn trạng thái đều nằm trong nhờ . Trạng thái cuối 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 , đúng bằng 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à chỉ phụ thuộc các bước đã qua.
- Chuỗi đảo ngược cho , khác hẳn , 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ố: có 6, có 9, có 3, có 3, có 1. Con số này không đổi khi chuỗi dài hơn.
Đo gradient qua nhiều bước
Phần tiêu đề “Đo gradient qua nhiều bước”Ô dưới đo đúng đại lượng ở mục “Vì sao RNN khó nhớ xa”: chuẩn phổ theo khoảng cách , với và . Để điều khiển được độ lớn của , ô lấy , với 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 đều bằng đúng . Đầu vào là một chuỗi số nhỏ, và để gọn, ô bỏ hệ số tự do .
Code đi lùi từ bước , mỗi bước nhân thêm một Jacobian 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ỗidistances = [1, 10, 20, 30, 40] # các khoảng cách L - t cần inprint("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}")Lần chạy đầu cần tải Python, khoảng 10 MB.
Đọc kết quả:
- : tiêu biến. Ở khoảng cách 10 còn , rất sát chặn trên , vì trạng thái nhỏ nên gần ma trận đơn vị. Ở khoảng cách 40 chỉ còn : đầ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ố.
- : co chậm. Chặn trên là 1, nhưng 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.
- : bùng nổ. Từ 1.9 ở khoảng cách 1 lên ở khoảng cách 10 và ở khoảng cách 40. Con số này còn xa chặn trên , vì trạng thái đã lớn và bão hòa kéo bớt xuống. Điều kiện 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 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: cộng nhiễu chuẩn độ lệch 0.05, dài 2000 bước. Đầu vào là , nhãn là .
- Mô hình:
nn.RNN(1, 32)(, , hàm ) và một lớpnn.Linear(32, 1)cho . PyTorch tính với hai vectơ hệ số tự do,bias_ihvàbias_hh; tổng của chúng đóng vai trong công thức của bài. - Truncated BPTT: chuỗi chia thành 40 đoạn, mỗi đoạn 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 . 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.
Chạy trên máy bạn
nn.RNN đoán giá trị tiếp theo của sóng sin
- Cần cài
pip install torch- Phần cứng
- 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 torchfrom 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ệnx_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}")Kết quả mẫu
Lấy từ một lần chạy thật.
epoch 1 train MSE 0.0779 max grad norm 1.66epoch 5 train MSE 0.0045 max grad norm 0.15epoch 10 train MSE 0.0042 max grad norm 0.26epoch 15 train MSE 0.0042 max grad norm 0.16test MSE (RNN) 0.0038test 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 , 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.
Lỗi hay gặp
Phần tiêu đề “Lỗi hay gặp”Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Tóm tắt và đọc thêm
Phần tiêu đề “Tóm tắt và đọc thêm”Ý chính:
- RNN đọc chuỗi từng bước và cập nhật trạng thái ẩn , một bản tóm tắt của mọi bước đã qua; dự đoán ở bước là .
- Mở rộng theo thời gian, RNN là một mạng sâu lớp dùng chung trọng số: số tham số không phụ thuộc , đọ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 về bước đi qua tích Jacobian , nên co hoặc giãn theo cấp số nhân của khoảng cách: chắc chắn tiêu biến, mới có thể bùng nổ.
- Truncated BPTT giới hạn lan truyền ngược trong đoạn bước để tiết kiệm bộ nhớ; gradient clipping co gradient quá lớn về đúng ngưỡng . 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 , 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 cho độ dài chuỗi, chỗ bài này viết ; trong bài này 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.
Trình duyệt đang chặn lưu dữ liệu. Tiến độ sẽ mất khi bạn tải lại trang.
Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu