Phân tích cảm xúc bình luận TikTok tiếng Việt
Tự crawl bình luận TikTok, gán nhãn bằng LLM chạy local, rồi so sánh PhoBERT và ViSoBERT với các baseline TF-IDF trên năm nhãn cảm xúc.
- PhoBERT
- PyTorch
- Scikit-learn
- Streamlit
- Python
Đồ án môn IE403, làm trọn một vòng: tự thu thập bình luận TikTok tiếng Việt, tự gán nhãn, rồi so sánh các model ngôn ngữ tiếng Việt với baseline TF-IDF trên bài toán phân loại năm nhãn cảm xúc.
Không có dữ liệu thì tự crawl#
Tôi cần bình luận thật, viết bằng thứ tiếng Việt của TikTok: teen code, thiếu dấu,
emoji xen chữ. Dữ liệu lấy qua endpoint api/comment/list/ — truyền aweme_id tách
từ URL video, phân trang bằng cursor mỗi lượt 20 bình luận, nghỉ 0.4 giây giữa các
request và thử lại tối đa 3 lần khi lỗi.
Thứ tôi quan tâm ngay từ đầu là chạy lại được. Trước mỗi lần chạy, script đọc cột
post_url trong CSV đã có để biết video nào đã xong rồi ghi bổ sung thay vì ghi đè.
Chín video tin tức và đời sống cho khoảng 42.900 bình luận thô.
Gán nhãn bằng LLM chạy local#
Ngồi gán tay từng đó dòng là không khả thi. Tôi dựng một LLM local qua LM Studio và biến nó thành bộ gán nhãn: prompt mô tả năm class (Vui vẻ, Tức giận, Buồn bã, Sợ hãi, Trung lập) kèm ví dụ teen code cho từng class, rồi ép output về đúng một chữ số.
# Teen code rõ nghĩa thì tra thẳng từ điển, khỏi tốn một lượt gọi model.
teen_code_dict = {"hehe": 0, "hihi": 0, "dm": 1, "dkm": 1, "sml": 2, "hú hồn": 3}
if text.lower().strip() in teen_code_dict:
return teen_code_dict[text.lower().strip()]
payload = {
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 1, # chặn ở 1 token: model không có chỗ để giải thích dài dòng
"stop": ["\n"],
}
response = requests.post("http://localhost:1234/v1/chat/completions", json=payload)max_tokens: 1 là chi tiết đáng giá nhất: hậu xử lý rút gọn còn một phép isdigit().
Vòng gán nhãn ghi checkpoint sau mỗi 5 dòng và nhớ các line_number đã xử lý, nên tôi
dừng rồi chạy tiếp tuỳ ý. Tổng cộng gán được 28.811 dòng.
Tiền xử lý tiếng Việt#
def clean_and_tokenize_text(text):
text = re.sub(r'@\S+', '', text) # bỏ mention
text = re.sub(emoji_pattern, ' ', text) # bỏ emoji
text = text.lower()
text = unicodedata.normalize('NFKC', text)
text = text.translate(str.maketrans('', '', string.punctuation))
text = text_normalize(text) # underthesea: chuẩn hoá dấu
return word_tokenize(text, format="text") # "em trai" -> "em_trai"Ngoài các bước trên tôi còn rút gọn ký tự lặp (hayyy → hay) và loại những dòng có
từ 80% ký tự không thuộc bảng chữ Latin. Sau làm sạch còn 21.025 dòng, phân bố lệch
hẳn về nhãn Trung lập (6.780) trong khi Sợ hãi chỉ có 2.756. Tôi hạ các class dư
xuống bằng class nhỏ nhất, được dataset cân bằng 13.780 dòng, 2.756 dòng mỗi nhãn.
Model nào thắng#
Nhóm truyền thống dùng TF-IDF 5.000 feature; nhóm transformer fine-tune với
max_length 128, AdamW lr=5e-5, class weights cân bằng trong hàm loss và early
stopping theo val accuracy (patience 3). Cả hai nhóm đánh giá trên cùng dataset
cân bằng, chia 90/10.
| Model | Accuracy | Macro F1 |
|---|---|---|
| PhoBERT-base | 0.6640 | 0.6648 |
| ViSoBERT | 0.6626 | 0.6628 |
| RoBERTa-base-Vietnamese | 0.6524 | 0.6517 |
| Stacking (LR + SVM + RF) | 0.6466 | 0.6473 |
| viBERT-base-cased | 0.6335 | 0.6298 |
| Logistic Regression | 0.6321 | 0.6332 |
| SVM (linear) | 0.6212 | 0.6229 |
| Random Forest | 0.6154 | 0.6159 |
Khoảng cách giữa PhoBERT và stacking TF-IDF chỉ 1.7 điểm accuracy — ít hơn tôi tưởng, và là lý do tôi giữ luôn nhóm ML trong bản demo. Tôi cũng thử ghép mBERT đa ngữ với BiLSTM nhưng freeze phần BERT nên chỉ đạt 0.5434, tức cái giá của việc không fine-tune encoder. Nhìn theo từng class, mọi model đều đọc Sợ hãi tốt nhất (F1 0.75–0.82) và Vui vẻ tệ nhất (0.53–0.59): mỉa mai và đùa cợt vẫn là phần khó.
Kết quả#
- Pipeline khép kín: crawl → gán nhãn tự động → làm sạch → cân bằng → huấn luyện → demo
- PhoBERT-base cho kết quả tốt nhất, 0.6640 accuracy và 0.6648 macro F1 trên năm nhãn
- App Streamlit nhận link TikTok, crawl bình luận trực tiếp rồi chạy song song tám model để so sánh dự đoán cạnh nhau