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

Tách từ (Tokenizer)

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

  • Phân biệt được ba cách cắt văn bản thành token: theo từ, theo ký tự và theo mảnh từ (subword), và biết vì sao mô hình hiện đại chọn mảnh từ.
  • Hiểu vì sao tiếng Việt khó: chữ viết tách theo âm tiết, còn từ có thể gồm nhiều âm tiết. Cài đặt được khớp dài nhất (maximum matching) và chỉ ra chỗ nó sai.
  • Tự chạy tay được thuật toán BPE (byte pair encoding), và nói được WordPiece, Unigram, SentencePiece, byte-level BPE khác nhau ở đâu.
  • Đếm token bằng bộ tách từ thật, giải thích vì sao cùng một ý mà tiếng Việt tốn nhiều token hơn tiếng Anh, và nhận ra các token đặc biệt (special token).

Bạn muốn đưa câu “Học sinh đang học sinh học ở trường.” vào một mô hình ngôn ngữ. Mô hình chỉ nhận một dãy số nguyên, mỗi số là vị trí của một mục trong một danh sách cố định gọi là từ vựng (vocabulary). Vậy trước hết phải trả lời: cắt câu thành những mảnh nào, và mảnh nào ứng với số nào? Việc cắt đó là tách từ (tokenization), mỗi mảnh là một token, và chương trình làm việc này là bộ tách từ (tokenizer).

Có ba cách cắt tự nhiên:

Cách cắtCâu trên thànhSố tokenCỡ từ vựng cần có
Theo từ (cắt ở dấu cách và dấu câu)Học sinh đang học sinh học ở trường .9Rất lớn: mọi từ, mọi biến thể
Theo ký tựH ọ c, dấu cách, s i …36Nhỏ: vài trăm ký tự
Theo mảnh từmảnh thường gặp giữ nguyên, mảnh hiếm cắt nhỏở giữaDo bạn chọn, thường vài chục nghìn

Theo từ thì chuỗi ngắn, nhưng từ vựng không bao giờ đủ. Gặp chữ gõ sai (“truong”), tên riêng mới, hay từ mượn, mô hình chỉ còn cách đổi nó thành một token “không biết”, thường viết [UNK]. Đó là bài toán từ ngoài từ vựng (out-of-vocabulary, OOV). Dùng regex để cắt theo từ, như ở bài Biểu thức chính quy, là bước đầu tốt, nhưng không giải quyết được chuyện này.

Theo ký tự thì không còn từ lạ, nhưng chuỗi dài gấp mấy lần, và mỗi token mang rất ít nghĩa. Bài Xây dựng LLM đầu tiên dùng token ký tự cho gọn, và ghi rõ đó là một giới hạn.

Theo mảnh từ là đường ở giữa: từ hay gặp như “trường” là một token, từ hiếm được ghép từ vài mảnh quen. Không mảnh nào là lạ, mà chuỗi vẫn ngắn.

Tiếng Anh viết dấu cách giữa các từ. Tiếng Việt viết dấu cách giữa các âm tiết (syllable), còn một từ có thể gồm nhiều âm tiết: “học sinh”, “máy tính”, “giáo viên”. Đọc lại câu đầu bài: “Học sinh | đang | học | sinh học | ở | trường”. Cùng hai âm tiết “học sinh” xuất hiện hai lần, lần đầu là một từ, lần sau thuộc về hai từ khác nhau.

Trong bài này, “tách từ” theo nghĩa rộng là tokenization. Việc gộp âm tiết thành từ tiếng Việt gọi riêng là tách từ tiếng Việt (Vietnamese word segmentation). Hai việc này khác nhau: bộ tách từ của GPT hay BERT không hề tách từ tiếng Việt, như bạn sẽ thấy ở ô code cuối.

Bài toán của bài này là: chọn một cách cắt văn bản bất kỳ thành các mảnh thuộc một từ vựng cỡ cố định, sao cho không có mảnh lạ, chuỗi không quá dài, và cách cắt học được từ dữ liệu thay vì viết tay.

Gọi từ vựng là VV, có ∣V∣|V| token. Bộ tách từ của các mô hình hiện nay thường làm bốn bước:

  1. Chuẩn hóa (normalization): đưa văn bản về một dạng thống nhất, ví dụ cùng một kiểu mã Unicode. Chi tiết ở bài Chuẩn hóa văn bản.
  2. Tách sơ bộ (pre-tokenization): cắt thô theo dấu cách và dấu câu, thường bằng regex. Các bước sau không bao giờ ghép qua ranh giới này.
  3. Mô hình tách (tokenization model): cắt mỗi mảnh thô thành các token thuộc VV, theo một thuật toán học từ dữ liệu, như BPE.
  4. Đổi sang số và thêm token đặc biệt: mỗi token thành vị trí của nó trong VV, gọi là mã token (token ID).

Chiều ngược lại, từ dãy mã về chữ, gọi là giải mã (decode). Một bộ tách từ tốt cho phép giải mã ra đúng văn bản ban đầu.

Tách từ tiếng Việt bằng khớp dài nhất

Phần tiêu đề “Tách từ tiếng Việt bằng khớp dài nhất”

Cách đơn giản nhất để gộp âm tiết thành từ là dùng một từ điển. Khớp dài nhất (maximum matching) đi từ trái sang phải. Ở mỗi vị trí, nó thử cụm dài nhất (ví dụ 3 âm tiết), rồi ngắn dần. Gặp cụm nào có trong từ điển thì lấy cụm đó làm một từ và nhảy qua. Một âm tiết đứng riêng thì luôn được nhận, để thuật toán không bị kẹt.

Với câu “giáo viên chấm bài tập trên máy tính”, cách này cho đúng: giáo viên | chấm | bài tập | trên | máy tính. Nhưng với “học sinh học sinh học” (học sinh học môn sinh học), nó lấy học sinh ngay từ đầu, rồi lại học sinh, còn thừa học. Đi từ phải sang trái cũng sai, theo kiểu khác. Lỗi này gọi là nhập nhằng chồng lấp (overlapping ambiguity): âm tiết “học” ở giữa có thể ghép với bên trái hoặc bên phải, và từ điển không nói được nên chọn bên nào. Muốn chọn đúng phải dựa vào ngữ cảnh, tức là một mô hình học từ dữ liệu đã gán nhãn.

Các công cụ thật làm đúng việc đó. VnCoreNLP (Vu và cộng sự, 2018) là bộ công cụ Java có phần tách từ học từ dữ liệu đã gán nhãn, đạt F1 97.90% trên tập kiểm tra của VLSP 2013 (Nguyen và cộng sự, 2018). Nó in từ nhiều âm tiết bằng dấu gạch dưới: học_sinh. underthesea và pyvi là hai thư viện Python làm việc tương tự. Một số mô hình tiếng Việt, như PhoBERT (Nguyen và Nguyen, 2020), đòi văn bản đầu vào đã được tách từ theo cách này.

Mô hình mảnh từ không cần từ điển của người. Nó tự tìm mảnh từ thống kê. Thuật toán phổ biến nhất là BPE, vốn là một cách nén dữ liệu, được Sennrich, Haddow và Birch (2016) đem vào dịch máy.

Dữ liệu. Sau bước tách sơ bộ, ta có một bộ từ kèm số lần xuất hiện f(w)f(w) của mỗi từ ww. Với tiếng Việt, mỗi “từ” ở đây là một âm tiết, vì tách sơ bộ cắt ở dấu cách. Bài dùng bộ 7 âm tiết sau, chọn để có những mảnh mà người Việt nhận ra ngay: phụ âm đầu “ng”, “nh”, “tr”, vần “ường”.

Âm tiếtngườingànhnghềtrườngđườngthươngnhà
Số lần f(w)f(w)8346537

Bước 0: tách thành ký tự. Mỗi âm tiết thành dãy ký tự, thêm ký hiệu _ ở cuối để đánh dấu hết âm tiết: người thành n g ư ờ i _. Nhờ _, mảnh “ng” ở cuối “trường” (ng_) khác “ng” ở đầu “người”. Bài báo gốc dùng </w> cho ký hiệu này. Từ vựng ban đầu V0V_0 là mọi ký tự có mặt, kể cả _: ở đây ∣V0∣=13|V_0| = 13.

Bước 1: đếm mọi cặp ký hiệu kề nhau. Một cặp (a,b)(a, b) được đếm theo tần số của từ chứa nó:

count⁡(a,b)=∑wf(w)⋅nab(w)\operatorname{count}(a, b) = \sum_{w} f(w) \cdot n_{ab}(w)

Ở đây nab(w)n_{ab}(w) là số lần aa đứng ngay trước bb trong cách tách hiện tại của ww. Nhân với f(w)f(w) là vì một âm tiết gặp 8 lần trong văn bản thì mỗi cặp của nó cũng gặp 8 lần. Với cặp n g: “người” góp 8, “ngành” 3, “nghề” 4, “trường” 6, “đường” 5, “thương” 3, tổng 29. Không cặp nào nhiều hơn.

Bước 2: gộp cặp nhiều nhất. Thêm ký hiệu mới ng vào từ vựng, và thay mọi chỗ n g kề nhau bằng ng. Ghi lại phép gộp này vào danh sách phép gộp (merge), theo đúng thứ tự.

Bước 3: lặp lại với cách tách mới. Bước thứ hai gộp ư ờ (8 + 6 + 5 = 19, từ “người”, “trường”, “đường”). Bước thứ ba gộp ng _ (6 + 5 + 3 = 14). Bước thứ tư gộp ườ ng_ (6 + 5 = 11): “trường” và “đường” giờ chung một mảnh ường_. Khi nhiều cặp hòa nhau, code của bài chọn cặp được gặp trước khi đọc bộ từ từ trên xuống, mỗi từ từ trái sang phải.

Sau KK bước, từ vựng có đúng

∣V∣=∣V0∣+K|V| = |V_0| + K

token, vì mỗi phép gộp thêm đúng một ký hiệu. KK là siêu tham số chính của thuật toán. Bài dừng ở K=12K = 12, nên ∣V∣=25|V| = 25. Mô hình thật chọn KK để ∣V∣|V| cỡ vài chục tới vài trăm nghìn.

Tách một từ mới. Cắt từ thành ký tự cộng _, rồi áp lại các phép gộp theo đúng thứ tự đã học. “nhường” không có trong bộ từ, nhưng thành nh + ường_: hai mảnh quen. Một từ có ký tự lạ thì ký tự đó mới là token lạ. Còn mọi tổ hợp của các ký tự đã biết đều tách được.

Có một điều đáng để ý: “thương” vẫn rời thành t h ư ơ ng_ sau 12 bước. Chữ “ơ” trong “thương” và “ờ” trong “trường” là hai ký tự khác nhau, chỉ vì dấu thanh. Dấu thanh nhân số tổ hợp lên, nên mỗi tổ hợp xuất hiện ít hơn và được gộp muộn hơn.

WordPiece (Schuster và Nakajima, 2012; Wu và cộng sự, 2016) cũng gộp dần, nhưng không chọn cặp đếm được nhiều nhất. Nó chọn cặp làm likelihood của dữ liệu tăng nhiều nhất. Khóa học của Hugging Face diễn đạt điều này bằng một điểm dễ tính toán: số lần gặp cặp, chia cho tích số lần gặp từng phần. Một cặp được ưu tiên khi hai phần của nó hay đi với nhau hơn là đi riêng. BERT (Devlin và cộng sự, 2019) dùng WordPiece với từ vựng 30 000 token. Mảnh nằm giữa từ có tiền tố ##, ví dụ như ##ờng. Khi tách, WordPiece của BERT lấy mảnh dài nhất có trong từ vựng ở đầu từ rồi lặp lại với phần còn lại: chính là khớp dài nhất, ở mức ký tự thay vì âm tiết. Không khớp được mảnh nào thì cả từ thành [UNK].

Unigram LM (Kudo, 2018) đi theo chiều ngược lại. Nó bắt đầu từ một từ vựng rất lớn, rồi bỏ dần các mảnh ít có ích nhất. Mô hình giả sử các mảnh độc lập, nên xác suất của một cách tách x=(x1,…,xM)\mathbf{x} = (x_1, \dots, x_M) là tích xác suất từng mảnh:

P(x)=∏i=1Mp(xi)P(\mathbf{x}) = \prod_{i=1}^{M} p(x_i)

Cách tách được chọn là cách có P(x)P(\mathbf{x}) lớn nhất, tìm bằng thuật toán Viterbi. Xác suất p(xi)p(x_i) được ước lượng bằng thuật toán EM. Mỗi vòng, thuật toán tính xem bỏ từng mảnh thì likelihood của dữ liệu giảm bao nhiêu, rồi chỉ giữ những mảnh làm nó giảm nhiều nhất, ví dụ 80% số mảnh như trong bài báo. Các ký tự đơn luôn được giữ, để không có token lạ. Vì có xác suất cho mọi cách tách, Unigram cho phép lấy mẫu một cách tách khác mỗi lần huấn luyện (subword regularization), giúp mô hình bền hơn với nhiễu, như lỗi chính tả.

SentencePiece (Kudo và Richardson, 2018) là một thư viện, không phải thuật toán mới. Nó cài cả BPE lẫn Unigram, và học thẳng từ câu thô, không cần tách sơ bộ theo dấu cách. Dấu cách được coi là một ký tự thường, viết thành ▁, nên "Hello world." thành [Hello] [▁wor] [ld] [.] và giải mã ra lại đúng chuỗi gốc. Điều này hợp với tiếng Nhật, tiếng Trung, vốn không có dấu cách. Nhiều mô hình như T5, LLaMA 1 và 2 dùng SentencePiece (LLaMA dùng BPE của nó). Llama 3 chuyển sang byte-level BPE kiểu tiktoken, từ vựng 128K.

Byte-level BPE là BPE chạy trên byte thay vì ký tự. GPT-2 (Radford và cộng sự, 2019) dùng cách này. Lý do: Unicode có hơn 130 000 ký tự, quá nhiều cho từ vựng gốc, trong khi byte chỉ có 256 giá trị. Mọi văn bản đều là một dãy byte trong mã UTF-8, nên không bao giờ cần [UNK]. GPT-2 có từ vựng 50 257 token, và không cho gộp qua các loại ký tự khác nhau (chữ với dấu câu), trừ dấu cách. Nếu không, BPE sẽ phí chỗ cho dog., dog!, dog?.

Thuật toánBắt đầu từHọc từ vựng bằngToken lạVí dụ dùng
BPEKý tựGộp cặp đếm nhiều nhấtChỉ ký tự chưa gặpDịch máy của Sennrich và cộng sự
WordPieceKý tựGộp cặp tăng likelihood nhiều nhất[UNK] cả từBERT, mBERT
Unigram LMTừ vựng lớnBỏ dần mảnh ít có íchChỉ ký tự chưa gặpQua SentencePiece
Byte-level BPE256 byteGộp cặp đếm nhiều nhấtKhông cóGPT-2

Ngoài mảnh chữ, từ vựng còn có vài token đặc biệt không đến từ văn bản. BERT có [CLS] ở đầu mọi đầu vào (trạng thái của nó dùng để phân loại cả câu), [SEP] để ngăn hai câu, [MASK] để che token khi huấn luyện, [UNK] cho từ lạ và [PAD] để đệm (padding). GPT-2 chỉ có một: <|endoftext|>, đánh dấu hết văn bản, đóng vai trò <eos> (end of sequence).

Đệm cần khi đưa nhiều câu vào cùng một lô: các câu phải dài bằng nhau, nên câu ngắn được nối thêm token đệm. Bộ tách từ trả về kèm một attention mask (mặt nạ attention): 1 ở token thật, 0 ở token đệm, để mô hình bỏ qua phần đệm. Bài BERT dùng lại các token này.

Mã UTF-8 dùng 1 byte cho mỗi chữ cái tiếng Anh, nhưng 2 byte cho “đ”, “ư”, “ơ”, và 3 byte cho chữ có dấu thanh như “ọ”, “ờ”, “ở”. Câu “Học sinh đang học sinh học ở trường.” có 36 ký tự nhưng 48 byte. Với byte-level BPE, mỗi byte là một token, trừ khi một phép gộp đã nối nó với byte khác. Phép gộp lại được học từ dữ liệu: dữ liệu ít tiếng Việt thì ít phép gộp cho chuỗi byte tiếng Việt.

Petrov và cộng sự (2023) đo việc này trên FLORES-200: 2 000 câu Wikipedia được dịch sang 200 thứ tiếng. Họ chia số token của bản dịch cho số token của bản tiếng Anh, gọi là mức phụ trội (tokenization premium). Với tiếng Việt, mức đó là 4.54 cho bộ tách từ của GPT-2, và 2.45 cho bộ tách từ cl100k_base của ChatGPT và GPT-4. Từ vựng lớn hơn, dữ liệu đa ngôn ngữ hơn thì khoảng cách nhỏ lại, nhưng chưa mất.

Hệ quả rất thực tế. API của LLM tính tiền theo token và giới hạn cửa sổ ngữ cảnh (context window) theo token (bài Sử dụng API văn bản). Nên cùng một nội dung, bản tiếng Việt có thể đắt hơn, chậm hơn (mỗi token sinh ra mất một bước), và chứa được ít chữ hơn trong cửa sổ ngữ cảnh.

  • Bộ tách từ gắn chặt với mô hình. Mô hình giữ một bảng embedding (bảng vectơ, mỗi mã token một hàng; xem bài Vector Semantics và Embeddings). Mỗi hàng ứng với đúng một mã token. Đổi bộ tách từ thì mã đổi, mô hình đọc sai hoàn toàn. Hộp đầu tiên ở mục Lỗi hay gặp nói thêm.
  • BPE thường không gộp qua dấu cách, vì bước tách sơ bộ cắt ở đó. Vì vậy “học sinh” thường là ít nhất hai token. Mô hình phải tự học rằng hai token này thường đi với nhau.
  • Con số bị cắt tùy ý. GPT-2 cắt ” 2026” thành 20 và 26, ” 12345” thành 123 và 45 (tự chạy). Mô hình khó làm toán trên những mảnh như vậy.
  • Một chữ, hai cách mã. “ọ” có thể là một ký tự, hoặc “o” cộng một dấu chấm dưới. Hai cách trông như nhau nhưng tách ra khác nhau. Bài Chuẩn hóa văn bản xử lý chuyện này.

Hình dưới chạy BPE từng bước trên đúng bộ 7 âm tiết ở mục Lý thuyết. Mỗi bước, bạn thấy bảng đếm cặp kề nhau (cặp thắng tô đậm), danh sách phép gộp đã học, và cách mỗi âm tiết đang bị chia. Nút “Bước tiếp” chạy một phép gộp, nút “Đặt lại” quay về bước 0. Hãy thử:

  1. Bấm “Bước tiếp” một lần: cặp n g thắng với số đếm 29, và mọi âm tiết có “ng” đổi sang mảnh ng.
  2. Bấm tới bước 4: phép gộp ườ + ng_ (11) làm “trường” và “đường” chung một mảnh ường_.
  3. Bấm tới bước 8: cả người_ thành một token. Ở bước 6, ba cặp cùng đếm được 8, và cặp gặp trước (ng + ườ) thắng.
  4. Bấm tới bước 12: trường_ là một token, còn “thương” vẫn rời thành 5 mảnh t h ư ơ ng_.
BPE chạy từng bước trên 7 âm tiết ở mục Lý thuyết. Mỗi lần bấm Bước tiếp, cặp ký hiệu kề nhau có số đếm lớn nhất được gộp thành một ký hiệu mới.
Bước 0/12Từ vựng ∣V∣|V| = 13 + 0 = 13

Bước 0: mỗi âm tiết là dãy ký tự, thêm _ ở cuối. Từ vựng có 13 ký hiệu.

Số đếm các cặp kề nhau lúc đầu
CặpSố đếm
n + g29
ư + ờ19
g + _14
ờ + n11
n + h10
g + ư8
ờ + i8
i + _8
Cách chia mỗi âm tiết. Mảnh vừa tạo ở bước này được tô nền và in đậm.
Âm tiếtCách chiaf(w)f(w)
người6 mảnh: n, g, ư, ờ, i, _8
ngành6 mảnh: n, g, à, n, h, _3
nghề5 mảnh: n, g, h, ề, _4
trường7 mảnh: t, r, ư, ờ, n, g, _6
đường6 mảnh: đ, ư, ờ, n, g, _5
thương7 mảnh: t, h, ư, ơ, n, g, _3
nhà4 mảnh: n, h, à, _7
Phép gộp đã học, theo thứ tựChưa có phép gộp nào.

BPE chạy trên 7 âm tiết có tần số: người 8, ngành 3, nghề 4, trường 6, đường 5, thương 3, nhà 7. Mỗi bước đếm các cặp ký hiệu kề nhau, gộp cặp có số đếm lớn nhất thành một ký hiệu mới, rồi chia lại mọi âm tiết.

Code trong bài chạy thử lần cuối tháng 11/2026: hai ô Python thuần với Python 3.14 (bản có trong Pyodide), ô đếm token với Python 3.14 và transformers 5.22. Hai ô đầu chạy ngay trên trang, không cần thư viện nào. Ô thứ ba cần tải bộ tách từ từ Hugging Face Hub, nên chạy trên máy bạn.

Ô dưới cài khớp dài nhất với một từ điển 15 mục tự viết, theo cả hai chiều: từ trái (left) và từ phải (right). Hàm segment nhận danh sách âm tiết, vì chữ viết tiếng Việt đã tách sẵn theo âm tiết ở dấu cách.

Tách từ tiếng Việt bằng khớp dài nhất

# Tách từ tiếng Việt bằng khớp dài nhất (maximum matching) với từ điển nhỏ
# Từ điển tự viết: mỗi từ là một hay nhiều âm tiết, cách nhau bởi dấu cách
DICT = {"học sinh", "sinh học", "máy tính", "bài tập", "giáo viên",
"học", "sinh", "máy", "tính", "bài", "tập", "giáo", "viên",
"chấm", "trên"}
MAX_LEN = max(len(w.split()) for w in DICT) # từ dài nhất: 2 âm tiết
def segment(syl, from_right=False):
"""Mỗi lần lấy từ dài nhất có trong từ điển, từ trái hoặc từ phải."""
words = []
i, j = 0, len(syl) # phần chưa tách là syl[i:j]
while i < j:
for n in range(min(MAX_LEN, j - i), 0, -1):
piece = syl[j - n:j] if from_right else syl[i:i + n]
cand = " ".join(piece)
if n == 1 or cand in DICT: # 1 âm tiết thì luôn nhận
if from_right:
words.insert(0, cand)
j -= n
else:
words.append(cand)
i += n
break
return words
for sent in ["giáo viên chấm bài tập trên máy tính",
"học sinh học sinh học"]:
syl = sent.split() # chữ viết tiếng Việt đã tách sẵn theo âm tiết
print("left :", " | ".join(segment(syl)))
print("right:", " | ".join(segment(syl, from_right=True)))

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

Đọc kết quả:

  • Câu thứ nhất được tách đúng theo cả hai chiều: giáo viên | chấm | bài tập | trên | máy tính. Khi không có chỗ chồng lấp, từ điển là đủ.
  • Câu thứ hai sai theo cả hai chiều. Từ trái ra học sinh | học sinh | học, từ phải ra học | sinh học | sinh học. Cách đọc đúng, học sinh | học | sinh học, nằm ở giữa, và không chiều nào thấy được nó. Từ điển có đủ mọi từ cần thiết. Cái thiếu là thông tin về ngữ cảnh.

Thử thêm “sinh viên” vào câu, hoặc bỏ “sinh học” khỏi DICT, để xem kết quả đổi thế nào.

Ô này làm đúng các bước ở mục Lý thuyết, trên cùng bộ 7 âm tiết với hình ở mục Minh họa. Hàm merge thay mọi cặp kề nhau bằng ký hiệu gộp. Vòng lặp chạy 12 phép gộp, mỗi lần in cặp được gộp và số đếm. Cuối cùng, ô tách bốn âm tiết bằng cách áp lại các phép gộp theo thứ tự.

BPE đồ chơi trên 7 âm tiết tiếng Việt

# BPE đồ chơi: học các phép gộp từ một bộ âm tiết có tần số
from collections import Counter
# Bộ âm tiết và số lần xuất hiện (cùng dữ liệu với hình BpeMergeViz)
WORDS = {"người": 8, "ngành": 3, "nghề": 4, "trường": 6,
"đường": 5, "thương": 3, "nhà": 7}
END = "_" # ký hiệu cuối âm tiết
def merge(symbols, pair):
"""Thay mọi chỗ hai ký hiệu kề nhau bằng pair bởi một ký hiệu gộp."""
out, i = [], 0
while i < len(symbols):
if tuple(symbols[i:i + 2]) == pair:
out.append(pair[0] + pair[1])
i += 2
else:
out.append(symbols[i])
i += 1
return out
# Lúc đầu mỗi âm tiết là dãy ký tự, thêm ký hiệu cuối
corpus = {w: list(w) + [END] for w in WORDS}
vocab = {s for syms in corpus.values() for s in syms}
print("start vocab:", len(vocab))
merges = []
for step in range(1, 13):
pairs = Counter() # đếm cặp kề nhau, nhân với tần số của âm tiết
for w, syms in corpus.items():
for pair in zip(syms, syms[1:]):
pairs[pair] += WORDS[w]
best = max(pairs, key=pairs.get) # hòa thì lấy cặp gặp trước
merges.append(best)
corpus = {w: merge(syms, best) for w, syms in corpus.items()}
print(f"{step:2d}: {best[0]} + {best[1]} ({pairs[best]})")
print("end vocab:", len(vocab) + len(merges))
# Tách âm tiết bằng cách áp lại các phép gộp theo đúng thứ tự đã học
for w in ["trường", "thương", "nhường", "nghiêng"]:
syms = list(w) + [END]
for pair in merges:
syms = merge(syms, pair)
print(w, "->", syms)

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

Đọc kết quả:

  • start vocab: 13, end vocab: 25: đúng ∣V∣=∣V0∣+K=13+12|V| = |V_0| + K = 13 + 12.
  • Ba phép gộp đầu là n + g (29), ư + ờ (19), ng + _ (14), khớp số tính tay ở mục Lý thuyết. Thuật toán không biết gì về chính tả, nhưng tự tìm ra phụ âm đầu “ng”, “nh”, “tr” và vần “ường”, vì chúng lặp lại nhiều.
  • Các bước 6, 7, 8 có cùng số đếm 8: một khi “người” bắt đầu được gộp, các cặp bên trong nó có cùng tần số, nên nó được gộp liền một mạch tới người_.
  • Tách âm tiết: trường là một token trường_. nhường chưa từng gặp nhưng thành nh + ường_. nghiêng thành ng h i ê ng_. thương vẫn là 5 mảnh, vì “ơ” khác “ờ”.

Thử đổi tần số của “thương” từ 3 lên 20 rồi chạy lại: các bước 3 tới 6 dành để gộp nó thành một token thương_, còn “trường” bị đẩy ra ngoài 12 bước và chỉ còn t r ường_. Từ vựng phản ánh đúng dữ liệu nó được học.

Ô cuối nạp hai bộ tách từ thật bằng AutoTokenizer của thư viện transformers: GPT-2 (byte-level BPE, Radford và cộng sự, 2019) và mBERT, bản BERT đa ngôn ngữ (WordPiece, Devlin và cộng sự, 2019). Chỉ các file từ vựng được tải, không có trọng số mô hình. Ô đếm token của cùng một câu tiếng Việt và bản dịch tiếng Anh, in các mảnh, tách thử một cụm ít gặp (rare), và in dãy mã của câu tiếng Anh kèm token đặc biệt. Ô chỉ cần transformers, không cần PyTorch: nếu bạn chỉ cài transformers, dòng cảnh báo PyTorch was not found… lúc đầu là bình thường, vì ô không nạp mô hình.

Chạy trên máy bạn

Đếm token tiếng Việt và tiếng Anh với GPT-2 và mBERT

Cần cài
pip install transformers
Phần cứng
CPU là đủ. Chỉ tải bộ tách từ, khoảng 6 MB cho cả hai. Từ 30 giây tới vài phút, phần lớn là thời gian nạp thư viện
# Đếm token của cùng một câu tiếng Việt và tiếng Anh bằng hai tokenizer thật
from transformers import AutoTokenizer
# Chỉ tải tokenizer (vài file từ vựng), không tải trọng số mô hình
NAMES = ["openai-community/gpt2",
"google-bert/bert-base-multilingual-cased"]
SENTS = {"vi": "Học sinh đang học sinh học ở trường.",
"en": "Students are studying biology at school."}
RARE = "nhường nhịn" # từ ít gặp hơn
for text in SENTS.values():
n_bytes = len(text.encode("utf-8")) # chữ có dấu tốn 2-3 byte
print(f"{len(text)} chars, {n_bytes} bytes: {text}")
for name in NAMES:
tok = AutoTokenizer.from_pretrained(name)
print(f"\n{name} (vocab {tok.vocab_size})")
for lang, text in SENTS.items():
pieces = tok.tokenize(text) # các mảnh, chưa thêm token đặc biệt
print(f"{lang}: {len(pieces)} tokens")
print(" ", pieces)
print("rare:", tok.tokenize(RARE))
ids = tok(SENTS["en"])["input_ids"] # mã số, có token đặc biệt
print("ids:", ids)
print("back:", tok.decode(ids))

Kết quả mẫu

Lấy từ một lần chạy thật.

36 chars, 48 bytes: Học sinh đang học sinh học ở trường.
40 chars, 40 bytes: Students are studying biology at school.
openai-community/gpt2 (vocab 50257)
vi: 33 tokens
['H', 'á', '»', 'į', 'c', 'Ġsin', 'h', 'ĠÄ', 'ij', 'ang', 'Ġh', 'á', '»', 'į', 'c', 'Ġsin', 'h', 'Ġh', 'á', '»', 'į', 'c', 'Ġá', '»', 'Ł', 'Ġtr', 'Æ', '°', 'á', '»', 'Ŀ', 'ng', '.']
en: 7 tokens
['Students', 'Ġare', 'Ġstudying', 'Ġbiology', 'Ġat', 'Ġschool', '.']
rare: ['n', 'h', 'Æ', '°', 'á', '»', 'Ŀ', 'ng', 'Ġn', 'h', 'á', '»', 'ĭ', 'n']
ids: [28239, 389, 11065, 17219, 379, 1524, 13]
back: Students are studying biology at school.
google-bert/bert-base-multilingual-cased (vocab 119547)
vi: 9 tokens
['Học', 'sinh', 'đang', 'học', 'sinh', 'học', 'ở', 'trường', '.']
en: 7 tokens
['Students', 'are', 'studying', 'biology', 'at', 'school', '.']
rare: ['như', '##ờng', 'n', '##hị', '##n']
ids: [101, 30356, 10301, 47308, 48262, 10160, 11393, 119, 102]
back: [CLS] Students are studying biology at school. [SEP]

Đọc kết quả:

  • Hai dòng đầu: câu tiếng Việt ngắn hơn (36 ký tự so với 40) nhưng nặng hơn (48 byte so với 40), vì mỗi chữ có dấu tốn 2 tới 3 byte.
  • GPT-2 cần 33 token cho câu tiếng Việt, 7 token cho câu tiếng Anh, gấp khoảng 4.7 lần, gần mức 4.54 mà Petrov và cộng sự đo trên cả FLORES-200. Các mảnh trông lạ vì GPT-2 in mỗi byte thành một ký tự cho dễ nhìn: Ġ là dấu cách, còn “ọ” (3 byte E1 BB 8D) thành ba mảnh á, », į. Riêng chữ “Học” đã là 5 token, “trường” là 7. Trong khi đó Students và studying mỗi từ chỉ là một token.
  • mBERT cần 9 token cho câu tiếng Việt, mỗi âm tiết một token, vì từ vựng 119 547 mảnh của nó học trên Wikipedia của hơn một trăm thứ tiếng. Nhưng nó không tách từ tiếng Việt: “học sinh” vẫn là học và sinh.
  • Cụm ít gặp “nhường nhịn” cho thấy mảnh ## của WordPiece: như ##ờng, n ##hị ##n. GPT-2 tốn 14 token cho cùng cụm này.
  • Token đặc biệt: khi gọi tok(...) thay vì tok.tokenize(...), mBERT tự thêm [CLS] (mã 101) ở đầu và [SEP] (mã 102) ở cuối, nên dãy có 9 mã cho 7 mảnh. GPT-2 không thêm gì. Giải mã dãy mã cho lại đúng câu gốc.
Câu 1/5 Bộ từ có tần số: nhà 5 lần, nhanh 4 lần, anh 3 lần. Mỗi từ được tách thành ký tự, thêm _ ở cuối. Bước đầu tiên của BPE gộp cặp nào, với số đếm bao nhiêu?
Câu 2/5 Một bộ tách từ byte-level BPE như của GPT-2 gặp một chuỗi chữ Khmer mà nó chưa từng thấy khi huấn luyện. Điều gì xảy ra?
Câu 3/5 Từ điển có học sinh, sinh học, học, sinh. Khớp dài nhất từ trái và từ phải tách chuỗi sinh học sinh thế nào?
Câu 4/5 Ở ô đếm token của bài, câu tiếng Việt tốn 33 token với GPT-2 nhưng chỉ 9 token với mBERT. Giải thích nào đúng nhất?
Câu 5/5 Bạn huấn luyện BPE trên cùng một kho văn bản, tăng số phép gộp từ 8 000 lên 64 000. Điều nào đúng?

Ý chính:

  • Bộ tách từ đổi văn bản thành dãy mã token thuộc một từ vựng cố định. Tách theo từ thì gặp từ ngoài từ vựng, tách theo ký tự thì chuỗi quá dài. Mảnh từ (subword) là đường ở giữa: từ hay gặp là một token, từ hiếm ghép từ vài mảnh.
  • Chữ viết tiếng Việt tách theo âm tiết, nhưng từ có thể nhiều âm tiết. Khớp dài nhất với từ điển làm được phần lớn, nhưng sai ở nhập nhằng chồng lấp như “học sinh học sinh học”. Công cụ thật (VnCoreNLP, underthesea, pyvi) học từ dữ liệu có nhãn.
  • BPE gộp dần cặp kề nhau có ∑wf(w) nab(w)\sum_w f(w)\, n_{ab}(w) lớn nhất, sau KK bước có ∣V∣=∣V0∣+K|V| = |V_0| + K token. WordPiece gộp theo likelihood (BERT, tiền tố ##), Unigram bỏ dần mảnh từ một từ vựng lớn, SentencePiece học từ câu thô với ▁ cho dấu cách, byte-level BPE bắt đầu từ 256 byte nên không có token lạ.
  • Cùng một ý, tiếng Việt tốn nhiều token hơn với bộ tách từ thiên về tiếng Anh, vì chữ có dấu tốn 2 tới 3 byte và ít được gộp: 33 so với 7 token với GPT-2 ở ô của bài. Token đặc biệt ([CLS], [SEP], [PAD], <|endoftext|>) do bộ tách từ thêm vào.

Đọ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