Fine-tuning toàn bộ một model lớn cần nhiều bộ nhớ GPU: ngoài trọng số còn phải lưu gradient và trạng thái optimizer cho từng tham số. LoRA giữ nguyên trọng số gốc và chỉ học thêm hai ma trận nhỏ đặt cạnh mỗi lớp tuyến tính, nên số tham số cần học giảm đi rất nhiều. Bài này tự cài một lớp LoRA, đếm số tham số, kiểm gradient bằng NumPy, rồi gắn LoRA vào model văn bản DistilBERT.
Học phần thay đổi thay vì viết lại cả ma trận
Một lớp tuyến tính không bias nhận số và trả số bằng . có shape . Trong full fine-tuning, ta học mọi phần tử của . LoRA giữ cố định và viết thay đổi dưới dạng tích:
có shape , đưa input xuống tọa độ; có shape , đưa chúng lên output. gọi là rank và thường nhỏ; điều khiển scale, ở đây là . Có biến thể dùng scale khác (thư viện PEFT có tùy chọn chia cho ), nên khi dùng thư viện phải đọc đúng cấu hình.
Trường hợp cho thấy rõ nghĩa của hạng. Khi đó là một hàng , là một cột , và phần sửa bằng : adapter chiếu input lên đúng một hướng để được một con số, rồi đẩy output dọc theo đúng một hướng với độ lớn tỉ lệ con số ấy. Với rank , phần sửa là tổng của cặp như vậy, nên mọi vector nằm trong không gian sinh bởi cột của : có hạng tối đa .
LoRA dựa trên giả thuyết: phần sửa cần cho nhiệm vụ mới tập trung vào ít hướng. Đo trên mô hình lớn, phần thay đổi khi fine-tune đúng là thường có hạng thấp. Nếu nhiệm vụ cần nhiều hướng sửa độc lập hơn , adapter không đủ sức biểu diễn; validation sẽ cho biết. Vì giữ nguyên và phần sửa nhỏ, LoRA cũng ít làm mô hình quên năng lực chung hơn fine-tune toàn bộ.
Hỏi nhanh
Với , , , hai ma trận và có shape gì?
nhận input 8 chiều nên có 8 cột. trả output 6 chiều nên có 6 hàng. có shape , đúng bằng . và là shape của và , nhầm quy ước với cách viết X @ W.T cho batch xếp theo hàng. và là đảo với . Hai ma trận thì không nhân được với nhau, và cũng chẳng tiết kiệm gì.
Hỏi nhanh
LoRA rank 1 có và . Một input vuông góc với , tức . Adapter làm output của input này thay đổi thế nào?
Phần sửa bằng . LoRA không có bias riêng, nên không cộng thêm hằng số nào. Hướng của phần sửa luôn là , trong không gian output, còn chỉ quyết định input nào được “nhìn thấy”. đủ shape nhưng chỉ có hạng 1: mọi input vuông góc với đi qua y như ở model gốc.
Tiết kiệm bao nhiêu tham số?
Full update của lớp trên có số. Nhánh LoRA không bias có số được học. Với lớp và , đó là 48 số so với 28 số: ví dụ nhỏ chỉ tiết kiệm vừa phải. Với một lớp vuông và , có 16 777 216 số gốc nhưng 65 536 số adapter, tức .
Cả model thì cộng theo mọi lớp được chèn adapter: LoRA trên và của 24 lớp, mỗi ma trận , là tham số; mục cuối bài đếm như vậy trên DistilBERT.
import numpy as np
import torch
from torch import nn
torch.set_num_threads(1)
torch.manual_seed(12)
torch.set_default_dtype(torch.float64)
for din, dout, rank in [(8, 6, 2), (4096, 4096, 8)]:
full = din * dout
adapter = rank * (din + dout)
print("shape:", (dout, din), "full:", full,
"LoRA:", adapter, "ty le:", round(adapter / full, 6))
shape: (6, 8) full: 48 LoRA: 28 ty le: 0.583333
shape: (4096, 4096) full: 16777216 LoRA: 65536 ty le: 0.003906
Tỉ lệ 0.583333 ở lớp nhỏ và 0.003906 (đúng ) ở lớp lớn cho thấy LoRA lợi nhất ở ma trận lớn, khi rất nhỏ so với và . Hình dưới vẽ số tham số theo cho lớp : đường vượt mức 48 của full update giữa và , nên từ adapter đã lớn hơn chính ma trận nó sửa.
Phần tiết kiệm nằm ở gradient và trạng thái optimizer, vốn tỉ lệ với số tham số học; với Adam, mỗi tham số học kèm hai trạng thái. Trọng số gốc vẫn nằm trong bộ nhớ để forward, activation vẫn được giữ để backward đi tới adapter, nên bộ nhớ tổng không giảm theo đúng tỉ lệ tham số.
Nén trọng số gốc xuống 4-bit (quantization, như trong QLoRA) là một kỹ thuật khác, dùng kèm được nhưng không có sẵn trong LoRA.
Hỏi nhanh
DistilBERT có 6 block, mỗi block có hai lớp q_lin và v_lin, cùng là Linear . Chèn LoRA rank 4 vào cả hai lớp ở mọi block. Không tính head, adapter có bao nhiêu tham số?
Một lớp có tham số adapter. Có lớp, nên tổng là . 6144 là của một lớp. 36 864 là chỉ tính một trong hai lớp ở mỗi block. 7 077 888 là số tham số nếu học đầy đủ cả 12 ma trận. Mục cuối bài đếm lại bằng code trên đúng kiến trúc này.
Tự cài một lớp LoRA
Để có đáp án biết trước, ta dựng một bài hồi quy tuyến tính. đóng vai trọng số pretrained; dữ liệu được sinh từ cộng một phần sửa có rank tối đa 2, và adapter phải học lại phần sửa ấy. Vì biết trước mục tiêu, mọi kết quả đều có phép đối chiếu độc lập. Code dùng float64 để sai phân số ở mục sau đủ chính xác; train model lớn thường dùng float32 hoặc mixed precision (bài 3.7).
class TinyLoRA(nn.Module):
def __init__(self, base_weight, rank, alpha):
super().__init__()
dout, din = base_weight.shape
self.base = nn.Parameter(base_weight.clone(), requires_grad=False)
self.A = nn.Parameter(torch.randn(rank, din) * 0.1)
self.B = nn.Parameter(torch.zeros(dout, rank))
self.scale = alpha / rank
def forward(self, X):
original = X @ self.base.T
update = (X @ self.A.T) @ self.B.T
return original + self.scale * update
W0 = torch.randn(6, 8) * 0.1
true_A = torch.randn(2, 8) * 0.2
true_B = torch.randn(6, 2) * 0.2
W_target = W0 + true_B @ true_A
X_train = torch.randn(64, 8)
X_valid = torch.randn(64, 8)
y_train = X_train @ W_target.T
y_valid = X_valid @ W_target.T
adapter = TinyLoRA(W0, rank=2, alpha=4)
with torch.no_grad():
unchanged = torch.allclose(adapter(X_train), X_train @ W0.T)
print("ban dau dung model goc:", unchanged)
print("tham so hoc:", sum(p.numel() for p in adapter.parameters() if p.requires_grad))
ban dau dung model goc: True
tham so hoc: 28
Hai dòng output xác nhận hai ý: output ban đầu trùng model gốc, và chỉ có 28 tham số được học, đúng . B=0 làm nhánh sửa bằng 0 lúc bắt đầu, nên output giữ nguyên model nguồn. A ngẫu nhiên để gradient của B có đường đi.
Ở batch hàng, PyTorch dùng X @ W.T: chuyển vị này chỉ do ta xếp mỗi mẫu một hàng, còn công thức vector ở đầu bài dùng mẫu cột. Đừng đổi shape của chỉ để code tình cờ chạy.
Hỏi nhanh
Lớp LoRA khởi tạo ngẫu nhiên và . Vì sao output ban đầu đúng bằng output của lớp gốc?
Nhân với ma trận 0 thì ra ma trận 0, nên đúng hoàn toàn, không chỉ xấp xỉ. Freeze chỉ tắt gradient của , nó vẫn tham gia tính output. Rank thấp không làm phần sửa nhỏ: sau vài bước, có thể có giá trị lớn. Scale là hằng số khác 0 ngay từ đầu.
Gradient đầu tiên đi vào đâu?
Ta dùng MSE trung bình trên cả phần tử. Gọi có shape với batch hàng, và . Theo chain rule:
Khi , gradient của bằng 0 ở bước đầu; gradient của có thể khác 0 vì đã ngẫu nhiên. Sau khi được cập nhật, có thể bắt đầu học. “A không có gradient khác 0 ở bước đầu” không phải lỗi trong trường hợp này. Nhưng nếu đặt cả A và B bằng 0, cả hai gradient đều bằng 0: nhánh không thoát được điểm khởi tạo chỉ bằng gradient descent thông thường.
Hỏi nhanh
Với , , , , tích trong công thức gradient của có shape gì?
là , là , là , nên tích là , đúng shape của . Gradient theo một tham số luôn cùng shape với tham số ấy, nên đây là một phép kiểm nhanh khi tự viết công thức. là shape của . là shape của . là , một bước giữa của forward.
Ta kiểm với công thức NumPy và sai phân trung tâm cho một phần tử của . Ba cách phải thống nhất. Bản số học không cần tin công thức gradient, giống viết brute để kiểm lời giải trong lập trình thi đấu.
loss = ((adapter(X_train) - y_train) ** 2).mean()
loss.backward()
X_np = X_train.numpy()
A_np = adapter.A.detach().numpy()
B_np = adapter.B.detach().numpy()
W_np = W0.numpy()
y_np = y_train.numpy()
s = adapter.scale
prediction_np = X_np @ W_np.T + s * (X_np @ A_np.T) @ B_np.T
G_np = 2 * (prediction_np - y_np) / y_np.size
grad_B_np = s * G_np.T @ X_np @ A_np.T
grad_A_np = s * B_np.T @ G_np.T @ X_np
def numpy_loss(B_value):
prediction = X_np @ W_np.T + s * (X_np @ A_np.T) @ B_value.T
return np.mean((prediction - y_np) ** 2)
h = 1e-5
B_plus, B_minus = B_np.copy(), B_np.copy()
B_plus[0, 0] += h
B_minus[0, 0] -= h
finite_difference = (numpy_loss(B_plus) - numpy_loss(B_minus)) / (2 * h)
print("A grad bang 0:", torch.count_nonzero(adapter.A.grad).item() == 0)
print("B grad khac 0:", adapter.B.grad.norm().item() > 0)
print("W0 grad None:", adapter.base.grad is None)
print("khop cong thuc A/B:", np.allclose(adapter.A.grad.numpy(), grad_A_np),
np.allclose(adapter.B.grad.numpy(), grad_B_np))
print("khop sai phan B00:", np.isclose(finite_difference, adapter.B.grad[0, 0].item(), atol=1e-8))
A grad bang 0: True
B grad khac 0: True
W0 grad None: True
khop cong thuc A/B: True True
khop sai phan B00: True
Các dòng boolean tách rõ ba phần: trọng số nguồn không học, adapter có đường gradient đúng, bản tính độc lập khớp. Trong một model thật, cần kiểm tất cả module được chèn adapter và head mới; số tham số trainable không chỉ bằng công thức của một lớp nếu còn bias hoặc head được học.
Gradient A bằng 0 ở khởi tạo chưa kiểm được công thức của A trong trường hợp tổng quát. Ta dùng gradient đang có để đi một bước, làm B khác 0, rồi kiểm lại A bằng NumPy và sai phân. Bước này chỉ đổi adapter; thí nghiệm ở mục sau tạo model mới cho từng cách.
one_step = torch.optim.SGD([adapter.A, adapter.B], lr=0.1)
one_step.step()
one_step.zero_grad(set_to_none=True)
second_loss = ((adapter(X_train) - y_train) ** 2).mean()
second_loss.backward()
A_after = adapter.A.detach().numpy()
B_after = adapter.B.detach().numpy()
pred_after = X_np @ W_np.T + s * (X_np @ A_after.T) @ B_after.T
G_after = 2 * (pred_after - y_np) / y_np.size
expected_A = s * B_after.T @ G_after.T @ X_np
def loss_after(A_value):
pred = X_np @ W_np.T + s * (X_np @ A_value.T) @ B_after.T
return np.mean((pred - y_np) ** 2)
A_plus, A_minus = A_after.copy(), A_after.copy()
A_plus[0, 0] += h
A_minus[0, 0] -= h
numeric_A00 = (loss_after(A_plus) - loss_after(A_minus)) / (2 * h)
print("A grad sau buoc khac 0:", adapter.A.grad.norm().item() > 0)
print("A khop NumPy:", np.allclose(adapter.A.grad.numpy(), expected_A))
print("A00 khop sai phan:", np.isclose(numeric_A00, adapter.A.grad[0, 0].item(), atol=1e-8))
A grad sau buoc khac 0: True
A khop NumPy: True
A00 khop sai phan: True
Nhìn dòng đầu để biết đã kiểm ở một trạng thái A thực sự có đường học, rồi đọc hai dòng đối chiếu. Nếu chỉ kiểm tại , một bản gradient luôn trả về 0 cũng vượt phép kiểm đầu; trạng thái thứ hai bắt được lỗi ấy.
Hỏi nhanh
Ở bước đầu, với và ngẫu nhiên, autograd báo gradient của bằng 0. Nên kết luận gì?
có chứa , nên bằng 0 khi . Autograd tính gradient mà không quan tâm optimizer có chứa A hay không. Sau bước đầu, khác 0 và bắt đầu nhận gradient, đúng như phép kiểm ở trạng thái thứ hai. Đặt cả hai bằng 0 thì gradient của cả hai bằng 0 mãi, và nhánh không bao giờ rời điểm khởi tạo.
So với học toàn bộ trên cùng một bài nhỏ
Ta cho full update, LoRA rank 1 và LoRA rank 2 cùng học từ trọng số gốc , trên cùng train và validation, cùng 250 bước Adam với learning rate 0.03; không cách nào được dò learning rate riêng. Adapter đặt , giống script ở cuối bài.
Dự đoán trước: dữ liệu không nhiễu và phần sửa có rank tối đa 2, nên full và rank 2 biểu diễn được mục tiêu, còn rank 1 thì không. Code chạy thêm rank 1 với 2500 bước để tách hai nguyên nhân có thể của sai số: thiếu bước tối ưu hay thiếu khả năng biểu diễn.
def train_small(kind, rank=2, steps=250):
torch.manual_seed(20)
if kind == "full":
candidate = nn.Linear(8, 6, bias=False)
with torch.no_grad():
candidate.weight.copy_(W0)
else:
candidate = TinyLoRA(W0, rank=rank, alpha=2 * rank)
learned = [p for p in candidate.parameters() if p.requires_grad]
optimizer = torch.optim.Adam(learned, lr=0.03)
for _ in range(steps):
optimizer.zero_grad(set_to_none=True)
train_loss = ((candidate(X_train) - y_train) ** 2).mean()
train_loss.backward()
optimizer.step()
with torch.no_grad():
valid_loss = ((candidate(X_valid) - y_valid) ** 2).mean().item()
count = sum(p.numel() for p in learned)
return candidate, valid_loss, count
full_net, full_mse, full_count = train_small("full")
rank1_net, rank1_mse, rank1_count = train_small("lora", rank=1)
rank2_net, rank2_mse, rank2_count = train_small("lora", rank=2)
for name, error, count in [
("full", full_mse, full_count),
("rank 1", rank1_mse, rank1_count),
("rank 2", rank2_mse, rank2_count),
]:
print(name, "trainable =", count, "valid MSE =", format(error, ".3g"))
_, rank1_long, _ = train_small("lora", rank=1, steps=2500)
print("rank 1, 2500 buoc: valid MSE =", format(rank1_long, ".3g"))
singular = np.linalg.svd((true_B @ true_A).numpy(), compute_uv=False)
print("gia tri ky di cua phan sua:", np.round(singular[:3], 4))
full trainable = 48 valid MSE = 2.93e-14
rank 1 trainable = 14 valid MSE = 0.00204
rank 2 trainable = 28 valid MSE = 5.04e-06
rank 1, 2500 buoc: valid MSE = 0.00204
gia tri ky di cua phan sua: [0.1668 0.108 0. ]
Đọc cả hai cột. Số tham số là phép đếm; MSE là số đo trên validation.
- Full đạt khoảng , tức về 0 trong sai số số thực.
- Rank 2 đạt ở bước cuối: biểu diễn được mục tiêu, nhưng đường validation của nó không giảm đều như full mà dao động, có lúc xuống dưới , nên con số ở bước 250 phụ thuộc lúc dừng.
- Rank 1 dừng ở 0.00204, và chạy gấp mười lần số bước vẫn là 0.00204: đây là sàn của khả năng biểu diễn, không phải do thiếu bước.
Dòng cuối giải thích độ cao của sàn. Phần sửa thật có hai giá trị kỳ dị khác 0 là 0.1668 và 0.108 (SVD, bài 2.9). Rank 1 giữ được tốt nhất hướng thứ nhất; hướng thứ hai bị bỏ. Vì các chiều input độc lập, phương sai 1, phần bị bỏ góp vào tổng bình phương sai số của mỗi mẫu, tức MSE khoảng sau khi lấy trung bình trên 6 output, gần đúng mức đo được. Sai số của adapter rank vì vậy do các giá trị kỳ dị từ thứ trở đi quyết định.
Trên model thật, phổ của phần sửa cần thiết không biết trước, nên rank là hyperparameter chọn bằng validation, và thứ tự chất lượng có thể đổi khi đổi dữ liệu, lớp được chèn hoặc ngân sách tối ưu.
Hỏi nhanh
Code đặt , và script của chương cũng vậy. Tăng rank từ 4 lên 16 thì hệ số scale thay đổi thế nào?
. Hai phương án đầu chỉ nhìn một nửa phân số. Nếu giữ cố định mà tăng thì scale mới giảm, và cùng learning rate sẽ cho phần sửa nhỏ hơn. Vì vậy khi so nhiều rank, cần biết cấu hình đang theo kiểu nào. Scale không dính gì tới shape của lớp.
Hỏi nhanh
Dựng lại thí nghiệm, cùng input, nhưng phần sửa có hai giá trị kỳ dị là 0.1668 và 0.01, các giá trị khác bằng 0. Train rank 1 đủ lâu thì validation MSE khoảng bao nhiêu?
Phần mà rank 1 không biểu diễn được là hướng thứ hai, góp khoảng vào MSE trung bình trên 6 output. Hạng vẫn là 2, nhưng sai số do độ lớn của hướng bị bỏ quyết định, chứ không do hạng. Sàn tỉ lệ với bình phương của , nên giảm khoảng 117 lần, chứ không phải 11 lần. Đây là trực giác vì sao rank nhỏ thường là đủ: khi phần sửa dồn năng lượng vào vài hướng, các hướng còn lại góp rất ít.
Merge để kiểm tính tương đương
Khi adapter đã học, có thể gộp để suy luận qua một ma trận. Với lớp tuyến tính đơn giản của bài, không dropout và không quantization, hai cách phải cho kết quả khớp trong sai số số thực. Nếu dùng quantization hoặc biến thể adapter, phải kiểm lại điều kiện merge của thư viện.
with torch.no_grad():
merged_weight = rank2_net.base + rank2_net.scale * rank2_net.B @ rank2_net.A
separate = rank2_net(X_valid)
merged = X_valid @ merged_weight.T
print("merge khop:", torch.allclose(separate, merged, atol=1e-10))
print("rank delta:", int(torch.linalg.matrix_rank(merged_weight - W0)))
torch.set_default_dtype(torch.float32) # khôi phục mặc định sau phép tính float64
merge khop: True
rank delta: 2
rank delta: 2 xác nhận phần đã gộp vào ma trận có đúng hạng 2, như cấu trúc cho phép.
Lưu adapter riêng thì phải lưu cả cấu hình và ghi rõ model nguồn đúng revision: chỉ tệp A, B không tái tạo được model nếu thiếu . Merge vào một model nguồn khác dù cùng shape có thể cho output sai mà không báo lỗi. Khi thử merge, đừng ghi đè bản model gốc duy nhất.
Giữ base riêng còn có lợi: một base đi được với nhiều adapter cho nhiều nhiệm vụ; mỗi adapter chỉ là vài ma trận nhỏ, và đổi adapter là đổi nhiệm vụ.
Hỏi nhanh
Đội có một base và hai adapter học riêng trên base ấy: X để phân loại chủ đề đề bài, Y để gắn nhãn độ khó. Cần phục vụ cả hai nhiệm vụ. Làm thế nào cho đúng?
Mỗi adapter là phần sửa cho riêng nhiệm vụ của nó, tính trên . Merge cả hai vào một ma trận thì nhiệm vụ nào cũng nhận thêm phần sửa của nhiệm vụ kia. Cộng từng ma trận còn tệ hơn: sinh thêm hai số hạng chéo không thuộc nhiệm vụ nào. Adapter tốt cho nhiệm vụ này không có lý do gì để tốt cho nhiệm vụ kia, và hai nhiệm vụ còn cần head khác nhau. Nếu cần mỗi nhiệm vụ một tệp, hãy merge từng adapter vào một bản sao riêng của base.
Chèn LoRA vào một model Transformer
Trong model thật, LoRA không thay cả model mà bọc một số lớp Linear được chọn theo tên. DistilBERT có 6 block Transformer; mỗi block có bốn Linear trong attention là q_lin, k_lin, v_lin, out_lin (query, key, value, output; bài 3.9), cùng hai Linear của MLP. Bài báo LoRA chủ yếu chèn adapter vào query và value; script của chương cũng làm vậy. Head phân loại pre_classifier và classifier không có trong checkpoint gốc, được khởi tạo mới, nên phải học cùng adapter.
Code dưới dựng DistilBERT từ cấu hình mặc định của transformers với trọng số ngẫu nhiên. Kiến trúc và tên module giống checkpoint thật, nên đếm tham số và kiểm việc chèn adapter được mà không cần tải gì. Lớp LoRALinear là TinyLoRA viết lại để bọc một nn.Linear có sẵn, giữ cả bias gốc. Ta đóng băng mọi tham số cũ, bọc mỗi q_lin, v_lin bằng LoRA rank 4 với , rồi mở lại head.
from transformers import DistilBertConfig, DistilBertForSequenceClassification
class LoRALinear(nn.Module):
def __init__(self, base, rank, alpha):
super().__init__()
self.base = base # nn.Linear gốc, kể cả bias
self.A = nn.Parameter(torch.randn(rank, base.in_features) * 0.01)
self.B = nn.Parameter(torch.zeros(base.out_features, rank))
self.scale = alpha / rank
def forward(self, x):
return self.base(x) + self.scale * (x @ self.A.T) @ self.B.T
torch.manual_seed(4)
model = DistilBertForSequenceClassification(DistilBertConfig(num_labels=2)).eval()
token_ids = torch.randint(0, model.config.vocab_size, (2, 16))
with torch.no_grad():
logits_before = model(input_ids=token_ids).logits
model.requires_grad_(False)
targets = [name for name, module in model.named_modules()
if name.endswith(("q_lin", "v_lin")) and isinstance(module, nn.Linear)]
for name in targets:
parent_name, child_name = name.rsplit(".", 1)
parent = model.get_submodule(parent_name)
base = getattr(parent, child_name)
setattr(parent, child_name, LoRALinear(base, rank=4, alpha=8))
model.pre_classifier.requires_grad_(True)
model.classifier.requires_grad_(True)
with torch.no_grad():
logits_after = model(input_ids=token_ids).logits
adapter_count = sum(p.numel() for n, p in model.named_parameters() if n.endswith((".A", ".B")))
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print("module duoc chen:", len(targets))
print("adapter:", adapter_count, "head:", trainable - adapter_count)
print("trainable:", trainable, "/", total, "=", f"{trainable / total:.2%}")
print("logits giu nguyen:", torch.allclose(logits_before, logits_after))
module duoc chen: 12
adapter: 73728 head: 592130
trainable: 665858 / 67028738 = 0.99%
logits giu nguyen: True
12 module đúng bằng 6 block nhân 2 lớp, và 73 728 tham số adapter khớp phép tính tay . Head gồm pre_classifier ( cộng bias) và classifier ( cộng bias), tổng 592 130 tham số, gấp tám lần adapter. Phần được học thật vì vậy là 665 858 trên khoảng 67 triệu, chừng 1%; nếu chỉ đếm A, B thì con số là 0.11%.
Logits giữ nguyên vì mọi bằng 0 và model ở chế độ eval (dropout tắt), nên đây là phép kiểm rằng việc bọc không làm hỏng forward.
Hỏi nhanh
Một báo cáo ghi: “LoRA rank 4 trên DistilBERT chỉ học 0.11% tham số.” Với cấu hình như code trên, head phân loại mới cũng được học. Nhận định nào đúng?
0.11% là , chỉ đếm A và B. Checkpoint DistilBERT gốc không có head cho nhiệm vụ mới, nên pre_classifier và classifier được khởi tạo và học, thêm 592 130 tham số. classifier chỉ là lớp cuối, còn pre_classifier cỡ mới là phần lớn. Tham số đóng băng có thể nằm trên đường backward, nhưng không có gradient được lưu lại và không có trạng thái optimizer.
Script của chương làm đúng các bước trên với checkpoint DistilBERT thật, dùng thư viện PEFT thay cho lớp tự viết. Script text_experiment.py:
- kiểm hash từng tệp model, ghi model ID và revision vào kết quả;
- kiểm rằng tên
q_lin,v_lintồn tại trước khi chèn, đặtlora_alphabằng vàlora_dropoutbằng 0.05, lưu head mới quamodules_to_save; - chạy bốn mode trên cùng tệp CSV train/validation, mỗi mode một tiến trình:
features(trích embedding rồi học logistic regression),head(chỉ học head),fullvàlora; - ghi
RESULT.jsonvới số tham số học, macro-F1 validation, thời gian chạy và, khi chạy trên CUDA, đỉnh bộ nhớ.
Máy thiếu PEFT thì mode lora dừng trước khi nạp model. Đọc kết quả bằng cách đặt bốn dòng cạnh nhau: LoRA đáng giữ khi vượt head-only rõ rệt và gần full về macro-F1, trong khi thời gian và bộ nhớ thấp hơn.
Một so sánh dùng được cần ghi rõ batch size, độ dài token, precision, optimizer, số bước và thiết bị. Đếm riêng số tham số học không thay được đo peak memory. Với CUDA, đo thời gian phải đồng bộ trước/sau vùng tính; nếu không, đồng hồ CPU có thể chỉ đo thời gian xếp việc vào hàng đợi.
Hỏi nhanh
LoRA làm số tham số học của một lớp chỉ còn . Đỉnh bộ nhớ GPU khi train có giảm còn không?
Chỉ gradient và trạng thái optimizer tỉ lệ với số tham số học. vẫn nằm trong bộ nhớ để forward, activation vẫn phải giữ để backward đi qua các lớp tới adapter, cộng thêm vùng làm việc của thư viện; mức giảm thật phải đo mới biết. LoRA thường tốn ít bộ nhớ hơn học toàn bộ, vì bỏ được gradient và trạng thái Adam của , chỉ là không giảm đúng theo tỉ lệ tham số. Rank có là lũy thừa của 2 hay không thì chẳng liên quan.
Khi đi thi
- Chạy một batch: in số tham số học, so với tính tay; trọng số gốc không được có gradient.
- Giữ baseline head-only; LoRA chỉ đáng giữ khi thắng nó trên metric của đề.
- Bắt đầu với rank nhỏ như , chỉ tăng khi validation còn kém rõ rệt.
Tóm tắt
- LoRA học trong khi giữ cố định: tham số, hạng tối đa .
- Rank quá nhỏ thì sai số do các giá trị kỳ dị bị bỏ của phần sửa quyết định; rank chọn bằng validation.
- A ngẫu nhiên, B bằng 0: output ban đầu trùng model gốc nhưng vẫn có đường học.
- Trong model thật, adapter được chèn theo tên module; head mới có thể chiếm phần lớn số tham số học.
- LoRA tiết kiệm gradient và trạng thái optimizer, không tiết kiệm đúng theo tỉ lệ tham số; phải đo bộ nhớ thật.
Kiểm tra cuối bài
Câu 1
LoRA cơ bản: đã freeze, còn và trainable và nằm trong optimizer. Sau 100 bước train, so với lúc khởi tạo, tham số nào đã đổi giá trị?
Freeze chỉ tắt gradient của , còn và vẫn học. Gradient của chỉ bằng 0 ở bước đầu, khi còn bằng 0; từ bước sau, nhận gradient. Tham gia forward không có nghĩa là được cập nhật: tham số phải có requires_grad và phải nằm trong optimizer.
Câu 2
Một lớp Linear , tức có shape . Một bạn đặt LoRA “cho chắc”. Nhận định nào đúng?
Ma trận có hạng không quá , nên 14 hướng thêm của không biểu diễn được gì mới. Adapter có tham số, gần gấp đôi của học toàn bộ. LoRA chỉ tiết kiệm khi , ở lớp này là khi .
Câu 3
Đội cần thích nghi một model ngôn ngữ lớn cho bài thi, chỉ với một GPU. Một bạn đề xuất LoRA, và lập luận thêm: “Model nộp sẽ dự đoán nhanh hơn, vì chỉ có vài trăm nghìn tham số.” Nhận định nào đúng?
LoRA giảm số tham số cần gradient và trạng thái optimizer, nên fine-tune vừa GPU hơn và adapter lưu gọn. Lúc dự đoán, input vẫn đi qua toàn bộ . Để nhánh adapter riêng thì tốn thêm một chút phép tính, còn merge xong thì chi phí bằng đúng base. Kích thước của base quyết định bộ nhớ khi suy luận, và LoRA không sinh ra dữ liệu.
Câu 4
Một biến thể khởi tạo ngược lại: , ngẫu nhiên. Ở bước đầu, điều gì đúng?
vì , nên output vẫn là . chứa nên bằng 0, còn khác 0 vì ngẫu nhiên. Vai trò hai ma trận đổi chỗ cho nhau, và nhánh vẫn học được. Điều cần chỉ là một ma trận bằng 0 để giữ nguyên output gốc, ma trận kia khác 0 để gradient có đường đi.
Câu 5
Sau khi train, của một lớp với . Nhận định nào đúng về và ?
Hạng của không vượt quá , nên phương trình có không gian nghiệm ít nhất chiều. Như vậy có trị riêng 0, và ma trận có trị riêng 0 thì suy biến, dù , được khởi tạo thế nào. Cộng thêm thì khác. Chẳng hạn với , trị riêng của là với là trị riêng của , phần lớn bằng 1, nên tổng thường vẫn khả nghịch. Phần sửa suy biến không có nghĩa là tổng cũng suy biến.
Câu 6
Vì sao một adapter hạng thấp thường đủ để thích nghi model pretrained cho một nhiệm vụ mới?
LoRA đặt cược rằng cần cho nhiệm vụ mới dồn năng lượng vào ít hướng, giống ý tưởng phân tích ma trận trong hệ gợi ý: quan hệ giữa người dùng và sản phẩm được mô tả trong một không gian ít chiều. Đó là một giả định, được kiểm bằng validation khi đổi rank. thường có hạng đầy đủ, và LoRA không đòi phải hạng thấp. không gom output thành cụm: mỗi output nhận một tổ hợp riêng của hướng. Rank thấp là để tiết kiệm tham số, không phải để tính tương quan.
Phỏng theoCMU 10-601 · Exam 3 Practice · Q7.3
Câu 7
có shape , có shape , có shape , , . Ma trận merge dùng để suy luận, không có dropout, là gì?
Scale là . có shape , khớp với . có shape , không cộng được. Quên scale hoặc lấy nhầm thì output sau khi merge lệch với output khi chạy nhánh riêng, và phép kiểm allclose như trong bài sẽ bắt được ngay.
Câu 8
Có tệp adapter đã train, nhưng chưa có base model. Muốn tái tạo đúng dự đoán, cần thêm gì?
Adapter chỉ chứa phần sửa , , cộng head nếu được lưu kèm, và phần sửa ấy tính trên đúng kia. Một base khác revision có thể cùng shape nhưng khác trọng số: merge vẫn chạy, không báo lỗi, chỉ có output là sai. Tokenizer cùng kích thước vẫn có thể đổi token theo cách khác. Đổi tên tệp không tạo ra được .
Câu 9
Đội đã full fine-tune một lớp , và có . Muốn thay bằng một adapter rank 8 để lưu gọn, sao cho nhỏ nhất. Làm thế nào?
Theo định lý Eckart-Young, xấp xỉ hạng tốt nhất theo chuẩn Frobenius là SVD cắt cụt (bài 2.9): , , và sai số bình phương bằng tổng bình phương các giá trị kỳ dị bị bỏ. Giữ 8 hàng cũng cho hạng không quá 8, nhưng xóa trắng mọi hàng khác. Tích của 8 cột đầu với 8 hàng đầu không liên quan gì tới . Giữ các giá trị kỳ dị nhỏ nhất là bỏ đúng phần năng lượng lớn nhất. Adapter trích ra như vậy tốt đến đâu thì còn tùy phổ của , giống sàn MSE của rank 1 trong bài.
Phỏng theoUSA-NA-AIO 2026 · Vòng 1 · Part 5.14
Câu 10
đã freeze. Code train adapter đặt cả phép tính W0 x + scale * B(A x) trong torch.no_grad(). Sửa thế nào để giữ được gradient cho việc học , ?
no_grad khiến autograd không ghi lại phép tính của nhánh adapter, nên backward không có đường đi về , . Bật requires_grad cho output sau khối chỉ tạo ra một tensor lá mới, không phục hồi được đồ thị đã bị bỏ. Optimizer và scale đều không tạo ra gradient. Freeze chỉ cần đặt requires_grad=False cho , không đòi phải tắt đồ thị của , .
Đọc thêm
- Hu và cộng sự - LoRA: Low-Rank Adaptation of Large Language Models: đọc công thức nhánh sửa và giới hạn của các kết quả thực nghiệm trong paper.
- PEFT - LoRA guide: kiểm khởi tạo A/B, scale, target modules và các biến thể.
- PEFT - adapter checkpoint format: hiểu vì sao adapter cần model nguồn cùng cấu hình.
- PyTorch - autograd mechanics: hiểu gradient vẫn cần đi qua trọng số bị freeze trong một số đồ thị.
Tải gói recipe của chương - gồm các module phụ và hướng dẫn chuẩn bị asset.