Xử lý ngôn ngữ tự nhiên tiếng Việt với Python

Xử lý ngôn ngữ tự nhiên với tiếng Việt có những thách thức riêng, chủ yếu vì tiếng Việt tách âm tiết bằng dấu cách nhưng từ lại có thể gồm nhiều âm tiết.

Vấn đề tách từ

Với tiếng Anh, tách theo dấu cách là gần đúng. Với tiếng Việt thì không:

cau = "Học sinh học sinh học"
cau.split()
# ['Học', 'sinh', 'học', 'sinh', 'học']  - mất hết ý nghĩa

“Học sinh” là một từ, “sinh học” là một từ khác. Tách sai là mọi bước sau đều sai.

pip install underthesea
from underthesea import word_tokenize, pos_tag, ner

cau = "Học sinh học sinh học tại trường Đại học Bách khoa Hà Nội"

print(word_tokenize(cau))
# ['Học sinh', 'học', 'sinh học', 'tại', 'trường', 'Đại học', ...]

print(word_tokenize(cau, format="text"))
# Học_sinh học sinh_học tại trường Đại_học Bách_khoa Hà_Nội

print(pos_tag(cau))       # gán nhãn từ loại
print(ner(cau))           # nhận dạng thực thể có tên

Định dạng nối bằng gạch dưới rất tiện: sau bước này bạn tách theo dấu cách như tiếng Anh mà vẫn giữ đúng ranh giới từ.

Tiền xử lý

import re, unicodedata

def lam_sach(van_ban: str) -> str:
    # Chuẩn hóa Unicode - bắt buộc với tiếng Việt
    van_ban = unicodedata.normalize("NFC", van_ban)
    van_ban = van_ban.lower()

    # Bỏ URL, email, thẻ HTML
    van_ban = re.sub(r"https?://\S+|www\.\S+", " ", van_ban)
    van_ban = re.sub(r"\S+@\S+", " ", van_ban)
    van_ban = re.sub(r"<[^>]+>", " ", van_ban)

    # Giữ chữ cái tiếng Việt, số và khoảng trắng
    van_ban = re.sub(r"[^\w\sàáảãạăằắẳẵặâầấẩẫậèéẻẽẹêềếểễệ"
                     r"ìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữự"
                     r"ỳýỷỹỵđ]", " ", van_ban)

    return re.sub(r"\s+", " ", van_ban).strip()

Bước chuẩn hóa NFC quan trọng hơn người ta tưởng: cùng một chữ “ế” có thể được lưu bằng hai cách khác nhau, và mô hình sẽ coi chúng là hai từ riêng biệt.

Từ dừng

TU_DUNG = {"và", "của", "là", "có", "được", "cho", "trong", "với",
           "các", "những", "này", "đó", "thì", "mà", "ở", "từ", "một"}

def bo_tu_dung(tu: list[str]) -> list[str]:
    return [t for t in tu if t not in TU_DUNG and len(t) > 1]

Cân nhắc trước khi bỏ. Với phân loại chủ đề, từ dừng chỉ là nhiễu. Nhưng với phân tích cảm xúc, từ “không” đổi hẳn ý nghĩa câu — bỏ nó đi là hỏng.

Biểu diễn văn bản thành số

from sklearn.feature_extraction.text import TfidfVectorizer

vec = TfidfVectorizer(
    max_features=20_000,
    ngram_range=(1, 2),     # lấy cả cụm hai từ
    min_df=3,               # bỏ từ xuất hiện dưới 3 lần
    max_df=0.85,            # bỏ từ xuất hiện ở hơn 85% văn bản
    sublinear_tf=True,
)

X = vec.fit_transform(van_ban_da_tach)

TF-IDF cho điểm cao những từ xuất hiện nhiều trong một văn bản nhưng hiếm trong toàn bộ tập — tức là những từ đặc trưng cho văn bản đó.

Dùng ngram_range=(1, 2) giúp bắt được cụm từ có nghĩa như “không tốt”, điều mà mô hình chỉ nhìn từng từ sẽ bỏ lỡ.

Phân loại văn bản

from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report

quy_trinh = Pipeline([
    ("vec", TfidfVectorizer(ngram_range=(1, 2), min_df=3, max_features=30_000)),
    ("mo_hinh", LogisticRegression(max_iter=1000, class_weight="balanced")),
])

quy_trinh.fit(X_train, y_train)
print(classification_report(y_test, quy_trinh.predict(X_test), digits=3))

Với phân loại chủ đề, hồi quy logistic trên TF-IDF thường cho kết quả rất tốt và chạy nhanh. Đừng vội dùng mô hình lớn nếu chưa thử mốc này.

Xem mô hình học được gì

import numpy as np

ten_dac_trung = quy_trinh["vec"].get_feature_names_out()
he_so = quy_trinh["mo_hinh"].coef_[0]

top = np.argsort(he_so)[-15:]
print("Từ đẩy về lớp dương:", ten_dac_trung[top])

bottom = np.argsort(he_so)[:15]
print("Từ đẩy về lớp âm:  ", ten_dac_trung[bottom])

Bước này rất đáng làm. Nó hay lộ ra rằng mô hình đang học những thứ vô nghĩa — ví dụ tên miền website hoặc mẫu chữ ký lặp lại trong dữ liệu.

Tìm văn bản tương tự

from sklearn.metrics.pairwise import cosine_similarity

X = vec.fit_transform(danh_sach_bai)
tuong_dong = cosine_similarity(X[0:1], X).flatten()
gan_nhat = tuong_dong.argsort()[-6:-1][::-1]

for i in gan_nhat:
    print(f"{tuong_dong[i]:.3f}  {tieu_de[i]}")

Đây là cách đơn giản để làm gợi ý “bài viết liên quan” mà không cần mô hình phức tạp.

Mô hình ngôn ngữ hiện đại

Khi TF-IDF không đủ — ví dụ cần hiểu ngữ cảnh hoặc câu diễn đạt vòng vo:

pip install transformers torch
from transformers import pipeline

phan_loai = pipeline("sentiment-analysis",
                     model="wonrax/phobert-base-vietnamese-sentiment")

kq = phan_loai("Sản phẩm này dùng rất tốt, giao hàng nhanh")
print(kq)

Các mô hình họ PhoBERT được huấn luyện riêng cho tiếng Việt, cho kết quả tốt hơn nhiều so với mô hình đa ngôn ngữ. Đổi lại, chúng cần nhiều tài nguyên hơn và chậm hơn đáng kể.

Vector nhúng câu

from sentence_transformers import SentenceTransformer

mo_hinh = SentenceTransformer("keepitreal/vietnamese-sbert")
vector = mo_hinh.encode(["Câu thứ nhất", "Câu thứ hai"])

from sklearn.metrics.pairwise import cosine_similarity
print(cosine_similarity([vector[0]], [vector[1]]))

Khác với TF-IDF, vector nhúng nắm được ngữ nghĩa: “xe hơi” và “ô tô” sẽ gần nhau dù không dùng chung từ nào.

Nên bắt đầu từ đâu

  1. Làm sạch và chuẩn hóa văn bản cho tử tế — bước này quyết định nhiều nhất
  2. Tách từ bằng công cụ hiểu tiếng Việt, đừng tách theo dấu cách
  3. Thử TF-IDF cộng hồi quy logistic làm mốc
  4. Chỉ chuyển sang mô hình lớn khi mốc trên không đủ tốt

Rất nhiều bài toán thực tế dừng ở bước 3 là đủ, với chi phí vận hành thấp hơn nhiều lần.