$ cat ./blog/gen-ai/llm-fundamentals/fine-tuning-pretrained-model.md
seriesCách hoạt động của LLMbài 3

Fine-tuning một pretrained model: từ dữ liệu thô đến training loop

Bài thứ ba trong series Cách hoạt động của LLM: fine-tuning thực chất thay đổi điều gì, dữ liệu đi qua tokenizer và collate ra sao, loss biến thành gradient thế nào, optimizer cập nhật weights ra sao, và cách đọc learning curve để biết nên sửa gì.

~/diagrams/fine-tuning-pretrained-model/finetune-pipeline.htmltương tác
mở riêng ↗
HìnhToàn bộ đường đi của một quá trình fine-tuning: dữ liệu được chia và tokenize thành tensor, đi qua encoder và task head để ra logits, cross-entropy biến sai số thành gradient, optimizer cập nhật weights, rồi validation quyết định checkpoint nào được giữ lại.
dữ liệu và biểu diễnmodel và tối ưuđánh giá và checkpoint

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_mask và 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.

~/diagrams/fine-tuning-pretrained-model/finetune-pipeline.htmltương tác
mở riêng ↗
HìnhHàng trên là đường đi chính của dữ liệu: train split được tokenize thành tensor, đi qua encoder và head thành logits, cross-entropy biến sai số thành gradient, và validation kết thúc mỗi epoch. Hàng dưới là những quyết định định hình từng chặng - cách chia dữ liệu, cách gom batch, mức freeze, learning rate, và tiêu chí lưu checkpoint.
dữ liệu và biểu diễnmodel và tối ưuđánh giá và checkpoint

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

~/diagrams/fine-tuning-pretrained-model/finetune-data-split.htmltương tác
mở riêng ↗
HìnhĐường chính đi từ dataset thô qua khử trùng lặp rồi mới chia split, sau đó mỗi split giữ đúng vai trò của nó. Làn dưới là ba con đường rò rỉ hay gặp nhất: fit preprocessing trước khi chia, bỏ qua khử trùng lặp, và tune hyperparameter theo test score.
đường chuẩn bị dữ liệuvai trò từng splitđường rò rỉ

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.

~/diagrams/fine-tuning-pretrained-model/finetune-input-representation.htmltương tác
mở riêng ↗
HìnhMột cặp câu tách thành token rồi rẽ thành ba chuỗi số song song. input_ids và token_type_ids gộp vào phép cộng embedding để tạo vector đầu vào cho encoder, còn attention_mask đi thẳng vào bước tính attention - nó không phải một thành phần của biểu diễn token mà là thứ chặn padding trước softmax.
đường biểu diễn chínhthông tin segmentđường của mask

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à zlogits. 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-softmaxnegative 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_tv_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:

Cùng một mặt loss, hai độ lớn bước đi
Learning rate hợp lýLearning rate quá lớnbước ngắn dần, dừng ở đáynhảy qua đáy, biên độ lớn dần

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:

Hai schedule giảm learning rate
Linear decayCosine decayLRStepsLRSteps

Nhưng trước khi giảm, nhiều setup còn tăng dần ở đầu - đó là warmup:

Warmup rồi mới decay
LRStepswarmupdecay

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_t củ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.

~/diagrams/fine-tuning-pretrained-model/finetune-training-step.htmltương tác
mở riêng ↗
HìnhMột training step chia thành ba pha. Forward: DataLoader đưa batch vào model, model vừa tính logits vừa để autograd ghi lại computation graph, cross-entropy trả về loss. Backward: backward() đi ngược graph để tính gradient cho mọi parameter. Update: scheduler cấp learning rate cho step này, optimizer dùng gradient để cập nhật weights rồi xoá gradient trước batch kế tiếp.
đường đi chínhgradient và graphlearning rate và dọn dẹp

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_tv_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:

Learning curve lành mạnhTrainValidation
LossStepsValidationTrain

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.

~/diagrams/fine-tuning-pretrained-model/finetune-curve-diagnosis.htmltương tác
mở riêng ↗
HìnhQuy trình chẩn đoán bắt đầu từ việc so sánh hai đường loss thay vì nhìn riêng training loss. Ba làn dưới là ba triệu chứng loại trừ nhau - overfitting, underfitting và curve dao động - mỗi triệu chứng dẫn tới một nhóm hành động khác hẳn nhau, rồi tất cả quay lại một lần chạy mới với đúng một thay đổi.
đường quan sát chínhtriệu chứnghành động sửa

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:

OverfittingTrainValidation
LossStepsbest checkpointValidationTrain

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:

Curve dao động mạnh
LossSteps

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:

~/diagrams/fine-tuning-pretrained-model/finetune-strategy.htmltương tác
mở riêng ↗
HìnhBắt đầu bằng mức rẻ nhất là train head trên backbone đã freeze, chỉ mở thêm layer khi validation chưa đạt, và chỉ full fine-tuning khi hai mức trước không đủ. Làn dưới là rủi ro khi mở quá tay: overfitting đẩy quy trình quay lại mức freeze nhiều hơn, thay vì tiếp tục đi tới.
mức fine-tuning tăng dầnchốt và đo lần cuốirủi ro và đường quay lại

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.

Trương Đức Dũng

AI Engineer - Hà Nội

Tập trung vào LLM, RAG và các hệ thống AI cần chạy ổn định trong môi trường vận hành thật.