9.2. Bộ nhớ Ngắn hạn Dài (LSTM)¶
Thách thức đối với việc lưu trữ những thông tin dài hạn và bỏ qua đầu vào ngắn hạn trong các mô hình biến tiềm ẩn đã tồn tại trong một thời gian dài. Một trong những phương pháp tiếp cận sớm nhất để giải quyết vấn đề này là LSTM [Hochreiter & Schmidhuber, 1997]. Nó có nhiều tính chất tương tự Nút Hồi tiếp có Cổng (GRU). Điều thú vị là thiết kế của LSTM chỉ phức tạp hơn GRU một chút nhưng đã xuất hiện trước GRU gần hai thập kỷ.
Có thể cho rằng thiết kế này được lấy cảm hứng từ các cổng logic trong máy tính. Để kiểm soát một ô nhớ chúng ta cần một số các cổng. Một cổng để đọc các thông tin từ ô nhớ đó (trái với việc đọc từ các ô khác). Chúng ta sẽ gọi cổng này là cổng đầu ra (output gate). Một cổng thứ hai để quyết định khi nào cần ghi dữ liệu vào ô nhớ. Chúng ta gọi cổng này là cổng đầu vào (input gate). Cuối cùng, chúng ta cần một cơ chế để thiết lập lại nội dung chứa trong ô nhớ, được chi phối bởi một cổng quên (forget gate). Động lực của thiết kế trên cũng tương tự như trước đây, đó là để đưa ra quyết định khi nào cần nhớ và khi nào nên bỏ qua đầu vào trong trạng thái tiềm ẩn thông qua một cơ chế chuyên dụng. Chúng ta hãy xem thiết kế này hoạt động như thế nào trong thực tế.
9.2.1. Các Ô nhớ có Cổng¶
Ba cổng được giới thiệu trong LSTM đó là: cổng đầu vào, cổng quên và cổng đầu ra. Bên cạnh đó chúng ta sẽ giới thiệu một ô nhớ có kích thước giống với trạng thái ẩn. Nói đúng hơn đây chỉ là phiên bản đặc biệt của trạng thái ẩn, được thiết kế để ghi lại các thông tin bổ sung.
9.2.1.1. Cổng Đầu vào, Cổng Quên và Cổng Đầu ra¶
Tương tự như với GRU, dữ liệu được đưa vào các cổng LSTM là đầu vào ở
bước thời gian hiện tại \(\mathbf{X}_t\) và trạng thái ẩn ở bước
thời gian trước đó \(\mathbf{H}_{t-1}\). Những đầu vào này được xử
lý bởi một tầng kết nối đầy đủ và một hàm kích hoạt sigmoid để tính toán
các giá trị của các cổng đầu vào, cổng quên và cổng đầu ra. Kết quả là,
tất cả các giá trị đầu ra tại ba cổng đều nằm trong khoảng
\([0, 1]\). :numref:lstm_0
minh hoạ luồng dữ liệu cho các cổng
đầu vào, cổng quên, và cổng đầu ra.
Fig. 9.2.1 Các phép tính tại cổng đầu vào, cổng quên và cổng đầu ra trong một đơn vị LSTM.¶
Chúng ta giả sử rằng có \(h\) nút ẩn, mỗi minibatch có kích thước \(n\) và kích thước đầu vào là \(d\). Như vậy, đầu vào là \(\mathbf{X}_t \in \mathbb{R}^{n \times d}\) và trạng thái ẩn của bước thời gian trước đó là \(\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}\). Tương tự, các cổng được định nghĩa như sau: cổng đầu vào là \(\mathbf{I}_t \in \mathbb{R}^{n \times h}\), cổng quên là \(\mathbf{F}_t \in \mathbb{R}^{n \times h}\), và cổng đầu ra là \(\mathbf{O}_t \in \mathbb{R}^{n \times h}\). Chúng được tính như sau:
trong đó \(\mathbf{W}_{xi}, \mathbf{W}_{xf}, \mathbf{W}_{xo} \in \mathbb{R}^{d \times h}\) và \(\mathbf{W}_{hi}, \mathbf{W}_{hf}, \mathbf{W}_{ho} \in \mathbb{R}^{h \times h}\) là các trọng số và \(\mathbf{b}_i, \mathbf{b}_f, \mathbf{b}_o \in \mathbb{R}^{1 \times h}\) là các hệ số điều chỉnh.
9.2.1.2. Ô nhớ Tiềm năng¶
Tiếp theo, chúng ta sẽ thiết kế một ô nhớ. Vì ta vẫn chưa chỉ định tác động của các cổng khác nhau, nên đầu tiên ta sẽ giới thiệu ô nhớ tiềm năng \(\tilde{\mathbf{C}}_t \in \mathbb{R}^{n \times h}\). Các phép tính toán cũng tương tự như ba cổng mô tả ở trên, ngoài trừ việc ở đây ta sử dụng hàm kích hoạt \(\tanh\) với miền giá trị nằm trong khoảng \([-1, 1]\). Điều này dẫn đến phương trình sau tại bước thời gian \(t\).
Ở đây \(\mathbf{W}_{xc} \in \mathbb{R}^{d \times h}\) và \(\mathbf{W}_{hc} \in \mathbb{R}^{h \times h}\) là các tham số trọng số và \(\mathbf{b}_c \in \mathbb{R}^{1 \times h}\) là một hệ số điều chỉnh.
Ô nhớ tiềm năng được mô tả ngắn gọn trong Fig. 9.2.2.
Fig. 9.2.2 Các phép tính toán trong ô nhớ tiềm năng của LSTM.¶
9.2.1.3. Ô nhớ¶
Trong GRU, chúng ta chỉ có một cơ chế duy nhất để quản lý cả việc nhớ và quên. Trong LSTM, chúng ta có hai tham số, \(\mathbf{I}_t\) điều chỉnh lượng dữ liệu mới được lấy vào thông qua \(\tilde{\mathbf{C}}_t\) và tham số quên \(\mathbf{F}_t\) chỉ định lượng thông tin cũ cần giữ lại trong ô nhớ \(\mathbf{C}_{t-1} \in \mathbb{R}^{n \times h}\). Sử dụng cùng một phép nhân theo từng điểm (pointwise) như trước đây, chúng ta đi đến phương trình cập nhật như sau.
Nếu giá trị ở cổng quên luôn xấp xỉ bằng \(1\) và cổng đầu vào luôn xấp xỉ bằng \(0\), thì giá trị ô nhớ trong quá khứ \(\mathbf{C}_{t-1}\) sẽ được lưu lại qua thời gian và truyền tới bước thời gian hiện tại. Thiết kế này được giới thiệu nhằm giảm bớt vấn đề tiêu biến gradient cũng như nắm bắt các phụ thuộc dài hạn trong chuỗi thời gian tốt hơn. Do đó chúng ta có sơ đồ luồng trong Fig. 9.2.3.
Fig. 9.2.3 Các phép tính toán trong ô nhớ của LSTM. Ở đây, ta sử dụng phép nhân theo từng phần tử.¶
9.2.1.4. Các Trạng thái Ẩn¶
Cuối cùng, chúng ta cần phải xác định cách tính trạng thái ẩn \(\mathbf{H}_t \in \mathbb{R}^{n \times h}\). Đây là nơi cổng đầu ra được sử dụng. Trong LSTM, đây chỉ đơn giản là một phiên bản có kiểm soát của hàm kích hoạt \(\tanh\) trong ô nhớ. Điều này đảm bảo rằng các giá trị của \(\mathbf{H}_t\) luôn nằm trong khoảng \((-1, 1)\). Bất cứ khi nào giá trị của cổng đầu ra là \(1\), thực chất chúng ta đang đưa toàn bộ thông tin trong ô nhớ tới bộ dự đoán. Ngược lại, khi giá trị của cổng đầu ra là \(0\), chúng ta giữ lại tất cả các thông tin trong ô nhớ và không xử lý gì thêm. Fig. 9.2.4 minh họa các luồng dữ liệu.
Fig. 9.2.4 Các phép tính của trạng thái ẩn. Phép tính nhân được thực hiện trên từng phần tử.¶
9.2.2. Lập trình Từ đầu¶
Bây giờ ta sẽ lập trình một LSTM từ đầu. Giống như các thử nghiệm trong các phần trước, đầu tiên ta sẽ nạp tập dữ liệu The Time Machine.
from d2l import mxnet as d2l
from mxnet import np, npx
from mxnet.gluon import rnn
npx.set_np()
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
9.2.2.1. Khởi tạo Tham số Mô hình¶
Tiếp theo ta cần định nghĩa và khởi tạo các tham số mô hình. Cũng giống
như trước đây, siêu tham số num_hiddens
định nghĩa số lượng các nút
ẩn. Ta sẽ khởi tạo các trọng số theo phân phối Gauss với độ lệch chuẩn
bằng \(0.01\) và đặt các hệ số điều chỉnh bằng \(0\).
def get_lstm_params(vocab_size, num_hiddens, ctx):
num_inputs = num_outputs = vocab_size
def normal(shape):
return np.random.normal(scale=0.01, size=shape, ctx=ctx)
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
np.zeros(num_hiddens, ctx=ctx))
W_xi, W_hi, b_i = three() # Input gate parameters
W_xf, W_hf, b_f = three() # Forget gate parameters
W_xo, W_ho, b_o = three() # Output gate parameters
W_xc, W_hc, b_c = three() # Candidate cell parameters
# Output layer parameters
W_hq = normal((num_hiddens, num_outputs))
b_q = np.zeros(num_outputs, ctx=ctx)
# Attach gradients
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
for param in params:
param.attach_grad()
return params
9.2.2.2. Định nghĩa Mô hình¶
Trong hàm khởi tạo, trạng thái ẩn của LSTM cần trả về thêm một ô nhớ có giá trị bằng \(0\) và kích thước bằng (kích thước batch, số lượng các nút ẩn). Do đó ta có hàm khởi tạo trạng thái sau đây.
def init_lstm_state(batch_size, num_hiddens, ctx):
return (np.zeros(shape=(batch_size, num_hiddens), ctx=ctx),
np.zeros(shape=(batch_size, num_hiddens), ctx=ctx))
Mô hình thực sự được định nghĩa giống như những gì ta đã thảo luận trước đây: nó có ba cổng và một ô nhớ phụ. Lưu ý rằng chỉ có trạng thái ẩn được truyền tới tầng đầu ra. Các ô nhớ \(\mathbf{C}_t\) không tham gia trực tiếp vào việc tính toán đầu ra.
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = npx.sigmoid(np.dot(X, W_xi) + np.dot(H, W_hi) + b_i)
F = npx.sigmoid(np.dot(X, W_xf) + np.dot(H, W_hf) + b_f)
O = npx.sigmoid(np.dot(X, W_xo) + np.dot(H, W_ho) + b_o)
C_tilda = np.tanh(np.dot(X, W_xc) + np.dot(H, W_hc) + b_c)
C = F * C + I * C_tilda
H = O * np.tanh(C)
Y = np.dot(H, W_hq) + b_q
outputs.append(Y)
return np.concatenate(outputs, axis=0), (H, C)
9.2.2.3. Huấn luyện và Dự đoán¶
Như đã từng làm trong Section 9.1, ta sẽ huấn luyện một LSTM
bằng cách gọi hàm RNNModelScratch
đã được giới thiệu ở
Section 8.5.
vocab_size, num_hiddens, ctx = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, ctx, get_lstm_params,
init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, ctx)
perplexity 1.1, 10273.7 tokens/sec on gpu(0)
time traveller it s against reason said filby what reason said
traveller cond there wiln o do eare we save pock and means
9.2.3. Lập trình Súc tích¶
Trong Gluon, ta có thể gọi trực tiếp lớp LSTM
trong mô-đun rnn
.
Lớp này gói gọn tất cả các chi tiết cấu hình mà ta đã lập trình một cách
chi tiết ở trên. Mã nguồn sẽ chạy nhanh hơn đáng kể vì nó sử dụng các
toán tử đã được biên dịch thay vì các toán tử Python cho nhiều phép tính
mà ta đã lập trình trước đây.
lstm_layer = rnn.LSTM(num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, ctx)
perplexity 1.1, 139732.2 tokens/sec on gpu(0)
time traveller for so it will be convenient to speak of him was
traveller the ear sion sowird we meally ho dry direction al
Trong nhiều trường hợp, các mô hình LSTM hoạt động tốt hơn một chút so với các mô hình GRU nhưng việc huấn luyện và thực thi các mô hình này khá là tốn kém do chúng có kích thước trạng thái tiềm ẩn lớn hơn. LSTM là nguyên mẫu điển hình của một mô hình tự hồi quy biến tiềm ẩn có cơ chế kiểm soát trạng thái phức tạp. Nhiều biến thể đã được đề xuất qua từng năm, ví dụ như các kiến trúc đa tầng, các kết nối phần dư hay các kiểu điều chuẩn khác nhau. Tuy nhiên, việc huấn luyện LSTM và các mô hình chuỗi khác (như GRU) khá là tốn kém do sự phụ thuộc dài hạn của chuỗi. Sau này ta có thể sử dụng các mô hình khác như Transformer để song song hoá việc huấn luyện chuỗi.
9.2.4. Tóm tắt¶
- LSTM có ba loại cổng để kiểm soát luồng thông tin: cổng đầu vào, cổng quên và cổng đầu ra.
- Đầu ra tầng ẩn của LSTM bao gồm các trạng thái ẩn và các ô nhớ. Chỉ các trạng thái ẩn là được truyền tới tầng đầu ra. Các ô nhớ hoàn toàn được sử dụng nội bộ trong tầng.
- LSTM có thể đối phó với vấn đề tiêu biến và bùng nổ gradient.
9.2.5. Bài tập¶
- Thay đổi các siêu tham số. Quan sát và phân tích tác động đến thời gian chạy, perplexity và đầu ra.
- Cần thay đổi mô hình như thế nào để sinh ra các từ hoàn chỉnh thay vì các chuỗi ký tự?
- So sánh chi phí tính toán của GRU, LSTM và RNN thông thường với cùng một chiều ẩn. Đặc biệt chú ý đến chi phí huấn luyện và dự đoán.
- Dù các ô nhớ tiềm năng đã đảm bảo rằng phạm vi giá trị nằm trong khoảng từ \(-1\) đến \(1\) bằng cách sử dụng hàm \(\tanh\), tại sao trạng thái ẩn vẫn phải sử dụng tiếp hàm \(\tanh\) để đảm bảo rằng phạm vi giá trị đầu ra nằm trong khoảng từ \(-1\) đến \(1\)?
- Lập trình một mô hình LSTM để dự đoán chuỗi thời gian thay vì dự đoán chuỗi ký tự.
9.2.6. Thảo luận¶
9.2.7. Những người thực hiện¶
Bản dịch trong trang này được thực hiện bởi:
- Đoàn Võ Duy Thanh
- Nguyễn Văn Quang
- Nguyễn Lê Quang Nhật
- Nguyễn Văn Cường
- Lê Khắc Hồng Phúc
- Nguyễn Duy Du
- Phạm Minh Đức
- Phạm Hồng Vinh