ژرفا — خودآموز یادگیری عمیق و مهندسی هوش مصنوعی (متوسط)

فصل ۱ از ۷

پیشرفت ترم
۰٪

ترم ۶ · تخصصی‌شدن و مسیرِ بعدی

`fine-tuning` کارآمد: `LoRA`

فصل ۱پیش‌نمایش رایگان
۱۳ دقیقه مطالعه فصل ۱

در این فصل چه یاد می‌گیری#

در ترمِ ۳ و ۴ دو گزینه داشتی: 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 هر لایه فقط یک جهت برای تغییر دارد؛ ولی چون همهٔ لایه‌ها آن یک جهت را دارند، مدل می‌تواند تغییرِ کوچکی را در کلِ عمقِ شبکه توزیع کند.

پس دو ابرپارامترِ LoRArank و تعدادِ لایه — با هم معنا دارند، نه جدا. و مثلِ هر ابرپارامترِ دیگری در این دوره، جوابش از جدول درمی‌آید نه از توصیهٔ کسی.

چک کن: اگر دقتِ 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 مسئلهٔ آموزش را ارزان کرد. مسئلهٔ اجرا هنوز سرِ جایش است: ۴۴۸ مگابایت فقط برای این مدلِ کوچک. فصلِ بعد وزن‌ها را از ۳۲ بیت به ۸ و ۴ بیت می‌برد، حافظه را چند برابر کم می‌کند، و اندازه می‌گیرد که این فشرده‌سازی روی فارسی گران‌تر تمام می‌شود یا نه.

به آخر این فصل رسیدی!

اگر ساختی و جواب داد، این دکمه مال توست.