AI Tưởng Tiếng Lóng Là Chửi Nhau: Thử Nghiệm Phân Loại Cảm Xúc Tiếng Việt

Hầu hết các bài báo về phân tích cảm xúc (sentiment analysis) tiếng Việt đều công bố độ chính xác trên 90%. Điển hình là mức 94% F1 trên tập dữ liệu đánh giá môn học của sinh viên.
Con số đó là thật, nhưng nó đến từ những câu văn mẫu mực, tròn vành rõ chữ.
Chuyện gì sẽ xảy ra khi đưa model vào thực tế mạng xã hội, nơi người dùng viết tắt, dùng icon tếu táo và xưng hô "tao - mày" thân mật — đúng kiểu văn bản hỗn loạn khiến mình bắt đầu benchmark các model BERT tiếng Việt ngay từ đầu?
Mình đem 4 model LLM phổ biến đi phân loại cảm xúc trên 2 tập dữ liệu: văn bản chuẩn mực và comment Facebook thực tế. Kết quả: trong khi 3 model khác giữ phong độ ổn định, Llama-3.1-8B-Instruct tụt tới 20 điểm chính xác và liên tục hiểu nhầm các câu đùa vui thành thù địch (negative).
Thiết Kế Thử Nghiệm
Hai tập dữ liệu được so sánh:
- UIT-VSFC: Đánh giá giảng dạy của sinh viên (chuẩn chỉ, câu cú đầy đủ).
- UIT-VSMEC (Đã chuẩn hóa): Comment Facebook thực tế (teencode, tiếng lóng, không dấu).
Lấy ngẫu nhiên 25 mẫu mỗi tập, phân loại thành 3 nhóm: positive, negative, neutral.
| Model | Dữ liệu chuẩn (VSFC) | Dữ liệu MXH thực tế (VSMEC) | Mức độ sụt giảm |
|---|---|---|---|
| Qwen3-8B | 84% (21/25) | 80% (20/25) | Giảm 4% |
| Llama-3.1-8B-Instruct | 88% (22/25) | 68% (17/25) | Giảm 20% |
| gpt-4o-mini | 84% (21/25) | 80% (20/25) | Giảm 4% |
| DeepSeek-V4-Flash | 84% (21/25) | 76% (19/25) | Giảm 8% |
Qwen3, gpt-4o-mini và DeepSeek-V4 giữ được độ chính xác 76–80%. Riêng Llama-3.1 sụt giảm nghiêm trọng xuống còn 68%.
Chi Tiết Các Pha "Hiểu Lầm" Của Llama-3.1
Khi soi vào các mẫu câu tích cực thực tế bị Llama-3.1 phán đoán sai:
| Mẫu câu | Qwen3 | Llama-3.1 | gpt-4o-mini | DeepSeek-V4 |
|---|---|---|---|---|
| "...nghe hay hơn bản gốc...nhiều < 3" | Positive | Negative | Positive | Positive |
| "con gái tao thì suốt ngày hêy siri bắt chước mẹ 😂" | Positive | Negative | Positive | Positive |
| "per hẹn xem phim này nữa nha mày 😛" | Positive | Negative | Positive | Positive |
| "nghe bạn này nói dễ thương zị" | Positive | Negative | Positive | Positive |
Cả 3 model kia đều nhận diện đúng tinh thần vui vẻ, ấm áp. Riêng Llama-3.1 phán sai cả 4 câu thành tiêu cực.
Có hai câu xuất hiện đại từ "tao" và "mày". Trong văn cảnh trang trọng, hai từ này nghe có vẻ thô. Nhưng trên mạng xã hội giữa bạn bè thân thiết, đây là cách nói chuyện hết sức bình thường.
Hai câu còn lại không có đại từ nhạy cảm nào, chỉ có từ viết tắt ("zị") hoặc icon ("< 3"). Điều này cho thấy Llama-3.1 có xu hướng quy chụp bất kỳ câu nào không thuộc văn phong chuẩn mực là mang cảm xúc tiêu cực — đúng kiểu lỗi âm thầm khiến những phần khó khăn của nghề AI engineer trở nên khó khăn.
Lưu Ý Về Giới Hạn Token Của Model Suy Luận
Trong thử nghiệm, ban đầu mình đặt max_tokens=300. Qwen3-8B thường xuyên trả về chuỗi rỗng vì tiêu tốn hết 300 token cho phần "suy nghĩ nội tâm" trước khi kịp đưa ra nhãn cuối cùng. Khi nâng lên 800 token, model hoạt động trơn tru.
Nếu bạn đang dùng các reasoning model cho tác vụ phân loại đơn giản, hãy đảm bảo cấp đủ token budget cho quá trình suy luận.
Thí Nghiệm
Đây là toàn bộ quy trình chạy thực tế, từng bước một. Script đầy đủ nằm ở phần phụ lục cuối bài.
1. Lấy mẫu 25 dòng từ mỗi tập dữ liệu với seed cố định, ánh xạ nhãn số của VSMEC về cùng thang 3 lớp với VSFC:
VSMEC_LABEL_MAP = {-1: "negative", 0: "neutral", 1: "positive"}
def sample_vsfc(n):
ds = load_dataset("ura-hcmut/UIT-VSFC")["test"]
idx = list(range(len(ds)))
random.Random(SEED).shuffle(idx)
picked = idx[:n]
return [{"text": ds[i]["text"], "gold": ds[i]["label"]} for i in picked]
def sample_vsmec(n):
ds = load_dataset("viethq1906/UIT-VSMEC-Sentiment-Relabelled")["test"]
idx = list(range(len(ds)))
random.Random(SEED + 1).shuffle(idx)
picked = idx[:n]
return [{"text": ds[i]["sentence"], "gold": VSMEC_LABEL_MAP[ds[i]["sentiment"]]} for i in picked]
2. Trích nhãn từ output thô của model — trả về None nếu không khớp nhãn nào:
def extract_label(raw):
raw = raw.lower()
for label in ("positive", "negative", "neutral"):
if label in raw:
return label
return None
3. Chạy từng model trên từng comment đã lấy mẫu, ở cả hai tập dữ liệu:
for dataset_name, samples in [("vsfc", vsfc_samples), ("vsmec", vsmec_samples)]:
for ex in samples:
entry = {"text": ex["text"], "gold": ex["gold"], "models": {}}
for model in MODELS:
try:
raw, finish = call_model(model, ex["text"])
label = extract_label(raw)
entry["models"][model] = {"raw": raw, "extracted": label, "finish": finish,
"correct": label == ex["gold"]}
except Exception as e:
entry["models"][model] = {"error": str(e)[:200]}
results[dataset_name].append(entry)
4. Tính độ chính xác cho từng model, từng tập dữ liệu — chính là bảng số liệu ở đầu bài:
summary = {}
for dataset_name in ("vsfc", "vsmec"):
for model in MODELS:
correct = sum(1 for e in results[dataset_name] if e["models"].get(model, {}).get("correct"))
total = len(results[dataset_name])
summary.setdefault(model, {})[dataset_name] = f"{correct}/{total} ({100*correct/total:.0f}%)"
Tự chạy lại: uv run python sentiment_gap_run.py.
Tài Liệu Tham Khảo
- UIT-VSFC — Bộ dữ liệu đánh giá giảng dạy chuẩn mực của sinh viên.
- UIT-VSMEC (Đã chuẩn hóa) — Bộ dữ liệu comment mạng xã hội thực tế.
- Llama-3.1-8B-Instruct — Model hiểu nhầm tiếng lóng thành thù địch.
- Qwen3-8B — Một trong các model giữ được phong độ ổn định.
- gpt-4o-mini — Tài liệu model của OpenAI.
Hệ thống của bạn có gặp vấn đề khi xử lý ngôn ngữ phi trang trọng của người dùng không? Hãy để lại bình luận nhé.
👉 Theo dõi mình tại: LinkedIn | GitHub
Phụ Lục: Toàn Bộ Script
Toàn bộ file có thể chạy được:
#!/usr/bin/env python3
"""Measure whether LLM-prompted sentiment classification holds up on real Vietnamese
social media text (UIT-VSMEC) the way it does on curated, formal text (UIT-VSFC).
Both datasets are public, real, human-labeled:
- ura-hcmut/UIT-VSFC (test split, 3166 rows) — formal student feedback, 3-class
(positive/negative/neutral).
- viethq1906/UIT-VSMEC-Sentiment-Relabelled (test split, 693 rows) — real Facebook
comments, slang/emoji/typos, sentiment relabelled to the same 3-class scheme
(-1/0/1 = negative/neutral/positive).
No fine-tuning here: this tests LLM-prompted classification specifically, since a lot
of 2026 production sentiment analysis is done via LLM prompting rather than a
dedicated fine-tuned classifier. Not a reproduction of the older PhoBERT/ensemble
benchmark numbers (94% VSFC / ~60% VSMEC CNN baseline) cited in prior literature —
those are a different method entirely, cited separately in the post as corroboration.
"""
import json
import os
import random
import time
from pathlib import Path
import requests
from datasets import load_dataset
OUT_PATH = Path("content/2026-09-01/sentiment-gap/scratch/sentiment_gap_results.json")
N_PER_DATASET = 25
SEED = 20260901
HF_MODELS = [
"Qwen/Qwen3-8B",
"meta-llama/Llama-3.1-8B-Instruct",
]
OPENROUTER_MODELS = [
"openai/gpt-4o-mini",
"deepseek/deepseek-v4-flash-0731",
]
MODELS = HF_MODELS + OPENROUTER_MODELS
HF_TOKEN = os.environ["HF_TOKEN"]
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY")
HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
VSMEC_LABEL_MAP = {-1: "negative", 0: "neutral", 1: "positive"}
def sample_vsfc(n):
ds = load_dataset("ura-hcmut/UIT-VSFC")["test"]
idx = list(range(len(ds)))
random.Random(SEED).shuffle(idx)
picked = idx[:n]
return [{"text": ds[i]["text"], "gold": ds[i]["label"]} for i in picked]
def sample_vsmec(n):
ds = load_dataset("viethq1906/UIT-VSMEC-Sentiment-Relabelled")["test"]
idx = list(range(len(ds)))
random.Random(SEED + 1).shuffle(idx)
picked = idx[:n]
return [{"text": ds[i]["sentence"], "gold": VSMEC_LABEL_MAP[ds[i]["sentiment"]]} for i in picked]
def call_model(model, text):
prompt = (
f'Phân loại cảm xúc của câu sau là "positive", "negative", hoặc "neutral". '
f'Chỉ trả lời đúng một từ, không giải thích.\n\nCâu: "{text}"'
)
if model in OPENROUTER_MODELS:
url, token = OPENROUTER_URL, OPENROUTER_API_KEY
else:
url, token = HF_ROUTER_URL, HF_TOKEN
resp = requests.post(
url,
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={"model": model, "messages": [{"role": "user", "content": prompt}],
"max_tokens": 800, "temperature": 0.0},
timeout=60,
)
resp.raise_for_status()
data = resp.json()
msg = data["choices"][0]["message"]
content = (msg.get("content") or "").strip().lower()
return content, data["choices"][0].get("finish_reason")
def extract_label(raw):
raw = raw.lower()
for label in ("positive", "negative", "neutral"):
if label in raw:
return label
return None
def run():
vsfc_samples = sample_vsfc(N_PER_DATASET)
vsmec_samples = sample_vsmec(N_PER_DATASET)
print(f"Sampled {len(vsfc_samples)} VSFC, {len(vsmec_samples)} VSMEC")
results = {"vsfc": [], "vsmec": []}
for dataset_name, samples in [("vsfc", vsfc_samples), ("vsmec", vsmec_samples)]:
for ex in samples:
entry = {"text": ex["text"], "gold": ex["gold"], "models": {}}
for model in MODELS:
try:
raw, finish = call_model(model, ex["text"])
label = extract_label(raw)
entry["models"][model] = {"raw": raw, "extracted": label, "finish": finish,
"correct": label == ex["gold"]}
except Exception as e: # noqa: BLE001
entry["models"][model] = {"error": str(e)[:200]}
results[dataset_name].append(entry)
print(f"[{dataset_name}] gold={ex['gold']:8s} " +
" ".join(f"{m.split('/')[-1]}={entry['models'][m].get('extracted')}" for m in MODELS))
# accuracy summary
summary = {}
for dataset_name in ("vsfc", "vsmec"):
for model in MODELS:
correct = sum(1 for e in results[dataset_name] if e["models"].get(model, {}).get("correct"))
total = len(results[dataset_name])
summary.setdefault(model, {})[dataset_name] = f"{correct}/{total} ({100*correct/total:.0f}%)"
print("\n=== Accuracy summary ===")
for model, d in summary.items():
print(model, d)
OUT_PATH.write_text(json.dumps({"results": results, "summary": summary}, ensure_ascii=False, indent=2))
print(f"\nWrote {OUT_PATH}")
if __name__ == "__main__":
run()
All rights reserved