Bài đầu tiên mở bên trong một Transformer: token, attention, residual, FFN, ba họ kiến trúc. Bài thứ hai đi từ ngoài vào: pipeline(), tokenizer, task head, rồi tới KV cache và các engine phục vụ inference.
Cả hai bài đó đều nói về một model đã biết sẵn thứ gì đó. Bài này nói về việc thay đổi những gì nó biết.
Fine-tuning là kỹ thuật quen thuộc nhất khi làm việc với model ngôn ngữ hiện đại. Thay vì huấn luyện từ đầu với hàng tỷ mẫu, ta bắt đầu từ một model đã pretrain trên tập dữ liệu rất lớn, rồi huấn luyện tiếp trên một tập nhỏ hơn và sát với bài toán thật.
Ý tưởng nghe đơn giản. Nhưng để làm được, cần hiểu một chuỗi cơ chế nối vào nhau:
Dữ liệu
↓
Tokenizer
↓
Tensor
↓
Batch
↓
Model
↓
Logits
↓
Loss
↓
Backpropagation
↓
Gradient
↓
Optimizer
↓
Weights mới
Bài viết đi hết chuỗi đó, với trọng tâm là supervised fine-tuning cho bài toán classification trên một Transformer encoder kiểu BERT. Phần lớn nguyên lý ở đây áp dụng được cho nhiều kiến trúc và bài toán khác.
Những câu hỏi bài sẽ trả lời:
- Fine-tuning khác training from scratch ở đúng chỗ nào, và vì sao khác biệt đó lại quan trọng đến vậy?
- Vì sao chất lượng split dữ liệu là một phần của chất lượng model, không phải việc dọn dẹp phụ?
attention_maskvà padding giải quyết hai vấn đề khác nhau ra sao?- Vì sao classification head luôn được khởi tạo ngẫu nhiên, và điều đó ảnh hưởng gì tới learning rate?
- Cross-entropy nhìn thấy gì mà accuracy không nhìn thấy?
- Gradient xuất hiện từ đâu, và AdamW làm gì khác so với gradient descent?
- Vì sao quên
zero_grad()lại là một lỗi im lặng? - Nhìn vào learning curve, làm sao biết nên tăng learning rate hay nên dừng sớm?
01Fine-tuning thực chất thay đổi gì?
Một pretrained model là model đã được huấn luyện trên lượng dữ liệu lớn để học các quy luật tổng quát: quan hệ giữa các từ, cú pháp, ngữ nghĩa, ngữ cảnh, và những pattern lặp lại trong ngôn ngữ.
Corpus rất lớn
↓
Pretraining
↓
Pretrained model
↓
Biểu diễn ngôn ngữ tổng quát
Khi cần giải một bài toán cụ thể - sentiment classification, spam detection, intent classification, paraphrase detection, natural language inference - ta không cần huấn luyện lại toàn bộ từ đầu:
Pretrained model
+
Dataset của task
↓
Fine-tuning
↓
Model chuyên biệt
1.1 Khác biệt nằm ở điểm khởi đầu của parameters
Đây là điểm mấu chốt, và nó chỉ là một khác biệt duy nhất:
| Training from scratch | Fine-tuning | |
|---|---|---|
| Parameters ban đầu | ngẫu nhiên | đã học từ pretraining |
| Lượng dữ liệu cần | rất lớn | nhỏ hơn nhiều |
| Compute | rất tốn | tương đối rẻ |
| Tốc độ hội tụ | chậm | nhanh |
| Rủi ro chính | không đủ dữ liệu để học được gì | phá hỏng thứ model đã biết |
Ở cột thứ hai, model không bắt đầu từ trạng thái “không biết gì”. Các layer bên trong đã mang sẵn nhiều representation hữu ích.
Điều đó dẫn tới một hệ quả thực tế mà cả bài này sẽ quay lại nhiều lần: rủi ro lớn nhất của fine-tuning không phải học chậm, mà là học quá mạnh. Learning rate quá lớn, train quá lâu, hoặc dataset quá nhỏ đều có thể xoá đi phần kiến thức đáng giá nhất mà pretraining để lại.
1.2 Ba mức fine-tuning
Fine-tuning không đồng nghĩa với “train lại toàn bộ model”. Có ba mức phổ biến, khác nhau ở chỗ layer nào được phép nhận gradient:
| Chiến lược | Layer được train | Ưu điểm | Đánh đổi |
|---|---|---|---|
| Feature extraction | chỉ classification head | rẻ nhất, ít overfit nhất, backbone giữ nguyên | khả năng thích nghi thấp nhất |
| Partial fine-tuning | head + vài layer trên cùng | cân bằng giữa chi phí và chất lượng | phải chọn mở bao nhiêu layer |
| Full fine-tuning | toàn bộ model | thích nghi tốt nhất với task | tốn memory, dễ overfit, cần kiểm soát learning rate chặt |
Trực giác đằng sau thứ tự này: các layer thấp của một Transformer học những đặc trưng rất tổng quát, còn các layer cao gần với task hơn. Dataset càng nhỏ, càng nên giữ nguyên phần tổng quát và chỉ chỉnh phần gần task.
Phần 12 sẽ quay lại chuyện chọn mức nào, sau khi đã có đủ công cụ để đo.
02Bốn lớp của một pipeline fine-tuning
Lấy một bài toán cụ thể để bám theo suốt bài: paraphrase detection, tức xác định hai câu có cùng nghĩa hay không.
Sentence A:
"The company released its quarterly report."
Sentence B:
"The firm's quarterly results were published."
Label:
0 → không tương đương
1 → tương đương
Model không học trực tiếp từ chuỗi ký tự. Nó học từ tensor số. Vì vậy một training pipeline thực tế luôn có ít nhất bốn lớp:
| Lớp | Trách nhiệm | Hỏng thì triệu chứng là gì |
|---|---|---|
| Data | chia split, làm sạch, chống rò rỉ | validation score đẹp nhưng model vô dụng ngoài thực tế |
| Representation | tokenize, tạo tensor, padding, mask | shape đúng nhưng ngữ nghĩa sai từ bước đầu |
| Model | forward pass, tạo logits | nhãn sai, shape mismatch, head không khớp task |
| Optimization | loss, gradient, cập nhật weights | loss không giảm, dao động, hoặc giảm mà không generalize |
Cách chia này không chỉ để sắp xếp kiến thức. Nó là một công cụ chẩn đoán: khi có sự cố, câu hỏi đầu tiên nên là “triệu chứng này thuộc lớp nào?” trước khi đổi model hay chỉnh hyperparameter.
Điều sơ đồ này nói thêm so với một danh sách tuần tự: hàng dưới không phải phụ kiện. Mỗi ô ở hàng dưới là một quyết định bạn đưa ra trước khi training bắt đầu, và mỗi quyết định đó ràng buộc mọi thứ ở hàng trên. Chia dữ liệu sai thì không con số nào phía sau còn ý nghĩa. Chọn learning rate sai thì backbone tốt đến mấy cũng bị phá.
03Data pipeline và rò rỉ dữ liệu
Một model mạnh không bù được cho một data pipeline kém.
Trong supervised learning, dataset thường chia làm ba phần, và ba phần đó có ba vai trò không thay thế được cho nhau:
| Split | Được dùng để | Không được dùng để |
|---|---|---|
| Train | forward → loss → backward → cập nhật weights | báo cáo chất lượng |
| Validation | chọn checkpoint, chỉnh learning rate, quyết định dừng | cập nhật weights |
| Test | đo lần cuối cùng sau khi đã chốt mọi thứ | bất kỳ quyết định nào trong lúc tuning |
Validation set trả lời những câu hỏi mà training loss không trả lời được:
- model có generalize không?
- learning rate có phù hợp không?
- đã bắt đầu overfit chưa?
- checkpoint nào tốt nhất?
- có nên dừng không?
Test set thì khác hẳn. Nó chỉ có giá trị khi chưa từng ảnh hưởng tới bất kỳ quyết định nào. Nếu bạn xem test score sau mỗi lần đổi hyperparameter rồi chọn cấu hình theo nó, bạn đang “học” từ test set qua chính bộ não của mình. Con số cuối cùng khi đó không còn là ước lượng khách quan nữa - nó chỉ là một validation score thứ hai, được trang trí bằng cái tên khác.
3.1 Data leakage: lỗi nguy hiểm nhưng rất dễ mắc
Data leakage xảy ra khi thông tin từ validation hoặc test vô tình đi vào quá trình training. Ví dụ đơn giản nhất:
Train:
"This product is excellent"
Validation:
"This product is excellent"
Cùng một sample nằm ở hai split. Model chỉ cần nhớ, không cần hiểu, và validation score sẽ rất đẹp.
Những dạng rò rỉ hay gặp:
| Dạng | Cơ chế | Cách chặn |
|---|---|---|
| Sample trùng | cùng một câu xuất hiện ở nhiều split | khử trùng lặp trước khi chia |
| Rò rỉ theo nhóm | cùng một user, tài liệu, hoặc phiên nằm ở cả train và test | chia theo nhóm, không chia theo dòng |
| Preprocessing fit sớm | scaler, vocabulary, thống kê được fit trên toàn bộ dataset | fit trên train, chỉ transform val và test |
| Target leakage | feature được tạo từ thống kê của nhãn | tính feature trong phạm vi train |
| Augmentation | sinh biến thể gần giống rồi chia sang split khác | augment sau khi chia, chỉ trên train |
Điểm chung của cả năm: chúng đều làm validation score cao lên, không phải thấp đi. Đó là lý do chúng nguy hiểm. Một lỗi làm điểm số xấu đi sẽ bị phát hiện ngay; một lỗi làm điểm số đẹp lên thì thường được ăn mừng.
Vì vậy: chất lượng split là một phần của chất lượng model, không phải bước dọn dẹp trước khi làm việc thật.
04Biểu diễn input cho encoder
Neural network không hiểu trực tiếp chuỗi ký tự. Nó cần một chuỗi số:
"Transformers are powerful."
↓
[101, 19081, 2024, 3928, 102]
Một tokenizer hiện đại làm ba việc: chia text thành token hoặc subword, ánh xạ token thành integer ID, và thêm các special token cần thiết.
4.1 Vì sao là subword
Nếu mỗi từ là một token riêng, vocabulary sẽ bùng nổ, và model phải học một representation riêng cho từng biến thể:
run
runs
running
runner
runners
Subword tokenization tách chúng ra thành phần dùng chung:
running → run + ##ning
Đổi lại, ta được: vocabulary nhỏ hơn, xử lý từ hiếm tốt hơn, xử lý từ chưa từng thấy tốt hơn, và chia sẻ representation giữa các từ cùng gốc.
4.2 Special token và cấu trúc của một cặp câu
Với encoder kiểu BERT, một câu đơn được tổ chức:
[CLS] sentence [SEP]
Còn một cặp câu - đúng bài toán paraphrase detection của chúng ta:
[CLS] sentence A [SEP] sentence B [SEP]
Vai trò từng token:
| Token | Vai trò |
|---|---|
[CLS] |
vị trí có hidden state được dùng làm biểu diễn cho cả chuỗi trong nhiều bài classification |
[SEP] |
đánh dấu ranh giới giữa các sequence |
[PAD] |
làm các sequence trong cùng batch có cùng độ dài |
4.3 Input IDs chỉ là chỉ mục
Sau tokenization:
[CLS] hello world [SEP]
↓
[101, 7592, 2088, 102]
Các số này là input_ids. Điều quan trọng: model không xử lý chúng theo ý nghĩa số học. Token 2000 không “lớn gấp đôi” token 1000. ID chỉ là chỉ mục dòng trong bảng embedding:
E = W_embed[id]
Cụ thể:
token id = 7592
↓
tra bảng embedding
↓
[0.12, -0.34, 0.08, ..., 0.91]
4.4 Attention mask không đi qua embedding
Khi padding được thêm vào:
[CLS] hello world [SEP] [PAD] [PAD]
model cần biết token nào là dữ liệu thật. Attention mask ghi lại đúng điều đó:
1 1 1 1 0 0
1 → token thực
0 → padding
Nhưng cơ chế của nó không phải “bỏ token khỏi input”. Mask được áp vào attention score, ngay trước softmax. Nhắc lại công thức từ bài đầu tiên:
S = (Q K^T) / sqrt(d_k)
Mask cộng thêm một ma trận M vào đó:
S_masked = S + M
M = 0 tại vị trí hợp lệ
M = -inf tại vị trí bị mask
Sau softmax:
softmax(-inf) ≈ 0
nên [PAD] gần như không đóng góp gì vào attention.
Đây là chỗ rất dễ nhầm, nên nói thẳng: padding và attention mask giải quyết hai vấn đề khác nhau. Padding làm tensor có đúng shape. Attention mask làm phép tính có đúng ngữ nghĩa. Có padding mà thiếu mask thì code vẫn chạy, shape vẫn đúng, và self-attention vẫn nhìn vào những token rỗng.
4.5 Segment: token này thuộc câu nào
Khi model nhận hai câu cùng lúc, nó cần biết ranh giới:
[CLS] sentence A [SEP] sentence B [SEP]
0 0 0 0 1 1 1
Thông tin này thành segment embedding. Biểu diễn cuối cùng của một token là tổng của ba thành phần:
x_i = E_token(i) + E_position(i) + E_segment(i)
Trong đó token embedding mã hoá token đó là gì, position embedding mã hoá nó đứng ở đâu, và segment embedding mã hoá nó thuộc câu nào. Không phải kiến trúc nào cũng dùng segment embedding, nhưng nó là cách gọn nhất để hiểu một encoder xử lý cặp câu ra sao.
05Padding, batching và chi phí thật
GPU hoạt động hiệu quả khi xử lý nhiều sample cùng lúc. Thay vì đưa từng sample vào model, ta gom thành batch:
[sample 1, sample 2, sample 3]
↓
model
Nhưng câu thường có độ dài khác nhau, trong khi một tensor batch phải là hình chữ nhật:
[batch_size, sequence_length]
Nên phải padding.
5.1 Global padding và dynamic padding
Global padding pad mọi sequence tới một độ dài cố định:
max_length = 512
Một câu 40 token trở thành 40 token thật cộng 472 token rỗng. Dễ triển khai, nhưng lãng phí.
Dynamic padding chỉ pad theo sample dài nhất trong chính batch đó:
Batch 1: 32, 45, 39, 50 → pad tới 50
Batch 2: 71, 63, 69 → pad tới 71
Tổng số token giả phải xử lý giảm đi đáng kể.
5.2 Vì sao padding vẫn tốn tài nguyên dù đã có mask
Đây là câu hỏi đáng dừng lại. Attention mask khiến [PAD] không đóng góp ý nghĩa, nhưng nó không làm tensor nhỏ đi.
Với batch 32, sequence length 512, hidden size 768, activation tensor có kích thước cỡ:
32 × 512 × 768
Nếu sequence thực tế chỉ dài trung bình 100 token, thì phần lớn khối tensor đó tồn tại chỉ vì padding - và nó vẫn chiếm GPU memory, vẫn đi qua các phép nhân ma trận.
Tệ hơn, self-attention có độ phức tạp gần:
O(n^2)
theo sequence length n. Tăng sequence từ 128 lên 512:
512^2 / 128^2 = 16
Phần attention phải xử lý số cặp token lớn gấp 16 lần. Đây là lý do “cứ đặt max_length=512 cho chắc” là một trong những quyết định tốn kém nhất mà cũng dễ mắc nhất.
5.3 Collate function
Trong data loader, collate function là bước gom các sample riêng lẻ thành một batch tensor:
sample riêng lẻ
↓
collate
↓
một batch tensor
Nó thường lo: pad sequence, stack tensor, tạo attention mask, xử lý labels, convert dtype.
Nguyên tắc thực hành: trì hoãn padding tới lúc batch được tạo. Nếu pad ngay lúc tokenize toàn bộ dataset, bạn đã khoá mình vào global padding. Nếu pad trong collate, bạn được dynamic padding gần như miễn phí.
06Từ hidden state đến logits
Một pretrained encoder tạo representation cho từng token:
Input tokens
↓
Transformer encoder
↓
Hidden states
Nếu sequence dài n và hidden dimension là d:
H có shape [n, d]
Để phân loại cả chuỗi, ta cần biến toàn bộ tensor đó thành một vector đại diện. Cách phổ biến với encoder kiểu BERT là lấy hidden state của token đầu tiên:
h_cls có shape [d]
Rồi đưa qua một linear layer:
z = W · h_cls + b
trong đó W có shape [C, d] với C là số class, và z là logits. Với binary classification:
hidden vector (768 chiều)
↓
Linear(768 → 2)
↓
[logit_class_0, logit_class_1]
Linear layer này gọi là classification head.
6.1 Vì sao head luôn được khởi tạo ngẫu nhiên
Pretrained backbone học representation tổng quát. Nhưng task mới có label space mới:
Task A: positive / negative → 2 output
Task B: entailment / neutral / contradiction → 3 output
Output dimension khác nhau, nên không có trọng số nào để tái sử dụng. Head buộc phải tạo mới:
Pretrained backbone
↓
Head khởi tạo ngẫu nhiên
Điều này có một hệ quả rất thực tế. Trong những training step đầu tiên, head còn ngẫu nhiên nên gradient chảy ngược về backbone rất nhiễu. Nếu learning rate lớn ngay từ bước đầu, những gradient nhiễu đó có thể làm hỏng chính phần representation mà ta muốn giữ. Đây là lý do kỹ thuật warmup ở phần 9 tồn tại.
Fine-tuning vì vậy làm hai việc cùng lúc: học một head cho task mới, và chỉnh backbone để representation hợp với task đó hơn.
6.2 Logits chưa phải xác suất
Giả sử output:
logits = [-1.2, 2.8]
Softmax biến chúng thành phân phối xác suất:
p_i = exp(z_i) / sum_j exp(z_j)
Cụ thể:
logits: [-1.2, 2.8]
softmax: [0.018, 0.982]
argmax → class 1
Nhưng trong training, loss function thường nhận raw logits, không cần tự softmax trước. Lý do là implementation của loss gộp log-softmax và negative log likelihood vào một operation ổn định hơn về numerical precision. Tự softmax rồi lấy log là cách nhanh nhất để gặp NaN khi xác suất tiến gần 0.
07Cross-entropy và cách model biết mình sai
Với classification nhiều lớp, loss phổ biến là cross-entropy. Nếu class đúng là y:
L = -log(p_y)
Chỉ cần nhìn vài giá trị là thấy ngay tính chất của nó:
| Xác suất model gán cho class đúng | Loss |
|---|---|
| 0,99 | ≈ 0,01 |
| 0,90 | ≈ 0,11 |
| 0,50 | ≈ 0,69 |
| 0,10 | ≈ 2,30 |
| 0,01 | ≈ 4,61 |
Cross-entropy không chỉ quan tâm đúng hay sai. Nó quan tâm model tự tin đến mức nào. Sai mà tự tin thì bị phạt rất nặng.
Đây cũng là lý do loss có thể tiếp tục giảm dù accuracy chưa nhúc nhích.
7.1 Loss và accuracy đo hai thứ khác nhau
Giả sử nhãn đúng là class 1.
Model A: P(class 1) = 0.40 → dự đoán class 0
Model B: P(class 1) = 0.49 → dự đoán class 0
Cả hai đều sai, nên accuracy của cả hai đều bằng 0. Nhưng model B đã gần decision boundary hơn hẳn, và cross-entropy nhìn thấy điều đó:
| Accuracy | Cross-entropy | |
|---|---|---|
| Model A | 0 | -log(0.40) ≈ 0,92 |
| Model B | 0 | -log(0.49) ≈ 0,71 |
Loss là đại lượng liên tục; accuracy là metric rời rạc. Một thay đổi nhỏ trong weights hầu như luôn làm loss đổi, nhưng thường không đủ để lật một dự đoán.
Đó chính là lý do phân vai: loss là objective để tối ưu, accuracy là metric để báo cáo. Gradient descent cần một mặt phẳng mượt để trượt xuống, và accuracy không cung cấp được điều đó.
7.2 Khi accuracy nói dối
Accuracy còn một điểm yếu nữa: nó sụp đổ khi class mất cân bằng.
95% negative
5% positive
Một model luôn dự đoán negative đạt accuracy 95% mà hoàn toàn vô dụng. Vì vậy cần thêm ba metric:
Precision = TP / (TP + FP)
Recall = TP / (TP + FN)
F1 = 2 · (Precision · Recall) / (Precision + Recall)
| Metric | Trả lời câu hỏi | Dùng khi |
|---|---|---|
| Precision | trong những dự đoán positive, bao nhiêu là đúng? | false positive đắt - spam, fraud detection |
| Recall | trong tất cả positive thật, model tìm ra bao nhiêu? | false negative nguy hiểm - sàng lọc bệnh, phát hiện sự cố |
| F1 | cân bằng cả hai | class mất cân bằng, cần một con số duy nhất |
08Backpropagation và optimizer
Một training step bắt đầu bằng forward pass:
Input → Embedding → Transformer layers → Classifier → Logits → Loss
Trong lúc forward, framework âm thầm xây một computation graph: mỗi operation ghi lại quan hệ toán học của nó với operation trước đó.
Lấy ví dụ đơn giản nhất:
z = w · x
L = (z - y)^2
Ta muốn biết loss thay đổi thế nào khi w thay đổi. Chain rule cho:
dL/dw = (dL/dz) · (dz/dw)
Trong một Transformer thật, graph có hàng triệu operation, nhưng nguyên lý không đổi. backward() chạy automatic differentiation để tính gradient cho mọi trainable parameter.
8.1 Gradient nói gì với optimizer
Gradient ∇L cho biết hướng loss tăng nhanh nhất. Muốn giảm loss thì đi ngược lại:
θ ← θ - η · ∇L
với θ là parameters và η là learning rate. Đó là toàn bộ gradient descent.
8.2 AdamW làm gì hơn thế
Gradient descent thuần hoạt động được, nhưng deep learning thường dùng optimizer thích nghi. Adam giữ hai thống kê chạy theo thời gian:
m_t = β1 · m_{t-1} + (1 - β1) · g_t (trung bình trượt của gradient)
v_t = β2 · v_{t-1} + (1 - β2) · g_t^2 (trung bình trượt của bình phương gradient)
Sau bias correction, update được scale theo:
m_hat / (sqrt(v_hat) + ε)
Ý nghĩa: mỗi parameter có learning rate hiệu dụng riêng, thích nghi theo lịch sử gradient của chính nó. Parameter có gradient dao động mạnh sẽ bị bước ngắn lại; parameter có gradient ổn định được bước dài hơn.
Điều này cũng giải thích một chi tiết quan trọng ở phần 10: optimizer có trạng thái. m_t và v_t là thứ nó tích luỹ qua hàng nghìn step, và mất chúng không giống như mất weights - nhưng cũng không phải không mất gì.
8.3 Adam và AdamW khác nhau ở đâu
Weight decay là kỹ thuật regularization khuyến khích weights không phình to. Trực giác đơn giản:
L_total = L_task + λ · ||θ||^2
Cách cũ là cộng thẳng L2 penalty vào gradient, rồi để Adam xử lý gradient đó. Vấn đề: cơ chế adaptive của Adam chia update cho sqrt(v_hat), nên nó cũng vô tình chia luôn phần weight decay - và mức decay thực tế trở nên khác nhau giữa các parameter theo cách không ai mong muốn.
AdamW thực hiện decoupled weight decay, tách weight decay ra khỏi adaptive update:
θ ← θ - η · AdamUpdate - η · λ · θ
Phần decay giờ áp dụng đều cho mọi parameter. Đây là lý do AdamW gần như luôn là mặc định hợp lý khi fine-tuning Transformer.
8.4 Gradient clipping
Đôi khi gradient norm tăng vọt, kéo theo một chuỗi hỏng:
gradient lớn → update lớn → loss spike → training mất ổn định
Gradient clipping đặt trần cho gradient norm:
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
Nếu ||g|| > max_norm, toàn bộ gradient được scale xuống - giữ nguyên hướng, chỉ giảm độ lớn.
Clipping không thay thế việc chỉnh learning rate. Nó là lưới an toàn cho những bước bất thường, không phải cách sửa một learning rate sai.
09Learning rate, batch size và các núm vặn
Learning rate quyết định độ lớn mỗi bước update, và nó là hyperparameter ảnh hưởng mạnh nhất tới fine-tuning.
| Learning rate | Triệu chứng |
|---|---|
| Quá nhỏ | training chậm, loss giảm lê thê, có thể chưa hội tụ khi hết epoch |
| Hợp lý | loss giảm đều rồi phẳng dần |
| Quá lớn | loss dao động, optimizer overshoot, đôi khi loss bật lên rồi không về được |
Minh hoạ trực quan:
Fine-tuning gần như luôn dùng learning rate nhỏ hơn training from scratch. Lý do quay lại phần 1: ta không muốn phá hỏng representation đã học. Khoảng 2e-5 đến 5e-5 là điểm khởi đầu quen thuộc cho một encoder cỡ BERT.
9.1 Scheduler và warmup
Learning rate không nhất thiết cố định. Hai schedule phổ biến:
Nhưng trước khi giảm, nhiều setup còn tăng dần ở đầu - đó là warmup:
Warmup giải quyết đúng vấn đề đã nêu ở phần 6.1. Ở những step đầu tiên:
- classification head còn ngẫu nhiên, nên gradient nhiễu;
- moment
m_t,v_tcủa optimizer chưa tích luỹ đủ để ước lượng đáng tin; - một update quá mạnh lúc này có thể làm hỏng backbone trước khi head kịp học được gì.
Cho learning rate bò lên từ 0 trong vài trăm step đầu là cách rẻ nhất để tránh chuyện đó.
9.2 Batch size và effective batch size
Batch size là số sample dùng cho một lượt forward/backward. Mỗi batch cho một ước lượng gradient:
g ≈ (1/B) · sum_{i=1..B} ∇L_i
| Batch lớn | Batch nhỏ | |
|---|---|---|
| Gradient | ổn định hơn | nhiễu hơn |
| GPU | tận dụng tốt hơn | ít memory hơn |
| Tác dụng phụ | có thể cần learning rate cao hơn | nhiễu đôi khi có tác dụng regularization |
9.3 Gradient accumulation
Nếu GPU chỉ chứa nổi batch 4 nhưng bạn muốn effective batch 16, không cần đổi phần cứng - chỉ cần hoãn optimizer.step():
micro batch 1 → backward
micro batch 2 → backward
micro batch 3 → backward
micro batch 4 → backward
↓
optimizer.step()
optimizer.zero_grad()
effective batch = 4 × 4 = 16
Kỹ thuật này chạy được chính vì PyTorch cộng dồn gradient theo mặc định - đúng cái tính chất mà phần 10 sẽ chỉ ra là nguồn của một lỗi im lặng khi ta không cố ý dùng nó.
Một lưu ý dễ bỏ sót: loss cần được chia cho số accumulation step, nếu không gradient sẽ có độ lớn gấp 4 lần mong đợi và learning rate hiệu dụng bị lệch.
9.4 Mixed precision
Training truyền thống dùng FP32 - mỗi số chiếm 32 bit. Mixed precision chuyển phần lớn phép tính sang FP16 hoặc BF16, giữ một số bước quan trọng ở precision cao hơn.
Lợi ích: giảm activation memory, giảm băng thông, tăng throughput trên phần cứng có tensor core.
Rủi ro nằm ở FP16: dynamic range của nó hẹp hơn FP32 nhiều, nên gradient rất nhỏ có thể underflow về 0 - biến mất hoàn toàn thay vì chỉ mất chính xác. Kỹ thuật chuẩn để chống lại là loss scaling:
L' = s · L
Backward trên L' tạo gradient lớn hơn s lần, đủ để nằm trong vùng biểu diễn được của FP16. Trước khi optimizer cập nhật, gradient được chia lại cho s.
BF16 có exponent range giống FP32 hơn, nên thường ổn định hơn FP16 và ít cần loss scaling - nếu phần cứng của bạn hỗ trợ.
10Training loop hoàn chỉnh
Một training loop tối thiểu bằng PyTorch:
model.train()
for batch in train_loader:
input_ids = batch["input_ids"].to(device)
attention_mask = batch["attention_mask"].to(device)
labels = batch["labels"].to(device)
logits = model(
input_ids=input_ids,
attention_mask=attention_mask,
)
loss = criterion(logits, labels)
loss.backward()
optimizer.step()
scheduler.step()
optimizer.zero_grad()
Điều đáng chú ý ở đây không phải cú pháp, mà là trật tự. Mỗi lời gọi phụ thuộc vào kết quả của lời gọi trước, và đảo thứ tự sẽ hỏng theo những cách không phải lúc nào cũng báo lỗi.
10.1 Vì sao phải zero_grad()
Trong PyTorch, gradient cộng dồn vào .grad theo mặc định. Nếu không xoá sau mỗi batch:
gradient lưu trong .grad = g1 + g2 + g3 + ...
Model sẽ cập nhật theo tổng gradient của mọi batch đã đi qua, với độ lớn tăng dần. Không có exception nào được ném ra. Loss chỉ đơn giản là hành xử kỳ lạ.
Như phần 9.3 đã nói, chính tính chất này là thứ làm gradient accumulation khả thi. Khác biệt duy nhất giữa “một kỹ thuật hữu ích” và “một bug” ở đây là bạn có cố ý hay không.
10.2 model.train(), model.eval() và dropout
Một model có thể hành xử khác nhau giữa training và evaluation. model.train() bật behavior training; model.eval() chuyển các module như dropout sang chế độ inference.
Dropout ngẫu nhiên loại bỏ một phần activation trong lúc training:
Trước: [0.2, 0.7, -0.1, 0.9, 0.4]
Sau: [0.2, 0.0, -0.1, 0.0, 0.4]
Mục tiêu là giảm việc model phụ thuộc quá mạnh vào một nhóm feature cụ thể. Ở inference, dropout được tắt.
Quên model.eval() khi validation dẫn tới: prediction trở nên ngẫu nhiên, validation metric bị nhiễu, và hai lần đánh giá trên cùng dữ liệu cho hai kết quả khác nhau. Rất khó chẩn đoán nếu không biết chỗ để nhìn.
10.3 torch.no_grad() giải quyết vấn đề khác
model.eval()
with torch.no_grad():
for batch in validation_loader:
...
Cần phân biệt rạch ròi:
| Làm gì | Không làm gì | |
|---|---|---|
model.eval() |
đổi behavior của dropout, batch norm… | không ảnh hưởng tới gradient tracking |
torch.no_grad() |
ngừng dựng computation graph, tiết kiệm memory, chạy nhanh hơn | không đổi behavior của bất kỳ layer nào |
Hai cơ chế, hai vấn đề. Evaluation cần cả hai.
10.4 Loop đầy đủ hơn
Ghép mixed precision, gradient clipping và evaluation vào:
for epoch in range(num_epochs):
model.train()
for batch in train_loader:
input_ids = batch["input_ids"].to(device)
attention_mask = batch["attention_mask"].to(device)
labels = batch["labels"].to(device)
optimizer.zero_grad()
with autocast():
logits = model(
input_ids=input_ids,
attention_mask=attention_mask,
)
loss = criterion(logits, labels)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
scaler.step(optimizer)
scaler.update()
scheduler.step()
model.eval()
all_predictions = []
all_labels = []
with torch.no_grad():
for batch in validation_loader:
input_ids = batch["input_ids"].to(device)
attention_mask = batch["attention_mask"].to(device)
logits = model(
input_ids=input_ids,
attention_mask=attention_mask,
)
all_predictions.append(logits.argmax(dim=-1).cpu())
all_labels.append(batch["labels"])
# Tính validation metric
# Lưu checkpoint nếu cải thiện
# Áp dụng early stopping nếu cần
Chi tiết đáng chú ý: scaler.unscale_(optimizer) phải chạy trước khi clip. Nếu clip trên gradient còn đang bị scale lên s lần, max_norm=1.0 sẽ không còn nghĩa gì cả.
10.5 Checkpoint không chỉ là weights
Một checkpoint đầy đủ nên chứa:
model weights
optimizer state (m_t, v_t)
scheduler state
epoch hiện tại
global step hiện tại
random states
Vì sao optimizer state quan trọng? Như phần 8.2 đã nói, Adam tích luỹ m_t và v_t qua hàng nghìn step. Nếu chỉ load weights rồi tạo optimizer mới, việc “resume” thực chất là khởi động lại quá trình tối ưu từ một điểm khác - hai moment về 0, và những step đầu sau khi resume sẽ hành xử như những step đầu của một run mới.
Checkpoint tử tế cho phép: resume khi job crash, chọn model tốt nhất, rollback, và so sánh nhiều giai đoạn training.
11Learning curves và chẩn đoán
Training chạy hết epoch không có nghĩa là training thành công. Thứ cần nhìn là learning curves, và cần nhìn cả bốn đường: training loss, validation loss, training metric, validation metric.
Một quá trình học lành mạnh trông như thế này:
Dấu hiệu tốt: cả hai cùng giảm, khoảng cách giữa chúng hợp lý và không nới rộng dần, curve phẳng dần, metric tăng rồi plateau.
11.1 Overfitting
Overfitting xảy ra khi model học training data ngày càng tốt trong lúc generalization xấu đi:
Dấu hiệu: training loss ↓ trong khi validation loss ↑, hoặc training accuracy tăng mạnh còn validation accuracy plateau rồi tụt.
Nguyên nhân thường gặp: model quá lớn so với dataset, train quá lâu, dataset nhỏ, labels nhiễu, phân phối training hẹp, regularization yếu, hoặc split không tốt.
Các cách giảm, xếp theo mức can thiệp:
| Cách | Cơ chế |
|---|---|
| Early stopping | dừng ngay khi validation ngừng cải thiện |
| Weight decay | hạn chế weights phình to |
| Dropout | giảm co-adaptation giữa các feature |
| Data augmentation | tăng độ đa dạng của training data |
| Freeze bớt backbone | giảm số parameter được phép học |
| Giảm model capacity | dùng model nhỏ hơn nếu dataset rất nhỏ |
11.2 Early stopping và best checkpoint
Giả sử validation loss qua các epoch:
Epoch 1 → 0.54
Epoch 2 → 0.43
Epoch 3 → 0.37 ← tốt nhất
Epoch 4 → 0.39
Epoch 5 → 0.42
Epoch 6 → 0.45
Model tốt nhất nằm ở epoch 3, không phải epoch 6. Nói cách khác:
Checkpoint cuối ≠ Checkpoint tốt nhất
Early stopping thường có tham số patience. Với patience = 3, training dừng khi validation metric không cải thiện trong 3 lần đánh giá liên tiếp - đủ kiên nhẫn để bỏ qua một lần dao động, nhưng không kéo dài vô ích.
Điều quan trọng hơn cả việc dừng: lưu checkpoint theo validation metric, chứ không phải lấy model cuối cùng.
11.3 Underfitting
Underfitting là khi model chưa học nổi cả training data:
training loss = cao
validation loss = cao
training metric = thấp
validation metric = thấp
Nguyên nhân có thể: learning rate quá thấp, số epoch quá ít, model capacity quá nhỏ, freeze quá nhiều layer, preprocessing sai, labels có vấn đề, hoặc input đơn giản là không chứa đủ thông tin để giải bài toán.
Chẩn đoán phân biệt đơn giản nhất trong toàn bộ bài này:
Training tốt + Validation kém → Overfitting
Training kém + Validation kém → Underfitting
Hai trường hợp này dẫn tới hai nhóm hành động ngược nhau. Nhầm lẫn chúng nghĩa là vặn núm sai chiều: thêm regularization cho một model đang underfit sẽ chỉ làm nó tệ hơn.
11.4 Curve dao động
Một số run không giảm mượt:
Nguyên nhân có thể: learning rate quá cao, batch quá nhỏ, gradient explosion, data quá nhiễu, preprocessing không nhất quán, mixed precision không ổn định, hoặc scheduler không phù hợp.
Hướng xử lý, thử theo thứ tự này:
↓ learning rate
↑ batch size (hoặc gradient accumulation)
bật gradient clipping
kiểm tra data và labels
kiểm tra NaN / Inf
xem lại scheduler
11.5 Hội tụ không đồng nghĩa với tốt
Model được coi là gần hội tụ khi training không còn cải thiện đáng kể:
0.80 → 0.55 → 0.41 → 0.35 → 0.33 → 0.329 → 0.328
Nhưng hội tụ chỉ nói về quá trình tối ưu, không nói gì về chất lượng. Một model underfit vẫn hội tụ - nó chỉ hội tụ ở một mức loss cao.
Optimization convergence ≠ Good generalization
Kết luận về chất lượng luôn cần validation metric, không bao giờ chỉ cần training loss phẳng.
12Từ một run đến một quy trình
Một training run hiếm khi là đủ. Phần cuối này nói về việc biến fine-tuning từ một lần chạy may rủi thành một quy trình lặp lại được.
12.1 Mở dần, đừng mở hết ngay
Quay lại ba chiến lược ở phần 1. Cách làm hiệu quả trong thực tế là đi từ mức rẻ nhất lên, thay vì bắt đầu bằng cấu hình nặng nhất:
Cách này có hai giá trị. Thứ nhất, nó tránh trả giá đắt khi không cần. Thứ hai - và quan trọng hơn - nó cho bạn biết mức nào thực sự tạo ra cải thiện. Nếu nhảy thẳng vào full fine-tuning và kết quả tốt, bạn không biết liệu train mỗi head có đạt được y như vậy với một phần chi phí hay không.
Tóm tắt khi nào chọn gì:
| Chọn | Khi |
|---|---|
| Feature extraction | dataset rất nhỏ, compute hạn chế, task gần với representation sẵn có |
| Partial fine-tuning | dataset vừa phải, muốn cải thiện thêm mà vẫn kiểm soát được overfit |
| Full fine-tuning | task khác đáng kể so với pretraining, dataset đủ lớn, đủ GPU memory |
12.2 Experiment tracking
Ta thường thử nhiều cấu hình:
Run A: lr = 5e-5, batch = 16
Run B: lr = 2e-5, batch = 16
Run C: lr = 2e-5, batch = 32
Run D: lr = 2e-5, batch = 32, weight_decay = 0.01
Nếu không ghi lại, rất khó biết run nào tốt vì lý do gì. Một experiment log nên lưu tối thiểu:
model version scheduler
dataset version warmup ratio
random seed weight decay
learning rate số epoch
batch size validation metric
optimizer best checkpoint
thời gian train + phần cứng
Nguyên tắc đi kèm: đổi một thứ mỗi lần. Nếu Run D đổi cả batch size lẫn weight decay so với Run B, kết quả tốt hơn không cho bạn biết thứ nào đã giúp.
12.3 Reproducibility
Deep learning có nhiều nguồn ngẫu nhiên: khởi tạo parameter, xáo trộn dữ liệu, dropout, augmentation, và cả một số GPU kernel.
import random
import numpy as np
import torch
seed = 42
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
Cần thành thật về giới hạn: set seed giảm biến thiên chứ không loại bỏ hoàn toàn, vì một số GPU operation vốn không deterministic. Do đó, nếu phương sai giữa các run đáng kể, hãy báo cáo kết quả dựa trên nhiều run thay vì một con số duy nhất.
12.4 Bảng tra lỗi thường gặp
| Lỗi | Triệu chứng | Sửa |
|---|---|---|
| Learning rate quá cao | loss spike, validation không tăng, model degrade nhanh | giảm LR, thêm warmup |
| Tune theo test set | điểm cuối cùng đẹp nhưng không tái lập được ngoài thực tế | chỉ dùng test một lần, ở cuối |
Fixed max_length quá lớn |
GPU memory cạn, training chậm bất thường | dynamic padding trong collate |
| Quên attention mask | kết quả đổi khi đổi batch size | truyền attention_mask vào model |
Quên model.eval() |
validation metric nhiễu, hai lần đo khác nhau | gọi eval() trước vòng validation |
Quên torch.no_grad() |
evaluation tốn memory bất thường | bọc vòng validation trong no_grad() |
Không zero_grad() |
loss hành xử kỳ lạ, không có lỗi nào được ném | zero_grad() sau mỗi optimizer.step() |
| Chỉ nhìn accuracy | model bỏ qua hoàn toàn lớp thiểu số | thêm precision, recall, F1 |
| Không lưu best checkpoint | model cuối tệ hơn model giữa chừng | lưu theo validation metric |
| Không track hyperparameter | không biết thay đổi nào có tác dụng | ghi log mọi run, đổi một thứ mỗi lần |
Kết luận
Fine-tuning một pretrained Transformer không phải là gọi một hàm train(). Đằng sau nó là một chuỗi cơ chế nối chặt vào nhau, và bài này đã đi hết chuỗi đó: dữ liệu được chia sao cho không rò rỉ, tokenizer biến văn bản thành ba chuỗi số, collate gom chúng thành tensor, encoder và head tạo logits, cross-entropy biến sai số thành một con số, autograd biến con số đó thành gradient, và AdamW biến gradient thành weights mới.
Nhưng điều đáng giữ lại nhất không phải chuỗi đó. Nó là chuyện có hai vòng lặp, không phải một:
Vòng trong (mỗi batch) Vòng ngoài (mỗi lần đánh giá)
forward evaluate
→ backward → chẩn đoán
→ update → chỉnh hyperparameter
→ chạy lại
Vòng trong là thứ code làm. Vòng ngoài là thứ bạn làm, và nó mới là nơi quyết định model cuối cùng tốt đến đâu.
Một sai lầm phổ biến là nghĩ loss càng thấp thì model càng tốt. Training loss chỉ nói model khớp training data đến đâu. Mục tiêu thật là generalization - hoạt động tốt trên dữ liệu chưa từng thấy - và một model tốt phải cân bằng bốn thứ cùng lúc: optimization, generalization, robustness, efficiency.
Hiểu được dữ liệu đi qua pipeline thế nào, loss biến thành gradient ra sao, optimizer cập nhật weights bằng cách nào, và learning curve phản ánh điều gì, thì training không còn là hộp đen. Đó là nền tảng trước khi đi xa hơn tới parameter-efficient fine-tuning, instruction tuning, multi-task learning, distributed training và preference optimization.
Ở bài này, dữ liệu huấn luyện là thứ ta kiểm soát hoàn toàn: ta chọn split, chọn nhãn, chọn cách tokenize. Nhưng phần lớn hệ thống LLM thực tế không fine-tune gì cả - chúng đưa thông tin vào model ngay tại thời điểm chạy, qua cửa sổ ngữ cảnh. Bài tiếp theo trong series đi vào đúng chỗ đó: context engineering - ngân sách token, thứ tự thông tin, và những gì nên bị cắt trước khi prompt chạm tới model.