Skip to content

GRU

This content is not available in your language yet.

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) rt\mathbf{r}_t, cổng update (update gate) zt\mathbf{z}_t, trạng thái ứng viên (candidate state) h~t\tilde{\mathbf{h}}_t, và phép nội suy ra ht\mathbf{h}_t.
  • Giải thích được vì sao khi zt≈1\mathbf{z}_t \approx \mathbf{1}, 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.GRU của PyTorch trên một bài toán cần nhớ xa.

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 Whh\mathbf{W}_{hh}, cộng đầu vào mới, cho qua tanh⁡\tanh. 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 DkWhh\mathbf{D}_k\mathbf{W}_{hh}, 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à +1+1 hoặc −1-1, 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.

Ý 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.

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 (0,1)(0, 1), 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 (0,1)(0, 1), 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:

gt=σ(Wxgxt+Whght−1+bg)\mathbf{g}_t = \sigma\left(\mathbf{W}_{xg}\mathbf{x}_t + \mathbf{W}_{hg}\mathbf{h}_{t-1} + \mathbf{b}_g\right)

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 (x\mathbf{x} hay h\mathbf{h}), chữ sau là thứ được tính ra (ở đây là cổng gg). GRU có hai cổng như vậy.

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 tt, GRU nhận xt\mathbf{x}_t và ht−1\mathbf{h}_{t-1}, 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:

rt=σ(Wxrxt+Whrht−1+br)\mathbf{r}_t = \sigma\left(\mathbf{W}_{xr}\mathbf{x}_t + \mathbf{W}_{hr}\mathbf{h}_{t-1} + \mathbf{b}_r\right)

Bước 2: cổng update. Quyết định giữ lại bao nhiêu phần trạng thái cũ:

zt=σ(Wxzxt+Whzht−1+bz)\mathbf{z}_t = \sigma\left(\mathbf{W}_{xz}\mathbf{x}_t + \mathbf{W}_{hz}\mathbf{h}_{t-1} + \mathbf{b}_z\right)

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 Whh\mathbf{W}_{hh}:

h~t=tanh⁡(Wxhxt+Whh(rt⊙ht−1)+bh)\tilde{\mathbf{h}}_t = \tanh\left(\mathbf{W}_{xh}\mathbf{x}_t + \mathbf{W}_{hh}(\mathbf{r}_t \odot \mathbf{h}_{t-1}) + \mathbf{b}_h\right)

Nếu rt≈1\mathbf{r}_t \approx \mathbf{1} thì rt⊙ht−1≈ht−1\mathbf{r}_t \odot \mathbf{h}_{t-1} \approx \mathbf{h}_{t-1}, và h~t\tilde{\mathbf{h}}_t chính là trạng thái mới của RNN thường. Nếu rt≈0\mathbf{r}_t \approx \mathbf{0} thì số hạng Whh(… )\mathbf{W}_{hh}(\dots) biến mất, và ứng viên chỉ được tính từ xt\mathbf{x}_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:

ht=zt⊙ht−1+(1−zt)⊙h~t\mathbf{h}_t = \mathbf{z}_t \odot \mathbf{h}_{t-1} + (\mathbf{1} - \mathbf{z}_t) \odot \tilde{\mathbf{h}}_t

Với mỗi thành phần jj, hai trọng số zt,jz_{t,j} và 1−zt,j1 - z_{t,j} đều dương và cộng lại bằng 1, nên ht,jh_{t,j} luôn nằm giữa ht−1,jh_{t-1,j} và h~t,j\tilde{h}_{t,j}. Đây là cái van ở phần Trực giác: zt,j≈1z_{t,j} \approx 1 thì giữ cái cũ, zt,j≈0z_{t,j} \approx 0 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.

Đặt các cổng ở giá trị cực trị giúp hiểu GRU làm được những gì:

Cổng update zt\mathbf{z}_tCổng reset rt\mathbf{r}_tht\mathbf{h}_t gần bằngÝ nghĩa
≈1\approx \mathbf{1}bất kỳht−1\mathbf{h}_{t-1}Giữ nguyên, bỏ qua xt\mathbf{x}_t
≈0\approx \mathbf{0}≈1\approx \mathbf{1}tanh⁡(Wxhxt+Whhht−1+bh)\tanh(\mathbf{W}_{xh}\mathbf{x}_t + \mathbf{W}_{hh}\mathbf{h}_{t-1} + \mathbf{b}_h)Đúng một bước RNN thường
≈0\approx \mathbf{0}≈0\approx \mathbf{0}tanh⁡(Wxhxt+bh)\tanh(\mathbf{W}_{xh}\mathbf{x}_t + \mathbf{b}_h)Quên hết, bắt đầu lại từ xt\mathbf{x}_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.

Ở RNN thường, Jacobian một bước là DtWhh\mathbf{D}_t\mathbf{W}_{hh}, và gradient đi từ bước LL về bước tt phải nhân L−tL - t ma trận như vậy. Giờ ta tính Jacobian một bước của GRU, ∂ht/∂ht−1\partial \mathbf{h}_t / \partial \mathbf{h}_{t-1}.

Trong công thức ht=zt⊙ht−1+(1−zt)⊙h~t\mathbf{h}_t = \mathbf{z}_t \odot \mathbf{h}_{t-1} + (\mathbf{1} - \mathbf{z}_t) \odot \tilde{\mathbf{h}}_t, ht−1\mathbf{h}_{t-1} xuất hiện ở ba chỗ: trực tiếp trong số hạng đầu, bên trong zt\mathbf{z}_t, và bên trong h~t\tilde{\mathbf{h}}_t. Đạ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ỗ:

∂ht∂ht−1=diag(zt)⏟đường thẳng+diag(ht−1−h~t) ∂zt∂ht−1⏟qua cổng update+diag(1−zt) ∂h~t∂ht−1⏟qua ứng vieˆn\frac{\partial \mathbf{h}_t}{\partial \mathbf{h}_{t-1}} = \underbrace{\mathrm{diag}(\mathbf{z}_t)}_{\text{đường thẳng}} + \underbrace{\mathrm{diag}(\mathbf{h}_{t-1} - \tilde{\mathbf{h}}_t)\,\frac{\partial \mathbf{z}_t}{\partial \mathbf{h}_{t-1}}}_{\text{qua cổng update}} + \underbrace{\mathrm{diag}(\mathbf{1} - \mathbf{z}_t)\,\frac{\partial \tilde{\mathbf{h}}_t}{\partial \mathbf{h}_{t-1}}}_{\text{qua ứng viên}}

Số hạng giữa có hệ số ht−1−h~t\mathbf{h}_{t-1} - \tilde{\mathbf{h}}_t vì zt\mathbf{z}_t nhân với ht−1\mathbf{h}_{t-1} và −zt-\mathbf{z}_t nhân với h~t\tilde{\mathbf{h}}_t. Đạo hàm của sigmoid là σ′(a)=σ(a)(1−σ(a))\sigma'(a) = \sigma(a)(1 - \sigma(a)), nên:

∂zt∂ht−1=diag(zt⊙(1−zt)) Whz\frac{\partial \mathbf{z}_t}{\partial \mathbf{h}_{t-1}} = \mathrm{diag}\big(\mathbf{z}_t \odot (\mathbf{1} - \mathbf{z}_t)\big)\,\mathbf{W}_{hz}

Giờ xét một thành phần có z≈1z \approx 1:

  • Số hạng đầu có số z≈1z \approx 1 trên đường chéo.
  • Số hạng giữa chứa z(1−z)≈0z(1 - z) \approx 0, nên gần 0.
  • Số hạng cuối bị nhân với 1−z≈01 - z \approx 0, nên cũng gần 0. Đây là nhánh duy nhất đi qua Whh\mathbf{W}_{hh}.

Khi mọi thành phần có z≈1z \approx 1, Jacobian một bước gần bằng diag(zt)≈I\mathrm{diag}(\mathbf{z}_t) \approx \mathbf{I}, ma trận đơn vị. Qua nhiều bước, tích các Jacobian gần bằng tích các diag(zk)\mathrm{diag}(\mathbf{z}_k): thành phần jj chỉ bị nhân với ∏kzk,j\prod_k z_{k,j}. Ví dụ z=0.99z = 0.99 suốt 50 bước cho 0.9950≈0.60.99^{50} \approx 0.6. So với RNN thường ở bài trước, nơi σmax⁡(Whh)=0.9\sigma_{\max}(\mathbf{W}_{hh}) = 0.9 đã cho chặn trên 0.950≈0.0050.9^{50} \approx 0.005, đâ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 ht−1\mathbf{h}_{t-1} sang ht\mathbf{h}_t, 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.

RNN thường có một nhánh: Wxh∈Rdh×dx\mathbf{W}_{xh} \in \mathbb{R}^{d_h \times d_x}, Whh∈Rdh×dh\mathbf{W}_{hh} \in \mathbb{R}^{d_h \times d_h}, bh∈Rdh\mathbf{b}_h \in \mathbb{R}^{d_h}, tổng dh(dx+dh)+dhd_h(d_x + d_h) + d_h tham số. GRU có ba nhánh cùng hình dạng (cổng reset, cổng update, ứng viên), nên:

soˆˊ tham soˆˊ GRU=3(dh(dx+dh)+dh)\text{số tham số GRU} = 3\left(d_h(d_x + d_h) + d_h\right)

Ví dụ dx=3d_x = 3, dh=4d_h = 4 như ô NumPy bên dưới: RNN có 4⋅7+4=324 \cdot 7 + 4 = 32 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 3(dh(dx+dh)+2dh)3\left(d_h(d_x + d_h) + 2d_h\right). 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 (3dh,dx)(3d_h, d_x), weight_hh_l0 có kích thước (3dh,dh)(3d_h, d_h).

PyTorch đặt cổng reset sau phép nhân ma trận, không phải trước:

h~t=tanh⁡(Wxhxt+bxh+rt⊙(Whhht−1+bhh))\tilde{\mathbf{h}}_t = \tanh\left(\mathbf{W}_{xh}\mathbf{x}_t + \mathbf{b}_{xh} + \mathbf{r}_t \odot (\mathbf{W}_{hh}\mathbf{h}_{t-1} + \mathbf{b}_{hh})\right)

Tài liệu PyTorch gọi đại lượng này là ntn_t (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 Whh(rt⊙ht−1)\mathbf{W}_{hh}(\mathbf{r}_t \odot \mathbf{h}_{t-1}) vì nó dễ đọc hơn: cổng reset quyết định “nhìn bao nhiêu trạng thái cũ”.

  • Cổng không xóa hẳn gradient tiêu biến. Mạng phải học được zt≈1\mathbf{z}_t \approx \mathbf{1}, mà lúc mới khởi tạo, zt≈0.5\mathbf{z}_t \approx 0.5. Ô 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 tt phải đợi bước t−1t - 1, 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).

Hình dưới vẽ một ô GRU. Hãy nhìn theo đường ngang phía trên, nơi ht−1\mathbf{h}_{t-1} đi thẳng tới ht\mathbf{h}_t chỉ qua một phép nhân với zt\mathbf{z}_t: đó là “đường thẳng” của mục Lý thuyết. Mọi phép nhân ma trận và hàm tanh⁡\tanh 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 h0=[0.9,−0.8,0.5,−0.3]\mathbf{h}_0 = [0.9, -0.8, 0.5, -0.3], chỉ đổi hệ số tự do của cổng update để đẩy z\mathbf{z} về các đầu mút.

Cộng thêm vào cổng updatez\mathbf{z} trung bìnhh1\mathbf{h}_1Gần với
000.510[0.245,−0.375,−0.092,0.244][0.245, -0.375, -0.092, 0.244]trộn cũ và mới
+5+50.992[0.885,−0.796,0.493,−0.293][0.885, -0.796, 0.493, -0.293]h0\mathbf{h}_0
−5-50.008[−0.022,0.353,−0.911,0.847][-0.022, 0.353, -0.911, 0.847]ứng viên h~1\tilde{\mathbf{h}}_1

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.

Ô dưới viết hàm gru_step đúng theo bốn phương trình ở mục Lý thuyết, với dx=3d_x = 3, dh=4d_h = 4 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 z\mathbf{z} 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, h0\mathbf{h}_0 và −h0-\mathbf{h}_0. 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: z\mathbf{z} trung bình 0.510, nên h1\mathbf{h}_1 là một hỗn hợp gần nửa cũ nửa mới, khác hẳn h0\mathbf{h}_0.
  • bias_z=+5: z\mathbf{z} trung bình 0.992, và h1=[0.885,−0.796,0.493,−0.293]\mathbf{h}_1 = [0.885, -0.796, 0.493, -0.293] gần như trùng h0=[0.9,−0.8,0.5,−0.3]\mathbf{h}_0 = [0.9, -0.8, 0.5, -0.3]. Đầu vào x1\mathbf{x}_1 gần như bị bỏ qua.
  • bias_z=-5: z\mathbf{z} trung bình 0.008, nên h1\mathbf{h}_1 gần bằng ứng viên, không còn dấu vết của h0\mathbf{h}_0.
  • 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ới bias_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: 0.99250≈0.670.992^{50} \approx 0.67, và 1.8⋅0.67≈1.21.8 \cdot 0.67 \approx 1.2.

Đ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 zz, như Jacobian ở mục Lý thuyết.

File dưới làm đúng nhiệm vụ ở phần Trực giác: chuỗi dài LL, mỗi phần tử là +1+1 hoặc −1-1 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 hL\mathbf{h}_L để đoán.

  • Mô hình: nn.RNN hoặc nn.GRU với dx=1d_x = 1, dh=16d_h = 16, 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 L=10L = 10 và L=100L = 100. Đá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 trong bias_hh_l0 (khối thứ hai trong ba khối r, z, n) bằng 3, nên lúc đầu zt≈σ(3)≈0.95\mathbf{z}_t \approx \sigma(3) \approx 0.95: 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 L
import torch
from torch import nn
torch.manual_seed(0)
torch.set_num_threads(1) # mạng nhỏ: một luồng CPU là đủ nhanh
H, STEPS = 16, 200 # số chiều trạng thái ẩn, số bước huấn luyện
BIAS_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 parameters
GRU: 912 recurrent parameters
L= 10 RNN: loss 0.000 test accuracy 1.000
L= 10 GRU: loss 0.000 test accuracy 1.000
L=100 RNN: loss 0.691 test accuracy 0.496
L=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: 16⋅17+2⋅16=30416 \cdot 17 + 2 \cdot 16 = 304 cho mỗi nhánh.
  • L=10L = 10: cả hai đều đúng hoàn toàn. Với chuỗi ngắn, RNN thường vẫn nhớ được.
  • L=100L = 100: RNN thường có loss 0.691, gần ln⁡2≈0.693\ln 2 \approx 0.693, 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.

Question 1 of 4 Một cuốn sách viết GRU theo dạng ht=(1−zt)⊙ht−1+zt⊙h~t\mathbf{h}_t = (1 - \mathbf{z}_t) \odot \mathbf{h}_{t-1} + \mathbf{z}_t \odot \tilde{\mathbf{h}}_t. Theo cách viết này, muốn một đơn vị giữ nguyên thông tin qua nhiều bước thì cổng update của nó phải thế nào?
Question 2 of 4 Một lớp có dx=10d_x = 10, dh=20d_h = 20, mỗi nhánh một vectơ hệ số tự do. RNN thường có 620 tham số. GRU cùng kích thước có bao nhiêu?
Question 3 of 4 Ở một bước, cả cổng reset lẫn cổng update của mọi đơn vị đều gần 0. Trạng thái mới ht\mathbf{h}_t gần bằng gì?
Question 4 of 4 Một đơn vị của GRU có cổng update z≈0.99z \approx 0.99 suốt 50 bước liền. Theo đường đi thẳng qua các trạng thái, gradient từ bước cuối về bước đầu bị nhân với khoảng bao nhiêu?

Ý 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 DtWhh\mathbf{D}_t\mathbf{W}_{hh} và mờ dần theo cấp số nhân.
  • GRU thêm cổng reset rt\mathbf{r}_t (nhìn trạng thái cũ bao nhiêu khi tính ứng viên) và cổng update zt\mathbf{z}_t (giữ trạng thái cũ bao nhiêu), rồi nội suy ht=zt⊙ht−1+(1−zt)⊙h~t\mathbf{h}_t = \mathbf{z}_t \odot \mathbf{h}_{t-1} + (\mathbf{1} - \mathbf{z}_t) \odot \tilde{\mathbf{h}}_t. Một số tài liệu đổi vai trò zt\mathbf{z}_t và 1−zt\mathbf{1} - \mathbf{z}_t.
  • Khi zt≈1\mathbf{z}_t \approx \mathbf{1}, Jacobian một bước gần bằng I\mathbf{I}, 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 zt\mathbf{z}_t với bài, nhưng viết theo dạng hàng (XtWxr\mathbf{X}_t\mathbf{W}_{xr}) 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 zt\mathbf{z}_t 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ủa batch_first.

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