0

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:

  1. UIT-VSFC: Đánh giá giảng dạy của sinh viên (chuẩn chỉ, câu cú đầy đủ).
  2. 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-miniDeepSeek-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""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


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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí