RNN cho các dạng bài toán chuỗi: thiết kế và thực hành
Mục tiêu
Sau bài này, bạn cần có khả năng đi từ một bài toán thực tế đến cấu hình RNN phù hợp, thay vì chỉ nhớ công thức.
Bạn sẽ học cách:
- xác định một mẫu dữ liệu được chia theo thời gian như thế nào;
- chọn đầu ra ở cuối chuỗi hay ở từng bước;
- xử lý batch gồm các chuỗi có độ dài khác nhau;
- hiểu khi nào dùng RNN một chiều và hai chiều;
- đọc kích thước tensor trong một triển khai PyTorch cơ bản;
- phát hiện một số lỗi thiết kế phổ biến.
1. Bắt đầu từ câu hỏi: “đầu ra cần ở đâu?”
Trước khi chọn kiến trúc, hãy trả lời hai câu:
- Một mẫu đầu vào là một chuỗi gì?
- Nhãn nằm ở cuối chuỗi hay ở từng thời điểm?
Đây thường là quyết định quan trọng hơn việc chọn hidden_size=64 hay hidden_size=128.
2. Dạng 1 - nhiều-đến-một: một nhãn cho cả chuỗi
Ví dụ gần với sinh viên:
Dựa trên chuỗi hoạt động 6 tuần đầu trên LMS, dự đoán sinh viên có nguy cơ không hoàn thành môn học hay không.
Mỗi tuần có vector:
với , , lần lượt là số lần đăng nhập, số tài liệu đã xem và số bài đã nộp.
Ta có chuỗi:
và một nhãn duy nhất:
Kiến trúc đơn giản:
Ở đây thường chỉ cần .
Câu hỏi thiết kế
Nếu sinh viên nghỉ học ở tuần 2 nhưng hoạt động lại bình thường từ tuần 3-6, liệu trạng thái cuối có đủ giữ thông tin tuần 2 không?
Với chuỗi ngắn, có thể có. Với chuỗi dài hơn, đây là nơi giới hạn của Vanilla RNN bắt đầu rõ.
3. Dạng 2 - nhiều-đến-nhiều: mỗi bước có một nhãn
Ví dụ:
Với từng ngày trong chuỗi sử dụng hệ thống, đánh dấu ngày nào có hành vi bất thường.
Ta có:
Các bài toán tương tự:
- gán nhãn từ loại cho từng token;
- xác định trạng thái hoạt động tại từng khung tín hiệu;
- dự đoán lớp cho từng bước trong chuỗi sự kiện.
Loss thường được tính trên nhiều thời điểm:
Nếu chuỗi được padding, không được tính loss trên phần padding.
4. Dạng 3 - dự đoán bước tiếp theo
Ví dụ chuỗi ký tự:
h ọ c _ a i
Ta có thể tạo cặp mục tiêu:
input : h ọ c _ a
label : ọ c _ a i
Tại mỗi bước, mô hình dùng trạng thái hiện tại để dự đoán phần tử kế tiếp.
Đây là hình thức cơ bản của mô hình hóa chuỗi tự hồi quy. Các mô hình ngôn ngữ hiện đại dùng kiến trúc khác, nhưng ý tưởng dự đoán token tiếp theo vẫn là một mục tiêu huấn luyện quan trọng.
5. Dạng 4 - chuỗi sang chuỗi có độ dài khác nhau
Ví dụ dịch máy:
"tôi đang học AI" → "I am learning AI"
Đây là bài toán sequence-to-sequence. RNN/LSTM từng là kiến trúc chủ đạo cho hướng này, đặc biệt với encoder-decoder.
Khóa học sẽ quay lại vấn đề này ở chương Attention. Khi chỉ nén toàn bộ chuỗi nguồn thành một vector cố định, encoder-decoder RNN gặp nút thắt thông tin với chuỗi dài. Attention ra đời để giảm nút thắt đó.
6. RNN một chiều và RNN hai chiều
RNN một chiều
Tại bước t, mô hình chỉ dùng:
Phù hợp khi dự đoán phải chạy theo thời gian thực.
Ví dụ:
- dự đoán sự kiện kế tiếp;
- cảnh báo online từ cảm biến;
- mô hình ngôn ngữ sinh văn bản từ trái sang phải.
RNN hai chiều
Bidirectional RNN có hai dòng trạng thái:
- một mạng đọc từ trái sang phải;
- một mạng đọc từ phải sang trái.
Tại vị trí t, biểu diễn có thể dùng cả ngữ cảnh trước và sau.
Schuster và Paliwal (1997) đề xuất Bidirectional RNN để loại bỏ giới hạn chỉ dùng thông tin quá khứ trong các nhiệm vụ mà toàn bộ chuỗi đã có sẵn.
Khi không được dùng hai chiều
Nếu bài toán yêu cầu dự đoán online tại thời điểm t, dữ liệu tương lai chưa tồn tại. Dùng mô hình hai chiều trong huấn luyện khi triển khai chỉ có quá khứ sẽ gây rò rỉ thông tin tương lai.
Ví dụ sai:
Dùng dữ liệu từ tuần 1-12 để tạo biểu diễn bidirectional rồi tuyên bố dự đoán nguy cơ bỏ học “tại tuần 4”.
Ở thời điểm tuần 4, dữ liệu tuần 5-12 chưa tồn tại.
7. Chuỗi có độ dài khác nhau trong cùng batch
Giả sử ba câu có độ dài:
- mẫu A: 5 token;
- mẫu B: 3 token;
- mẫu C: 4 token.
Tensor batch cần cùng kích thước, nên thường padding về 5:
A: a b c d e
B: f g h <pad> <pad>
C: i j k l <pad>
Tại sao mask quan trọng?
Nếu coi <pad> như dữ liệu thật:
- trạng thái có thể tiếp tục thay đổi sau phần tử cuối;
- loss bị cộng ở các vị trí không tồn tại;
- trạng thái “cuối” có thể thực ra là trạng thái sau nhiều token padding.
Các framework cung cấp cơ chế đóng gói chuỗi hoặc mask để tránh điều này.
8. Kích thước tensor trong PyTorch
Với:
rnn = nn.RNN(
input_size=16,
hidden_size=32,
num_layers=1,
batch_first=True,
)
và batch:
x.shape == (8, 20, 16)
ta đọc là:
8: batch size;20: số bước thời gian;16: số đặc trưng ở mỗi bước.
Đầu ra:
output, h_n = rnn(x)
với RNN một chiều, một tầng:
output.shape == (8, 20, 32)
h_n.shape == (1, 8, 32)
Trong đó:
output[:, t, :]là trạng thái ẩn của tầng cuối tại thời điểmt;h_nlà trạng thái ẩn cuối của từng tầng/hướng.
Đừng nhầm output[:, -1, :] với trạng thái cuối hợp lệ nếu batch có padding mà chưa xử lý length đúng cách.
9. Ví dụ tối giản: phân loại một chuỗi số
Mục tiêu chỉ để nhìn luồng dữ liệu, chưa tối ưu kiến trúc.
import torch
import torch.nn as nn
class SequenceClassifier(nn.Module):
def __init__(self, input_size=3, hidden_size=32, num_classes=2):
super().__init__()
self.rnn = nn.RNN(
input_size=input_size,
hidden_size=hidden_size,
batch_first=True,
)
self.classifier = nn.Linear(hidden_size, num_classes)
def forward(self, x):
output, h_n = self.rnn(x)
last_hidden = h_n[-1]
logits = self.classifier(last_hidden)
return logits
model = SequenceClassifier()
x = torch.randn(8, 6, 3) # 8 mẫu, 6 bước, 3 đặc trưng/bước
logits = model(x)
print(logits.shape) # (8, 2)
Luồng xử lý:
10. Tách đúng dữ liệu theo thời gian
Đây là lỗi thực nghiệm rất phổ biến.
Giả sử bạn dự báo chuỗi theo thời gian. Nếu chia ngẫu nhiên từng hàng vào train/test, dữ liệu test ở quá khứ có thể xen kẽ dữ liệu train ở tương lai.
Ví dụ:
Tháng 1 → test
Tháng 2 → train
Tháng 3 → train
Tháng 4 → test
Cách này có thể không phản ánh tình huống triển khai thật.
Nếu mục tiêu là dự đoán tương lai từ quá khứ, thường nên dùng chia theo thời gian:
[train: quá khứ] [validation] [test: tương lai]
Đây là vấn đề thiết kế thí nghiệm, không phải vấn đề riêng của RNN.
11. Cửa sổ trượt: biến một chuỗi dài thành nhiều mẫu
Với chuỗi:
và cửa sổ dài 5, ta có thể tạo:
[s1 s2 s3 s4 s5] -> s6
[s2 s3 s4 s5 s6] -> s7
[s3 s4 s5 s6 s7] -> s8
...
Cần tránh để các cửa sổ gần như giống nhau bị phân tán ngẫu nhiên giữa train và test nếu điều đó tạo rò rỉ theo thời gian.
12. Stateful và stateless
Stateless giữa các mẫu
Mỗi mẫu bắt đầu với:
hoặc một trạng thái khởi tạo riêng. Đây là lựa chọn mặc định an toàn khi các mẫu độc lập.
Stateful qua các đoạn liên tiếp
Nếu một chuỗi dài được cắt thành nhiều đoạn nhưng các đoạn thật sự nối tiếp nhau, có thể truyền trạng thái cuối đoạn trước sang đoạn sau.
Nếu dùng truncated BPTT, thường phải detach trạng thái khi sang đoạn mới để không giữ toàn bộ đồ thị gradient.
13. Checklist chọn cấu hình
| Câu hỏi | Lựa chọn thường gặp |
|---|---|
| Một nhãn cho cả chuỗi? | lấy trạng thái cuối / pooling trạng thái |
| Một nhãn cho mỗi bước? | dùng output ở mọi thời điểm |
| Cần dự đoán online? | một chiều |
| Có toàn bộ chuỗi trước khi dự đoán? | có thể cân nhắc hai chiều |
| Chuỗi dài khác nhau? | padding + mask hoặc packed sequence |
| Chuỗi rất dài? | truncated BPTT hoặc kiến trúc khác |
| Phụ thuộc dài quan trọng? | cân nhắc LSTM/GRU thay Vanilla RNN |
14. Lỗi phổ biến
Lỗi 1 - coi padding là dữ liệu thật
Hậu quả: trạng thái và loss bị nhiễu.
Lỗi 2 - dùng bidirectional cho bài toán online
Hậu quả: mô hình nhìn thấy tương lai trong huấn luyện nhưng không có tương lai khi triển khai.
Lỗi 3 - reset trạng thái sai chỗ
- reset giữa các đoạn thật sự liên tiếp → mất ngữ cảnh;
- không reset giữa hai mẫu độc lập → trộn ngữ cảnh.
Lỗi 4 - chỉ nhìn accuracy tổng
Với dữ liệu mất cân bằng, cần thêm precision, recall, F1 và confusion matrix tùy bài toán.
Lỗi 5 - kỳ vọng Vanilla RNN nhớ hàng trăm bước ổn định
Nếu task thật sự phụ thuộc vào thông tin xa, vấn đề có thể nằm ở kiến trúc chứ không chỉ ở hyperparameter.
15. Bài tập thiết kế
Bài 1 - Phân loại chuỗi
Một trường đại học muốn dự đoán sinh viên có nguy cơ trượt môn dựa trên 8 tuần đầu:
- số lần đăng nhập LMS;
- số bài đã nộp;
- tổng thời gian xem tài liệu.
Hãy xác định:
- một gồm những gì;
- chiều dài chuỗi;
- đầu ra là nhiều-đến-một hay nhiều-đến-nhiều;
- tại sao không nên dùng bidirectional nếu dự đoán phải được đưa ra ngay cuối tuần 8 nhưng dữ liệu sau tuần 8 không tồn tại.
Bài 2 - Gán nhãn từng bước
Cho chuỗi log hệ thống, mỗi dòng cần được gán một trong ba nhãn: normal, warning, critical.
Hãy vẽ sơ đồ đầu vào/đầu ra và viết dạng loss phù hợp.
Bài 3 - Phát hiện rò rỉ dữ liệu
Một nhóm tạo cửa sổ từ dữ liệu cảm biến 2024-2026 rồi chia ngẫu nhiên 80/20. Giải thích ít nhất một trường hợp mà cách chia này làm kết quả test quá lạc quan.
Bài 4 - Đọc tensor
Cho:
rnn = nn.RNN(12, 64, num_layers=2, batch_first=True, bidirectional=True)
x = torch.randn(32, 50, 12)
Hãy xác định:
output.shape;h_n.shape;- vì sao chiều đặc trưng của
outputtăng gấp đôi khi dùng hai chiều.
Bài 5 - Chọn kiến trúc
Bạn cần phân loại cảm xúc của một câu đã thu thập đầy đủ trước khi dự đoán. Hãy nêu lý do vì sao bidirectional RNN có thể hợp lý hơn RNN một chiều trong trường hợp này.
16. Những điều cần nhớ
- Hãy chọn kiến trúc dựa trên vị trí và thời điểm của nhãn.
- Bidirectional RNN chỉ hợp lý khi ngữ cảnh tương lai có sẵn tại lúc dự đoán.
- Padding cần đi cùng length/mask.
- Với dữ liệu thời gian, cách chia train/test phải mô phỏng đúng điều kiện triển khai.
- Trạng thái RNN chỉ nên được giữ qua các đoạn thật sự liên tục.
- Vanilla RNN là nền tảng tốt để hiểu cơ chế, nhưng phụ thuộc dài thường cần kiến trúc có cổng.
Tài liệu tham khảo
- Schuster, M., & Paliwal, K. K. (1997). Bidirectional recurrent neural networks. IEEE Transactions on Signal Processing, 45(11), 2673-2681. https://doi.org/10.1109/78.650093
- Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning, Chapter 10. MIT Press. https://www.deeplearningbook.org/contents/rnn.html
- Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to Sequence Learning with Neural Networks. NeurIPS. https://arxiv.org/abs/1409.3215
- PyTorch documentation. torch.nn.RNN. https://docs.pytorch.org/docs/stable/generated/torch.nn.RNN.html