Chương 6·Bài 6.3

BERT

Ở bài 6.1, câu “bài giảng không hay” bị đoán là khen, vì baseline chỉ đếm từ và không thấy “không” đi với “hay”. Để hiểu câu, vector của mỗi từ phải phụ thuộc vào các từ xung quanh. BERT là model Transformer đọc cả câu theo hai chiều và cho mỗi token một vector theo ngữ cảnh. Bài này học cách BERT được huấn luyện, và cách thêm một lớp nhỏ lên trên để dùng BERT phân loại câu hoặc gán nhãn từng token.

Token có ngữ cảnh, không chỉ một hàng trong bảng

Embedding đầu vào là phép tra bảng: token ID tit_i lấy vector E[ti]E[t_i]. Sau encoder, hidden state hi\mathbf{h}_i phụ thuộc các token được phép nhìn trong câu. “đường” trong “đường đi” và “đường ngọt” bắt đầu ở cùng một hàng embedding nhưng nhận hidden states khác nhau. Embedding tĩnh như word2vec (bài 3.8) chỉ có một vector cho “bank”, dù nghĩa là bờ sông hay ngân hàng; hidden state của encoder thì tách được hai nghĩa.

Positional embedding bổ sung vị trí, vì self-attention không tự biết thứ tự từ chỉ từ một tập vector.

Hỏi nhanh

Để phân biệt “đường” là lối đi với “đường” là vị ngọt, một bạn lấy token_embedding.weight[id("đường")] của checkpoint BERT làm vector của từ trong từng câu. Cách này cho ra gì?

BERT là tên một họ model và một phương pháp pretraining cụ thể, không phải tên của mọi encoder. BERT gốc dùng WordPiece, token embedding, position embedding và token-type embedding để đánh dấu đoạn A/B. Một số biến thể bỏ hoặc đổi token-type embedding, tokenizer hoặc mục tiêu học. Vì vậy cần đọc config và model card của đúng checkpoint; PhoBERT, RoBERTa hay DistilBERT không thừa hưởng mọi đặc điểm của BERT gốc.

Một attention head tính điểm giữa query và key. Với ma trận Q,K,VQ,K,V rút từ hidden states, ta dùng

A=softmax⁡(QK⊤dk+M),H′=AV.A=\operatorname{softmax}\left(\frac{QK^{\top}}{\sqrt{d_k}}+M\right),\qquad H'=AV.

Softmax theo chiều key làm mỗi hàng thành phân phối trọng số. MM đặt −∞-\infty ở liên kết bị cấm để sau softmax trọng số bằng 0. Encoder BERT không dùng causal mask cho các token thật: khi đọc một câu hoàn chỉnh để phân loại, nó được nhìn hai phía, nhưng vẫn cần mask padding. Đó cũng là khác biệt chính giữa BERT và GPT: BERT là encoder đọc hai phía để hiểu câu, GPT là decoder chỉ nhìn bên trái để sinh tiếp.

Causal mask dành cho dự đoán tiếp token ở bài 6.4; dùng nhầm hai loại mask có thể làm đổi nhiệm vụ hoặc lộ đáp án.

Hỏi nhanh

Câu có 3 token thật, được pad thêm 2 PAD thành độ dài 5. Ở hàng attention của token thật thứ hai, encoder BERT và decoder causal lần lượt cho phép bao nhiêu key?

Encoder chỉ chặn cột PAD; causal mask còn chặn key tương lai.encoder BERT: chặn PADt1t2t3PADPADt1t2t3PADPADhàng t2: 3 keydecoder causal: chặn PAD và tương lait1t2t3PADPADhàng t2: 2 key
Encoder chỉ chặn cột PAD; causal mask còn chặn key tương lai.
Vector của chữ “đường” trong 12 câu: càng lên lớp cao, hai nghĩa càng tách xa.

BERT học trước bằng cách che một phần câu

Masked language modeling (MLM) chọn một số vị trí, làm biến đổi token đầu vào ở đó, rồi yêu cầu đoán token gốc. Ví dụ “An [MASK] sách” có thể cần “đọc”. Loss chỉ tính trên các vị trí được chọn; các token còn lại chỉ đóng vai ngữ cảnh. BERT gốc chọn 15% vị trí; trong số đó 80% thay bằng [MASK], 10% thay bằng token ngẫu nhiên, 10% giữ nguyên.

Lý do nằm ở fine-tuning: [MASK] không bao giờ xuất hiện trong câu thật, nên nếu mọi vị trí được chấm đều mang [MASK], model chỉ cần biểu diễn tốt ở những chỗ có ký hiệu ấy. Khi một token thật cũng có thể là vị trí bị chấm, model phải giữ biểu diễn tốt cho mọi token. Bài báo BERT.

Nếu tập vị trí được chọn là SS, loss MLM là

LMLM=−1∣S∣∑i∈Slog⁡pθ(ti∣t~1,…,t~L).\mathcal{L}_{\mathrm{MLM}}=-\frac{1}{|S|}\sum_{i\in S}\log p_\theta(t_i\mid\widetilde{t}_1,\ldots,\widetilde{t}_L).

Dấu ngã nhắc rằng chuỗi đầu vào đã được làm biến đổi. Nhãn tit_i vẫn là token gốc. MLM học từ corpus không có nhãn phân loại, còn fine-tuning học từ các câu có nhãn nhiệm vụ. Pretraining không tự cho biết nhận xét nào tích cực; head phân loại và nhiều khi cả encoder phải được điều chỉnh.

Hỏi nhanh

Một vị trí được chọn cho MLM chứa token gốc “đọc”, và ở lượt này đầu vào tại đó được thay bằng [MASK]. Nhãn của vị trí ấy là gì?

BERT chọn 15% vị trí: 80% thay MASK, 10% thay token ngẫu nhiên, 10% giữ nguyên.15% vị trí được chọn để chấm loss80%: thay [MASK]An[MASK]sách→ đoán “đọc”10%: token ngẫu nhiênAnbànsách→ đoán “đọc”10%: giữ nguyênAnđọcsách→ đoán “đọc”nhãn luôn là token gốc; vị trí không được chọn mang -100
BERT chọn 15% vị trí: 80% thay MASK, 10% thay token ngẫu nhiên, 10% giữ nguyên.

Hỏi nhanh

Một câu có 40 token thật và 24 PAD. MLM chọn 6 vị trí, và tổng −log⁡p-\log p của token gốc trên 6 vị trí ấy là 9.0. LMLM\mathcal{L}_{\mathrm{MLM}} của câu bằng bao nhiêu?

Hai nhiệm vụ, hai vị trí đặt head

Encoder trả HH có shape (B, L, d). Có hai kiểu head:

  • Phân loại câu (sequence classification) cần một dự đoán cho mỗi câu: lấy hidden state của [CLS] hoặc pooling như lúc model được huấn luyện, rồi đưa qua linear head thành (B, C), với CC lớp.
  • Phân loại token (token classification) cần một dự đoán cho mỗi vị trí: linear head trên từng hi\mathbf{h}_i cho (B, L, C).

Nhận diện thực thể có tên (NER) là phân loại token, thường dùng nhãn BIO: B-LOC bắt đầu địa danh, I-LOC tiếp tục địa danh, O ở ngoài thực thể: “Nguyễn Văn An đến Huế” là B-PER I-PER I-PER O B-LOC. Gán từ loại (danh từ, động từ...) cho từng từ cũng là token classification; thêm dấu cho văn bản không dấu, “toi di hoc” thành “tôi đi học”, thường được làm như vậy, mỗi âm tiết một nhãn.

Cặp câu được ghép thành [CLS] A [SEP] B [SEP], với token_type_ids bằng 0 cho phần A, kể cả [CLS] và [SEP] đầu, và bằng 1 cho phần B. Head phân loại 3 lớp trên bert-base (ẩn 768) có 768⋅3+3=2307768 \cdot 3 + 3 = 2307 tham số. attention_mask của Hugging Face dùng 1 cho token giữ lại, ngược với key_padding_mask của nn.MultiheadAttention (True là bỏ), nên phải đảo trước khi chuyển.

Ba cách đặt head: đọc CLS cho cả câu, mỗi token một nhãn, hoặc điểm đầu và cuối của đoạn.CLSh0Anh1đếnh2Hàh3Nộih4SEPh5encoder · (B, L, d)(B, C)head câu: đọc CLS(B, L, C): một nhãn mỗi vị tríhead token (NER)head trích đoạn(B, L, 2)softmax theo L
Ba cách đặt head: đọc CLS cho cả câu, mỗi token một nhãn, hoặc điểm đầu và cuối của đoạn.
Đại lượng Shape Kiểu hoặc ý nghĩa
input_ids (B, L) số nguyên, dùng tra bảng
attention_mask kiểu HF (B, L) 1 giữ, 0 padding
last_hidden_state (B, L, d) vector ngữ cảnh
logits phân loại câu (B, C) điểm trước softmax
logits gán nhãn token (B, L, C) một hàng điểm mỗi vị trí

Head pretrained cho MLM không thể dùng nguyên như head NER vì số lớp và ý nghĩa đầu ra khác nhau. Một head nhiệm vụ mới thường khởi tạo ngẫu nhiên; trọng số pretrained chỉ nằm ở backbone. Vì vậy dự đoán in ra ngay sau khi gắn head chưa mang thông tin gì về nhiệm vụ; head phải được học trên dữ liệu có nhãn.

Extractive question answering (chọn đoạn trả lời nằm sẵn trong văn bản, như bộ SQuAD) cũng dùng head token. Câu hỏi và đoạn văn được ghép thành một chuỗi [CLS] câu hỏi [SEP] đoạn văn [SEP]; mỗi vị trí cho hai logits, một cho “bắt đầu đáp án”, một cho “kết thúc đáp án”, nên logits có shape (B, L, 2). Softmax lấy theo chiều vị trí LL, không theo chiều 2: hai số ở một vị trí không phải hai lớp cạnh tranh nhau, mà là điểm của vị trí ấy trong hai cuộc chọn riêng.

Hỏi nhanh

Head trích đoạn trả về logits (1, 384, 2). Argmax của logits “bắt đầu” rơi vào vị trí 57, còn argmax của logits “kết thúc” rơi vào vị trí 41. Đọc đáp án thế nào?

Một encoder nhỏ học từ đầu trên CPU

Ta dựng một encoder một lớp, hidden size 16 và hai attention heads, rồi dạy nó phân loại mười câu tự soạn. Kiến trúc giống một lớp của BERT: token embedding cộng position embedding, self-attention hai chiều có padding mask, head đọc CLS. Khác biệt là model học thẳng nhiệm vụ phân loại từ khởi tạo ngẫu nhiên, bỏ qua giai đoạn MLM; mọi tensor đủ nhỏ để in ra và cả bài chạy trên CPU trong vài giây.

Câu được tách bằng split(); ví_dụ viết liền để thành một token. Nhãn theo đúng khó khăn ở đầu bài: “hay” và “không tệ” là 1, “tệ” và “không hay” là 0.

Python
import torch
from torch import nn

torch.manual_seed(63)
torch.set_num_threads(1)
train_texts = [
    "bài hay", "bài không tệ", "ví_dụ hay", "ví_dụ không tệ", "giảng hay",
    "bài tệ", "bài không hay", "ví_dụ tệ", "ví_dụ không hay", "giảng không tệ",
]
train_labels = torch.tensor([1, 1, 1, 1, 1, 0, 0, 0, 0, 1])
valid_texts = ["giảng tệ", "giảng không hay"]
valid_labels = torch.tensor([0, 0])
vocab = {"[PAD]": 0, "[CLS]": 1, "[UNK]": 2}
for word in sorted({word for text in train_texts for word in text.split()}):
    vocab[word] = len(vocab)

def make_batch(texts):
    rows = [[vocab["[CLS]"]] + [vocab.get(word, 2) for word in text.split()] for text in texts]
    ids = torch.zeros(len(rows), max(map(len, rows)), dtype=torch.long)
    for i, row in enumerate(rows):
        ids[i, :len(row)] = torch.tensor(row)
    return ids

class SmallEncoderClassifier(nn.Module):
    def __init__(self):
        super().__init__()
        self.token_embedding = nn.Embedding(len(vocab), 16, padding_idx=0)
        self.position_embedding = nn.Embedding(12, 16)
        layer = nn.TransformerEncoderLayer(
            d_model=16, nhead=2, dim_feedforward=32,
            dropout=0.0, batch_first=True,
        )
        self.encoder = nn.TransformerEncoder(layer, num_layers=1, enable_nested_tensor=False)
        self.classifier = nn.Linear(16, 2)

    def forward(self, ids):
        positions = torch.arange(ids.shape[1]).unsqueeze(0)
        inputs = self.token_embedding(ids) + self.position_embedding(positions)
        # PyTorch key_padding_mask: True nghĩa là BỎ qua key ấy.
        hidden = self.encoder(inputs, src_key_padding_mask=ids.eq(0))
        return self.classifier(hidden[:, 0]), hidden

model = SmallEncoderClassifier()
train_ids = make_batch(train_texts)
logits_before, hidden_before = model(train_ids)
print("IDs/hidden/logits:", tuple(train_ids.shape), tuple(hidden_before.shape), tuple(logits_before.shape))

Dòng hidden[:, 0] lấy CLS ở đầu mọi câu. Bảng vị trí có 12 hàng, nên một câu dài hơn 12 token sẽ gây lỗi chỉ số; BERT gốc có 512 hàng, và đó là giới hạn độ dài của checkpoint. PyTorch src_key_padding_mask=True là bỏ vị trí, trái với quy ước HF attention_mask=1 là giữ; bài 3.9 đã gặp kiểu ngược quy ước này giữa hai hàm attention của chính PyTorch.

Hỏi nhanh

Batch ["bài không hay", "giảng hay"] có ID [[1, 3, 6, 5], [1, 4, 5, 0]]. Một bạn truyền mask kiểu HF ids.ne(0), tức True ở token thật, thẳng vào src_key_padding_mask. Chuyện gì xảy ra?

Vòng học dưới đây gói trong hai hàm fit và predict để lát nữa chạy lại với seed khác. Ta dùng CrossEntropyLoss trực tiếp trên logits: hàm này tự làm log-softmax ổn định, nên không softmax trước. Loss được lưu trước mỗi lần cập nhật; đánh giá chạy ở chế độ eval().

Python
loss_function = nn.CrossEntropyLoss()

def fit(model, steps=100):
    optimizer = torch.optim.Adam(model.parameters(), lr=0.02)
    history = []
    model.train()
    for step in range(steps):
        optimizer.zero_grad()
        loss = loss_function(model(train_ids)[0], train_labels)
        loss.backward()
        optimizer.step()
        history.append(loss.item())
    model.eval()
    return history

def predict(model, texts):
    with torch.no_grad():
        return model(make_batch(texts))[0].argmax(dim=-1)

loss_history = fit(model)
train_pred = predict(model, train_texts)
valid_pred = predict(model, valid_texts)
print("loss đầu/cuối:", round(loss_history[0], 4), round(loss_history[-1], 4))
print("train accuracy:", round((train_pred == train_labels).float().mean().item(), 3))
for text, label, pred in zip(valid_texts, valid_labels.tolist(), valid_pred.tolist()):
    print(text, "thật:", label, "đoán:", pred)

Loss giảm từ 1.248 xuống 0.2777, nhưng train accuracy chỉ 0.8, và cả hai câu validation đều bị đoán nhãn 1 dù gold là 0. Con số 0.2777 đáng nhìn kỹ: nó rất gần 0.4ln⁡2≈0.27730.4\ln 2\approx0.2773, đúng mức loss khi sáu câu được đoán gần như chắc chắn đúng còn bốn câu có xác suất 0.5. Ta in xác suất của các câu chứa “không” để kiểm giả thuyết ấy, rồi chạy lại cùng recipe với năm seed khác.

Hàng CLS gần như giống hệt ở hai câu khác nhãn.1.001.00.10.34.28.28.60.23.15head 1CLSbàikhônghayCLSbàikhônghay1.00.67.32.961.00head 2CLSbàikhônghay“bài không hay”1.00.90.10.13.45.37.051.00head 1CLSbàikhôngtệCLSbàikhôngtệ1.001.00.961.00head 2CLSbàikhôngtệ“bài không tệ”hàng CLS của hai câu gần như trùng nhau: head đọc CLS không thấy khác biệt hay/tệ
Hàng CLS gần như giống hệt ở hai câu khác nhãn.
Python
import math

with torch.no_grad():
    train_prob = model(train_ids)[0].softmax(dim=-1)[:, 1]
for text, label, prob in zip(train_texts, train_labels.tolist(), train_prob.tolist()):
    if "không" in text:
        print(f"{text}: nhãn {label}, P(1) = {prob:.4f}")
print("0.4 * ln 2 =", round(0.4 * math.log(2), 4))

for seed in [1, 2, 3, 4, 5]:
    torch.manual_seed(seed)
    other = SmallEncoderClassifier()
    history = fit(other)
    train_acc = (predict(other, train_texts) == train_labels).float().mean().item()
    valid_ok = int((predict(other, valid_texts) == valid_labels).sum())
    print(f"seed {seed}: loss cuối {history[-1]:.4f}, train {train_acc:.1f}, validation đúng {valid_ok}/2")

“bài không tệ” (nhãn 1) và “bài không hay” (nhãn 0) nhận cùng một xác suất 0.5017; cặp “ví_dụ” cũng vậy với 0.5054. Model chưa tách được “không tệ” khỏi “không hay”. Nhãn của bốn câu này phụ thuộc vào cặp từ chứ không vào từng từ: “không” đảo nghĩa của từ đứng sau, đúng dạng XOR (bài 3.2). Lần chạy với seed 63 dừng ở xác suất 0.5 cho cả bốn câu, và seed 1 cũng kẹt ở loss 0.2781.

Còn “giảng không tệ” được đoán đúng với 0.9995 vì một lý do khác: trong train, “giảng” chỉ xuất hiện ở hai câu nhãn 1, nên model có thể dựa vào lối tắt “giảng → tích cực”.

Sau 100 bước, bốn câu “không hay”, “không tệ” vẫn đứng ở 0.5.00.51bài hay0.9996bài không tệ0.5017ví_dụ hay0.9996ví_dụ không tệ0.5054giảng hay0.9995bài tệ0.0011bài không hay0.5017ví_dụ tệ0.0011ví_dụ không hay0.5054giảng không tệ0.9995P(nhãn 1)nhãn 1nhãn 0
Sau 100 bước, bốn câu “không hay”, “không tệ” vẫn đứng ở 0.5.

Lối tắt ấy giải thích phần validation. Bốn seed 2-5 học thuộc cả mười câu train, nhưng validation dao động từ 0/2 đến 2/2. Train không chứa thông tin nào về “giảng tệ” hay “giảng không hay”, nên mọi cách đoán hai câu này đều khớp train như nhau và seed quyết định model rơi vào cách nào. Hai câu validation đều chứa “giảng”, vì vậy chúng đo lối tắt chứ không đo khả năng đọc phủ định. Pretraining MLM trên corpus lớn là cách BERT có sẵn nhiều ví dụ “không + tính từ” trước khi gặp mười câu có nhãn.

Cùng cách train, sáu seed cho kết quả rất khác nhau.seedloss cuốitrainvalidation630.27770.80/210.27810.80/220.00001.02/230.00011.00/240.00001.02/250.00011.01/2kẹt ở 0.4 ln 2 hoặc học thuộc train; validation chỉ là may rủi trên hai câu
Cùng cách train, sáu seed cho kết quả rất khác nhau.

Hỏi nhanh

Seed 2 đến 5 đều đạt 1.0 trên train, nhưng trên 2 câu validation lần lượt được 2/2, 0/2, 2/2 và 1/2. Nên làm gì tiếp?

Kiểm padding bằng một bất biến

Thêm padding vào cuối câu mà giữ mask đúng phải gần như không đổi logits của câu. Đây là phép kiểm mạnh hơn việc chỉ nhìn shape. Các khác biệt làm tròn nhỏ có thể có; dùng allclose thay vì so từng bit.

Python
one_sentence = make_batch(["bài không hay"])
extra_padding = torch.zeros(1, 3, dtype=torch.long)
padded_sentence = torch.cat([one_sentence, extra_padding], dim=1)
with torch.no_grad():
    original_logits = model(one_sentence)[0]
    padded_logits = model(padded_sentence)[0]
print("logits không đổi:", torch.allclose(original_logits, padded_logits, atol=1e-5))

Token PAD vẫn có query ở một số implementation, nên output ở vị trí PAD có thể khác 0. Điều cần ở đây là PAD không được làm key cung cấp thông tin cho token thật và không được tính vào loss hay pooling. Vì vậy mask attention và mask loss là hai việc liên quan nhưng không thay thế nhau.

Nhãn ở vị trí đệm đặt −100-100 để cross-entropy bỏ qua (ignore_index, bài 3.4). GPT-2 không có token đệm; cách thường làm là gán tokenizer.pad_token = tokenizer.eos_token, rồi dựa vào attention_mask và nhãn −100-100 để vị trí đệm không ảnh hưởng.

Hỏi nhanh

Attention mask đã đúng. Vì sao nhãn token ở vị trí PAD vẫn phải là -100?

NER: nhãn từ phải căn vào subword

Giả sử câu đã chia thành từ An, đến, Hà_Nội, sớm, với nhãn B-PER, O, B-LOC, O. Tokenizer minh họa chia Hà_Nội thành Hà và ##Nội, đồng thời thêm CLS/SEP. Có 7 token nhưng chỉ 4 nhãn gốc.

Ta chọn chính sách chỉ tính loss ở subword đầu của mỗi từ; các subword còn lại, special tokens và padding nhận nhãn -100. Đây là giá trị bỏ qua trong loss, không phải ID token hay nhãn “không biết”.

Chỉ subword đầu của mỗi từ mang nhãn; CLS, SEP và subword tiếp nối nhận −100.[CLS]None-100An0B-PERđến1OHà2B-LOC##Nội2-100sớm3O[SEP]None-100word_idnhãn4 vị trí góp loss; ##Nội cùng word_id 2 với Hà nên nhận -100
Chỉ subword đầu của mỗi từ mang nhãn; CLS, SEP và subword tiếp nối nhận −100.
Python
label_to_id = {"O": 0, "B-PER": 1, "B-LOC": 2}
word_labels = [1, 0, 2, 0]
tokens = ["[CLS]", "An", "đến", "Hà", "##Nội", "sớm", "[SEP]"]
word_ids = [None, 0, 1, 2, 2, 3, None]

def align_first_subword(word_ids, labels):
    aligned = []
    previous = None
    for word_id in word_ids:
        if word_id is None or word_id == previous:
            aligned.append(-100)
        else:
            aligned.append(labels[word_id])
        previous = word_id
    return aligned

aligned = align_first_subword(word_ids, word_labels)
print(list(zip(tokens, aligned)))

# Logits giả định để kiểm cách tính loss, không phải dự đoán NER của encoder trên.
token_logits = torch.zeros(1, len(tokens), 3)
for position, label in enumerate(aligned):
    if label != -100:
        token_logits[0, position, label] = 2.0
labels_tensor = torch.tensor([aligned])
ner_loss = nn.CrossEntropyLoss(ignore_index=-100)(
    token_logits.reshape(-1, 3), labels_tensor.reshape(-1)
)
print("vị trí tính loss:", int(labels_tensor.ne(-100).sum()))
print("loss:", round(ner_loss.item(), 4))

Ở đây chỉ 4 vị trí góp loss. Nếu nhân bản nhãn đầu cho mọi subword, các từ bị cắt nhiều sẽ có trọng số lớn hơn trong loss; nếu dùng BIO cho mọi subword thì còn phải chuyển nhãn tiếp nối phù hợp. Chính sách nào cũng cần thống nhất khi train và đọc output. Fast tokenizer thường cung cấp word_ids() để căn nhãn; không tự dùng độ dài chuỗi hoặc vị trí token để đoán tương ứng.

NER được chấm theo thực thể trọn vẹn: sai một phần của “Hà Nội” có thể làm mất cả entity. Token accuracy thường cao vì nhiều token là O. Với tiếng Việt, cần xác định nhãn đặt trên âm tiết, ký tự hay token nào và metric đo ranh giới; không dùng nguyên xi cách làm NER theo từ nếu đơn vị của đề khác.

Hỏi nhanh

Ta đổi chính sách: chép nhãn của từ cho mọi subword, nên Hà và ##Nội đều mang B-LOC. Đọc dự đoán ở mức subword theo quy tắc BIO thì “Hà Nội” thành gì?

Hai B-LOC liền nhau là hai thực thể; subword tiếp nối phải mang I-LOC.chép B-LOC cho cả haiHàB-LOC##NộiB-LOC2 thực thểđổi subword sau thành I-LOCHàB-LOC##NộiI-LOC1 thực thể
Hai B-LOC liền nhau là hai thực thể; subword tiếp nối phải mang I-LOC.

Kiểm loss của đoạn code trên bằng tay. Logits ở mỗi vị trí được tính loss là (2,0,0)(2,0,0) theo một hoán vị để lớp đúng đứng đầu. Xác suất lớp đúng là e2/(e2+2)≈0.787e^2/(e^2+2)\approx0.787, nên loss một vị trí là −log⁡0.787≈0.2395-\log0.787\approx0.2395. Cả bốn vị trí có cùng phân phối nên loss trung bình cũng bằng 0.2395. Các vị trí -100 không làm tăng mẫu số lên 7. Đây là cách kiểm độc lập kết quả code.

Hỏi nhanh

Quên đặt -100, nên ba vị trí [CLS], ##Nội và [SEP] nhận nhãn O, tức ID 0, còn logits của chúng vẫn là (0,0,0)(0, 0, 0). Loss trung bình mới bằng bao nhiêu?

Từ encoder nhỏ sang checkpoint pretrained

Với dữ liệu thật, encoder không học từ đầu mà nạp một checkpoint đã pretrain bằng MLM. Script fine-tuning của chương dùng distilbert/distilbert-base-multilingual-cased, bản thu gọn đa ngôn ngữ trong họ BERT, và đọc một dataset JSON trên máy gồm label_names, train, validation; hướng dẫn của chương mô tả định dạng dữ liệu và cách chuẩn bị model.

Tùy --task, script gắn lên backbone một head câu (AutoModelForSequenceClassification) hoặc head token (AutoModelForTokenClassification) mới khởi tạo. Với nhãn token, nó căn nhãn bằng word_ids() theo đúng chính sách subword đầu ở trên.

Cách script chạy:

  • Mẫu dài hơn --max-length (mặc định 128, tối đa 512 bằng số vị trí của checkpoint) bị từ chối thay vì cắt âm thầm.
  • Mặc định học bằng AdamW với learning rate 2⋅10−52\cdot10^{-5} trong 2 epoch và clip norm gradient ở 1.0; cờ --head-only đóng băng backbone như ở bài 4.3.
  • Sau mỗi epoch, chấm validation bằng macro-F1 (head câu) hoặc entity-F1 trên span BIO (head token), giữ trạng thái tốt nhất, rồi lưu model, tokenizer và tệp kết quả run-receipt.json gồm lịch sử từng epoch, điểm tốt nhất và danh sách nhãn.

Đọc tệp kết quả như đọc output của encoder nhỏ: epoch tốt nhất nằm ở đâu, số thực thể đoán so với số thực thể gold, và với head câu, điểm có vượt baseline TF-IDF của bài 6.1 hay không. PhoBERT là lựa chọn tiếng Việt khác, nhưng đầu vào phải được tách từ trước theo hướng dẫn của nhà phát hành.

Model đa ngôn ngữ hợp với câu trộn Việt-Anh như “mai mình đi meeting nhé”; fine-tune chỉ bằng dữ liệu tiếng Anh thường vẫn chuyển được một phần sang tiếng Việt, dù kém hơn khi có dữ liệu tiếng Việt. Với NER, 90% token mang nhãn O nên accuracy theo token gần như vô nghĩa; chấm bằng F1 trên thực thể.

Khi phải phân loại một triệu bình luận mỗi ngày và đã có 20 000 nhãn, fine-tune một encoder nhỏ như trên thường rẻ và ổn định hơn gọi một LLM cho từng câu.

Hỏi nhanh

Một hồ sơ NER dài 700 subword, và script dừng lại với thông báo mẫu vượt max_length. Xử lý thế nào để giữ được nhãn của cả hồ sơ?

Văn bản dài chia thành các cửa sổ chồng lấp; mỗi từ lấy dự đoán từ cửa sổ mà nó gần giữa hơn.hồ sơ 700 subwordcửa sổ 1: 0-512cửa sổ 2: 188-700vùng chồng lấp: giữ dự đoán của cửa sổ mà từ nằm gần giữa hơn
Văn bản dài chia thành các cửa sổ chồng lấp; mỗi từ lấy dự đoán từ cửa sổ mà nó gần giữa hơn.

Khi đi thi

  • Đọc đơn vị chấm trước khi chọn head: nhãn theo câu, theo từ, hay đoạn trích trong văn bản.
  • In tokenization, word_ids() và nhãn sau khi đặt -100 của vài mẫu trước khi train.
  • Fine-tune với learning rate từ 2⋅10−52\cdot10^{-5} đến 5⋅10−55\cdot10^{-5}, chạy hai, ba seed trước khi tin một chênh lệch nhỏ.

Tóm tắt

  • BERT học ngữ cảnh hai chiều bằng MLM: chọn 15% vị trí (80/10/10), loss chỉ tính trên các vị trí được chọn.
  • Head câu trả (B, C); head token trả (B, L, C); head trích đoạn trả (B, L, 2). Logits đưa trực tiếp vào cross-entropy.
  • Padding mask, loss mask -100 và causal mask là ba việc khác nhau; quy ước True/1 phải đọc theo từng API.
  • Trên dữ liệu nhỏ, đọc xác suất từng câu và chạy vài seed trước khi kết luận; validation phải chứa đủ tình huống để bắt lối tắt.
  • Căn nhãn subword, dùng -100 đúng nghĩa và đánh giá theo đơn vị mà đề yêu cầu.

Kiểm tra cuối bài

Câu 1

Một đội có hai checkpoint cùng cỡ: một encoder hai chiều kiểu BERT và một decoder tự hồi quy kiểu GPT. Đề có hai phần: gán nhãn thực thể cho từng từ trong hồ sơ, và viết tiếp đoạn kết luận cho hồ sơ. Nên dùng model nào cho phần nào?

Phỏng theoNOAI 2026 Singapore · Vòng sơ loại · Câu 224

Câu 2

Lúc train, label_to_id được dựng từ sorted(set(train_labels)) với ba nhãn neg, neu, pos. Script chấm validation dựng lại mapping theo cùng cách từ nhãn validation, và tập này tình cờ không có câu neu nào. Chuyện gì xảy ra?

Câu 3

Hàm loss MLM tự viết chọn khoảng 15% vị trí không phải PAD, đặt nhãn -100 ở mọi vị trí khác, rồi ở “dòng A” thay toàn bộ vị trí được chọn bằng MASK_TOKEN trước khi đưa vào model. Bài báo BERT gốc làm gì ở dòng A, và vì sao?

Đề thiNOAI 2026 Singapore · Chung kết · Câu 20

Câu 4

Hidden states có shape (8, 32, 16). Head token là một nn.Linear(16, 5) dùng chung cho mọi vị trí. Logits có shape gì, và head có bao nhiêu tham số?

Câu 5

Một đội thay encoder BERT bằng một decoder causal cùng cỡ, nhưng vẫn giữ logits = head(hidden[:, 0]), với [CLS] vẫn đứng đầu mọi câu. Sau khi fine-tune, gần như chắc chắn chuyện gì sẽ xảy ra?

Phỏng theoNOAI 2026 Singapore · Vòng sơ loại · Câu 229

Câu 6

Head token trả về logits (B, L, K), còn nhãn là index (B, L) với -100 ở những vị trí bỏ qua. Đưa vào CrossEntropyLoss(ignore_index=-100) thế nào cho đúng?

Câu 7

Một batch pretraining có 32 câu, mỗi câu 128 token thật. MLM chọn 15% vị trí. Khoảng bao nhiêu vị trí góp vào loss, và so với một causal LM dự đoán token kế tiếp trên cùng batch thì thế nào?

Phỏng theoNOAI 2026 Singapore · Vòng sơ loại · Câu 226

Câu 8

Văn bản 20 từ có ba thực thể gold: PER “Lan” gồm 1 từ, LOC “Hà Nội” gồm 2 từ, ORG “Bách khoa” gồm 2 từ. Model đoán đúng “Lan”, gán B-LOC cho “Hà” nhưng O cho “Nội”, và bỏ sót “Bách khoa”. Token accuracy là 17/20=0.8517/20 = 0.85. Entity-F1 bằng bao nhiêu?

Câu 9

Cùng tokenizer và checkpoint, model đang ở chế độ train. Thêm PAD vào cuối câu, attention mask đúng, vậy mà logits của câu đổi. Nên kiểm gì trước?

Câu 10

Một bạn giải thích: “Attention mask trong BERT giúp model tập trung vào phần quan trọng của câu.” Nên sửa câu này thế nào cho chính xác?

Phỏng theoCMU 10-601 · Exam 3 Practice · Q2.1

Đọc thêm

Tải gói recipe của chương - gồm các module phụ và hướng dẫn chuẩn bị asset.