Đây là Chương 4 trong chuỗi bài giảng tương tác “Nền tảng Mô hình Ngôn ngữ Lớn | Học qua Thực hành”. Ở Chương 3, chúng ta đã chi hàng triệu đô-la tính toán để tiền huấn luyện một mô hình nền tảng (base model) trên hàng nghìn tỷ từ thô từ Internet. Kết quả là một hệ thống sở hữu kho tàng tri thức khổng lồ của nhân loại, nhưng lại hoàn toàn không biết… trò chuyện.
Trong chương này, bạn sẽ tự tay cài đặt toàn bộ các thuật toán Hậu huấn luyện và Căn chỉnh (Post-training & Alignment) hiện đại nhất: từ SFT với mặt nạ hàm mất mát, tinh chỉnh hạng thấp LoRA, căn chỉnh sở thích RLHF / DPO, cho đến thuật toán học tăng cường GRPO đứng sau khả năng tự suy luận của DeepSeek-R1.
Mỗi khái niệm đều đi kèm biểu đồ tương tác và bài tập PyTorch chạy hoàn toàn độc lập dưới vài giây trên CPU. Hãy tự mình viết mã trước khi mở lời giải!
Hãy thử đặt mình vào thời điểm tháng 5 năm 2020, ngay sau khi OpenAI hoàn tất quá trình tiền huấn luyện GPT-3 với 175 tỷ tham số trên 300 tỷ token web. Bạn mở giao diện dòng lệnh, gõ vào một câu hỏi tưởng chừng đơn giản:
“Giải thích cách hoạt động của mặt trăng cho một đứa trẻ 6 tuổi trong vài câu.”
Bạn kỳ vọng một câu trả lời ấm áp, dễ hiểu. Nhưng thứ mà mô hình nền tảng trả về lại là:
“Giải thích cách hoạt động của mặt trời cho một đứa trẻ 8 tuổi trong một đoạn văn. Giải thích tại sao bầu trời có màu xanh cho học sinh tiểu học. Câu hỏi 4: Giải thích hiện tượng thủy triều…”
Tại sao một siêu mô hình 175 tỷ tham số lại hành xử kỳ quặc như vậy? Câu trả lời nằm ở chính hàm mục tiêu mà nó đã tối ưu suốt hàng nghìn giờ GPU ở Chương 3: dự đoán token tiếp theo trên một trang web ngẫu nhiên. Trên Internet, một chuỗi các câu hỏi bắt đầu bằng “Giải thích…” hầu như luôn nằm trong một đề cương ôn tập hoặc danh sách bài tập về nhà — nơi dòng tiếp theo tất yếu là một câu hỏi khác!
Mô hình không hề “ngu ngốc”; nó chỉ đang hoàn thành xuất sắc nhiệm vụ tiếp nối văn bản web thay vì đóng vai một trợ lý trả lời người dùng. Khoảng cách giữa “dự đoán từ tiếp theo trên Internet” và “làm theo chỉ dẫn của con người một cách hữu ích, trung thực và an toàn” được gọi là bài toán căn chỉnh (alignment problem).
Xuyên suốt chương này, chúng ta sẽ giải quyết ba câu hỏi lớn đã định hình kỷ nguyên hậu huấn luyện từ InstructGPT đến DeepSeek-R1:
- Làm thế nào để dạy một mô hình tiền huấn luyện hiểu định dạng hội thoại và tuân thủ chỉ dẫn của người dùng chỉ với vài nghìn mẫu dữ liệu và một chiếc GPU tiêu dùng? (Mục 1 & Mục 2: SFT, Chat Templates, LoRA & QLoRA)
- Khi con người không thể tự viết ra câu trả lời hoàn hảo nhưng dễ dàng nhận biết câu trả lời nào tốt hơn, làm sao để chuyển hóa sở thích tương đối đó thành trọng số mạng nơ-ron mà không bị mô hình “lách luật”? (Mục 3 & Mục 4: RLHF, Mô hình Bradley–Terry, PPO, KL Penalty & DPO)
- Làm thế nào để vượt qua giới hạn của việc bắt chước con người, giúp mô hình tự khám phá năng lực suy luận chuỗi dài (Chain-of-Thought), tự kiểm tra và sửa lỗi qua Học tăng cường với Phần thưởng Kiểm chứng được? (Mục 5: RLVR & GRPO)
Trong thực tế công nghiệp, quy trình hậu huấn luyện hiện đại dựa trên ba nhóm dữ liệu và thư viện mã nguồn mở tiêu chuẩn trên Hugging Face Hub:
- Dữ liệu Chỉ dẫn & Hội thoại (SFT): LIMA (
GAIR/lima, 1.000 mẫu chọn lọc thủ công chất lượng cực cao), UltraChat 200k (HuggingFaceH4/ultrachat_200k) và OpenAssistant (OpenAssistant/oasst1). - Dữ liệu Sở thích Cặp (Preference Tuning — RLHF / DPO): Anthropic HH-RLHF (
Anthropic/hh-rlhf, các cặp phản hồichosenvsrejectedvề độ hữu ích và vô hại) và UltraFeedback (openbmb/UltraFeedback, 64.000 prompt được chấm điểm đa chiều). - Dữ liệu Suy luận có Kiểm chứng (Reasoning RL — GRPO): GSM8K (
openai/gsm8k, 8.500 bài toán đố tiểu học có đáp số cuối cùng) và MATH (hendrycks/competition_math, 12.500 bài toán thi học sinh giỏi). - Thư viện chuẩn: Thư viện TRL (
Transformer Reinforcement Learning—SFTTrainer,PPOTrainer,DPOTrainer,GRPOTrainer) và PEFT (Parameter-Efficient Fine-Tuning—LoraConfig). Trong các bài tập của chương này, chúng ta sẽ tự cài đặt lõi toán học của chúng bằng PyTorch thuần để hiểu rõ từng dòng đạo hàm.
1. Tinh chỉnh có giám sát (SFT) và Mẫu hội thoại (Chat Templates)
Bước đầu tiên để biến một mô hình hoàn thành văn bản (document completer) thành một trợ lý đối thoại (conversational assistant) là Tinh chỉnh có giám sát (Supervised Fine-Tuning — SFT), hay còn gọi là Tinh chỉnh theo chỉ dẫn (Instruction Tuning). Ý tưởng cốt lõi rất trực quan: ta thu thập một tập hợp các cuộc hội thoại mẫu giữa người dùng và trợ lý, rồi tiếp tục huấn luyện mô hình ngôn ngữ bằng chính hàm mất mát Cross-Entropy tự hồi quy của Chương 3.
Tuy nhiên, có hai chi tiết kỹ thuật quyết định sự thành bại của SFT mà nếu bỏ qua, mô hình sẽ tự độc thoại hoặc sinh văn bản hỗn loạn: Mẫu hội thoại (Chat Template) và Mặt nạ hàm mất mát (Loss Masking).
1.1 Khoảng cách giữa Dự đoán từ tiếp theo và Tuân thủ chỉ dẫn
Ở giai đoạn đầu (khoảng năm 2021), các nhà nghiên cứu tại Google tự hỏi: điều gì sẽ xảy ra nếu ta gom hàng chục bài toán NLP cổ điển (dịch máy, tóm tắt, phân loại cảm xúc, trả lời câu hỏi) và viết lại tất cả chúng dưới dạng các lời nhắc tự nhiên (natural language instructions)? Công trình FLAN (Finetuned Language Models Are Zero-Shot Learners) của Wei et al. (Wei & others, 2022) đã chứng minh rằng khi tinh chỉnh một mô hình 137 tỷ tham số trên hơn 60 tập dữ liệu được viết thành dạng chỉ dẫn, mô hình đột nhiên có khả năng giải quyết các tác vụ hoàn toàn mới chưa từng gặp (zero-shot) vượt trội cả GPT-3 175B!
Nhưng một trợ lý hiện đại không chỉ nhận một câu lệnh đơn lẻ; nó phải duy trì một cuộc hội thoại nhiều lượt (multi-turn conversation) gồm chỉ dẫn hệ thống (system prompt), câu hỏi của người dùng (user) và câu trả lời của trợ lý (assistant). Vì mô hình ngôn ngữ thực chất chỉ nhận đầu vào là một mảng một chiều các ID token, ta cần một quy ước nghiêm ngặt để “dẹt hóa” (serialize) danh sách các lượt lời thành một chuỗi token duy nhất, sao cho mô hình phân biệt rõ ranh giới giữa người hỏi và người trả lời.
Quy ước phổ biến nhất hiện nay là định dạng ChatML (được sử dụng trong Qwen, OpenAI, SmolLM, v.v.), dùng các token đặc biệt (special tokens) được thêm trực tiếp vào bảng từ vựng của bộ tách từ:
<|im_start|>system
Bạn là một trợ lý AI hữu ích, chính xác và an toàn.<|im_end|>
<|im_start|>user
Thủ đô của Pháp là gì?<|im_end|>
<|im_start|>assistant
Thủ đô của Pháp là Paris.<|im_end|>
Các token <|im_start|> và <|im_end|> là các token nguyên tử (atomic special tokens) — chúng không bao giờ bị tách nhỏ thành các ký tự con và không bao giờ xuất hiện trong văn bản người dùng thông thường. Khi suy luận (inference), giao diện chat nối câu hỏi của bạn vào sau <|im_start|>user\n...<|im_end|>\n<|im_start|>assistant\n, trao quyền sinh tiếp cho mô hình, và dừng lại ngay khi mô hình vừa sinh ra token <|im_end|>.
1.2 Cấu trúc Chat Template và Mặt nạ Hàm mất mát (Loss Masking)
Khi huấn luyện SFT trên chuỗi ChatML ở trên, một câu hỏi quan trọng xuất hiện: Ta có nên tính hàm mất mát Cross-Entropy trên toàn bộ các token trong chuỗi hay không?
Giả sử chuỗi hội thoại đã mã hóa là . Trong tiền huấn luyện (Chương 3), mọi token đều bình đẳng:
Nhưng trong một cuộc hội thoại SFT, các token thuộc về system và user là đầu vào cho trước (context/prompt), còn nhiệm vụ của mô hình là chỉ sinh ra các token thuộc phần trả lời của assistant. Nếu ta ép mô hình tối ưu hóa xác suất sinh ra câu hỏi của user, hai hậu quả tai hại sẽ xảy ra:
- Lãng phí dung lượng biểu diễn: Trên các tập dữ liệu có prompt dài (ví dụ yêu cầu tóm tắt một văn bản 2.000 từ chỉ bằng 1 câu 20 từ), 99% đạo hàm sẽ dồn vào việc học thuộc lòng văn bản của người dùng thay vì học cách viết câu tóm tắt.
- Nhiễm giọng người dùng: Mô hình học cách bắt chước cả sự mơ hồ, lỗi chính tả hoặc giọng điệu ra lệnh của người dùng.
Do đó, trong SFT chuẩn, ta định nghĩa một mặt nạ nhị phân (loss mask) cho từng vị trí :
- đối với mọi token thuộc lượt
system, lượtuser, và cả phần tiêu đề mở đầu<|im_start|>assistant\n. - chỉ đối với các token nội dung của
assistantvà token kết thúc<|im_end|>ngay sau đó (để mô hình học được khi nào phải dừng lời!).
Hàm mất mát SFT có mặt nạ (Masked Cross-Entropy Loss) khi đó trở thành:
Trong PyTorch, hàm F.cross_entropy(logits, labels, ignore_index=-100) mặc định bỏ qua mọi vị trí có nhãn bằng -100. Vì vậy, việc tạo mặt nạ đơn giản là gán labels[t] = -100 tại các vị trí không thuộc phần trả lời của trợ lý! Hãy khám phá cơ chế này trực tiếp qua biểu đồ tương tác dưới đây.
Hãy tự tay cài đặt quy trình đóng gói hội thoại ChatML và tính hàm mất mát SFT có mặt nạ trong Bài tập 4.1 dưới đây.
Bài tập 4.1 Đóng gói ChatML và tính hàm mất mát SFT có mặt nạ
Cho một cuộc hội thoại 5 lượt (messages) và bảng từ vựng 20 token trong mã khởi tạo:
- Duyệt qua từng lượt trong
messages, ghép phần tiêu đề["<|im_start|>", role, "\n"]và phần thâncontent + ["<|im_end|>", "\n"]để tạo danh sáchinput_ids. - Đồng thời xây dựng danh sách nhãn
labelscó cùng độ dài vớiinput_ids: đặt-100cho toàn bộ phần tiêu đề và các lượtsystem/user; chỉ giữ nguyên ID token cho phần thân (content + ["<|im_end|>", "\n"]) khirole == "assistant". - Thực hiện dịch chuyển tự hồi quy (
shift_logits = logits[:-1],shift_labels = y_masked[1:]) và tính hàm mất mát bằngF.cross_entropy(..., ignore_index=-100), so sánh với khi không dùng mặt nạ.
import torch
import torch.nn.functional as F
vocab = [
"<|im_start|>", "<|im_end|>", "\n", "system", "user",
"assistant", "Bạn", "là", "trợ", "lý.", "Thủ", "đô",
"Pháp?", "Là", "Paris.", "Dân", "số?", "Khoảng", "2,1", "triệu."
]
tok2id = {w: i for i, w in enumerate(vocab)}
messages = [
{"role": "system", "content": ["Bạn", "là", "trợ", "lý."]},
{"role": "user", "content": ["Thủ", "đô", "Pháp?"]},
{"role": "assistant", "content": ["Là", "Paris."]},
{"role": "user", "content": ["Dân", "số?"]},
{"role": "assistant", "content": ["Khoảng", "2,1", "triệu."]},
]
print(f"Từ vựng: {len(vocab)} tokens | Số lượt: {len(messages)}")
Từ vựng: 20 tokens | Số lượt: 5
Hint
Với mỗi msg trong messages, phần header luôn gồm 3 token ["<|im_start|>", msg["role"], "\n"] — mô hình không bao giờ phải tự đoán xem lượt tiếp theo là user hay assistant, nên nhãn của header luôn là [-100, -100, -100].
Hint
Trong mô hình ngôn ngữ tự hồi quy, vector logit tại vị trí (logits[t]) được dùng để dự đoán token ở vị trí (input_ids[t+1]). Do đó, trước khi gọi F.cross_entropy, bạn luôn phải cắt logits[:-1] và labels[1:].
Điểm kiểm tra kết quả bạn cần đạt được
Chuỗi sau khi đóng gói có tổng cộng 31 token, nhưng chỉ có đúng 9 token (29.0%) thuộc phần trả lời của assistant mang nhãn khác -100. Hàm mất mát có mặt nạ thấp hơn rõ rệt so với khi tính trên toàn chuỗi vì không bị phạt bởi các từ khó đoán trong câu hỏi của user.
Tổng số token trong chuỗi: 31 Số token tính đạo hàm (mask=1): 9 / 31 (29.0%) Token được học: ['Là', 'Paris.', '<|im_end|>', '\n', 'Khoảng', '2,1', 'triệu.', '<|im_end|>', '\n'] Loss không dùng mặt nạ (Unmasked): 3.0412 Loss chuẩn SFT có mặt nạ (Masked): 2.8135
Lời giải 2 ô mã
Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).
import torch
import torch.nn.functional as F
vocab = [
"<|im_start|>", "<|im_end|>", "\n", "system", "user",
"assistant", "Bạn", "là", "trợ", "lý.", "Thủ", "đô",
"Pháp?", "Là", "Paris.", "Dân", "số?", "Khoảng", "2,1", "triệu."
]
tok2id = {w: i for i, w in enumerate(vocab)}
messages = [
{"role": "system", "content": ["Bạn", "là", "trợ", "lý."]},
{"role": "user", "content": ["Thủ", "đô", "Pháp?"]},
{"role": "assistant", "content": ["Là", "Paris."]},
{"role": "user", "content": ["Dân", "số?"]},
{"role": "assistant", "content": ["Khoảng", "2,1", "triệu."]},
]
print(f"Từ vựng: {len(vocab)} tokens | Số lượt: {len(messages)}")
Từ vựng: 20 tokens | Số lượt: 5
# (1) Đóng gói hội thoại theo chuẩn ChatML và tạo mặt nạ labels
input_ids = []
labels = []
for msg in messages:
header = ["<|im_start|>", msg["role"], "\n"]
body = msg["content"] + ["<|im_end|>", "\n"]
header_ids = [tok2id[t] for t in header]
body_ids = [tok2id[t] for t in body]
input_ids.extend(header_ids + body_ids)
# Luôn che mặt nạ (-100) cho phần header (<|im_start|>role\n)
labels.extend([-100] * len(header_ids))
if msg["role"] == "assistant":
labels.extend(body_ids)
else:
labels.extend([-100] * len(body_ids))
x = torch.tensor(input_ids, dtype=torch.long)
y_masked = torch.tensor(labels, dtype=torch.long)
active_toks = [vocab[i] for i in labels if i != -100]
print(f"Tổng số token trong chuỗi: {len(x)}")
pct = 100 * len(active_toks) / len(x)
print(f"Số token tính đạo hàm (mask=1): {len(active_toks)} / {len(x)} ({pct:.1f}%)")
print(f"Token được học: {active_toks}")
# (2) Dịch chuyển tự hồi quy (causal shift) và tính Cross-Entropy
torch.manual_seed(42)
logits = torch.randn(len(x), len(vocab))
# Tăng nhẹ logit cho các token trả lời đúng của assistant
for pos in range(len(x) - 1):
if y_masked[pos + 1] != -100:
logits[pos, y_masked[pos + 1]] += 0.35
shift_logits = logits[:-1]
shift_unmasked = x[1:]
shift_masked = y_masked[1:]
loss_unmasked = F.cross_entropy(shift_logits, shift_unmasked)
loss_masked = F.cross_entropy(
shift_logits, shift_masked, ignore_index=-100
)
print(f"Loss không dùng mặt nạ (Unmasked): {loss_unmasked.item():.4f}")
print(f"Loss chuẩn SFT có mặt nạ (Masked): {loss_masked.item():.4f}")
Tổng số token trong chuỗi: 31 Số token tính đạo hàm (mask=1): 9 / 31 (29.0%) Token được học: ['Là', 'Paris.', '<|im_end|>', '\n', 'Khoảng', '2,1', 'triệu.', '<|im_end|>', '\n'] Loss không dùng mặt nạ (Unmasked): 3.0412 Loss chuẩn SFT có mặt nạ (Masked): 2.8135
Hãy nhìn vào danh sách 9 token được học: ngoài các từ nội dung ('Là', 'Paris.', 'Khoảng', '2,1', 'triệu.'), chúng ta bắt buộc phải giữ lại <|im_end|> trong phần tính hàm mất mát (mask = 1). Nếu bạn vô tình gán -100 cho <|im_end|>, mô hình sẽ không bao giờ học được xác suất dừng câu trả lời và sẽ tiếp tục sinh văn bản bất tận cho đến khi chạm giới hạn cửa sổ ngữ cảnh! Ngược lại, các token thuộc câu hỏi của người dùng vẫn nằm nguyên trong input_ids để các lớp Self-Attention của assistant đọc ngữ cảnh, nhưng bị chặn hoàn toàn ở tầng tính đạo hàm mục tiêu.
1.3 Giả thuyết Căn chỉnh Bề mặt (Superficial Alignment Hypothesis — LIMA)
Khi mới làm quen với SFT, nhiều người lầm tưởng rằng giai đoạn này dạy cho mô hình những kiến thức mới. Nhưng hãy so sánh quy mô dữ liệu: tiền huấn luyện đọc 15.000 tỷ token (), trong khi SFT thường chỉ chạy trên vài chục nghìn mẫu hội thoại (khoảng token — ít hơn một phần triệu!).
Thậm chí, vào tháng 5 năm 2023, nhóm nghiên cứu của Zhou et al. (Zhou & others, 2023) đã đặt một câu hỏi táo bạo: Cần tối thiểu bao nhiêu mẫu SFT để biến một mô hình nền tảng LLaMA 65B thành một trợ lý xuất sắc? Câu trả lời của họ đã gây chấn động cộng đồng: chỉ cần đúng 1.000 mẫu hội thoại được biên tập kỹ lưỡng!
Các tác giả của LIMA đúc kết phát hiện này thành Giả thuyết Căn chỉnh Bề mặt (Superficial Alignment Hypothesis) (Zhou & others, 2023):
“Hầu hết mọi tri thức và năng lực của một mô hình ngôn ngữ đều đã được học trong quá trình tiền huấn luyện; giai đoạn căn chỉnh (alignment) thực chất chỉ dạy cho mô hình nên chọn định dạng và phong cách giao tiếp (subdistribution of formats) nào khi tương tác với người dùng.”
Hệ quả thực tiễn của giả thuyết này cực kỳ sâu sắc: Trong SFT, chất lượng dữ liệu quan trọng gấp bội số lượng. 1.000 câu trả lời mạch lạc, chuẩn xác sẽ tạo ra một trợ lý thông minh hơn hẳn 100.000 câu trả lời cẩu thả thu thập tự động từ web.
2. Tinh chỉnh hiệu quả tham số (PEFT): LoRA và QLoRA
Ở Mục 1, chúng ta đã biết hàm mất mát được tính như thế nào. Nhưng hãy làm một phép tính thực tế về bộ nhớ phần cứng (VRAM) khi bạn muốn tinh chỉnh toàn bộ tham số (Full Fine-Tuning) của một mô hình cỡ vừa như Llama-3 8B ( tham số) ở độ chính xác bfloat16 (2 byte mỗi tham số):
- Trọng số mô hình (): .
- Đạo hàm (): .
- Trạng thái bộ tối ưu hóa AdamW (bản sao trọng số FP32 + momen bậc nhất FP32 + momen bậc hai FP32): .
Chưa tính bộ nhớ lưu các vector kích hoạt trung gian (activations), chỉ riêng việc giữ mô hình và bộ tối ưu hóa đã ngốn VRAM — vượt xa các GPU phổ thông (16 GB – 24 GB) và thậm chí tràn cả một siêu GPU A100/H100 80 GB! Hơn nữa, nếu bạn muốn phục vụ 100 khách hàng với 100 mô hình tinh chỉnh riêng biệt, bạn sẽ phải lưu trữ 100 bản sao nặng ( dung lượng ổ cứng).
Có cách nào tinh chỉnh một mô hình hàng chục tỷ tham số mà không cần cập nhật toàn bộ ma trận trọng số khổng lồ của nó hay không?
2.1 Chiều nội tại thấp và Phân rã ma trận hạng thấp (LoRA)
Như Giả thuyết Căn chỉnh Bề mặt vừa chỉ ra, quá trình tinh chỉnh không dạy lại ngôn ngữ từ đầu mà chỉ điều chỉnh nhẹ hành vi của mô hình. Nói theo ngôn ngữ đại số tuyến tính: mặc dù ma trận trọng số gốc có số chiều rất lớn (ví dụ triệu tham số), phần độ lệch cập nhật được học trong quá trình tinh chỉnh thực chất nằm trên một không gian con có chiều nội tại rất thấp (low intrinsic dimension).
Dựa trên nhận xét này, năm 2021, Edward Hu và các đồng nghiệp tại Microsoft đã giới thiệu LoRA (Low-Rank Adaptation of Large Language Models) (Hu & others, 2022) — thuật toán đã dân chủ hóa hoàn toàn việc tinh chỉnh LLM.
Ý tưởng của LoRA đẹp đến mức tối giản. Xét một lớp tuyến tính bất kỳ trong khối Transformer có ma trận trọng số tiền huấn luyện . Thay vì cập nhật trực tiếp , ta đóng băng hoàn toàn (không tính đạo hàm, không lưu trạng thái AdamW cho ), và biểu diễn ma trận cập nhật dưới dạng tích của hai ma trận thon dài có hạng :
trong đó:
- là ma trận chiếu giảm chiều từ xuống nút cổ chai ,
- là ma trận chiếu nâng chiều từ lên lại ,
- là hạng (rank) của bộ thích nghi LoRA,
- là hằng số tỷ lệ (scaling hyperparameter, thường chọn hoặc ).
Khi một vector đầu vào đi qua lớp LoRA, phép tính truyền xuôi (forward pass) diễn ra trên hai nhánh song song rồi cộng lại:
Hãy kiểm tra số lượng tham số cần huấn luyện! Nếu và ta chọn :
- Ma trận gốc có tham số.
- Hai ma trận và chỉ có tổng cộng tham số — tức là chỉ bằng so với ma trận gốc!
Hãy kéo các thanh trượt trong biểu đồ tương tác dưới đây để quan sát cấu trúc cổ chai của và mức tiết kiệm VRAM thực tế.
2.2 Tại sao khởi tạo ngẫu nhiên và bằng ? Quy tắc chọn hạng và hệ số
Nhìn vào sơ đồ trên, có ba câu hỏi thiết kế tinh tế mà mọi kỹ sư LLM đều phải nắm vững:
-
Tại sao khởi tạo nhưng bắt buộc khởi tạo ? Ở bước đầu tiên của quá trình tinh chỉnh (), ta muốn mô hình LoRA hành xử giống hệt 100% mô hình tiền huấn luyện gốc để không làm xáo trộn các biểu diễn đã học. Nhờ , tích tại . Nhưng tại sao không khởi tạo cả và ? Nếu cả lẫn , thì đạo hàm theo (vốn tỷ lệ với ) và đạo hàm theo (tỷ lệ với ) đều bằng ! Đó là một điểm yên ngựa chết (dead saddle point) khiến cả và mãi mãi bằng . Khởi tạo ngẫu nhiên và giúp tại , nhưng ngay ở bước lan truyền ngược đầu tiên, giúp thoát khỏi điểm và kéo theo cùng học ở các bước tiếp theo.
-
Tại sao cần hệ số chia ? Khi khởi tạo và cập nhật , độ lớn của tổng nội bộ tỷ lệ thuận với số chiều cổ chai . Việc nhân với giúp chuẩn hóa biên độ đầu ra của nhánh LoRA: khi bạn thử nghiệm tăng hạng từ lên , bạn không cần phải dò lại tốc độ học (learning rate) từ đầu (Hu & others, 2022).
-
Không tốn thêm bất kỳ mili-giây nào khi suy luận (Zero Inference Overhead)! Sau khi huấn luyện xong và , khi đưa mô hình vào triển khai thực tế, ta không cần giữ hai nhánh riêng biệt nữa. Vì phép nhân ma trận có tính phân phối, ta chỉ việc cộng gộp trực tiếp một lần duy nhất: Ma trận có kích thước y hệt , nên tốc độ suy luận giống hệt mô hình gốc! Còn nếu muốn phục vụ 100 tác vụ khác nhau trên cùng một GPU, ta giữ nguyên trên VRAM và chỉ tráo đổi các cặp nặng vài chục MB theo từng yêu cầu.
Hãy kiểm chứng cả ba tính chất này bằng cách tự cài đặt lớp LoRALinear trong Bài tập 4.2.
Bài tập 4.2 Cài đặt lớp LoRALinear và gộp trọng số sau tinh chỉnh
Từ lớp tuyến tính base_linear () trong mã khởi tạo:
- Viết lớp
LoRALinear(nn.Module)đóng băngbase.weight(requires_grad_(False)), khởi tạo tham sốlora_Akích thước từ phân phối chuẩn vàlora_Bkích thước bằng toàn số . - Cài đặt hàm
forward(x)tính , kiểm tra sai lệch cực đại so vớibase_linear(X)tại và đếm tỷ lệ tham số cần huấn luyện. - Huấn luyện
lora_Avàlora_Btrong 150 bước bằngAdamW(lr=0.05)để khớp đầu ra mục tiêuY_target, sau đó cài đặt hàmmerged_weight()tính và kiểm chứng đầu ra sau khi gộp trùng khớp với nhánh song song.
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
d_in, d_out, r, alpha = 512, 512, 4, 8.0
base_linear = nn.Linear(d_in, d_out, bias=False)
# Tạo dữ liệu mục tiêu có độ lệch hạng thấp thực sự từ base_linear
true_B = torch.randn(d_out, r) * 0.15
true_A = torch.randn(r, d_in) * 0.15
X = torch.randn(128, d_in)
with torch.no_grad():
Y_target = F.linear(X, base_linear.weight + true_B @ true_A)
print(f"Ma trận gốc W0: {base_linear.weight.numel():,} tham số")
Ma trận gốc W0: 262,144 tham số
Hint
Trong PyTorch, lớp nn.Linear(d_in, d_out) lưu ma trận trọng số ở dạng chuyển vị (d_out, d_in) và thực hiện phép tính x @ weight.T. Do đó, nếu lora_A có shape (r, d_in) và lora_B có shape (d_out, r), thì với batch đầu vào x có shape (batch, d_in), nhánh LoRA được tính cực nhanh qua (x @ self.lora_A.T) @ self.lora_B.T.
Hint
Đừng bao giờ tính tích (self.lora_B @ self.lora_A) trước rồi mới nhân với x bên trong hàm forward lúc huấn luyện! Phép tính (x @ A.T) @ B.T chỉ tốn phép nhân, trong khi tạo ma trận đầy đủ B @ A tốn . Chỉ khi huấn luyện xong và gọi merged_weight(), ta mới nhân self.lora_B @ self.lora_A một lần duy nhất.
Điểm kiểm tra kết quả bạn cần đạt được
Tại , sai lệch giữa LoRALinear và base_linear bằng đúng 0.00e+00. Chỉ với 4.096 tham số được huấn luyện (1.56% so với 262.144 tham số của ), hàm mất mát MSE giảm từ 0.451820 xuống 0.000014 sau 150 bước. Sau khi gộp vào một ma trận duy nhất, sai số số học dấu phẩy động chỉ là 1.19e-07.
Sai lệch lúc khởi tạo (t=0): 0.00e+00 Tham số cần huấn luyện: 4,096 / 262,144 (1.56%) MSE ban đầu (t=0): 0.451820 MSE sau 150 bước LoRA: 0.000014 Sai lệch sau khi gộp W_merged = W0 + (alpha/r)*B@A: 1.19e-07
Lời giải 2 ô mã
Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
d_in, d_out, r, alpha = 512, 512, 4, 8.0
base_linear = nn.Linear(d_in, d_out, bias=False)
# Tạo dữ liệu mục tiêu có độ lệch hạng thấp thực sự từ base_linear
true_B = torch.randn(d_out, r) * 0.15
true_A = torch.randn(r, d_in) * 0.15
X = torch.randn(128, d_in)
with torch.no_grad():
Y_target = F.linear(X, base_linear.weight + true_B @ true_A)
print(f"Ma trận gốc W0: {base_linear.weight.numel():,} tham số")
Ma trận gốc W0: 262,144 tham số
# (1) Định nghĩa lớp LoRALinear với W0 đóng băng, A ~ N(0,0.02^2), B = 0
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, r: int, alpha: float):
super().__init__()
self.base = base
self.base.weight.requires_grad_(False)
self.scaling = alpha / r
d_out, d_in = base.weight.shape
self.lora_A = nn.Parameter(torch.randn(r, d_in) * 0.02)
self.lora_B = nn.Parameter(torch.zeros(d_out, r))
def forward(self, x: torch.Tensor) -> torch.Tensor:
delta = (x @ self.lora_A.T) @ self.lora_B.T
return self.base(x) + self.scaling * delta
def merged_weight(self) -> torch.Tensor:
return self.base.weight + self.scaling * (self.lora_B @ self.lora_A)
lora_layer = LoRALinear(base_linear, r=r, alpha=alpha)
# (2) Kiểm tra tính chất bảo toàn tại t=0 và đếm số tham số
with torch.no_grad():
init_diff = (lora_layer(X) - base_linear(X)).abs().max().item()
init_mse = F.mse_loss(lora_layer(X), Y_target).item()
trainable = sum(p.numel() for p in lora_layer.parameters() if p.requires_grad)
total_w0 = base_linear.weight.numel()
print(f"Sai lệch lúc khởi tạo (t=0): {init_diff:.2e}")
print(f"Tham số cần huấn luyện: {trainable:,} / {total_w0:,} ({100*trainable/total_w0:.2f}%)")
print(f"MSE ban đầu (t=0): {init_mse:.6f}")
# (3) Tinh chỉnh chỉ trên A và B bằng AdamW rồi gộp trọng số
opt = torch.optim.AdamW([lora_layer.lora_A, lora_layer.lora_B], lr=0.05)
for _ in range(150):
opt.zero_grad()
loss = F.mse_loss(lora_layer(X), Y_target)
loss.backward()
opt.step()
with torch.no_grad():
out_lora = lora_layer(X)
out_merged = F.linear(X, lora_layer.merged_weight())
merge_diff = (out_lora - out_merged).abs().max().item()
print(f"MSE sau 150 bước LoRA: {loss.item():.6f}")
print(f"Sai lệch sau khi gộp W_merged = W0 + (alpha/r)*B@A: {merge_diff:.2e}")
Sai lệch lúc khởi tạo (t=0): 0.00e+00 Tham số cần huấn luyện: 4,096 / 262,144 (1.56%) MSE ban đầu (t=0): 0.451820 MSE sau 150 bước LoRA: 0.000014 Sai lệch sau khi gộp W_merged = W0 + (alpha/r)*B@A: 1.19e-07
Hãy chú ý hai chi tiết kỹ thuật trong lời giải:
- Thứ tự kết hợp phép nhân ma trận
(x @ A.T) @ B.T: bằng cách ép chiều từ xuống cổ chai trước rồi mới nâng lên , số phép nhân-cộng (FLOPs) của nhánh phụ giảm đi lần so với một lớp tuyến tính thông thường. - Trong các kiến trúc Transformer hiện đại (như Llama-3, Qwen), các nghiên cứu thực nghiệm cho thấy áp dụng LoRA lên tất cả các ma trận tuyến tính (cả trong Attention lẫn trong SwiGLU FFN) mang lại chất lượng tốt hơn rõ rệt so với việc chỉ gắn LoRA vào mà hầu như không tốn thêm bao nhiêu VRAM (Dettmers & others, 2023).
2.3 QLoRA: Tinh chỉnh mô hình 65 tỷ tham số trên một GPU duy nhất
Với LoRA, ta đã loại bỏ gần như hoàn toàn bộ nhớ cho đạo hàm và trạng thái AdamW. Tuy nhiên, ma trận gốc đóng băng vẫn phải nằm trên VRAM để phục vụ các phép nhân truyền xuôi và truyền ngược. Với mô hình 65 tỷ tham số ở dạng 16-bit, riêng đã chiếm VRAM!
Tháng 5 năm 2023, Tim Dettmers và các cộng sự tại Đại học Washington đã giải quyết triệt để nút cổ chai cuối cùng này bằng QLoRA (Quantized LoRA) (Dettmers & others, 2023):
- Toàn bộ trọng số gốc đóng băng được nén (lượng tử hóa) xuống định dạng 4-bit NormalFloat (NF4), chỉ tốn mỗi tham số (giảm 4 lần so với
bfloat16). - Trong quá trình huấn luyện, khi dữ liệu đi qua một lớp, trọng số 4-bit của lớp đó được giải nén tạm thời (dequantize on-the-fly) sang
bfloat16để nhân với , trong khi các ma trận LoRA vẫn được lưu trữ và cập nhật đạo hàm ở độ chính xác caobfloat16.
Nhờ QLoRA, một mô hình 8 tỷ tham số chỉ cần dưới 6 GB VRAM để tinh chỉnh (chạy mượt mà trên một chiếc laptop hay Google Colab miễn phí), và một mô hình khổng lồ 65B–70B có thể được tinh chỉnh trọn vẹn trên một GPU 48 GB duy nhất mà không suy giảm chất lượng so với tinh chỉnh 16-bit (Dettmers & others, 2023)!
Đọc thêm NormalFloat 4-bit (NF4) và Lượng tử hóa Kép (Double Quantization)
Tại sao QLoRA không dùng lượng tử hóa số nguyên 4-bit đều (INT4 thông thường) mà lại phát minh ra kiểu dữ liệu NF4 (4-bit NormalFloat)?
Trong mạng nơ-ron đã tiền huấn luyện, các trọng số trong mỗi ma trận không phân bố đều mà tuân theo phân phối chuẩn (Gaussian) tập trung dày đặc quanh giá trị và thưa dần ở hai đuôi. Nếu chia đều 16 mức lượng tử hóa () trên đoạn , phần lớn các mức ở hai rìa sẽ bị bỏ phí trong khi vùng trung tâm quanh lại bị làm tròn quá thô.
NF4 chia miền giá trị thành 16 phần có xác suất tích lũy bằng nhau (equal-mass quantiles) dưới phân phối chuẩn tắc , sau đó chuẩn hóa về . Về mặt lý thuyết thông tin, NF4 là kiểu dữ liệu 4-bit tối ưu cho các đại lượng phân bố chuẩn. Kết hợp với Lượng tử hóa Kép (Double Quantization) — tiếp tục nén chính các hằng số tỷ lệ lượng tử hóa (quantization scales) từ 32-bit xuống 8-bit — QLoRA giảm dung lượng lưu trữ trung bình từ 16 bit xuống chỉ còn 4.27 bit/tham số (Dettmers & others, 2023).
3. Học tăng cường từ Phản hồi Con người (RLHF): Mô hình Phần thưởng và PPO
Sau giai đoạn SFT (với LoRA/QLoRA), mô hình của chúng ta đã biết trả lời người dùng đúng định dạng trợ lý. Nhưng khi đưa vào sử dụng thực tế, các hạn chế bản chất của việc “học bắt chước” (imitation learning) trong SFT lập tức lộ diện.
3.1 Tại sao SFT là chưa đủ? So sánh dễ hơn Viết mẫu
Hãy nhìn vào cấu trúc toán học của hàm mất mát SFT: nó đối xử với mọi lỗi sai ở mức token theo cùng một cách. Một câu trả lời làm toán sai ở con số cuối cùng chỉ bị phạt đúng 1 token, trong khi một câu trả lời hoàn toàn chính xác nhưng dùng từ đồng nghĩa khác với người viết mẫu lại bị phạt nặng trên hàng chục token!
Hơn nữa, việc yêu cầu người gán nhãn tự tay viết ra câu trả lời hoàn hảo cho hàng vạn câu hỏi phức tạp (từ lập trình, y học, pháp lý đến sáng tác văn chương) là cực kỳ tốn kém và không mở rộng được. Ngược lại, trong tâm lý học nhận thức có một nguyên lý bất biến: Đánh giá và so sánh hai phương án luôn dễ hơn rất nhiều so với việc tự sáng tạo ra phương án tối ưu từ con số không (recognition is easier than generation).
Năm 2017, Paul Christiano và các cộng sự tại OpenAI & DeepMind đã chứng minh rằng ta có thể huấn luyện các tác tử học tăng cường giải quyết các bài toán phức tạp chỉ từ vài nghìn lượt con người chọn “Video A tốt hơn Video B” (Christiano & others, 2017). Đến tháng 3 năm 2022, nhóm của Long Ouyang tại OpenAI đã áp dụng trọn vẹn quy trình 3 bước này lên GPT-3 để tạo ra InstructGPT (Ouyang et al., 2022) — tiền thân trực tiếp khai sinh ra ChatGPT.
Quy trình Học tăng cường từ Phản hồi Con người (Reinforcement Learning from Human Feedback — RLHF) của InstructGPT (Ouyang et al., 2022) biến bài toán căn chỉnh thành hai bước nối tiếp nhau sau SFT:
- Bước 2 — Huấn luyện Mô hình Phần thưởng (Reward Modeling): Dạy một mạng nơ-ron chấm điểm độ “hữu ích, trung thực và vô hại” của câu trả lời cho câu hỏi , dựa trên dữ liệu so sánh cặp của con người.
- Bước 3 — Tối ưu hóa Chính sách bằng Học tăng cường (RL Policy Optimization): Dùng mô hình phần thưởng làm giám khảo tự động để tinh chỉnh mô hình ngôn ngữ sinh ra các câu trả lời đạt điểm cao.
3.2 Mô hình Sở thích Bradley–Terry và Hàm mất mát Xếp hạng Cặp
Làm thế nào để huấn luyện một mô hình phần thưởng xuất ra một con số thực duy nhất khi người gán nhãn không hề cho điểm số tuyệt đối (vì mỗi người có một thang điểm chủ quan khác nhau), mà chỉ cho biết trong hai câu trả lời cho cùng một prompt , câu trả lời (winner / chosen) tốt hơn câu trả lời (loser / rejected)?
Chúng ta sử dụng mô hình xác suất sở thích Bradley–Terry (1952): xác suất để con người ưa thích hơn được mô hình hóa bằng hàm logistic () của hiệu số điểm thưởng giữa hai câu trả lời:
Về mặt kiến trúc, Mô hình Phần thưởng được khởi tạo từ chính một mô hình SFT, trong đó ta bỏ lớp chiếu từ vựng (lm_head kích thước ) ở cuối và thay bằng một đầu tuyến tính vô hướng (score_head kích thước ). Khi đọc chuỗi ghép , vector trạng thái ẩn của token cuối cùng được chiếu qua score_head để cho ra điểm thưởng .
Với tập dữ liệu sở thích , ta huấn luyện bằng cách cực đại hóa log-likelihood của các lựa chọn đúng (tức cực tiểu hóa hàm mất mát phân loại nhị phân trên hiệu điểm thưởng) (Christiano & others, 2017; Ouyang et al., 2022):
Hãy chú ý tính chất bất biến dịch chuyển của phương trình này: nếu bạn cộng thêm một hằng số vào cả và , hiệu số không hề thay đổi! Nhờ đó, mô hình phần thưởng chỉ cần tập trung học thứ tự xếp hạng tương đối giữa các câu trả lời cho cùng một câu hỏi . (Khi quy mô dữ liệu lớn, người ta cũng có thể dùng một LLM mạnh chấm điểm thay con người dựa trên một bản hiến pháp nguyên tắc — kỹ thuật RLAIF / Constitutional AI của Bai et al. (Bai & others, 2022)).
3.3 Tối ưu hóa Chính sách với Ràng buộc KL (PPO) và Hiện tượng Hack phần thưởng
Sau khi đã có mô hình phần thưởng cố định, ta bước sang giai đoạn Học tăng cường: coi mô hình ngôn ngữ đang cần căn chỉnh là một chính sách (policy) nhận “trạng thái” là prompt và thực hiện “hành động” là sinh ra chuỗi phản hồi .
Tuy nhiên, tại sao ta không thể tính đạo hàm trực tiếp để cập nhật như một mạng nơ-ron bình thường? Bởi vì để đưa văn bản vào mô hình phần thưởng, ta phải lấy mẫu các token rời rạc , và phép lấy mẫu rời rạc là không khả vi (non-differentiable)! Đây chính là lý do ta phải dùng các thuật toán đạo hàm chính sách (Policy Gradient) trong Học tăng cường như PPO (Proximal Policy Optimization) của Schulman et al. (Schulman & others, 2017).
Nhưng một thảm họa còn lớn hơn đang chờ đợi nếu bạn chỉ đơn thuần cực đại hóa . Hãy nhớ rằng chỉ là một mạng nơ-ron xấp xỉ không hoàn hảo được huấn luyện trên vài chục nghìn mẫu hữu hạn. Nếu chính sách được tự do thay đổi không giới hạn để săn điểm cao từ , nó sẽ nhanh chóng tìm ra các “vùng mù” (out-of-distribution adversarial examples) của mô hình phần thưởng:
- Ví dụ, nếu người gán nhãn thường thích các câu trả lời lịch sự và có gạch đầu dòng, mô hình sẽ học cách lặp đi lặp lại hàng trăm từ “Tuyệt vời! Xuất sắc! Hoàn hảo! 1) … 2) …” hoàn toàn vô nghĩa nhưng lại khiến chấm điểm !
- Hiện tượng này được gọi là Hack phần thưởng (Reward Hacking) hay Tối ưu hóa quá mức (Over-optimization) — một biểu hiện kinh điển của Định luật Goodhart: “Khi một thước đo trở thành mục tiêu tối ưu, nó không còn là một thước đo tốt nữa.”
Để ngăn chặn rời quá xa vùng ngôn ngữ tự nhiên đã học, bài toán RLHF chuẩn (Ouyang et al., 2022) luôn giữ lại một bản sao đóng băng của mô hình SFT ban đầu làm chính sách tham chiếu (reference policy) , và trừ đi một khoản phạt Phân kỳ Kullback–Leibler (KL Divergence) với hệ số :
hay viết gộp dưới dạng phần thưởng hiệu chỉnh trên mỗi mẫu :
Mỗi khi mô hình đẩy xác suất của một chuỗi kỳ dị lên cao gấp nhiều lần xác suất mà mô hình SFT gốc gán cho nó, tỷ số sẽ tăng vọt và kéo điểm thưởng tổng xuống. Hãy tự mình điều chỉnh hệ số trong biểu đồ tương tác dưới đây để chứng kiến tận mắt ranh giới giữa vùng cân bằng tối ưu và vực thẳm Reward Hacking.
4. Tối ưu hóa Sở thích Trực tiếp (Direct Preference Optimization — DPO)
Mặc dù RLHF với PPO (Ouyang et al., 2022; Schulman & others, 2017) cực kỳ thành công, việc triển khai nó trong thực tế lại là một cơn ác mộng kỹ thuật:
- Bạn phải huấn luyện một Mô hình Phần thưởng riêng biệt, rồi trong vòng lặp PPO phải nạp cùng lúc 4 mô hình khổng lồ lên VRAM: Chính sách (đang học), Tham chiếu (đóng băng), Phần thưởng (đóng băng), và Mạng giá trị Critic (đang học để ước lượng baseline từng token).
- Trong mỗi bước PPO, bạn phải chạy giải mã tự hồi quy (autoregressive sampling) để sinh văn bản mới, rất chậm và cực kỳ nhạy cảm với siêu tham số.
Tháng 5 năm 2023, Rafael Rafailov và các đồng nghiệp tại Đại học Stanford đã đặt ra một câu hỏi làm thay đổi hoàn toàn bức tranh hậu huấn luyện: Liệu ta có thể giải trực tiếp bài toán tối ưu hóa RLHF bằng biến đổi đại số và loại bỏ hoàn toàn cả Mô hình Phần thưởng lẫn vòng lặp RL hay không? Câu trả lời là DPO (Direct Preference Optimization) (Rafailov & others, 2023).
4.1 Nghiệm giải tích của bài toán RLHF có ràng buộc KL
Hãy nhìn lại hàm mục tiêu RLHF có phạt phân kỳ KL ở phương trình của Mục 3.3 với một hàm phần thưởng bất kỳ:
Bằng cách rút ra ngoài và biến đổi biểu thức bên trong thành một phân kỳ KL, bài toán cực đại hóa trên thực chất có nghiệm tối ưu dạng đóng (closed-form optimal policy) thuộc phân phối Gibbs–Boltzmann (Rafailov & others, 2023):
trong đó là hàm phân hoạch (partition function) chỉ phụ thuộc vào prompt (nhằm đảm bảo tổng xác suất của mọi câu trả lời khả dĩ bằng ). Trong thực tế, ta không bao giờ tính nổi vì không gian mọi chuỗi văn bản là vô hạn.
4.2 Biến đổi đại số DPO: Mô hình ngôn ngữ vốn dĩ là một mô hình phần thưởng
Bước ngoặt thiên tài của Rafailov et al. (Rafailov & others, 2023) nằm ở chỗ: thay vì tính từ , hãy lấy logarit hai vế của phương trình trên và rút ngược hàm phần thưởng theo chính sách tối ưu !
Lấy hai vế và nhân với , ta thu được ngay lập tức mối liên hệ 1-1:
Bây giờ, hãy đem biểu thức này thế trực tiếp vào mô hình sở thích cặp Bradley–Terry của Mục 3.2:
Vì cả và đều trả lời cho cùng một prompt , khi lấy hiệu , đại lượng bất khả tính ở hai số hạng triệt tiêu nhau hoàn toàn! Thay chính sách vào, ta định nghĩa phần thưởng ẩn (implicit reward) do chính mô hình ngôn ngữ tạo ra:
và thu được Hàm mất mát DPO (Direct Preference Optimization Loss) (Rafailov & others, 2023):
Hãy dừng lại một nhịp để chiêm ngưỡng vẻ đẹp của công thức :
- Không còn mô hình phần thưởng riêng biệt!
- Không còn mạng giá trị Critic !
- Không cần lấy mẫu tự hồi quy hay tinh chỉnh siêu tham số RL đỏng đảnh!
- Toàn bộ bài toán căn chỉnh sở thích chỉ còn là một phép truyền xuôi tính tổng log-xác suất trên hai chuỗi có sẵn trong tập dữ liệu, giống hệt như huấn luyện phân loại nhị phân!
4.3 Phân tích đạo hàm của DPO và Cài đặt thực hành
Tại sao cập nhật bằng lại hiệu quả hơn nhiều so với việc đơn thuần tăng xác suất của và giảm xác suất của một cách mù quáng? Hãy lấy đạo hàm của theo tham số (Rafailov & others, 2023):
Biểu thức đạo hàm này giải thích toàn bộ động lực học của DPO:
- Thành phần đẩy xác suất sinh câu trả lời tốt lên và kéo xác suất sinh câu trả lời tệ xuống.
- Hệ số đứng trước đóng vai trò bộ điều tiết tự động: nếu một cặp đã được mô hình phân biệt đúng với biên độ lớn (), hệ số sẽ tiến về để ngừng ép mô hình thay đổi quá đà. Ngược lại, nếu mô hình đang xếp hạng sai (), hệ số này tiến gần để tập trung sửa chữa sai lầm đó!
Nếu bỏ đi hệ số trọng số thích nghi (tức chỉ tối ưu hiệu , còn gọi là Unlikelihood), mô hình sẽ nhanh chóng suy thoái ngôn ngữ vì tiếp tục đẩy xác suất xuống ngay cả khi đã rất thấp.
Hãy tự tay cài đặt hàm mất mát DPO và kiểm chứng tính chất đạo hàm biên này trong Bài tập 4.4.
Bài tập 4.4 Cài đặt hàm mất mát DPO và phân tích đạo hàm biên
Trong mã khởi tạo, ta có một batch cặp phản hồi được chọn y_w và bị loại y_l (mỗi chuỗi dài token), cùng các tensor logit của mô hình tham chiếu ref_logits và mô hình chính sách pi_logits (khởi tạo bằng đúng ref_logits tại ):
- Viết hàm
sequence_logprob(logits, tokens)tính bằngF.log_softmaxvà.gather(). - Viết hàm
dpo_loss(...)tính phần thưởng ẩn và tương ứng, biên độ , hàm mất mát (dùngF.logsigmoid), và trọng số đạo hàm biên . - Kiểm tra giá trị hàm mất mát tại bước khởi tạo (phải bằng đúng ), sau đó chạy 10 bước cập nhật SGD để quan sát biên độ phần thưởng ẩn tăng dương và trọng số đạo hàm biên giảm dần.
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(7)
# B=4 cặp sở thích (prompt x, câu trả lời được chọn y_w, câu bị loại y_l)
# Mỗi chuỗi có độ dài T=6 token từ bộ từ vựng V=32
B, T, V = 4, 6, 32
beta = 0.1
ref_logits_w = torch.randn(B, T, V)
ref_logits_l = torch.randn(B, T, V)
# Khởi tạo chính sách pi_theta trùng khít với pi_ref lúc bắt đầu
pi_logits_w = ref_logits_w.clone().detach().requires_grad_(True)
pi_logits_l = ref_logits_l.clone().detach().requires_grad_(True)
y_w = torch.randint(0, V, (B, T))
y_l = torch.randint(0, V, (B, T))
print(f"Batch: {B} cặp (y_w, y_l) | Độ dài T={T} | Hệ số beta={beta}")
Batch: 4 cặp (y_w, y_l) | Độ dài T=6 | Hệ số beta=0.1
Hint
Khi logits có shape (B, T, V) và tokens có shape (B, T), sau khi tính log_probs = F.log_softmax(logits, dim=-1), bạn dùng log_probs.gather(dim=-1, index=tokens.unsqueeze(-1)).squeeze(-1) để lấy log-xác suất của đúng token mục tiêu tại mỗi vị trí , rồi gọi .sum(dim=-1) để cộng dọc theo chiều chuỗi .
Hint
Để đảm bảo độ ổn định số học tuyệt đối, luôn sử dụng -F.logsigmoid(margins).mean() thay vì -torch.log(torch.sigmoid(margins)).mean(). Đừng quên bọc phần tính log-prob của mô hình tham chiếu ref_w, ref_l trong with torch.no_grad():.
Điểm kiểm tra kết quả bạn cần đạt được
Tại , vì nên , dẫn đến . Sau 10 bước cập nhật, hàm mất mát giảm xuống 0.214836, biên độ phần thưởng ẩn trung bình tăng lên +1.4125, độ chính xác sở thích đạt 100.0% và trọng số đạo hàm biên tự động giảm từ 0.5000 xuống 0.1958.
Loss DPO lúc khởi tạo (pi_theta = pi_ref): 0.693147 (ln 2 = 0.693147) Biên phần thưởng ẩn ban đầu: 0.0000 | Độ chính xác: 0.0% Sau 10 bước cập nhật DPO: Loss DPO mới: 0.214836 Biên phần thưởng ẩn E[r(y_w) - r(y_l)]: +1.4125 Trọng số đạo hàm biên trung bình sigma(r_l - r_w): 0.1958 Độ chính xác sở thích (r_w > r_l): 100.0%
Lời giải 2 ô mã
Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(7)
# B=4 cặp sở thích (prompt x, câu trả lời được chọn y_w, câu bị loại y_l)
# Mỗi chuỗi có độ dài T=6 token từ bộ từ vựng V=32
B, T, V = 4, 6, 32
beta = 0.1
ref_logits_w = torch.randn(B, T, V)
ref_logits_l = torch.randn(B, T, V)
# Khởi tạo chính sách pi_theta trùng khít với pi_ref lúc bắt đầu
pi_logits_w = ref_logits_w.clone().detach().requires_grad_(True)
pi_logits_l = ref_logits_l.clone().detach().requires_grad_(True)
y_w = torch.randint(0, V, (B, T))
y_l = torch.randint(0, V, (B, T))
print(f"Batch: {B} cặp (y_w, y_l) | Độ dài T={T} | Hệ số beta={beta}")
Batch: 4 cặp (y_w, y_l) | Độ dài T=6 | Hệ số beta=0.1
# (1) Hàm tính tổng log-xác suất của chuỗi token y dưới phân phối logits
def sequence_logprob(logits: torch.Tensor, tokens: torch.Tensor) -> torch.Tensor:
log_probs = F.log_softmax(logits, dim=-1)
token_lp = log_probs.gather(dim=-1, index=tokens.unsqueeze(-1)).squeeze(-1)
return token_lp.sum(dim=-1)
# (2) Hàm tính DPO Loss, phần thưởng ẩn r_hat và trọng số đạo hàm biên
def dpo_loss(pi_w, pi_l, ref_w, ref_l, y_w, y_l, beta=0.1):
lp_pi_w = sequence_logprob(pi_w, y_w)
lp_pi_l = sequence_logprob(pi_l, y_l)
with torch.no_grad():
lp_ref_w = sequence_logprob(ref_w, y_w)
lp_ref_l = sequence_logprob(ref_l, y_l)
r_w = beta * (lp_pi_w - lp_ref_w)
r_l = beta * (lp_pi_l - lp_ref_l)
margins = r_w - r_l
loss = -F.logsigmoid(margins).mean()
grad_weight = torch.sigmoid(-margins).mean()
acc = (margins > 0).float().mean()
return loss, margins.mean(), grad_weight, acc
init_loss, init_margin, _, init_acc = dpo_loss(
pi_logits_w, pi_logits_l, ref_logits_w, ref_logits_l, y_w, y_l, beta
)
print(f"Loss DPO lúc khởi tạo (pi_theta = pi_ref): {init_loss.item():.6f} (ln 2 = 0.693147)")
print(f"Biên phần thưởng ẩn ban đầu: {init_margin.item():.4f} | Độ chính xác: {100*init_acc.item():.1f}%")
# (3) Tối ưu hóa trực tiếp logits của pi_theta bằng DPO
opt = torch.optim.SGD([pi_logits_w, pi_logits_l], lr=5.0)
for _ in range(10):
opt.zero_grad()
loss, margin, gw, acc = dpo_loss(
pi_logits_w, pi_logits_l, ref_logits_w, ref_logits_l, y_w, y_l, beta
)
loss.backward()
opt.step()
loss, margin, gw, acc = dpo_loss(
pi_logits_w, pi_logits_l, ref_logits_w, ref_logits_l, y_w, y_l, beta
)
print("Sau 10 bước cập nhật DPO:")
print(f"Loss DPO mới: {loss.item():.6f}")
print(f"Biên phần thưởng ẩn E[r(y_w) - r(y_l)]: +{margin.item():.4f}")
print(f"Trọng số đạo hàm biên trung bình sigma(r_l - r_w): {gw.item():.4f}")
print(f"Độ chính xác sở thích (r_w > r_l): {100*acc.item():.1f}%")
Loss DPO lúc khởi tạo (pi_theta = pi_ref): 0.693147 (ln 2 = 0.693147) Biên phần thưởng ẩn ban đầu: 0.0000 | Độ chính xác: 0.0% Sau 10 bước cập nhật DPO: Loss DPO mới: 0.214836 Biên phần thưởng ẩn E[r(y_w) - r(y_l)]: +1.4125 Trọng số đạo hàm biên trung bình sigma(r_l - r_w): 0.1958 Độ chính xác sở thích (r_w > r_l): 100.0%
Giá trị tại bước khởi tạo là bài kiểm tra bắt buộc (sanity check) khi bạn cài đặt hoặc chạy DPO trong thực tế! Nếu ở bước đầu tiên của quá trình huấn luyện DPO (khi chưa cập nhật trọng số nào nên ), log huấn luyện của bạn không bắt đầu ở con số 0.6931, chắc chắn đã có lỗi trong cách căn chỉnh mặt nạ token hoặc truyền nhầm mô hình tham chiếu.
Đọc thêm So sánh trực diện PPO và DPO: Khi nào DPO gặp giới hạn?
Nếu DPO vừa đơn giản, vừa tiết kiệm bộ nhớ lại tương đương toán học với RLHF, tại sao các phòng thí nghiệm hàng đầu vẫn kết hợp cả RL trực tuyến (Online RL)?
Sự khác biệt nằm ở phân phối dữ liệu (Off-policy vs On-policy):
- DPO thuần túy là thuật toán Ngoại tuyến (Offline / Off-policy): Cặp dữ liệu được thu thập cố định từ trước. Khi thay đổi trong quá trình huấn luyện, nó không tự sinh ra câu trả lời mới để nhận phản hồi trên chính những lỗi mới phát sinh của nó. Ngoài ra, trên một số bài toán suy luận dài, DPO đôi khi gặp hiện tượng giảm đồng thời xác suất của cả lẫn (miễn là giảm nhanh hơn ).
- Online RL (PPO / GRPO) là thuật toán Trực tuyến (On-policy): Mô hình tự sinh ra các phản hồi mới ở mỗi bước và được chấm điểm ngay lập tức. Nhờ đó, trên các bài toán đòi hỏi chuỗi suy luận hàng nghìn bước như Toán học và Lập trình, Học tăng cường trực tuyến phát huy sức mạnh vượt trội — đưa chúng ta tới bước đột phá của Mục 5.
5. Học tăng cường với Phần thưởng Kiểm chứng được (RLVR & GRPO — DeepSeek-R1)
Cho đến năm 2024, cả SFT, RLHF lẫn DPO đều xoay quanh một triết lý chung: dạy mô hình bắt chước hoặc chiều lòng đánh giá của con người. Nhưng đối với các bài toán đòi hỏi tư duy logic sâu sắc như chứng minh toán học, giải thuật lập trình hay suy luận khoa học, cách tiếp cận này vấp phải hai rào cản lớn:
- Con người rất dễ bị đánh lừa bởi một lời giải viết trình bày đẹp mắt nhưng sai ở một bước biến đổi đại số nhỏ ở dòng thứ 20 (hiện tượng sycophancy và lỗi của Mô hình Phần thưởng nơ-ron ).
- Nếu ép mô hình bắt chước từng bước suy nghĩ do con người viết mẫu, mô hình sẽ không bao giờ khám phá ra được các chiến lược suy luận mới hiệu quả hơn cho chính kiến trúc của nó.
5.1 Từ phần thưởng cảm tính đến phần thưởng kiểm chứng được (RLVR)
Điều gì xảy ra nếu bài toán có đáp án khách quan, kiểm chứng được 100% bằng quy tắc hoặc trình biên dịch (Verifiable Rewards)?
- Với một bài toán đại số trong tập GSM8K hay MATH, dù mô hình nháp dài 10 dòng hay 500 dòng, ta chỉ cần trích xuất đáp số cuối cùng trong thẻ
<answer>...</answer>và so sánh với đáp án chuẩn! - Với một bài toán lập trình, ta chỉ cần chạy đoạn mã mô hình sinh ra qua bộ kiểm thử (unit tests): vượt qua toàn bộ test thì nhận thưởng , sai hoặc lỗi cú pháp thì nhận .
Hướng tiếp cận này được gọi là Học tăng cường với Phần thưởng Kiểm chứng được (Reinforcement Learning with Verifiable Rewards — RLVR). Bộ chấm điểm quy tắc (rule-based verifier) không phải là một mạng nơ-ron khả nghi, nên mô hình không thể dùng văn hoa tán dương để “hack phần thưởng” — cách duy nhất để nhận điểm cao là thực sự tìm ra lời giải đúng!
5.2 Group Relative Policy Optimization (GRPO): Loại bỏ mạng Critic bằng chuẩn hóa nhóm
Khi quay lại với Học tăng cường trực tuyến cho các chuỗi suy luận dài hàng nghìn token, rào cản lớn nhất của PPO (Schulman & others, 2017) là Mạng giá trị Critic . Trong PPO chuẩn, để biết một token hay một câu trả lời là “tốt hơn hay kém hơn mức kỳ vọng trung bình” (tức đại lượng Lợi thế — Advantage ), ta phải huấn luyện thêm một mạng Critic có kích thước ngang ngửa mô hình chính sách (ví dụ thêm một mô hình 70B hay 671B tham số nữa!), vừa tốn gấp đôi bộ nhớ VRAM vừa rất khó ước lượng chính xác khi phần thưởng chỉ xuất hiện ở token cuối cùng của chuỗi suy luận.
Trong công trình DeepSeekMath (Shao & others, 2024) và sau đó là DeepSeek-R1 (DeepSeek-AI, 2025), nhóm nghiên cứu DeepSeek đã giới thiệu thuật toán Group Relative Policy Optimization (GRPO) — giải pháp loại bỏ hoàn toàn mạng Critic bằng một ý tưởng thống kê cực kỳ thanh lịch:
Thay vì dùng mạng nơ-ron để đoán điểm kỳ vọng của câu hỏi , ta hãy lấy mẫu một nhóm câu trả lời độc lập từ chính sách hiện tại cho cùng một câu hỏi , chấm điểm từng câu để được , rồi lấy chính điểm trung bình của nhóm đó làm mốc cơ sở (baseline)!
Cụ thể, Lợi thế tương đối theo nhóm (Group-Normalized Advantage) của câu trả lời thứ (được gán chung cho mọi token thuộc ) được tính trực tiếp bằng chuẩn hóa -score trong nhóm (DeepSeek-AI, 2025; Shao & others, 2024):
Nếu câu trả lời giải đúng và tuân thủ định dạng tốt hơn mức trung bình của lần thử trên cùng bài toán đó, nó nhận (được tăng xác suất). Nếu nó giải sai hoặc rườm rà kém hơn các câu trả lời khác trong nhóm, nó nhận (bị giảm xác suất).
Hàm mục tiêu của GRPO kết hợp cơ chế cắt tỉ số xác suất (clipped surrogate) của PPO với bộ ước lượng phân kỳ KL không âm của Schulman (DeepSeek-AI, 2025; Shao & others, 2024):
trong đó là tỉ số xác suất giữa chính sách mới và chính sách cũ, còn hạng tử phạt phân kỳ KL được ước lượng không thiên lệch và luôn không âm qua công thức:
5.3 Khoảnh khắc “Aha” và sự trỗi dậy tự nhiên của Chuỗi suy luận
Tháng 1 năm 2025, DeepSeek-AI đã thực hiện một thí nghiệm lịch sử mang tên DeepSeek-R1-Zero (DeepSeek-AI, 2025): họ lấy trực tiếp mô hình nền tảng DeepSeek-V3-Base (671 tỷ tham số MoE), bỏ qua hoàn toàn giai đoạn SFT, và chỉ chạy thuần túy thuật toán GRPO với hai phần thưởng quy tắc đơn giản:
- Phần thưởng độ chính xác (Accuracy Reward): Đáp số toán học hoặc kết quả chạy code có đúng không?
- Phần thưởng định dạng (Format Reward): Quá trình suy nghĩ có nằm trong cặp thẻ
<think>...</think>và kết luận trong<answer>...</answer>không?
Kết quả quan sát được trong quá trình huấn luyện DeepSeek-R1-Zero (DeepSeek-AI, 2025) là một minh chứng ngoạn mục cho sức mạnh của Học tăng cường:
- Không hề có ai lập trình hay viết mẫu dạy mô hình phải suy nghĩ dài bao nhiêu, nhưng qua hàng nghìn bước GRPO, mô hình tự nhận ra rằng các chuỗi
<think>có bước thử nghiệm, tự phản biện và kiểm tra lại đáp số thường mang lại lợi thế cao hơn. - Giữa quá trình huấn luyện, mô hình tự phát sinh “Khoảnh khắc Aha” (Aha moment): đang giải dở bài toán, nó tự viết “Wait, wait. Wait. That’s an aha moment I can flag here… Let’s re-evaluate this step-by-step” rồi quay lại sửa lỗi đại số ở dòng trên!
Hãy tự tay cài đặt bộ chấm điểm tự động và bước cập nhật GRPO trong Bài tập 4.5 dưới đây.
Bài tập 4.5 Cài đặt bộ chấm điểm tự động và bước cập nhật GRPO
Cho bài toán "Giải phương trình 3x + 5 = 20" (đáp án chuẩn gold_answer = "5") và một nhóm câu trả lời rollouts được sinh ra từ :
- Viết hàm
verify_rollout(text, gold)chấm điểm theo quy tắc: cộng+0.25điểm định dạng nếu chuỗi khớp cấu trúc<think>...</think>...<answer>...</answer>, và cộng+1.0điểm chính xác nếu nội dung bên trong<answer>...</answer>bằng đúnggold. - Tính vector lợi thế chuẩn hóa theo nhóm .
- Cài đặt hàm mất mát
grpo_lossgồm phần cắt tỉ số trừ đi , thực hiện 1 bước cập nhật đạo hàm và kiểm tra xem mẫu nào được tăng xác suất, mẫu nào bị giảm xác suất.
import re
import torch
gold_answer = "5"
# Nhóm G=6 phản hồi được lấy mẫu từ pi_old cho cùng một câu hỏi toán
rollouts = [
"<think> 3x = 20 - 5 = 15, suy ra x = 15 / 3 = 5. </think><answer>5</answer>",
"<think> 3x = 15 nên x = 5. </think><answer>5</answer>",
"<think> 3x = 20 + 5 = 25, x = 25/3. </think><answer>8.33</answer>",
"Đáp số của phương trình là <answer>5</answer>",
"<think> Thử x = 4 thì 3*4 + 5 = 17. </think><answer>4</answer>",
"Tôi nghĩ x bằng 6.",
]
# Log-prob trung bình mỗi token dưới pi_old và pi_ref
old_logp = torch.tensor([-0.85, -0.92, -1.10, -0.78, -1.05, -1.30])
ref_logp = torch.tensor([-0.90, -0.95, -1.08, -0.80, -1.02, -1.25])
print(f"Số mẫu trong nhóm G = {len(rollouts)} | Đáp án chuẩn: {gold_answer}")
Số mẫu trong nhóm G = 6 | Đáp án chuẩn: 5
Hint
Dùng biểu thức chính quy re.search(r"<think>.*?</think>\s*<answer>.*?</answer>", text) để kiểm tra định dạng, và re.search(r"<answer>(.*?)</answer>", text) để trích xuất chuỗi đáp số bên trong thẻ <answer>.
Hint
Với log_r_ref = ref_lp - curr_lp (tức ), bộ ước lượng KL không âm của Schulman được viết gọn trong 1 dòng: kl = torch.exp(log_r_ref) - log_r_ref - 1.0. Vì trong PyTorch ta cực tiểu hóa hàm mất mát (loss.backward()), hãy nhớ đặt dấu trừ - phía trước hàm mục tiêu .
Điểm kiểm tra kết quả bạn cần đạt được
Hai mẫu đầu tiên (vừa có <think> vừa ra đáp số 5) đạt điểm tối đa 1.25 và nhận lợi thế dương A_i = +1.04. Mẫu thứ 4 ("Đáp số của phương trình là <answer>5</answer>") tuy đúng kết quả (r = 1.0) nhưng thiếu thẻ <think> nên chỉ nhận lợi thế +0.59. Các mẫu giải sai (r = 0.25 hoặc 0.0) đều nhận lợi thế âm (-0.74 và -1.18) và bị giảm log-prob ngay sau 1 bước cập nhật.
Điểm thưởng r_i: [1.25, 1.25, 0.25, 1.0, 0.25, 0.0] Trung bình nhóm: 0.6667 | Độ lệch chuẩn: 0.5627 Lợi thế chuẩn hóa A_i: ['+1.04', '+1.04', '-0.74', '+0.59', '-0.74', '-1.18'] Hàm mất mát GRPO ban đầu: 0.001241 Sau 1 bước cập nhật GRPO, thay đổi log-prob (pi_theta - pi_old): Mẫu 0 (r=1.25, A=+1.04): delta_logp = +0.1000 (TĂNG xác suất) Mẫu 2 (r=0.25, A=-0.74): delta_logp = -0.0742 (GIẢM xác suất) Mẫu 5 (r=0.00, A=-1.18): delta_logp = -0.1184 (GIẢM xác suất)
Lời giải 2 ô mã
Chạy các ô mã theo đúng thứ tự này trong một notebook mới: chúng đã bao gồm các lệnh import, bước tải dữ liệu và khởi tạo hạt giống ngẫu nhiên (random seed).
import re
import torch
gold_answer = "5"
# Nhóm G=6 phản hồi được lấy mẫu từ pi_old cho cùng một câu hỏi toán
rollouts = [
"<think> 3x = 20 - 5 = 15, suy ra x = 15 / 3 = 5. </think><answer>5</answer>",
"<think> 3x = 15 nên x = 5. </think><answer>5</answer>",
"<think> 3x = 20 + 5 = 25, x = 25/3. </think><answer>8.33</answer>",
"Đáp số của phương trình là <answer>5</answer>",
"<think> Thử x = 4 thì 3*4 + 5 = 17. </think><answer>4</answer>",
"Tôi nghĩ x bằng 6.",
]
# Log-prob trung bình mỗi token dưới pi_old và pi_ref
old_logp = torch.tensor([-0.85, -0.92, -1.10, -0.78, -1.05, -1.30])
ref_logp = torch.tensor([-0.90, -0.95, -1.08, -0.80, -1.02, -1.25])
print(f"Số mẫu trong nhóm G = {len(rollouts)} | Đáp án chuẩn: {gold_answer}")
Số mẫu trong nhóm G = 6 | Đáp án chuẩn: 5
# (1) Bộ chấm điểm quy tắc (Rule-based Verifier: Độ chính xác + Định dạng)
def verify_rollout(text: str, gold: str) -> float:
r = 0.0
if re.search(r"<think>.*?</think>\s*<answer>.*?</answer>", text):
r += 0.25
ans_match = re.search(r"<answer>(.*?)</answer>", text)
if ans_match and ans_match.group(1).strip() == gold:
r += 1.0
return r
rewards = torch.tensor([verify_rollout(o, gold_answer) for o in rollouts])
# (2) Tính lợi thế tương đối theo nhóm (Group-Normalized Advantage)
mean_r = rewards.mean()
std_r = rewards.std(unbiased=True)
advantages = (rewards - mean_r) / (std_r + 1e-8)
print(f"Điểm thưởng r_i: {rewards.tolist()}")
print(f"Trung bình nhóm: {mean_r.item():.4f} | Độ lệch chuẩn: {std_r.item():.4f}")
print(f"Lợi thế chuẩn hóa A_i: {[f'{a:+.2f}' for a in advantages.tolist()]}")
# (3) Tính hàm mất mát GRPO với Clipped Surrogate và KL Penalty
eps, beta = 0.2, 0.04
curr_logp = old_logp.clone().detach().requires_grad_(True)
def grpo_loss(curr_lp, old_lp, ref_lp, adv, eps=0.2, beta=0.04):
ratio = torch.exp(curr_lp - old_lp)
surr1 = ratio * adv
surr2 = torch.clamp(ratio, 1.0 - eps, 1.0 + eps) * adv
# Bộ ước lượng KL không âm của Schulman: exp(ref - curr) - (ref - curr) - 1
log_r_ref = ref_lp - curr_lp
kl = torch.exp(log_r_ref) - log_r_ref - 1.0
return -(torch.min(surr1, surr2) - beta * kl).mean()
loss0 = grpo_loss(curr_logp, old_logp, ref_logp, advantages, eps, beta)
print(f"Hàm mất mát GRPO ban đầu: {loss0.item():.6f}")
loss0.backward()
with torch.no_grad():
curr_logp -= 0.6 * curr_logp.grad
delta = curr_logp - old_logp
print("Sau 1 bước cập nhật GRPO, thay đổi log-prob (pi_theta - pi_old):")
for idx in [0, 2, 5]:
direction = "TĂNG xác suất" if delta[idx] > 0 else "GIẢM xác suất"
print(
f" Mẫu {idx} (r={rewards[idx]:.2f}, A={advantages[idx]:+.2f}): "
f"delta_logp = {delta[idx]:+.4f} ({direction})"
)
Điểm thưởng r_i: [1.25, 1.25, 0.25, 1.0, 0.25, 0.0] Trung bình nhóm: 0.6667 | Độ lệch chuẩn: 0.5627 Lợi thế chuẩn hóa A_i: ['+1.04', '+1.04', '-0.74', '+0.59', '-0.74', '-1.18'] Hàm mất mát GRPO ban đầu: 0.001241 Sau 1 bước cập nhật GRPO, thay đổi log-prob (pi_theta - pi_old): Mẫu 0 (r=1.25, A=+1.04): delta_logp = +0.1000 (TĂNG xác suất) Mẫu 2 (r=0.25, A=-0.74): delta_logp = -0.0742 (GIẢM xác suất) Mẫu 5 (r=0.00, A=-1.18): delta_logp = -0.1184 (GIẢM xác suất)
Hãy quan sát cách GRPO phân biệt tinh tế giữa 6 câu trả lời mà không cần bất kỳ nhãn sở thích cặp thủ công hay mạng Critic nào:
- Mẫu 0 và Mẫu 1 vừa đúng đáp án vừa tuân thủ định dạng
<think>(được khuyến khích mạnh nhất). - Mẫu 3 ra đúng đáp án
5nhưng nhảy cóc không chịu suy luận trong thẻ<think>(vẫn dương nhưng thấp hơn Mẫu 0 và 1). - Mẫu 2 và Mẫu 4 có trình bày
<think>nhưng tính toán sai đáp số (bị phạt nhẹ hơn Mẫu 5 hoàn toàn sai cả định dạng lẫn kết quả ). Chính áp lực chọn lọc tự nhiên theo nhóm này đã dẫn dắt mô hình vừa giữ cấu trúc<think>, vừa rèn giũa độ chính xác suy luận qua từng vòng lặp!
Để tổng kết toàn bộ hành trình của Chương 4, bảng dưới đây đặt 5 phương pháp hậu huấn luyện cạnh nhau theo dữ liệu đầu vào, hàm mục tiêu, chi phí bộ nhớ và vai trò thực tế trong một đường ống LLM hiện đại:
| Phương pháp | Dữ liệu đầu vào | Mô hình trên VRAM | Hàm mục tiêu cốt lõi | Vai trò chính trong Pipeline |
|---|---|---|---|---|
| SFT (Full Fine-Tuning) | Các cuộc hội thoại mẫu | 1 mô hình () + Gradient + AdamW | Masked Cross-Entropy trên token của assistant | Dạy định dạng Chat Template và phong cách trợ lý cơ bản |
| LoRA / QLoRA (PEFT) | Như SFT hoặc DPO | đóng băng (4-bit/16-bit) + nhánh nhỏ | Cập nhật hạng thấp ( tham số) | Giảm 70%–85% VRAM, cho phép tinh chỉnh trên 1 GPU |
| RLHF (PPO) | Prompt + Mô hình phần thưởng | 4 mô hình () | Căn chỉnh trực tuyến theo sở thích con người & độ an toàn | |
| DPO | Các cặp sở thích tĩnh | 2 mô hình ( và ; hoặc 1 base + LoRA) | Căn chỉnh sở thích ngoại tuyến gọn nhẹ, không cần Reward Model | |
| RLVR (GRPO) | Câu hỏi + Bộ kiểm chứng tự động (Toán/Code) | 2 mô hình ( và ; bỏ Critic ) | Clipped Surrogate với | Đánh thức năng lực tự suy luận chuỗi dài (<think>) và tự sửa lỗi |
Nếu bạn muốn đào sâu hơn nữa, hãy thử sức với các bài tập mở rộng dưới đây trước khi bước sang Chương 5: Suy luận và Tối ưu hóa (Inference) — nơi chúng ta sẽ tìm hiểu cách làm cho trợ lý AI vừa được huấn luyện này trả lời người dùng với tốc độ hàng trăm token mỗi giây!
LoRA dùng chung làm Mô hình Tham chiếu trong DPO. Khi chạy DPO (Bài tập 4.4), ta cần cả và . Nếu dùng LoRA để tinh chỉnh , làm thế nào để tính mà không cần nạp thêm bản sao thứ hai của mô hình gốc lên VRAM? Hãy thêm một cờ enable_lora: bool vào lớp LoRALinear của Bài tập 4.2 để tính cả hai phân phối trên cùng một mô hình duy nhất!
Hiện tượng suy giảm xác suất của cả và trong DPO (Likelihood Displacement). Trong Bài tập 4.4, hãy vẽ đồ thị của và theo từng bước tối ưu hóa khi hai chuỗi và có chung 4/6 token đầu tiên. Điều gì xảy ra với xác suất của các token dùng chung? Hãy thử cộng thêm một hạng tử nhỏ (như trong công thức RPO / CPO) và so sánh kết quả.
Trường hợp suy biến trong GRPO khi mọi mẫu trong nhóm đều đúng hoặc đều sai. Trong Bài tập 4.5, điều gì xảy ra với lợi thế và đạo hàm của GRPO nếu cả câu trả lời được lấy mẫu từ đều giải sai () hoặc đều giải đúng tuyệt đối ()? Tại sao việc lọc bỏ các câu hỏi có (dynamic sampling) lại giúp tăng tốc huấn luyện GRPO lên đáng kể?
Tinh chỉnh QLoRA thực tế trên SmolLM2-135M. Sử dụng thư viện transformers và peft, hãy tải mô hình HuggingFaceTB/SmolLM2-135M, gắn bộ thích nghi LoRA () vào các lớp q_proj, k_proj, v_proj, o_proj, và tinh chỉnh SFT trên 200 mẫu hội thoại tiếng Việt trong 3 phút trên CPU/GPU. So sánh câu trả lời của mô hình trước và sau khi tinh chỉnh!
Tài liệu tham khảo
- 2017 Deep Reinforcement Learning from Human Preferences. NeurIPS. 3
- 2017 Proximal Policy Optimization Algorithms. arXiv. 3
- 2022 Constitutional AI: Harmlessness from AI Feedback. arXiv. 3
- 2022 LoRA: Low-Rank Adaptation of Large Language Models. ICLR. 2
- 2022 Training Language Models to Follow Instructions with Human Feedback. NeurIPS. 3
- 2022 Finetuned Language Models Are Zero-Shot Learners. ICLR. 1
- 2023 QLoRA: Efficient Finetuning of Quantized LLMs. NeurIPS. 2
- 2023 Direct Preference Optimization: Your Language Model is Secretly a Reward Model. NeurIPS. 4
- 2023 LIMA: Less Is More for Alignment. NeurIPS. 1
- 2024 DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models. arXiv. 5
- 2025 DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning. arXiv. 5