Bỏ qua để đến nội dung

Word2Vec (CBOW, Skip-gram), GloVe, FastText

Sau bài này, bạn sẽ:

  • Hiểu ý tưởng của Word2Vec: học embedding dày bằng một bài toán dự đoán giả, với hai kiến trúc skip-gram và CBOW (continuous bag-of-words).
  • Tự dẫn được hàm mất mát và gradient của negative sampling (skip-gram with negative sampling, SGNS), và giải thích vì sao nó rẻ hơn softmax trên cả từ vựng.
  • Đọc được hàm mất mát của GloVe và cách FastText ghép vectơ từ n-gram ký tự (character n-gram), kể cả cho từ chưa gặp (out-of-vocabulary).
  • Nhận ra giới hạn của các embedding này: phép tương tự (analogy) vectơ không thần kỳ như quảng cáo, embedding mang theo thiên lệch (bias) của dữ liệu, và mỗi từ chỉ có một vectơ dù có nhiều nghĩa.

Ở bài Vector Semantics và Embeddings, bạn đã gặp giả thuyết phân bố (distributional hypothesis): từ có nghĩa gần nhau thì xuất hiện trong ngữ cảnh giống nhau. Cách làm ở đó là đếm: lập ma trận từ–ngữ cảnh, đổi sang PPMI, rồi nén bằng SVD.

Word2Vec (Mikolov và cộng sự, 2013) đi đường khác: đoán. Hãy đọc câu sau, có một chỗ trống:

Sáng nay tôi ăn một bát ____ nóng hổi.

Bạn sẽ đoán “phở”, “bún”, “cháo”, có lẽ “cơm”. Bạn sẽ không đoán “mèo” hay “trà”. Muốn đoán đúng, bạn phải biết phở, bún, cháo là cùng một loại thứ: món ăn đựng trong bát, ăn nóng. Một mô hình tập đoán như vậy trên hàng tỉ câu sẽ buộc phải xếp phở, bún, cháo vào cùng một vùng của không gian vectơ, dù không ai dạy nó “phở là món ăn”.

Điểm lạ của Word2Vec: ta không cần mô hình đoán từ giỏi. Việc đoán chỉ là cái cớ. Thứ ta giữ lại sau huấn luyện là bảng vectơ mà mô hình dùng để đoán. Hai cách đặt câu hỏi:

  • CBOW: biết các từ xung quanh (“một”, “bát”, “nóng”, “hổi”), đoán từ ở giữa (“phở”).
  • Skip-gram: biết từ ở giữa (“phở”), đoán từng từ xung quanh.

Dữ liệu huấn luyện là văn bản thô, không cần ai gán nhãn: mỗi câu tự tạo ra các cặp (từ, ngữ cảnh) để đoán. Người ta gọi kiểu học này là tự giám sát (self-supervised).

Bài toán của bài này là: từ văn bản thô, học cho mỗi từ một vectơ dd chiều sao cho các từ hay đi cùng ngữ cảnh giống nhau có vectơ gần nhau, và làm việc đó đủ rẻ để chạy trên hàng tỉ từ.

Đi dọc văn bản. Ở mỗi vị trí, gọi từ ở đó là từ đích ww. Các từ cách nó không quá ss vị trí là từ ngữ cảnh cc (cửa sổ ±s\pm s, ví dụ s=2s = 2). Với câu “tôi ăn một bát phở nóng hổi”, từ đích “phở” cho bốn cặp: (phở, một), (phở, bát), (phở, nóng), (phở, hổi).

Gọi VV là từ vựng, có ∣V∣|V| từ. Mỗi từ có hai vectơ dd chiều:

  • w\mathbf{w} khi nó đóng vai từ đích, xếp thành hàng của ma trận W∈R∣V∣×d\mathbf{W} \in \mathbb{R}^{|V| \times d};
  • c\mathbf{c} khi nó đóng vai từ ngữ cảnh, xếp thành hàng của C∈R∣V∣×d\mathbf{C} \in \mathbb{R}^{|V| \times d}.

Lưu ý ký hiệu: trong bài này w\mathbf{w} là vectơ của từ đích, không phải vectơ trọng số chung như ở các bài ML. Vì sao lại cần hai vectơ? Một từ hiếm khi đứng ngay cạnh chính nó. Nếu chỉ có một vectơ, điểm của cặp (từ, chính nó) là w⋅w=∥w∥2\mathbf{w} \cdot \mathbf{w} = \lVert\mathbf{w}\rVert^2, luôn dương và càng lớn khi vectơ càng dài, nên mô hình có xu hướng tin mỗi từ hay đứng cạnh chính nó. Hai bảng riêng gỡ được ràng buộc đó.

Skip-gram gốc cho xác suất “thấy cc quanh ww” bằng softmax trên toàn bộ từ vựng:

p(c∣w)=exp⁡(c⋅w)∑c′∈Vexp⁡(c′⋅w)p(c \mid w) = \frac{\exp(\mathbf{c} \cdot \mathbf{w})}{\sum_{c' \in V} \exp(\mathbf{c}' \cdot \mathbf{w})}

Tử số lớn khi c\mathbf{c} và w\mathbf{w} cùng hướng. Mẫu số cộng trên mọi từ c′c' để các xác suất cộng lại bằng 1. Hàm mất mát là cross-entropy: tổng của −ln⁡p(c∣w)-\ln p(c \mid w) trên mọi cặp lấy từ văn bản.

Mẫu số cần ∣V∣|V| tích vô hướng, và gradient của nó chạm tới mọi hàng của C\mathbf{C}, cho từng cặp. Mikolov và cộng sự (2013b) huấn luyện trên một tỉ từ tin tức, từ vựng 692 nghìn từ, và nói thẳng rằng chi phí tỉ lệ với ∣V∣|V|, thường từ 10510^5 tới 10710^7, nên công thức trên “không thực tế”. Một cách giảm chi phí là softmax phân cấp (hierarchical softmax), dùng cây nhị phân để chỉ tính khoảng log⁡2∣V∣\log_2 |V| nút. Cách được dùng nhiều hơn, và là trọng tâm của bài, là negative sampling.

Negative sampling: đổi sang câu hỏi có/không

Phần tiêu đề “Negative sampling: đổi sang câu hỏi có/không”

Thay câu hỏi “trong 692 nghìn từ, từ nào đứng cạnh phở?” bằng câu hỏi dễ hơn: “cặp (phở, bát) là cặp thật lấy từ văn bản, hay cặp ghép bừa?” Đó là phân loại nhị phân, nên dùng sigmoid:

P(+∣w,c)=σ(c⋅w)P(+ \mid w, c) = \sigma(\mathbf{c} \cdot \mathbf{w})

Dấu ++ nghĩa là “cặp thật”. Xác suất cặp là giả bằng 1−σ(c⋅w)=σ(−c⋅w)1 - \sigma(\mathbf{c} \cdot \mathbf{w}) = \sigma(-\mathbf{c} \cdot \mathbf{w}).

Với mỗi cặp thật (w,cpos)(w, c_{\text{pos}}), ta bốc ngẫu nhiên kk từ cneg,1,…,cneg,kc_{\text{neg},1}, \dots, c_{\text{neg},k} làm mẫu âm (negative sample), coi các cặp (w,cneg,i)(w, c_{\text{neg},i}) là giả. Hàm mất mát cho một cặp thật và kk cặp giả của nó:

L=−ln⁡σ(cpos⋅w)−∑i=1kln⁡σ(−cneg,i⋅w)\mathcal{L} = -\ln \sigma(\mathbf{c}_{\text{pos}} \cdot \mathbf{w}) - \sum_{i=1}^{k} \ln \sigma(-\mathbf{c}_{\text{neg},i} \cdot \mathbf{w})
  • Số hạng đầu nhỏ khi σ(cpos⋅w)\sigma(\mathbf{c}_{\text{pos}} \cdot \mathbf{w}) gần 1: mô hình tin cặp thật là thật.
  • Mỗi số hạng trong tổng dùng σ(−z)=1−σ(z)\sigma(-z) = 1 - \sigma(z), tức xác suất “là giả”. Nó nhỏ khi mô hình tin cặp ghép bừa là giả.
  • Mỗi cặp giờ chỉ cần k+1k + 1 tích vô hướng thay cho ∣V∣|V|. Với ∣V∣=692 000|V| = 692\,000 và k=5k = 5: 692 000/6≈115 000692\,000 / 6 \approx 115\,000 lần ít hơn (tự tính).

Bốc mẫu âm từ đâu? Mikolov và cộng sự thử nhiều phân phối và thấy tốt nhất là tần suất unigram mũ 3/4:

Pneg(c)=count(c)3/4∑c′∈Vcount(c′)3/4P_{\text{neg}}(c) = \frac{\mathrm{count}(c)^{3/4}}{\sum_{c' \in V} \mathrm{count}(c')^{3/4}}

Mũ 3/4 kéo từ phổ biến xuống, đẩy từ hiếm lên. Ví dụ, “của” gặp nhiều gấp 500 lần “phở” thì chỉ được bốc nhiều gấp 5003/4≈106500^{3/4} \approx 106 lần (tự tính). Về kk, bài báo gợi ý 5 tới 20 với dữ liệu nhỏ, và chỉ 2 tới 5 với dữ liệu lớn.

Bước 1: đạo hàm của ln⁡σ\ln \sigma. Sigmoid có đạo hàm σ′(z)=σ(z) (1−σ(z))\sigma'(z) = \sigma(z)\,(1 - \sigma(z)). Theo quy tắc dây chuyền cho ln⁡\ln:

ddzln⁡σ(z)=σ′(z)σ(z)=1−σ(z)\frac{d}{dz} \ln \sigma(z) = \frac{\sigma'(z)}{\sigma(z)} = 1 - \sigma(z)

Với ln⁡σ(−z)\ln \sigma(-z), đạo hàm của phần trong −z-z là −1-1, nên:

ddzln⁡σ(−z)=−(1−σ(−z))=−σ(z)\frac{d}{dz} \ln \sigma(-z) = -\bigl(1 - \sigma(-z)\bigr) = -\sigma(z)

Dấu bằng cuối dùng lại 1−σ(−z)=σ(z)1 - \sigma(-z) = \sigma(z).

Bước 2: tích vô hướng theo từng vectơ. Với z=c⋅wz = \mathbf{c} \cdot \mathbf{w}, đạo hàm theo c\mathbf{c} là w\mathbf{w}, và theo w\mathbf{w} là c\mathbf{c}.

Bước 3: ghép lại. Mỗi số hạng của L\mathcal{L} có dấu trừ ở trước, nên đổi dấu kết quả Bước 1 rồi nhân với Bước 2:

∂L∂cpos=[σ(cpos⋅w)−1] w∂L∂cneg,i=σ(cneg,i⋅w) w∂L∂w=[σ(cpos⋅w)−1] cpos+∑i=1kσ(cneg,i⋅w) cneg,i\begin{aligned} \frac{\partial \mathcal{L}}{\partial \mathbf{c}_{\text{pos}}} &= \bigl[\sigma(\mathbf{c}_{\text{pos}} \cdot \mathbf{w}) - 1\bigr]\,\mathbf{w} \\ \frac{\partial \mathcal{L}}{\partial \mathbf{c}_{\text{neg},i}} &= \sigma(\mathbf{c}_{\text{neg},i} \cdot \mathbf{w})\,\mathbf{w} \\ \frac{\partial \mathcal{L}}{\partial \mathbf{w}} &= \bigl[\sigma(\mathbf{c}_{\text{pos}} \cdot \mathbf{w}) - 1\bigr]\,\mathbf{c}_{\text{pos}} + \sum_{i=1}^{k} \sigma(\mathbf{c}_{\text{neg},i} \cdot \mathbf{w})\,\mathbf{c}_{\text{neg},i} \end{aligned}

Dòng thứ ba cộng hai đóng góp, vì w\mathbf{w} có mặt trong mọi số hạng.

Bước 4: đọc ý nghĩa. Gradient descent cập nhật cpos←cpos−η ∂L/∂cpos\mathbf{c}_{\text{pos}} \leftarrow \mathbf{c}_{\text{pos}} - \eta\,\partial\mathcal{L}/\partial\mathbf{c}_{\text{pos}}. Hệ số σ(⋅)−1\sigma(\cdot) - 1 luôn âm, nên bước này cộng thêm một phần w\mathbf{w} vào cpos\mathbf{c}_{\text{pos}}: kéo ngữ cảnh thật lại gần từ đích. Ngược lại, mỗi cneg,i\mathbf{c}_{\text{neg},i} bị trừ đi một phần w\mathbf{w}: đẩy ra xa. Lực kéo hay đẩy tỉ lệ với mức mô hình đang sai: cặp thật mà σ\sigma đã gần 1 thì gần như không đổi.

CBOW: đoán từ giữa từ trung bình ngữ cảnh

Phần tiêu đề “CBOW: đoán từ giữa từ trung bình ngữ cảnh”

CBOW đảo vai. Ở vị trí tt, lấy trung bình vectơ của các từ ngữ cảnh, gọi Ct\mathcal{C}_t là tập các vị trí đó:

ht=1∣Ct∣∑j∈Ctwj\mathbf{h}_t = \frac{1}{|\mathcal{C}_t|} \sum_{j \in \mathcal{C}_t} \mathbf{w}_{j}

Ở đây wj\mathbf{w}_j là hàng của W\mathbf{W} ứng với từ tại vị trí jj: các từ xung quanh giờ là đầu vào. Rồi mô hình đoán từ ở giữa bằng σ(ct⋅ht)\sigma(\mathbf{c}_t \cdot \mathbf{h}_t), với ct\mathbf{c}_t là hàng của C\mathbf{C} ứng với từ ở giữa, cũng kèm kk mẫu âm. Gradient giống hệt phần trên, chỉ thay w\mathbf{w} bằng ht\mathbf{h}_t, rồi chia đều cho các từ ngữ cảnh.

Lấy trung bình làm mất trật tự từ, nên mới có tên “bag-of-words”. Mỗi vị trí chỉ cho một lần đoán thay vì 2s2s lần, nên CBOW nhanh hơn. Trong bảng so sánh của Mikolov và cộng sự (2013a) với vectơ 1000 chiều trên 6 tỉ từ, skip-gram đúng 66.1% câu hỏi tương tự về nghĩa, so với 57.3% của CBOW; CBOW nhỉnh hơn ở câu hỏi ngữ pháp (68.9% so với 65.1%).

Hình ở mục Minh họa đặt hai kiến trúc cạnh nhau trên cùng một câu.

  • Subsampling từ phổ biến. Cặp (phở, của) ít thông tin vì “của” đứng cạnh gần như mọi từ. Mikolov và cộng sự bỏ mỗi lần xuất hiện của từ ww với xác suất 1−τ/P(w)1 - \sqrt{\tau / P(w)}, với P(w)P(w) là tần suất tương đối của từ và ngưỡng τ≈10−5\tau \approx 10^{-5}. Từ chiếm 1% văn bản bị bỏ khoảng 96.8% số lần (tự tính). Huấn luyện nhanh hơn nhiều lần, và vectơ của từ hiếm tốt hơn.
  • Cửa sổ co giãn và bảng giữ lại. Ở mỗi từ, kích thước cửa sổ được chọn ngẫu nhiên từ 1 tới ss (Mikolov và cộng sự, 2013a), nên từ gần được dùng thường hơn từ xa. Sau huấn luyện thường giữ W\mathbf{W}, bỏ C\mathbf{C}.

Word2Vec trông khác hẳn PPMI cộng SVD ở bài trước. Levy và Goldberg (2014) chỉ ra hai cách gần nhau hơn ta tưởng. Với mẫu âm bốc theo tần suất unigram thường (không mũ 3/4) và số chiều dd đủ lớn, SGNS đạt tối ưu khi:

w⋅c=PMI(w,c)−ln⁡k\mathbf{w} \cdot \mathbf{c} = \mathrm{PMI}(w, c) - \ln k

PMI ở đây tính bằng logarit tự nhiên ln⁡\ln, vì sigmoid dùng ee; bài trước dùng log⁡2\log_2, hai cách chỉ khác một hằng số nhân. Khi mẫu âm bốc theo count3/4\mathrm{count}^{3/4} như Word2Vec thật, P(c)P(c) trong PMI được thay bằng phân phối ngữ cảnh làm trơn mũ 0.75, đúng mục làm trơn ở bài Vector Semantics và Embeddings (Levy, Goldberg và Dagan, 2015).

Tức là WC⊤\mathbf{W}\mathbf{C}^\top cố gắng tái tạo ma trận PMI dịch xuống một hằng số ln⁡k\ln k. Word2Vec ngầm phân tích một ma trận đồng xuất hiện, như SVD làm tường minh. Khác biệt nằm ở trọng số: SGNS chú ý cặp gặp nhiều hơn cặp hiếm, và không bao giờ phải lập ra ma trận ∣V∣×∣V∣|V| \times |V|. Từ kết quả này, họ đề xuất ma trận Shifted PPMI, max⁡(PMI−ln⁡k,0)\max(\mathrm{PMI} - \ln k, 0), rồi SVD trên đó.

GloVe: hồi quy trên log số lần đồng xuất hiện

Phần tiêu đề “GloVe: hồi quy trên log số lần đồng xuất hiện”

GloVe (Pennington, Socher, Manning, 2014) đi đường ngược lại: đếm trước, học sau. Gọi XijX_{ij} là số lần từ jj nằm trong cửa sổ quanh từ ii, đếm trên cả kho văn bản. GloVe tìm vectơ sao cho tích vô hướng xấp xỉ ln⁡Xij\ln X_{ij}, bằng bình phương tối thiểu có trọng số (weighted least squares). Bài báo gọi hàm mất mát là JJ:

LGloVe=∑i,j:Xij>0f(Xij)(wi⋅cj+bi+bj′−ln⁡Xij)2\mathcal{L}_{\text{GloVe}} = \sum_{i, j : X_{ij} > 0} f(X_{ij}) \bigl(\mathbf{w}_i \cdot \mathbf{c}_j + b_i + b'_j - \ln X_{ij}\bigr)^2
  • bib_i, bj′b'_j là hai hệ số tự do riêng cho mỗi từ, hấp thụ chuyện từ ii hay từ jj vốn phổ biến.
  • Chỉ cộng trên các ô Xij>0X_{ij} > 0, vì ln⁡0\ln 0 không xác định. Bài báo viết tổng trên mọi ô nhưng đặt f(0)=0f(0) = 0, nên hai cách như nhau. Theo bài báo, 75–95% số ô bằng 0 tùy từ vựng và kho văn bản, nên tổng này thưa.
  • Hàm trọng số f(x)=(x/xmax⁡)3/4f(x) = (x / x_{\max})^{3/4} khi x<xmax⁡x < x_{\max}, và bằng 1 khi x≥xmax⁡x \ge x_{\max}; bài báo dùng xmax⁡=100x_{\max} = 100. Cặp gặp 1 lần có trọng số 0.030.03, gặp 10 lần 0.180.18, từ 100 lần trở lên là 1 (tự tính). Cặp hiếm, nhiều nhiễu, ít ảnh hưởng; cặp quá phổ biến không lấn át phần còn lại.

Vectơ cuối của GloVe là tổng hai bảng, W+C\mathbf{W} + \mathbf{C}. Ý tưởng dẫn tới GloVe là tỉ số xác suất: trong ví dụ của bài báo, “solid” gặp quanh “ice” nhiều hơn quanh “steam” khoảng 8.9 lần, còn “water” (liên quan cả hai) có tỉ số gần 1. Hiệu wice−wsteam\mathbf{w}_{\text{ice}} - \mathbf{w}_{\text{steam}} được thiết kế để mang thông tin tỉ số đó.

Word2Vec và GloVe coi mỗi từ là một ký hiệu nguyên khối. “nhà_thơ” và “nhà_báo” chung tiền tố nhưng vectơ của chúng không chia sẻ gì, và một từ chưa từng gặp thì không có vectơ. FastText (Bojanowski và cộng sự, 2017) sửa chỗ này:

  1. Thêm < ở đầu và > ở cuối từ, để phân biệt tiền tố, hậu tố với phần giữa.
  2. Lấy mọi n-gram ký tự dài 3 tới 6. Từ “nhà” thành <nh, nhà, hà>, <nhà, nhà>, <nhà>.
  3. Thêm chính từ đó như một chuỗi đặc biệt (nếu nó có trong từ vựng).
  4. Mỗi n-gram gg có một vectơ zg\mathbf{z}_g. Vectơ của từ là tổng các vectơ đó, rồi huấn luyện như SGNS.

Để không phải lưu một bảng cho hàng chục triệu n-gram, FastText băm (hash) mỗi n-gram vào một trong KK ô bằng hàm FNV-1a, với K=2×106K = 2 \times 10^6. Hai n-gram khác nhau có thể rơi chung một ô; mô hình chấp nhận chút va chạm đó.

Lợi ích: từ hiếm mượn được thông tin từ các mảnh chung với từ phổ biến, và từ chưa gặp vẫn có vectơ bằng cách cộng các n-gram của nó. Bài báo lấy ví dụ tiếng Phần Lan, nơi danh từ có 15 cách (case), và đo trên các ngôn ngữ như tiếng Đức, tiếng Séc, tiếng Nga: lợi ích rõ nhất ở những ngôn ngữ biến đổi hình thái nhiều.

Còn tiếng Việt thì sao? Thành thật mà nói, lợi ích nhỏ hơn. Tiếng Việt viết theo âm tiết cách nhau bằng dấu cách, không chia đuôi theo thì hay số. Nếu tách token theo dấu cách, mỗi token là một âm tiết ngắn (thường 1 tới 7 chữ cái), nên n-gram 3–6 ký tự gần như chỉ lặp lại âm tiết đó. Lợi ích lớn hơn khi văn bản đã tách từ, nối âm tiết bằng _ (bài Tách từ): nhà_văn, nhà_thơ, nhà_báo chung các mảnh <nhà_, đúng với cách tiếng Việt tạo từ ghép. FastText cũng giúp với lỗi gõ, tên riêng và từ mượn. Hai bẫy riêng của tiếng Việt:

  • Văn bản không dấu là một chuỗi khác hẳn. “người” và “nguoi” chỉ chung đúng một n-gram, <ng (tự đếm), nên FastText không tự nối được hai cách viết. Muốn dùng văn bản không dấu, hãy khôi phục dấu trước, hoặc học vectơ trên dữ liệu cùng kiểu.
  • Cùng một chữ có dấu có thể là nhiều chuỗi Unicode. Dạng dựng sẵn NFC và dạng tổ hợp NFD của “người” khác nhau từng ký tự, nên n-gram khác nhau. Chuẩn hóa Unicode (bài Chuẩn hóa văn bản) để cùng một chữ luôn ra cùng một chuỗi.

Bạn không phải tự huấn luyện để dùng thử: Grave và cộng sự (2018) công bố vectơ FastText cho 157 ngôn ngữ, có tiếng Việt (cc.vi.300.bin, trang tải, giấy phép CC BY-SA 3.0). Bộ vectơ học trên Common Crawl và Wikipedia, 300 chiều, kiến trúc CBOW, n-gram ký tự dài 5. Văn bản tiếng Việt được tách từ bằng UETsegmenter trước khi học, nên bộ vectơ có token kiểu nhà_văn.

Phép tương tự vectơ và giới hạn của nó

Phần tiêu đề “Phép tương tự vectơ và giới hạn của nó”

Ví dụ nổi tiếng nhất của Word2Vec: vectơ gần nhất với wking−wman+wwoman\mathbf{w}_{\text{king}} - \mathbf{w}_{\text{man}} + \mathbf{w}_{\text{woman}} là wqueen\mathbf{w}_{\text{queen}}. Nói bằng tiếng Việt: “vua” trừ “đàn ông” cộng “phụ nữ” ra “nữ hoàng”. Cách giải một câu ”aa với bb như a∗a^* với ?”:

b∗=arg max⁡x∉{a,b,a∗}cos⁡(wx, wb−wa+wa∗)b^* = \operatorname*{arg\,max}_{x \notin \{a, b, a^*\}} \cos\bigl(\mathbf{w}_x,\ \mathbf{w}_b - \mathbf{w}_a + \mathbf{w}_{a^*}\bigr)

Ý trực quan: hiệu wwoman−wman\mathbf{w}_{\text{woman}} - \mathbf{w}_{\text{man}} là một “hướng giới tính”, cộng hướng đó vào “king” thì tới “queen”. Nhưng có hai điều cần biết (và một hộp ở mục Lỗi hay gặp):

  • Phải loại các từ đầu vào. Điều kiện x∉{a,b,a∗}x \notin \{a, b, a^*\} không phải chi tiết nhỏ. Mikolov và cộng sự (2013b) ghi rõ họ loại các từ đầu vào khỏi phép tìm. Không loại thì từ gần nhất thường chính là một từ đầu vào hoặc biến thể của nó (Jurafsky và Martin, SLP3).
  • Chỉ chạy tốt với vài loại quan hệ. Thủ đô–quốc gia, dạng số nhiều, so sánh hơn thì tốt; nhiều quan hệ khác thì kém hẳn.

Embedding học từ văn bản người viết, nên học luôn định kiến trong đó. Bolukbasi và cộng sự (2016) dùng vectơ Word2Vec học trên Google News và thấy phép tương tự cho ra cặp như “man : computer programmer :: woman : homemaker” (đàn ông với lập trình viên như phụ nữ với nội trợ). Họ cho thấy thiên lệch giới tính nằm gần một hướng trong không gian, và đề xuất cách chiếu bớt hướng đó khỏi các từ trung tính về giới. Gonen và Goldberg (2019) cho thấy cách làm này chỉ che bớt: thiên lệch vẫn còn trong cách các từ xếp gần nhau. Đây vẫn là vấn đề mở (SLP3 mục 5.8).

Nếu bạn dùng embedding trong hệ thống lọc hồ sơ hay chấm điểm, thiên lệch này đi thẳng vào quyết định. Hãy đo nó trên dữ liệu của bạn trước khi tin.

Cả ba phương pháp trong bài cho embedding tĩnh (static embedding): mỗi từ đúng một vectơ, dùng cho mọi câu. “Đường” trong “một cân đường” và trong “con đường” có cùng một vectơ, một kiểu trung bình của hai nghĩa. Embedding theo ngữ cảnh (contextual embedding) cho mỗi lần xuất hiện một vectơ riêng, tính từ cả câu; bài BERT dạy cách làm đó.

  • Một vectơ cho mọi nghĩa (mục trên). Mô hình theo ngữ cảnh ở bài BERT xử lý chuyện này.
  • Từ chưa gặp: Word2Vec và GloVe không có vectơ; FastText ghép được (ô code thứ hai).
  • Thiên lệch của dữ liệu đi vào vectơ, và phép tương tự có thể khuếch đại nó.
  • Không có trật tự từ. Trong cửa sổ, từ đứng trước hay sau từ đích đều được tính như nhau. Và nếu lấy trung bình vectơ các từ làm vectơ câu, “chó cắn mèo” và “mèo cắn chó” cho đúng cùng một vectơ.

Hình dưới đặt hai kiến trúc của Word2Vec cạnh nhau trên cùng một câu. Hãy nhìn chiều của mũi tên: CBOW gom bốn từ xung quanh thành một vectơ rồi đoán từ giữa, còn skip-gram đi từ một từ giữa ra bốn lần đoán riêng.

tôiănmộtbátphởnónghổicửa sổ ±2ngoài cửa sổCBOWngữ cảnh đoán từ giữamộtbátnónghổitrung bình 4 vectơ1 lần đoánphởSkip-gramtừ giữa đoán từng ngữ cảnhphở4 lần đoán riêngmộtbátnónghổi
CBOW chấm một lần σ(c⋅h)\sigma(\mathbf{c} \cdot \mathbf{h}) với h\mathbf{h} là trung bình bốn vectơ ngữ cảnh, còn skip-gram chấm bốn lần σ(c⋅w)\sigma(\mathbf{c} \cdot \mathbf{w}), mỗi lần kèm kk mẫu âm. Cửa sổ ±2 khớp ô code của bài.

Bảng dưới so ba phương pháp theo cùng các tiêu chí.

Tiêu chíWord2Vec (SGNS)GloVeFastText
Học từTừng cặp (từ, ngữ cảnh) khi quét văn bảnBảng đếm đồng xuất hiện XijX_{ij} của cả kho văn bảnTừng cặp, như SGNS
Hàm mất mátPhân loại cặp thật / giả bằng sigmoidBình phương tối thiểu có trọng số trên ln⁡Xij\ln X_{ij}Như SGNS, nhưng vectơ từ là tổng n-gram
Đơn vị có vectơTừTừN-gram ký tự 3–6 (và từ)
Từ chưa gặpKhông có vectơKhông có vectơCộng vectơ các n-gram
Nối với PMINgầm phân tích PMI dịch xuống ln⁡k\ln kTường minh khớp ln⁡Xij\ln X_{ij}Như SGNS

Code trong bài chạy thử lần cuối tháng 11/2026, với Python 3.14 và NumPy 2.4 (bản có trong Pyodide). Cả hai ô chạy ngay trên trang và chạy độc lập: ô nào cũng tự import và tự tạo dữ liệu.

Ô dưới dựng một kho văn bản (corpus) nhỏ tự viết: 180 câu từ ba khuôn câu, mỗi khuôn cho một nhóm từ. Con vật (mèo, chó, gà, vịt) đi với loại từ “con”; món ăn (phở, bún, cháo, cơm) đi với “bát”; đồ uống (trà, sữa, bia, nước) đi với “ly”. Từ vựng có 40 từ, cửa sổ ±2\pm 2 cho 2604 cặp (từ, ngữ cảnh). Mô hình có d=10d = 10 chiều, k=3k = 3 mẫu âm bốc theo count3/4\mathrm{count}^{3/4}, học bằng gradient descent theo lô 64 cặp, η=0.03\eta = 0.03, 13 lượt (epoch). Dòng gw tính ∂L/∂w\partial\mathcal{L}/\partial\mathbf{w}. Ba dòng np.add.at trừ η\eta lần ba gradient vào C\mathbf{C} (ngữ cảnh thật), C\mathbf{C} (mẫu âm) và W\mathbf{W}. Gradient của các cặp trong lô được cộng lại, không lấy trung bình; np.add.at cộng dồn đúng khi một từ xuất hiện nhiều lần trong cùng một lô.

Skip-gram với negative sampling trên kho văn bản tiếng Việt nhỏ

# Skip-gram với negative sampling (SGNS) bằng NumPy trên một kho văn bản nhỏ
import numpy as np
rng = np.random.default_rng(0)
# Ba nhóm từ, mỗi nhóm đi với ngữ cảnh riêng (loại từ "con", "bát", "ly")
groups = {
"con {} chạy ngoài sân|nhà tôi nuôi một con {}|con {} đang ngủ":
["mèo", "chó", "gà", "vịt"],
"tôi ăn một bát {} nóng|bát {} này rất ngon|mẹ nấu {} buổi sáng":
["phở", "bún", "cháo", "cơm"],
"anh uống một ly {} lạnh|ly {} này hơi ngọt|rót {} ra ly":
["trà", "sữa", "bia", "nước"],
}
corpus = []
for tpl, words in groups.items():
for _ in range(60):
s = rng.choice(tpl.split("|")).format(rng.choice(words))
corpus.append(s.split())
vocab = sorted({w for s in corpus for w in s})
idx = {w: i for i, w in enumerate(vocab)}
V, d, k, win = len(vocab), 10, 3, 2
# Cặp (từ đích, từ ngữ cảnh) trong cửa sổ ±win
pairs = np.array([(idx[s[i]], idx[s[j]]) for s in corpus
for i in range(len(s))
for j in range(max(0, i - win), min(len(s), i + win + 1))
if j != i])
count = np.bincount(pairs[:, 1], minlength=V)
p_neg = count ** 0.75 / (count ** 0.75).sum() # unigram mũ 3/4
W = rng.normal(0, 0.1, (V, d)) # vectơ từ đích
C = rng.normal(0, 0.1, (V, d)) # vectơ ngữ cảnh
sig = lambda z: 1 / (1 + np.exp(-z))
eta, B = 0.03, 64 # tốc độ học, cỡ lô
for epoch in range(13):
order, total = rng.permutation(len(pairs)), 0.0
for b0 in range(0, len(order), B):
w, c = pairs[order[b0:b0 + B]].T # chỉ số w, c_pos
n = rng.choice(V, size=(len(w), k), p=p_neg) # k mẫu âm mỗi cặp
sp = sig((W[w] * C[c]).sum(1)) # σ(c_pos·w)
sn = sig((W[w][:, None] * C[n]).sum(2)) # σ(c_neg·w)
total -= np.log(sp).sum() + np.log(1 - sn).sum()
# Ba gradient dẫn trong bài, rồi trừ eta lần gradient
gw = (sp - 1)[:, None] * C[c] + (sn[..., None] * C[n]).sum(1)
np.add.at(C, c, -eta * (sp - 1)[:, None] * W[w])
np.add.at(C, n, -eta * sn[..., None] * W[w][:, None])
np.add.at(W, w, -eta * gw)
if epoch % 3 == 0:
print(f"epoch {epoch:2d} loss {total / len(pairs):.3f}")
E = W / np.linalg.norm(W, axis=1, keepdims=True) # chuẩn hóa để lấy cosine
print(f"cos(mèo, phở) = {E[idx['mèo']] @ E[idx['phở']]:.2f}")
for q in ["mèo", "phở", "trà"]:
sims = E @ E[idx[q]]
top = [i for i in np.argsort(-sims) if i != idx[q]][:3]
print(q, "->", ", ".join(f"{vocab[i]} {sims[i]:.2f}" for i in top))

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • Loss đi từ 2.751 xuống 1.132. Ở đầu, mọi vectơ gần 0 nên mọi tích vô hướng gần 0 và σ≈0.5\sigma \approx 0.5. Loss của một cặp thật cùng 3 mẫu âm khi đó là 4ln⁡2≈2.774 \ln 2 \approx 2.77 (tự tính), khớp số đầu tiên. Số in ra là trung bình trong lúc học ở mỗi lượt, nên lượt 0 đã thấp hơn 2.77 một chút.
  • Loss không về 0, và không nên về 0. Mẫu âm được bốc ngẫu nhiên, nên đôi khi chính là một ngữ cảnh thật. “một” đứng cạnh mọi nhóm, nên mô hình không thể chắc chắn mọi cặp chứa “một” là giả.
  • Hàng xóm gần nhất đúng nhóm: “mèo” gần vịt 0.99, gà 0.98, chó 0.97; “phở” gần cơm 0.99, cháo 0.99, bún 0.96; “trà” gần bia 0.96, nước 0.96, sữa 0.95. Không ai dạy mô hình đâu là con vật hay món ăn; nó tự suy ra từ ngữ cảnh.
  • cos(mèo, phở) = 0.28, thấp hơn hẳn trong nhóm nhưng không âm. Lý do: cả hai có “một” trong ngữ cảnh (“nuôi một con mèo”, “ăn một bát phở”).

Thử đổi seed ở dòng default_rng(0). Số cụ thể đổi, nhưng ba nhóm vẫn tách ra: ở các lần chạy thử với seed 1 và 2, mọi hàng xóm gần nhất vẫn cùng nhóm. Kho này dễ một cách cố ý; văn bản thật cần hàng triệu câu mới có kết quả tương tự. Ô cũng không làm subsampling vì kho quá nhỏ.

N-gram ký tự kiểu FastText và từ chưa gặp

Phần tiêu đề “N-gram ký tự kiểu FastText và từ chưa gặp”

Ô thứ hai làm hai việc. Thứ nhất, liệt kê n-gram 3–6 ký tự của một từ, có < và >. Thứ hai, cho thấy cơ chế ghép vectơ cho từ chưa gặp: băm mỗi n-gram bằng FNV-1a vào 10 000 ô, mỗi ô một vectơ 300 chiều, rồi cộng lại. Lưu ý quan trọng: các vectơ n-gram ở đây là số ngẫu nhiên (có seed), chưa huấn luyện gì. Ô này chỉ minh họa cách ghép, không phải embedding thật.

N-gram ký tự kiểu FastText và vectơ cho từ chưa gặp

# N-gram ký tự kiểu FastText và vectơ cho một từ chưa gặp
import numpy as np
def ngrams(word, nmin=3, nmax=6):
w = "<" + word + ">" # đánh dấu đầu và cuối từ
return [w[i:i + n] for n in range(nmin, nmax + 1)
for i in range(len(w) - n + 1)]
def fnv1a(s): # băm FNV-1a 32 bit (FastText dùng một biến thể)
h = 2166136261
for b in s.encode("utf-8"):
h = ((h ^ b) * 16777619) % 2**32
return h
print(ngrams("nhà"))
print(len(ngrams("nhà_văn")), "n-grams for nhà_văn")
# Bảng vectơ n-gram: số NGẪU NHIÊN, chỉ để xem cơ chế ghép
BUCKET, d = 10_000, 300
Z = np.random.default_rng(0).normal(size=(BUCKET, d))
def vec(word): # vectơ từ = tổng vectơ các n-gram của nó
return sum(Z[fnv1a(g) % BUCKET] for g in ngrams(word))
def cos(u, v):
return u @ v / (np.linalg.norm(u) * np.linalg.norm(v))
new = "nhà_văn" # giả sử từ này không có trong dữ liệu huấn luyện
for w in ["nhà_thơ", "nhà_báo", "văn_học", "con_mèo"]:
shared = len(set(ngrams(new)) & set(ngrams(w)))
print(f"{w:8s} shared {shared:2d} cos {cos(vec(new), vec(w)):.2f}")

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • “nhà” có 6 n-gram: <nh, nhà, hà>, <nhà, nhà>, <nhà>. Từ ngắn đến mức n-gram 5 ký tự đã trùng với cả từ có dấu biên. Đây là chuyện thường gặp với âm tiết tiếng Việt.
  • nhà_văn có 22 n-gram. Nó chung 6 n-gram với nhà_thơ và với nhà_báo (<nh, <nhà, <nhà_, hà_, nhà, nhà_), chung 1 với văn_học (văn), không chung gì với con_mèo.
  • Cosine đi theo số mảnh chung, dù vectơ là ngẫu nhiên: 0.35 với nhà_thơ, 0.27 với nhà_báo, 0.05 với văn_học, -0.00 (tức là 0) với con_mèo. Với vectơ ngẫu nhiên 300 chiều, các mảnh khác nhau gần như vuông góc, nên cosine xấp xỉ tỉ lệ mảnh chung, khoảng 6/22≈0.276/22 \approx 0.27, cộng nhiễu.

Trong FastText thật, vectơ n-gram được huấn luyện. Mảnh <nhà_ gặp trong nhà_thơ, nhà_báo, nhưng cũng trong nhà_cửa, nhà_máy, nên nó học một thứ trộn giữa các nghĩa đó. Phần còn lại của từ (_văn>, văn) kéo vectơ về đúng hướng hơn. Nhờ vậy một từ chưa gặp vẫn có một vectơ hợp lý, tốt hơn hẳn việc không có gì, dù không bằng vectơ học từ nhiều lần xuất hiện thật. Thư viện fasttext lấy trung bình thay cho tổng; cosine không đổi vì hai cách chỉ khác nhau một hệ số.

Câu 1/5 Bạn huấn luyện SGNS với k=3k = 3. Sau một bước gradient descent trên đúng một cặp thật (mèo, con) cùng 3 mẫu âm, những vectơ nào thay đổi?
Câu 2/5 Theo Levy và Goldberg (2014), khi SGNS với k=5k = 5 (mẫu âm theo unigram thường) đạt tối ưu, w⋅c=PMI(w,c)−ln⁡5\mathbf{w} \cdot \mathbf{c} = \mathrm{PMI}(w, c) - \ln 5, với PMI tính bằng ln⁡\ln. Với một cặp từ xuất hiện độc lập nhau (PMI=0\mathrm{PMI} = 0), mô hình cho xác suất "cặp thật" σ(w⋅c)\sigma(\mathbf{w} \cdot \mathbf{c}) bằng bao nhiêu?
Câu 3/5 Một mô hình Word2Vec và một mô hình FastText cùng học trên văn bản đã tách từ (nối âm tiết bằng _). Từ nhà_sử_học không có trong dữ liệu huấn luyện. Điều gì xảy ra?
Câu 4/5 Bạn lấy vectơ Word2Vec của từ "đường" trong hai câu: "mẹ mua một cân đường" và "con đường này rất đẹp". Hai vectơ đó so với nhau thế nào?
Câu 5/5 Bạn lấy vectơ của "vua", trừ vectơ của "đàn_ông", cộng vectơ của "phụ_nữ", được v\mathbf{v}. Rồi bạn tìm từ có cosine lớn nhất với v\mathbf{v} trong cả từ vựng. Kết quả là "vua". Kết luận nào hợp lý nhất?

Ý chính:

  • Word2Vec học embedding bằng một bài toán dự đoán giả trên văn bản thô. Skip-gram đoán ngữ cảnh từ từ giữa; CBOW đoán từ giữa từ trung bình ngữ cảnh. Mỗi từ có hai vectơ: w\mathbf{w} (từ đích) và c\mathbf{c} (ngữ cảnh).
  • Softmax trên cả từ vựng tốn ∣V∣|V| phép tính cho mỗi cặp. Negative sampling thay bằng phân loại cặp thật với kk cặp ghép bừa: L=−ln⁡σ(cpos⋅w)−∑iln⁡σ(−cneg,i⋅w)\mathcal{L} = -\ln\sigma(\mathbf{c}_{\text{pos}} \cdot \mathbf{w}) - \sum_i \ln\sigma(-\mathbf{c}_{\text{neg},i} \cdot \mathbf{w}), mẫu âm bốc theo count3/4\mathrm{count}^{3/4}. Gradient kéo ngữ cảnh thật lại gần, đẩy mẫu âm ra xa.
  • SGNS ngầm phân tích ma trận PMI−ln⁡k\mathrm{PMI} - \ln k; GloVe khớp tường minh ln⁡Xij\ln X_{ij} bằng bình phương tối thiểu có trọng số f(Xij)f(X_{ij}). FastText biểu diễn từ bằng tổng vectơ n-gram ký tự 3–6, nên ghép được vectơ cho từ chưa gặp; với tiếng Việt, lợi ích rõ nhất khi văn bản đã tách từ.
  • Các embedding này là tĩnh: một vectơ mỗi từ, mang theo thiên lệch của dữ liệu, và phép tương tự vectơ chỉ đúng với vài loại quan hệ. Embedding theo ngữ cảnh ở bài BERT giải quyết chuyện nhiều nghĩa.

Đọc thêm:

Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu