Ở bài trước, TF-IDF coi "ô tô" và "xe hơi" là hai cột hoàn toàn khác nhau: không có cột nào nói rằng chúng gần nghĩa. Mô hình chỉ biết hai từ đó xuất hiện ở những tin khác nhau. Word embedding giải quyết đúng chỗ này bằng cách biểu diễn mỗi từ thành một vector thực khoảng vài chục tới vài trăm chiều, sao cho các từ hay xuất hiện trong ngữ cảnh giống nhau nằm gần nhau trong không gian. Bài này huấn luyện Word2Vec (CBOW và Skip-gram) và FastText trên 7,7 triệu token tin tức tiếng Việt, xem mô hình học được gì, hỏng ở đâu, rồi đo xem word embedding có giúp bài phân loại ở bài phân loại văn bản tiếng Việt hay không.
Môi trường: máy 16 GB RAM chạy CPU, Python 3.12, gensim 4.x, NumPy, scikit-learn và pyvi (phiên bản chính xác ghi trong requirements.txt). Ý tưởng Word2Vec ra đời năm 2013 và FastText năm 2016, nên mọi kỹ thuật word embedding trong bài đều đã ổn định từ lâu. Mã nằm trong code/s7-26. Kiến thức về vector, tích vô hướng và độ dài vector ở bài NumPy cho machine learning là nền tảng cho phần cosine bên dưới.
Ý tưởng của word embedding: nghĩa của từ nằm ở những từ đi cùng nó
Giả thuyết phân bố (distributional hypothesis) nói rằng những từ xuất hiện trong ngữ cảnh giống nhau thì có nghĩa gần nhau. "Agribank" và "Vietcombank" đều đứng cạnh "lãi suất", "chi nhánh", "tín dụng", nên cặp này sẽ được đặt gần nhau dù mô hình không hề biết ngân hàng là gì. Thay vì đếm từ, Word2Vec huấn luyện một mạng nơ-ron nông (một lớp ẩn, không phi tuyến) làm một nhiệm vụ phụ: đoán từ từ ngữ cảnh hoặc đoán ngữ cảnh từ từ. Ta không dùng chính dự đoán, mà giữ lại ma trận trọng số của lớp ẩn: mỗi hàng là vector của một từ.
Bài báo gốc về word embedding Efficient Estimation of Word Representations in Vector Space (Mikolov và cộng sự, 2013) đề xuất hai kiến trúc nhẹ và báo cáo huấn luyện được trên tập 1,6 tỉ từ trong chưa đầy một ngày. Hai biến thể word embedding được dùng rộng rãi:
- CBOW (continuous bag of words) lấy trung bình vector các từ xung quanh để đoán từ ở giữa. Nhanh, làm mịn nhiễu, hợp từ phổ biến.
- Skip-gram lấy một từ để đoán từng từ xung quanh. Chậm hơn nhưng thường cho vector tốt hơn với từ hiếm.
Bài báo thứ hai Distributed Representations of Words and Phrases and their Compositionality thêm hai thủ thuật làm Word2Vec thành công thực tế. Một là negative sampling, thay softmax toàn bộ từ vựng bằng việc phân biệt cặp thật với vài cặp nhiễu (mặc định negative=5 trong gensim). Hai là subsampling từ quá phổ biến như "của", "và", giúp tăng tốc và làm biểu diễn đều hơn (mặc định sample=0.001).
Chuẩn bị ngữ liệu cho word embedding: tách từ và làm sạch
Word embedding học từ lượng chữ rất lớn, nên với word embedding, ngữ liệu quan trọng hơn thuật toán. Ta dùng lại bộ Vietnamese News Classification (Apache-2.0) đã giới thiệu ở bài trước: mỗi chuyên mục lấy tối đa 20.000 tin (tiêu đề cộng mô tả), loại đúng 5.500 tin thuộc tập đánh giá để phần phân loại cuối bài không bị lẫn, rồi tách câu, hạ chữ thường, tách từ bằng pyvi. Mỗi câu thành một dòng trong corpus_seg.txt, định dạng mà LineSentence của gensim đọc được mà không cần nạp cả tệp vào RAM. Output thật:
220000 tin, 478656 câu, 7691965 token, tách từ mất 354s
Bước tách từ là chỗ tốn thời gian nhất (354 giây, tính cả việc đọc parquet) và cũng để lại dấu vết lỗi trong kết quả, như sẽ thấy: pyvi đôi lúc ghép sai, ví dụ ra các token như thi_đấu_đá hay mu_xuất. Quyết định hạ chữ thường làm "Mỹ" và "mỹ" (từ "mỹ phẩm") dùng chung một vector; với tiếng Việt, hạ chữ thường vẫn thường đáng làm vì dữ liệu ít, nhưng bạn phải biết mình đang đánh đổi điều đó.
Huấn luyện word embedding: Word2Vec và FastText với gensim
Code dùng chung một bộ siêu tham số để so sánh công bằng: 100 chiều, window=5, min_count=5 (bỏ từ có dưới 5 lần), 5 epoch. Dùng workers=1 và seed=1 để kết quả tái lập được, vì gensim cho nhiều luồng chạy thì thứ tự cập nhật không xác định. Theo chữ ký hàm của gensim 4.x, các mặc định còn lại là negative=5, sample=0.001, alpha=0.025 và FastText dùng n-gram ký tự độ dài min_n=3 tới max_n=6.
from gensim.models import Word2Vec, FastText
from gensim.models.word2vec import LineSentence
SENT = LineSentence("corpus_seg.txt")
COMMON = dict(vector_size=100, window=5, min_count=5, epochs=5, workers=1, seed=1)
cbow = Word2Vec(SENT, sg=0, **COMMON)
skip = Word2Vec(SENT, sg=1, **COMMON)
ft = FastText(SENT, sg=1, min_n=3, max_n=6, **COMMON)
[huấn luyện] Word2Vec CBOW từ vựng= 27344 38s
[huấn luyện] Word2Vec Skip-gram từ vựng= 27344 100s
[huấn luyện] FastText Skip-gram từ vựng= 27344 301s
Chi phí huấn luyện word embedding chênh nhau rõ: Skip-gram chậm gấp khoảng 2,6 lần CBOW và FastText chậm gấp 3 lần Skip-gram, vì mỗi từ phải cập nhật thêm hàng chục vector n-gram. Cái giá còn nằm ở dung lượng: mô hình Skip-gram lưu ra 23 MB, trong khi FastText chiếm 823 MB, phần lớn là bảng băm n-gram (bucket=2000000 hàng). Ghi nhớ điều này nếu bạn định triển khai trên máy nhỏ.
Đọc kết quả word embedding: láng giềng gần nhất
Cách kiểm tra đơn giản và trực quan nhất cho một word embedding tiếng Việt là hỏi từ nào gần nhất theo cosine. Hàm most_similar của gensim trả về các từ và điểm cosine. Output thật của Skip-gram:
ngân_hàng agribank(0.77), tmcp(0.77), bidv(0.76), scb(0.76), vietinbank(0.75), nh(0.75)
bệnh_viện bv(0.90), đa_khoa(0.83), phụ_sản(0.82), ung_bướu(0.78), medic(0.78), hồi_sức(0.77)
đại_học đh(0.91), bách_khoa(0.82), cao_đẳng(0.82), sư_phạm(0.81), trường_thành_viên(0.78), trúng_tuyển(0.78)
ô_tô ôtô(0.90), xe(0.88), xe_máy(0.88), gắn_máy(0.81), xe_tải(0.80), giường_nằm(0.80)
iphone ipad(0.85), 5s(0.82), macbook(0.82), 6s(0.81), apple(0.81), galaxy(0.81)
Kết quả cho thấy word embedding bắt được cả viết tắt (bệnh viện và bv, đại học và đh), tên riêng cùng loại (các ngân hàng, các dòng điện thoại) và cả biến thể chính tả (ô_tô và ôtô, 0,90). Chú ý cái nó không làm: các "láng giềng" là những từ cùng vai trò trong câu, không nhất thiết là từ đồng nghĩa. "ô_tô" gần "xe_máy" và "xe_tải" như một họ phương tiện, và dấu hiệu "giường_nằm" cho thấy ngữ liệu tin tức đang ảnh hưởng. Cặp "ô_tô" và "xe_hơi", mà ta mong gần nghĩa nhất, chỉ đạt cosine 0,674; nguyên nhân khớp với tần suất: đếm bằng count_words.py trên ngữ liệu, "xe_hơi" chỉ xuất hiện 250 lần so với 2.518 lần của "ô_tô", nên vector của nó được học từ ít ngữ cảnh hơn nhiều.
Thử cosine trên vài cặp cho thấy word embedding phân biệt được độ liên quan: giáo_viên–học_sinh đạt 0,805, bác_sĩ–bệnh_nhân 0,727, còn các cặp khác chủ đề rất thấp (bóng_đá–ngân_hàng 0,276, bác_sĩ–cổ_phiếu 0,170). Một cặp trong thử nghiệm báo "thiếu từ": tp.hcm không có trong từ vựng vì pyvi tách thành tp, dấu chấm và hcm, và bộ lọc dấu câu của ta bỏ dấu chấm. Đây là bài học đầu tiên: chuỗi bạn gõ phải đi qua đúng bước tiền xử lý như khi huấn luyện.
CBOW so với Skip-gram trên cùng ngữ liệu
CBOW:
ngân_hàng doanh_nghiệp(0.65), scb(0.61), vietinbank(0.61), dn(0.61), bidv(0.59), kiểm_toán(0.58)
bóng_đá giao_hữu(0.58), bóng_chuyền(0.56), u20(0.56), đội_tuyển(0.56), u19(0.55), trận_đấu(0.55)
Skip-gram:
bóng_đá thi_đấu(0.72), sea_games(0.71), seagame(0.70), thi_đấu_đá(0.69), mu_xuất(0.67), mu_vs(0.66)
Với word embedding CBOW, láng giềng của "ngân_hàng" lẫn cả "doanh_nghiệp" và "kiểm_toán": các từ cùng chủ đề hơn cùng loại. Skip-gram cho toàn tên ngân hàng hơn. Tôi không rút ra kết luận mạnh từ mẫu nhỏ này, và điểm cosine của hai mô hình không so trực tiếp được với nhau vì thang phân bố khác nhau. Tham số window cũng có tác động: với Skip-gram, cửa sổ 2 cho toàn tên ngân hàng (nh, scb, agribank), còn cửa sổ 10 bắt đầu thêm từ cùng chủ đề như tín_dụng (0,79). Quy tắc kinh nghiệm: cửa sổ nhỏ nghiêng về quan hệ cú pháp, cửa sổ lớn nghiêng về chủ đề.
Phép loại suy vector: ấn tượng nhưng mong manh
Điểm làm word embedding nổi tiếng là phép loại suy kiểu "vua − nam + nữ ≈ nữ hoàng". Với gensim đó là most_similar(positive=[a, c], negative=[b]). Hướng dẫn của fastText trình bày ví dụ berlin germany france ra "paris" với điểm 0,896, nhưng đó là mô hình huấn luyện trên Wikipedia tiếng Anh, lớn hơn ngữ liệu của ta cả trăm lần. Kết quả của ta:
hà_nội - việt_nam + pháp -> quận(0.58), nại(0.51), huyên(0.51)
paris - pháp + đức -> tddc(0.56), phàn(0.55), phến(0.53)
tokyo - nhật_bản + hàn_quốc -> triều_tiên(0.64), seoul(0.64), bangkok(0.62)
vua - nam + nữ -> tài_ba(0.53), công_tử(0.53), lừng_lẫy(0.51)
Chỉ một trong bốn phép cho kết quả hợp lý một nửa: "seoul" nằm thứ hai, sau "triều_tiên". Ba phép còn lại ra rác, và ở hai phép đầu có lẽ do "pháp" vừa là nước Pháp vừa là "pháp luật", nên vector bị kéo về hai hướng (đây là giả thuyết, tôi không kiểm chứng riêng). Đừng đưa phép loại suy vào demo cho khách hàng nếu chưa tự thử trên dữ liệu của mình: nó chỉ đáng tin khi ngữ liệu đủ lớn và từ đủ thường gặp. Bảy triệu token là nhỏ so với các mô hình công bố, và con số này nên khiến ta khiêm tốn hơn khi đọc các biểu đồ 2D đẹp trên mạng.
FastText: xử lý từ chưa từng thấy bằng n-gram ký tự
Word embedding kiểu Word2Vec cấp cho mỗi từ một vector riêng. Từ nào không có trong từ vựng thì gặp ngay KeyError, kể cả khi chỉ là một biến thể nhỏ của từ đã biết. Enriching Word Vectors with Subword Information (Bojanowski và cộng sự, 2016) đề xuất FastText: mỗi từ là tổng các vector của n-gram ký tự, nên từ mới vẫn có vector. Thử với bốn từ không có trong từ vựng:
ngân_hàng_số Skip-gram: KeyError | FastText: ngân_hàng(0.93), ngân_hàng_tnhh(0.88), ngân_hàng_đầu_tư(0.84), ngân_hà(0.82)
bóng_đá_nữ Skip-gram: KeyError | FastText: bóng_đá(0.92), bóng_đè(0.89), bóng_chày(0.78), bóng_bàn(0.76)
xe_điện_hóa Skip-gram: KeyError | FastText: xe_tải_điện(0.79), điện_phân(0.76), cơ_điện_tử(0.76), điện_áp(0.75)
covid19 Skip-gram: KeyError | FastText: covid(0.98), 19(0.91), covivac(0.82), covax(0.76)
Word embedding FastText gán đúng "ngân_hàng_số" gần "ngân_hàng" (0,93) và "covid19" gần "covid" (0,98). Nhưng cũng thấy mặt trái: "bóng_đè" (một từ không liên quan, chỉ giống mặt chữ) xếp thứ hai cho "bóng_đá_nữ" với 0,89, và "ngân_hà" lọt vào top cho "ngân_hàng_số". Vì vector dựa trên mặt chữ, các từ giống chữ nhưng khác nghĩa sẽ bị kéo lại gần. Ở tiếng Việt, nơi từ ghép nối bằng gạch dưới và nhiều từ khác nghĩa chỉ khác dấu, hiện tượng này không hiếm. FastText vì vậy hữu ích nhất với lỗi chính tả, từ ghép mới và ngôn ngữ nhiều biến hình, và cần kiểm tra thủ công trước khi tin.
Word embedding làm đặc trưng cho phân loại: đo thật
Câu hỏi thực dụng: nếu thay TF-IDF bằng word embedding thì phân loại văn bản tốt hơn không? Cách đơn giản nhất là lấy trung bình vector các từ trong tin, rồi đưa vào mô hình tuyến tính. Dùng đúng tập 5.500 tin của bài trước với cùng phép chia 80/20 và random_state=42, các embedding đã không thấy tin nào của tập kiểm tra trong lúc học:
def mean_vec(model, tokens_list, use_oov=False):
out = []
for toks in tokens_list:
vs = [model.wv[w] for w in toks if use_oov or w in model.wv.key_to_index]
out.append(np.mean(vs, axis=0) if vs else np.zeros(model.vector_size))
return np.array(out)
trung bình Word2Vec CBOW: tỉ lệ token ngoài từ vựng = 1.34%
+ LinearSVC acc=0.7409 macroF1=0.7378
+ LogisticRegression acc=0.7264 macroF1=0.7260
trung bình Word2Vec Skip-gram: tỉ lệ token ngoài từ vựng = 1.34%
+ LinearSVC acc=0.7545 macroF1=0.7506
+ LogisticRegression acc=0.7455 macroF1=0.7436
trung bình FastText (có OOV): tỉ lệ token ngoài từ vựng = 0.00%
+ LinearSVC acc=0.7609 macroF1=0.7572
+ LogisticRegression acc=0.7509 macroF1=0.7496
TF-IDF 1-2 gram + LinearSVC (cùng chia dữ liệu) acc=0.7527 macroF1=0.7513
Kết quả word embedding cần đọc cẩn thận. Skip-gram trung bình (macro-F1 0,7506) và FastText (0,7572) ngang TF-IDF (0,7513) chứ không vượt rõ. Ở bài trước, độ lệch chuẩn giữa các fold của cross-validation là khoảng 0,015, lớn hơn mọi chênh lệch ở đây, nên không thể nói embedding "thắng". Chỉ có hai nhận xét đủ chắc: CBOW kém hơn Skip-gram khoảng 1,3 điểm trong thí nghiệm này, và việc lấy trung bình vector đã vứt bỏ thứ tự từ mà vẫn giữ được chất lượng ngang một pipeline TF-IDF có bigram.
Vậy word embedding có ích ở đâu, nếu không thắng TF-IDF? Khi dữ liệu có nhãn rất ít nhưng có nhiều chữ chưa gắn nhãn, ta huấn luyện embedding trên toàn bộ chữ thô rồi học bộ phân loại trên vài trăm mẫu. Ở thí nghiệm này, ta có 4.400 mẫu nhãn, nên TF-IDF chưa thua. Vector 100 chiều cũng dày đặc (dense) và gọn: một tin là 100 số, so với vector thưa hơn 20 nghìn chiều của TF-IDF, thuận tiện cho tìm kiếm gần đúng và gom cụm. Tôi chưa chạy thí nghiệm ít nhãn trong bài này nên không có số để kết luận; đây là hướng đáng thử.
Khi nào không nên dùng word embedding tĩnh
- Một từ, một vector: với word embedding tĩnh, "bank" bờ sông và "bank" ngân hàng, hay "pháp" ở trên, dùng chung một vector. Đa nghĩa là giới hạn cố hữu; mô hình ngữ cảnh như Transformer, chủ đề hai bài sau, sinh vector khác nhau theo câu.
- Ngữ liệu nhỏ hoặc lệch miền: word embedding chỉ phản ánh ngữ liệu; huấn luyện trên tin tức thì "giường_nằm" thành láng giềng của "ô_tô". Với miền chuyên biệt như y tế hoặc pháp lý, cần ngữ liệu cùng miền.
- Phủ định và thứ tự từ: trung bình vector không phân biệt "tốt, không tệ" với "tệ, không tốt".
- Thiên lệch trong dữ liệu: word embedding học cả định kiến của ngữ liệu; khi dùng cho xếp hạng hồ sơ hay chấm điểm người, phải kiểm tra thiên lệch.
Lỗi thường gặp khi dùng word embedding
- Tiền xử lý lúc dùng khác lúc huấn luyện: hạ chữ thường, tách từ, bỏ dấu câu phải giống hệt, nếu không từ sẽ nằm ngoài từ vựng (như
tp.hcmở trên). - Đánh giá bằng vài ví dụ đẹp: chọn ví dụ thành công rồi kết luận mô hình tốt. Hãy liệt kê cả ví dụ hỏng, như bốn phép loại suy ở trên.
- Chạy nhiều luồng mà mong kết quả giống nhau: với
workerslớn hơn 1, hai lần chạy cho vector khác nhau. Ghi rõ nếu cần tái lập, và chạy một luồng như bài này. - Quên
min_count: từ xuất hiện 1–2 lần có vector gần như ngẫu nhiên; tăngmin_counthoặc dùng FastText. - Nạp mô hình FastText nặng cả khi không cần: bảng n-gram chiếm 823 MB. Nếu không cần từ lạ, dùng Word2Vec 23 MB.
- Bỏ qua cảnh báo trong log: trong lần chạy này, gensim 4.x trên Python 3.12 in 129 dòng (chưa tính các dòng bị gộp trùng, thực tế còn nhiều hơn)
Exception ignored in: 'gensim.models.word2vec_inner.our_dot_float'ra stderr (lưu ởstderr.txt). Tập lệnhcheck_dot.pyhuấn luyện lại một mô hình nhỏ (20.000 câu đầu, 2 epoch) và xác nhận mọi vector hữu hạn, cosine do gensim trả về khớp phép tính tay bằng NumPy (cặp ngân_hàng và bidv: 0,8810 so với 0,8810; output trongcheck_dot_output.txt, lần chạy này không có dòng cảnh báo nào), nhưng chưa tìm ra nguyên nhân gốc; nếu đưa vào sản xuất, hãy ghim phiên bản và tự kiểm tra tương tự.
Checklist word embedding
- Với mọi dự án word embedding, tách từ và chuẩn hoá thống nhất giữa huấn luyện và sử dụng; lưu cả đoạn mã tiền xử lý.
- Bắt đầu bằng Skip-gram cho ngữ liệu vừa, CBOW khi cần nhanh, FastText khi có nhiều từ lạ hoặc lỗi chính tả.
- Đặt
min_count,window,vector_sizecó chủ đích; ghiseedvàworkers=1nếu cần tái lập. - Kiểm tra word embedding bằng láng giềng gần nhất và cosine trên cặp từ của chính miền bạn, kể cả cặp hỏng.
- So với baseline TF-IDF bằng cùng phép chia và nhớ sai số giữa các lần chạy.
- Cân nhắc dung lượng mô hình (23 MB so với 823 MB) trước khi triển khai.
Mã nguồn word embedding gồm prepare_corpus.py, embed_demo.py, downstream.py cùng count_words.py, check_dot.py kèm output.txt, count_output.txt, check_dot_output.txt, downstream_output.txt, stderr.txt và requirements.txt. Các tệp mô hình (*.model, *.npy, tổng gần 900 MB) và corpus_seg.txt không đưa kèm; chạy lại prepare_corpus.py rồi embed_demo.py để tạo lại. Mọi số liệu word embedding là output thật trên máy 16 GB, một luồng, 5 epoch; ngữ liệu là tin tức công khai, không chứa dữ liệu cá nhân của khách hàng.
Bài tiếp theo
Transformer và cơ chế attention: đọc hiểu kiến trúc qua code. Word embedding tĩnh gán một vector cho mỗi từ bất kể ngữ cảnh. Bài sau xây cơ chế attention bằng code để mỗi từ có vector phụ thuộc cả câu, nền tảng của PhoBERT và các mô hình ngôn ngữ lớn.



