در این فصل چه یاد میگیری#
در ترمِ ۳ و ۴ دو گزینه داشتی: backbone را قفل کن و فقط یک سر آموزش بده، یا همهچیز را باز کن. اولی ارزان و ضعیف، دومی گران و قوی.
LoRA گزینهٔ سوم است و این فصل از صفر میسازدش — بیست خط کد. و نتیجهای میگیریم که اولین بار باورکردنی نیست: با ۱۹٬۹۷۲ پارامترِ آموختنی — یعنی ۰٫۰۲ درصدِ مدل — دقیقاً همان دقتی را میگیریم که با آموزشِ هر ۱۱۷ میلیون پارامتر. و فایلی که برای هر کارِ تازه ذخیره میکنی، بهجای ۴۴۹ مگابایت ۹۶ کیلوبایت است.

آخر این فصل میتوانی:
LoRAرا خودت پیاده کنی و بگویی چراBرا با صفر مقداردهی میکنیم- پارامترِ آموختنی را بشماری و سه روش را با عدد مقایسه کنی
rankو تعدادِ لایه را انتخاب کنی- بگویی کِی
fine-tuneمیارزد و کِیpromptکافی است
قبل از شروع#
از ترمِ ۴ فصل ۸: بار کردنِ AutoModel، سرِ طبقهبندی، و آن جدولِ مهم — مدلِ آماده با ۱۶ نمونه ۰٫۶۵ گرفت و TF-IDF ۰٫۳۹.
از ترمِ ۳ فصل ۵: feature extraction در برابرِ fine-tuning و جدولِ «کِی کدام».
دادهٔ این ترم همان دادهٔ ترمِ ۴ است — مجموعهٔ «موضوع». عمداً، تا عددها با هم مقایسهشدنی بمانند.
💡 نکته: زمانِ تخمینی روی CPU: سه تا پنج دقیقه. سنگینترین بخشش
fine-tuningِ کاملِ ۱۱۷ میلیون پارامتر است — که خودش بخشی از درس است.
📓 نوتبوک: نوتبوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و بهترتیب داخلش هست.
۱. مدلی که میخواهیم تکانش بدهیم#
import time
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from transformers import AutoModel, AutoTokenizer
NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
tokenizer = AutoTokenizer.from_pretrained(NAME)
def encode(texts):
batch = tokenizer(texts, padding=True, truncation=True, max_length=32, return_tensors="pt")
return batch["input_ids"], batch["attention_mask"]
ids_tr, mask_tr = encode(train_texts)
ids_va, mask_va = encode(val_texts)
y_tr = torch.tensor(train_labels)
y_va = torch.tensor(val_labels)
loss_fn = nn.CrossEntropyLoss()
base = AutoModel.from_pretrained(NAME)
print("پارامترهای مدلِ آماده:", sum(p.numel() for p in base.parameters()))
print("لایههای ترنسفورمر :", len(base.encoder.layer))
پارامترهای مدلِ آماده: 117653760
لایههای ترنسفورمر : 12
۱۱۷ میلیون پارامتر، ۱۲ بلوکِ ترنسفورمر — همان ساختاری که در ترمِ ۴ فصلِ ۵ تکهتکه ساختی.
و مسئلهٔ fine-tuningِ کامل سهتاست، نه یکی:
- حافظه. برای هر پارامتر باید گرادیان نگه داری، و
Adamدو حالتِ دیگر هم برای هر پارامتر نگه میدارد. یعنی حدودِ چهار برابرِ خودِ مدل. - فایل. برای هر کارِ تازه یک نسخهٔ کاملِ مدل ذخیره میکنی. ده کار یعنی ده نسخه.
- فراموشی. با دادهٔ کم، مدلی که همهچیزش باز است چیزی را که قبلاً بلد بود خراب میکند.
۲. ایدهٔ LoRA، در بیست خط#
فرضِ اصلی این است: تغییری که یک کارِ تازه در وزنها لازم دارد، «کمرتبه» است — یعنی بهجای یک ماتریسِ کاملِ ۳۸۴×۳۸۴، حاصلضربِ دو ماتریسِ باریک کافی است.
پس وزنِ اصلی W را دست نمیزنیم و کنارش مینویسیم: W·x + (B·A·x) × scale — که A شکلِ r×384 دارد و B شکلِ 384×r، و r عددِ خیلی کوچکی است مثلِ ۴.
class LoRALinear(nn.Module):
"""لایهٔ خطیِ یخزده، بهعلاوهٔ یک مسیرِ کمرتبهٔ آموختنی."""
def __init__(self, base_linear, rank=4, alpha=8):
super().__init__()
self.base = base_linear
for p in self.base.parameters():
p.requires_grad = False
self.a = nn.Parameter(torch.randn(rank, base_linear.in_features) * 0.01)
self.b = nn.Parameter(torch.zeros(base_linear.out_features, rank))
self.scale = alpha / rank
def forward(self, x):
return self.base(x) + (x @ self.a.T @ self.b.T) * self.scale
demo = LoRALinear(nn.Linear(384, 384))
x = torch.ones(2, 384)
print("در آغاز، خروجی دقیقاً همان لایهٔ اصلی است:",
torch.allclose(demo(x), demo.base(x)))
print("پارامترهای لایهٔ اصلی:", demo.base.weight.numel() + demo.base.bias.numel())
print("پارامترهای LoRA :", demo.a.numel() + demo.b.numel())
در آغاز، خروجی دقیقاً همان لایهٔ اصلی است: True
پارامترهای لایهٔ اصلی: 147840
پارامترهای LoRA : 3072
سه تصمیم در این کلاس هست و هر سه دلیلِ دقیق دارند:
۱) b با صفر شروع میشود، a با تصادفِ کوچک. پس B·A در آغاز صفرِ محض است و مدل دقیقاً همان مدلِ آماده است — همان True بالا. اگر هر دو را تصادفی بگذاری، از همان قدمِ اول مدلِ آموزشدیده را به هم ریختهای و مزیتِ شروع از یک مدلِ خوب را دور انداختهای.
۲) چرا هر دو صفر نه؟ چون آنوقت گرادیانِ هر دو صفر میماند و هیچوقت از جایش تکان نمیخورد. یکی باید تصادفی باشد تا مسیرِ گرادیان باز شود.
۳) scale = alpha / rank — تا وقتی rank را عوض میکنی، بزرگیِ اثر تقریباً ثابت بماند و مجبور نشوی lr را از نو تنظیم کنی.
و آن نسبتِ ۳۰۷۲ به ۱۴۷۸۴۰ را نگه دار: حدودِ دو درصد.
🌱 ریشهاش کجاست: «کمرتبه» یعنی چه، در ریشه ترمِ ۷ فصل ۵ از دلِ خودِ ضربِ ماتریس درمیآید: حاصلضربِ یک ماتریسِ
۳۸۴×۴در یک۴×۳۸۴باز هم۳۸۴×۳۸۴است، ولی نمیتواند هر ماتریسِ ۳۸۴×۳۸۴ای باشد — فقط آنهایی که «راهِ باریک» از وسطشان رد میشود.LoRAروی همین محدودیت شرط میبندد.
۳. سه مدل، سه بودجه#
class Classifier(nn.Module):
def __init__(self, backbone, classes=4):
super().__init__()
self.backbone = backbone
self.head = nn.Linear(384, classes)
def forward(self, ids, mask):
hidden = self.backbone(input_ids=ids, attention_mask=mask).last_hidden_state
m = mask.unsqueeze(-1).float()
return self.head((hidden * m).sum(1) / m.sum(1))
def fresh_backbone():
torch.manual_seed(0)
return AutoModel.from_pretrained(NAME)
def freeze_all(model):
for p in model.backbone.parameters():
p.requires_grad = False
def add_lora(model, layers=4, rank=4):
for layer in model.backbone.encoder.layer[-layers:]:
layer.attention.self.query = LoRALinear(layer.attention.self.query, rank)
layer.attention.self.value = LoRALinear(layer.attention.self.value, rank)
def trainable(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
torch.manual_seed(0)
head_only = Classifier(fresh_backbone())
freeze_all(head_only)
torch.manual_seed(0)
with_lora = Classifier(fresh_backbone())
freeze_all(with_lora)
add_lora(with_lora)
torch.manual_seed(0)
full = Classifier(fresh_backbone())
for name, model in [("فقط سر", head_only), ("LoRA", with_lora), ("کاملاً باز", full)]:
print(f" {name:<10} {trainable(model):>10,} پارامترِ آموختنی")
فقط سر 1,540 پارامترِ آموختنی
LoRA 26,116 پارامترِ آموختنی
کاملاً باز 117,655,300 پارامترِ آموختنی
و به add_lora دقت کن: فقط query و value را عوض کردیم، آن هم فقط در چهار لایهٔ آخر.
چرا query و value و نه key؟ این انتخابِ رایج است و از آزمایش آمده نه از نظریه — در عمل همین دو بیشترین سود را میدهند. و چرا لایههای آخر؟ چون همانجاست که مدل از «ویژگیهای عمومیِ زبان» به «ویژگیهای این کارِ خاص» میرسد؛ همان چیزی که در ترمِ ۳ فصلِ ۵ برای تصویر دیدی.
هر دو انتخاب قابلِ آزمایشاند و بخشِ ۶ یکیشان را زیرِ سؤال میبرد.
۴. حالا آموزششان بده#
N = 100
def accuracy(model, ids, mask, y):
model.eval()
with torch.no_grad():
return float((model(ids, mask).argmax(1) == y).float().mean())
def train(model, epochs=12, lr=1e-3, batch=16, seed=0):
torch.manual_seed(seed)
model.backbone.eval() # dropout خاموش: با دادهٔ کم فقط نویز اضافه میکند
params = [p for p in model.parameters() if p.requires_grad]
opt = torch.optim.Adam(params, lr=lr)
loader = DataLoader(TensorDataset(ids_tr[:N], mask_tr[:N], y_tr[:N]),
batch_size=batch, shuffle=True)
started = time.perf_counter()
for _ in range(epochs):
for ids, mask, y in loader:
opt.zero_grad()
loss_fn(model(ids, mask), y).backward()
opt.step()
return time.perf_counter() - started
rows = []
for name, model, lr in [("فقط سر", head_only, 1e-2), ("LoRA", with_lora, 1e-3),
("کاملاً باز", full, 2e-5)]:
seconds = train(model, lr=lr)
rows.append((name, trainable(model), accuracy(model, ids_va, mask_va, y_va), seconds))
print(f" {name:<10} دقت {rows[-1][2]:.4f}")
فقط سر دقت 0.9651
LoRA دقت 0.9826
کاملاً باز دقت 1.0000
و به lrهای متفاوت دقت کن — این تصادفی نیست:
| روش | lr |
چرا |
|---|---|---|
| فقط سر | 1e-2 |
یک لایهٔ تصادفی است، باید سریع یاد بگیرد |
LoRA |
1e-3 |
وزنهای تازهاند، ولی روی یک مدلِ آموزشدیده سوارند |
| کاملاً باز | 2e-5 |
وزنهای خوب را نباید تکان داد |
🔧 اگر کار نکرد: اگر
fine-tuningِ کامل دقتش خیلی پایین آمد یاlossعددِnanشد، تقریباً همیشهlrبزرگ است. قاعدهٔ سرانگشتی: هرچه پارامترهای آموزشدیدهتری را باز میکنی،lrباید کوچکتر باشد — دو تا سه مرتبهٔ بزرگی کوچکتر از یک لایهٔ تازه.
۵. جدولِ اصلی#
print(f" {'روش':<10} {'پارامترِ آموختنی':>16} {'دقت':>8} سهم از کل")
for name, params, acc, seconds in rows:
share = params / trainable(full)
print(f" {name:<10} {params:>16,} {acc:>8.4f} {share:>7.4%}")
روش پارامترِ آموختنی دقت سهم از کل
فقط سر 1,540 0.9651 0.0013%
LoRA 26,116 0.9826 0.0222%
کاملاً باز 117,655,300 1.0000 100.0000%
📏 اندازه بگیر:
LoRAبا دو صدمِ درصدِ پارامترها، دقیقاً نصفِ فاصلهٔ «فقط سر» تا «کاملاً باز» را پر کرد — از ۰٫۹۶۵۱ به ۰٫۹۸۲۶، در حالی که سقف ۱٫۰ بود؛ یعنی ۰٫۰۱۷۵ از ۰٫۰۳۴۹. و بخشِ ۶ نشان میدهد که با یک انتخابِ بهترِrankو لایه، همان فاصله کاملاً پر میشود. این شکلِ همیشگیِ ماجراست:LoRAمعمولاً بهfine-tuningِ کامل خیلی نزدیک میشود، نه اینکه از آن بهتر باشد. هر کس ادعای بهتر بودن کرد، از او جدولِ خودش را بخواه.
۶. rank کوچکتر، لایههای بیشتر#
torch.manual_seed(0)
tiny = Classifier(fresh_backbone())
freeze_all(tiny)
add_lora(tiny, layers=12, rank=1)
train(tiny, lr=2e-3)
print("LoRA با rank=1 روی هر ۱۲ لایه:", trainable(tiny), "پارامتر ·",
f"دقت {accuracy(tiny, ids_va, mask_va, y_va):.4f}")
LoRA با rank=1 روی هر ۱۲ لایه: 19972 پارامتر · دقت 1.0000
نوزده هزار و نهصد و هفتاد و دو پارامتر — کمتر از حالتِ قبل — و دقتِ ۱٫۰: دقیقاً همان fine-tuningِ کامل.
و این جواب یک سؤالِ طراحی را عوض میکند: پخش کردنِ بودجه روی همهٔ لایهها بهتر از تمرکزش روی چند لایهٔ آخر بود. با rank=1 هر لایه فقط یک جهت برای تغییر دارد؛ ولی چون همهٔ لایهها آن یک جهت را دارند، مدل میتواند تغییرِ کوچکی را در کلِ عمقِ شبکه توزیع کند.
پس دو ابرپارامترِ LoRA — rank و تعدادِ لایه — با هم معنا دارند، نه جدا. و مثلِ هر ابرپارامترِ دیگری در این دوره، جوابش از جدول درمیآید نه از توصیهٔ کسی.
✅ چک کن: اگر دقتِ
rank=1پیشِ تو خیلی پایینتر آمد، احتمالاًlrرا عوض نکردهای. با پارامترِ کمتر باید بزرگتر باشد (اینجا2e-3در برابرِ1e-3)؛ ظرفیتِ کمتر یعنی هر قدم باید مؤثرتر باشد.
۷. و بزرگترین مزیتِ عملی: فایل#
saved = {k: v for k, v in with_lora.state_dict().items() if "a" == k.split(".")[-1] or "b" == k.split(".")[-1]}
size = sum(v.numel() * v.element_size() for v in saved.values())
whole = sum(p.numel() * p.element_size() for p in full.parameters())
print("فایلِ وزنِ LoRA :", round(size / 1024, 1), "کیلوبایت")
print("فایلِ مدلِ کامل :", round(whole / 1024 / 1024, 1), "مگابایت")
print("نسبت :", round(whole / size))
فایلِ وزنِ LoRA : 96.0 کیلوبایت
فایلِ مدلِ کامل : 448.8 مگابایت
نسبت : 4787
نود و شش کیلوبایت در برابرِ چهارصد و چهلوهشت مگابایت.
و این تفاوت، معماریِ محصولت را عوض میکند:
- یک مدلِ پایه در حافظه، دهها
LoRAروی دیسک. برای هر مشتری یا هر کار یکی، و در لحظه سوارش میکنی. - آزمایش ارزان میشود. ده نسخه نگه داشتن یعنی یک مگابایت، نه چهار گیگابایت.
- و میشود ادغامش کرد:
W + B·Aرا یک بار حساب کنی و در وزنِ اصلی بنویسی، آنوقت زمانِ اجرا هیچ سربارِ اضافهای ندارد.
QLoRA هم که زیاد میشنوی، همین است بهعلاوهٔ فصلِ بعد: مدلِ پایه را ۴بیتی نگه میداری و فقط وزنهای LoRA را با دقتِ کامل آموزش میدهی. یعنی مدلی که در حافظه جا نمیشد، حالا هم جا میشود هم fine-tune میشود.
۸. کِی اصلاً fine-tune نکن#
قبل از اینکه سراغِ هر کدام از اینها بروی، این جدول را نگاه کن:
| وضعیت | کارِ درست |
|---|---|
| مدل کار را بلد است، فقط قالبِ خروجی را میخواهی | prompt + schema (ترمِ ۵ فصلِ ۲) |
| مدل دانشِ لازم را ندارد ولی در سندهایت هست | RAG (ترمِ ۵ فصلِ ۴) |
| مدل باید حساب کند یا به سامانهای وصل شود | ابزار (ترمِ ۵ فصلِ ۳) |
| کار با ده مثال در پرامپت خوب میشود | few-shot — هیچ آموزشی لازم نیست |
| لحن یا قالبِ خاصی میخواهی که با پرامپت نمیآید | LoRA با چند صد نمونهٔ باکیفیت |
| دامنهٔ کاملاً تخصصی و دادهٔ زیاد داری | fine-tuning کامل |
📏 اندازه بگیر: دادهٔ
fine-tuningبیشتر از حجمش، به کیفیتش وابسته است. پانصد نمونهٔ تمیز و یکدست از پنج هزار نمونهٔ نامرتب بهتر عمل میکند — چون مدل الگوی غالب را میگیرد، و اگر دادهات ناهماهنگ باشد، ناهماهنگی را یاد میگیرد. قبل از آموزش، صد نمونهات را با چشمِ خودت بخوان.
واژههای تازهٔ این فصل#
| کلمه | تلفظ به حروف فارسی | یعنی چه |
|---|---|---|
| LoRA | لورا | آموزشِ یک مسیرِ کمرتبهٔ کنارِ وزنِ یخزده |
| rank | رنک | پهنای همان مسیرِ باریک |
| adapter | آداپتور | وزنهای کوچکی که کنارِ مدلِ پایه سوار میشوند |
| QLoRA | کیو-لورا | LoRA روی مدلِ پایهٔ کوانتیزهشده |
| merge | مرج | نوشتنِ B·A در وزنِ اصلی تا سربارِ اجرا صفر شود |
| catastrophic forgetting | کتستروفیک فورگتینگ | فراموش شدنِ دانشِ قبلی هنگامِ آموزش روی دادهٔ کم |
🤖 از دستیارت بپرس: «چرا
LoRAرا معمولاً رویqueryوvalueمیگذارند و نه روی همهٔ ماتریسها؟» بعد خودت امتحانش کن:add_loraرا طوری عوض کن کهkeyرا هم بگیرد، و جدولِ بخشِ ۵ را دوباره بساز. آیا پارامترِ بیشتر دقتِ بیشتر داد؟
تمرینها
اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.
در فصل بعد#
LoRA مسئلهٔ آموزش را ارزان کرد. مسئلهٔ اجرا هنوز سرِ جایش است: ۴۴۸ مگابایت فقط برای این مدلِ کوچک. فصلِ بعد وزنها را از ۳۲ بیت به ۸ و ۴ بیت میبرد، حافظه را چند برابر کم میکند، و اندازه میگیرد که این فشردهسازی روی فارسی گرانتر تمام میشود یا نه.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.