# Kiến trúc Long short-term memory
## Sinh viên thực hiện
1. Bùi Cao Doanh - 19521366
2. Bùi Trần Ngọc Dũng - 19521385
3. Nguyễn Phú Quang - 19522096
## Lời nói đầu
Chắc hẳn chúng ta đã từng nghe qua các công nghệ dịch thuật tự động từ tiếng Anh sang tiếng Việt, hoặc một ngôn ngữ A sang một ngôn ngữ B nào đó. Các bài toán nghe khó nhằn hơn mà chúng ta đã từng nghe qua có thể kể đến như sinh câu mô tả nội dung bức ảnh (image captioning), sinh bức ảnh từ đoạn mô tả (text-to-image), ... Tựu chung, điểm chung của các bài toán này đó là ta sẽ làm việc với dữ liệu dạng chuỗi, tức là đầu vào có thể là một chuỗi $X=\{x_1, x_2, x_3, ..., x_n\}$ (hoặc là 1 cái gì đó ký hiệu là $X$ không phải chuỗi), và ta mong muốn nhận được một chuỗi đầu ra là $\hat Y=\{\hat y_1, \hat y_2, \hat y_3, ..., \hat y_n\}$ (hoặc chỉ đơn giản là $\hat y$ không phải chuỗi).
Trong bài toán dịch Anh-Việt Nếu xét một câu đơn giản "Good morning", ta có thể hiểu chuỗi $S=\{'Good', 'morning'\}$ là chuỗi đầu vào. Tuy nhiên, các bài toán liên quan đến chuỗi thường chia thành 03 loại:
- One-to-many: tức là ta đưa vào 01 đầu vào, và ta cần đầu ra là một chuỗi gì đó. Ví dụ bài toán image captioning, đầu vào chúng ta là một bức ảnh $X$, đầu ra là một chuỗi từ $y={y_1, y_2, ..., y_n}$ mô tả cho bức ảnh $X$.
- Many-to-one: tức là ta đưa vào nhiều đầu vào, tức đầu vào là 01 chuỗi, và ta cần đầu ra là một xác suất gì đó. Ví dụ đối với bài toán anomoly detection in video, ta có đầu vào là 1 chuỗi frame, đầu rà 1 xác suất liệu frame hình tiếp theo hoặc cả video có chứa bất thường hay không.
- Many-to-many: ta có nhiều đầu vào, tức đầu vào là 01 chuỗi, và ta cũng mong muốn nhận đầu ra là 01 chuỗi. Bài toán dịch thuật từ ngôn ngữ này sang ngôn ngữ khác là 01 ví dụ.
> Q: thế có one-to-one không?
> A: one-to-one thực ra là một bài toán thông thường 01 đầu vào và 01 đầu ra. Ví dụ bài toán phân lớp: đầu vào là 01 ảnh, đầu ra là 01 lớp khả năng cao nhất mà bức ảnh đó thuộc về.
Trên thực tế, đối với các bài toán one-to-many hay many-to-many, ta sẽ dự đoán chuỗi đầu ra **lần lượt từng thời điểm một**, tại mỗi thời điểm, mô hình cho chúng ta biết được đầu ra nhận được $\hat y_i$ là một phân phối xác suất khả năng được chọn các chuỗi / phần tử trong một "từ điển" có sẵn. Ví dụ bộ "từ điển" của chúng ta có $N$ từ, thì đầu ra nhận được $\hat y_i \in \mathbb{R}^{1\times N}$. Tuy nhiên, để dự đoán được đầu ra nhận được tại thời điểm thứ $t$, ta cần cho mô hình biết các "đầu ra" của thời điểm $t-1$ trở về trước và "đầu vào" $X$ ($X$ có thể là một cái gì đó duy nhất hoặc là 01 chuỗi, tùy theo là one-to-many hay many-to-many). Đối với bài toán many-to-one, "đầu ra" tại thời điểm $t$ vẫn tồn tại, nhưng ta không cần đến. Cái chúng ta cần là từ "đầu ra" ở thời điểm cuối cùng để suy ra đầu ra cuối cùng.
> Q: "đầu ra" (đầu ra trong ngoặc kép) là gì? Đầu ra nhận được là gì? Hai đầu ra này khác nhau như thế nào?
> A: Hai đầu ra này khác nhau. Ở đây nhóm sinh viên chỉ muốn đề cập 1 cách phổ quát về bài toán liên quan tới chuỗi, tuy nhiên có một chút liên quan tới phương pháp nên sử dụng "đầu ra" và đầu ra nhận được. Chi tiết sẽ được đề cập ở phần nói về RNN và LSTM.
Để xây dựng các mô hình (model), các nhà nghiên cứu thường dựa trên các phương pháp như: RNN (Recurrent Neural Network), LSTM (Long Short-term Memory) hay nổi tiếng là Transformer. Gần đây cũng có mô hình hiện đại mang tên GPT dựa trên Transformer cũng đã chứng minh được khả năng dịch chuỗi mạnh mẽ.
Trong bài viết này, nhóm sinh viên sẽ nhắc lại ý tưởng về kiến trúc RNN, và giới thiệu qua kiến trúc vượt trội hơn, mang tên LSTM.
## Nhắc lại về RNN
Recurrent Neural Network là một mô hình phổ biến trước đây được sử dụng để giải quyết các bài toán liên quan tới chuỗi. Nếu chỉ xét bài toán many-to-many, mô hình RNN có thể được hình dung bằng hình sau:

Hình 1. Mô hình RNN. Nguồn ảnh: https://commons.wikimedia.org/wiki/File:Recurrent_neural_network_unfold.svg
Quan sát Hình 1, có thể thấy tại từng thời điểm $t$, ta sẽ đưa vào mô hình các đầu vào như sau:
- $x_t$: phần tử của chuỗi đầu vào thứ $t$.
- $h_{t-1}$: trạng thái ẩn sinh ra bởi RNN ở thời điểm $t-1$, đây chính là "đầu ra" mà nhóm sinh viên đề cập ở phần trước. Có thể hiểu trạng thái ẩn là một dạng đặc trưng đặc biệt được sinh ra bởi RNN.
Cũng tại mỗi thời điểm $t$, ta sẽ nhận được đầu ra $o_t$, đây là đầu ra nhận được mà nhóm sinh viên đề cập ở phần trước. Đầu ra $o_t$ được hiểu là trạng thái ẩn $h_t$ đi qua một lớp tuyến tính (Linear), sử dụng hàm kích hoạt $Softmax$ để đưa về phân phối xác suất xảy ra các chuỗi trong bộ "từ điển" được định nghĩa trước.
Chi tiết hơn về mô hình RNN:
- $x_i$: vector đầu vào tại 01 thời điểm nào đó, $x_i \in \mathbb{R}^{n \times 1}$, với $d$ là số chiều của vector. Ví dụ trong bài toán dịch từ ngôn ngữ này sang ngôn ngữ khác, ta cần qua một bước là word embedding, bước này sẽ chuyển đổi 1 câu từ nhiên thành chuỗi các vector, mỗi vector $n$ chiều đại diện cho 01 từ, trong trường hợp này $x_i$ chính là vector đại diện cho 01 từ đó.
- $U$: một ma trận trọng số biến đổi $x_i$ về một dạng biểu diễn khác. Dạng biểu diễn này có số chiều là $d$, do đó $U \in \mathbb{R}^{d \times n}$.
- $h_i$: trạng thái ẩn của tại 01 thời điểm nào đó, chính là dạng biểu diễn có $d$ chiều đề cập bên trên. Như vậy $h_i \in \mathbb{R}^{1 \times d}$.
- $V$: một ma trận trọng số biến đổi trạng thái ẩn tại một thời điểm nào đó sang một dạng biểu diễn mới để đưa vào tính trạng thái ẩn mới tại thời điểm tiếp theo. Dạng biểu diễn này có chiều không đổi so với trạng thái ẩn trước đó, do đó $V \in \mathbb{R}^{d \times d}$.
- $W$: một ma trận biến đổi trạng thái ẩn $h_i$ thành một dạng biểu diễn nào đó có $c$ chiều, do đó $W \in \mathbb{R^{d \times c}}$
Tại 01 thời điểm $t$ nào đó, đầu ra $o_t$ sẽ được tính như sau:
$$h_t = f(U \times x_t + V \times h_{t-1})$$
$$o_t = a(W \times h_t)$$
Trong đó $f$, $a$ là một hàm kích hoạt nào đó.
Giả sử chúng huấn luyện mạng RNN này, chúng ta sẽ cần một hàm mất mát để tính sai số giữa đầu ra và giá trị thực tế, ký hiệu là $\mathcal{L}$.
Các ma trận chứa trọng số học của chúng ta là $U$, $W$, $V$. Do đó chúng ta cần đi tính đạo hàm của từng ma trận đối với hàm mất mát $\mathcal{L}$: $\frac{\partial \mathcal{L}}{\partial U}$, $\frac{\partial \mathcal{L}}{\partial W}$, $\frac{\partial \mathcal{L}}{\partial V}$.
Dễ thấy, $\frac{\partial \mathcal{L}}{\partial W}$ tính khá đơn giản, vì $W$ chỉ phụ thuộc vào trạng thái ẩn đang xét $t$. Tuy nhiên, muốn lấy đạo hàm $\frac{\partial \mathcal{L}}{\partial V}$, khi áp dụng chain rule chúng ta cần phải lấy $\frac{\partial h_t}{\partial V}$. Mà ta có $h_t=f(U \times x_t + V \times h_{t-1})$, tức là để tính $h_t$ thì cần tính $h_{t-1}$, do đó lấy đạo hàm $\frac{\partial h_t}{\partial V}$ cũng sẽ phải lấy đạo hàm của $\frac{\partial h_{t}}{\partial h_{t-1}}$, $\frac{\partial h_{t}}{\partial h_{t-2}}$, ... ,$\frac{\partial h_t}{\partial h_0}$.
Giả sử nhóm sinh viên chọn hàm kích hoạt $f$ ở đây là hàm $Tanh$, vậy thì ta có trạng thái ẩn thứ $t$ được tính bằng:
$$ h_t = Tanh(U\times x_t + V \times h_{t-1}) $$
Mà ta có đạo hàm $Tanh'(x) = 1-Tanh^2(x)$
Như vậy khi lấy đạo hàm của $h_t$ theo $h_{t-1}$, ta có:
$$ \frac{\partial h_t}{\partial h_{t-1}} = (1-h_t^2) \times V $$
Nếu ta xét luôn cả đạo hàm của các trạng thái ẩn phía trước nữa, ta có công thức đạo hàm chung như sau:
$$ \frac{\partial h_t}{\partial h_{i}} = V^{t-i} \times \prod^{t-1}_{j=i} (1-h_j^2)$$
Dễ thấy nếu $h_t < 1$, $V < 1$, $\frac{\partial h_t}{\partial h_{i}}$ sẽ xấp xỉ 0, dẫn đến $\frac{\partial \mathcal{L}}{\partial V}$ cũng xấp xỉ 0. Như vậy xem như mô hình sẽ không học được thêm gì ở những trạng thái ẩn xa hiện tại. Hiện tượng này gọi là Vanishing Gradient, và đây chính là vấn đề RNN gặp phải. Đôi khi muốn dự đoán cái gì đó từ chuỗi đầu vào $X = \{x_1, x_2, x_3, ..., x_t, ..., x_n\}$, ví dụ ta muốn dự đoán đầu ra $y_{t+1}$, đôi khi chúng ta cần cả các thông tin $\{x_i\}^{t-1}_{i=0}$ trước đó nữa, trong khi RNN nhiều khả năng chỉ dựa vào $x_t$ hay một số ít trạng thái ẩn trước đó. Do đó, ta cần một kiến trúc mới hơn, có khả năng truyền đi những thông tin ở xa đến những trạng thái hiện tại, kiến trúc này gọi là Long short-term memory. "Long" ý chỉ những thông tin ở xa, "short" chỉ những thông tin ở gần, tức là LSTM sẽ có khả năng tận dụng linh hoạt cả những thông tin ở xa và ở gần.
## Long short-term memory
Kiến trúc mô tả ảnh dựa trên LSTM phổ biến nhất chính là kiến trúc dựa trên một lớp LSTM được đề xuất bởi Vinyals và cộng sự.
LSTM (Long short term memory) là một mạng hồi quy đặc biệt (RNN) được thiết kế cho các bài toán xử lý thông tin dạng chuỗi (sequences / time series). LSTM được cho là có kết quả cao hơn rất nhiều so với RNN. Khác với RNN, LSTM chứa một số đơn vị đặc biệt gọi là các khối nhớ (memory blocks) trong các lớp ẩn hồi quy (recurrent hidden layer). Các khối nhớ này sẽ chứa các ô nhớ được kết nối lẫn nhau để lưu thông tin trạng thái. Bên cạnh đó, một số đơn vị đặc biệt khác gọi là các cổng (gates) sẽ có nhiệm vụ kiểm soát các dòng thông tin. Mỗi khối nhớ bao gồm ba loại cổng: cổng đầu vào, cổng đầu ra và cổng quên.

Hình 2. Minh họa kiến trúc Long short-term memory. Nguồn: https://nttuan8.com/bai-14-long-short-term-memory-lstm/
Các cổng này có thể được trình bày bằng các công thức sau:
- Cổng quên: $f_t = g(U_f \times x_t + V_f + b_f)$
- Cổng đầu vào: $i_t = g(U_i \times x_t + W_i \times h_{t-1} + b_i)$
- Cổng đầu ra $o_t = g(U_o \times x_t + V_o \times h_{t-1} + b_o)$
Trong đó các cổng quên $f_t$, cổng đầu vào $i_t$ và cổng đầu ra $o_t$ sẽ có giá trị trong khoảng $(0;1)$. $b_f$, $b_i$, $b_o$ là các hệ số bias. $V_f$, $V_i$, $V_o$, $U_f$, $U_i$, $U_o$ là các ma trận học.
Trong khối nhớ, chúng ta cũng sẽ đi tính các trạng thái $\tilde{c_t}=a(U_c \times x_t + V_c \times h_{t-1} + b_c)$. Các trạng thái $c_t$ cũng tương tự như các trạng thái ẩn $h_t$ trong mạng nơ-ron hồi quy (RNN).
Trong đó $g$ và $a$ là các hàm kích hoạt nào đó. Trong Hình 2, $g$ là hàm $\sigma$, $a$ là hàm $Tanh$
Tuy nhiên, chúng ta sẽ có các cổng quên (forget gate) để quyết định xem cần lấy bao nhiều từ ô trạng thái trước. Như vậy, mô hình sẽ không đưa $c_t$ ở lớp LSTM phía trước vào lớp LSTM phía sau, mà nó chỉ lấy những thông tin cần bằng công thức:
$$c_t = f_t \times c_{t-1} + i_t \times \tilde{c_t}$$
Bên cạnh đó, các trạng thái ẩn cũng được tính bằng công thức $h_t=o_t\times a(c_t)$.
Tổng kết lại, cho một chuỗi đầu vào $X=\{x_1, x_2, ..., x_T\}$, đầu ra $Y=\{y_1, y_2, ..., y_T\}$ được dự đoán bằng một chuỗi các công thức, mà trong đó chuỗi công thức này được lặp từ $t=1$ cho tới $t=T$ như sau:
$$i_t = g(U_i \times x_t + V_i \times h_{t-1} + b_i)$$
$$\tilde{c_t} = a(U_c \times x_c + V_c \times h_{t-1} + b_c)$$
$$f_t = g(U_f \times x_t + V_f \times h_{t-1} + b_f)$$
$$c_t = f_t \times c_{t-1} + i_t \times \tilde{c_t}$$
$$o_t = g(U_o \times x_t + V_o \times h_{t-1} + b_o)$$
$$h_t = o_t \times a(c_t)$$
$h_t$ và $\tilde{c_t}$ khá quen thuộc, vì nó đã xuất hiện trong các kiến trúc RNN. Trong đó $c_t$ là một dòng thông tin mới, cho phép mô hình học từ các thông tin ở xa. Các thông tin ở gần chính là $h_t$.
## Code demo
Trong phần này, nhóm sinh viên sẽ demo mà LSTM hoạt động bằng các đoạn code đơn giản để người đọc hiểu vấn đề.
### Bài toán
Để dễ hình dung, nhóm sinh viên chọn bài toán kiểu many-to-one để làm ví dụ. Bài toán này có đầu vào là một chuỗi gồm nhiều phần tử, đầu ra cho biết phần tử đó thuộc lớp nào.
Giả sử chúng ta có đầu vào là một chuỗi các vector $d$ chiều, và ta muốn dự đoán đầu ra thuộc về lớp 0 hay 1, ta thực hiện sinh ngẫu nhiên dữ liệu như sau:
```[python3]
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
import numpy as np
# Số mẫu dữ liệu
n_samples = 1000
# Sinh dữ liệu ngẫu nhiên từ hàm make_classification
X, y = make_classification(n_samples=n_samples, n_features=100, n_informative=3, n_classes=2, hypercube=True)
# max_length chính là độ dài chuỗi
# n_features chính là số chiều d của mỗi vector
max_length = 10
n_features = 10
# Tái cấu trúc biến X sinh ra được về dạng (n_samples, max_length, n_features)
sequence_X = X.reshape(n_samples, max_length, n_features)
# Chia ngẫu nhiên dữ liệu theo tỷ lệ 80-20
X_train, X_test, y_train, y_test = train_test_split(sequence_X, y)
```
Sau khi dùng hàm `make_classification`, ta thu được đầu vào $X \in \mathcal{R}^{1000 \times 100}$. Tuy nhiên ta muốn đầu vào là một chuỗi gồm các vector, ta cấu trúc lại thành $X \in \mathcal{R}^{1000 \times 10 \times 10}$, tức là dữ liệu có 1000 mẫu, mỗi mẫu là một chuỗi vector có 10 phần tử.
Đầu ra $y \in \mathcal{R}^{[0,1]}$.
### Xây dựng mô hình LSTM
Lý thuyết về LSTM dài dòng là thế, tuy nhiên khi xây dựng mô hình, chúng ta có thể sử dụng thư viện Keras đã cung cấp cho chúng ta một số class cần thiết để định nghĩa mô hình LSTM.
```[python3]
from keras.models import Sequential
from keras.layers import Input, LSTM, Dense
from keras.layers.embeddings import Embedding
import keras.backend as K
```
Ở đoạn code trên, nhóm sinh viên sử dụng các lớp `Input`, `LSTM` và `Dense` để xây dựng một kiến trúc chứa lớp LSTM.
Ta tiếp tục định nghĩa mô hình LSTM
```[python3]
model = Sequential()
model.add(Input(shape=(max_length, n_features)))
model.add(LSTM(128, return_sequences=True))
model.add(LSTM(128))
model.add(Dense(32,activation='relu'))
model.add(Dense(1,activation='sigmoid'))
model.compile(loss=['binary_crossentropy'] , optimizer='adam', metrics=['accuracy'])
model.summary()
```
Ở đoạn code trên, ta thiết kế kiến trúc bao gồm đầu vào $X \in \mathcal{R}^{N\times maxlength \times nfeatures}$. Đầu vào đi qua hai lớp LSTM. Lớp LSTM đầu tiên có số chiều trạng thái ẩn $d_h = 128$, tuy nhiên ta sẽ sử dụng đầu ra của tất cả các trạng thái, do đó cờ `return_sequences=True`. Ở lớp LSTM thứ hai, số chiều trạng thái ẩn vẫn là 128, tuy nhiên ta chỉ lấy đầu ra cuối cùng. Đầu ra cuối cùng đi qua một lớp `Dense` để các dạng biểu diễn về vector 32 chiều, các node tại đây sẽ được áp dụng hàm kích hoạt `ReLU`. Cuối cùng, lớp output chỉ có 01 đơn vị, và đi qua hàm `Sigmoid` để đưa về dạng xác suất thuộc đoạn $[0, 1]$. Hàm mất mát được định nghĩa là Binary CrossEntropy, bộ tối ưu hóa sử dụng là `Adam`.
Sau khi định nghĩa, chúng ta huấn luyện mô hình:
```[python3]
history = model.fit(X_train, y_train, batch_size=32, epochs=50, validation_split=0.2)
```
```
Epoch 1/50
19/19 [==============================] - 6s 80ms/step - loss: 0.6851 - accuracy: 0.5550 - val_loss: 0.6717 - val_accuracy: 0.6400
Epoch 2/50
19/19 [==============================] - 1s 28ms/step - loss: 0.6611 - accuracy: 0.5833 - val_loss: 0.6578 - val_accuracy: 0.6467
Epoch 3/50
19/19 [==============================] - 1s 30ms/step - loss: 0.6504 - accuracy: 0.6117 - val_loss: 0.6621 - val_accuracy: 0.6267
Epoch 4/50
19/19 [==============================] - 1s 28ms/step - loss: 0.6281 - accuracy: 0.6533 - val_loss: 0.6440 - val_accuracy: 0.6867
Epoch 5/50
19/19 [==============================] - 1s 28ms/step - loss: 0.5955 - accuracy: 0.6800 - val_loss: 0.6502 - val_accuracy: 0.6333
Epoch 6/50
19/19 [==============================] - 1s 29ms/step - loss: 0.5774 - accuracy: 0.7017 - val_loss: 0.6087 - val_accuracy: 0.6933
...
Epoch 46/50
19/19 [==============================] - 1s 31ms/step - loss: 2.4229e-04 - accuracy: 1.0000 - val_loss: 3.0219 - val_accuracy: 0.6200
Epoch 47/50
19/19 [==============================] - 1s 31ms/step - loss: 2.2924e-04 - accuracy: 1.0000 - val_loss: 3.0418 - val_accuracy: 0.6267
Epoch 48/50
19/19 [==============================] - 1s 32ms/step - loss: 2.1655e-04 - accuracy: 1.0000 - val_loss: 3.0614 - val_accuracy: 0.6267
Epoch 49/50
19/19 [==============================] - 1s 31ms/step - loss: 2.0481e-04 - accuracy: 1.0000 - val_loss: 3.0822 - val_accuracy: 0.6267
Epoch 50/50
19/19 [==============================] - 1s 32ms/step - loss: 1.9335e-04 - accuracy: 1.0000 - val_loss: 3.0966 - val_accuracy: 0.6267
```
Sau khi huấn luyện xong 50 epochs, ta thấy tập huấn luyện accuracy đạt 100%, trong khi tập validation chỉ có 62.67%, rất có thể mô hình đã gặp Overfitting.
Ta đánh giá độ chính xác trên tập test:
```[python3]
from sklearn.metrics import classification_report
# Chạy evaluation
y_pred = model.predict(X_test)
# Nếu cao hơn 0.5 thì thuộc về lớp 1, nhỏ hơn thì thuộc về lớp 0
y_pred_argmax = y_pred > 0.5
# So với ground-truth
print(classification_report(y_true=y_test, y_pred=y_pred_argmax))
```
Ta thu được kết quả:
```
precision recall f1-score support
0 0.61 0.74 0.66 121
1 0.69 0.55 0.61 129
accuracy 0.64 250
macro avg 0.65 0.64 0.64 250
weighted avg 0.65 0.64 0.64 250
```
Như vậy mô hình LSTM cho kết quả 0.64 với độ đo F1 trên tập test.
Trên đây là một hướng dẫn nho nhỏ cách implement xây dựng một kiến trúc bao gồm các lớp LSTM. Hy vọng bạn đọc có thể làm cơ sở áp dụng cho các bài toán dịch chuỗi khác.
## Bài tập
Đề bài: Dựa vào đoạn code trên, hãy tinh chỉnh một chút và thực hiện bài toán sentiment analysis. Bộ dữ liệu có thể tự crawl hoặc tải từ Kaggle.