Skip to content

LSTM và BiLSTM

This content is not available in your language yet.

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

  • Hiểu vai trò của trạng thái ô (cell state) ct\mathbf{c}_t và ba cổng của LSTM (Long Short-Term Memory): cổng quên (forget gate), cổng vào (input gate), cổng ra (output gate).
  • Giải thích được vì sao đường cộng ct=ft⊙ct−1+it⊙c~t\mathbf{c}_t = \mathbf{f}_t \odot \mathbf{c}_{t-1} + \mathbf{i}_t \odot \tilde{\mathbf{c}}_t giúp gradient đi xa hơn RNN thường.
  • Cài đặt được một bước LSTM bằng NumPy, đếm được số tham số, và so LSTM với GRU.
  • Nhận ra khi nào dùng được LSTM hai chiều (BiLSTM) và khi nào không, và dùng LSTM nhiều tầng.
  • GRU, in Deep Learning(Not translated yet) (Learned)

Đọc câu này và đoán chữ còn thiếu:

Chiếc xe máy mà ông nội mua từ năm 1995, sau bao nhiêu lần sửa, thay lốp, sơn lại và một trận ngập nước năm ngoái, đến giờ vẫn ___ được.

Bạn đoán “chạy”, không phải “nấu” hay “đọc”. Từ quyết định đáp án là “xe máy”, nằm cách chỗ trống 26 chữ. Trong khoảng giữa có rất nhiều thứ mà bạn đọc xong là quên ngay (năm 1995, lốp, sơn). Bạn chỉ giữ lại một ghi chú nhỏ: “chủ ngữ là một cái xe”.

RNN thường không giữ ghi chú kiểu đó được lâu. Mỗi bước nó viết lại toàn bộ trạng thái ht\mathbf{h}_t: nhân với ma trận, cộng đầu vào mới, rồi qua tanh⁡\tanh. Giống như chụp lại bản photo của bản photo: sau vài chục lần, nét chữ ban đầu mờ hẳn. Về mặt huấn luyện, như bài RNN đã chỉ ra, tín hiệu lỗi đi ngược 26 bước cũng bị nhân 26 lần với cùng một ma trận, nên thường tắt trước khi tới được chữ “xe máy”.

LSTM thêm một đường riêng cho ghi chú, gọi là trạng thái ô ct\mathbf{c}_t. Hãy hình dung một băng chuyền chạy ngang qua mọi bước thời gian. Mặc định, thứ gì đặt lên băng chuyền thì cứ thế trôi đi, không bị nhân với ma trận nào. Ở mỗi bước có ba người gác, mỗi người cầm một cái van:

  • Người gác quên: nhìn đầu vào mới và quyết định bỏ bớt món nào trên băng. Đọc tới một chủ ngữ mới thì xóa chủ ngữ cũ.
  • Người gác vào: quyết định đặt thêm món mới nào lên băng. Đọc “xe máy” thì ghi lại “chủ ngữ là một cái xe”.
  • Người gác ra: quyết định lúc này lấy món nào trên băng ra dùng. Ở chỗ trống, cần lấy đúng ghi chú về chủ ngữ.

Phần lớn các bước, cả ba van gần như không động tới ghi chú “xe máy”, nên nó đi qua 26 chữ mà gần như nguyên vẹn.

Giờ đổi sang một việc khác: gắn nhãn cho từng từ. Từ “Hồ” ở đầu hai câu sau là tên người hay một phần tên địa danh?

Hồ Chí Minh đọc bản Tuyên ngôn Độc lập năm 1945.

Hồ Chí Minh là thành phố đông dân nhất Việt Nam.

Bên trái từ “Hồ” không có gì cả. Chỉ phần bên phải (“đọc bản Tuyên ngôn” hay “là thành phố”) mới quyết định. Một LSTM đọc từ trái sang phải phải đưa ra nhãn cho “Hồ” khi chưa thấy chữ nào phía sau. Nếu cả câu đã nằm sẵn trong tay, sao không đọc thêm một lượt từ phải sang trái?

Bài toán của bài này là: thiết kế một ô hồi tiếp giữ được thông tin (và gradient) qua hàng chục bước, rồi ghép hai ô như vậy chạy ngược chiều nhau để mỗi vị trí nhìn được cả hai phía của câu.

Đầu vào là chuỗi x1,…,xL\mathbf{x}_1, \dots, \mathbf{x}_L, mỗi xt∈Rdx\mathbf{x}_t \in \mathbb{R}^{d_x} (ví dụ embedding của một từ). Khác RNN, LSTM mang theo hai vectơ qua mỗi bước, cùng kích thước dhd_h:

  • Trạng thái ô ct\mathbf{c}_t: băng chuyền, bộ nhớ dài hạn. Nó không đi thẳng ra ngoài.
  • Trạng thái ẩn ht\mathbf{h}_t: phần được đọc ra ở bước tt, dùng để dự đoán và để tính các cổng ở bước sau.

Ban đầu c0=h0=0\mathbf{c}_0 = \mathbf{h}_0 = \mathbf{0}. Mỗi bước nhận xt\mathbf{x}_t, ht−1\mathbf{h}_{t-1}, ct−1\mathbf{c}_{t-1} và trả ra ht\mathbf{h}_t, ct\mathbf{c}_t.

Mỗi bước, LSTM tính bốn vectơ từ cùng một đầu vào (xt,ht−1)(\mathbf{x}_t, \mathbf{h}_{t-1}). Mỗi vectơ có bộ trọng số riêng, cùng khuôn với RNN thường: Wx⋅∈Rdh×dx\mathbf{W}_{x\cdot} \in \mathbb{R}^{d_h \times d_x} nhân với đầu vào, Wh⋅∈Rdh×dh\mathbf{W}_{h\cdot} \in \mathbb{R}^{d_h \times d_h} nhân với trạng thái cũ, và một vectơ bias.

ft=σ(Wxfxt+Whfht−1+bf)it=σ(Wxixt+Whiht−1+bi)ot=σ(Wxoxt+Whoht−1+bo)c~t=tanh⁡(Wxcxt+Whcht−1+bc)\begin{aligned} \mathbf{f}_t &= \sigma(\mathbf{W}_{xf}\mathbf{x}_t + \mathbf{W}_{hf}\mathbf{h}_{t-1} + \mathbf{b}_f) \\ \mathbf{i}_t &= \sigma(\mathbf{W}_{xi}\mathbf{x}_t + \mathbf{W}_{hi}\mathbf{h}_{t-1} + \mathbf{b}_i) \\ \mathbf{o}_t &= \sigma(\mathbf{W}_{xo}\mathbf{x}_t + \mathbf{W}_{ho}\mathbf{h}_{t-1} + \mathbf{b}_o) \\ \tilde{\mathbf{c}}_t &= \tanh(\mathbf{W}_{xc}\mathbf{x}_t + \mathbf{W}_{hc}\mathbf{h}_{t-1} + \mathbf{b}_c) \end{aligned}
  • ft\mathbf{f}_t là cổng quên, it\mathbf{i}_t là cổng vào, ot\mathbf{o}_t là cổng ra. Cả ba qua sigmoid σ\sigma, nên mỗi thành phần nằm trong (0,1)(0, 1): 0 là đóng hẳn, 1 là mở hết.
  • c~t\tilde{\mathbf{c}}_t là ứng viên (candidate): nội dung mới có thể ghi lên băng. Nó qua tanh⁡\tanh, nên nằm trong (−1,1)(-1, 1) và có thể âm hay dương. Nhánh này chính là công thức của RNN thường.

Vì sao cổng dùng sigmoid còn ứng viên dùng tanh⁡\tanh? Cổng là một hệ số nhân, cần nằm giữa “chặn hết” và “cho qua hết”. Ứng viên là nội dung, cần được tăng lẫn giảm giá trị của ô.

Cập nhật trạng thái ô và trạng thái ẩn

Phần tiêu đề “Cập nhật trạng thái ô và trạng thái ẩn”

Hai dòng quan trọng nhất của bài:

ct=ft⊙ct−1+it⊙c~tht=ot⊙tanh⁡(ct)\begin{aligned} \mathbf{c}_t &= \mathbf{f}_t \odot \mathbf{c}_{t-1} + \mathbf{i}_t \odot \tilde{\mathbf{c}}_t \\ \mathbf{h}_t &= \mathbf{o}_t \odot \tanh(\mathbf{c}_t) \end{aligned}

Đọc dòng đầu theo từng thành phần jj. Thành phần ct,jc_{t,j} mới bằng phần giữ lại của giá trị cũ, ft,j ct−1,jf_{t,j}\,c_{t-1,j}, cộng phần ghi thêm của ứng viên, it,j c~t,ji_{t,j}\,\tilde{c}_{t,j}. Vài trường hợp đầu mút:

ft,jf_{t,j}it,ji_{t,j}ct,jc_{t,j}Ô làm gì
10ct−1,jc_{t-1,j}Giữ nguyên, bỏ qua đầu vào
01c~t,j\tilde{c}_{t,j}Xóa cũ, ghi mới hoàn toàn
11ct−1,j+c~t,jc_{t-1,j} + \tilde{c}_{t,j}Cộng dồn
000Xóa sạch

Hai cổng độc lập, nên LSTM có cả trường hợp “cộng dồn” mà GRU không có: ở GRU, tỉ lệ giữ cái cũ và tỉ lệ lấy cái mới luôn cộng lại bằng 1.

Dòng thứ hai đọc ô ra ngoài. tanh⁡\tanh ép giá trị của ô (có thể lớn hơn 1 do cộng dồn) về (−1,1)(-1, 1), rồi cổng ra chọn thành phần nào được lộ ra. Vì vậy ô có thể giữ một thông tin rất lâu mà không để nó ảnh hưởng tới ht\mathbf{h}_t, cho tới lúc cần.

Nhắc lại vì sao RNN thường gặp khó. Với 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), đạo hàm của ht\mathbf{h}_t theo ht−1\mathbf{h}_{t-1} là

∂ht∂ht−1=diag ⁣(1−ht⊙ht)Whh\frac{\partial \mathbf{h}_t}{\partial \mathbf{h}_{t-1}} = \mathrm{diag}\!\left(\mathbf{1} - \mathbf{h}_t \odot \mathbf{h}_t\right)\mathbf{W}_{hh}

(diag(v)\mathrm{diag}(\mathbf{v}) là ma trận đường chéo có các phần tử của v\mathbf{v} trên đường chéo; 1−ht⊙ht\mathbf{1} - \mathbf{h}_t \odot \mathbf{h}_t là đạo hàm của tanh⁡\tanh, tính từng phần tử). Đi ngược kk bước thì gradient bị nhân với kk ma trận như vậy, cùng một Whh\mathbf{W}_{hh} lặp lại. Tích đó thường co về 0 hoặc phình ra theo cấp số nhân.

Giờ xét LSTM, và chỉ nhìn đường thẳng trên băng chuyền: từ ct−1\mathbf{c}_{t-1} tới ct\mathbf{c}_t, coi các cổng như những hệ số đã biết. Lấy đạo hàm dòng ct=ft⊙ct−1+it⊙c~t\mathbf{c}_t = \mathbf{f}_t \odot \mathbf{c}_{t-1} + \mathbf{i}_t \odot \tilde{\mathbf{c}}_t theo ct−1\mathbf{c}_{t-1}: số hạng thứ hai không chứa ct−1\mathbf{c}_{t-1} nên bỏ đi, số hạng đầu là nhân từng phần tử với ft\mathbf{f}_t, nên

∂ct∂ct−1=diag(ft)\frac{\partial \mathbf{c}_t}{\partial \mathbf{c}_{t-1}} = \mathrm{diag}(\mathbf{f}_t)

Đi ngược từ bước tt về bước t−kt - k, theo quy tắc dây chuyền ta nhân kk ma trận đường chéo. Tích các ma trận đường chéo vẫn là ma trận đường chéo, với phần tử là tích các phần tử tương ứng:

∂ct∂ct−k=diag ⁣(ft⊙ft−1⊙⋯⊙ft−k+1)\frac{\partial \mathbf{c}_t}{\partial \mathbf{c}_{t-k}} = \mathrm{diag}\!\left(\mathbf{f}_t \odot \mathbf{f}_{t-1} \odot \cdots \odot \mathbf{f}_{t-k+1}\right)

So với RNN, có ba khác biệt:

  1. Không có ma trận trọng số và không có đạo hàm tanh⁡\tanh trên đường này. Gradient chỉ bị nhân với các giá trị cổng.
  2. Hệ số do mạng tự chọn, ở từng bước và từng thành phần. Thành phần nào đang giữ thông tin quan trọng thì mạng học cách để f≈1f \approx 1 ở đó, và gradient đi qua gần như nguyên vẹn. Ở RNN, cùng một Whh\mathbf{W}_{hh} phải dùng cho mọi bước.
  3. Không bùng nổ trên đường này, vì mỗi ff nằm trong (0,1)(0, 1).

Đây chưa phải toàn bộ gradient: các cổng cũng phụ thuộc ht−1\mathbf{h}_{t-1}, nên còn những đường khác đi qua ma trận trọng số. Nhưng chỉ cần một đường đi xa được là tín hiệu lỗi tới được chữ “xe máy”. Ý tưởng “cộng thay vì viết lại” bạn sẽ gặp lại ở kết nối tắt (residual connection) của ResNet và Transformer.

LSTM cũng không phải phép màu. Với f=0.9f = 0.9 ở mọi bước, sau 100 bước còn 0.9100≈2.7×10−50.9^{100} \approx 2.7 \times 10^{-5}; với f=0.99f = 0.99 thì còn 0.99100≈0.370.99^{100} \approx 0.37. Mạng phải học được để ff rất gần 1. Gradient qua các đường khác vẫn có thể bùng nổ, nên cắt chuẩn gradient (gradient clipping) vẫn hay dùng khi huấn luyện LSTM.

LSTM do Sepp Hochreiter và Jürgen Schmidhuber công bố năm 1997, nhằm giải quyết đúng vấn đề gradient tiêu biến của RNN. Bản gốc chưa có cổng quên: ô cập nhật bằng ct=ct−1+it⊙c~t\mathbf{c}_t = \mathbf{c}_{t-1} + \mathbf{i}_t \odot \tilde{\mathbf{c}}_t, tức ft\mathbf{f}_t luôn bằng 1. Các tác giả gọi đường này là “vòng quay lỗi không đổi” (constant error carousel): lỗi chạy vòng qua ô mà không to lên hay nhỏ đi.

Vấn đề: với một luồng dữ liệu dài không có điểm ngắt, ô chỉ cộng mà không bao giờ xóa, nên giá trị cứ phình ra. Năm 2000, Felix Gers, Jürgen Schmidhuber và Fred Cummins thêm cổng quên để ô tự xóa khi cần. Dạng có cổng quên là dạng mà ngày nay mọi người gọi là LSTM, và là dạng nn.LSTM của PyTorch cài đặt. Một nghiên cứu lớn (Greff và cộng sự, 2017) so tám biến thể của LSTM và thấy cổng quên là một trong hai thành phần quan trọng nhất.

Mỗi nhánh có Wx⋅\mathbf{W}_{x\cdot} (dhdxd_h d_x số), Wh⋅\mathbf{W}_{h\cdot} (dh2d_h^2 số) và bias (dhd_h số), tức dh(dx+dh)+dhd_h(d_x + d_h) + d_h, đúng bằng một RNN thường. LSTM có bốn nhánh nên:

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

GRU có ba nhánh (hai cổng và một ứng viên), nên bằng 3 lần RNN. Ví dụ với dx=3d_x = 3, dh=4d_h = 4 (ô NumPy dưới đây): RNN có 4⋅7+4=324 \cdot 7 + 4 = 32 tham số, GRU có 96, LSTM có 128.

So sánh LSTM và GRU:

Tiêu chíLSTMGRU
Vectơ mang qua mỗi bướcHai: ct\mathbf{c}_t và ht\mathbf{h}_tMột: ht\mathbf{h}_t
Số nhánh4 (quên, vào, ra, ứng viên)3 (reset, update, ứng viên)
Số tham số4 lần RNN3 lần RNN
Giữ cũ và ghi mớiHai cổng độc lập, được cộng dồnMột cổng, nội suy giữa cũ và mới
Cổng raCó, ô có thể giữ kín thông tinKhông, toàn bộ ht\mathbf{h}_t lộ ra
Năm1997 (cổng quên: 2000)2014

Còn chạy thực tế thì sao? Chung và cộng sự (2014) so hai loại trên dữ liệu nhạc và tiếng nói, và thấy GRU tương đương LSTM. Jozefowicz và cộng sự (2015) thử hơn mười nghìn kiến trúc hồi tiếp. Với khởi tạo thông thường, GRU hơn LSTM ở hầu hết các bài, trừ mô hình ngôn ngữ; LSTM có bias cổng quên bằng 1 thì gần đuổi kịp GRU, và khi có dropout thì LSTM tốt nhất ở bài mô hình ngôn ngữ. Không loại nào thắng hẳn. Lời khuyên thực dụng: GRU nhẹ hơn khoảng một phần tư, thử trước nếu cần nhanh; LSTM là lựa chọn mặc định quen thuộc; còn tốt hơn thì phải đo trên dữ liệu của bạn.

Một tầng LSTM biến chuỗi x1,…,xL\mathbf{x}_1, \dots, \mathbf{x}_L thành chuỗi h1,…,hL\mathbf{h}_1, \dots, \mathbf{h}_L, cùng độ dài. Vậy có thể đưa chuỗi đó làm đầu vào cho một tầng LSTM thứ hai, rồi thứ ba. Viết ht[l]\mathbf{h}^{[l]}_t là trạng thái ẩn của tầng ll tại bước tt, với ht[0]=xt\mathbf{h}^{[0]}_t = \mathbf{x}_t:

ht[l],ct[l]=LSTM[l] ⁣(ht[l−1], ht−1[l], ct−1[l])\mathbf{h}^{[l]}_t, \mathbf{c}^{[l]}_t = \mathrm{LSTM}^{[l]}\!\left(\mathbf{h}^{[l-1]}_t,\ \mathbf{h}^{[l]}_{t-1},\ \mathbf{c}^{[l]}_{t-1}\right)

Mỗi tầng có bộ tham số và băng chuyền riêng. Cũng như các lớp của một MLP, xếp chồng cho mạng thêm khả năng biểu diễn: tầng trên làm việc với những đặc trưng mà tầng dưới đã rút ra, thay vì với đầu vào thô. Trong PyTorch chỉ cần nn.LSTM(..., num_layers=2, dropout=0.2); dropout áp lên đầu ra giữa các tầng. Hai đến bốn tầng là phổ biến. Mô hình seq2seq của Sutskever và cộng sự (2014) dùng LSTM 4 tầng (xem bài Mô hình chuỗi sang chuỗi), còn hệ dịch máy GNMT của Google (Wu và cộng sự, 2016) dùng 8 tầng cho encoder và 8 tầng cho decoder.

Ý tưởng RNN hai chiều (bidirectional RNN) có từ Schuster và Paliwal (1997). Graves và Schmidhuber (2005) ghép nó với LSTM thành BiLSTM. Cách làm rất đơn giản: chạy hai LSTM riêng biệt, tham số khác nhau, trên cùng chuỗi.

  • Chiều xuôi đọc từ x1\mathbf{x}_1 tới xL\mathbf{x}_L, cho h→t\overrightarrow{\mathbf{h}}_t tóm tắt x1,…,xt\mathbf{x}_1, \dots, \mathbf{x}_t.
  • Chiều ngược đọc từ xL\mathbf{x}_L về x1\mathbf{x}_1, cho h←t\overleftarrow{\mathbf{h}}_t tóm tắt xL,…,xt\mathbf{x}_L, \dots, \mathbf{x}_t.

Tại mỗi vị trí, ghép (concatenate) hai vectơ thành một:

ht=[h→t; h←t]∈R2dh\mathbf{h}_t = \left[\overrightarrow{\mathbf{h}}_t;\ \overleftarrow{\mathbf{h}}_t\right] \in \mathbb{R}^{2d_h}

Vậy ht\mathbf{h}_t biết cả phần trước lẫn phần sau vị trí tt. Với từ “Hồ” ở mục Trực giác, nửa ngược đã đọc “đọc bản Tuyên ngôn” hay “là thành phố” trước khi tới “Hồ”. BiLSTM có gấp đôi tham số của một LSTM cùng dhd_h, và lớp phía sau nhận vectơ 2dh2d_h chiều.

Dùng được khi cả chuỗi có sẵn từ đầu:

  • Gán nhãn từng từ: từ loại, nhận dạng thực thể có tên (named entity recognition, NER). Huang, Xu và Yu (2015) ghép BiLSTM với CRF cho các bài này, một cấu hình rất phổ biến trước Transformer.
  • Phân loại cả câu, cả đoạn.
  • Encoder của mô hình dịch: câu nguồn đã có đủ. Tầng dưới cùng của encoder GNMT là hai chiều.

Không dùng được khi:

  • Sinh văn bản từ trái sang phải. Khi sinh từ thứ t+1t + 1, các từ phía sau chưa được viết ra, nên chiều ngược không có gì để đọc. Tệ hơn, nếu cố huấn luyện mô hình ngôn ngữ bằng BiLSTM, h←t\overleftarrow{\mathbf{h}}_t đã đọc xt+1\mathbf{x}_{t+1}, đúng là nhãn cần dự đoán: mô hình chép đáp án thay vì học. Decoder của seq2seq vì vậy luôn một chiều.
  • Xử lý theo thời gian thực, như nhận dạng giọng nói trực tiếp: muốn chạy chiều ngược thì phải chờ hết câu.
  • Vẫn tuần tự. Bước tt phải đợi bước t−1t - 1 xong, nên không tận dụng được GPU để xử lý song song theo chiều dài chuỗi. Huấn luyện trên chuỗi dài chậm.
  • Vẫn khó với chuỗi rất dài. Gradient trên băng chuyền là tích các ff, chỉ đi xa khi ff rất gần 1. Thông tin từ hàng trăm bước trước vẫn phải đi qua từng bước một.
  • Mọi thứ phải nén vào một vectơ dhd_h chiều. Ở seq2seq, cả câu nguồn dồn vào trạng thái cuối, là nút thắt mà attention được sinh ra để gỡ.

Ba giới hạn này dẫn thẳng tới bài Transformers: thay vì chuyền thông tin qua từng bước, mỗi vị trí nhìn thẳng vào mọi vị trí khác bằng attention, và mọi vị trí được tính song song.

Hình dưới vẽ một ô LSTM. Hãy nhìn đường nằm ngang phía trên, từ ct−1\mathbf{c}_{t-1} tới ct\mathbf{c}_t: trên đường này chỉ có một phép nhân với cổng quên và một phép cộng, không có ma trận trọng số nào. Mọi thứ phức tạp (bốn nhánh sigmoid và tanh⁡\tanh) nằm ở phía dưới và chỉ “chạm” vào băng chuyền qua hai điểm đó.

Bảng dưới cho thấy BiLSTM thấy gì ở vị trí đầu tiên của câu “Hồ Chí Minh là thành phố đông dân nhất Việt Nam”. Chiều xuôi mới đọc đúng một từ, chiều ngược đã đọc cả câu. Ở vị trí cuối thì ngược lại.

Vị tríChiều xuôi h→t\overrightarrow{\mathbf{h}}_t đã đọcChiều ngược h←t\overleftarrow{\mathbf{h}}_t đã đọc
1 (“Hồ”)HồHồ Chí Minh là thành phố … Việt Nam
4 (“là”)Hồ Chí Minh làlà thành phố … Việt Nam
11 (“Nam”)Hồ Chí Minh là thành phố … Việt NamNam

Hàng cuối cũng giải thích một lỗi hay gặp: vectơ ở vị trí cuối không tóm tắt cả câu theo cả hai chiều (xem mục Lỗi hay gặp).

Các ô chạy độc lập. Ô thứ nhất chạy trên trang, chỉ dùng NumPy. Ô thứ hai cần PyTorch nên chạy trên máy bạn, CPU là đủ.

Ô thứ nhất cài đặt đúng các công thức ở mục Lý thuyết với dx=3d_x = 3, dh=4d_h = 4, chạy qua một chuỗi ngẫu nhiên L=5L = 5 bước, bias cổng quên khởi tạo bằng 1, rồi đếm tham số.

Một bước LSTM bằng NumPy

import numpy as np
rng = np.random.default_rng(0)
d_x, d_h, L = 3, 4, 5 # chiều đầu vào, chiều trạng thái, độ dài chuỗi
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def init(bias):
# Mỗi nhánh có W_x (d_h x d_x), W_h (d_h x d_h) và b (d_h)
W_x = rng.normal(0, 0.5, (d_h, d_x))
W_h = rng.normal(0, 0.5, (d_h, d_h))
return W_x, W_h, np.full(d_h, bias)
# Bốn nhánh: cổng quên, cổng vào, cổng ra, ứng viên.
# Mẹo: bias cổng quên khởi tạo bằng 1 để lúc đầu mạng "nhớ" nhiều.
P = {"f": init(1.0), "i": init(0.0), "o": init(0.0), "c": init(0.0)}
def lstm_step(x, h, c):
z = {k: W_x @ x + W_h @ h + b for k, (W_x, W_h, b) in P.items()}
f, i, o = sigmoid(z["f"]), sigmoid(z["i"]), sigmoid(z["o"])
c_tilde = np.tanh(z["c"])
c = f * c + i * c_tilde # băng chuyền: giữ một phần, cộng phần mới
h = o * np.tanh(c) # cổng ra chọn phần nào của ô được đưa ra
return h, c, f
X = rng.normal(size=(L, d_x))
h, c = np.zeros(d_h), np.zeros(d_h)
keep = np.ones(d_h) # tích các f_t: phần c_0 còn lại trên đường cộng
for t in range(L):
h, c, f = lstm_step(X[t], h, c)
keep *= f
print(f"t={t + 1} f={f.round(2)} c={c.round(2) + 0.0}")
print("h_L =", h.round(3))
print("prod f =", keep.round(3))
# Đếm tham số và so với công thức
n = sum(W_x.size + W_h.size + b.size for W_x, W_h, b in P.values())
rnn = d_h * (d_x + d_h) + d_h
print("params:", n, " formula 4*RNN:", 4 * rnn)
print("RNN:", rnn, " GRU:", 3 * rnn,
" PyTorch LSTM:", 4 * (d_h * (d_x + d_h) + 2 * d_h))

The first run downloads Python, about 10 MB.

Đọc kết quả:

  • Cổng quên ở bước đầu có các giá trị 0.77, 0.74, 0.69, 0.73, quanh σ(1)≈0.73\sigma(1) \approx 0.73: đúng tác dụng của mẹo bias bằng 1. Các bước sau, khi ht−1\mathbf{h}_{t-1} khác 0, giá trị tản ra hơn (từ 0.48 tới 0.88).
  • Trạng thái ô đổi dấu và lớn dần qua các bước (thành phần đầu: 0.13, 0.49, 0.19, −0.53-0.53, −0.87-0.87), còn hL\mathbf{h}_L luôn nằm trong (−1,1)(-1, 1) vì đi qua tanh⁡\tanh và cổng ra.
  • prod f là tích các ftf_t của năm bước, tức đạo hàm theo đường thẳng trên băng chuyền ∂c5/∂c0\partial \mathbf{c}_5 / \partial \mathbf{c}_0: từ 0.103 tới 0.271. Gần với 0.735≈0.210.73^5 \approx 0.21. Nếu bias cổng quên bằng 0, các ff quanh 0.5 và tích chỉ còn cỡ 0.55≈0.030.5^5 \approx 0.03. Đây là mạng chưa huấn luyện; mạng đã học sẽ đẩy ff sát 1 ở những thành phần cần nhớ.
  • Đếm tham số: 128, khớp công thức 4(dh(dx+dh)+dh)4(d_h(d_x + d_h) + d_h). RNN có 32, GRU 96, còn PyTorch sẽ báo 144 vì có hai bias mỗi nhánh.

Ô thứ hai so LSTM một chiều với BiLSTM trên một bài gán nhãn tự sinh. Đầu vào là dãy 12 chữ số ngẫu nhiên. Nhãn tại vị trí tt là 1 nếu chữ số kế tiếp lớn hơn chữ số hiện tại, ngược lại là 0 (vị trí cuối không có nhãn). Nhãn phụ thuộc vào phía bên phải, giống từ “Hồ”.

Run on your machine

LSTM một chiều và BiLSTM gán nhãn từng vị trí

Install
pip install torch
Hardware
CPU là đủ, không cần GPU. Khoảng 30 tới 60 giây trên máy phát triển, có thể vài phút trên máy chậm hoặc đang bận
import torch
from torch import nn
torch.manual_seed(0)
L, D_H = 12, 32 # độ dài chuỗi, chiều trạng thái mỗi chiều
def make_batch(n):
# Dãy chữ số ngẫu nhiên. Nhãn tại t: chữ số kế tiếp có lớn hơn không?
x = torch.randint(0, 10, (n, L))
y = (x[:, 1:] > x[:, :-1]).long() # L - 1 nhãn, vị trí cuối bỏ qua
return x, y
class Tagger(nn.Module):
def __init__(self, bidirectional):
super().__init__()
self.emb = nn.Embedding(10, 16)
self.lstm = nn.LSTM(16, D_H, batch_first=True,
bidirectional=bidirectional)
k = 2 if bidirectional else 1 # BiLSTM ghép hai trạng thái
self.out = nn.Linear(k * D_H, 2)
def forward(self, x):
H, _ = self.lstm(self.emb(x)) # H: (n, L, k * D_H)
return self.out(H)[:, :-1] # một logit cho mỗi vị trí
x_test, y_test = make_batch(2000)
for bi in (False, True):
model = Tagger(bi)
n_lstm = sum(p.numel() for p in model.lstm.parameters())
opt = torch.optim.Adam(model.parameters(), lr=0.01)
for step in range(400):
x, y = make_batch(64)
loss = nn.functional.cross_entropy(
model(x).reshape(-1, 2), y.reshape(-1))
opt.zero_grad()
loss.backward()
opt.step()
with torch.no_grad():
acc = (model(x_test).argmax(-1) == y_test).float().mean()
name = "BiLSTM" if bi else "LSTM "
print(f"{name} params {n_lstm:5d} loss {loss.item():.3f}"
f" test acc {acc.item():.3f}")

Sample output

From one real run.

LSTM params 6400 loss 0.497 test acc 0.753
BiLSTM params 12800 loss 0.000 test acc 1.000

Đọc kết quả:

  • Số tham số: LSTM có dx=16d_x = 16 (embedding), dh=32d_h = 32, nên PyTorch báo 4(32⋅48+2⋅32)=64004(32 \cdot 48 + 2 \cdot 32) = 6400, đúng công thức hai bias ở mục Lý thuyết. BiLSTM là hai LSTM như vậy, nên gấp đôi: 12 800.
  • LSTM một chiều dừng ở 0.753. Đây không phải do huấn luyện chưa đủ. Tại vị trí tt, nó chưa đọc xt+1x_{t+1}, nên chỉ đoán được theo xtx_t: chữ số hiện tại là vv thì xác suất chữ số kế tiếp lớn hơn là (9−v)/10(9 - v)/10. Đoán theo phía nhiều khả năng hơn thì đúng max⁡((9−v)/10, (v+1)/10)\max\big((9 - v)/10,\ (v + 1)/10\big), trung bình trên 10 giá trị của vv là 7.5/10=0.757.5/10 = 0.75. Mô hình đã học được cách đoán tốt nhất có thể và chạm trần (0.753 hơi trên 0.75 chỉ do 2000 dãy kiểm tra là một mẫu ngẫu nhiên).
  • BiLSTM đạt 1.000 trên 2000 dãy kiểm tra, loss về 0.000. Chiều ngược tại vị trí tt đã đọc xt+1x_{t+1}, nên bài toán trở nên tầm thường.

Chênh lệch ở đây đến từ thông tin, không phải từ số tham số: cho LSTM một chiều thêm tầng hay thêm chiều thì nó vẫn không thấy được xt+1x_{t+1}. Seed cố định nên chạy lại 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ố loss có thể lệch đôi chút. Nếu máy chạy chậm bất thường, đặt biến môi trường OMP_NUM_THREADS=1 trước khi chạy: mô hình nhỏ thế này không lợi gì từ nhiều luồng.

Question 1 of 5 Ở một thành phần nào đó của ô LSTM, suốt 50 bước liền, cổng quên ft≈1f_t \approx 1 và cổng vào it≈0i_t \approx 0. Điều gì xảy ra với thành phần đó của ct\mathbf{c}_t?
Question 2 of 5 Một LSTM có dx=100d_x = 100, dh=200d_h = 200. Theo công thức của bài (mỗi nhánh một vectơ bias), LSTM này có bao nhiêu tham số?
Question 3 of 5 Bạn huấn luyện một mô hình ngôn ngữ dự đoán từ kế tiếp xt+1x_{t+1} từ trạng thái tại vị trí tt. Đổi LSTM sang BiLSTM làm loss huấn luyện giảm gần về 0. Vì sao không nên vui?
Question 4 of 5 Bạn phân loại cả câu bằng BiLSTM của PyTorch, lấy output[:, -1] (vectơ tại vị trí cuối) đưa vào lớp phân loại. Chỗ nào chưa ổn?
Question 5 of 5 Trong ô PyTorch của bài, nhãn tại vị trí tt là "chữ số kế tiếp có lớn hơn chữ số hiện tại không". LSTM một chiều dừng ở khoảng 75% đúng. Vì sao không cao hơn được?

Ý chính:

  • LSTM mang theo trạng thái ô ct\mathbf{c}_t (bộ nhớ) bên cạnh trạng thái ẩn ht\mathbf{h}_t (phần đọc ra). Ba cổng sigmoid điều khiển nó: ct=ft⊙ct−1+it⊙c~t\mathbf{c}_t = \mathbf{f}_t \odot \mathbf{c}_{t-1} + \mathbf{i}_t \odot \tilde{\mathbf{c}}_t và ht=ot⊙tanh⁡(ct)\mathbf{h}_t = \mathbf{o}_t \odot \tanh(\mathbf{c}_t).
  • Trên đường băng chuyền, gradient chỉ bị nhân với các giá trị cổng quên, không qua ma trận trọng số hay đạo hàm tanh⁡\tanh. Mạng học f≈1f \approx 1 ở chỗ cần nhớ, nên gradient đi xa hơn RNN thường. Khởi tạo bias cổng quên bằng 1 giúp lúc đầu.
  • LSTM có 4 lần số tham số của RNN, GRU có 3 lần. Trên thực tế hai loại ngang nhau, tùy bài. Xếp chồng nhiều tầng LSTM bằng cách lấy chuỗi ht\mathbf{h}_t của tầng dưới làm đầu vào tầng trên.
  • BiLSTM chạy hai LSTM ngược chiều và ghép trạng thái, nên mỗi vị trí thấy cả hai phía. Dùng khi có cả chuỗi (gán nhãn, NER, phân loại, encoder); không dùng để sinh văn bản từ trái sang phải.

Đọc thêm:

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