Phân tích cảm xúc với LSTM trên UIT-VSFC là một bài toán phân loại chuỗi tương đối trực quan: đầu vào là một câu phản hồi của sinh viên và đầu ra là một trong ba nhãn tiêu cực, trung tính hoặc tích cực. Điểm thú vị nằm ở chỗ mô hình không chỉ cần nhận ra những từ như “tốt” hay “kém”, mà còn phải học cách thông tin cảm xúc hình thành từ cả chuỗi từ.
UIT-VSFC được xây dựng riêng cho phản hồi sinh viên bằng tiếng Việt và chứa hơn 16.000 câu được gán nhãn thủ công cho cả cảm xúc và chủ đề [1]. Bài hướng dẫn này tập trung vào tác vụ cảm xúc và xây dựng một mô hình LSTM bằng TensorFlow/Keras theo hướng dễ chạy, dễ kiểm tra và dễ mở rộng; đây là implementation phục vụ học tập, không phải bản tái hiện chính thức của thí nghiệm trong paper gốc.
- UIT-VSFC có ba lớp cảm xúc và bị mất cân bằng mạnh ở lớp trung tính, vì vậy accuracy không đủ để đánh giá mô hình.
- Pipeline được xây dựng theo chuỗi: văn bản → mã hóa token → embedding → LSTM → softmax.
- Mô hình được huấn luyện với tập train, chọn cấu hình bằng tập dev và chỉ dùng test để đánh giá cuối cùng.
- Code sử dụng class weight như một lựa chọn thực hành; hiệu quả của nó cần được xác nhận bằng macro-F1 và F1 của lớp trung tính trên tập dev.
Bài toán cảm xúc trong UIT-VSFC thực sự trông như thế nào?
UIT-VSFC, viết tắt của Vietnamese Students' Feedback Corpus, gồm 16.175 câu phản hồi của sinh viên. Mỗi câu được gán nhãn cho hai tác vụ: phân loại cảm xúc và phân loại chủ đề. Với tác vụ chúng ta đang xét, mục tiêu là ánh xạ một câu phản hồi thành ba cực cảm xúc: negative, neutral hoặc positive [1].
Một đặc điểm rất quan trọng của dữ liệu là phân phối lớp không cân bằng. Paper thực nghiệm về các biến thể LSTM báo cáo toàn bộ corpus gồm khoảng 45,99% câu tiêu cực, 4,32% câu trung tính và 49,69% câu tích cực [2]. Vì vậy, một mô hình có accuracy khá cao vẫn có thể xử lý lớp trung tính rất kém.
| Tập dữ liệu | Negative | Neutral | Positive | Tổng |
|---|---|---|---|---|
| Train | 5.325 | 458 | 5.643 | 11.426 |
| Dev | 705 | 73 | 805 | 1.583 |
| Test | 1.409 | 167 | 1.590 | 3.166 |
Tỷ lệ train/dev/test xấp xỉ 70%/10%/20% và được cung cấp sẵn. Trong quá trình xây dựng mô hình, train dùng để cập nhật tham số, dev dùng để lựa chọn siêu tham số hoặc quyết định dừng huấn luyện, còn test nên được giữ lại đến bước đánh giá cuối cùng [2].
LSTM đọc một câu phản hồi như thế nào?
Mạng bộ nhớ dài-ngắn hạn (Long Short-Term Memory, LSTM) là một dạng mạng nơ-ron hồi quy được thiết kế để duy trì và điều khiển thông tin qua nhiều bước thời gian [3]. Thay vì xử lý toàn bộ câu như một vector Bag-of-Words, LSTM nhận chuỗi vector theo thứ tự và cập nhật trạng thái ẩn sau từng token.
Giả sử câu đã được biến thành chuỗi x1, x2, ..., xT. Ở bước t, LSTM kết hợp token hiện tại xt, trạng thái ẩn trước đó ht-1 và trạng thái bộ nhớ ct-1. Ba cổng sigmoid điều khiển phần thông tin cần quên, cần ghi và cần xuất [3].
Trong đó ft là cổng quên, it là cổng đầu vào, ot là cổng đầu ra, còn ct là bộ nhớ của cell. Trực giác quan trọng không nằm ở việc học thuộc các phương trình, mà ở khả năng LSTM học cách giữ lại hoặc loại bỏ thông tin tùy theo ngữ cảnh của chuỗi.
Với phân loại cảm xúc, chúng ta có thể lấy trạng thái cuối của LSTM làm biểu diễn cho toàn câu, sau đó đưa biểu diễn đó qua một lớp Dense với softmax để tạo xác suất cho ba lớp. Đây cũng là cách tiếp cận cơ bản được nghiên cứu trực tiếp trên UIT-VSFC [2].
Đọc UIT-VSFC và kiểm tra dữ liệu trước khi huấn luyện
Bản dữ liệu thường được tổ chức thành ba thư mục train, dev và test. Mỗi thư mục chứa sents.txt cho câu phản hồi, sentiments.txt cho nhãn cảm xúc và topics.txt cho nhãn chủ đề. Với bài này chúng ta chỉ cần hai file đầu.
Trong bản dữ liệu được sử dụng phổ biến hiện nay, nhãn cảm xúc được mã hóa thành 0 = negative, 1 = neutral, 2 = positive. Dù vậy, khi nhận dữ liệu từ một nguồn đóng gói lại, nên kiểm tra một vài cặp câu–nhãn thay vì mặc định rằng mọi bản phân phối đều giữ nguyên quy ước mã hóa.
from pathlib import Path
import numpy as np
ROOT = Path("UIT-VSFC")
def load_split(split_name):
split_dir = ROOT / split_name
texts = (split_dir / "sents.txt").read_text(
encoding="utf-8"
).splitlines()
labels = [
int(x)
for x in (split_dir / "sentiments.txt").read_text(
encoding="utf-8"
).splitlines()
]
assert len(texts) == len(labels)
return texts, np.asarray(labels, dtype="int32")
x_train, y_train = load_split("train")
x_dev, y_dev = load_split("dev")
x_test, y_test = load_split("test")
label_names = np.array([
"negative",
"neutral",
"positive"
])
print(len(x_train), len(x_dev), len(x_test))
print(x_train[0], label_names[y_train[0]])
Nếu thư mục đúng với split chuẩn của UIT-VSFC, ba kích thước đầu tiên sẽ là 11.426, 1.583 và 3.166. Việc in thử câu đầu tiên cùng tên nhãn là một kiểm tra đơn giản nhưng hữu ích để phát hiện sớm trường hợp file văn bản và file nhãn bị lệch dòng.
Tiếp theo, hãy nhìn vào phân phối lớp thay vì bắt đầu xây mô hình ngay. Đây là bước quyết định cách chúng ta đánh giá và có nên thử class weight hay không.
for split_name, labels in [
("train", y_train),
("dev", y_dev),
("test", y_test),
]:
counts = np.bincount(labels, minlength=3)
distribution = {
name: int(count)
for name, count in zip(label_names, counts)
}
print(split_name, distribution)
Điểm cần chú ý là train chỉ có 458 câu neutral so với hơn 5.000 câu ở mỗi lớp còn lại. Trong nghiên cứu LSTM trên UIT-VSFC, F1 của lớp neutral thấp hơn đáng kể so với negative và positive; đây là dấu hiệu rõ ràng rằng kết quả tổng thể có thể che khuất một lớp khó [2].
Biến văn bản thành đầu vào cho LSTM
LSTM không nhận trực tiếp chuỗi ký tự. Ta cần biến mỗi token thành một số nguyên, sau đó ánh xạ số nguyên đó thành một vector liên tục bằng lớp Embedding. TensorFlow cung cấp TextVectorization để học từ vựng từ tập train và thực hiện bước mã hóa này ngay trong model.
Một lỗi phổ biến là chọn độ dài chuỗi tùy ý. Thay vào đó, đoạn code dưới đây lấy percentile 95 của độ dài câu trong train. Đây là một lựa chọn thực hành của tutorial, không phải siêu tham số từ paper [2]. Nó giúp giữ phần lớn câu mà không buộc mọi batch phải padding theo các câu dài bất thường.
import tensorflow as tf
from tensorflow.keras import layers
def normalize_text(text):
return " ".join(text.lower().strip().split())
x_train = [normalize_text(x) for x in x_train]
x_dev = [normalize_text(x) for x in x_dev]
x_test = [normalize_text(x) for x in x_test]
lengths = np.array([len(x.split()) for x in x_train])
MAX_LEN = int(np.percentile(lengths, 95))
MAX_TOKENS = 10000
vectorizer = layers.TextVectorization(
max_tokens=MAX_TOKENS,
standardize=None,
split="whitespace",
output_mode="int",
output_sequence_length=MAX_LEN
)
vectorizer.adapt(np.asarray(x_train))
print("MAX_LEN =", MAX_LEN)
print("Vocabulary =", len(vectorizer.get_vocabulary()))
Ở đây chúng ta chỉ chuẩn hóa chữ thường và khoảng trắng. Không nên xóa từ dừng một cách máy móc trong phân tích cảm xúc: những từ như “không”, “chưa” hoặc “không được” có thể mang thông tin quyết định. Tương tự, nếu bản sents.txt của bạn đã được tách từ bằng dấu gạch dưới, không nên chạy thêm một tokenizer khác mà chưa kiểm tra tác động.
Trong nghiên cứu [2], tác giả dùng VnCoreNLP cho bước tách từ, gán nhãn từ loại và phân tích phụ thuộc vì thí nghiệm còn bao gồm Dependency Tree-LSTM. Với LSTM tuần tự đơn giản trong bài này, POS tagging và dependency parsing không phải đầu vào bắt buộc.
Xây dựng mạng Embedding → LSTM → Softmax
Kiến trúc dưới đây cố tình giữ nhỏ để thấy rõ vai trò của từng thành phần. TextVectorization chuyển câu thành chuỗi ID, Embedding học một vector cho mỗi token, LSTM tổng hợp chuỗi thành biểu diễn câu, còn lớp Dense cuối sinh phân phối xác suất trên ba lớp.
Embedding trong ví dụ được học từ đầu cùng mô hình. Điều này phù hợp với mục tiêu tutorial và cũng giúp tách bạch ảnh hưởng của LSTM khỏi một mô hình ngôn ngữ pretrained. Nó không phải cấu hình tái hiện chính xác paper [2], nơi tác giả thử nhiều kích thước biểu diễn và các thiết lập huấn luyện khác.
vocab_size = len(vectorizer.get_vocabulary())
inputs = tf.keras.Input(shape=(), dtype=tf.string, name="text")
x = vectorizer(inputs)
x = layers.Embedding(
input_dim=vocab_size,
output_dim=128,
mask_zero=True
)(x)
x = layers.LSTM(
units=128,
dropout=0.2
)(x)
x = layers.Dense(64, activation="relu")(x)
x = layers.Dropout(0.3)(x)
outputs = layers.Dense(
3,
activation="softmax",
name="sentiment"
)(x)
model = tf.keras.Model(inputs, outputs)
model.compile(
optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3),
loss=tf.keras.losses.SparseCategoricalCrossentropy(),
metrics=["accuracy"]
)
model.summary()
mask_zero=True cho phép lớp Embedding đánh dấu token padding để LSTM không xem phần đệm như từ thực. Dense cuối có ba neuron vì bài toán có ba lớp, còn softmax biến ba logit thành ba giá trị không âm có tổng bằng 1.
128 chiều embedding và 128 unit LSTM ở đây chỉ là điểm bắt đầu hợp lý cho thí nghiệm, không phải giá trị tối ưu đã được chứng minh cho UIT-VSFC. Khi tune mô hình, nên thay đổi một nhóm nhỏ siêu tham số và chọn bằng dev set thay vì nhìn vào test nhiều lần.
Huấn luyện khi lớp neutral chỉ chiếm một phần rất nhỏ
Nếu huấn luyện trực tiếp bằng cross-entropy thông thường, mô hình có thể ưu tiên hai lớp lớn vì dự đoán đúng negative và positive đã đóng góp phần lớn vào loss trung bình. Một lựa chọn đơn giản là gán trọng số lớn hơn cho các mẫu thuộc lớp hiếm bằng class weight.
Class weight không phải “thuốc chữa” mất cân bằng. Nó thay đổi mức phạt của các lỗi và có thể làm precision/recall dịch chuyển theo những hướng khác nhau. Vì vậy nên chạy ít nhất hai cấu hình — có và không có class weight — rồi so sánh macro-F1 và F1 của neutral trên dev.
from sklearn.utils.class_weight import compute_class_weight
classes = np.unique(y_train)
weights = compute_class_weight(
class_weight="balanced",
classes=classes,
y=y_train
)
class_weight = {
int(c): float(w)
for c, w in zip(classes, weights)
}
early_stopping = tf.keras.callbacks.EarlyStopping(
monitor="val_loss",
patience=2,
restore_best_weights=True
)
history = model.fit(
np.asarray(x_train),
y_train,
validation_data=(np.asarray(x_dev), y_dev),
epochs=12,
batch_size=64,
class_weight=class_weight,
callbacks=[early_stopping],
verbose=2
)
Early stopping ở đây theo dõi validation loss và khôi phục trọng số tốt nhất. Điều đó giúp tránh việc chọn checkpoint chỉ vì epoch cuối cùng tình cờ có accuracy cao. Nếu mục tiêu thực tế đặc biệt quan tâm lớp neutral, bạn có thể viết callback đánh giá macro-F1 trên dev sau mỗi epoch và dùng chỉ số đó để chọn checkpoint.
Để có mốc tham khảo lịch sử, thí nghiệm năm 2018 trên UIT-VSFC báo cáo LSTM đạt F1 tổng hợp 89,72% và accuracy 90,25%, trong đó F1 của negative, neutral và positive lần lượt là 92,13%, 41,29% và 92,67% [2]. Những con số này thuộc cấu hình và quy trình của paper; không nên xem chúng là kết quả mà đoạn code TensorFlow ở trên bắt buộc phải tái tạo.
Đánh giá LSTM: đừng dừng lại ở accuracy
Với UIT-VSFC, accuracy cho biết tỷ lệ dự đoán đúng trên toàn test nhưng không cho biết mô hình đối xử với lớp neutral như thế nào. Hai chỉ số nên đọc song song là macro-F1 và weighted-F1. Macro-F1 cho mỗi lớp trọng số ngang nhau, trong khi weighted-F1 cân theo số mẫu của từng lớp.
Macro-F1 đặc biệt hữu ích trong trường hợp này vì một neutral F1 thấp sẽ không bị “che” bởi hai lớp lớn. Đồng thời, confusion matrix giúp xác định neutral đang bị nhầm chủ yếu sang negative hay positive.
from sklearn.metrics import (
classification_report,
confusion_matrix,
f1_score
)
probabilities = model.predict(
np.asarray(x_test),
batch_size=256,
verbose=0
)
y_pred = probabilities.argmax(axis=1)
print(
classification_report(
y_test,
y_pred,
target_names=label_names,
digits=4
)
)
print(
"Macro-F1:",
f1_score(y_test, y_pred, average="macro")
)
print(
"Weighted-F1:",
f1_score(y_test, y_pred, average="weighted")
)
print(
confusion_matrix(y_test, y_pred)
)
Nếu weighted-F1 cao nhưng macro-F1 thấp đáng kể, hãy kiểm tra classification report trước khi kết luận mô hình hoạt động tốt. Trên UIT-VSFC, lớp neutral là ứng viên đầu tiên cần xem vì nó chỉ chiếm một phần nhỏ dữ liệu.
Một quy trình thí nghiệm sạch là dùng dev để quyết định MAX_LEN, kích thước embedding, số unit LSTM, learning rate, dropout và có dùng class weight hay không. Sau khi chốt toàn bộ cấu hình, chỉ chạy test để báo cáo kết quả cuối. Nếu liên tục thay cấu hình dựa trên test, test set về thực chất đã trở thành một validation set thứ hai.
Dùng mô hình để dự đoán phản hồi mới
Vì TextVectorization được đặt bên trong model, inference khá gọn: chỉ cần đưa chuỗi tiếng Việt vào đúng quy trình chuẩn hóa đã dùng khi train. Model trả về ba xác suất softmax và chúng ta chọn lớp có xác suất lớn nhất.
def predict_sentiment(text):
text = normalize_text(text)
probs = model.predict(
np.array([text]),
verbose=0
)[0]
label_id = int(np.argmax(probs))
return {
"label": label_names[label_id],
"score": float(probs[label_id]),
"probabilities": probs.tolist()
}
print(
predict_sentiment(
"Giảng viên giảng dễ hiểu và rất nhiệt tình."
)
)
Giá trị score là xác suất softmax của lớp được chọn, nhưng không nên tự động diễn giải nó như xác suất tin cậy đã được hiệu chỉnh. Nếu hệ thống cần ra quyết định dựa trên ngưỡng xác suất, cần đánh giá thêm calibration trên dữ liệu phù hợp.
Khi mô hình sai, nên đọc lỗi theo nhóm thay vì chỉ nhìn vài câu riêng lẻ. Các nhóm hữu ích gồm câu phủ định, phản hồi có nhiều mệnh đề, câu rất ngắn, câu chứa từ ngoài từ vựng và đặc biệt là neutral. Phân tích lỗi như vậy thường cho thông tin thực tế hơn việc chỉ tăng số unit của LSTM.
LSTM trên UIT-VSFC nên được hiểu như một baseline nào?
LSTM là một baseline học sâu hữu ích vì nó buộc chúng ta hiểu toàn bộ pipeline phân loại chuỗi: tokenization, embedding, padding, trạng thái chuỗi, mất cân bằng lớp và đánh giá đa lớp. Quan trọng hơn, UIT-VSFC từng được nghiên cứu trực tiếp với LSTM, Dependency Tree-LSTM và các biến thể kết hợp, nên ta có một mốc khoa học hợp lý để đối chiếu [2].
Tuy nhiên, không nên lấy kết quả từ một paper rồi kết luận rằng LSTM nói chung tốt hơn hoặc kém hơn mọi kiến trúc khác. Trong nghiên cứu [2], LSTM đạt F1 89,72% trong điều kiện thí nghiệm cụ thể, còn mô hình kết hợp trạng thái LSTM và Dependency Tree-LSTM với SVM đạt 90,20%. Một nghiên cứu khác trên cùng corpus báo cáo Bi-LSTM đạt F1 cao hơn các mô hình được so sánh trong thiết lập của chính nghiên cứu đó [2]. Những kết quả này chỉ nên được đọc trong bối cảnh dữ liệu, preprocessing và protocol tương ứng.
Nếu mục tiêu là học cơ chế mô hình hóa chuỗi, LSTM là một lựa chọn rất phù hợp. Nếu mục tiêu chuyển sang tối đa hóa hiệu năng cho một hệ thống hiện đại, LSTM có thể đóng vai trò baseline để so sánh với các mô hình pretrained cho tiếng Việt, nhưng việc so sánh cần được thực hiện trên cùng split, cùng metric và cùng quy trình đánh giá.
Kết luận
Phân tích cảm xúc trên UIT-VSFC bằng LSTM là một ví dụ điển hình cho bài toán phân loại chuỗi tiếng Việt: câu được mã hóa thành token, chuyển sang embedding, tổng hợp bằng LSTM và phân loại qua softmax. Thách thức đáng chú ý nhất của bộ dữ liệu không chỉ nằm ở việc học ngữ cảnh mà còn ở lớp neutral rất nhỏ, vì vậy kết quả cần được đọc qua F1 theo lớp và macro-F1 thay vì chỉ dựa vào accuracy.
Pipeline trong bài là mô hình học tập có thể chạy và mở rộng, nhưng không được xem là bản tái hiện thí nghiệm của paper. Khi thực nghiệm nghiêm túc, hãy giữ test set độc lập, tune trên dev và báo cáo đầy đủ preprocessing, siêu tham số cùng metric để kết quả có thể được hiểu và so sánh đúng.
References
[1] K. V. Nguyen, V. D. Nguyen, P. X.-V. Nguyen, T. T.-H. Truong, and N. L.-T. Nguyen, “UIT-VSFC: Vietnamese Students’ Feedback Corpus for Sentiment Analysis,” in Proc. 2018 10th International Conference on Knowledge and Systems Engineering (KSE), 2018, pp. 19–24. DOI: 10.1109/KSE.2018.8573337.
[2] V. D. Nguyen, K. V. Nguyen, and N. L.-T. Nguyen, “Variants of Long Short-Term Memory for Sentiment Analysis on Vietnamese Students’ Feedback Corpus,” in Proc. 2018 10th International Conference on Knowledge and Systems Engineering (KSE), 2018, pp. 306–311. DOI: 10.1109/KSE.2018.8573351.
[3] S. Hochreiter and J. Schmidhuber, “Long Short-Term Memory,” Neural Computation, vol. 9, no. 8, pp. 1735–1780, 1997. DOI: 10.1162/neco.1997.9.8.1735.
Nội dung gốc thuộc tác giả. Vui lòng không đăng lại toàn văn khi chưa được phép, trừ trường hợp pháp luật hoặc giấy phép áp dụng cho phép. Nội dung của bên thứ ba tuân theo quyền và giấy phép tương ứng.
Nguồn gốc: Phân tích cảm xúc tiếng Việt với LSTM và UIT-VSFC