Tóm tắt một bản tin, dịch một câu tiếng Anh sang tiếng Việt, viết mô tả cho một ảnh: ở cả ba việc, model phải đọc hết một đầu vào rồi viết ra một chuỗi khác với độ dài khác. Kiến trúc encoder-decoder có hai phần: encoder đọc đầu vào, decoder sinh đầu ra từng token một và nhìn lại đầu vào qua cross-attention. Bài này học cách huấn luyện, cách sinh kết quả của encoder-decoder, và các metric chấm bản dịch, bản tóm tắt.
Nguồn là điều kiện, đích được viết từng bước
Encoder đọc toàn bộ nguồn thành bộ nhớ . Decoder sinh chuỗi đích theo
Encoder của bài dịch được nhìn cả câu nguồn vì câu đó đã có khi bắt đầu. Decoder chỉ được nhìn tiền tố đích đã có; để nó nhìn token đích tương lai khi train là lộ đáp án. Trong transformer encoder-decoder, decoder có causal self-attention trên đích và cross-attention sang các token nguồn. Query của cross-attention đến từ decoder; key và value đến từ encoder.
Nguồn có thể là token văn bản hoặc các vector patch do một image encoder tạo ra; decoder làm việc như nhau với cả hai.
Dịch máy, tóm tắt, viết caption cho ảnh đều là bài chuyển chuỗi (sequence-to-sequence). Dịch từng từ một không được: “red car” thành “đỏ xe” sai trật tự, nên model phải đọc cả câu nguồn; dữ liệu để học là kho song ngữ gồm các cặp câu nguồn-đích. Caption kinh điển ghép một image encoder (CNN) với một decoder sinh câu.
T5 đi xa hơn, đưa mọi nhiệm vụ về dạng văn bản vào, văn bản ra, kể cả phân loại cảm xúc: model sinh ra chữ “positive” hay “negative”.
Nếu nguồn có token, đích có token và hidden size , hidden states của encoder và decoder có shape (B, Lx, d) và (B, Ly, d). Transformer encoder-decoder dùng attention ở ba chỗ, mỗi chỗ một mask riêng:
- Self-attention của encoder: query và key đều là nguồn; mask các vị trí PAD của nguồn.
- Self-attention của decoder: query và key đều là đích; causal mask, thêm mask PAD của đích khi batch có câu ngắn.
- Cross-attention: query từ đích, key và value từ nguồn; ma trận điểm của một head có shape
(Ly, Lx), và PAD của nguồn lại phải bị mask.
Bài 3.9 đã tách padding mask khỏi causal mask. Điểm mới ở đây là mask PAD của nguồn phải đi theo memory sang cross-attention, còn cross-attention không cần causal mask: toàn bộ nguồn đã có từ đầu.
Hỏi nhanh
Decoder có 7 vị trí đích, nguồn có 10 token. Ma trận trọng số cross-attention của một head, sau softmax, có shape gì, và cộng bằng 1 theo trục nào?
Mỗi query đích phân phối trọng số lên 10 key nguồn, nên softmax lấy theo trục nguồn, và mỗi hàng cộng bằng 1. Cột thì không có ràng buộc nào. Một token nguồn có thể được nhiều vị trí đích cùng đọc, hoặc gần như không vị trí nào đọc. Ma trận (7, 7) có causal mask là self-attention của decoder, một khối khác.
Hỏi nhanh
Một model dịch tự cài dịch riêng một câu nguồn 3 token thì ra một kết quả. Ghép đúng câu đó vào batch với một câu nguồn 30 token, câu ngắn được pad bên phải, thì bản dịch đổi. Nên nghi chỗ nào trước tiên?
Trong batch, câu ngắn có thêm 27 key PAD ở nguồn. Nếu cross-attention, hoặc self-attention của encoder, không mask chúng, mỗi query đích sẽ chia một phần trọng số cho PAD. Lượng PAD lại tùy vào câu dài nhất trong batch. Causal mask sai thì hỏng cả khi dịch riêng, không phụ thuộc batch. Encoder đọc nguồn đã có đủ, nên không cần causal mask.
Teacher forcing: đáp án chỉ được đưa từ bước trước
Trong train, ta đã có chuỗi đích đúng. Teacher forcing đưa token đích đúng ở bước trước vào decoder thay vì token model vừa đoán. Với target B A EOS, decoder input là BOS B A; label là B A EOS. Không đưa B A EOS vào input cùng vị trí label rồi bắt model “đoán” lại. Dịch một vị trí kết hợp causal mask giữ bài toán đúng. Thư viện như Hugging Face tự làm bước dịch này khi nhận labels, nên chỉ đưa label kết thúc bằng EOS; label đã có sẵn token mở đầu sẽ bị dịch hai lần.
Teacher forcing giúp train hiệu quả vì mọi input đích đã biết, nên transformer có thể xử lý song song các vị trí. Lúc inference, không có token đúng để đưa vào; model phải ăn lại token nó vừa sinh. Một lỗi đầu có thể làm tiền tố sau khác train, rồi lỗi lan tiếp. Sự khác biệt phân phối này thường gọi là exposure bias. Vì thế validation cần chấm cả lúc model tự sinh (free-running), không chỉ loss với teacher forcing.
Hỏi nhanh
Một đội muốn lúc train giống hệt lúc sinh. Ở mỗi bước, decoder GRU nhận token model vừa đoán bằng argmax thay cho token đích đúng, còn loss vẫn so với label đúng. Ở đầu quá trình train, chuyện gì xảy ra?
Lúc đầu model đoán gần như ngẫu nhiên, nên từ bước thứ hai decoder đã nhận tiền tố sai, trong khi label vẫn là token đúng của tiền tố đúng. Model bị đòi đoán A sau một tiền tố vô nghĩa, và gradient của các bước sau gần như là nhiễu. Teacher forcing cho mỗi vị trí đúng tiền tố, nên gradient sạch và tính song song được. Argmax chỉ chặn gradient đi qua việc chọn token. Mỗi bước vẫn có loss, và gradient vẫn tới tham số qua trạng thái ẩn, nên vẫn train được, chỉ kém hơn. Cách dung hòa là scheduled sampling: tăng dần tỉ lệ token tự sinh trong lúc train.
Một bài chuyển chuỗi nhỏ để nhìn tensor
Ta chọn nhiệm vụ đảo chuỗi hai ký hiệu lấy từ A, B, C. Nó không phải dịch máy hoặc tóm tắt; thứ tự đúng xác định được bằng phép đảo, nên ta kiểm độc lập được model. Có 9 cặp có thể có. Ta dùng 7 cặp train, giữ C B và C C để thử tổ hợp chưa thấy. Vocabulary gồm ba ký hiệu và ba token đặc biệt PAD, BOS, EOS.
Thí nghiệm dùng GRU encoder-decoder train từ đầu. Encoder là RNN như ở bài 3.8: hidden state cuối tóm tắt cả nguồn, đúng trạng thái mà 3.8 đọc tại lengths - 1 để phân loại câu. Ở đây trạng thái ấy không đi vào head phân loại mà trở thành trạng thái đầu của decoder. Model chưa có attention; phép tính cross-attention được làm riêng ở mục sau.
import itertools
import torch
from torch import nn
torch.manual_seed(65)
torch.set_num_threads(1)
PAD, BOS, EOS = 0, 1, 2
symbol_to_id = {"A": 3, "B": 4, "C": 5}
id_to_symbol = {value: key for key, value in symbol_to_id.items()}
pairs = list(itertools.product("ABC", repeat=2))
train_pairs = pairs[:7]
valid_pairs = pairs[7:]
def make_seq2seq_batch(pairs):
sources = []
decoder_inputs = []
labels = []
for pair in pairs:
source = [symbol_to_id[symbol] for symbol in pair]
target = list(reversed(source))
sources.append(source)
decoder_inputs.append([BOS] + target)
labels.append(target + [EOS])
return (torch.tensor(sources), torch.tensor(decoder_inputs), torch.tensor(labels))
source_ids, decoder_ids, target_ids = make_seq2seq_batch(train_pairs)
print("nguồn/input đích/label:", tuple(source_ids.shape), tuple(decoder_ids.shape), tuple(target_ids.shape))
print("cặp A B:", source_ids[1].tolist(), decoder_ids[1].tolist(), target_ids[1].tolist())
nguồn/input đích/label: (7, 2) (7, 3) (7, 3)
cặp A B: [3, 4] [1, 4, 3] [4, 3, 2]
Ở cặp A B, nguồn [3,4], decoder input [1,4,3], target [4,3,2]. Vị trí đầu dự đoán B từ BOS và nguồn, vị trí thứ hai dự đoán A từ B và nguồn. Vị trí thứ ba học dừng bằng EOS. Với chuỗi thật dài ngắn khác nhau, cần thêm padding, loss mask, và trạng thái encoder phải lấy tại token thật cuối (hoặc qua pack_padded_sequence) như bài 3.8; dữ liệu cố định dài hai ký hiệu giúp tách lỗi shift khỏi lỗi padding.
Ta viết model thành các khối có trách nhiệm rõ: embedding, encoder, decoder và projection về vocabulary. Hidden state GRU có shape (layers, B, hidden_size), ở đây layers bằng 1. batch_first=True làm chuỗi input có batch ở trục đầu.
class SmallSeq2Seq(nn.Module):
def __init__(self):
super().__init__()
self.embedding = nn.Embedding(6, 12, padding_idx=PAD)
self.encoder = nn.GRU(12, 24, batch_first=True)
self.decoder = nn.GRU(12, 24, batch_first=True)
self.output_layer = nn.Linear(24, 6)
def forward(self, source, decoder_input):
_, source_state = self.encoder(self.embedding(source))
decoded, _ = self.decoder(self.embedding(decoder_input), source_state)
return self.output_layer(decoded)
model = SmallSeq2Seq()
logits = model(source_ids, decoder_ids)
print("logits:", tuple(logits.shape))
logits: (7, 3, 6)
Logits có shape (7, 3, 6): 7 mẫu, 3 bước đích, 6 token có thể chọn. Loss flatten hai chiều đầu thành một danh sách vị trí, nhưng giữ chiều lớp là 6. Target dùng số nguyên ID; không biến target thành one-hot để gọi loss này.
Hỏi nhanh
Logits có shape (7, 3, 6). Muốn đưa vào cross-entropy thì flatten thành shape gì?
Có 21 vị trí dự đoán, mỗi vị trí có 6 lớp, còn target là danh sách 21 ID. (7, 18) trộn bước đích với lớp. (6, 21) đặt lớp ở trục đầu, sai quy ước (N, C) của CrossEntropyLoss. (126,) làm mất trục lớp.
optimizer = torch.optim.Adam(model.parameters(), lr=0.02)
loss_function = nn.CrossEntropyLoss(ignore_index=PAD)
loss_history = []
model.train()
for step in range(220):
optimizer.zero_grad()
logits = model(source_ids, decoder_ids)
loss = loss_function(logits.reshape(-1, 6), target_ids.reshape(-1))
loss.backward()
optimizer.step()
loss_history.append(loss.item())
valid_source, valid_input, valid_target = make_seq2seq_batch(valid_pairs)
model.eval()
with torch.no_grad():
valid_logits = model(valid_source, valid_input)
valid_loss = loss_function(valid_logits.reshape(-1, 6), valid_target.reshape(-1))
print("train loss đầu/cuối:", round(loss_history[0], 4), round(loss_history[-1], 4))
print("validation teacher-forced loss:", round(valid_loss.item(), 4))
train loss đầu/cuối: 1.8026 0.0003
validation teacher-forced loss: 1.2613
Train loss 0.0003 cho biết model khớp 7 cặp đã học dưới teacher forcing. Validation loss 1.2613 cao gấp hàng nghìn lần: hai tổ hợp chưa thấy là chỗ model còn yếu. Loss này vẫn đo với token đích đúng ở bước trước, nên chưa cho biết chuỗi tự sinh sẽ ra sao.
Free-running: tự ăn lại token vừa sinh
Vòng suy luận dưới đây encode nguồn một lần, bắt đầu bằng BOS, lấy argmax rồi đưa token đó vào bước tiếp. Nó dừng ở EOS hoặc tối đa 4 bước. PAD và BOS không được chọn làm output bằng cách đặt logits của chúng thành ; trong nhiệm vụ thật, chỉ cấm token khi đề đã xác định chúng không hợp lệ.
def generate_reverse(pair, max_steps=4):
source = torch.tensor([[symbol_to_id[symbol] for symbol in pair]])
generated = []
with torch.no_grad():
_, state = model.encoder(model.embedding(source))
current = torch.tensor([[BOS]])
for _ in range(max_steps):
decoded, state = model.decoder(model.embedding(current), state)
next_logits = model.output_layer(decoded[:, -1]).clone()
next_logits[:, [PAD, BOS]] = -torch.inf
token = int(next_logits.argmax(dim=-1).item())
if token == EOS:
break
generated.append(id_to_symbol[token])
current = torch.tensor([[token]])
return tuple(generated)
for split_name, split_pairs in [("train", train_pairs), ("validation", valid_pairs)]:
correct = 0
for pair in split_pairs:
prediction = generate_reverse(pair)
gold = tuple(reversed(pair))
correct += prediction == gold
if split_name == "validation":
print("nguồn:", pair, "đích thật:", gold, "tự sinh:", prediction)
print(split_name, "exact match:", f"{correct}/{len(split_pairs)}")
train exact match: 7/7
nguồn: ('C', 'B') đích thật: ('B', 'C') tự sinh: ('B', 'C')
nguồn: ('C', 'C') đích thật: ('C', 'C') tự sinh: ('C', 'A')
validation exact match: 1/2
Train khớp 7/7, validation chỉ khớp 1/2: nguồn C C phải cho C C, nhưng model cho C A. Train loss 0.0003 chưa chứng minh model đã học quy tắc đảo.
Exact match đòi cả chuỗi trùng gold, nên đúng token đầu mà sai token sau, hoặc dư một token, đều tính sai. Đảo chuỗi có thuật toán chính xác làm oracle, nên mọi lỗi của model đều kiểm được; đó là lý do chọn nhiệm vụ nhỏ này.
Lỗi nằm ở token nào? Code dưới tách validation loss thành NLL từng vị trí, rồi xem phân phối của model ở bước thứ hai của C C khi được cho tiền tố đúng BOS C.
with torch.no_grad():
token_nll = nn.functional.cross_entropy(
valid_logits.reshape(-1, 6), valid_target.reshape(-1), reduction="none"
).reshape(2, 3)
step2_probs = valid_logits[1, 1].softmax(dim=-1) # cặp C C, vị trí 2, input BOS C
print("NLL từng vị trí:", [[round(x, 3) for x in row] for row in token_nll.tolist()])
print("C C, bước 2 sau BOS C:", {s: round(step2_probs[i].item(), 3) for s, i in symbol_to_id.items()})
NLL từng vị trí: [[0.027, 0.729, 0.003], [0.002, 6.804, 0.003]]
C C, bước 2 sau BOS C: {'A': 0.994, 'B': 0.0, 'C': 0.001}
Trung bình sáu số NLL đúng bằng 1.2613; riêng vị trí thứ hai của C C là 6.804, gần 90% tổng. Ở C B, vị trí đó có NLL 0.729, tức xác suất token đúng chỉ khoảng 0.48: model phân vân, và free-running chọn đúng vì C nhỉnh hơn A. Với C C, dù được cho tiền tố đúng, model vẫn đặt 0.994 vào A. Token đầu đã đúng nên tiền tố tự sinh trùng tiền tố teacher forcing; lỗi ở đây không do lệch tiền tố mà do model chưa học được quy tắc cho tổ hợp này, và sai một cách tự tin.
Lỗi do lệch tiền tố vẫn có thật ở bài khác: train luôn được cho tiền tố đúng, còn lúc sinh model ăn lại token của chính nó, nên một lỗi nhỏ đầu câu đưa model vào những tiền tố chưa từng gặp và lỗi chồng lỗi (exposure bias).
Hỏi nhanh
Ở mẫu C C, model sai token thứ hai cả khi free-running lẫn khi được cho tiền tố đúng BOS C. Đây là loại lỗi gì?
Exposure bias cần tiền tố tự sinh khác tiền tố đúng. Ở đây token đầu đúng, nên hai tiền tố trùng nhau. Model đặt 0.994 vào A, nên chuỗi C C có xác suất rất nhỏ, và beam rộng đến đâu cũng không xếp nó lên đầu. Exact match không phải nguyên nhân, vì C A sai nội dung chứ không chỉ sai định dạng.
Giải mã: beam search, độ dài và token lặp
Bài 6.4 đã giảng beam search: thay vì một tiền tố như greedy, giữ tiền tố có tổng log-prob lớn nhất; tiền tố gặp EOS được cất riêng. Ở seq2seq, thuật toán giữ nguyên, chỉ thêm điều kiện là nguồn: encoder chạy một lần, mỗi tiền tố mang theo trạng thái decoder của riêng nó. Code dưới chạy beam rộng 3 trên model GRU vừa train để xem beam làm được gì và không làm được gì.
def beam_search(pair, beam_size=3, max_steps=4):
source = torch.tensor([[symbol_to_id[symbol] for symbol in pair]])
finished = []
with torch.no_grad():
_, state = model.encoder(model.embedding(source))
beams = [((), 0.0, state, BOS)] # (đã sinh, tổng log-prob, trạng thái, token cuối)
for _ in range(max_steps):
candidates = []
for tokens, score, state, last in beams:
decoded, new_state = model.decoder(model.embedding(torch.tensor([[last]])), state)
log_probs = torch.log_softmax(model.output_layer(decoded[:, -1]), dim=-1)[0]
for token in [EOS, *symbol_to_id.values()]:
candidates.append((tokens, score + log_probs[token].item(), new_state, token))
candidates.sort(key=lambda item: item[1], reverse=True)
beams = []
for tokens, score, state, token in candidates[:beam_size]:
if token == EOS:
finished.append((" ".join(tokens), round(score, 3)))
else:
beams.append((tokens + (id_to_symbol[token],), score, state, token))
if not beams:
break
return sorted(finished, key=lambda item: item[1], reverse=True)
for pair in valid_pairs:
print(" ".join(pair), "->", beam_search(pair)[:3])
C B -> [('B C', -0.759), ('B A', -1.153), ('B B', -2.047)]
C C -> [('C A', -0.009), ('C', -5.279), ('A C', -6.142)]
Với C B, chuỗi đứng đầu là B C, log-prob (xác suất khoảng 0.47), trùng greedy; B A theo sát ở . Với C C, beam trả về C A với , tức model tin chuỗi này tới 0.991. Beam search tìm chuỗi tốt nhất theo model; khi model sai một cách tự tin, mở rộng beam chỉ tìm ra đúng lỗi đó. Muốn sửa C C phải sửa model hoặc dữ liệu, không phải bộ giải mã.
Dòng C C còn có một ứng viên dừng sớm: chuỗi C, điểm , đứng thứ hai. Nó đứng trên A C vì token đầu A gần như không được chọn, chưa phải vì ngắn; nhưng tổng log-prob vẫn mang thiên lệch độ dài mà bài 6.4 đã nhắc. Mỗi token thêm vào cộng một số hạng , nên chuỗi càng dài tổng càng thấp; beam rộng dễ chọn câu ngắn hơn câu đúng, và BLEU (mục cuối bài) sẽ phạt độ ngắn ấy.
Vì vậy tăng độ rộng beam từ 5 lên 100 thường làm câu dịch ngắn đi và BLEU giảm: beam rộng tìm ra đúng những chuỗi ngắn mà model chấm cao. Cách thường dùng là chuẩn hóa theo độ dài:
Với là tổng thô, là log-prob trung bình mỗi token. Trong Hugging Face, tham số này là length_penalty (mặc định 1.0), áp lên các chuỗi đã xong của beam search.
Hỏi nhanh
Có hai giả thuyết đã gặp EOS. X EOS có tổng log-prob , gồm 2 token. X Y Z EOS có , gồm 4 token. Xếp theo tổng thô và theo trung bình mỗi token, với , thì chuỗi nào thắng?
Theo tổng thô, , nên chuỗi ngắn thắng. Chia theo độ dài thì được và , nên chuỗi dài thắng. Chuẩn hóa không làm chuỗi dài có xác suất cao hơn. Nó đổi tiêu chí xếp hạng, để bù cho việc mỗi token thêm vào luôn kéo tổng xuống.
Model tóm tắt còn hay lặp một cụm: khi cụm đó đã có trong tiền tố, chính tiền tố lại làm cụm ấy có xác suất cao lần nữa. Hai cách chặn:
no_repeat_ngram_size=ncấm mọi n-gram xuất hiện lần thứ hai trong output, bằng cách đặt logit của token sẽ tạo n-gram lặp thành , giống cách code free-running cấm PAD và BOS. Đặtno_repeat_ngram_size=3là cấm mọi bộ ba token xuất hiện lần thứ hai.repetition_penaltymềm hơn: nó chỉ giảm logit của các token đã xuất hiện, chia logit dương và nhân logit âm cho hệ số; với 1.2, logit 2.4 thành 2.0 còn thành .
Cả hai đổi phân phối của model để lấy output gọn hơn, nên chúng, cùng beam size và max_new_tokens, được chọn bằng metric trên validation như mọi hyperparameter khác.
Hỏi nhanh
Bản tóm tắt tin bóng đá bị lặp cụm “đội tuyển Việt Nam”, nên đội bật no_repeat_ngram_size=2, với mỗi âm tiết là một token. Nhưng bản tóm tắt đúng lại cần nhắc “Việt Nam” hai lần. Chuyện gì xảy ra?
Với , bigram “Việt Nam” chỉ được xuất hiện một lần ở bất kỳ đâu trong output. Lần nhắc sau bị cấm, và model phải chọn token khác, có thể làm đổi nghĩa hoặc làm giảm ROUGE. Trừ nhẹ điểm là việc của repetition_penalty. Chặn mọi token đã xuất hiện là . Vì vậy phải được chọn trên validation, không đặt theo thói quen.
Cross-attention: decoder đọc lại nguồn
GRU ở trên gửi sang decoder đúng một vector 24 số, dù nguồn dài bao nhiêu. Bài 3.8 đã đo gradient của RNN thường từ token cuối về token đầu một chuỗi 40 chỉ còn ; GRU và LSTM làm dịu hiện tượng đó, nhưng nút thắt một vector vẫn còn khi nguồn dài. Attention cho decoder đọc lại mọi vị trí nguồn ở từng bước sinh.
Để thấy phép toán, cho query đích , hai key nguồn và , value tương ứng và . Điểm sau chia là ; softmax xấp xỉ . Vector đọc được là , một tổ hợp có trọng số của hai values.
import numpy as np
query = np.array([[1.0, 0.0]])
keys = np.array([[1.0, 0.0], [0.0, 1.0]])
values = np.array([[2.0, 0.0], [0.0, 2.0]])
scores = query @ keys.T / np.sqrt(keys.shape[1])
weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
weights /= weights.sum(axis=-1, keepdims=True)
context = weights @ values
print("attention:", weights.round(3))
print("context:", context.round(3))
attention: [[0.67 0.33]]
context: [[1.34 0.66]]
Query gần key thứ nhất hơn nên value thứ nhất nhận trọng số 0.67. Một token đích khác có query khác, nên đọc cùng memory nguồn với một hàng trọng số khác. Trọng số attention cho biết decoder đọc từ đâu, nhưng chưa đủ giải thích vì sao model chọn một token: sau attention còn residual, FFN và projection.
Hỏi nhanh
Một query đích có điểm, đã chia cho , với ba key nguồn là . Ba value là , và , trong đó key thứ ba là PAD. Context khi có mask PAD và khi quên mask lần lượt là bao nhiêu?
Có mask thì softmax trên hai key là , và context là . Quên mask thì trọng số là , và context là . Value của PAD kéo context đi rất xa. Cộng thẳng các value là bỏ qua softmax, không còn là attention.
Dịch, tóm tắt và caption cần cách đọc kết quả khác nhau
Dịch có nhiều câu đích cùng đúng, nên exact match quá khắt khe. BLEU so n-gram của bản dịch với bản tham chiếu:
- Tính precision n-gram có clipping: một n-gram chỉ được tính khớp tối đa bằng số lần nó có trong tham chiếu.
- Lấy trung bình nhân theo .
- Nhân brevity penalty , với là độ dài bản dịch và là độ dài tham chiếu, để phạt bản dịch quá ngắn.
BLEU chuẩn dùng tới 4-gram và cộng số đếm trên cả corpus; code dưới tính tới bigram trên một câu để đọc được từng thành phần.
from collections import Counter
import math
def ngram_counts(tokens, n):
return Counter(tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1))
def bleu2(candidate, reference):
precisions = []
for n in (1, 2):
counts, ref_counts = ngram_counts(candidate, n), ngram_counts(reference, n)
clipped = sum(min(count, ref_counts[gram]) for gram, count in counts.items())
precisions.append(clipped / sum(counts.values()))
brevity = min(1.0, math.exp(1 - len(reference) / len(candidate)))
return precisions, brevity, brevity * math.sqrt(precisions[0] * precisions[1])
reference = "câu lạc bộ họp vào thứ sáu".split()
for candidate in ["họp họp họp họp", "câu lạc bộ họp thứ sáu", "câu lạc bộ họp vào thứ bảy"]:
(p1, p2), bp, score = bleu2(candidate.split(), reference)
print(f"{candidate:27} p1={p1:.3f} p2={p2:.3f} BP={bp:.3f} BLEU-2={score:.3f}")
họp họp họp họp p1=0.250 p2=0.000 BP=0.472 BLEU-2=0.000
câu lạc bộ họp thứ sáu p1=1.000 p2=0.800 BP=0.846 BLEU-2=0.757
câu lạc bộ họp vào thứ bảy p1=0.857 p2=0.833 BP=1.000 BLEU-2=0.845
Câu lặp họp bốn lần chỉ được tính khớp một lần () và không có bigram nào khớp, nên điểm bằng 0: clipping chặn mẹo lặp từ. Câu thứ hai đúng nội dung nhưng thiếu “vào”, ngắn hơn tham chiếu nên BP bằng 0.846, điểm 0.757. Câu thứ ba đổi thứ sáu thành thứ bảy, sai đúng thông tin quan trọng nhất, vẫn được 0.845, cao hơn câu đúng. Đếm n-gram trùng không kiểm được sự thật.
BLEU còn phụ thuộc tokenizer (ở đây tách theo âm tiết) và cách tổng hợp trên corpus; khi thi, dùng đúng metric và cách tính đề cho, không thay bằng một metric thuận lợi hơn rồi so với điểm chính thức.
Tóm tắt thường chấm bằng ROUGE. ROUGE-N trong định nghĩa gốc là recall n-gram: bao nhiêu n-gram của tham chiếu xuất hiện trong bản tóm tắt; ROUGE-L dựa trên subsequence chung dài nhất. Recall không phạt bản tóm tắt dài dòng, nên đề thường giới hạn độ dài hoặc báo F1. Tham chiếu “mèo ngủ trên ghế” và tóm tắt “mèo ngủ” có ROUGE-1 recall , precision .
Khác biệt cốt lõi: ROUGE thiên về recall, BLEU thiên về precision kèm phạt độ ngắn. Tóm tắt bằng cách chọn nguyên văn vài câu quan trọng từ bài gốc là tóm tắt trích xuất (extractive); viết lại bằng câu mới là tóm tắt tóm lược (abstractive).
Hỏi nhanh
Đề tóm tắt chấm ROUGE-1 recall, không giới hạn độ dài. Một đội nộp nguyên văn bản nguồn làm “tóm tắt”, và đứng đầu bảng xếp hạng tạm. Điều này cho thấy gì?
Phần lớn từ của bản tham chiếu lấy từ nguồn, nên chép cả nguồn gần như phủ hết chúng, và recall gần 1. Precision thì rất thấp, vì phần lớn từ được nộp không có trong tham chiếu. Không cần đọc trộm tham chiếu mới đạt được điểm này. Bài nộp chỉ khai thác đúng chỗ metric không đo.
Tóm tắt còn cần kiểm người, ngày, số lượng và quan hệ trong nguồn. Câu ngắn nghe hợp lý nhưng đổi “chưa tổ chức” thành “đã tổ chức” là lỗi lớn dù trùng nhiều token. Caption phải đúng đối tượng và hành động trong ảnh; model có thể sinh chi tiết phổ biến nhưng không có trong ảnh.
Một tập nhỏ có thể chấm bằng bảng lỗi: thiếu thông tin, thừa chi tiết, đảo quan hệ, sai phủ định và lặp câu. Khi một tài liệu có nhiều bản tham chiếu hoặc bị cắt thành nhiều đoạn, chia train và validation theo tài liệu để nguồn không rò sang validation. Với văn bản sáng tác như thơ, có rất nhiều đáp án tốt và không có tham chiếu duy nhất, nên metric tự động chỉ là gợi ý; vẫn cần người đọc.
Script text-to-text chạy một encoder-decoder pretrained, FLAN-T5 small, có sẵn trên máy cho một yêu cầu như tóm tắt. Theo hướng dẫn của chương, script kiểm tên model, revision và SHA-256 từng tệp trước khi nạp, và không tải gì từ Internet.
Prompt được tokenize với giới hạn 256 token; dài hơn thì script dừng, vì nó không tự chia đoạn. model.generate giải mã greedy (do_sample=False) với --max-new-tokens (mặc định 48), rồi in JSON gồm output, thời gian sinh, số token output và, trên CUDA, đỉnh bộ nhớ. Muốn thử beam search, length_penalty hay no_repeat_ngram_size, thêm chúng vào lời gọi generate và so trên cùng tập validation. Caption cần thêm image encoder và bộ xử lý ảnh; đường dẫn ảnh không đi qua tokenizer chữ.
Khi đi thi
- Đọc metric trước khi chọn cách giải mã: BLEU phạt câu ngắn, exact match không cho điểm từng phần.
- Chọn checkpoint bằng output tự sinh trên validation, không bằng teacher-forced loss.
- Đọc vài chục output bằng mắt để bắt cụm lặp, mất phủ định, sai số.
Tóm tắt
- Encoder-decoder học : nguồn đọc trọn, đích sinh từ trái sang phải.
- Teacher forcing đưa token đích đúng ở bước trước; label dịch một vị trí, và thư viện tự dịch khi nhận
labels. - Cross-attention đặt query đích lên memory nguồn và cần mask PAD của nguồn; khác causal self-attention của decoder.
- Loss teacher-forced cần đi cùng generation; tách loss theo vị trí để biết lỗi do tổng quát hóa hay do tiền tố lệch.
- Beam search tìm chuỗi tốt theo model, không sửa được model sai; tổng log-prob thiên về câu ngắn.
- BLEU và ROUGE đo overlap n-gram, không kiểm sự thật.
Kiểm tra cuối bài
Câu 1
Một model tóm tắt encoder-decoder đọc nguồn 4000 token và sinh tóm tắt 100 token, mọi attention đều đầy đủ. Nếu nguồn tăng lên 8000 token mà tóm tắt vẫn 100 token, thì ma trận điểm của encoder self-attention và của cross-attention lớn lên bao nhiêu lần?
Encoder self-attention có ma trận , từ lên , tức gấp 4. Đó đúng là chi phí bậc hai theo độ dài chuỗi. Cross-attention có ma trận , từ lên , tức gấp 2. Decoder self-attention không đổi. Với tài liệu dài, phần đắt nhất là encoder, nên bài tóm tắt văn bản dài phải cắt, chia đoạn, hoặc dùng encoder có attention thưa.
Câu 2
Bài sửa chính tả: một đội dùng decoder-only thay cho encoder-decoder. Mỗi mẫu là chuỗi nguồn [SEP] đích, train theo next-token và tính loss ở mọi vị trí. Muốn objective giống encoder-decoder, tức chỉ học , thì cần đổi gì?
Tính loss ở mọi vị trí là học cả . Model tốn sức dự đoán câu sai chính tả vốn đã được cho sẵn, và loss trộn hai việc với nhau. Đặt label phần nguồn là -100 thì chỉ còn các số hạng , đúng objective của encoder-decoder. Causal self-attention trên chuỗi ghép đã cho phần đích đọc toàn bộ phần nguồn đứng trước, nên không cần cross-attention. [SEP] đánh dấu chỗ bắt đầu sinh. Đặt đích trước nguồn thì lúc sinh, phần đích chưa thấy nguồn.
Câu 3
Một model sinh caption gồm image encoder biến ảnh thành 196 vector patch, và một text decoder sinh caption. Một bạn đề xuất bỏ cross-attention cho nhẹ, decoder chỉ giữ causal self-attention trên các token chữ. Hậu quả chính là gì?
Cross-attention là chỗ chữ đặt query lên patch ảnh, với patch ảnh làm key và value. Bỏ nó đi thì self-attention chỉ trộn các token chữ với nhau, nên caption chỉ còn dựa vào thống kê ngôn ngữ, kiểu “một người đang đứng”, bất kể ảnh là gì. Có một thiết kế khác ghép patch vào đầu chuỗi chữ để self-attention đọc được, nhưng đề xuất ở đây không làm vậy. Embedding chữ không chứa gì về ảnh. Causal mask hoạt động độc lập với cross-attention.
Câu 4
Train model tóm tắt 12 epoch. Validation loss tính bằng teacher forcing giảm đều tới epoch 12. Còn ROUGE-L của output sinh bằng beam search trên cùng validation thì cao nhất ở epoch 8 rồi giảm. Đề chấm ROUGE-L. Nên nộp checkpoint nào?
Validation loss đo xác suất của tham chiếu khi được cho tiền tố đúng. Đề thì chấm chuỗi tự sinh, nên phải chọn theo ROUGE-L của output. Hai đại lượng này có thể lệch nhau, chẳng hạn khi model ngày càng tự tin vào câu ngắn hoặc cụm từ lặp. Đổi sang greedy là đổi bộ giải mã, phải đo chứ không mặc định là ROUGE-L sẽ tăng. Trung bình trọng số là một thí nghiệm khác, cũng phải đo trước khi nộp.
Câu 5
Bản tham chiếu dài 10 token. Brevity penalty của BLEU bằng bao nhiêu khi bản dịch dài 8 token, và khi bản dịch dài 12 token?
Với , . Với , , nhưng BP lấy với 1, nên bằng 1. BLEU không thưởng bản dịch dài, vì từ thừa đã làm precision giảm rồi. Tỉ số độ dài không phải BP. Đảo hai giá trị là nhầm chiều phạt.
Câu 6
Một model dịch có loss teacher forcing thấp nhưng exact match khi tự sinh lại thấp. Thí nghiệm nào kiểm được giả thuyết “lỗi bắt nguồn từ việc tiền tố tự sinh lệch khỏi tiền tố đúng”?
Nếu với tiền tố đúng model đoán đúng vị trí , nhưng khi tự sinh lại sai từ sau token sai đầu tiên, thì lỗi lan từ tiền tố. Đó đúng là exposure bias. Nếu với tiền tố đúng mà vẫn sai, như C C trong bài, thì lỗi nằm ở model. Chấm lại loss với tiền tố đúng không nhìn thấy tiền tố tự sinh. Tăng beam hay đổi metric là đổi cách sinh hoặc cách chấm, không tách được hai nguyên nhân.
Câu 7
Dataset tóm tắt có 2000 bài báo, mỗi bài có 3 bản tóm tắt tham chiếu do ba người viết. Một đội tách thành 6000 cặp (bài, tóm tắt) rồi chia ngẫu nhiên 80/20. ROUGE trên validation cao bất thường so với leaderboard. Nguyên nhân khả dĩ nhất là gì?
Mỗi cặp validation có hai cặp “anh em” cùng bài báo, mỗi cặp vào train với xác suất 0.8. Vì vậy khoảng số cặp validation có bài báo mà model đã gặp, kèm một bản tóm tắt gần giống. Phải chia theo bài báo, tức group split, để ba tham chiếu của một bài nằm cùng một phía. Tham chiếu nhiễu thường làm ROUGE thấp đi chứ không cao lên. Tỉ lệ chia không gây chênh lệch có hệ thống.
Câu 8
Một model dịch dựng như GRU trong bài: encoder dồn cả câu nguồn vào một vector 24 số rồi giao cho decoder. Với câu nguồn trên 40 token, bản dịch hay bỏ sót ý ở đầu câu. Thêm cross-attention vào decoder giúp đúng ở chỗ nào?
GRU dồn mọi thông tin của nguồn vào một trạng thái có kích thước cố định trước khi decoder bắt đầu. Ý ở đầu một câu dài vì vậy phải sống sót qua hàng chục bước cập nhật. Cross-attention giữ hidden state của mọi vị trí nguồn làm key và value. Ở mỗi bước sinh, query của decoder tính trọng số lên chúng rồi lấy tổ hợp, nên ý ở đầu câu vẫn được đọc trực tiếp. Attention không nén nguồn thành một vector cố định, vì đó chính là nút thắt nó gỡ bỏ. Nhìn trước token đích là lộ đáp án, và causal mask vẫn cấm. Lúc sinh, token sau phụ thuộc token vừa chọn, nên không thể sinh song song.
Phỏng theoNOAI 2025 Singapore · Phần 1 · Câu 16
Câu 9
Fine-tune T5 cho bài dịch. Label được tạo bằng tokenizer với add_special_tokens=False, nên không có EOS ở cuối. Train loss giảm bình thường. Khi sinh với max_new_tokens=64, hiện tượng nào dễ xảy ra nhất?
Model không còn được dạy dự đoán EOS sau nội dung, nên xác suất EOS ở mọi bước bị đẩy xuống, và vòng sinh chỉ dừng khi chạm max_new_tokens. Thư viện tự dịch label để tạo decoder input, nên label ngắn hơn không gây lỗi shape. Muốn model biết lúc dừng, chuỗi đích phải kết thúc bằng EOS, như label B A EOS trong bài.
Câu 10
Decoder transformer, chuỗi đích dài 50 token kể cả EOS. Khi train với teacher forcing, và khi sinh greedy, mỗi mẫu cần bao nhiêu lượt chạy decoder?
Teacher forcing có sẵn toàn bộ decoder input, và causal mask cho phép tính 50 vị trí trong một lượt. Lúc sinh, token phụ thuộc token vừa chọn, nên phải chạy 50 lượt nối tiếp. Lưu lại phần đã tính cho tiền tố, như KV cache ở bài 6.6, chỉ làm mỗi lượt rẻ hơn, không gộp được các lượt. Vì vậy thời gian sinh, chứ không phải thời gian train một bước, thường là phần cần dự toán kỹ trong ngân sách thi.
Đọc thêm
- Dive into Deep Learning - Sequence-to-sequence learning và Transformer. Đọc teacher forcing rồi nối sang cross-attention.
- Vaswani và cộng sự - Attention Is All You Need. Nguồn kiến trúc transformer encoder-decoder.
- Jurafsky, Martin - Machine Translation. Đọc cách đánh giá bản dịch và giới hạn của overlap.
- Papineni và cộng sự - BLEU; Lin - ROUGE. Xem định nghĩa metric thay vì chỉ gọi tên.
- Post - A Call for Clarity in Reporting BLEU Scores. Vì sao cùng một bản dịch cho BLEU khác nhau theo tokenizer, và cách báo điểm tái lập được.
- Hugging Face - Generation strategies. Đọc
num_beams,length_penalty,no_repeat_ngram_sizetrước khi gọigenerate. - Model card - FLAN-T5 small. Kiểm mục đích, tokenizer và license trước khi chuẩn bị asset text-to-text.
Tải gói recipe của chương - gồm các module phụ và hướng dẫn chuẩn bị asset.