GRU
This content is not available in your language yet.
Mục tiêu
Phần tiêu đề “Mục tiêu”Sau bài này, bạn sẽ:
- Hiểu vì sao RNN thường khó nhớ xa, và ý tưởng cổng (gate) như một cái van cho từng ô nhớ.
- Viết được các phương trình của GRU (gated recurrent unit): cổng reset (reset gate) , cổng update (update gate) , trạng thái ứng viên (candidate state) , và phép nội suy ra .
- Giải thích được vì sao khi , gradient đi qua nhiều bước mà gần như không bị co lại.
- Cài đặt được một bước GRU bằng NumPy, đếm được số tham số (gấp 3 RNN), và dùng
nn.GRUcủa PyTorch trên một bài toán cần nhớ xa.
Cần biết trước
Phần tiêu đề “Cần biết trước”- Recurrent Neural Networks (RNN), in Deep Learning(Not translated yet) (Learned)
Trực giác
Phần tiêu đề “Trực giác”Bản photo của bản photo
Phần tiêu đề “Bản photo của bản photo”Hãy nghĩ tới một tờ giấy được photo, rồi lấy bản photo đó photo tiếp, cứ thế 50 lần. Mỗi lần chỉ mờ đi một chút, nhưng tới bản cuối thì chữ trên bản gốc gần như không đọc được nữa.
RNN thường làm đúng như vậy với trí nhớ của nó. Ở mỗi bước, nó tính trạng thái mới hoàn toàn từ đầu: lấy trạng thái cũ nhân với , cộng đầu vào mới, cho qua . Thông tin của bước 1 muốn tới bước 50 phải đi qua 49 lần biến đổi như thế. Bài Mạng hồi tiếp đã cho thấy hệ quả ở chiều ngược lại: gradient từ bước cuối về bước đầu là tích của 49 Jacobian , và thường co về 0 theo cấp số nhân.
Thử một nhiệm vụ cụ thể: mạng đọc một chuỗi 100 số, mỗi số là hoặc , rồi phải nói số đầu tiên là gì. 99 số sau chỉ là nhiễu. Người làm việc này rất dễ: ghi số đầu ra giấy, rồi không đụng vào tờ giấy nữa. RNN thường không có cách nào “không đụng vào”: bước nào nó cũng viết lại toàn bộ trạng thái.
Một cái van cho mỗi ô nhớ
Phần tiêu đề “Một cái van cho mỗi ô nhớ”Ý tưởng của GRU: gắn cho mỗi thành phần của trạng thái một cái van. Van mở hết về phía “giữ” thì thành phần đó được chép nguyên sang bước sau. Van mở về phía “ghi mới” thì nó được thay bằng giá trị mới tính từ đầu vào. Van không phải công tắc bật tắt, mà vặn được tới mọi mức ở giữa.
Quan trọng nhất: độ mở của van không cố định. Nó được tính ở mỗi bước từ đầu vào và trạng thái hiện tại, bằng các trọng số học được. Mạng tự học khi nào nên giữ, khi nào nên ghi đè.
Bài toán của bài này là: thiết kế một bước cập nhật trạng thái có thể học cách giữ nguyên trạng thái cũ qua nhiều bước, để cả thông tin lẫn gradient đi được xa.
Lý thuyết
Phần tiêu đề “Lý thuyết”Cổng là gì
Phần tiêu đề “Cổng là gì”Một cổng là một vectơ có cùng kích thước với trạng thái, mỗi số nằm trong , dùng để nhân từng phần tử với một vectơ khác. Số gần 0 thì chặn, số gần 1 thì cho qua. Để có các số trong , ta lấy sigmoid của một hàm tuyến tính, giống hệt một lớp của mạng nơ-ron:
Tên ma trận theo đúng quy ước của bài Mạng hồi tiếp: chữ đầu là thứ được nhân vào ( hay ), chữ sau là thứ được tính ra (ở đây là cổng ). GRU có hai cổng như vậy.
Bốn phương trình của GRU
Phần tiêu đề “Bốn phương trình của GRU”GRU do Cho và cộng sự đề xuất năm 2014, trong cùng bài báo giới thiệu khung encoder–decoder mà bạn gặp ở bài Mô hình chuỗi sang chuỗi. Ở bước , GRU nhận và , rồi tính bốn thứ theo thứ tự.
Bước 1: cổng reset. Quyết định khi tính giá trị mới thì nhìn trạng thái cũ bao nhiêu:
Bước 2: cổng update. Quyết định giữ lại bao nhiêu phần trạng thái cũ:
Bước 3: trạng thái ứng viên. Đây là đúng một bước RNN thường, chỉ khác là trạng thái cũ đi qua cổng reset trước khi nhân với :
Nếu thì , và chính là trạng thái mới của RNN thường. Nếu thì số hạng biến mất, và ứng viên chỉ được tính từ , như thể chuỗi mới bắt đầu ở đây.
Bước 4: nội suy. Trạng thái mới là trung bình có trọng số của trạng thái cũ và ứng viên, theo từng thành phần:
Với mỗi thành phần , hai trọng số và đều dương và cộng lại bằng 1, nên luôn nằm giữa và . Đây là cái van ở phần Trực giác: thì giữ cái cũ, thì lấy cái mới. Mỗi thành phần có van riêng, nên có thành phần giữ lâu, có thành phần thay đổi liên tục.
Bốn trường hợp đầu mút
Phần tiêu đề “Bốn trường hợp đầu mút”Đặt các cổng ở giá trị cực trị giúp hiểu GRU làm được những gì:
| Cổng update | Cổng reset | gần bằng | Ý nghĩa |
|---|---|---|---|
| bất kỳ | Giữ nguyên, bỏ qua | ||
| Đúng một bước RNN thường | |||
| Quên hết, bắt đầu lại từ |
Vậy RNN thường là một trường hợp riêng của GRU. GRU thêm được hai hành vi mà RNN thường không có: giữ nguyên và quên hẳn. Cho và cộng sự nhận xét rằng đơn vị nào học phụ thuộc ngắn sẽ hay dùng cổng reset, đơn vị nào học phụ thuộc dài sẽ để cổng update gần mức “giữ” phần lớn thời gian.
Vì sao cổng giúp gradient đi xa
Phần tiêu đề “Vì sao cổng giúp gradient đi xa”Ở RNN thường, Jacobian một bước là , và gradient đi từ bước về bước phải nhân ma trận như vậy. Giờ ta tính Jacobian một bước của GRU, .
Trong công thức , xuất hiện ở ba chỗ: trực tiếp trong số hạng đầu, bên trong , và bên trong . Đạo hàm của tích theo quy tắc nhân, cộng phần đóng góp của cả ba chỗ:
Số hạng giữa có hệ số vì nhân với và nhân với . Đạo hàm của sigmoid là , nên:
Giờ xét một thành phần có :
- Số hạng đầu có số trên đường chéo.
- Số hạng giữa chứa , nên gần 0.
- Số hạng cuối bị nhân với , nên cũng gần 0. Đây là nhánh duy nhất đi qua .
Khi mọi thành phần có , Jacobian một bước gần bằng , ma trận đơn vị. Qua nhiều bước, tích các Jacobian gần bằng tích các : thành phần chỉ bị nhân với . Ví dụ suốt 50 bước cho . So với RNN thường ở bài trước, nơi đã cho chặn trên , đây là một đường đi gần như thẳng.
Điểm mấu chốt nằm ở phép cộng: số 1 trên đường chéo đến từ việc chép sang , không phải từ một ma trận trọng số. Chung và cộng sự (2014) gọi đây là thành phần cộng (additive) mà RNN thường không có: RNN thường luôn thay nội dung, còn GRU giữ nội dung cũ và thêm nội dung mới lên trên.
Số tham số
Phần tiêu đề “Số tham số”RNN thường có một nhánh: , , , tổng tham số. GRU có ba nhánh cùng hình dạng (cổng reset, cổng update, ứng viên), nên:
Ví dụ , như ô NumPy bên dưới: RNN có tham số, GRU có 96. Nhiều hơn RNN, nhưng ít hơn LSTM (4 nhánh, bài LSTM và BiLSTM).
nn.GRU của PyTorch dùng hai vectơ hệ số tự do cho mỗi nhánh (bias_ih và bias_hh), nên nó báo . Ba nhánh được xếp chồng trong một tensor theo thứ tự reset, update, ứng viên: weight_ih_l0 có kích thước , weight_hh_l0 có kích thước .
Cách PyTorch tính ứng viên
Phần tiêu đề “Cách PyTorch tính ứng viên”PyTorch đặt cổng reset sau phép nhân ma trận, không phải trước:
Tài liệu PyTorch gọi đại lượng này là (new gate). Chung và cộng sự (2014) đã thử cả hai cách đặt cổng reset và thấy kết quả ngang nhau. Các phương trình khác, kể cả phép nội suy, giống hệt mục trên. Bài dùng dạng vì nó dễ đọc hơn: cổng reset quyết định “nhìn bao nhiêu trạng thái cũ”.
Giới hạn
Phần tiêu đề “Giới hạn”- Cổng không xóa hẳn gradient tiêu biến. Mạng phải học được , mà lúc mới khởi tạo, . Ô PyTorch và hộp “Lỗi hay gặp” cho thấy chuyện này.
- Gradient vẫn có thể bùng nổ qua nhánh ứng viên, nên huấn luyện GRU vẫn hay dùng cắt chuẩn gradient (gradient clipping) như ở bài Mạng hồi tiếp.
- Vẫn tuần tự. Bước phải đợi bước , nên không song song theo thời gian được. Đây là lý do của bài Transformers.
- Một vectơ cho cả giữ lẫn đọc. GRU đưa toàn bộ trạng thái ra ngoài ở mỗi bước, và tỉ lệ giữ cũ với lấy mới luôn cộng lại bằng 1. LSTM tách hai việc này bằng trạng thái ô và ba cổng (bài LSTM và BiLSTM).
Minh họa
Phần tiêu đề “Minh họa”Hình dưới vẽ một ô GRU. Hãy nhìn theo đường ngang phía trên, nơi đi thẳng tới chỉ qua một phép nhân với : đó là “đường thẳng” của mục Lý thuyết. Mọi phép nhân ma trận và hàm nằm ở nhánh phía dưới, nhánh tạo ứng viên.
Bảng dưới lấy số từ ô NumPy thứ nhất: cùng một đầu vào và cùng , chỉ đổi hệ số tự do của cổng update để đẩy về các đầu mút.
| Cộng thêm vào cổng update | trung bình | Gần với | |
|---|---|---|---|
| 0.510 | trộn cũ và mới | ||
| 0.992 | |||
| 0.008 | ứng viên |
Ví dụ code
Phần tiêu đề “Ví dụ code”Các ô dưới đây chạy độc lập. Ô thứ nhất chỉ cần NumPy và chạy ngay trên trang. Ô thứ hai cần PyTorch nên chạy trên máy bạn; nó chạy thử lần cuối tháng 11/2026 với Python 3.14 và PyTorch 2.15 trên CPU.
Một bước GRU bằng NumPy
Phần tiêu đề “Một bước GRU bằng NumPy”Ô dưới viết hàm gru_step đúng theo bốn phương trình ở mục Lý thuyết, với , và trọng số ngẫu nhiên. Tham số bias_z cộng thêm một số vào cổng update để đẩy về gần 0 hoặc gần 1. Phần đầu chạy một bước với ba giá trị của bias_z. Phần sau cho mạng đọc cùng một chuỗi 50 đầu vào, xuất phát từ hai trạng thái đầu khác nhau, và . Nếu sau 50 bước hai trạng thái cuối vẫn khác nhau, mạng còn “nhớ” nó xuất phát từ đâu.
Một bước GRU, và trí nhớ sau 50 bước
import numpy as np
rng = np.random.default_rng(0)d_x, d_h = 3, 4 # số chiều đầu vào và số chiều trạng thái ẩn
def sigmoid(a): return 1 / (1 + np.exp(-a))
def init(): # một bộ (W_x., W_h., b_.) cho mỗi cổng r, z và cho ứng viên return {k: (rng.normal(0, 0.5, (d_h, d_x)), rng.normal(0, 0.5, (d_h, d_h)), np.zeros(d_h)) for k in "rzh"}
def gru_step(x, h, p, bias_z=0.0): (Wxr, Whr, br), (Wxz, Whz, bz), (Wxh, Whh, bh) = p["r"], p["z"], p["h"] r = sigmoid(Wxr @ x + Whr @ h + br) # cổng reset z = sigmoid(Wxz @ x + Whz @ h + bz + bias_z) # cổng update h_cand = np.tanh(Wxh @ x + Whh @ (r * h) + bh) # trạng thái ứng viên return z * h + (1 - z) * h_cand, z # nội suy theo z
p = init()h0 = np.array([0.9, -0.8, 0.5, -0.3])x1 = rng.normal(size=d_x)np.set_printoptions(precision=3, suppress=True)for bias_z in [0.0, 5.0, -5.0]: # cộng thêm vào cổng update h1, z = gru_step(x1, h0, p, bias_z) print(f"bias_z={bias_z:+.0f} mean z={z.mean():.3f} h1={h1}")
# Đi 50 bước với hai trạng thái đầu khác nhau, cùng một chuỗi đầu vào.# Hai trạng thái cuối còn khác nhau bao nhiêu = mạng còn nhớ h0 bao nhiêu.xs = rng.normal(size=(50, d_x))print(f"start |ha - hb| = {np.abs(2 * h0).max():.4f}")for bias_z in [0.0, 5.0]: ha, hb = h0.copy(), -h0 for x in xs: ha, _ = gru_step(x, ha, p, bias_z) hb, _ = gru_step(x, hb, p, bias_z) gap = np.abs(ha - hb).max() print(f"bias_z={bias_z:+.0f} after 50 steps |ha - hb| = {gap:.4f}")The first run downloads Python, about 10 MB.
Đọc kết quả:
bias_z=+0: trung bình 0.510, nên là một hỗn hợp gần nửa cũ nửa mới, khác hẳn .bias_z=+5: trung bình 0.992, và gần như trùng . Đầu vào gần như bị bỏ qua.bias_z=-5: trung bình 0.008, nên gần bằng ứng viên, không còn dấu vết của .- Sau 50 bước: lúc đầu hai trạng thái cách nhau tối đa 1.8 ở một thành phần. Với
bias_z=+0, khoảng cách còn 0.0000: hai trạng thái đã trùng nhau, mạng quên hẳn điểm xuất phát. Vớibias_z=+5, khoảng cách vẫn là 1.3047. Con số này khớp cỡ với phép tính ở mục Lý thuyết: , và .
Điều mà ô này cho thấy với giá trị trạng thái cũng đúng với gradient: khoảng cách giữa hai trạng thái cuối co lại theo đúng tích các , như Jacobian ở mục Lý thuyết.
RNN thường và GRU trên bài toán nhớ xa
Phần tiêu đề “RNN thường và GRU trên bài toán nhớ xa”File dưới làm đúng nhiệm vụ ở phần Trực giác: chuỗi dài , mỗi phần tử là hoặc ngẫu nhiên, nhãn là phần tử đầu tiên. Mô hình chỉ được dùng trạng thái cuối để đoán.
- Mô hình:
nn.RNNhoặcnn.GRUvới , , rồi một lớp tuyến tính ra 2 lớp. Đoán bừa thì độ chính xác khoảng 0.5. - Huấn luyện: 200 bước Adam, mỗi bước 64 chuỗi mới, với và . Đánh giá trên 2000 chuỗi mới.
- Khởi tạo cổng update: dòng
BIAS_Z = 3.0đặt phần hệ số tự do của cổng update trongbias_hh_l0(khối thứ hai trong ba khối r, z, n) bằng 3, nên lúc đầu : mạng bắt đầu ở chế độ “giữ là chính”. Đây là cùng ý với mẹo đặt hệ số tự do của cổng quên bằng 1 ở bài LSTM và BiLSTM.
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
RNN thường và GRU nhớ phần tử đầu tiên
- Install
pip install torch- Hardware
- CPU là đủ, không cần GPU. Khoảng 1 tới 2 phút trên máy phát triển khi đang chạy song song nhiều việc, có thể nhanh hơn trên máy rảnh
# RNN thường và GRU học nhớ phần tử ĐẦU TIÊN của một chuỗi dài Limport torchfrom torch import nn
torch.manual_seed(0)torch.set_num_threads(1) # mạng nhỏ: một luồng CPU là đủ nhanhH, STEPS = 16, 200 # số chiều trạng thái ẩn, số bước huấn luyệnBIAS_Z = 3.0 # hệ số tự do ban đầu của cổng update: z ≈ 0.95 lúc đầu
def batch(n, L): x = torch.randint(0, 2, (n, L, 1)).float() * 2 - 1 # các số +1 hoặc -1 y = (x[:, 0, 0] > 0).long() # nhãn: phần tử đầu tiên là +1 hay -1 return x, y
class Net(nn.Module): def __init__(self, cell): super().__init__() self.rnn = cell(1, H, batch_first=True) # nn.RNN hoặc nn.GRU self.out = nn.Linear(H, 2) if cell is nn.GRU: # các khối trong bias_hh xếp theo thứ tự r, z, n with torch.no_grad(): self.rnn.bias_hh_l0[H:2 * H].fill_(BIAS_Z)
def forward(self, x): hs, _ = self.rnn(x) # trạng thái ẩn ở mọi bước return self.out(hs[:, -1]) # chỉ dùng trạng thái cuối, h_L
for cell in [nn.RNN, nn.GRU]: n_par = sum(p.numel() for p in cell(1, H).parameters()) print(f"{cell.__name__}: {n_par} recurrent parameters")
for L in [10, 100]: for cell in [nn.RNN, nn.GRU]: torch.manual_seed(0) model = Net(cell) opt = torch.optim.Adam(model.parameters(), lr=1e-2) for step in range(STEPS): x, y = batch(64, L) loss = nn.functional.cross_entropy(model(x), y) opt.zero_grad() loss.backward() opt.step() with torch.no_grad(): x, y = batch(2000, L) # 2000 chuỗi mới acc = (model(x).argmax(1) == y).float().mean() print(f"L={L:3d} {cell.__name__}: loss {loss.item():.3f}" f" test accuracy {acc:.3f}")Sample output
From one real run.
RNN: 304 recurrent parametersGRU: 912 recurrent parametersL= 10 RNN: loss 0.000 test accuracy 1.000L= 10 GRU: loss 0.000 test accuracy 1.000L=100 RNN: loss 0.691 test accuracy 0.496L=100 GRU: loss 0.001 test accuracy 1.000Đọc kết quả:
- Số tham số: 304 và 912, đúng gấp 3. Theo công thức hai bias của PyTorch: cho mỗi nhánh.
- : cả hai đều đúng hoàn toàn. Với chuỗi ngắn, RNN thường vẫn nhớ được.
- : RNN thường có loss 0.691, gần , mức của đoán bừa, và độ chính xác 0.496. Gradient từ bước 100 về bước 1 quá nhỏ để nó học được gì. GRU có loss 0.001 và độ chính xác 1.000: các cổng update giữ thông tin của phần tử đầu qua 99 bước nhiễu.
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 thường ghi đè trạng thái ở mọi bước, nên thông tin và gradient bị nhân lặp với và mờ dần theo cấp số nhân.
- GRU thêm cổng reset (nhìn trạng thái cũ bao nhiêu khi tính ứng viên) và cổng update (giữ trạng thái cũ bao nhiêu), rồi nội suy . Một số tài liệu đổi vai trò và .
- Khi , Jacobian một bước gần bằng , nên gradient đi qua nhiều bước gần như không co. Nhưng mạng phải học được tới đó: khởi tạo cổng nghiêng về phía giữ giúp ích với chuỗi rất dài.
- GRU có 3 lần số tham số của RNN cùng kích thước, ít hơn LSTM (4 lần), và cho kết quả tương đương LSTM trong nhiều thí nghiệm.
Đọc thêm:
- Aston Zhang, Zachary C. Lipton, Mu Li, Alexander J. Smola (2023). Dive into Deep Learning, mục 10.2 “Gated Recurrent Units (GRU)”. Giải thích từng cổng kèm code; cùng quy ước với bài, nhưng viết theo dạng hàng () thay cho dạng cột.
- Kyunghyun Cho và cộng sự (2014). Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation, EMNLP 2014, mục 2.3. Bài báo giới thiệu GRU (khi đó chưa có tên này), với giải thích vai trò của từng cổng.
- Junyoung Chung, Caglar Gulcehre, KyungHyun Cho, Yoshua Bengio (2014). Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling, NIPS 2014 Deep Learning Workshop, mục 3. So GRU, LSTM và RNN thường trên nhạc và tiếng nói; nói rõ thành phần cộng. Bài báo dùng quy ước ngược với bài này.
- PyTorch. torch.nn.GRU. Phương trình chính xác mà PyTorch tính, cách xếp ba nhánh trong
weight_ih_l0,weight_hh_l0, và ý nghĩa củabatch_first.
Your browser is blocking storage. Your progress will be lost when you reload the page.
Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About