Lập trình viên khi chuyển sang lĩnh vực xử lý ngôn ngữ tự nhiên (NLP) thường đối mặt với một câu hỏi hóc búa: Làm thế nào để máy tính hiểu được ý nghĩa của một từ ngữ? Máy tính chỉ xử lý các con số, ma trận và phép toán đại số tuyến tính, chứ không thể đọc hiểu trực tiếp chữ cái hay câu văn.
Bài học này sẽ hướng dẫn bạn quy trình số hóa văn bản một cách khoa học: từ việc bẻ nhỏ câu văn thành các mã token (Tokenization), vượt qua cạm bẫy từ ngoài từ điển (OOV), cho đến cơ chế biểu diễn ngữ nghĩa bằng không gian vector đa chiều (Word Embeddings). Chúng ta cũng sẽ tìm hiểu toán học của Cosine Similarity và thực hành xây dựng một ứng dụng tìm kiếm từ đồng nghĩa, giải bài toán tương đồng ngữ nghĩa bằng PyTorch.
pip install torch. Dự án cuối bài không tải dữ liệu, không cần
mạng. Kiến thức cần có: Bài 2 — tích vô hướng và Cosine Similarity. Mục 8.3 dùng lại đúng công thức đó, chỉ đổi từ so hai vector 3 chiều sang so hai vector 300 chiều. Nếu bạn còn lạ với "vector là mũi tên trong không gian", hãy đọc lại mục 2.1 trước.
8.1 Từ Chữ viết sang Con số: Tokenization
Trước khi nạp văn bản vào một mô hình học máy hay mạng nơ-ron, văn bản thô phải trải qua bước tiền xử lý bắt buộc gọi là Tokenization (Phân tách từ). Đây là quá trình chẻ một chuỗi ký tự dài thành các đơn vị thông tin nhỏ hơn, gọi là các tokens (có thể là ký tự đơn lẻ, các âm tiết, từ hoặc cụm từ).
Có ba hướng thiết kế Tokenizer chính:
-
Ký tự (Character-level Tokenization): Chia văn bản đến từng chữ cái đơn lẻ (a, b,
c...).
Ưu điểm: Từ điển cực kỳ nhỏ gọn (chỉ vài trăm ký tự), hoàn toàn không sợ gặp từ lạ.
Nhược điểm: Máy tính phải xử lý chuỗi cực kỳ dài, và nơ-ron rất khó học được ngữ nghĩa tổng quan của từ vì thông tin bị bẻ vụn ở cấp độ chữ cái. -
Từ thô (Word-level Tokenization): Chia văn bản dựa trên khoảng trắng để tách thành các
từ hoàn chỉnh.
Ưu điểm: Bảo toàn ngữ nghĩa nguyên bản của từ tốt.
Nhược điểm: Bùng nổ kích thước từ điển (lên tới hàng triệu từ). Đặc biệt, khi gặp một từ mới chưa từng xuất hiện trong quá trình huấn luyện, mô hình sẽ gặp lỗi Out-of-Vocabulary (OOV) và bắt buộc phải chuyển từ đó thành mã rác đại diện[UNK](Unknown), làm mất hoàn toàn thông tin của câu. -
Cấp độ mảnh từ (Subword-level Tokenization): Đây là tiêu chuẩn vàng của các mô hình
ngôn ngữ lớn (LLM) ngày nay như GPT hay BERT, sử dụng các thuật toán như
BPE (Byte Pair Encoding) hoặc WordPiece.
Cơ chế: Chia các từ phổ biến thành các từ nguyên bản, nhưng chẻ nhỏ các từ lạ hoặc từ ghép thành các mảnh tiền tố/hậu tố có nghĩa (ví dụ: từ "embeddings" có thể được chẻ thành "embed" và "##dings"). Điều này giúp từ điển duy trì ở kích thước tối ưu (khoảng 30.000 - 50.000 tokens) nhưng vẫn biểu diễn được bất kỳ từ mới nào bằng cách lắp ghép các mảnh nhỏ lại, loại bỏ triệt để lỗi OOV.
</w>, thuật toán chạy như sau (đã kiểm chứng bằng code
Python thực tế):
-
Bước 1: cặp
(w, e)xuất hiện nhiều nhất (2 lần trong "lowest" + 6 lần trong "newest" = 8 lần) → gộp thànhwe. -
Bước 2: cặp
(we, s)xuất hiện 8 lần → gộp thànhwes. -
Bước 3: cặp
(wes, t)xuất hiện 8 lần → gộp thànhwest. -
Bước 4: cặp
(west, </w>)xuất hiện 8 lần → gộp thànhwest</w>, tạo ra một token mảnh từ hoàn chỉnh dùng chung cho cả "lowest" và "newest".
Một chi tiết quan trọng nếu bạn tự cài BPE: ở bước 1 có tới bốn cặp đồng hạng, đều xuất hiện 8 lần —
(w,e), (e,s),
(s,t) và (t,</w>). Ví dụ trên chọn (w,e), nhưng đó là một
lựa chọn phá hoà tuỳ ý, không phải kết quả duy nhất đúng. Cài BPE với thứ tự phá hoà khác sẽ
cho chuỗi gộp khác và bộ mảnh từ khác — mà vẫn hoàn toàn hợp lệ. Biết điều này để bạn không tưởng mình
cài sai khi kết quả không khớp bài viết.
8.2 Bản đồ ngữ nghĩa: Khái niệm Word Embeddings
Sau khi Tokenizer biến câu văn thành một danh sách các ID số nguyên: $$\text{"Tôi yêu AI"} \to [102, 540, 891]$$ Làm thế nào để truyền tải thông tin này vào mạng nơ-ron?
Cách tiếp cận đơn giản nhất là One-Hot Encoding: Tạo ra một vector có độ dài bằng toàn bộ từ điển $V$, đặt số 1 tại vị trí ID của từ và số 0 tại tất cả các vị trí còn lại. Tuy nhiên, cách này có hai điểm yếu chí mạng:
- Ma trận thưa thớt cực kỳ lãng phí RAM: Nếu từ điển có $50.000$ từ, mỗi từ đơn lẻ sẽ là một vector $50.000$ chiều chứa toàn số 0.
- Mất mát hoàn toàn tính liên kết ngữ nghĩa: Hai vector One-Hot bất kỳ $v_a$ và $v_b$ luôn vuông góc với nhau trong không gian vector. Do đó tích vô hướng của chúng bằng 0: $$v_a \cdot v_b = 0$$ Điều này đồng nghĩa với việc máy tính coi từ "mèo" và "chó" hoàn toàn độc lập, xa lạ y hệt như từ "mèo" và "chiếc bàn".
Trong PyTorch, lớp
nn.Embedding(num_embeddings, embedding_dim) thực chất là một bảng tra
cứu (Lookup Table) khổng lồ chứa một ma trận trọng số khả nạp (trainable weight matrix) kích thước $V
\times d$. Khi ta nạp một Token ID là số nguyên $k$, lớp này không thực hiện phép nhân ma trận phức tạp nào cả, mà chỉ đơn giản truy xuất trực tiếp và trả về dòng thứ $k$ của ma trận trọng số. Phép toán này cực kỳ nhanh ($\mathcal{O}(1)$) và tương đương về mặt toán học với việc nhân một vector One-Hot với ma trận trọng số của lớp nhúng: $$\text{Embedding}(k) = \text{OneHot}(k) \times W_{\text{embed}}$$
Nhờ việc huấn luyện đồng thời trên lượng văn bản khổng lồ, ma trận nhúng tự động dịch chuyển tọa độ của các từ sao cho: Các từ xuất hiện trong ngữ cảnh tương đồng sẽ nằm gần nhau trong không gian đa chiều. Đây chính là tiền đề tạo nên khả năng tính toán tương tự ngữ nghĩa kinh điển: $$\text{Embedding("Vua")} - \text{Embedding("Nam")} + \text{Embedding("Nữ")} \approx \text{Embedding("Hoàng hậu")}$$
8.3 Đo lường khoảng cách ngữ nghĩa: Cosine Similarity
Khi các từ đã được biểu diễn thành các vector trong không gian đa chiều, làm thế nào để đo lường mức độ tương đồng ngữ nghĩa giữa chúng?
Chúng ta không thể dùng khoảng cách hình học thông thường (Khoảng cách Euclid - L2 Distance) để so sánh trực tiếp, bởi vì khoảng cách Euclid nhạy cảm với độ dài (norm) của vector. Trong các tác vụ so sánh câu văn, một câu dài có thể chứa các vector tổng hợp có độ dài cực lớn, làm khoảng cách Euclid lệch đi rất nhiều so với câu ngắn mặc dù chúng có cùng chung chủ đề ngữ nghĩa.
Thay vào đó, chúng ta đo góc giữa hai vector bằng công thức toán học Cosine Similarity (Độ tương đồng Cosine):
- Giá trị kết quả nằm trong đoạn $[-1, 1]$.
- Nếu kết quả bằng $1$: Hai vector hoàn toàn cùng hướng (góc $0^\circ$), chỉ ra sự tương đồng tuyệt đối.
- Nếu kết quả bằng $0$: Hai vector vuông góc ($90^\circ$), không có mối liên hệ ngữ nghĩa nào.
- Nếu kết quả bằng $-1$: Hai vector ngược hướng hoàn toàn ($180^\circ$).
Ví dụ: Hai từ trái nghĩa hoàn toàn như "nóng" và "lạnh" thường có điểm Cosine cực kỳ cao vì chúng cùng xuất hiện trong các câu nói về thời tiết, nhiệt độ. Hãy lưu ý điều này khi thiết kế các bộ lọc từ khóa đồng nghĩa thực tế.
8.4 Dự án thực hành bài 8: Không gian ngữ nghĩa dựng bằng tay
Dự án của bài này nhận vào một từ khoá, đổi thành vector nhúng, rồi tính Cosine Similarity với toàn bộ từ vựng để lọc ra những từ gần nghĩa nhất — kèm cả phép toán đại số từ vựng Vua − Nam + Nữ.
Vì vậy đây KHÔNG phải Word2Vec. Word2Vec là một thuật toán huấn luyện: nó đọc hàng tỉ câu và tự tìm ra các con số ấy, không ai nói cho nó chiều nào nghĩa gì. Script này bỏ qua toàn bộ phần huấn luyện và chỉ dùng phần sau đó — tra cứu và đo góc.
Vì sao vẫn nên làm theo cách này: với vector thật 300 chiều học từ ngữ liệu, không ai đọc được chiều thứ 174 nghĩa là gì — nó là hộp đen. Ở đây bạn thấy được từng chiều, nên khi phép Vua − Nam + Nữ ra "hoàng hậu" bạn hiểu vì sao nó ra, chứ không chỉ thấy nó ra. Cái giá phải trả là các con số bị "sạch" một cách nhân tạo, và mục đọc kết quả bên dưới sẽ chỉ rõ chỗ nào sạch quá mức.
Huấn luyện embedding thật cần một vòng lặp như Bài 6 trên hàng triệu cặp từ-ngữ cảnh; Bài 10 và Bài 14 sẽ dùng embedding đã được huấn luyện sẵn từ mô hình thật.
# word_similarity.py
# Lesson 8: Text processing & word embeddings
# Practical AI Engineer series
#
# Run it with: python word_similarity.py
# Requires: pip install torch
#
# IMPORTANT — THE VECTORS BELOW ARE HAND-WRITTEN, NOT TRAINED.
# This is not Word2Vec. Word2Vec is a training algorithm that reads billions of
# sentences and discovers these numbers on its own. Here the 9 vectors are typed
# out by hand, with each of the 4 dimensions given a meaning by a human, so that
# you can SEE why the geometry works. A real 300-dimensional trained embedding is
# opaque: nobody can say what dimension 174 means.
#
# The cost of that clarity is that the numbers come out artificially clean — see
# the note on the 1.0000 score at the bottom of this file.
import torch
import torch.nn as nn
vocab = {
'king': 0,
'queen': 1,
'man': 2,
'woman': 3,
'computer': 4,
'programming': 5,
'artificial_intelligence': 6,
'coffee': 7,
'tea': 8,
}
inverse_vocab = {v: k for k, v in vocab.items()}
# Dimension 0: royalty · 1: gender (positive male, negative female)
# Dimension 2: technology · 3: drinks
embedding_weights = torch.tensor(
[
[1.0, 0.9, 0.0, 0.0], # king
[1.0, -0.9, 0.0, 0.0], # queen
[0.0, 1.0, 0.0, 0.0], # man
[0.0, -1.0, 0.0, 0.0], # woman
[0.0, 0.0, 1.0, 0.0], # computer
[0.0, 0.0, 0.9, 0.0], # programming
[0.0, 0.1, 1.0, 0.0], # artificial_intelligence
[0.0, 0.0, 0.0, 1.0], # coffee
[0.0, 0.0, 0.0, 0.9], # tea
],
dtype=torch.float32,
)
vocab_size, embedding_dim = embedding_weights.shape
embed = nn.Embedding(num_embeddings=vocab_size, embedding_dim=embedding_dim)
# Fixed weights, never trained further.
embed.weight = nn.Parameter(embedding_weights, requires_grad=False)
def cosine_similarity(vector_a, matrix_b):
"""Cosine of the angle between one vector (1,d) and every row of (V,d)."""
dot_product = torch.sum(vector_a * matrix_b, dim=1)
norm_a = torch.norm(vector_a, p=2, dim=1)
norm_b = torch.norm(matrix_b, p=2, dim=1)
# +1e-8 guards against dividing by zero for an all-zero vector.
return dot_product / (norm_a * norm_b + 1e-8)
def find_most_similar(target_word, top_n=3):
if target_word not in vocab:
print(f"'{target_word}' is not in the vocabulary.")
return
target_vector = embed(torch.tensor([vocab[target_word]]))
scores = cosine_similarity(target_vector, embed.weight)
top_scores, top_indices = torch.topk(scores, k=len(vocab))
print(f"--- closest words to '{target_word}':")
shown = 0
for score, idx in zip(top_scores.tolist(), top_indices.tolist()):
word = inverse_vocab[idx]
if word == target_word:
continue # a word is always its own closest match; skip it
print(f' {shown + 1}. {word:<24} cosine {score:.4f}')
shown += 1
if shown >= top_n:
break
print()
def analogy(a, b, c, top_n=2):
"""Solve 'a is to b as c is to ?' — the classic king - man + woman."""
vec = embed(torch.tensor([vocab[a]])) - embed(torch.tensor([vocab[b]])) + embed(torch.tensor([vocab[c]]))
scores = cosine_similarity(vec, embed.weight)
print(f'=== word analogy: {a} - {b} + {c}')
shown = 0
for score, idx in zip(*[t.tolist() for t in torch.topk(scores, k=len(vocab))]):
word = inverse_vocab[idx]
# Exclude the three input words. With hand-made vectors this barely
# matters, but with REAL trained embeddings the input word almost always
# ranks first, and forgetting to exclude it is the classic mistake that
# makes analogy code look broken.
if word in (a, b, c):
continue
print(f' {shown + 1}. {word:<24} cosine {score:.4f}')
shown += 1
if shown >= top_n:
break
print()
if __name__ == '__main__':
find_most_similar('coffee', top_n=2)
find_most_similar('computer', top_n=2)
analogy('king', 'man', 'woman')
# Why 'tea' scores exactly 1.0000 against 'coffee': their vectors are
# [0,0,0,1] and [0,0,0,0.9] — exactly parallel, differing only in length,
# and cosine ignores length. A real trained embedding never gives exactly
# 1.0 for two different words. This is the artificial cleanliness that comes
# with hand-writing the numbers.
print('note: coffee and tea are exactly parallel by construction, hence 1.0000.')
Kết quả chạy thực tế xác nhận đúng công thức đại số ngữ nghĩa đã nêu ở mục 8.2: Vua − Nam + Nữ ≈ Hoàng hậu, với điểm Cosine đạt $0.9950$ — gần tuyệt đối. Đáng chú ý, "cà_phê" và "trà" đạt điểm Cosine chính xác bằng $1.0000$ dù không phải từ đồng nghĩa hoàn toàn, vì trong ma trận nhúng giả lập này hai từ chỉ khác nhau ở độ dài vector (0.9 so với 1.0) chứ không khác hướng — đây chính là hệ quả trực tiếp của cạm bẫy đã nêu ở mục 8.3: Cosine Similarity chỉ đo góc, hoàn toàn bỏ qua độ dài, nên hai vector cùng hướng nhưng khác độ lớn vẫn cho điểm tương đồng tuyệt đối.
Chạy nó ra như sau:
--- closest words to 'coffee':
1. tea cosine 1.0000
2. man cosine 0.0000
--- closest words to 'computer':
1. programming cosine 1.0000
2. artificial_intelligence cosine 0.9950
=== word analogy: king - man + woman
1. queen cosine 0.9950
2. computer cosine 0.0000
note: coffee and tea are exactly parallel by construction, hence 1.0000.
Phép loại suy chạy đúng: king − man + woman cho ra queen ở 0,995. Nhưng ba con số còn lại mới là phần đáng học, vì chúng cho thấy giới hạn của cách dựng bằng tay.
[0,0,0,1] và [0,0,0,0.9] — song song tuyệt đối, chỉ khác độ
dài, và Cosine bỏ qua độ dài. Với embedding học từ ngữ liệu thật, hai từ khác nhau
không bao giờ cho đúng 1,0. Thấy 1,0 trong dự án thật thì gần như chắc chắn bạn đang so
một từ với chính nó. 2. Điểm 0,0000 giữa "coffee" và "man" là One-Hot quay lại. Hai vector vuông góc vì các chiều được gán tách biệt hoàn toàn — đúng cái nhược điểm của One-Hot mà mục 8.2 vừa phê phán. Embedding thật không bao giờ cho 0 tuyệt đối, vì mọi từ đều chia sẻ ít nhiều ngữ cảnh.
3. Việc loại các từ đầu vào khỏi kết quả loại suy là bắt buộc. Trong code,
analogy() bỏ qua cả king, man và woman. Ở đây bỏ hay
không gần như không đổi kết quả (king chỉ đạt 0,005), nhưng với embedding thật
từ đầu vào gần như luôn xếp nhất — và quên loại nó là lỗi kinh điển làm code loại suy trông như
bị hỏng. Nói gọn: dựng bằng tay giúp bạn thấy hình học, nhưng đừng lấy các con số này làm chuẩn kỳ vọng cho dữ liệu thật.
Cách chạy dự án này trên máy bạn
- Cài:
pip install torch. Không cần tải dữ liệu. - Tải
word_similarity.pyở cuối bài, hoặc gõ lại đoạn code trên. -
Chạy:
python3 word_similarity.py. Không có yếu tố ngẫu nhiên nên kết quả luôn giống hệt. -
Rồi thử ba việc, mỗi việc dạy một điều:
-
Thêm một từ mới vào
vocabvà một dòng vector tương ứng — ví dụ'prince': 9với[1.0, 0.9, 0.0, 0.0]. Bạn sẽ thấy nó đạt 1,0 với "king", vì bạn vừa gõ đúng cùng một hướng. Đó là cách cảm nhận trực tiếp rằng "ý nghĩa" ở đây do bạn quyết định. -
Đổi chiều giới tính của "queen" từ
-0.9thành+0.9rồi chạy lại. Phép loại suy sẽ không còn ra "queen" — chứng minh rằng kết quả đến từ hình học, không từ cái tên. -
Bỏ dòng
if word in (a, b, c): continuetronganalogy(). Ở đây kết quả gần như không đổi, nhưng hãy nhớ hành vi này khi bạn dùng embedding thật ở Bài 14.
-
Thêm một từ mới vào
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: ba kiểu Tokenizer và lý do LLM hiện đại chọn mảnh từ — cùng việc BPE có thể cho nhiều bộ mảnh từ khác nhau đều hợp lệ, tuỳ cách phá hoà.
-
Đạt được: vì sao One-Hot thất bại (mọi từ vuông góc nhau), và
nn.Embeddingthực chất chỉ là một bảng tra cứu $\mathcal{O}(1)$. - Đạt được: Cosine Similarity, và lý do nó đo góc chứ không đo khoảng cách — độ dài vector không nói gì về ngữ nghĩa.
- Đạt được: phân biệt dùng embedding với huấn luyện embedding. Dự án của bài này làm việc thứ nhất; Word2Vec là việc thứ hai, và bài này không làm.
- Đạt được: đọc được điểm Cosine một cách phê phán — 1,0 tuyệt đối và 0,0 tuyệt đối đều là dấu hiệu của dữ liệu nhân tạo, không phải của ngữ nghĩa tốt.
Cầu nối bài tiếp theo: Các vector từ đơn lẻ chưa thể hiện được thứ tự của câu văn. Để xử lý các chuỗi ngôn ngữ dài theo thời gian, chúng ta cần cơ chế mạng tuần hoàn RNN và cơ chế Attention ở Bài số 9.
Tải file code thực hành minh họa bài học
File Python word_similarity.py — mã nguồn khởi tạo ma trận nhúng, định nghĩa từ điển tiếng
Việt giả lập và tính toán độ tương đồng Cosine (chạy python word_similarity.py, yêu cầu cài
đặt thư viện torch):
📖 Tài liệu tham khảo
- CS224n: Natural Language Processing with Deep Learning — Tài liệu giảng dạy chuyên sâu về Word Embeddings và mô hình ngôn ngữ (Stanford University)
- PyTorch nn.Embedding Documentation — Tài liệu kỹ thuật chi tiết cấu hình tham số lớp nhúng vector (Official Docs)
- Cosine Similarity pairwise metrics — Hướng dẫn thuật toán đo khoảng cách tương đồng (Scikit-Learn Docs)
Bình luận