LoRA — متخصص کردن بدون تغییر کل مدل

قسمت ۳ ۲۰ دقیقه

مشکل Full Fine-tuning

فرض کن می‌خوای یه مدل ۷ میلیارد پارامتری مثل LLaMA 3.1 8B رو Fine-tune کنی. اگه بخوای همه پارامترها رو آموزش بدی (Full Fine-tuning)، به چه چیزی نیاز داری؟

  • حافظه مدل: ۷ میلیارد پارامتر × ۲ بایت (FP16) = ۱۴ گیگابایت
  • گرادیان‌ها: ۱۴ گیگابایت دیگه
  • Optimizer states (AdamW): ۲۸ گیگابایت (دو برابر وزن‌ها)
  • Activations: بسته به batch size، ۱۰-۲۰ گیگابایت
  • جمع: حداقل ۶۰-۷۰ گیگابایت VRAM!

یعنی حتی با یه A100 80GB هم تنگه. حالا اگه بخوای مدل ۷۰ میلیاردی رو Fine-tune کنی چی؟ چند صد گیگابایت VRAM لازمه!

مشکل اصلی: Full Fine-tuning گرونه، کنده و برای اکثر ما غیرعملیه. باید یه راه بهتر پیدا کنیم.

LoRA چیه؟

LoRA مخفف Low-Rank Adaptation هست. ایده‌اش ساده ولی نابغانه‌ست:

به جای تغییر همه وزن‌های مدل، فقط یه سری ماتریس کوچک (adapter) اضافه کن و فقط اون‌ها رو آموزش بده. وزن‌های اصلی مدل یخ بزنن (freeze) و تغییر نکنن.

تشبیه ساده

فرض کن یه کتاب ۵۰۰ صفحه‌ای داری. به جای اینکه کل کتاب رو بازنویسی کنی، یه دفترچه یادداشت کوچیک ۱۰ صفحه‌ای می‌نویسی که کنار کتاب اصلی استفاده بشه. کتاب اصلی دست‌نخورده می‌مونه و دفترچه فقط تغییرات و نکات اضافی رو داره.

ریاضیات LoRA — ساده شده

توی شبکه‌های عصبی، لایه‌های Linear یه ماتریس وزن W دارن. مثلاً یه ماتریس ۴۰۹۶×۴۰۹۶ (حدود ۱۶ میلیون پارامتر).

LoRA می‌گه: به جای تغییر W، یه تغییر کوچیک ΔW بهش اضافه کن. و این ΔW رو به صورت ضرب دو ماتریس کوچک‌تر بنویس:

# بدون LoRA:
# W_new = W_original + ΔW
# ΔW سایزش همون W هست: 4096 × 4096 = 16,777,216 پارامتر

# با LoRA:
# ΔW = A × B
# A: 4096 × r (مثلاً r=16)
# B: r × 4096
# تعداد پارامتر: 4096×16 + 16×4096 = 131,072
# فقط 0.78% پارامتر اصلی!

import torch
import torch.nn as nn

class LoRALayer(nn.Module):
    def __init__(self, in_features, out_features, rank=16, alpha=32):
        super().__init__()
        # وزن‌های اصلی — freeze شده
        self.linear = nn.Linear(in_features, out_features, bias=False)
        self.linear.weight.requires_grad = False  # یخ‌زده!
        
        # ماتریس‌های LoRA — فقط اینا آموزش می‌بینن
        self.lora_A = nn.Parameter(torch.randn(in_features, rank) * 0.01)
        self.lora_B = nn.Parameter(torch.zeros(rank, out_features))
        
        self.scaling = alpha / rank
    
    def forward(self, x):
        # خروجی اصلی + تغییر LoRA
        original = self.linear(x)
        lora_output = (x @ self.lora_A @ self.lora_B) * self.scaling
        return original + lora_output

Low-Rank Decomposition چیه؟

وقتی یه ماتریس بزرگ رو به صورت ضرب دو ماتریس کوچک‌تر تجزیه می‌کنی، بهش می‌گن Low-Rank Decomposition. عدد r (rank) تعیین می‌کنه ماتریس‌های کوچیک چقدر کوچیکن.

# مقایسه تعداد پارامترها
d = 4096  # ابعاد مدل

print("Full Fine-tuning:")
print(f"  پارامترها: {d * d:,} = {d * d / 1e6:.1f}M")

for r in [4, 8, 16, 32, 64]:
    lora_params = 2 * d * r
    ratio = lora_params / (d * d) * 100
    print(f"LoRA (rank={r}):")
    print(f"  پارامترها: {lora_params:,} ({ratio:.2f}% از اصلی)")

خروجی:

Full Fine-tuning:
  پارامترها: 16,777,216 = 16.8M
LoRA (rank=4):
  پارامترها: 32,768 (0.20% از اصلی)
LoRA (rank=8):
  پارامترها: 65,536 (0.39% از اصلی)
LoRA (rank=16):
  پارامترها: 131,072 (0.78% از اصلی)
LoRA (rank=32):
  پارامترها: 262,144 (1.56% از اصلی)
LoRA (rank=64):
  پارامترها: 524,288 (3.12% از اصلی)

پارامترهای کلیدی LoRA

Rank (r)

rank تعیین می‌کنه ماتریس‌های adapter چقدر بزرگن. rank بالاتر = ظرفیت یادگیری بیشتر ولی حافظه بیشتر.

  • r=8: برای task‌های ساده (مثل تغییر سبک)
  • r=16: مقدار پیش‌فرض خوب برای اکثر کارها
  • r=32-64: برای task‌های پیچیده (مثل یادگیری زبان جدید)
  • r=128+: نادره و معمولاً لازم نیست

Alpha (α)

alpha یه ضریب مقیاس‌دهیه. خروجی LoRA در alpha/rank ضرب می‌شه. alpha بالاتر یعنی تأثیر LoRA بیشتر.

# رابطه alpha و rank
# scaling = alpha / rank

# مثال ۱: rank=16, alpha=16 → scaling=1.0
# مثال ۲: rank=16, alpha=32 → scaling=2.0 (تأثیر بیشتر LoRA)
# مثال ۳: rank=16, alpha=8  → scaling=0.5 (تأثیر کمتر LoRA)

# قاعده کلی: alpha رو معمولاً ۲ برابر rank بذار
lora_config = {
    "r": 16,
    "lora_alpha": 32,  # 2 × rank
}

Target Modules

LoRA رو روی کدوم لایه‌ها اعمال کنی؟ توی مدل‌های Transformer، معمولاً روی لایه‌های attention:

from peft import LoraConfig

config = LoraConfig(
    r=16,
    lora_alpha=32,
    # کدوم لایه‌ها LoRA بخورن
    target_modules=[
        "q_proj",    # Query projection
        "k_proj",    # Key projection
        "v_proj",    # Value projection
        "o_proj",    # Output projection
        "gate_proj", # MLP gate
        "up_proj",   # MLP up
        "down_proj", # MLP down
    ],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)
نکته: هرچی لایه‌های بیشتری رو هدف بذاری، مدل بیشتر می‌تونه یاد بگیره ولی حافظه و زمان بیشتری مصرف می‌شه. شروع با q_proj و v_proj خوبه، بعد اگه لازم بود بقیه رو هم اضافه کن.

پیاده‌سازی عملی با PEFT

کتابخانه PEFT از Hugging Face، LoRA رو خیلی ساده کرده:

from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import get_peft_model, LoraConfig

# ۱. لود مدل پایه
model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.1-8B-Instruct",
    torch_dtype=torch.float16,
    device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")

# ۲. تنظیم LoRA
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

# ۳. اعمال LoRA روی مدل
model = get_peft_model(model, lora_config)

# ببین چند پارامتر آموزش می‌بینن
model.print_trainable_parameters()
# خروجی: trainable params: 13,631,488 || all params: 8,043,847,680
#          || trainable%: 0.1695%

یعنی فقط ۰.۱۷% پارامترها رو آموزش می‌دی! بقیه یخ‌زده‌ان.

مزایای LoRA

  • حافظه کمتر: ۷۰-۸۰ درصد کمتر از Full Fine-tuning
  • سرعت بیشتر: پارامتر کمتر = آموزش سریع‌تر
  • مدل اصلی دست‌نخورده: می‌تونی چند adapter مختلف داشته باشی
  • ذخیره‌سازی کم: adapter فقط چند ده مگابایته (نه چند گیگابایت)
  • قابل ترکیب: می‌تونی adapter‌های مختلف رو ترکیب یا عوض کنی

Merge کردن Adapter

بعد از آموزش، می‌تونی adapter LoRA رو با مدل اصلی ترکیب (merge) کنی. اینطوری یه مدل واحد می‌شه و موقع inference هیچ overhead اضافه‌ای نداری:

# بعد از آموزش — merge کردن adapter با مدل اصلی
merged_model = model.merge_and_unload()

# ذخیره مدل merge شده
merged_model.save_pretrained("./my-merged-model")
tokenizer.save_pretrained("./my-merged-model")

# یا فقط adapter رو ذخیره کن (خیلی کوچیک‌تره)
model.save_pretrained("./my-lora-adapter")
# این فقط چند ده مگابایته!

چند Adapter برای چند Task

# می‌تونی چند adapter مختلف داشته باشی
from peft import PeftModel

base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")

# برای ترجمه
translation_model = PeftModel.from_pretrained(base_model, "./adapter-translation")

# برای خلاصه‌سازی
summary_model = PeftModel.from_pretrained(base_model, "./adapter-summary")

# برای کدنویسی
code_model = PeftModel.from_pretrained(base_model, "./adapter-code")

# یه مدل پایه، سه رفتار مختلف!

LoRA در مقابل Full Fine-tuning

  • Full Fine-tuning مدل ۸B: ـ ۶۰+ گیگابایت VRAM، ساعت‌ها آموزش، فایل نهایی ۱۶ گیگابایت
  • LoRA مدل ۸B: ـ ۱۶-۲۰ گیگابایت VRAM، سریع‌تر، adapter فقط ۵۰-۱۰۰ مگابایت

تحقیقات نشون داده که LoRA در اکثر task‌ها عملکردی نزدیک به Full Fine-tuning داره. فقط در task‌های خیلی پیچیده ممکنه کمی ضعیف‌تر باشه — که معمولاً با افزایش rank قابل جبرانه.

نکات عملی

  • با rank=16 و alpha=32 شروع کن — برای اکثر کارها کافیه
  • اگه نتیجه خوب نبود، rank رو بالا ببر (۳۲ یا ۶۴)
  • lora_dropout رو بین ۰.۰ تا ۰.۱ بذار
  • target_modules رو اگه منابع اجازه می‌ده، همه لایه‌های linear رو شامل کن
  • learning rate رو برای LoRA کمی بالاتر از Full Fine-tuning بذار (مثلاً 2e-4)

جمع‌بندی

LoRA یه تکنیک فوق‌العاده‌ست که Fine-tuning رو از یه کار گرون و پیچیده، تبدیل به یه کار عملی و قابل دسترس کرده. با LoRA می‌تونی مدل‌های بزرگ رو روی GPU‌های معمولی Fine-tune کنی.

ولی حتی با LoRA، مدل‌های خیلی بزرگ (مثل ۷۰B) هنوز GPU زیادی می‌خوان. توی اپیزود بعدی، QLoRA رو بررسی می‌کنیم — که با ترکیب quantization و LoRA، حتی مدل‌های ۷۰ میلیاردی رو هم می‌شه با یه کارت گرافیک معمولی Fine-tune کرد.

نظرات

هنوز نظری ثبت نشده. اولین نفر باشید!

نظر خود را بنویسید