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

BERT

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

  • Hiểu vì sao cần embedding theo ngữ cảnh (contextual embedding), và vì sao BERT là một Transformer encoder-only (chỉ có encoder) đọc cả hai phía của câu.
  • Giải thích được hai nhiệm vụ pre-train: mô hình ngôn ngữ có che (masked language model, MLM) với quy tắc 15% và 80/10/10, và dự đoán câu kế tiếp (next sentence prediction, NSP).
  • Biết cách fine-tune BERT cho ba kiểu đầu ra: phân loại cả chuỗi từ [CLS], gán nhãn từng token, và dự đoán một đoạn (span) trong văn bản.
  • Cài đặt được bước che token bằng NumPy, và dùng được pipeline("fill-mask") để xem BERT điền chỗ trống.

Đọc ba câu sau:

Câu“bay” nghĩa là
Con chim bay qua sông.chuyển động trên không
Máy bay hạ cánh lúc tám giờ.một phần của tên phương tiện
Anh thợ cầm cái bay trát vữa.dụng cụ của thợ xây

Ở bài Word2Vec, GloVe, FastText, mỗi token có đúng một dòng trong bảng embedding. Cả ba chữ “bay” ở trên đều tra cùng một dòng, nên nhận cùng một vectơ. Vectơ đó là một kiểu trung bình của mọi nghĩa mà “bay” từng có trong dữ liệu. Ta gọi đó là embedding tĩnh (static embedding).

Người đọc thì không bao giờ hiểu “bay” một mình. Ta hiểu nó nhờ các chữ xung quanh. Điều ta muốn là một vectơ cho mỗi lần xuất hiện, tính từ cả câu.

Che một chữ và đoán thử: “Hà Nội là ___ đô của Việt Nam.”

Nếu chỉ đọc bên trái, “Hà Nội là ___” có thể là “một”, “nơi”, “thành”… Chữ “đô” ở bên phải mới làm đáp án gần như chắc chắn là “thủ”. Nhiều chữ cần ngữ cảnh phía sau: “máy” trong “máy bay” hay “máy giặt”, “bàn” trong “bàn tay” hay “bàn học”.

GPT đọc từ trái sang phải: khi tính vectơ cho một vị trí, nó chỉ thấy các token đứng trước. Như vậy là đúng cho việc sinh văn bản, vì lúc sinh, phần bên phải chưa tồn tại. Nhưng với việc hiểu một câu đã có sẵn (phân loại nhận xét, tìm tên người, trả lời câu hỏi dựa trên đoạn văn), bỏ phí nửa bên phải là thiệt.

GPT học bằng cách đoán token kế tiếp. Nếu mỗi vị trí đã nhìn thấy cả câu, cách học này hỏng: token kế tiếp nằm ngay trong tầm nhìn, mô hình chỉ cần chép.

Lời giải của BERT giống bài tập điền từ ở trường: giấu đi vài chữ, rồi bắt mô hình đoán lại từ ngữ cảnh hai bên. Devlin và cộng sự (2019) gọi đây là mô hình ngôn ngữ có che, và ghi rằng nó lấy cảm hứng từ dạng bài điền chỗ trống (cloze) của Taylor (1953).

Bài toán của bài này là: học một mô hình cho mỗi token một vectơ phụ thuộc cả câu, từ văn bản thô không có nhãn, bằng cách che bớt token rồi đoán lại; sau đó dùng mô hình đó làm điểm khởi đầu cho các bài toán có nhãn.

BERT (Bidirectional Encoder Representations from Transformers) là phần encoder của Transformer (Vaswani và cộng sự, 2017), không có decoder, không có cross-attention. Self-attention không có mặt nạ nhân quả: mỗi vị trí nhìn mọi vị trí trong chuỗi, ở mọi lớp. Bài báo gọi số lớp là LL, số chiều ẩn là HH, số đầu là AA; bài này giữ quy ước của phần LLM: DD là số chiều ẩn, HH là số đầu, mỗi đầu có d=D/Hd = D/H chiều.

Thông sốBERT-baseBERT-large
Số khối encoder1224
Số chiều ẩn DD7681024
Số đầu attention HH1216
Số chiều mỗi đầu dd6464
Số tham số (bài báo, làm tròn)110 triệu340 triệu

Mạng truyền thẳng trong mỗi khối có 4D4D chiều ẩn (3072 với BERT-base), hàm kích hoạt GELU. Tự đếm cho BERT-base với từ vựng 30 522 token (tệp từ vựng thật; bài báo ghi tròn 30 000):

  • Embedding: 30 522×768+512×768+2×76830\,522 \times 768 + 512 \times 768 + 2 \times 768 cho token, vị trí và đoạn, cộng chuẩn hóa lớp: khoảng 23.8 triệu.
  • Một khối encoder: 4 ma trận 768×768768 \times 768 của attention (cả hệ số tự do) là 2.36 triệu; FFN 768→3072→768768 \to 3072 \to 768 là 4.72 triệu; cộng hai lớp chuẩn hóa (3 072 số), tổng khoảng 7.09 triệu. Nhân 12 khối được 85.1 triệu.
  • Lớp pooler trên [CLS] (một lớp 768×768768 \times 768, dùng cho NSP): khoảng 0.59 triệu.

Tổng là 109 482 240, đúng cỡ “110 triệu” của bài báo. Đếm cùng cách cho BERT-large được khoảng 335.1 triệu; bài báo ALBERT ghi 334 triệu, còn bài báo BERT làm tròn thành 340 triệu. Phần lớn tham số nằm trong các khối encoder; bảng embedding chiếm khoảng một phần năm.

BERT nhận một hoặc hai đoạn văn bản, gọi là câu A và câu B (thật ra là hai đoạn, có thể dài hơn một câu). Văn bản được tách bằng WordPiece (bài Tách từ), rồi thêm hai token đặc biệt:

  • [CLS] luôn đứng đầu. Nó là một token lớp (class token): vectơ của nó ở lớp cuối được dùng làm đại diện cho cả chuỗi, khi cần một nhãn cho cả chuỗi.
  • [SEP] đứng cuối mỗi đoạn, để phân tách A và B.

Ví dụ cặp câu “trời mưa” và “đường ngập”, giả sử mỗi âm tiết là một token:

Vị trí iiTokenĐoạn sis_i
0[CLS]A
1trờiA
2mưaA
3[SEP]A
4đườngB
5ngậpB
6[SEP]B

Vectơ đầu vào của vị trí ii là tổng của ba embedding:

ei=Etok[xi]+Eseg[si]+Epos[i]\mathbf{e}_i = \mathbf{E}^{\text{tok}}[x_i] + \mathbf{E}^{\text{seg}}[s_i] + \mathbf{E}^{\text{pos}}[i]
  • Etok\mathbf{E}^{\text{tok}} có ∣V∣|V| hàng, mỗi token một hàng; xix_i là số thứ tự của token ở vị trí ii. Đây chính là bảng embedding tĩnh.
  • Eseg\mathbf{E}^{\text{seg}} chỉ có 2 hàng: một cho A, một cho B. Nó là embedding đoạn (segment embedding), cho mô hình biết token thuộc câu nào ngoài dấu [SEP].
  • Epos\mathbf{E}^{\text{pos}} có 512 hàng, học được, nên BERT đọc tối đa 512 token mỗi chuỗi.

Mỗi bảng có DD cột. Sau 12 khối encoder, vị trí ii có vectơ hi∈RD\mathbf{h}_i \in \mathbb{R}^{D} (bài báo viết TiT_i, và CC cho vectơ của [CLS]). ei\mathbf{e}_i chỉ biết token xix_i; hi\mathbf{h}_i đã trộn thông tin của cả chuỗi qua 12 lần self-attention. hi\mathbf{h}_i chính là embedding theo ngữ cảnh mà phần Trực giác cần. Lưu ý ký hiệu: ở bài Word2Vec, dd là số chiều của embedding tĩnh; ở bài này, DD là số chiều của các vectơ ẩn (768), còn dd là số chiều của một đầu attention.

Bước 1: chọn vị trí. Với mỗi chuỗi, chọn ngẫu nhiên 15% số vị trí (không chọn [CLS], [SEP]). Gọi tập vị trí được chọn là M\mathcal{M}. Mã gốc của Google chọn khoảng đúng 15% số vị trí mỗi chuỗi; thư viện transformers chọn từng token độc lập với xác suất 0.15, nên số vị trí dao động quanh 15%.

Bước 2: làm hỏng đầu vào. Với mỗi i∈Mi \in \mathcal{M}, tung một đồng xu ba mặt:

  • 80%: thay token bằng [MASK];
  • 10%: thay bằng một token ngẫu nhiên trong từ vựng;
  • 10%: giữ nguyên.

Gọi chuỗi sau khi làm hỏng là x~\tilde{\mathbf{x}}.

Bước 3: đoán lại token gốc ở các vị trí được chọn. Cho x~\tilde{\mathbf{x}} qua encoder, được hi\mathbf{h}_i. Một đầu MLM (một lớp nhỏ rồi một lớp tuyến tính ra ∣V∣|V| logit) cộng softmax cho phân phối pθ(⋅∣x~)p_{\boldsymbol{\theta}}(\cdot \mid \tilde{\mathbf{x}}) trên từ vựng ở vị trí ii. Hàm mất mát là cross-entropy, chỉ lấy trung bình trên M\mathcal{M}:

LMLM(θ)=−1∣M∣∑i∈Mln⁡pθ(xi∣x~)\mathcal{L}_{\text{MLM}}(\boldsymbol{\theta}) = -\frac{1}{|\mathcal{M}|} \sum_{i \in \mathcal{M}} \ln p_{\boldsymbol{\theta}}(x_i \mid \tilde{\mathbf{x}})

Ở đây xix_i là token gốc, không phải token trong x~\tilde{\mathbf{x}}. Các vị trí ngoài M\mathcal{M} không góp gì vào loss: mô hình không bị hỏi về chúng.

So với GPT: GPT tính loss ở mọi vị trí, mỗi vị trí có điều kiện là phần bên trái. BERT chỉ tính ở khoảng 15% vị trí, nhưng mỗi vị trí có điều kiện là cả chuỗi (đã bị làm hỏng). Mỗi câu vì vậy cho ít tín hiệu học hơn. Bài báo ghi nhận MLM hội tụ chậm hơn mô hình trái sang phải một chút, nhưng vượt nó về độ chính xác rất sớm.

Vì sao không thay hết bằng [MASK]? Token [MASK] chỉ có trong lúc pre-train. Khi fine-tune và khi dùng, đầu vào là văn bản thật, không có [MASK] nào. Nếu luôn che bằng [MASK], mô hình học được một mẹo: chỉ chỗ nào có [MASK] mới cần đoán, còn token thật thì cứ tin. Hai nhánh 10% phá mẹo này:

  • Nhánh ngẫu nhiên: một token thật cũng có thể là token sai, nên mô hình phải kiểm tra mọi token bằng ngữ cảnh.
  • Nhánh giữ nguyên: đôi khi đáp án chính là token đang thấy, nên biểu diễn của token thật được kéo về phía nghĩa thật của nó.

Mô hình không biết vị trí nào đang bị hỏi, nên phải giữ một biểu diễn tốt cho mọi token. Tính trên toàn bộ token, 0.15×0.8=12%0.15 \times 0.8 = 12\% thấy [MASK], 0.15×0.1=1.5%0.15 \times 0.1 = 1.5\% thấy token ngẫu nhiên, 1.5%1.5\% được giữ nguyên nhưng vẫn bị hỏi. Bài báo cho rằng 1.5% token ngẫu nhiên đủ ít để không làm hại khả năng hiểu ngôn ngữ.

Phụ lục của bài báo có thí nghiệm đổi tỉ lệ. Khi fine-tune, kết quả gần như không đổi (MNLI 84.2 với 80/10/10, 84.3 với 100% [MASK]). Nhưng khi giữ BERT cố định và chỉ lấy vectơ làm đặc trưng cho bài nhận dạng thực thể, 100% [MASK] cho F1 94.0, kém 80/10/10 (94.9). Đúng chỗ mà độ lệch giữa pre-train và lúc dùng bị phóng to.

Nhiều bài toán xét quan hệ giữa hai câu: câu B có suy ra từ câu A không, đoạn văn có chứa câu trả lời cho câu hỏi không. MLM không dạy trực tiếp điều này. BERT thêm nhiệm vụ thứ hai: lấy câu A, rồi 50% lấy B là đoạn thật sự đi sau A trong văn bản (nhãn IsNext), 50% lấy B là một đoạn ngẫu nhiên từ chỗ khác (nhãn NotNext). Vectơ hCLS\mathbf{h}_{\text{CLS}} đi qua một lớp phân loại hai lớp. Loss pre-train là tổng của loss MLM và loss NSP.

Trong thí nghiệm của bài báo, bỏ NSP làm điểm tụt trên QNLI (88.4 xuống 84.9) và vài tác vụ khác. Nhưng RoBERTa (Liu và cộng sự, 2019) huấn luyện lại BERT cẩn thận hơn và thấy ngược lại: bỏ NSP, thay bằng các chuỗi liền mạch dài tới 512 token, cho kết quả ngang hoặc nhỉnh hơn. Họ cho rằng bản thí nghiệm gốc có thể chỉ bỏ loss NSP mà vẫn giữ định dạng cặp đoạn A/B. ALBERT (Lan và cộng sự, 2020) giải thích thêm: một đoạn ngẫu nhiên thường khác chủ đề với A, nên NSP dễ đoán chỉ nhờ chủ đề. ALBERT thay NSP bằng dự đoán thứ tự câu (sentence-order prediction, SOP): hai đoạn liền nhau, đúng thứ tự hoặc bị đảo.

Sau pre-train, ta có h1,…,hL\mathbf{h}_1, \dots, \mathbf{h}_L cho mọi token của một chuỗi dài LL. Fine-tune thêm một lớp nhỏ phía trên rồi huấn luyện toàn bộ mô hình (cả BERT lẫn lớp mới) trên dữ liệu có nhãn, thường chỉ vài epoch. Có ba kiểu:

1. Một nhãn cho cả chuỗi. Phân loại cảm xúc, phát hiện thư rác, hay xét quan hệ của một cặp câu. Với KK lớp, chỉ thêm ma trận W∈RK×D\mathbf{W} \in \mathbb{R}^{K \times D}:

y^=softmax(WhCLS)\hat{\mathbf{y}} = \mathrm{softmax}(\mathbf{W}\mathbf{h}_{\text{CLS}})

Vì sao lấy [CLS]? Nó không mang nghĩa riêng nào, ở mọi lớp nó nhìn được mọi token, và NSP đã tập cho nó tóm tắt cả chuỗi. Bài Phân tích cảm xúc dùng kiểu này.

2. Một nhãn cho mỗi token. Nhận dạng thực thể: “Nguyễn Du quê ở Hà Tĩnh” cần nhãn người cho “Nguyễn Du”, nhãn địa danh cho “Hà Tĩnh”. Cùng một lớp W\mathbf{W} áp lên từng hi\mathbf{h}_i. Một từ bị WordPiece chia thành nhiều mảnh thì bài báo chỉ đưa vectơ của mảnh đầu vào lớp phân loại. Chi tiết ở bài Nhận dạng thực thể.

3. Một đoạn trong văn bản. Hỏi đáp trích xuất: câu hỏi là A, đoạn văn là B, đáp án là một đoạn liền của B. Chỉ thêm hai vectơ wstart,wend∈RD\mathbf{w}_{\text{start}}, \mathbf{w}_{\text{end}} \in \mathbb{R}^{D}. Xác suất token ii là điểm đầu của đáp án:

pstart(i)=exp⁡(wstart⊤hi)∑kexp⁡(wstart⊤hk)p_{\text{start}}(i) = \frac{\exp(\mathbf{w}_{\text{start}}^\top \mathbf{h}_i)}{\sum_{k} \exp(\mathbf{w}_{\text{start}}^\top \mathbf{h}_k)}

với tổng lấy trên các token của đoạn văn; điểm cuối tính giống vậy bằng wend\mathbf{w}_{\text{end}}. Khi dùng, chọn cặp (i,j)(i, j) với j≥ij \ge i có wstart⊤hi+wend⊤hj\mathbf{w}_{\text{start}}^\top \mathbf{h}_i + \mathbf{w}_{\text{end}}^\top \mathbf{h}_j lớn nhất. Chi tiết ở bài Hỏi đáp.

Điểm chung: phần lớn tri thức nằm trong BERT đã pre-train, phần thêm vào rất nhỏ (K×DK \times D hay 2D2D số). Đó là lý do một mô hình pre-train một lần dùng lại được cho nhiều bài toán. Khi ra mắt (cuối 2018), BERT-large đạt 80.5 điểm GLUE (hơn kết quả tốt nhất trước đó 7.7 điểm) và F1 93.2 trên SQuAD v1.1.

Sau BERT có nhiều biến thể, mỗi cái sửa một điểm:

Mô hìnhNăm, tác giảThay đổi chính
RoBERTa2019, Liu và cộng sựBỏ NSP; che động (chọn lại vị trí che mỗi lần chuỗi được đưa vào, thay vì cố định từ trước); lô lớn hơn, huấn luyện lâu hơn, hơn 160 GB văn bản; BPE theo byte
ALBERT2020, Lan và cộng sựTách bảng embedding thành hai ma trận nhỏ (∣V∣×E\lvert V \rvert \times E rồi E×DE \times D, với EE nhỏ hơn DD); dùng chung tham số giữa mọi lớp; thay NSP bằng SOP. ALBERT-large có 18 triệu tham số, so với 334 triệu của BERT-large (theo cách đếm của bài báo ALBERT). Ít tham số nhưng không ít phép tính, vì mỗi lớp vẫn phải chạy đủ; trong bảng so sánh của bài báo, bản large kém BERT-large (điểm trung bình 82.4 so với 85.2); bản xlarge (60 triệu) chỉ nhỉnh hơn (85.5) nhưng chậm hơn; bản xxlarge (235 triệu) vượt rõ (88.7) nhưng chạy chậm khoảng 3 lần
DistilBERT2019, Sanh và cộng sựChắt lọc tri thức (knowledge distillation) từ BERT-base: 6 lớp thay vì 12, khoảng 66 triệu tham số, nhỏ hơn 40%, nhanh hơn 60%, giữ khoảng 97% khả năng hiểu ngôn ngữ (đo trên GLUE)
ELECTRA2020, Clark và cộng sựMột mô hình nhỏ (generator) thay vài token bằng token hợp lý; mô hình chính (discriminator) đoán từng token là gốc hay bị thay. Học từ mọi token chứ không chỉ 15%, và không có [MASK] trong đầu vào của mô hình chính

Có hai hướng:

  • Mô hình đa ngôn ngữ. mBERT (BERT-Base Multilingual Cased của Google) học trên Wikipedia của 104 ngôn ngữ, với một từ vựng WordPiece chung khoảng 119 nghìn token. Tự đếm từ tệp cấu hình: khoảng 178 triệu tham số, hơn một nửa (91.8 triệu) nằm ở bảng embedding token. XLM-R (Conneau và cộng sự, 2020) học trên hơn 2 TB văn bản CommonCrawl của 100 ngôn ngữ, và vượt mBERT rõ rệt (cao hơn trung bình 14.6 điểm trên XNLI).
  • Mô hình riêng cho tiếng Việt. PhoBERT (Nguyen và Nguyen, 2020) có bản base (135 triệu tham số) và large (370 triệu), học theo quy trình của RoBERTa trên 20 GB văn bản tiếng Việt. Điểm riêng: tiếng Việt dùng dấu cách giữa các âm tiết, không phải giữa các từ, nên “thủ đô” là hai âm tiết nhưng một từ. PhoBERT tách từ tiếng Việt (word segmentation: gộp âm tiết thành từ) trước, bằng RDRSegmenter của VnCoreNLP (“Hà_Nội là thủ_đô của Việt_Nam”), rồi mới tách subword bằng BPE. Vì vậy văn bản đưa vào PhoBERT phải được tách từ theo cùng cách. Trên các bài gán nhãn từ loại, phân tích cú pháp, nhận dạng thực thể và suy luận ngôn ngữ tiếng Việt, PhoBERT vượt XLM-R dù ít tham số hơn, và dù kho văn bản của XLM-R có tới 137 GB tiếng Việt, gấp khoảng 7 lần.
  • BERT không sinh văn bản tự nhiên. Nó điền chỗ trống trong một câu đã có, không viết tiếp từ trái sang phải. Việc sinh là của GPT (bài GPT và huấn luyện phân tán) và các mô hình encoder–decoder.
  • Tối đa 512 token. Văn bản dài hơn phải cắt thành nhiều đoạn. Chi phí self-attention tăng theo bình phương độ dài; các cách xử lý ở bài Transformers nâng cao.
  • Vectơ thô chưa phải embedding câu tốt. Xem hộp thứ ba ở mục Lỗi hay gặp.

Khác biệt cốt lõi giữa BERT và GPT nằm ở mặt nạ attention. Trong hình dưới, mỗi hàng là token đang được tính, mỗi cột là token mà nó được nhìn. Hãy so hai ô ở hàng “chim”, cột “bay”: BERT được nhìn, GPT bị che.

BERT (encoder): nhìn cả hai phíaConchimbayquasôngConchimbayquasôngGPT (decoder): chỉ nhìn phía trướcConchimbayquasôngConchimbayquasôngHàng:token đang tínhCột:token được nhìn25 / 25 ôđược nhìnđược nhìnbị cheô đang so:chimnhìn bay
Mặt nạ cộng vào QK⊤/d\mathbf{Q}\mathbf{K}^\top/\sqrt{d} trước softmax: ô bị che nhận −∞-\infty nên có trọng số 0; BERT không che ô nào (25 trên 25 ô được nhìn), GPT che 10 ô phía trên đường chéo.

Hàng cuối của lưới GPT là hàng duy nhất nhìn đủ cả câu: chỉ token cuối mới biết mọi thứ. Ở lưới BERT, hàng nào cũng nhìn đủ, kể cả hàng đầu. Cái giá: lưới đầy thì không thể dạy bằng cách đoán token kế tiếp, nên BERT cần MLM.

Code trong bài chạy thử lần cuối tháng 11/2026: ô che token với Python 3.14 và NumPy 2.4 (bản có trong Pyodide), ô BERT với Python 3.14, transformers 5.22 và PyTorch 2.14 trên CPU. Ô che token chạy ngay trên trang. Ô BERT cần PyTorch và tải mô hình từ Hugging Face Hub, nên chạy trên máy bạn.

Ô dưới làm lại Bước 1 và Bước 2 của MLM trên một câu tiếng Việt, tách theo âm tiết cho dễ đọc (BERT thật dùng WordPiece). Mỗi token thường được chọn với xác suất 0.15, giống cách thư viện transformers làm, nên số vị trí được chọn trong một câu dao động quanh 15%. Phần sau đếm tỉ lệ trên 2000 câu giả lập, mỗi câu 126 token, để thấy các con số hiện ra.

Chọn và che token cho MLM

# Chọn và che token cho MLM theo cách của BERT: 15%, rồi 80/10/10
import numpy as np
rng = np.random.default_rng(0)
# Một câu đã tách token (theo âm tiết, để dễ đọc), có [CLS] và [SEP]
tokens = ("[CLS] hà nội là thủ đô của việt nam , nơi có hơn "
"một nghìn năm lịch sử và rất nhiều món ăn ngon . [SEP]").split()
special = {"[CLS]", "[SEP]"}
vocab = sorted(set(tokens) - special) + ["mèo", "sông", "xanh"]
def mask_tokens(toks, rng):
out, picked = list(toks), []
for i, t in enumerate(toks):
if t in special or rng.random() >= 0.15:
continue # không chọn: vị trí này không tính loss
u = rng.random()
if u < 0.8:
out[i] = "[MASK]" # 80%: thay bằng [MASK]
elif u < 0.9:
out[i] = vocab[rng.integers(len(vocab))] # 10%: token ngẫu nhiên
# 10% còn lại: giữ nguyên token gốc
picked.append(i)
return out, picked
out, picked = mask_tokens(tokens, rng)
print(" ".join(out))
print(f"{len(picked)} of {len(tokens) - 2} tokens picked")
for i in picked:
print(f"pos {i:2d}: {tokens[i]:>6} -> {out[i]}")
# Đếm trên 2000 câu giả lập, mỗi câu 126 token (cộng [CLS], [SEP])
n_sent, n_tok = 2000, 126
sel = rng.random((n_sent, n_tok)) < 0.15
u = rng.random((n_sent, n_tok))[sel] # chỉ xét các vị trí được chọn
print(f"picked: {sel.mean():.4f}")
print(f"[MASK] {np.mean(u < 0.8):.3f} random "
f"{np.mean((u >= 0.8) & (u < 0.9)):.3f} keep {np.mean(u >= 0.9):.3f}")

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

Đọc kết quả:

  • Câu mẫu có 24 token thường, 3 vị trí được chọn (12.5%, vì một câu ngắn dao động nhiều quanh 15%). Vị trí 3 (“là”) và vị trí 18 (“và”) thành [MASK]. Vị trí 11 (“có”) bị thay bằng “năm”: câu thành “nơi năm hơn một nghìn năm”, sai nhưng trông vẫn như chữ thật. Mô hình phải nhận ra và đoán lại “có”.
  • Loss chỉ tính ở 3 vị trí này, với nhãn là “là”, “có”, “và”. 21 vị trí còn lại không bị hỏi.
  • Trên 2000 câu, tỉ lệ được chọn là 0.1507; trong số được chọn, 0.797 thành [MASK], 0.101 thành token ngẫu nhiên, 0.102 giữ nguyên. Đúng 15% và 80/10/10 như lý thuyết, cộng sai số ngẫu nhiên.

Câu mẫu không có vị trí nào rơi vào nhánh giữ nguyên. Với một vị trí như vậy, đầu vào và nhãn giống nhau, nhưng mô hình không biết điều đó, và vẫn phải đoán.

File dưới dùng mBERT (google-bert/bert-base-multilingual-cased), vì nó đọc được cả tiếng Việt lẫn tiếng Anh. Phần đầu gọi pipeline("fill-mask") cho hai câu có [MASK], in 5 token có xác suất cao nhất. Phần sau nạp riêng encoder (AutoModel, không có đầu MLM), lấy last_hidden_state (các vectơ hi\mathbf{h}_i) cho ba câu có chữ “bay”, và tính cosine giữa các vectơ của “bay”.

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

fill-mask và vectơ theo ngữ cảnh với mBERT

Cần cài
pip install transformers torch
Phần cứng
CPU là đủ. Lần đầu tải khoảng 717 MB (tệp trọng số 714 MB). Trên máy phát triển, cả file chạy khoảng một phút, phần lớn là thời gian nạp mô hình
# BERT đa ngôn ngữ: điền chỗ trống, rồi so vectơ của "bay" trong ba câu
import torch
from transformers import AutoModel, AutoTokenizer, pipeline
MODEL = "google-bert/bert-base-multilingual-cased"
fill = pipeline("fill-mask", model=MODEL)
for text in ["Hà Nội là [MASK] đô của Việt Nam.",
"I drink a cup of [MASK] every morning."]:
print(text)
for r in fill(text, top_k=5): # 5 token có xác suất cao nhất
print(f" {r['token_str']:<8} {r['score']:.3f}")
tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModel.from_pretrained(MODEL) # chỉ phần encoder, không có đầu MLM
model.eval()
def encode(sentence):
enc = tok(sentence, return_tensors="pt")
with torch.no_grad():
h = model(**enc).last_hidden_state[0] # (số token, 768)
tokens = tok.convert_ids_to_tokens(enc["input_ids"][0])
print(f"{tuple(h.shape)} {tokens}")
return tokens, h
t1, h1 = encode("Con chim bay qua sông.")
t2, h2 = encode("Đàn chim bay về phía nam.")
t3, h3 = encode("Máy bay hạ cánh lúc tám giờ.")
v1, v2, v3 = (h[t.index("bay")] for t, h in [(t1, h1), (t2, h2), (t3, h3)])
cos = torch.nn.functional.cosine_similarity
print(f"cos(bird flies, birds fly) = {cos(v1, v2, dim=0):.3f}")
print(f"cos(bird flies, airplane) = {cos(v1, v3, dim=0):.3f}")
print(f"cos(birds fly, airplane) = {cos(v2, v3, dim=0):.3f}")
# Mốc so sánh: "bay" của câu 1 với một chữ khác hẳn ("về" của câu 2)
print(f"cos(bay, ve) = {cos(v1, h2[t2.index('về')], dim=0):.3f}")
# Mốc nền: trung bình cosine giữa mọi cặp token của câu 1 và câu 3
n1 = torch.nn.functional.normalize(h1, dim=1)
n3 = torch.nn.functional.normalize(h3, dim=1)
print(f"mean cos, any token pair = {(n1 @ n3.T).mean():.3f}")

Kết quả mẫu

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

Hà Nội là [MASK] đô của Việt Nam.
thủ 0.935
Thủ 0.031
kinh 0.012
đô 0.004
đại 0.002
I drink a cup of [MASK] every morning.
coffee 0.664
drink 0.093
milk 0.056
wine 0.052
beer 0.021
(8, 768) ['[CLS]', 'Con', 'chim', 'bay', 'qua', 'sông', '.', '[SEP]']
(10, 768) ['[CLS]', 'Đà', '##n', 'chim', 'bay', 'về', 'phía', 'nam', '.', '[SEP]']
(12, 768) ['[CLS]', 'Má', '##y', 'bay', 'hạ', 'cánh', 'lúc', 'tá', '##m', 'giờ', '.', '[SEP]']
cos(bird flies, birds fly) = 0.729
cos(bird flies, airplane) = 0.562
cos(birds fly, airplane) = 0.539
cos(bay, ve) = 0.542
mean cos, any token pair = 0.325

Đọc kết quả:

  • Câu tiếng Việt: “thủ” được 0.935. Đây đúng là câu ở phần Trực giác: đáp án chắc chắn nhờ chữ “đô” ở bên phải. Phương án thứ hai “Thủ” chỉ khác chữ hoa, vì đây là mô hình phân biệt hoa thường (cased). “kinh” (0.012) cũng hợp lý: “kinh đô”.
  • Câu tiếng Anh: “coffee” được 0.664. Hạng hai lại là “drink” (0.093): mô hình chép lại một chữ có sẵn trong câu, mà “a cup of drink” thì không chỉ một đồ uống cụ thể nào. Sau đó mới tới “milk”, “wine”, “beer”. Phương án đầu hợp lý không có nghĩa mọi phương án trong top-5 đều hợp lý.
  • Hình dạng (8, 768): câu đầu có 8 token tính cả [CLS] và [SEP], mỗi token một vectơ 768 chiều. Bộ tách từ (tokenizer) của mBERT cắt một số âm tiết tiếng Việt thành mảnh: “Máy” thành Má và ##y, “tám” thành tá và ##m. Bài Tách từ giải thích vì sao.
  • Cosine: “bay” trong hai câu về chim gần nhau nhất (0.729). “bay” của “chim bay” và của “máy bay” xa hơn (0.562 và 0.539). Với embedding tĩnh, cả ba con số này đều bằng 1, vì cùng một dòng của bảng.
  • Hai mốc để đọc các con số trên. “bay” của câu 1 so với một chữ khác hẳn, “về” của câu 2, được 0.542: gần bằng 0.562 của cặp “chim bay” và “máy bay”. Tức là với mô hình, chữ “bay” trong “máy bay” cách chữ “bay” của “chim bay” xa cỡ như một chữ khác. Mốc nền, trung bình cosine giữa mọi cặp token của câu 1 và câu 3 (kể cả [CLS], [SEP] và dấu chấm), là 0.325.

Hai lưu ý trung thực. Thứ nhất, vectơ của BERT không đẳng hướng (anisotropic): chúng chụm về một vùng hẹp, nên cosine giữa hai token bất kỳ đã dương đáng kể (Ethayarajh, 2019); mốc nền 0.325 ở trên cho thấy điều đó. Vì vậy hãy so các con số với nhau và với mốc, đừng đọc riêng một con số. Thứ hai, ba câu chưa đủ để kết luận chung. Đây là một minh họa, không phải phép đo.

Khi chạy, transformers còn in ra (ở luồng lỗi) hai báo cáo nạp trọng số, liệt kê các tham số UNEXPECTED: có trong checkpoint nhưng lớp mô hình không dùng. Lần thứ nhất, pipeline nạp BertForMaskedLM và bỏ qua bert.pooler.* (lớp pooler) cùng cls.seq_relationship.* (đầu NSP), vì điền chỗ trống chỉ cần đầu MLM. Lần thứ hai, AutoModel nạp BertModel (encoder cùng lớp pooler) và bỏ qua cls.predictions.*, tức chính đầu MLM, cùng cls.seq_relationship.*, vì ở bước này ta chỉ cần encoder. Checkpoint giữ mọi đầu từ lúc pre-train; mỗi lớp mô hình lấy phần nó cần. Đây không phải lỗi.

Câu 1/5 Một lô dữ liệu pre-train có 10 00010\,000 token thường (không tính [CLS], [SEP]). Theo cách che của BERT, trung bình bao nhiêu vị trí góp vào loss MLM, và ở bao nhiêu vị trí mô hình thật sự thấy token [MASK]?
Câu 2/5 Vì sao BERT không thay mọi vị trí được chọn bằng [MASK]?
Câu 3/5 Vì sao không thể pre-train BERT bằng dự đoán token kế tiếp như GPT?
Câu 4/5 Cho một câu hỏi "Hàng giao ngày nào?" và một email dài. Đáp án là một đoạn liền nằm trong email. Khi fine-tune BERT cho việc này, đầu ra hợp nhất là gì?
Câu 5/5 Bảng embedding word2vec có một dòng cho token "bay". Cosine giữa vectơ word2vec của "bay" trong "Con chim bay qua sông" và trong "Máy bay hạ cánh" bằng bao nhiêu?

Ý chính:

  • Embedding tĩnh cho mỗi token một vectơ. BERT cho mỗi lần xuất hiện một vectơ hi\mathbf{h}_i, tính từ cả câu qua một chồng khối encoder có attention hai chiều (BERT-base: 12 lớp, D=768D = 768, 12 đầu, khoảng 110 triệu tham số).
  • Đầu vào là tổng embedding token, đoạn và vị trí, có [CLS] ở đầu và [SEP] sau mỗi đoạn, tối đa 512 token.
  • MLM chọn 15% vị trí; trong đó 80% thành [MASK], 10% thành token ngẫu nhiên, 10% giữ nguyên, để giảm độ lệch giữa pre-train và lúc dùng. Loss LMLM\mathcal{L}_{\text{MLM}} chỉ tính ở các vị trí được chọn, so với token gốc. NSP thêm vào lúc đầu, RoBERTa cho thấy bỏ đi không thiệt.
  • Fine-tune thêm một lớp nhỏ: phân loại từ hCLS\mathbf{h}_{\text{CLS}}, gán nhãn từ từng hi\mathbf{h}_i, hoặc chọn span bằng hai vectơ đầu và cuối. Cho tiếng Việt, có mBERT, XLM-R và PhoBERT (cần tách từ trước).

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