Nền tảng Mô hình Ngôn ngữ Lớn Học qua Thực hành

bởi David Louapre

Chương 1

Bộ tách từ (Tokenizers): Từ chuỗi byte thô đến từ vựng của LLM

Tại sao mô hình ngôn ngữ không đọc từng từ hay từng ký tự, và cách các thuật toán BPE, WordPiece, Unigram định hình khả năng hiểu đa ngôn ngữ của LLM.

Đây là Chương 1 trong chuỗi bài giảng tương tác “Nền tảng Mô hình Ngôn ngữ Lớn (Foundations of LLMs) — Học qua Thực hành”.

Ở Chương 0, chúng ta đã đi qua toàn bộ lịch sử NLP cổ điển cho đến khoảnh khắc kiến trúc Transformer ra đời. Tuy nhiên, có một mắt xích quan trọng mà ta đã tạm coi là hiển nhiên: trước khi bất kỳ mạng nơ-ron nào có thể nhân ma trận hay tính trọng số chú ý (attention), văn bản thô phải được biến đổi thành một chuỗi các số nguyên. Thành phần đảm nhiệm bước chuyển đổi đó được gọi là Bộ tách từ (Tokenizer).

Đúng với tinh thần học qua thực hành, chương này bao gồm 4 bài tập lập trình cốt lõi giúp bạn tự tay cài đặt thuật toán Byte-Pair Encoding (BPE) từ con số 0 bằng Python thuần, xây dựng bộ tách sơ bộ bằng biểu thức chính quy (Regex pre-tokenizer) theo chuẩn GPT-4, và đo lường sự bất bình đẳng chi phí token trên tiếng Việt.

Một mạng nơ-ron không biết đọc chữ cái, từ vựng hay dấu câu; nó chỉ biết cộng và nhân các vector số thực. Vì vậy, cửa ngõ đầu tiên của mọi mô hình ngôn ngữ lớn (LLM) luôn là một quy trình hai bước:

  1. Tách từ (Tokenization): Cắt chuỗi văn bản đầu vào thành các mảnh rời rạc gọi là token, rồi ánh xạ mỗi mảnh sang một mã số nguyên xi∈{0,1,…,∣V∣−1}x_i \in \{0, 1, \dots, |V|-1\} trong bộ từ vựng VV.
  2. Tra cứu vector nhúng (Embedding lookup): Lấy dòng thứ xix_i trong ma trận nhúng E∈R∣V∣×dE \in \mathbb{R}^{|V| \times d} để đưa vào các tầng Transformer.

Nghe có vẻ chỉ là một bước tiền xử lý kỹ thuật đơn giản, nhưng như Andrej Karpathy từng nhận xét nổi tiếng: “Rất nhiều hành vi kỳ quặc, lỗi suy luận số học và giới hạn đa ngôn ngữ của LLM thực chất bắt nguồn từ chính bộ tách từ”. Trong chương này, chúng ta sẽ trả lời hai câu hỏi nền tảng:

Tập dữ liệu & Công cụ
  • Python chuẩn (utf-8, re): Các bài tập 1.2a, 1.2b và 1.3 chỉ sử dụng thư viện chuẩn của Python để bạn nắm vững bản chất thuật toán BPE và biểu thức chính quy mà không phụ thuộc vào hộp đen thư viện.
  • tiktoken: Thư viện tách từ BPE tốc độ cao bằng Rust của OpenAI, cung cấp bộ từ vựng gpt2 (∣V∣=50,257|V| = 50{,}257) và cl100k_base của GPT-4 (∣V∣=100,277|V| = 100{,}277), dùng trong Bài tập 1.5 để đo hệ số phân mảnh (fertility) trên văn bản song ngữ Anh–Việt.

1. Bài toán biểu diễn văn bản đầu vào: Từ, Ký tự hay Từ phụ?

Khi thiết kế bộ tách từ cho một mô hình ngôn ngữ, quyết định đầu tiên chúng ta phải đưa ra là chọn độ mịn (granularity) của đơn vị cơ sở: một token sẽ đại diện cho cả một từ, một ký tự đơn lẻ, hay một mảnh trung gian giữa hai thái cực đó?

1.1 Mức từ (Word-level) và lời nguyền của token <unk>

Trong phần lớn Chương 0, chúng ta đã sử dụng cách tiếp cận tự nhiên nhất với con người: tách văn bản theo khoảng trắng và dấu câu để mỗi từ là một token. Trên tập dữ liệu WikiText-2 (khoảng 2 triệu từ huấn luyện), cách này tạo ra một bộ từ vựng gồm ∣V∣=33,277|V| = 33{,}277 từ.

Tuy nhiên, khi mở rộng sang quy mô dữ liệu web hàng nghìn tỷ từ, mô hình mức từ lập tức vấp phải ba rào cản không thể vượt qua:

  1. Bùng nổ kích thước từ vựng và bảng nhúng: Nếu giữ lại mọi từ xuất hiện trên Internet (bao gồm tên riêng, thuật ngữ chuyên ngành, từ viết sai chính tả, mã định danh lập trình), ∣V∣|V| sẽ vượt quá hàng triệu từ. Với chiều ẩn d=4,096d = 4{,}096, riêng ma trận nhúng đầu vào E∈R∣V∣×dE \in \mathbb{R}^{|V| \times d} và ma trận dự đoán đầu ra U∈Rd×∣V∣U \in \mathbb{R}^{d \times |V|} của một từ vựng 1 triệu từ đã tiêuốn tới 2×106×4,096≈8.22 \times 10^6 \times 4{,}096 \approx 8.2 tỷ tham số!
  2. Mất mát thông tin vì token <unk>: Để khống chế ∣V∣|V| (ví dụ chỉ giữ 50,00050{,}000 từ phổ biến nhất), mọi từ nằm ngoài danh sách khi suy luận đều bị quy về một token duy nhất là <unk> (unknown). Khi người dùng hỏi về một tên riêng hiếm gặp như "Zendaya" hay một hàm Python như "calc_loss", mô hình chỉ nhìn thấy <unk> và hoàn toàn mù tịt về nội dung chữ viết bên trong.
  3. Không chia sẻ được cấu trúc hình thái học (Morphology): Trong mô hình mức từ, "learn", "learned", "learning", "learner" và "unlearned" là 5 chỉ số nguyên hoàn toàn độc lập. Mô hình phải học 5 vector nhúng riêng rẽ từ đầu mà không hề biết chúng cùng chia sẻ gốc từ "learn". Điều này càng trầm trọng hơn với các ngôn ngữ giàu biến tố (như tiếng Đức, tiếng Nga, tiếng Thổ Nhĩ Kỳ), nơi một gốc động từ có thể biến đổi thành hàng chục dạng thức khác nhau.

1.2 Mức ký tự (Character-level) và nút cổ chai chiều dài chuỗi

Để xóa sổ hoàn toàn lỗi <unk>, ta có thể đi tới thái cực ngược lại: lấy từng ký tự (hoặc từng byte) làm đơn vị token, như mô hình LSTM mức ký tự trên tập TinyShakespeare ở mục 7.3 của Chương 0. Khi đó, bộ từ vựng chỉ gồm vài chục đến vài trăm ký hiệu, bảng nhúng nhỏ gọn tuyệt đối, và mô hình có thể đọc hay拼 viết bất kỳ từ mới nào.

Thế nhưng, cái giá phải trả lại nằm ở chiều dài chuỗi LL:

1.3 Điểm cân bằng từ phụ (Subword-level) và sự đánh đổi ∣V∣−L|V| - L

Hai thái cực trên dẫn chúng ta đến nguyên lý thiết kế cốt lõi của mọi bộ tách từ hiện đại — tách từ ở mức từ phụ (Subword Tokenization):

Nguyên lý từ phụ: Các từ xuất hiện thường xuyên ("the", "model", "ngôn", "ngữ") cần được giữ nguyên làm một token duy nhất để chuỗi ngắn gọn; trong khi các từ hiếm hoặc từ phức ("tokenization", "autoregressive", "불가사의") được phân rã thành các mảnh từ phụ nhỏ hơn có tần suất cao ("token" + "ization", "auto" + "regress" + "ive").

Nhờ đó, kích thước bộ từ vựng ∣V∣|V| được giữ ở mức vừa phải (thường từ 32,00032{,}000 đến 128,000128{,}000 token trong các LLM hiện nay), không có bất kỳ từ nào bị biến thành <unk>, các từ cùng gốc chia sẻ được mảnh gốc từ, và độ dài chuỗi LL chỉ dài hơn mức từ khoảng 15%–30%15\%\text{–}30\%.

Bạn có thể khám phá trực tiếp sự đánh đổi toán học giữa kích thước từ vựng ∣V∣|V|, độ dài chuỗi LL và chi phí Attention O(L2)\mathcal{O}(L^2) trên biểu đồ tương tác dưới đây.

Sự đánh đổi giữa Kích thước từ vựng |V| và Độ dài chuỗi L
Kéo thanh trượt hoặc chọn các mốc cấu hình thực tế (ByT5, BERT, GPT-2, Llama-3, Word-level) để quan sát cách độ dài chuỗi L giảm dần khi mở rộng |V|, và vùng cân bằng tối ưu (30K–150K tokens) giữa số tham số bảng nhúng 2|V|d và chi phí Self-Attention O(L²).

2. Byte-Pair Encoding (BPE): Từ thuật toán nén dữ liệu đến từ vựng của LLM

Làm thế nào để máy tính tự động tìm ra danh sách các mảnh từ phụ tối ưu từ một kho văn bản thô mà không cần nhà ngôn ngữ học ngồi viết quy tắc tách gốc từ và hậu tố cho từng ngôn ngữ? Giải pháp phổ biến nhất hiện nay là thuật toán Byte-Pair Encoding (BPE).

2.1 Bảng mã Unicode, UTF-8 và biểu diễn mức byte

Trước khi đi vào thuật toán BPE, ta cần hiểu văn bản thực sự được lưu trữ trong bộ nhớ máy tính như thế nào.

Trong chuẩn Unicode, mỗi ký tự của mọi ngôn ngữ trên thế giới (từ chữ cái Latin, chữ tiếng Việt có dấu, chữ Hán, chữ Ả Rập cho đến biểu tượng cảm xúc emoji 🚀) được gán một mã số nguyên duy nhất gọi là code point (ví dụ: 'A' là U+0041 =65= 65, 'ă' là U+0103 =259= 259, 'ử' là U+1EED =7917= 7917). Hiện có hơn 150,000150{,}000 ký tự Unicode đã được định nghĩa.

Nếu lấy trực tiếp toàn bộ hơn 150,000150{,}000 ký tự Unicode làm bảng chữ cái cơ sở ban đầu cho bộ tách từ, từ vựng cơ sở sẽ quá lớn và vẫn có nguy cơ gặp ký tự mới chưa từng xuất hiện trong tập huấn luyện. Thay vào đó, các LLM hiện đại (từ GPT-2 trở đi) làm việc trực tiếp trên chuỗi byte theo chuẩn mã hóa UTF-8.

Hãy kiểm chứng sự khác biệt giữa độ dài ký tự và độ dài byte UTF-8 của tiếng Việt so với tiếng Anh, cũng như điều gì xảy ra khi một ký tự đa byte bị cắt ngang trong bài tập đầu tiên dưới đây.

Bài tập 1.2a Khám phá bảng mã UTF-8 và ký tự đa byte tiếng Việt
Dữ liệu: Python chuẩn · Thời gian chạy: < 1 phút

Cho hai câu tiếng Anh (en_text) và tiếng Việt (vi_text) có độ dài ký tự gần bằng nhau trong mã khởi tạo:

  1. Chuyển cả hai câu sang chuỗi byte UTF-8 bằng .encode("utf-8"). In ra số ký tự, số byte và tỷ lệ số byte trung bình trên mỗi ký tự của từng câu.
  2. Thống kê phân bố số lượng ký tự có độ dài 1 byte, 2 byte và 3 byte trong câu tiếng Việt, đồng thời liệt kê các ký tự tốn 3 byte.
  3. Lấy chuỗi byte UTF-8 của từ "ngữ" ("ngữ".encode("utf-8")), thử cắt bỏ 1 byte cuối cùng ([:-1]) và giải mã ngược lại bằng .decode("utf-8") ở hai chế độ errors="strict" và errors="replace".
Mã khởi tạo
en_text = "Natural language processing with large language models"
vi_text = "Xử lý ngôn ngữ tự nhiên với các mô hình ngôn ngữ lớn"

print(f"EN ({len(en_text)} ký tự): {en_text}")
print(f"VI ({len(vi_text)} ký tự): {vi_text}")
EN (54 ký tự): Natural language processing with large language models
VI (52 ký tự): Xử lý ngôn ngữ tự nhiên với các mô hình ngôn ngữ lớn
Hint

Để biết một ký tự đơn lẻ ch tốn bao nhiêu byte trong UTF-8, chỉ cần lấy len(ch.encode("utf-8")). Duyệt qua từng ký tự for ch in vi_text: để cập nhật từ điển đếm byte_dist = {1: 0, 2: 0, 3: 0}.

Hint

Dùng khối try ... except UnicodeDecodeError as e: khi gọi truncated.decode("utf-8", errors="strict") để bắt và in thông báo ngoại lệ. Sau đó gọi truncated.decode("utf-8", errors="replace") để xem ký tự thay thế chuẩn của Unicode (\ufffd, hiển thị là “).

Điểm kiểm tra kết quả bạn cần đạt được

Câu tiếng Anh dài 54 ký tự và tốn đúng 54 bytes (1.00 byte/ký tự). Ngược lại, câu tiếng Việt ngắn hơn về mặt ký tự (52 ký tự) nhưng lại tốn tới 71 bytes (1.37 byte/ký tự), trong đó có 7 ký tự 2-byte và 6 ký tự 3-byte ('ử', 'ữ', 'ự', 'ớ').

Từ "ngữ" gồm 3 ký tự nhưng chiếm 5 bytes [110, 103, 225, 187, 175], trong đó riêng chữ 'ữ' chiếm 3 bytes cuối [225, 187, 175]. Khi cắt mất byte 175, hai byte mồ côi [225, 187] không tạo thành chuỗi UTF-8 hợp lệ, gây lỗi UnicodeDecodeError (hoặc biến thành ký tự lỗi ng).

EN: 54 ký tự -> 54 bytes (1.00 byte/ký tự)
VI: 52 ký tự -> 71 bytes (1.37 byte/ký tự)
Phân bố độ dài byte (VI): {1: 39, 2: 7, 3: 6}
Các ký tự 3-byte trong câu VI: ['ử', 'ữ', 'ự', 'ớ']

Từ 'ngữ' ở dạng UTF-8 bytes: [110, 103, 225, 187, 175]
Khi cắt mất 1 byte cuối (strict): UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 2-3: unexpected end of data
Khi cắt mất 1 byte cuối (replace): ng�
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

en_text = "Natural language processing with large language models"
vi_text = "Xử lý ngôn ngữ tự nhiên với các mô hình ngôn ngữ lớn"

print(f"EN ({len(en_text)} ký tự): {en_text}")
print(f"VI ({len(vi_text)} ký tự): {vi_text}")
EN (54 ký tự): Natural language processing with large language models
VI (52 ký tự): Xử lý ngôn ngữ tự nhiên với các mô hình ngôn ngữ lớn
# (1) So sánh độ dài byte UTF-8 và phân bố số byte trên mỗi ký tự
en_bytes = en_text.encode("utf-8")
vi_bytes = vi_text.encode("utf-8")

print(f"EN: {len(en_text)} ký tự -> {len(en_bytes)} bytes "
      f"({len(en_bytes)/len(en_text):.2f} byte/ký tự)")
print(f"VI: {len(vi_text)} ký tự -> {len(vi_bytes)} bytes "
      f"({len(vi_bytes)/len(vi_text):.2f} byte/ký tự)")

byte_dist = {1: 0, 2: 0, 3: 0}
three_byte_chars = []
for ch in vi_text:
    n_b = len(ch.encode("utf-8"))
    byte_dist[n_b] += 1
    if n_b == 3 and ch not in three_byte_chars:
        three_byte_chars.append(ch)

print(f"Phân bố độ dài byte (VI): {byte_dist}")
print(f"Các ký tự 3-byte trong câu VI: {three_byte_chars}")

# (2) Kiểm tra hiện tượng đứt gãy ký tự đa byte trên từ "ngữ"
word_bytes = "ngữ".encode("utf-8")
print(f"\nTừ 'ngữ' ở dạng UTF-8 bytes: {list(word_bytes)}")

truncated = word_bytes[:-1]
try:
    truncated.decode("utf-8", errors="strict")
except UnicodeDecodeError as e:
    print(f"Khi cắt mất 1 byte cuối (strict): {type(e).__name__}: {e}")

replaced = truncated.decode("utf-8", errors="replace")
print(f"Khi cắt mất 1 byte cuối (replace): {replaced}")
EN: 54 ký tự -> 54 bytes (1.00 byte/ký tự)
VI: 52 ký tự -> 71 bytes (1.37 byte/ký tự)
Phân bố độ dài byte (VI): {1: 39, 2: 7, 3: 6}
Các ký tự 3-byte trong câu VI: ['ử', 'ữ', 'ự', 'ớ']

Từ 'ngữ' ở dạng UTF-8 bytes: [110, 103, 225, 187, 175]
Khi cắt mất 1 byte cuối (strict): UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 2-3: unexpected end of data
Khi cắt mất 1 byte cuối (replace): ng�
Điều cần lưu ý

Bài tập nhỏ này hé lộ hai hệ quả thực tế quan trọng đối với các LLM đa ngôn ngữ:

  1. Ngay từ tầng biểu diễn byte thô, văn bản tiếng Việt đã “nặng” hơn tiếng Anh khoảng 35%–45%35\%\text{–}45\% số byte cho cùng một số lượng chữ cái vì hệ thống dấu thanh và dấu phụ.
  2. Trong các mô hình dùng Byte-level BPE (như GPT-2 hay Llama), một ký tự tiếng Việt hiếm hoặc một emoji có thể bị tách làm nhiều token mức byte riêng lẻ. Khi sinh văn bản từng token một (streaming), nếu mô hình mới chỉ sinh ra byte đầu tiên 225 của chữ 'ữ', giao diện người dùng chưa thể giải mã ngay thành chữ cái mà phải đợi đủ 3 byte, hoặc dùng errors="replace" để tránh sập chương trình!

2.2 Thuật toán gộp cặp BPE của Gage (1994) và Sennrich et al. (2016)

Năm 1994, Philip Gage giới thiệu Byte-Pair Encoding (BPE) như một thuật toán nén dữ liệu không mất mát đơn giản (Gage, 1994): tìm cặp hai byte đứng cạnh nhau xuất hiện nhiều nhất trong tệp tin, thay thế tất cả các vị trí của cặp đó bằng một byte mới chưa sử dụng, và lặp lại quá trình này cho đến khi không còn cặp nào xuất hiện nhiều hơn 1 lần.

Hai mươi hai năm sau, Rico Sennrich, Barry Haddow và Alexandra Birch nhận ra rằng chính ý tưởng nén cặp ký tự thường xuyên này là chìa khóa giải quyết bài toán từ hiếm cho Dịch máy Nơ-ron (NMT) (Sennrich et al., 2016). Thay vì nén tệp tin, họ dùng BPE để xây dựng bộ từ vựng từ phụ (subword vocabulary) từ kho văn bản huấn luyện.

Quy trình huấn luyện bộ tách từ BPE với số lượt gộp MM (tương ứng kích thước từ vựng đích ∣V∣=∣Vbase∣+M|V| = |V_{\text{base}}| + M) diễn ra hoàn toàn tất định qua từng bước:

  1. Khởi tạo bộ từ vựng cơ sở V0V_0: Tập hợp tất cả các đơn vị sơ cấp (các ký tự phân biệt hoặc 256 giá trị byte cơ sở). Biểu diễn toàn bộ kho văn bản huấn luyện dưới dạng chuỗi các đơn vị sơ cấp này: C0=(u1,u2,…,uN),ui∈V0.\mathcal{C}_0 = (u_1, u_2, \dots, u_N), \quad u_i \in V_0.
  2. Lặp qua từng bước t=1,2,…,Mt = 1, 2, \dots, M:
    • Đếm tần suất cặp kề nhau: Với mọi cặp hai token liên tiếp (a,b)∈Vt−1×Vt−1(a, b) \in V_{t-1} \times V_{t-1}, tính tổng số lần chúng xuất hiện cạnh nhau trong chuỗi hiện tại Ct−1\mathcal{C}_{t-1}: (a∗,b∗)=arg⁡max⁡(a,b)∑i=1∣Ct−1∣−11[Ct−1[i]=a  ∧  Ct−1[i+1]=b].(a^*, b^*) = \arg\max_{(a, b)} \sum_{i=1}^{|\mathcal{C}_{t-1}| - 1} \mathbf{1}\big[\mathcal{C}_{t-1}[i] = a \;\wedge\; \mathcal{C}_{t-1}[i+1] = b\big].
    • Tạo token mới và ghi nhận luật gộp: Gộp hai token (a∗,b∗)(a^*, b^*) thành một token mới vnew=a∗∘b∗v_{\text{new}} = a^* \circ b^*, thêm vnewv_{\text{new}} vào từ vựng: Vt=Vt−1∪{vnew},V_t = V_{t-1} \cup \{v_{\text{new}}\}, đồng thời lưu luật gộp thứ tt: (a∗,b∗)→vnew(a^*, b^*) \to v_{\text{new}}.
    • Cập nhật kho văn bản: Thay thế mọi vị trí xuất hiện của cặp (a∗,b∗)(a^*, b^*) kề nhau trong Ct−1\mathcal{C}_{t-1} bằng token đơn vnewv_{\text{new}} để thu được chuỗi ngắn hơn Ct\mathcal{C}_t.

Trong bài báo gốc của Sennrich et al. (Sennrich et al., 2016), mỗi từ trong kho văn bản được gắn thêm ký hiệu kết thúc từ </w> (để phân biệt tiền tố như "st" trong "star" với hậu tố "st</w>" ở cuối từ "newest" và "widest"). Hãy bấm nút Chạy tự động hoặc kéo thanh trượt trong mô phỏng dưới đây để theo dõi chính xác ví dụ kinh điển trong bài báo gốc!

Mô phỏng từng bước thuật toán gộp cặp BPE
Kho từ vựng mẫu từ bài báo Sennrich et al. (2016) gồm 4 từ: 'low' (5 lần), 'lower' (2 lần), 'newest' (6 lần) và 'widest' (3 lần). Ở mỗi bước, cặp ký hiệu kề nhau có tổng tần suất cao nhất (viền nét đứt) được gộp lại thành một token mới và bổ sung vào bộ từ vựng.

Quan sát quá trình gộp 9 bước trên biểu đồ, bạn sẽ thấy một điều kỳ diệu: mặc dù thuật toán hoàn toàn “mù” về ngôn ngữ học (nó chỉ đếm số!), các token mới sinh ra lại trùng khớp với cấu trúc hình thái học của tiếng Anh:

2.3 Giải mã và mã hóa một văn bản mới với danh sách luật gộp

Sau khi quá trình huấn luyện kết thúc, bộ tách từ BPE lưu trữ hai cấu trúc dữ liệu:

  1. Bảng từ vựng (vocab): Ánh xạ hai chiều giữa mã số nguyên token_id và chuỗi byte của token đó.
  2. Danh sách có thứ tự các luật gộp (merges): Danh sách (a1,b1)→v1,…,(aM,bM)→vM(a_1, b_1) \to v_1, \dots, (a_M, b_M) \to v_M theo đúng thứ tự ưu tiên lúc huấn luyện.

Khi cần mã hóa (encode) một văn bản mới lúc suy luận:

Ngược lại, bước giải mã (decode) từ dãy mã số nguyên về văn bản chỉ đơn giản là nối các chuỗi byte của từng token lại với nhau và giải mã UTF-8.

Hãy tự tay cài đặt toàn bộ quy trình huấn luyện BPE, hàm encode và hàm decode chỉ với vài chục dòng Python trong bài tập thực hành dưới đây.

Bài tập 1.2b Cài đặt thuật toán BPE từ đầu bằng Python thuần
Dữ liệu: Python chuẩn · Thời gian chạy: < 1 phút

Sử dụng đoạn văn bản corpus (dài 1,5911{,}591 bytes UTF-8) trong mã khởi tạo:

  1. Viết hàm get_stats(ids) nhận vào một danh sách các mã số nguyên ids và trả về từ điển đếm tần suất xuất hiện của mọi cặp liên tiếp (ids[i], ids[i+1]).
  2. Viết hàm merge_vocab(ids, pair, new_id) duyệt qua danh sách ids, thay thế mọi cặp liên tiếp khớp với pair bằng số nguyên new_id và trả về danh sách mới.
  3. Khởi tạo từ vựng cơ sở gồm 256 byte (0..255). Chạy vòng lặp 200 bước gộp: ở mỗi bước, tìm cặp có tần suất lớn nhất, gán cho nó mã new_id = 256 + step, cập nhật ids, lưu luật gộp vào merges và chuỗi byte vào vocab. Tính tỷ lệ nén (compression ratio) bằng số byte ban đầu chia cho số token còn lại sau 200 bước.
  4. Viết hàm encode(text) và decode(token_ids), kiểm thử trên câu "Simple language models read subword tokens." và xác nhận decode(encode(sample)) == sample.
Mã khởi tạo
corpus = """Beautiful is better than ugly. Explicit is better than implicit.
Simple is better than complex. Complex is better than complicated.
Flat is better than nested. Sparse is better than dense.
Special cases aren't special enough to break the rules.
Although practicality beats purity, errors should never pass silently.
In the face of ambiguity, refuse the temptation to guess.
Now is better than never, although never is often better than right now.
If the implementation is hard to explain, it is a bad idea.
If the implementation is easy to explain, it may be a good idea.
Language models do not read raw text directly as words or characters.
Instead, a tokenizer converts text into a sequence of integer token IDs.
Byte-Pair Encoding starts with individual bytes and repeatedly merges
the most frequent adjacent pair of tokens into a new vocabulary entry.
Common words like the, and, model, language quickly become single tokens,
while rarer words like tokenization, autoregressive, or transformers are
split into meaningful subword pieces such as token, ization, and transform.
Subword tokenization helps neural networks generalize across different forms
of the same root word, such as train, trained, trainer, and training,
or model, models, modeled, and modeling in natural language processing.
When a tokenizer splits unseen words into smaller subwords, the language
model can still understand their meaning from the familiar roots and suffixes.
Without pre-tokenization, however, merging raw bytes directly across spaces
can fuse frequent words with punctuation marks or adjacent words."""

raw_bytes = list(corpus.encode("utf-8"))
print(f"Độ dài ban đầu: {len(raw_bytes)} bytes "
      f"({len(set(raw_bytes))} byte phân biệt)")
Độ dài ban đầu: 1591 bytes (44 byte phân biệt)
Hint

Trong get_stats(ids), dùng for pair in zip(ids, ids[1:]): counts[pair] = counts.get(pair, 0) + 1. Để lấy cặp có tần suất cao nhất trong vòng lặp huấn luyện, dùng best_pair = max(stats, key=stats.get).

Hint

Trong merge_vocab(ids, pair, new_id), dùng chỉ số i = 0 và vòng lặp while i < len(ids):. Nếu i < len(ids) - 1 và (ids[i], ids[i + 1]) == pair, thêm new_id vào danh sách kết quả và tăng i += 2; ngược lại thêm ids[i] và tăng i += 1.

Hint

Trong encode(text), khi một chuỗi có nhiều cặp đều nằm trong merges, bạn phải gộp cặp được học sớm hơn trước: dùng pair = min(stats, key=lambda p: merges.get(p, float("inf"))). Nếu pair not in merges, thoát vòng lặp break.

Điểm kiểm tra kết quả bạn cần đạt được

Từ 1,5911{,}591 bytes ban đầu, sau 200 bước gộp, độ dài chuỗi giảm xuống chỉ còn 561 tokens, đạt tỷ lệ nén 2.84 bytes/token (tức độ dài chuỗi giảm đi gần 3 lần!).

Ở bước 1, cặp được gộp nhiều nhất là (32, 116) →\to ' t' (39 lần), tiếp theo là 's ' (35 lần) và 'er' (27 lần). Khi mã hóa câu mẫu và giải mã ngược lại, kết quả khớp 100% với chuỗi gốc (True).

Bước   1: gộp (32, 116) -> 256 (' t', tần suất 39), còn 1552 tokens
Bước   2: gộp (115, 32) -> 257 ('s ', tần suất 35), còn 1517 tokens
Bước   3: gộp (101, 114) -> 258 ('er', tần suất 27), còn 1490 tokens
Bước  10: gộp (44, 32) -> 265 (', ', tần suất 17), còn 1343 tokens
Bước  50: gộp (101, 115) -> 305 ('es', tần suất 6), còn 983 tokens
Bước 100: gộp (44, 261) -> 355 (', i', tần suất 3), còn 773 tokens
Bước 150: gộp (404, 355) -> 405 (' to explain, i', tần suất 2), còn 661 tokens
Bước 200: gộp (454, 288) -> 455 ('ural ', tần suất 2), còn 561 tokens
Tỷ lệ nén sau 200 bước: 1591/561 = 2.84 bytes/token
Mã hóa mẫu: ['S', 'i', 'mpl', 'e ', 'language ', 'model', 's ', 're', 'ad', ' s', 'ubword', ' token', 's', '.']
Khôi phục nguyên bản: True
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

corpus = """Beautiful is better than ugly. Explicit is better than implicit.
Simple is better than complex. Complex is better than complicated.
Flat is better than nested. Sparse is better than dense.
Special cases aren't special enough to break the rules.
Although practicality beats purity, errors should never pass silently.
In the face of ambiguity, refuse the temptation to guess.
Now is better than never, although never is often better than right now.
If the implementation is hard to explain, it is a bad idea.
If the implementation is easy to explain, it may be a good idea.
Language models do not read raw text directly as words or characters.
Instead, a tokenizer converts text into a sequence of integer token IDs.
Byte-Pair Encoding starts with individual bytes and repeatedly merges
the most frequent adjacent pair of tokens into a new vocabulary entry.
Common words like the, and, model, language quickly become single tokens,
while rarer words like tokenization, autoregressive, or transformers are
split into meaningful subword pieces such as token, ization, and transform.
Subword tokenization helps neural networks generalize across different forms
of the same root word, such as train, trained, trainer, and training,
or model, models, modeled, and modeling in natural language processing.
When a tokenizer splits unseen words into smaller subwords, the language
model can still understand their meaning from the familiar roots and suffixes.
Without pre-tokenization, however, merging raw bytes directly across spaces
can fuse frequent words with punctuation marks or adjacent words."""

raw_bytes = list(corpus.encode("utf-8"))
print(f"Độ dài ban đầu: {len(raw_bytes)} bytes "
      f"({len(set(raw_bytes))} byte phân biệt)")
Độ dài ban đầu: 1591 bytes (44 byte phân biệt)
# (1) Hàm đếm tần suất cặp kề nhau và hàm gộp cặp thành ID mới
def get_stats(ids):
    counts = {}
    for pair in zip(ids, ids[1:]):
        counts[pair] = counts.get(pair, 0) + 1
    return counts


def merge_vocab(ids, pair, new_id):
    new_ids, i = [], 0
    while i < len(ids):
        if i < len(ids) - 1 and (ids[i], ids[i + 1]) == pair:
            new_ids.append(new_id)
            i += 2
        else:
            new_ids.append(ids[i])
            i += 1
    return new_ids


# (2) Huấn luyện BPE trong 200 bước gộp từ bảng mã 256 byte cơ sở
ids = list(raw_bytes)
merges = {}
vocab = {i: bytes([i]) for i in range(256)}

for step in range(200):
    stats = get_stats(ids)
    best_pair = max(stats, key=stats.get)
    new_id = 256 + step
    ids = merge_vocab(ids, best_pair, new_id)
    merges[best_pair] = new_id
    vocab[new_id] = vocab[best_pair[0]] + vocab[best_pair[1]]
    if step in (0, 1, 2, 9, 49, 99, 149, 199):
        s = vocab[new_id].decode("utf-8", errors="replace")
        freq = stats[best_pair]
        print(f"Bước {step+1:3d}: gộp {best_pair} -> {new_id} "
              f"({s!r}, tần suất {freq}), còn {len(ids)} tokens")

ratio = len(raw_bytes) / len(ids)
print(f"Tỷ lệ nén sau 200 bước: "
      f"{len(raw_bytes)}/{len(ids)} = {ratio:.2f} bytes/token")


# (3) Hàm mã hóa (encode) theo thứ tự ưu tiên gộp và giải mã (decode)
def encode(text):
    tokens = list(text.encode("utf-8"))
    while len(tokens) >= 2:
        stats = get_stats(tokens)
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break
        tokens = merge_vocab(tokens, pair, merges[pair])
    return tokens


def decode(token_ids):
    raw = b"".join(vocab[idx] for idx in token_ids)
    return raw.decode("utf-8", errors="replace")


sample = "Simple language models read subword tokens."
enc = encode(sample)
print("Mã hóa mẫu:", [vocab[i].decode("utf-8") for i in enc])
print("Khôi phục nguyên bản:", decode(enc) == sample)
Bước   1: gộp (32, 116) -> 256 (' t', tần suất 39), còn 1552 tokens
Bước   2: gộp (115, 32) -> 257 ('s ', tần suất 35), còn 1517 tokens
Bước   3: gộp (101, 114) -> 258 ('er', tần suất 27), còn 1490 tokens
Bước  10: gộp (44, 32) -> 265 (', ', tần suất 17), còn 1343 tokens
Bước  50: gộp (101, 115) -> 305 ('es', tần suất 6), còn 983 tokens
Bước 100: gộp (44, 261) -> 355 (', i', tần suất 3), còn 773 tokens
Bước 150: gộp (404, 355) -> 405 (' to explain, i', tần suất 2), còn 661 tokens
Bước 200: gộp (454, 288) -> 455 ('ural ', tần suất 2), còn 561 tokens
Tỷ lệ nén sau 200 bước: 1591/561 = 2.84 bytes/token
Mã hóa mẫu: ['S', 'i', 'mpl', 'e ', 'language ', 'model', 's ', 're', 'ad', ' s', 'ubword', ' token', 's', '.']
Khôi phục nguyên bản: True
Điều cần lưu ý

Hãy nhìn kỹ vào các token được tạo ra ở bước 100 và bước 150 trong kết quả của bạn:

  • Bước 100 tạo ra token ', i' (dấu phẩy của từ trước gộp xuyên qua khoảng trắng sang chữ 'i' của từ sau!).
  • Bước 150 tạo ra token ' to explain, i' (gộp cả cụm 3 từ và dấu phẩy thành một token duy nhất!).
  • Trong câu mẫu được mã hóa, từ "models" bị tách thành 'model' + 's ' (chữ 's' bị dính chặt vào khoảng trắng phía sau).

Nếu cứ để BPE gộp tự do xuyên qua khoảng trắng và dấu câu như vậy trên kho dữ liệu lớn, bộ từ vựng sẽ lãng phí hàng chục nghìn vị trí chỉ để lưu các biến thể trùng lặp như "model", "model,", "model.", "model!", "model?" và các cụm từ ngẫu nhiên. Đây chính là lý do GPT-2 và GPT-4 phải bổ sung một tầng lọc quan trọng trước khi chạy BPE — chủ đề của Mục 3 ngay sau đây!


3. Byte-level BPE và Pre-tokenization: Công thức của GPT-2, GPT-4 và Llama-3

Năm 2019, khi huấn luyện GPT-2 trên tập dữ liệu WebText đa dạng thu thập từ Internet, Alec Radford và các cộng sự tại OpenAI đã định hình kiến trúc chuẩn mực cho hầu hết các bộ tách từ của LLM hiện đại (Radford et al., 2019): kết hợp Byte-level BPE với Tách sơ bộ bằng Biểu thức chính quy (Regex Pre-tokenization).

3.1 Byte-level BPE: Khởi đầu từ 256 byte cơ sở

Nếu áp dụng BPE trực tiếp trên các ký tự Unicode như cách làm ban đầu trong dịch máy, bộ từ vựng cơ sở V0V_0 sẽ phải chứa hàng chục nghìn ký tự từ mọi hệ chữ viết xuất hiện trong dữ liệu web (chữ Hán, chữ Nhật, ký hiệu toán học, emoji), chiếm mất phần lớn dung lượng của một bộ từ vựng 32,00032{,}000 hay 50,00050{,}000 token. Ngược lại, nếu chỉ giữ các ký tự phổ biến trong V0V_0, mô hình lại quay về bài toán phải dùng token <unk> cho các ký tự hiếm.

GPT-2 giải quyết triệt để mâu thuẫn này bằng cách đặt V0V_0 là tập 256 giá trị byte đơn lẻ (0x00 đến 0xFF). Với 50,00050{,}000 lượt gộp BPE cộng thêm 1 token đặc biệt <|endoftext|> để đánh dấu ranh giới giữa các văn bản, GPT-2 có bộ từ vựng tổng cộng:

∣VGPT-2∣=256+50,000+1=50,257 tokens.|V_{\text{GPT-2}}| = 256 + 50{,}000 + 1 = 50{,}257 \text{ tokens}.

Một chi tiết thú vị trong cài đặt của GPT-2 (và RoBERTa) là không bỏ đi khoảng trắng: khoảng trắng đứng trước một từ được giữ lại như một phần của từ đó (trong các tệp cấu hình của Hugging Face, byte khoảng trắng 0x20 thường được hiển thị bằng ký tự Ġ, ví dụ " hello" được viết là "Ġhello"). Nhờ vậy, quá trình nối token khi giải mã hoàn toàn bảo toàn khoảng trắng gốc mà không cần đoán xem giữa hai từ có dấu cách hay không.

3.2 Tại sao phải tách sơ bộ bằng Biểu thức chính quy (Regex Pre-tokenization)?

Như ta đã thấy trong Bài tập 1.2b và ghi chú của bài báo GPT-2 (Radford et al., 2019), nếu chạy BPE trên một chuỗi byte liền mạch, thuật toán sẽ gộp xuyên qua khoảng trắng và gộp dính chữ cái với dấu câu hoặc chữ số.

Để ngăn chặn hiện tượng này, trước khi đưa văn bản vào thuật toán BPE, GPT-2 và GPT-4 (cl100k_base trong thư viện tiktoken, cũng được Llama-3 sử dụng) dùng một biểu thức chính quy (Regular Expression) để cắt văn bản thành các mảnh thô (pre-tokens hay chunks). Sau đó, thuật toán BPE chỉ được phép đếm và gộp các cặp byte nằm bên trong cùng một mảnh, tuyệt đối không bao giờ gộp xuyên qua ranh giới giữa hai mảnh!

Biểu thức chính quy của GPT-4 (cl100k_base) được thiết kế rất tinh tế để xử lý 5 nhóm cấu trúc ngôn ngữ và lập trình:

  1. Tách các hậu tố rút gọn tiếng Anh ('(?i:[sdmt]|ll|ve|re)): Tách rời 's, 't, 're, 've, 'm, 'll, 'd khỏi từ đứng trước (ví dụ "It's" →\to ["It", "'s"], "don't" →\to ["don", "'t"]), giúp gốc động từ/danh từ không bị nhân bản.
  2. Giữ khoảng trắng dẫn đầu gắn với từ chữ cái ([^\r\n\p{L}\p{N}]?\p{L}+): Mỗi từ gồm các chữ cái Unicode (\p{L}+) cùng tối đa 1 ký tự đứng ngay trước nó (thường là 1 dấu cách ' ') tạo thành một mảnh độc lập (ví dụ " language"). Dấu câu theo sau từ đó bị tách sang mảnh khác!
  3. Tách các chữ số thành từng nhóm tối đa 3 chữ số (\p{N}{1,3}): Trong GPT-2, biểu thức \p{N}+ cho phép một con số dài bất kỳ đi vào cùng một mảnh, dẫn đến việc một số như 1000 là 1 token nhưng 1001 lại bị cắt thành 2 token khác nhau, làm mô hình rất khó học quy tắc cộng trừ theo hàng đơn vị, hàng chục, hàng trăm. Sang GPT-4 và Llama-3, luật \p{N}{1,3} ép mọi chuỗi số phải tách thành từng cụm từ 1 đến 3 chữ số trước khi BPE chạy!
  4. Cô lập cụm dấu câu và ký hiệu toán học ( ?[^\s\p{L}\p{N}]+[\r\n]*): Các dấu ., ,, !, ?, >=, ): đứng riêng thành mảnh dấu câu, không bao giờ bị gộp dính vào từ chữ cái hay con số.
  5. Gom nhóm khoảng trắng và thụt lề mã nguồn (\s*[\r\n]+|\s+(?!\S)|\s+): Giữ các chuỗi khoảng trắng liên tiếp (như 4 dấu cách hoặc 8 dấu cách thụt đầu dòng trong Python) thành một mảnh riêng để BPE có thể gộp 4 dấu cách ' ' thành 1 token duy nhất, giúp nén mã nguồn cực kỳ hiệu quả!

Hãy tương tác với biểu đồ dưới đây để so sánh cách BPE ngây thơ và BPE có Regex Pre-tokenization của GPT-4 xử lý dấu câu, chữ số và mã nguồn Python.

Biểu thức chính quy (Regex Pre-tokenization) bảo vệ ranh giới từ như thế nào?
Chọn các ví dụ mẫu (Dấu câu, Chữ số, Mã nguồn Python) để so sánh kết quả gộp BPE khi không có tiền xử lý (trên) và khi áp dụng bộ tách sơ bộ bằng Regex của GPT-4 cl100k_base (dưới). Mỗi hộp ở phần dưới là một mảnh độc lập mà BPE không được phép gộp vượt ranh giới.

Hãy kiểm chứng hiệu quả của Regex Pre-tokenization bằng cách nâng cấp bộ huấn luyện BPE của bạn trong Bài tập 1.3.

Bài tập 1.3 Ngăn lỗi gộp xuyên ranh giới bằng Regex Pre-tokenizer kiểu GPT-4
Dữ liệu: Python chuẩn · Thời gian chạy: < 1 phút

Mã khởi tạo cung cấp văn bản corpus chứa nhiều dấu câu (model,, model., model!, model?), dạng viết tắt (aren't, it's) và con số (123456, 2024), cùng biểu thức chính quy rút gọn GPT4_PAT:

  1. Dùng GPT4_PAT.findall(corpus) để tách văn bản thành danh sách các mảnh chunks.
  2. Viết hàm train_bpe(chunks_list, num_merges=80) nhận vào một danh sách các chuỗi văn bản, chuyển mỗi chuỗi thành danh sách byte UTF-8 riêng biệt, và chạy 80 bước gộp BPE sao cho chỉ đếm và gộp các cặp kề nhau bên trong từng chuỗi con, không bao giờ đếm cặp nối giữa cuối chuỗi con này với đầu chuỗi con kế tiếp.
  3. Gọi train_bpe([corpus], 80) (BPE ngây thơ trên 1 chuỗi liền) và train_bpe(chunks, 80) (BPE có Regex pre-tokenizer). Đếm số lượng “token bẩn” (token chứa đồng thời chữ cái và dấu câu ,.!?, hoặc chứa từ 2 từ cách nhau bởi khoảng trắng trở lên) ở cả hai phương pháp.
Mã khởi tạo
import re

corpus = """Beautiful is better than ugly. Explicit is better than implicit.
Simple is better than complex. Complex is better than complicated.
Special cases aren't special enough to break the rules.
Although practicality beats purity, errors should never pass silently.
Now is better than never, although never is often better than right now.
If the implementation is hard to explain, it's a bad idea!
Language models do not read raw text directly as words or characters.
Instead, a tokenizer converts text into a sequence of integer token IDs.
Common words like model, models, model., model!, model? share the root model.
When training on numbers like 123456 or 2024, GPT-4 splits digits into
groups of at most 3 digits so arithmetic is more consistent across tokens.
Subword tokenization helps neural networks generalize across different forms
of the same root word, such as train, trained, trainer, and training,
or token, tokens, tokenizer, and tokenization in language modeling."""

# Biểu thức chính quy pre-tokenization rút gọn theo chuẩn GPT-4 (cl100k_base)
GPT4_PAT = re.compile(
    r"'(?:[sdmt]|ll|ve|re)| ?[A-Za-z]+| ?\d{1,3}| ?[^\sA-Za-z\d]+|\s+"
)
print("Tách câu thử nghiệm:",
      GPT4_PAT.findall("It's 123456: model., model!"))
Tách câu thử nghiệm: ['It', "'s", ' 123', '456', ':', ' model', '.,', ' model', '!']
Hint

Trong train_bpe(chunks_list, num_merges=80), biểu diễn dữ liệu thành danh sách của các danh sách số nguyên: seqs = [list(c.encode("utf-8")) for c in chunks_list].

Hint

Ở mỗi bước gộp, để tổng hợp tần suất cặp trên toàn bộ các mảnh, dùng hai vòng lặp lồng nhau: for seq in seqs: rồi for pair in zip(seq, seq[1:]): counts[pair] = counts.get(pair, 0) + 1. Sau khi tìm được best_pair, áp dụng bước gộp độc lập trên từng seq trong seqs.

Hint

Một token chuỗi t bị coi là gộp xuyên ranh giới nếu (any(p in t for p in ",.!?") and any(c.isalpha() for c in t)) hoặc len(t.strip().split()) > 1.

Điểm kiểm tra kết quả bạn cần đạt được

Biểu thức GPT4_PAT cắt câu thử nghiệm "It's 123456: model., model!" thành ['It', "'s", ' 123', '456', ':', ' model', '.,', ' model', '!'] và cắt corpus thành 203 mảnh.

Trong 80 token mới học được, BPE ngây thơ sinh ra tới 11 token xuyên ranh giới (như 'better than', ' is better than', 's.\n', ', model', 'e model'), trong khi BPE có Regex sinh ra 0 token xuyên ranh giới (0/80) và tập trung ngân sách từ vựng vào các gốc từ chuẩn xác như ' tokeniz', ' better', ' model', ' token', ' train'.

Tổng số mảnh pre-tokenized: 203

Token gộp xuyên ranh giới từ/dấu câu:
  BPE ngây thơ (11/80): ['better th', 'better than', ' is better than', 's.\n', ', model', 's s', 'e model', 's t']
  BPE có Regex (0/80): []
10 token dài nhất (BPE có Regex): [' tokeniz', ' better', ' model', ' token', ' train', ' never', ' compl', 'pecial', ' mode', ' than']
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

import re

corpus = """Beautiful is better than ugly. Explicit is better than implicit.
Simple is better than complex. Complex is better than complicated.
Special cases aren't special enough to break the rules.
Although practicality beats purity, errors should never pass silently.
Now is better than never, although never is often better than right now.
If the implementation is hard to explain, it's a bad idea!
Language models do not read raw text directly as words or characters.
Instead, a tokenizer converts text into a sequence of integer token IDs.
Common words like model, models, model., model!, model? share the root model.
When training on numbers like 123456 or 2024, GPT-4 splits digits into
groups of at most 3 digits so arithmetic is more consistent across tokens.
Subword tokenization helps neural networks generalize across different forms
of the same root word, such as train, trained, trainer, and training,
or token, tokens, tokenizer, and tokenization in language modeling."""

# Biểu thức chính quy pre-tokenization rút gọn theo chuẩn GPT-4 (cl100k_base)
GPT4_PAT = re.compile(
    r"'(?:[sdmt]|ll|ve|re)| ?[A-Za-z]+| ?\d{1,3}| ?[^\sA-Za-z\d]+|\s+"
)
print("Tách câu thử nghiệm:",
      GPT4_PAT.findall("It's 123456: model., model!"))
Tách câu thử nghiệm: ['It', "'s", ' 123', '456', ':', ' model', '.,', ' model', '!']
# (1) Tách văn bản thành danh sách các mảnh độc lập bằng Regex
chunks = GPT4_PAT.findall(corpus)
print(f"Tổng số mảnh pre-tokenized: {len(chunks)}")


# (2) Huấn luyện BPE chỉ đếm và gộp cặp bên trong từng mảnh (chunk)
def train_bpe(chunks_list, num_merges=80):
    seqs = [list(c.encode("utf-8")) for c in chunks_list]
    vocab = {i: bytes([i]) for i in range(256)}
    for step in range(num_merges):
        counts = {}
        for seq in seqs:
            for pair in zip(seq, seq[1:]):
                counts[pair] = counts.get(pair, 0) + 1
        best_pair = max(counts, key=counts.get)
        new_id = 256 + step
        vocab[new_id] = vocab[best_pair[0]] + vocab[best_pair[1]]
        new_seqs = []
        for seq in seqs:
            merged, i = [], 0
            while i < len(seq):
                if i < len(seq) - 1 and (seq[i], seq[i + 1]) == best_pair:
                    merged.append(new_id)
                    i += 2
                else:
                    merged.append(seq[i])
                    i += 1
            new_seqs.append(merged)
        seqs = new_seqs
    return [
        vocab[256 + s].decode("utf-8", errors="replace")
        for s in range(num_merges)
    ]


# (3) So sánh BPE ngây thơ (1 chuỗi liền) và BPE có Regex pre-tokenizer
naive_tokens = train_bpe([corpus], num_merges=80)
regex_tokens = train_bpe(chunks, num_merges=80)


def is_cross_boundary(t):
    punct_alpha = any(p in t for p in ",.!?") and any(c.isalpha() for c in t)
    multi_words = len(t.strip().split()) > 1
    return punct_alpha or multi_words


bad_naive = [t for t in naive_tokens if is_cross_boundary(t)]
bad_regex = [t for t in regex_tokens if is_cross_boundary(t)]

print("\nToken gộp xuyên ranh giới từ/dấu câu:")
print(f"  BPE ngây thơ ({len(bad_naive)}/80): {bad_naive[:8]}")
print(f"  BPE có Regex ({len(bad_regex)}/80): {bad_regex}")
top_regex = sorted(regex_tokens, key=len, reverse=True)[:10]
print("10 token dài nhất (BPE có Regex):", top_regex)
Tổng số mảnh pre-tokenized: 203

Token gộp xuyên ranh giới từ/dấu câu:
  BPE ngây thơ (11/80): ['better th', 'better than', ' is better than', 's.\n', ', model', 's s', 'e model', 's t']
  BPE có Regex (0/80): []
10 token dài nhất (BPE có Regex): [' tokeniz', ' better', ' model', ' token', ' train', ' never', ' compl', 'pecial', ' mode', ' than']
Điều cần lưu ý

Chỉ với một dòng biểu thức chính quy GPT4_PAT.findall(corpus) trước khi chạy BPE, toàn bộ các biến thể "model,", "model.", "model!", "model?" đều được chuẩn hóa thành cùng một mảnh gốc ' model' đi kèm token dấu câu riêng biệt! Nhờ đó, mô hình không chỉ tiết kiệm hàng nghìn vị trí trong bộ từ vựng mà còn nhận diện ngay được cùng một vector nhúng của từ "model" dù nó đứng giữa câu hay cuối câu cảm thán.


4. WordPiece, Unigram LM và SentencePiece: Góc nhìn xác suất

Mặc dù BPE chiếm ưu thế trong các dòng mô hình GPT và Llama, lịch sử phát triển của NLP còn sản sinh ra hai thuật toán tách từ phụ quan trọng khác dựa trên lý thuyết xác suất và thông tin: WordPiece (dùng trong BERT, DistilBERT, Electra) và Unigram Language Model (dùng trong T5, ALBERT, mBART, Gemma).

4.1 WordPiece: Gộp cặp theo Điểm thông tin tương hỗ (Pointwise Mutual Information)

Được Mike Schuster và Kaisuke Nakajima phát triển tại Google năm 2012 cho hệ thống tìm kiếm giọng nói tiếng Nhật và tiếng Hàn (Schuster & Nakajima, 2012), sau đó được chọn làm bộ tách từ cho BERT, WordPiece cũng là một thuật toán gộp cặp từ dưới lên (bottom-up) giống BPE, nhưng khác nhau ở hai điểm cốt lõi:

  1. Đánh dấu mảnh nối tiếp bằng tiền tố ##: Thay vì giữ khoảng trắng ở đầu từ như GPT-2 ("Ġization"), WordPiece tách từ theo khoảng trắng trước rồi gắn ký hiệu ## vào đầu các mảnh không phải là đầu từ. Ví dụ, từ "tokenization" được tách thành ["token", "##ization"]. Nhờ tiền tố ##, khi giải mã ta biết ngay "##ization" phải nối liền vào "token" mà không có dấu cách ở giữa.
  2. Tiêu chí chọn cặp gộp cực đại hóa Hợp lý (Likelihood): Trong BPE, ta chọn cặp (a,b)(a, b) có tần suất xuất hiện cùng nhau C(a,b)C(a, b) lớn nhất. Tuy nhiên, hai ký tự rất phổ biến (như 'e' và 's') có thể đứng cạnh nhau nhiều lần chỉ vì bản thân từng ký tự xuất hiện quá nhiều, chứ không phải vì chúng có mối liên kết đặc biệt. WordPiece khắc phục điều này bằng cách chia tần suất cặp cho tích tần suất của từng thành phần đơn lẻ: ScoreWordPiece(a,b)=C(a,b)C(a)⋅C(b).\text{Score}_{\text{WordPiece}}(a, b) = \frac{C(a, b)}{C(a) \cdot C(b)}. Nếu lấy logarit của đại lượng này (nhân với kích thước kho văn bản NN), ta thu được chính xác Điểm thông tin tương hỗ từng điểm (Pointwise Mutual Information — PMI): PMI(a,b)=log⁡P(a,b)P(a) P(b).\text{PMI}(a, b) = \log \frac{P(a, b)}{P(a)\,P(b)}. Như vậy, WordPiece ưu tiên gộp những cặp mảnh có tần suất đi cùng nhau cao hơn nhiều so với kỳ vọng ngẫu nhiên (ví dụ gộp "q" với "u", hoặc "##iz" với "##ation" trước khi gộp hai chữ cái phổ biến ngẫu nhiên).

Khi mã hóa một từ mới lúc suy luận, thay vì áp dụng tuần tự một danh sách luật gộp như BPE, WordPiece sử dụng thuật toán khớp tiền tố dài nhất từ trái sang phải (Longest-match-first / MaxMatch): tìm mảnh tiền tố dài nhất của từ có mặt trong bộ từ vựng VV, cắt mảnh đó ra, thêm ## vào phần còn lại và lặp lại.

4.2 Unigram Language Model và Subword Regularization

Cả BPE lẫn WordPiece đều xây dựng từ vựng từ dưới lên (bottom-up) một cách tham lam: bắt đầu từ bảng chữ cái nhỏ và gộp dần lên. Năm 2018, Taku Kudo đề xuất một hướng tiếp cận ngược lại hoàn toàn và mang tính nguyên lý xác suất chặt chẽ hơn: Unigram Language Model (Kudo, 2018), xây dựng từ vựng từ trên xuống (top-down).

Trong mô hình Unigram của Kudo (Kudo, 2018), xác suất của một phương án tách từ x=(x1,…,xM)\mathbf{x} = (x_1, \dots, x_M) cho chuỗi đầu vào SS (với điều kiện nối các mảnh xix_i lại đúng bằng SS) được giả định là tích xác suất độc lập của từng mảnh:

P(x)=∏i=1Mp(xi),với ∑v∈Vp(v)=1.P(\mathbf{x}) = \prod_{i=1}^{M} p(x_i), \quad \text{với } \sum_{v \in V} p(v) = 1.

Khi đã có bộ từ vựng VV và xác suất p(v)p(v) của từng token, phương án tách từ tối ưu x∗\mathbf{x}^* cho chuỗi SS được tìm một cách chính xác trong thời gian tuyến tính O(∣S∣)\mathcal{O}(|S|) bằng thuật toán quy hoạch động Viterbi:

x∗=arg⁡max⁡x∈S(S)∑i=1∣x∣log⁡p(xi),\mathbf{x}^* = \arg\max_{\mathbf{x} \in \mathcal{S}(S)} \sum_{i=1}^{|\mathbf{x}|} \log p(x_i),

trong đó S(S)\mathcal{S}(S) là tập mọi cách chia chuỗi SS thành các mảnh thuộc VV.

Để huấn luyện bộ từ vựng Unigram đạt kích thước đích ∣V∣|V|:

  1. Khởi tạo từ vựng siêu lớn VinitV_{\text{init}}: Thu thập tất cả các chuỗi con xuất hiện thường xuyên trong kho văn bản (ví dụ 1,000,0001{,}000{,}000 chuỗi con tìm bằng thuật toán mảng hậu tố Enhanced Suffix Array) cùng toàn bộ các ký tự cơ sở.
  2. Lặp lại chu trình Cực đại hóa Kỳ vọng (EM) và Cắt tỉa (Pruning):
    • Ước lượng xác suất p(v)p(v) cho mọi v∈Vv \in V bằng thuật toán EM để cực đại hóa tổng log-likelihood L=∑s∈Dlog⁡∑x∈S(s)P(x)\mathcal{L} = \sum_{s \in \mathcal{D}} \log \sum_{\mathbf{x} \in \mathcal{S}(s)} P(\mathbf{x}) của kho văn bản D\mathcal{D}.
    • Với mỗi token v∈Vv \in V (trừ các ký tự cơ sở không bao giờ được xóa), tính độ sụt giảm hàm hợp lý ΔLv\Delta \mathcal{L}_v nếu xóa vv khỏi từ vựng VV.
    • Loại bỏ 10%–20%10\%\text{–}20\% số token có ΔLv\Delta \mathcal{L}_v nhỏ nhất (những token dư thừa mà nếu bỏ đi, các mảnh khác vẫn biểu diễn tốt văn bản), rồi lặp lại cho đến khi ∣V∣|V| giảm về đúng kích thước mong muốn.

4.3 SentencePiece: Xử lý ngôn ngữ không phân tách từ bằng khoảng trắng

Hầu hết các bộ tách từ đời đầu đều mặc định bước đầu tiên là text.split(" ") — giả định rằng các từ trong ngôn ngữ luôn được ngăn cách bởi dấu cách. Nhưng trong tiếng Trung (自然语言处理), tiếng Nhật (自然言語処理), tiếng Thái hay tiếng Lào, các từ được viết liền nhau hoàn toàn không có khoảng trắng! Hơn nữa, ngay cả trong tiếng Anh hay tiếng Việt, việc tách khoảng trắng trước làm mất thông tin hoàn nguyên chuỗi gốc (ví dụ không biết dấu ngoặc kép hay dấu chấm phẩy có cách chữ trước đó hay không).

Cùng năm 2018, Taku Kudo và John Richardson tại Google ra mắt thư viện SentencePiece (Kudo & Richardson, 2018) (hỗ trợ cả hai thuật toán BPE và Unigram LM), đưa ra một triết lý thiết kế cực kỳ thanh lịch: coi khoảng trắng chỉ là một ký tự bình thường như mọi chữ cái khác.

Bảng dưới đây tổng hợp toàn cảnh 4 phương pháp tách từ phụ phổ biến nhất trong thế giới LLM:

Đặc điểmBPE cổ điển (Sennrich 2016)Byte-level BPE + Regex (tiktoken)WordPiece (Schuster 2012)Unigram LM (SentencePiece)
Hướng xây dựng VVTừ dưới lên (gộp dần)Từ dưới lên (từ 256 bytes)Từ dưới lên (gộp dần)Từ trên xuống (cắt tỉa dần)
Tiêu chí chọn tokenTần suất cặp max⁡C(a,b)\max C(a,b)Tần suất cặp trong từng mảnh RegexĐiểm thông tin max⁡C(a,b)C(a)C(b)\max \frac{C(a,b)}{C(a)C(b)}Giảm thiểu sụt giảm Likelihood ΔLv\Delta \mathcal{L}_v
Thuật toán lúc encodeÁp dụng tuần tự luật gộp ưu tiênTách Regex →\to gộp BPE theo thứ tựKhớp tiền tố dài nhất (MaxMatch)Quy hoạch động Viterbi O(L)\mathcal{O}(L)
Đánh dấu khoảng trắngKý hiệu cuối từ </w>Giữ byte 0x20 ở đầu từ (Ġ)Tiền tố ## cho mảnh giữa/cuối từKý tự ▁ (U+2581) ở đầu từ
Mô hình tiêu biểuGPT-1, mô hình NMT đời đầuGPT-2, GPT-4, Llama-3, QwenBERT, DistilBERT, ElectraT5, ALBERT, mT5, Gemma, Llama-1/2
Đọc thêm Mô hình không cần bộ tách từ (Tokenizer-free / Byte-level Models): ByT5, MegaByte và Byte Latent Transformer

Nếu bộ tách từ gây ra nhiều phiền toái đến vậy, liệu chúng ta có thể loại bỏ hoàn toàn bước tách từ và đưa thẳng chuỗi byte UTF-8 (0..255) vào mạng Transformer được không?

Năm 2022, Linting Xue và các cộng sự tại Google đã thử nghiệm ý tưởng này với mô hình ByT5 (Xue & others, 2022) — một biến thể của mô hình Encoder–Decoder T5 đọc và sinh trực tiếp từng byte UTF-8 với bộ từ vựng chỉ gồm 256 byte cơ sở (cộng vài token đặc biệt). Họ phát hiện ra một quy luật thú vị:

  • Vì bảng nhúng của ByT5 gần như không tốn tham số (256×d256 \times d thay vì 250,000×d250{,}000 \times d như mT5 đa ngôn ngữ), toàn bộ ngân sách tham số được dồn vào các tầng Transformer (đặc biệt khi làm cho phần Encoder sâu gấp 3 lần phần Decoder).
  • ByT5 vượt trội hoàn toàn so với các mô hình dùng Subword trên các tác vụ có nhiễu chính tả, biến đổi chữ cái, từ hiếm và các ngôn ngữ ít tài nguyên, nhưng phải trả giá bằng thời gian huấn luyện và suy luận chậm hơn khoảng 30%–60%30\%\text{–}60\% do chuỗi byte dài hơn gấp 4 lần.

Gần đây hơn (2023–2025), các kiến trúc phân cấp như MegaByte và Byte Latent Transformer (BLT) đang mở ra hướng đi mới: dùng một mạng cục bộ nhỏ gom các byte liên tiếp thành các mảng động (dynamic patches) dựa trên độ khó dự đoán (entropy) của byte tiếp theo, sau đó chỉ chạy Transformer lớn trên chuỗi các mảng này — đạt tốc độ ngang ngửa BPE mà không cần bộ từ vựng cố định!


5. Các hệ quả thực tế: Đếm chữ cái, Toán học, Glitch Tokens và Bất bình đẳng Ngôn ngữ

Hiểu rõ cơ chế hoạt động của bộ tách từ giúp chúng ta giải thích ngay lập tức hàng loạt “căn bệnh” kinh điển từng làm đau đầu người dùng LLM trong thực tế.

5.1 Tại sao LLM từng đếm sai chữ 'r' trong "strawberry" và tính nhẩm kém?

Khi bạn hỏi một mô hình ngôn ngữ câu hỏi nổi tiếng: “Có bao nhiêu chữ r trong từ strawberry?”, con người nhìn thấy một chuỗi gồm 10 chữ cái rời nhau: s-t-r-a-w-b-e-r-r-y. Nhưng mô hình ngôn ngữ không bao giờ nhìn thấy chuỗi 10 chữ cái đó!

Trong bộ tách từ cl100k_base của GPT-4, từ "strawberry" được cắt thành 3 mã số nguyên:

"strawberry"⟶["str",  "aw",  "berry"]⟶[496,  675,  15717].\text{"strawberry"} \longrightarrow [\text{"str"},\; \text{"aw"},\; \text{"berry"}] \longrightarrow [496,\; 675,\; 15717].

Đối với mạng Transformer, đầu vào chỉ là 3 vector số thực e496,e675,e15717\mathbf{e}_{496}, \mathbf{e}_{675}, \mathbf{e}_{15717}. Bản thân vector e15717\mathbf{e}_{15717} đại diện cho khái niệm ngữ nghĩa của cả mảnh "berry", chứ không có trường thông tin tường minh nào ghi rằng bên trong nó chứa hai chữ 'r'. Để đếm đúng, mô hình buộc phải ghi nhớ thông tin chính tả của từng token trong quá trình tiền huấn luyện, hoặc bạn phải yêu cầu nó “viết tách từng chữ cái ra cách nhau bằng dấu gạch ngang trước khi đếm” (s - t - r - a - w - b - e - r - r - y) — khi đó mỗi chữ cái trở thành một token riêng lẻ và mô hình đếm đúng 100%!

Hệ quả tương tự cũng xảy ra với:

5.2 Hiện tượng “Glitch Tokens” (Các token dị biệt chưa được huấn luyện)

Năm 2023, cộng đồng nghiên cứu phát hiện ra rằng nếu yêu cầu GPT-2 hoặc GPT-3 lặp lại một số chuỗi ký tự kỳ lạ như " SolidGoldMagikarp", " petertodd" hay " davidjl", mô hình đột nhiên trả lời hoàn toàn điên loạn, bịa ra nội dung không liên quan hoặc từ chối một cách khó hiểu!

Nguyên nhân nằm ở sự lệch pha giữa dữ liệu huấn luyện Tokenizer và dữ liệu huấn luyện LLM:

  1. Bộ tách từ BPE của GPT-2 được huấn luyện trên dữ liệu Reddit chứa tên tài khoản của những người dùng đăng hàng trăm nghìn bình luận trên diễn đàn đếm số (như tài khoản SolidGoldMagikarp). Vì xuất hiện quá nhiều lần, cả cụm " SolidGoldMagikarp" được gộp thành một token duy nhất trong bộ từ vựng (ID 42573).
  2. Sau đó, khi lọc sạch dữ liệu để huấn luyện mạng nơ-ron GPT-2/GPT-3, các trang diễn đàn đếm số rác đó bị xóa bỏ hoàn toàn.
  3. Kết quả là token ID 42573 vẫn nằm trong bảng nhúng E∈R∣V∣×dE \in \mathbb{R}^{|V| \times d}, nhưng không bao giờ xuất hiện trong suốt quá trình huấn luyện LLM! Vector nhúng e42573\mathbf{e}_{42573} của nó giữ nguyên giá trị khởi tạo ngẫu nhiên ban đầu (nằm lạc lõng hoàn toàn so với đám mây vector đã được huấn luyện của các từ bình thường). Khi người dùng gõ " SolidGoldMagikarp", vector nhiễu chưa huấn luyện này đi thẳng vào các tầng Attention và làm rối loạn toàn bộ phép tính của mô hình.

5.3 Hệ số phân mảnh (Fertility) và “Thuế Token” đối với tiếng Việt

Hệ quả kinh tế và xã hội sâu sắc nhất của bộ tách từ chính là sự bất bình đẳng giữa các ngôn ngữ (Petrov & others, 2023). Để định lượng mức độ một bộ tách từ “ưu ái” hay “phân mảnh” một ngôn ngữ, người ta sử dụng hai chỉ số chuẩn:

  1. Hệ số phân mảnh (Fertility): Số lượng token trung bình cần thiết để biểu diễn một từ trong ngôn ngữ đó: Fertility=NtokensNwords≥1.\text{Fertility} = \frac{N_{\text{tokens}}}{N_{\text{words}}} \ge 1. Giá trị lý tưởng gần bằng 1.0–1.21.0\text{–}1.2 (mỗi từ hầu như là 1 token nguyên vẹn).
  2. Tỷ lệ nén (Bytes per token): Số byte UTF-8 trung bình được nén trong một token: Compression Ratio=NbytesNtokens.\text{Compression Ratio} = \frac{N_{\text{bytes}}}{N_{\text{tokens}}}.

Đối với tiếng Việt, sự phân mảnh này bắt nguồn từ hai nguyên nhân cộng hưởng:

Sang thế hệ GPT-4 (cl100k_base, ∣V∣≈100K|V| \approx 100\text{K}) và Llama-3 (∣V∣=128,256|V| = 128{,}256), nhờ mở rộng gấp đôi kích thước từ vựng và tăng tỷ trọng dữ liệu đa ngôn ngữ khi huấn luyện BPE, hầu hết các âm tiết tiếng Việt thông dụng đã trở thành 1–2 token. Bạn có thể quan sát trực quan bước tiến này qua biểu đồ tương tác dưới đây.

So sánh Hệ số phân mảnh (Fertility) và Chi phí Token đa ngôn ngữ
Chuyển đổi giữa 3 góc nhìn: Fertility (số token/từ), Chi phí tương đối (so với tiếng Anh = 1×) và Hiệu suất nén (bytes/token) trên cùng một đoạn văn bản tương đương bằng tiếng Anh, tiếng Pháp, tiếng Việt, tiếng Trung và mã nguồn Python qua 3 thế hệ bộ tách từ (GPT-2, GPT-4 cl100k, Llama-3).

Trong bài tập thực hành cuối cùng của chương, bạn sẽ trực tiếp sử dụng thư viện tiktoken để đo hệ số phân mảnh trên một cặp văn bản song ngữ Anh–Việt và thử mở rộng bộ từ vựng BPE cho tiếng Việt.

Bài tập 1.5 Đo hệ số phân mảnh (Fertility) tiếng Việt và mở rộng BPE
Dữ liệu: tiktoken · Thời gian chạy: < 1 phút

Mã khởi tạo cung cấp hai đoạn văn bản dịch song ngữ tương đương hoàn toàn về mặt thông tin: en_doc (42 từ tiếng Anh) và vi_doc (63 từ tiếng Việt):

  1. Sử dụng tiktoken.get_encoding(name) với hai bộ tách từ "gpt2" và "cl100k_base", mã hóa cả hai đoạn văn bản bằng .encode().
  2. Với mỗi bộ tách từ, tính và in ra tổng số token, hệ số phân mảnh (Fertility=Ntokens/Nwords\text{Fertility} = N_{\text{tokens}} / N_{\text{words}}) của tiếng Anh và tiếng Việt, cùng tỷ số chi phí token giữa bản tiếng Việt và bản tiếng Anh (Ntokens, VI/Ntokens, ENN_{\text{tokens, VI}} / N_{\text{tokens, EN}}).
  3. Tách các từ trong vi_doc thành danh sách các từ có khoảng trắng dẫn đầu vi_chunks, chuyển sang mức byte UTF-8 và chạy 60 lượt gộp BPE dành riêng cho tiếng Việt. So sánh tổng số token và hệ số phân mảnh trước và sau 60 lượt gộp.
Mã khởi tạo
import tiktoken

en_doc = (
    "Large language models are trained on massive multilingual text corpora. "
    "However, when a tokenizer is trained primarily on English text, words in "
    "other languages are fragmented into many small subword or byte tokens. "
    "This increases computational cost and reduces effective context length."
)

vi_doc = (
    "Các mô hình ngôn ngữ lớn được huấn luyện trên kho văn bản đa ngữ khổng lồ. "
    "Tuy nhiên, khi bộ tách từ chủ yếu học từ tiếng Anh, các từ trong ngôn ngữ "
    "khác bị cắt vụn thành nhiều mảnh từ phụ hoặc byte nhỏ. Điều này làm tăng "
    "chi phí tính toán và thu hẹp độ dài ngữ cảnh hiệu dụng."
)

en_words = en_doc.split()
vi_words = vi_doc.split()
print(f"Số từ: EN = {len(en_words)} từ, VI = {len(vi_words)} từ")
Số từ: EN = 42 từ, VI = 63 từ
Hint

enc = tiktoken.get_encoding(name) tải bộ tách từ của OpenAI, và enc.encode(text) trả về danh sách các mã số nguyên token_ids. Độ dài len(enc.encode(text)) chính là số lượng token.

Hint

Tạo danh sách các từ tiếng Việt kèm khoảng trắng dẫn đầu bằng vi_chunks = [" " + w if i > 0 else w for i, w in enumerate(vi_words)]. Trước khi gộp, tổng số token mức byte là sum(len(s) for s in seqs).

Điểm kiểm tra kết quả bạn cần đạt được

Trên bộ tách từ gpt2, đoạn văn tiếng Anh chỉ tốn 51 tokens (fertility 1.21), trong khi bản dịch tiếng Việt tốn tới 251 tokens (fertility 3.98) — tức người dùng tiếng Việt phải trả chi phí đắt gấp 4.92 lần cho cùng một nội dung!

Khi chuyển sang cl100k_base (GPT-4), số token tiếng Anh giữ nguyên ở mức 50 tokens (1.19), còn tiếng Việt giảm gần một nửa xuống 135 tokens (fertility 2.14, tỷ lệ 2.70x). Khi huấn luyện bổ sung chỉ 60 lượt gộp BPE trực tiếp trên từ vựng tiếng Việt từ mức byte, fertility giảm mạnh từ 5.87 xuống 2.57.

[gpt2       ] EN:  51 tokens (fertility 1.21) | VI: 251 tokens (fertility 3.98) | VI/EN = 4.92x
[cl100k_base] EN:  50 tokens (fertility 1.19) | VI: 135 tokens (fertility 2.14) | VI/EN = 2.70x

BPE tiếng Việt mở rộng (60 lượt gộp):
  Trước khi gộp (byte-level): 370 tokens (fertility 5.87)
  Sau 60 lượt gộp tiếng Việt: 162 tokens (fertility 2.57)
Lời giải 2 ô mã

Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).

import tiktoken

en_doc = (
    "Large language models are trained on massive multilingual text corpora. "
    "However, when a tokenizer is trained primarily on English text, words in "
    "other languages are fragmented into many small subword or byte tokens. "
    "This increases computational cost and reduces effective context length."
)

vi_doc = (
    "Các mô hình ngôn ngữ lớn được huấn luyện trên kho văn bản đa ngữ khổng lồ. "
    "Tuy nhiên, khi bộ tách từ chủ yếu học từ tiếng Anh, các từ trong ngôn ngữ "
    "khác bị cắt vụn thành nhiều mảnh từ phụ hoặc byte nhỏ. Điều này làm tăng "
    "chi phí tính toán và thu hẹp độ dài ngữ cảnh hiệu dụng."
)

en_words = en_doc.split()
vi_words = vi_doc.split()
print(f"Số từ: EN = {len(en_words)} từ, VI = {len(vi_words)} từ")
Số từ: EN = 42 từ, VI = 63 từ
# (1) Đo hệ số phân mảnh (fertility) trên GPT-2 và GPT-4 (cl100k_base)
for name in ["gpt2", "cl100k_base"]:
    enc = tiktoken.get_encoding(name)
    en_toks = enc.encode(en_doc)
    vi_toks = enc.encode(vi_doc)
    f_en = len(en_toks) / len(en_words)
    f_vi = len(vi_toks) / len(vi_words)
    ratio = len(vi_toks) / len(en_toks)
    print(f"[{name:11s}] EN: {len(en_toks):3d} tokens "
          f"(fertility {f_en:.2f}) | VI: {len(vi_toks):3d} tokens "
          f"(fertility {f_vi:.2f}) | VI/EN = {ratio:.2f}x")


# (2) Huấn luyện 60 lượt gộp BPE tiếng Việt từ mức byte
vi_chunks = [" " + w if i > 0 else w for i, w in enumerate(vi_words)]
seqs = [list(c.encode("utf-8")) for c in vi_chunks]
init_tokens = sum(len(s) for s in seqs)

for step in range(60):
    counts = {}
    for s in seqs:
        for pair in zip(s, s[1:]):
            counts[pair] = counts.get(pair, 0) + 1
    best = max(counts, key=counts.get)
    new_id = 256 + step
    new_seqs = []
    for s in seqs:
        m, i = [], 0
        while i < len(s):
            if i < len(s) - 1 and (s[i], s[i + 1]) == best:
                m.append(new_id)
                i += 2
            else:
                m.append(s[i])
                i += 1
        new_seqs.append(m)
    seqs = new_seqs

final_tokens = sum(len(s) for s in seqs)
print("\nBPE tiếng Việt mở rộng (60 lượt gộp):")
print(f"  Trước khi gộp (byte-level): {init_tokens} tokens "
      f"(fertility {init_tokens/len(vi_words):.2f})")
print(f"  Sau 60 lượt gộp tiếng Việt: {final_tokens} tokens "
      f"(fertility {final_tokens/len(vi_words):.2f})")
[gpt2       ] EN:  51 tokens (fertility 1.21) | VI: 251 tokens (fertility 3.98) | VI/EN = 4.92x
[cl100k_base] EN:  50 tokens (fertility 1.19) | VI: 135 tokens (fertility 2.14) | VI/EN = 2.70x

BPE tiếng Việt mở rộng (60 lượt gộp):
  Trước khi gộp (byte-level): 370 tokens (fertility 5.87)
  Sau 60 lượt gộp tiếng Việt: 162 tokens (fertility 2.57)
Điều cần lưu ý

Kết quả thực nghiệm này giải thích tại sao khi phát triển các mô hình ngôn ngữ chuyên biệt cho tiếng Việt (như PhoGPT, VinaLLaMA) trên nền tảng các mô hình mã nguồn mở (như Llama-2 vốn chỉ có 32K token tiếng Anh), bước đầu tiên mà các nhóm kỹ sư thực hiện luôn là Vocabulary Expansion (Mở rộng từ vựng): huấn luyện thêm 10,000–20,00010{,}000\text{–}20{,}000 token BPE tiếng Việt, ghép nối vào ma trận nhúng EE và tiếp tục tiền huấn luyện (continual pre-training). Việc giảm tỷ lệ token tiếng Việt từ ∼4×\sim 4\times xuống ∼1.5×\sim 1.5\times đồng nghĩa với việc tăng tốc độ suy luận và mở rộng cửa sổ ngữ cảnh tiếng Việt lên gấp hơn 2.5 lần mà không cần thay đổi kiến trúc mạng nơ-ron!

Nếu bạn muốn đào sâu hơn nữa, dưới đây là một số bài tập thử thách mở rộng.

Mở rộng

Tìm các Glitch Tokens hoặc token dài nhất trong GPT-4. Dùng tiktoken.get_encoding("cl100k_base"), duyệt qua toàn bộ các mã token từ 0 đến 100255, dùng enc.decode_single_token_bytes(i) để lấy chuỗi byte của từng token, và in ra 20 token dài nhất trong bộ từ vựng của GPT-4. Bạn có nhận ra các từ khóa lập trình hay chuỗi ký tự đặc biệt nào không?

Mở rộng

Tại sao " SolidGoldMagikarp" là 1 token trong GPT-2? Kiểm tra tiktoken.get_encoding("gpt2").encode(" SolidGoldMagikarp") và tiktoken.get_encoding("cl100k_base").encode(" SolidGoldMagikarp"). Trong GPT-2 nó gồm bao nhiêu token, và sang GPT-4 nó đã được tách thành những mảnh nào?

Mở rộng

Cài đặt thuật toán mã hóa WordPiece (MaxMatch). Viết hàm wordpiece_encode(word, vocab) nhận vào một từ và tập hợp từ vựng vocab (ví dụ {"un", "aff", "ord", "##ord", "##able", "##a", "<unk>"}), dùng thuật toán tham lam tìm tiền tố dài nhất từ trái sang phải để tách từ "unaffordable". Nếu tại một bước không tìm được tiền tố nào có trong vocab, trả về ["<unk>"].

Mở rộng

Subword Regularization với quy hoạch động Viterbi. Cho một bộ từ vựng nhỏ kèm log-xác suất của từng mảnh log_p = {"n": -3.0, "e": -2.5, "w": -3.2, "s": -2.8, "t": -2.7, "ne": -2.2, "new": -1.4, "est": -1.6, "west": -1.8, "newest": -1.9}. Viết hàm quy hoạch động Viterbi tìm cách tách có tổng log_p lớn nhất cho từ "newest", và liệt kê 3 cách tách có xác suất cao nhất.


Tài liệu tham khảo

  1. 1994 Gage. A New Algorithm for Data Compression. The C Users Journal. 2
  2. 2012 Schuster & Nakajima. Japanese and Korean Voice Search. ICASSP. 4
  3. 2016 Sennrich, Haddow & Birch. Neural Machine Translation of Rare Words with Subword Units. ACL. 2
  4. 2018 Kudo. Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates. ACL. 4
  5. 2018 Kudo & Richardson. SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing. EMNLP. 4
  6. 2019 Radford et al. Language Models are Unsupervised Multitask Learners. OpenAI report. 3
  7. 2022 Xue et al. ByT5: Towards a Token-Free Future with Pre-trained Byte-to-Byte Models. TACL. 4
  8. 2023 Petrov et al. Language Model Tokenizers Introduce Unfairness Between Languages. NeurIPS. 5