عنوان

چطور یک Foundation Model را برای مسئلهٔ خودمان آماده کنیم؟ از Full Fine-tuning تا روش‌های PEFT؛ با تمرکز بر Adapter و LoRA

در مقالهٔ قبلی به یک مدل «دنیا‌دیده» رسیدیم؛ مدلی که قبل از ملاقات با مسئلهٔ ما، روی حجم بزرگی از داده‌های متنوع آموزش دیده و الگوهای عمومی زیادی یاد گرفته است.

اما عمومی‌بودن همیشه کافی نیست.

مدلی مثل TimesFM ممکن است روند، فصل، نوسان و شوک را از تعداد زیادی سری زمانی یاد گرفته باشد، ولی دادهٔ ما احتمالاً ویژگی‌های خاص خودش را دارد. رفتار قیمت یک دارایی با مصرف برق یک شهر یکی نیست. حتی دو بازار مالی هم می‌توانند ساختار، نویز و رژیم‌های متفاوتی داشته باشند.

پس یک سؤال مهم شکل می‌گیرد:

چطور دانش عمومی مدل را حفظ کنیم، اما آن را با مسئلهٔ خودمان هم آشنا کنیم؟

ساده‌ترین جواب این است که مدل را دوباره آموزش بدهیم. ولی وقتی مدل صدها میلیون یا چند میلیارد پارامتر دارد، عبارت «دوباره آموزش بدهیم» دیگر آن‌قدر ساده نیست.

این مقاله دقیقاً دربارهٔ انتخاب میان همین راه‌هاست: از Full Fine-tuning که تقریباً همهٔ وزن‌ها را تغییر می‌دهد، تا روش‌های PEFT که فقط بخش کوچکی از مدل را آموزش می‌دهند. در میان روش‌های PEFT هم دو ایده را دقیق‌تر باز می‌کنیم: Adapter و LoRA.

اول مشخص کنیم Adaptation یعنی چه

مدل ازپیش‌آموزش‌دیده یک نقطهٔ شروع قدرتمند است. Adaptation یعنی این مدل عمومی را طوری با داده یا وظیفهٔ جدید هماهنگ کنیم که روی مسئلهٔ مشخص ما بهتر کار کند.

فرض کن یک مدل سری زمانی عمومی داریم و می‌خواهیم از آن برای پیش‌بینی فروش یک فروشگاه استفاده کنیم. مدل قبلاً چیزهایی دربارهٔ روندها و الگوهای تکرارشونده یاد گرفته، اما هنوز نمی‌داند:

  • تعطیلات برای این فروشگاه چه اثری دارد.
  • نوسان فروش این محصول چقدر است.
  • رفتار آخر هفته با روزهای کاری چه تفاوتی دارد.
  • کدام الگوهای عمومی برای این مسئله مفید و کدام‌ها گمراه‌کننده‌اند.

Adaptation قرار نیست همهٔ دانش مدل را پاک کند و از صفر شروع کند. قرار است دانش قبلی را به سمت مسئلهٔ جدید هل بدهد.

دانش عمومی مدل
+
دادهٔ مسئلهٔ ما
↓
مدل سازگارشده

این سازگاری می‌تواند بدون تغییر وزن‌ها اتفاق بیفتد، می‌تواند تمام وزن‌ها را درگیر کند یا فقط بخش کوچکی از پارامترها را تغییر بدهد.

نقشهٔ کلی روش‌ها

قبل از اینکه وارد جزئیات شویم، جای هر مفهوم را در درخت کلی ببینیم:

استفاده از Foundation Model برای مسئلهٔ جدید
│
├── بدون آموزش و بدون تغییر وزن‌ها
│   └── Zero-shot / In-context use
│
├── استفاده به‌عنوان Feature Extractor
│   └── Backbone ثابت + یک Head جدید
│
└── آموزش روی دادهٔ جدید
    │
    ├── Full Fine-tuning
    │   └── تغییر همه یا تقریباً همهٔ وزن‌ها
    │
    ├── Partial Fine-tuning
    │   └── بازکردن فقط چند لایهٔ موجود
    │
    └── PEFT
        ├── Bottleneck Adapter
        ├── LoRA
        ├── Prompt Tuning / Prefix Tuning
        ├── IA³
        └── BitFit و روش‌های دیگر

پس PEFT رقیب Fine-tuning به معنای خیلی کلی نیست. خودش خانواده‌ای از روش‌های Fine-tuning است که تعداد پارامترهای قابل‌آموزش را کوچک نگه می‌دارند.

وقتی می‌گوییم Full Fine-tuning، منظورمان حالتی است که تقریباً کل مدل آموزش می‌بیند. وقتی می‌گوییم PEFT، وزن‌های اصلی عمدتاً ثابت می‌مانند و فقط بخش کوچکی از پارامترها یاد می‌گیرند.

هنگام Fine-tuning دقیقاً چه اتفاقی می‌افتد؟

منطق یادگیری عوض نشده است. مدل روی دادهٔ ما Forward Pass انجام می‌دهد، پیش‌بینی می‌سازد، با جواب واقعی مقایسه می‌شود و Backpropagation گرادیان پارامترهای قابل‌آموزش را حساب می‌کند.

تفاوت روش‌ها در یک سؤال است:

کدام پارامترها اجازه دارند با این گرادیان تغییر کنند؟

در Full Fine-tuning جواب این است:

تقریباً همهٔ وزن‌های مدل

در Adapter Tuning جواب می‌شود:

وزن‌های Adapter و احتمالاً Head خروجی

و در LoRA:

ماتریس‌های کوچک A و B و احتمالاً Head خروجی

پارامتر ثابت هم در Forward Pass حضور دارد. خروجی تولید می‌کند و گرادیان باید از مسیرش عبور کند تا به بخش‌های قابل‌آموزش برسد؛ فقط خود آن پارامتر با optimizer به‌روزرسانی نمی‌شود.

Full Fine-tuning؛ وقتی کل مدل اجازهٔ تغییر دارد

در Full Fine-tuning مدل از وزن‌های پیش‌آموزش‌دیده شروع می‌کند، اما تقریباً تمام آن وزن‌ها روی دادهٔ جدید قابل‌تغییرند.

اگر وزن‌های مدل را با θ و دادهٔ مسئلهٔ خودمان را با D نشان بدهیم، هدف این است:

هدف Full Fine-tuning

initial_parameters θpretrained
target_dataset Dtarget
θadapted argminθ L(Dtarget; θ)

یعنی آموزش از یک مقدار رندوم شروع نمی‌شود. نقطهٔ شروع همان دانش قبلی است، اما optimizer اجازه دارد تمام فضای وزن‌ها را جست‌وجو کند.

چرا Full Fine-tuning جذاب است؟

بزرگ‌ترین مزیتش آزادی است. مدل مجبور نیست تغییر مسئلهٔ جدید را در چند پارامتر کوچک جا بدهد. اگر دادهٔ کافی داشته باشیم، دامنهٔ جدید واقعاً با pretraining متفاوت باشد و منابع محاسباتی مناسب در اختیارمان باشد، تغییر همهٔ لایه‌ها می‌تواند بیشترین ظرفیت سازگاری را ایجاد کند.

مثلاً شاید نمایش ورودی، Attention و Head خروجی همگی برای مسئلهٔ جدید نیاز به تغییر جدی داشته باشند. Full Fine-tuning جلوی هیچ‌کدام را نمی‌گیرد.

پس چرا همیشه از آن استفاده نمی‌کنیم؟

مشکل اول هزینهٔ حافظه است. برای آموزش، فقط خود وزن‌ها را نگه نمی‌داریم. معمولاً به gradient، وضعیت‌های optimizer و activationهای Forward Pass هم نیاز داریم.

برای Adam، به‌صورت تقریبی ممکن است برای هر پارامتر این موارد را داشته باشیم:

وزن مدل
گرادیان
نسخهٔ دقیق‌تر وزن در بعضی تنظیم‌های mixed precision
میانگین اول Adam
میانگین دوم Adam

در یک برآورد رایج، فقط وزن‌ها، gradient و stateهای Adam می‌توانند حدود ۱۶ بایت به‌ازای هر پارامتر مصرف کنند. مقدار واقعی به precision، optimizer و روش توزیع آموزش وابسته است و activationها هم جدا به این حافظه اضافه می‌شوند.

برآورد سادهٔ حافظهٔ وضعیت آموزش

parameter_count 200,000,000
approximate_bytes_per_parameter 16
training_state_memory 200,000,000 × 16
training_state_memory ≈ 3.2 GB

این عدد هنوز activationها، batch، بافرهای موقت و سربار framework را حساب نکرده است. برای مدل‌های چندمیلیاردپارامتری، هزینه خیلی سریع بزرگ می‌شود.

مشکل دوم ذخیره‌سازی است. اگر مدل را برای ده مسئله Full Fine-tune کنیم، معمولاً ده نسخهٔ تقریباً کامل از وزن‌ها خواهیم داشت.

Backbone عمومی + وزن‌های کامل مسئلهٔ A
Backbone عمومی + وزن‌های کامل مسئلهٔ B
Backbone عمومی + وزن‌های کامل مسئلهٔ C

مشکل سوم overfitting است. یک مدل بزرگ آزادی زیادی برای حفظ‌کردن dataset کوچک ما دارد. تعداد بیشتر پارامترهای قابل‌آموزش همیشه به معنی یادگیری بهتر نیست.

مشکل چهارم فراموشی است. اگر وزن‌ها را بیش‌ازحد به سمت یک دامنهٔ کوچک ببریم، ممکن است بخشی از توانایی عمومی مدل ضعیف شود. به این پدیده معمولاً catastrophic forgetting می‌گویند.

Partial Fine-tuning؛ فقط چند لایهٔ موجود را باز کنیم

قبل از اضافه‌کردن پارامتر جدید، یک راه ساده این است که بیشتر مدل را freeze کنیم و فقط چند لایهٔ آخر، LayerNormها یا Head خروجی را آموزش بدهیم.

لایه‌های ابتدایی → frozen
لایه‌های میانی → frozen
چند لایهٔ آخر → trainable
Head خروجی → trainable

این روش تعداد پارامترهای قابل‌آموزش را کم می‌کند و پیاده‌سازی ساده‌ای دارد. اما انتخاب اینکه کدام لایه‌ها باز شوند همیشه واضح نیست. اگر تغییر موردنیاز در سراسر مدل پخش شده باشد، بازکردن فقط لایه‌های آخر ممکن است کافی نباشد.

PEFT تلاش می‌کند با طراحی پارامترهای کوچک و هدفمند، راه انعطاف‌پذیرتری ارائه دهد.

PEFT دقیقاً چیست؟

PEFT مخفف Parameter-Efficient Fine-Tuning است؛ یعنی Fine-tuning با تعداد کمی پارامتر قابل‌آموزش.

ایدهٔ اصلی این است:

Backbone بزرگ و ازپیش‌آموزش‌دیده → frozen
بخش کوچک مخصوص مسئله → trainable

به‌جای اینکه برای هر مسئله یک کپی کامل از مدل ذخیره کنیم، یک Backbone مشترک نگه می‌داریم و برای هر مسئله فقط یک فایل کوچک ذخیره می‌کنیم:

یک Backbone مشترک
├── پارامترهای کوچک مسئلهٔ فروش
├── پارامترهای کوچک مسئلهٔ ترافیک
├── پارامترهای کوچک مسئلهٔ انرژی
└── پارامترهای کوچک مسئلهٔ مالی

این ایده سه فایدهٔ مهم دارد:

  • gradient و stateهای optimizer فقط برای بخش کوچکی از پارامترها ساخته می‌شوند.
  • ذخیره و جابه‌جایی نسخهٔ هر مسئله ارزان‌تر می‌شود.
  • چون Backbone ثابت است، احتمال تخریب مستقیم دانش عمومی آن کمتر می‌شود.

اما یک سوءتفاهم مهم را همین‌جا برطرف کنیم: PEFT به این معنی نیست که خود مدل بزرگ دیگر لازم نیست در حافظه باشد. Backbone هنوز باید Forward Pass را انجام بدهد. PEFT بیشتر هزینهٔ آموزش و ذخیرهٔ پارامترهای مخصوص هر وظیفه را کم می‌کند؛ مقدار کاهش حافظه و سرعت به معماری و پیاده‌سازی وابسته است.

Adapter دو معنی دارد و همین باعث سردرگمی می‌شود

کلمهٔ Adapter در متن‌ها و کتابخانه‌ها با دو معنی دیده می‌شود.

معنی دقیق معماری این است:

یک ماژول عصبی کوچک که داخل یا کنار لایه‌های مدل قرار می‌گیرد

مثلاً Bottleneck Adapter که کمی جلوتر می‌سازیم.

اما در بعضی APIها، واژهٔ adapter به شکل عمومی‌تری برای هر بستهٔ کوچک PEFT استفاده می‌شود. در این کاربرد، فایل LoRA هم ممکن است «LoRA adapter» نامیده شود.

پس وقتی جایی نوشته شده adapter، باید از متن تشخیص بدهیم منظور کدام است:

  • یک Bottleneck Adapter به‌عنوان روش مشخص معماری
  • یا هر مجموعه پارامتر کوچک PEFT که به Backbone متصل می‌شود

در این مقاله، هرجا Adapter را به‌عنوان نام یک روش می‌نویسیم، منظور همان Bottleneck Adapter است.

Bottleneck Adapter؛ یک مسیر کوچک کنار مسیر اصلی

فرض کن در یک Transformer، خروجی یک block برداری با اندازهٔ ۱۲۸۰ است. Adapter ابتدا این بردار را به یک فضای کوچک‌تر، مثلاً ۶۴بعدی، می‌برد؛ آن را پردازش می‌کند و دوباره به ۱۲۸۰ بعد برمی‌گرداند.

بردار 1280بعدی
↓  Down Projection
بردار 64بعدی
↓  Activation
بردار 64بعدی
↓  Up Projection
بردار 1280بعدی
↓
جمع با مسیر اصلی

به فضای کوچک وسط bottleneck می‌گوییم؛ چون اطلاعات مجبور می‌شود از یک گلوگاه باریک عبور کند.

اگر ورودی Adapter را h بنامیم، شکل سادهٔ آن این است:

خروجی Bottleneck Adapter

down Wdown h
hidden activation(down)
up Wup hidden
adapter_output h + up

عبارت h + up همان residual connection است. مسیر اصلی دست‌نخورده باقی می‌ماند و Adapter فقط یک اصلاح کوچک به آن اضافه می‌کند.

Adapter دقیقاً کجای Transformer قرار می‌گیرد؟

یک جای‌گذاری رایج این است که Adapter بعد از Attention و بعد از Feed-Forward Network قرار بگیرد:

Self-Attention
↓
Adapter
↓
Feed-Forward Network
↓
Adapter

اما طراحی‌های دیگری هم وجود دارند؛ ممکن است در هر block فقط یک Adapter، در مسیر موازی یا در محل دیگری قرار بگیرد. بنابراین وقتی تعداد پارامترها یا سرعت دو روش Adapter را مقایسه می‌کنیم، باید جای‌گذاری دقیق آن‌ها را هم بدانیم.

چرا تعداد پارامترهای Adapter کم می‌شود؟

اگر اندازهٔ نمایش اصلی را D و اندازهٔ bottleneck را m بنامیم، دو ماتریس اصلی داریم:

تعداد پارامترهای اصلی یک Adapter

down_projection m × D
up_projection D × m
adapter_parameters ≈ 2 × D × m

برای D = 1280 و m = 64:

مثال تعداد پارامترهای یک Adapter

رابطهٔ اصلی

adapter_parameters
(64 × 1280) + (1280 × 64)

جایگذاری مقادیر

81,920 + 81,920
163,840

این محاسبه biasها و LayerNormهای احتمالی را کنار گذاشته است. اگر در هرکدام از ۲۰ block دو Adapter بگذاریم، تقریباً ۶.۵ میلیون پارامتر اضافه می‌شود؛ یعنی حدود ۳.۳ درصد یک مدل ۲۰۰میلیونی. طراحی واقعی می‌تواند کمتر یا بیشتر باشد.

Adapter چطور آموزش می‌بیند؟

در Forward Pass، داده از Backbone ثابت و Adapterهای جدید عبور می‌کند. Loss مثل همیشه محاسبه می‌شود. در Backpropagation گرادیان از کل مسیر رد می‌شود، اما optimizer فقط وزن‌های Adapter و بخش‌های دیگری را که عمداً باز گذاشته‌ایم تغییر می‌دهد.

Backbone → در Forward Pass فعال، اما frozen
Adapter  → در Forward Pass فعال و trainable
Head     → بسته به طراحی، frozen یا trainable

این تفاوت میان «گرادیان از یک بخش عبور می‌کند» و «پارامترهای آن بخش به‌روزرسانی می‌شوند» خیلی مهم است.

مزیت‌های Adapter

Adapter یک ماژول مستقل و قابل‌فهم است. می‌توانیم برای هر وظیفه Adapter جدا داشته باشیم، بدون اینکه وزن‌های اصلی مدل را تغییر بدهیم. تعویض مسئله می‌تواند به معنی تعویض همین ماژول کوچک باشد.

همچنین Adapter یک شبکهٔ غیرخطی واقعی دارد. به‌خاطر activation داخل bottleneck، می‌تواند تغییرات پیچیده‌تری از یک تبدیل خطی ساده بسازد.

محدودیت‌های Adapter

Adapter عملیات جدیدی وارد Forward Pass می‌کند. چون داده باید از Down Projection، activation و Up Projection عبور کند، ممکن است latency استنتاج افزایش پیدا کند.

Adapter را معمولاً نمی‌توان مثل LoRA به‌سادگی در یک ماتریس خطی موجود ادغام کرد؛ چون میان دو projection یک تابع غیرخطی قرار دارد. همچنین اندازهٔ bottleneck و محل قرارگیری آن روی کیفیت و هزینه اثر زیادی دارند.

LoRA؛ به‌جای تغییر وزن بزرگ، تغییر آن را فشرده کنیم

حالا به یکی از مهم‌ترین روش‌های PEFT می‌رسیم: Low-Rank Adaptation یا LoRA.

برای فهم LoRA یک لایهٔ خطی معمولی را در نظر بگیر:

لایهٔ خطی اصلی

input x
pretrained_weight W
output Wx

در Full Fine-tuning خود ماتریس بزرگ W تغییر می‌کند. می‌توانیم وزن جدید را این‌طور بنویسیم:

تغییر وزن در Fine-tuning

pretrained_weight W
learned_change ΔW
adapted_weight W + ΔW

اگر W یک ماتریس بسیار بزرگ باشد، خود ΔW هم به همان اندازه بزرگ است. LoRA یک فرض مهم وارد می‌کند:

شاید تغییر موردنیاز برای سازگارکردن مدل، در تمام جهت‌های ممکن فضای وزن پخش نشده باشد. شاید بتوان آن را با تعداد کمی جهت مهم توضیح داد.

پس به‌جای یادگرفتن مستقیم ΔW، آن را حاصل‌ضرب دو ماتریس باریک می‌گیریم:

ایدهٔ اصلی LoRA

frozen_weight W
low_rank_change ΔW = BA
adapted_output Wx + (α ÷ r)BAx

در اینجا:

  • W وزن اصلی و ثابت مدل است.
  • A ورودی را به یک فضای کوچک با اندازهٔ r می‌برد.
  • B آن نمایش کوچک را به اندازهٔ خروجی برمی‌گرداند.
  • r همان rank انتخابی LoRA است.
  • α ÷ r مقیاسی است که شدت تغییر LoRA را کنترل می‌کند.

ماتریس‌های A و B واقعاً چه کار می‌کنند؟

اینجا همان بخش ساده‌ای قرار دارد که کل LoRA را قابل‌فهم می‌کند.

فرض کن ورودی و خروجی لایه هر دو ۱۲۸۰بعدی‌اند و rank را برابر ۸ انتخاب کرده‌ایم. ماتریس A ورودی ۱۲۸۰بعدی را به فقط ۸ عدد فشرده می‌کند:

x با 1280 عدد
↓ A با شکل 8 × 1280
نمایش کوچک با 8 عدد

بعد ماتریس B همان ۸ عدد را دوباره به یک اصلاح ۱۲۸۰بعدی تبدیل می‌کند:

نمایش کوچک با 8 عدد
↓ B با شکل 1280 × 8
اصلاح خروجی با 1280 عدد

پس مسیر کامل LoRA این شکلی است:

مسیر اصلی و ثابت:
x ─────────→ W ─────────→ Wx
│
└──→ A ──→ 8 عدد ──→ B ──→ BAx
                              │
                              ↓
                         Wx + BAx

حاصل‌ضرب B × A از نظر شکل، یک ماتریس 1280 × 1280 می‌سازد و بنابراین می‌تواند به وزن اصلی W اضافه شود. اما نکته این است که هنگام آموزش، آن ماتریس بزرگ را به‌صورت یک مجموعه وزن مستقل یاد نمی‌گیریم؛ فقط خود A و B را یاد می‌گیریم.

چرا تعداد پارامترهای LoRA کم می‌شود؟

full_change_parameters 1280 × 1280
lora_A_parameters 8 × 1280
lora_B_parameters 1280 × 8
lora_parameters lora_A_parameters + lora_B_parameters

در Full Fine-tuning تعداد پارامترهای تغییر با ضرب دو بُعد بزرگ به دست می‌آید. در LoRA تعداد پارامترها جمع اندازهٔ دو ماتریس باریک است. چون rank یعنی ۸ بسیار کوچک‌تر از ۱۲۸۰ است، این جمع خیلی کوچک‌تر از ماتریس کامل می‌شود.

پس کم‌شدن پارامترها صرفاً به‌خاطر وجود دو ماتریس نیست؛ به این دلیل است که فضای میانی میان آن‌ها عمداً باریک انتخاب شده است:

1280 → 8 → 1280

اگر rank را به‌اندازهٔ بُعد اصلی بزرگ کنیم، بخش زیادی از مزیت پارامتری LoRA از بین می‌رود.

Rank یعنی چه؟

Rank در اینجا یعنی تعداد جهت‌های مستقلی که برای بیان تغییر وزن در اختیار LoRA می‌گذاریم.

اگر r = 1 باشد، LoRA فقط یک مسیر فشرده برای تغییر رفتار لایه دارد. اگر r = 8 باشد، هشت جهت میانی دارد. rank بزرگ‌تر ظرفیت بیشتری می‌دهد، اما پارامتر و هزینهٔ بیشتری هم ایجاد می‌کند.

می‌توانیم آن را این‌طور تصور کنیم:

Full Fine-tuning:
تغییر آزاد در یک فضای بسیار بزرگ

LoRA با rank پایین:
تغییر مدل در چند جهت یادگرفتنی و مهم

LoRA ادعا نمی‌کند خود وزن اصلی W کم‌رتبه است. فرضش این است که تغییر موردنیاز برای Adaptation را می‌توان تقریباً با یک ساختار کم‌رتبه بیان کرد.

یک مثال عددی از تعداد پارامترهای LoRA

فرض کن یک لایهٔ خطی ورودی و خروجی ۱۲۸۰بعدی دارد. ماتریس اصلی آن این تعداد پارامتر دارد:

تعداد پارامترهای ماتریس اصلی

رابطهٔ اصلی

full_weight_parameters
input_dimension × output_dimension

جایگذاری مقادیر

1280 × 1280
1,638,400

حالا LoRA را با rank برابر ۸ اضافه کنیم. ماتریس A شکل 8 × 1280 و ماتریس B شکل 1280 × 8 دارد:

تعداد پارامترهای LoRA با rank هشت

رابطهٔ اصلی

lora_parameters
(8 × 1280) + (1280 × 8)

جایگذاری مقادیر

10,240 + 10,240
20,480

نسبت این دو:

نسبت پارامترهای LoRA به ماتریس کامل

رابطهٔ اصلی

parameter_ratio
20,480 ÷ 1,638,400

جایگذاری مقادیر

0.0125
1.25%

پس برای این لایه، به‌جای تغییر بیش از ۱.۶ میلیون پارامتر، فقط حدود ۲۰ هزار پارامتر آموزش می‌بینند.

این نسبت فقط مربوط به یک ماتریس است. نسبت کل مدل به این بستگی دارد که LoRA را روی کدام ماتریس‌ها، در چند لایه و با چه rankی قرار بدهیم.

LoRA را روی کدام وزن‌ها می‌گذاریم؟

در Transformer چند ماتریس خطی مهم داریم:

Attention:
Wq, Wk, Wv, Wo

Feed-Forward Network:
Wup, Wdown و ماتریس‌های مشابه

در بعضی تنظیم‌ها LoRA فقط روی Query و Value قرار می‌گیرد. در بعضی دیگر روی تمام projectionهای Attention یا حتی همهٔ لایه‌های خطی اعمال می‌شود.

هدف‌گرفتن ماتریس‌های بیشتر ظرفیت سازگاری را بالا می‌برد، اما تعداد پارامترهای قابل‌آموزش و مصرف حافظه هم بیشتر می‌شود. بنابراین عبارت «از LoRA استفاده کردیم» هنوز توضیح کاملی نیست. باید حداقل این موارد را بدانیم:

  • rank چقدر بوده است؟
  • α چه مقداری داشته است؟
  • LoRA روی کدام ماژول‌ها قرار گرفته است؟
  • dropout چقدر بوده است؟
  • آیا bias یا Head خروجی هم آموزش دیده‌اند؟

چرا LoRA در شروع رفتار مدل را خراب نمی‌کند؟

در یک مقداردهی اولیهٔ رایج، A با مقدارهای کوچک یا تصادفی شروع می‌شود و B از صفر شروع می‌شود.

شروع LoRA بدون تغییر خروجی مدل

initial_B 0
initial_ΔW BA
initial_ΔW 0
initial_adapted_output Wx

در لحظهٔ شروع، شاخهٔ LoRA چیزی به خروجی اضافه نمی‌کند و مدل دقیقاً مثل Backbone اصلی رفتار می‌کند. سپس Backpropagation کم‌کم A و B را طوری تغییر می‌دهد که اصلاح مناسب مسئلهٔ جدید ساخته شود.

جزئیات دقیق initialization می‌تواند میان پیاده‌سازی‌ها فرق کند، اما اصل مهم این است که شاخهٔ LoRA معمولاً به‌صورت یک تغییر خنثی آغاز می‌شود.

Backpropagation در LoRA چه چیزی را تغییر می‌دهد؟

Forward Pass این دو مسیر را با هم جمع می‌کند:

مسیر اصلی: x → W → Wx
مسیر LoRA: x → A → B → BAx
                         ↓
                    جمع دو مسیر

Loss از خروجی نهایی محاسبه می‌شود. گرادیان به هر دو مسیر می‌رسد، اما W دارای requires_grad = False است. بنابراین optimizer فقط A و B را تغییر می‌دهد.

پارامترهای قابل‌آموزش در LoRA

gradient_for_W not_stored_for_update
gradient_for_A trainable
gradient_for_B trainable
updated_parameters {A, B}

در عمل ممکن است Head خروجی، بعضی biasها یا LayerNormها را هم باز بگذاریم، اما این یک انتخاب جداست و باید صریح گزارش شود.

مزیت مهم LoRA در زمان استنتاج

چون W و BA هر دو تبدیل خطی‌اند، بعد از آموزش می‌توانیم آن‌ها را در یک ماتریس ادغام کنیم:

ادغام LoRA با وزن اصلی

learned_change ΔW = (α ÷ r)BA
merged_weight W + ΔW
merged_output merged_weight × x

بعد از merge دیگر لازم نیست شاخهٔ A و B جداگانه در Forward Pass اجرا شود. به همین دلیل LoRA می‌تواند بدون latency اضافهٔ قابل‌توجهِ شاخهٔ جدا، برای inference استفاده شود.

البته نگه‌داشتن LoRA به‌صورت جدا هم مزیت دارد: یک Backbone را در حافظه نگه می‌داریم و LoRAهای مختلف را برای وظایف مختلف جابه‌جا می‌کنیم.

Adapter و LoRA دقیقاً چه فرقی دارند؟

هر دو معمولاً Backbone را freeze می‌کنند و تعداد کمی پارامتر جدید یاد می‌گیرند، اما محل ایجاد تغییر متفاوت است.

ویژگی Bottleneck Adapter LoRA
ایدهٔ اصلی اضافه‌کردن یک شبکهٔ کوچک میان blockها ساختن تغییر کم‌رتبه برای وزن‌های موجود
مسیر محاسباتی Down Projection، activation و Up Projection دو projection خطی A و B
غیرخطی بودن معمولاً دارد شاخهٔ اصلی LoRA خطی است
تغییر وزن اصلی وزن اصلی frozen می‌ماند وزن اصلی frozen می‌ماند
امکان merge ساده معمولاً نه معمولاً بله
اثر روی latency ممکن است بیشتر شود بعد از merge می‌تواند تقریباً حذف شود
تنظیم مهم اندازهٔ bottleneck و محل قرارگیری rank، α و target moduleها
فایل مخصوص هر وظیفه کوچک معمولاً بسیار کوچک

نمی‌توان گفت یکی همیشه بهتر است. Adapter به‌خاطر شبکهٔ غیرخطی خودش انعطاف جالبی دارد. LoRA در بسیاری از معماری‌ها ساده، سبک و قابل‌ادغام است. نتیجه به مدل، حجم داده، نوع domain shift و محل تزریق پارامترها وابسته است.

آیا PEFT فقط Adapter و LoRA است؟

نه. این دو فقط دو شاخهٔ مهم‌اند.

Prompt Tuning

به‌جای تغییر وزن‌های مدل، چند بردار قابل‌آموزش شبیه token مجازی به ورودی اضافه می‌شود. این بردارها متن واقعی نیستند؛ پارامترهایی‌اند که optimizer یاد می‌گیرد.

Prefix Tuning

بردارهای قابل‌آموزش به‌صورت prefix در لایه‌های Attention در اختیار مدل قرار می‌گیرند. مدل می‌تواند هنگام ساخت خروجی به این prefixهای مجازی توجه کند.

IA³

به‌جای ماتریس‌های LoRA، چند بردار کوچک یاد گرفته می‌شود که activationهای Key، Value و Feed-Forward را مقیاس می‌دهند.

BitFit

بیشتر وزن‌ها ثابت می‌مانند و فقط biasها آموزش می‌بینند. این روش بسیار ساده و کم‌پارامتر است، ولی ظرفیت سازگاری آن هم محدودتر است.

این روش‌ها را می‌توان بر اساس نوع تغییر این‌طور دید:

اضافه‌کردن ماژول جدید      → Bottleneck Adapter
بازپارامتردهی تغییر وزن   → LoRA
یادگیری ورودی مجازی       → Prompt / Prefix Tuning
مقیاس‌دادن activationها   → IA³
انتخاب چند پارامتر موجود  → BitFit

QLoRA چیست و چرا با LoRA یکی نیست؟

LoRA تعداد پارامترهای قابل‌آموزش را کم می‌کند، اما Backbone هنوز باید در حافظه قرار بگیرد. QLoRA یک قدم دیگر برمی‌دارد: Backbone ثابت را با precision پایین، معمولاً ۴ بیت، نگه می‌دارد و گرادیان را از آن عبور می‌دهد تا LoRAهای کوچک آموزش ببینند.

Backbone quantized و frozen
+
LoRA با precision مناسب و trainable
↓
QLoRA

پس QLoRA صرفاً «LoRA با rank کمتر» نیست. ترکیب Quantization مدل پایه با آموزش LoRA است. این روش برای مدل‌های بسیار بزرگ، به‌خصوص LLMها، می‌تواند کاهش حافظهٔ چشمگیری ایجاد کند.

اما Quantization هم رایگان نیست. پشتیبانی سخت‌افزار، kernelها، سرعت و کیفیت عددی باید جدا بررسی شوند. موفقیت QLoRA روی یک LLM تضمین نمی‌کند همان تنظیم بدون آزمایش برای هر Foundation Model سری زمانی بهترین باشد.

یک پیاده‌سازی سادهٔ Bottleneck Adapter با PyTorch

این کد فقط خود ایده را نشان می‌دهد و به معماری خاصی وابسته نیست:

import torch
import torch.nn as nn

class BottleneckAdapter(nn.Module):
    def __init__(self, model_dim: int, bottleneck_dim: int):
        super().__init__()
        self.down = nn.Linear(model_dim, bottleneck_dim)
        self.activation = nn.GELU()
        self.up = nn.Linear(bottleneck_dim, model_dim)

        nn.init.zeros_(self.up.weight)
        nn.init.zeros_(self.up.bias)

    def forward(self, hidden_state):
        update = self.up(self.activation(self.down(hidden_state)))
        return hidden_state + update

صفرکردن لایهٔ up باعث می‌شود Adapter در شروع تقریباً هیچ تغییری در خروجی ایجاد نکند. سپس آموزش کم‌کم شاخهٔ اصلاحی را می‌سازد.

برای freezeکردن Backbone:

for parameter in backbone.parameters():
    parameter.requires_grad = False

adapter = BottleneckAdapter(
    model_dim=1280,
    bottleneck_dim=64,
)

optimizer = torch.optim.AdamW(
    adapter.parameters(),
    lr=1e-3,
)

در یک مدل واقعی باید Adapter را در محل درست blockها قرار بدهیم و احتمالاً Head خروجی را هم به optimizer اضافه کنیم.

اجرای LoRA با کتابخانهٔ PEFT

کتابخانهٔ Hugging Face PEFT اضافه‌کردن LoRA به مدل‌های پشتیبانی‌شده را ساده می‌کند. شکل کلی کار این است:

from peft import LoraConfig, get_peft_model

lora_config = LoraConfig(
    r=8,
    lora_alpha=16,
    lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"],
    bias="none",
)

model = get_peft_model(base_model, lora_config)
model.print_trainable_parameters()

نام q_proj و v_proj عمومی نیست. هر معماری نام‌گذاری خودش را دارد. قبل از اجرای LoRA باید ساختار named_modules() مدل را ببینیم و مطمئن شویم target moduleها واقعاً همان لایه‌هایی‌اند که قصد تغییرشان را داریم.

برای ذخیره‌کردن پارامترهای PEFT:

model.save_pretrained("my_lora_adapter")

خروجی معمولاً فقط وزن‌ها و تنظیمات LoRA را ذخیره می‌کند، نه یک کپی کامل از Backbone. برای بارگذاری دوباره، همچنان به همان مدل پایهٔ سازگار نیاز داریم.

اگر مدل ما در PEFT پشتیبانی نشود چه؟

همهٔ Foundation Modelها با کلاس‌های استاندارد Hugging Face Transformers ساخته نشده‌اند. ممکن است مدل سری زمانی ما PyTorch سفارشی داشته باشد یا نام لایه‌هایش برای PEFT شناخته‌شده نباشد.

در این حالت سه راه داریم:

  • لایه‌های خطی هدف را با wrapperهای LoRA جایگزین کنیم.
  • integration سفارشی برای مدل بنویسیم.
  • از پیاده‌سازی PEFT خود پروژه، اگر وجود دارد، استفاده کنیم.

اصل ریاضی LoRA به زبان یا سری زمانی وابسته نیست؛ هرجا یک تبدیل خطی مناسب داشته باشیم می‌توانیم تغییر کم‌رتبه اضافه کنیم. اما انتخاب لایهٔ درست و اتصال سالم آن به optimizer بخش مهم کار است.

در سری زمانی دقیقاً چه چیزی را باید Adapt کنیم؟

در یک Foundation Model سری زمانی چند بخش متفاوت داریم:

Input projection / patch encoder
↓
Transformer backbone
↓
Output head / forecast decoder

ممکن است فقط Head خروجی برای horizon یا نوع پیش‌بینی جدید نیاز به آموزش داشته باشد. ممکن است نمایش patchهای ورودی با دامنهٔ جدید سازگار نباشد. ممکن است Attention و Feed-Forwardهای Backbone هم نیاز به اصلاح داشته باشند.

بنابراین چند آزمایش منطقی داریم:

آزمایش 1: Backbone کاملاً frozen، فقط Head
آزمایش 2: Backbone frozen، Adapterها + Head
آزمایش 3: LoRA روی Attention، به‌همراه Head
آزمایش 4: LoRA روی Attention و Feed-Forward
آزمایش 5: Full Fine-tuning

مقایسهٔ این مسیرها نشان می‌دهد بهبود واقعاً از کجا آمده است. اگر فقط بهترین نتیجه را گزارش کنیم ولی baseline frozen و Head-only نداشته باشیم، نمی‌فهمیم پیچیدگی PEFT واقعاً لازم بوده یا نه.

دادهٔ سری زمانی را قبل از Fine-tuning چطور تقسیم کنیم؟

در متن و تصویر، می‌توانیم نمونه‌ها را تصادفی تقسیم کنیم؛ اما در سری زمانی، split تصادفی اغلب آینده را وارد گذشته می‌کند.

ترتیب درست معمولاً شبیه این است:

گذشتهٔ قدیمی‌تر → Train
بازهٔ بعدی       → Validation
جدیدترین بازه    → Test

تمام تصمیم‌ها، از انتخاب rank گرفته تا learning rate و تعداد epoch، باید با Train و Validation گرفته شوند. Test باید تا پایان دست‌نخورده بماند.

اگر چند دارایی یا چند سری داریم، دو نوع تعمیم را باید جدا کنیم:

  • تعمیم زمانی: آیندهٔ همان سری‌ها را پیش‌بینی کنیم.
  • تعمیم بین‌سری: روی دارایی یا سری‌ای آزمایش کنیم که در آموزش Adaptation حضور نداشته است.

یک مدل ممکن است در اولی عالی و در دومی ضعیف باشد. این دو سؤال علمی متفاوت‌اند.

Fine-tuning چه چیزی را نمی‌تواند جبران کند؟

هیچ روش Adaptation نمی‌تواند دادهٔ بد را جادویی اصلاح کند. اگر timestampها اشتباه باشند، leakage داشته باشیم، target را نادرست ساخته باشیم یا دورهٔ Train نمایندهٔ Test نباشد، LoRA و Adapter فقط همان مشکل را دقیق‌تر یاد می‌گیرند.

در سری زمانی باید قبل از آموزش این موارد روشن باشند:

  • context دقیقاً چند نقطه است؟
  • horizon چند نقطه است؟
  • داده با چه روشی normalize می‌شود؟
  • missing value و outlier چطور مدیریت شده‌اند؟
  • frequency سری چیست؟
  • target قیمت، بازده، جهت حرکت یا چیز دیگری است؟
  • ارزیابی point forecast است یا probabilistic forecast؟

PEFT روش بهینه‌سازی پارامترهاست؛ جای تعریف درست مسئله را نمی‌گیرد.

انتخاب rank در LoRA

rank کوچک پارامتر و حافظهٔ کمتری مصرف می‌کند، اما ظرفیت تغییر محدودتری دارد. rank بزرگ ظرفیت بیشتری ایجاد می‌کند، ولی ممکن است هزینه و خطر overfitting را بالا ببرد.

برای شروع می‌توان چند مقدار مثل ۴، ۸، ۱۶ و ۳۲ را در یک بودجهٔ آزمایش مشخص مقایسه کرد. اما عدد مناسب یک قانون جهانی نیست. rank باید همراه با target moduleها دیده شود؛ LoRA با rank هشت روی تمام لایه‌های خطی ممکن است از LoRA با rank شانزده فقط روی دو projection پارامتر بیشتری داشته باشد.

مقایسهٔ منصفانه باید حداقل این موارد را گزارش کند:

rank
alpha
target modules
trainable parameter count
learning rate
training steps
validation protocol

Learning rate در PEFT چرا می‌تواند متفاوت باشد؟

در Full Fine-tuning وزن‌های ازپیش‌آموزش‌دیده را مستقیم تغییر می‌دهیم؛ به همین دلیل معمولاً با learning rate محتاطانه‌تری حرکت می‌کنیم.

در Adapter و LoRA بخش زیادی از پارامترهای قابل‌آموزش تازه ساخته شده‌اند. این پارامترها گاهی می‌توانند learning rate بزرگ‌تری تحمل کنند. اما این هم قانون ثابت نیست؛ داده، normalization، batch size و optimizer هم مؤثرند.

راه درست این است که learning rate را برای هر روش جدا تنظیم کنیم. استفاده از یک learning rate یکسان برای Full Fine-tuning و LoRA لزوماً مقایسهٔ منصفانه‌ای نیست.

چطور روش‌ها را منصفانه مقایسه کنیم؟

فقط مقایسهٔ loss نهایی کافی نیست. هر روش باید از چند زاویه سنجیده شود:

معیار سؤال
کیفیت روی Test واقعاً بهتر پیش‌بینی می‌کند؟
پارامتر قابل‌آموزش چند پارامتر gradient و optimizer state دارند؟
حافظهٔ اوج آموزش واقعاً چقدر VRAM یا RAM مصرف می‌کند؟
زمان آموزش رسیدن به بهترین Validation چند ساعت طول می‌کشد؟
اندازهٔ checkpoint برای هر وظیفه چه مقدار باید ذخیره شود؟
latency استنتاج Adapter یا شاخهٔ اضافه سرعت را کم می‌کند؟
تعمیم روی بازه و سری ندیده چطور عمل می‌کند؟
پایداری نتیجه میان seedها و پنجره‌های زمانی چقدر تغییر می‌کند؟

حداقل baselineهای مفید:

مدل frozen و zero-shot
مدل frozen با Head جدید
Partial Fine-tuning
Adapter
LoRA
Full Fine-tuning
یک baseline سادهٔ غیر Foundation Model

ممکن است LoRA از Full Fine-tuning کمی ضعیف‌تر باشد، اما با یک‌صدم پارامتر قابل‌آموزش نتیجه‌ای نزدیک تولید کند. در آن صورت انتخاب بهتر به محدودیت پروژه بستگی دارد، نه فقط به آخرین رقم metric.

خطاهای رایج در استفاده از PEFT

اشتباه اول: هر پارامتر frozen یعنی هیچ هزینه‌ای ندارد

Backbone frozen هنوز Forward Pass انجام می‌دهد و activation تولید می‌کند. همچنین برای رساندن گرادیان به Adapterهای میانی، بخشی از گراف محاسباتی لازم است. پس کاهش پارامتر قابل‌آموزش الزاماً به همان نسبت آموزش را سریع نمی‌کند.

اشتباه دوم: LoRA را اضافه کنیم و تمام

اگر target_modules اشتباه باشد، ممکن است هیچ لایهٔ مؤثری آموزش نبیند یا LoRA روی بخش نامناسبی قرار بگیرد. همیشه تعداد پارامترهای trainable و نام ماژول‌های هدف را بررسی کن.

اشتباه سوم: فقط Training Loss را ببینیم

هدف Adaptation حفظ‌کردن Train نیست. باید Validation و Test زمانی داشته باشیم. در dataset کوچک، روش پرظرفیت ممکن است Training Loss عالی و تعمیم ضعیف تولید کند.

اشتباه چهارم: PEFT را تضمین حفظ دانش عمومی بدانیم

فریزکردن Backbone جلوی تغییر مستقیم وزن‌های اصلی را می‌گیرد، اما Adapter می‌تواند خروجی مدل را شدیداً منحرف کند. PEFT خطر فراموشی را کاهش می‌دهد، نه اینکه آن را از نظر رفتاری غیرممکن کند.

اشتباه پنجم: تعداد پارامتر کمتر را با مدل کوچک‌تر یکی بدانیم

فایل LoRA کوچک است، اما برای اجرا همچنان Backbone لازم است. یک LoRA ده‌مگابایتی به‌تنهایی مدل کامل نیست.

اشتباه ششم: Full Fine-tuning را همیشه بهترین حد بالا فرض کنیم

روی دادهٔ کم، آزادی زیاد Full Fine-tuning می‌تواند overfitting ایجاد کند. گاهی محدودیت ساختاری PEFT مثل regularization عمل می‌کند و نتیجهٔ بهتری می‌دهد.

برای هر مسئله کدام روش را انتخاب کنیم؟

اگر مدل zero-shot از قبل خوب است و دادهٔ کمی داریم، اول همان مدل frozen و یک Head ساده را امتحان می‌کنیم. پیچیدگی اضافه باید با بهبود واقعی توجیه شود.

اگر چند وظیفه داریم و می‌خواهیم یک Backbone مشترک نگه داریم، Adapter یا LoRA جذاب‌اند. برای هر وظیفه فقط پارامترهای کوچک خودش ذخیره می‌شود.

اگر latency استنتاج حساس است و امکان merge می‌خواهیم، LoRA معمولاً انتخاب راحت‌تری است.

اگر یک ماژول غیرخطی مستقل و قابل‌ترکیب می‌خواهیم، Bottleneck Adapter می‌تواند مناسب باشد.

اگر مدل در حافظه جا نمی‌شود، QLoRA یا روش‌های quantized PEFT ارزش بررسی دارند؛ به شرط اینکه معماری و سخت‌افزار پشتیبانی شوند.

اگر دادهٔ بسیار زیاد، سخت‌افزار کافی و domain shift عمیق داریم، Full Fine-tuning همچنان گزینهٔ جدی است.

می‌توانیم تصمیم را این‌طور خلاصه کنیم:

آیا zero-shot کافی است؟
├── بله → مدل را frozen نگه دار
└── نه
    │
    ├── داده و سخت‌افزار زیاد + نیاز به تغییر عمیق
    │   └── Full Fine-tuning را آزمایش کن
    │
    └── داده یا سخت‌افزار محدود / چند وظیفهٔ مختلف
        ├── نیاز به merge و latency کم → LoRA
        ├── نیاز به ماژول مستقل غیرخطی → Adapter
        └── کمبود شدید حافظه → QLoRA یا PEFT quantized

این درخت حکم قطعی نیست؛ نقطهٔ شروع آزمایش است.

یک برنامهٔ آزمایش عملی

برای اینکه نتیجهٔ علمی و قابل‌اعتماد باشد، می‌توانیم این ترتیب را اجرا کنیم:

  1. داده را به‌صورت زمانی به Train، Validation و Test تقسیم کنیم.
  2. نتیجهٔ zero-shot مدل پایه را ثبت کنیم.
  3. یک baseline ساده و یک Head-only baseline بسازیم.
  4. Adapter را با دو اندازهٔ bottleneck آزمایش کنیم.
  5. LoRA را با چند rank و target module مشخص آزمایش کنیم.
  6. اگر منابع اجازه داد، Full Fine-tuning را هم اجرا کنیم.
  7. همه را با split، metric و بودجهٔ جست‌وجوی منصفانه مقایسه کنیم.
  8. علاوه بر کیفیت، حافظه، زمان، تعداد پارامتر و اندازهٔ checkpoint را گزارش کنیم.

خروجی نهایی فقط یک جدول Accuracy یا RMSE نیست. باید بفهمیم برای هر مقدار بهبود، چه هزینه‌ای پرداخته‌ایم.

جمع‌بندی

Foundation Model دانش عمومی ارزشمندی دارد، اما مسئلهٔ ما ممکن است زبان آماری مخصوص خودش را داشته باشد. Adaptation پلی میان این دو است.

در Full Fine-tuning تقریباً همهٔ وزن‌های مدل اجازهٔ تغییر دارند. این روش بیشترین آزادی را ایجاد می‌کند، اما حافظه، ذخیره‌سازی و خطر overfitting بیشتری دارد.

PEFT تلاش می‌کند با تغییر بخش کوچکی از پارامترها، بیشتر دانش Backbone را مشترک نگه دارد. Adapter یک شبکهٔ bottleneck کوچک به مسیر مدل اضافه می‌کند؛ LoRA به‌جای تغییر مستقیم ماتریس بزرگ، تغییر آن را با دو ماتریس کم‌رتبه می‌سازد.

خلاصهٔ معماری این دو روش:

Bottleneck Adapter:
h → down projection → activation → up projection → + h

LoRA:
y = Wx + (α / r)BAx

Adapter ماژولی مستقل و غیرخطی است، اما ممکن است latency اضافه کند. LoRA تغییر وزن را فشرده می‌کند و معمولاً قابلیت merge با وزن اصلی را دارد. هیچ‌کدام همیشه برنده نیستند.

انتخاب درست از اسم روش شروع نمی‌شود؛ از یک سؤال روشن شروع می‌شود:

مدل برای سازگارشدن با دادهٔ ما دقیقاً به چه مقدار آزادی نیاز دارد، و ما حاضر هستیم برای آن آزادی چه هزینه‌ای بپردازیم؟

منابع

این مقاله با روایت و مثال‌های تألیفی نوشته شده و مفاهیم فنی آن بر منابع اولیه و مستندات رسمی زیر تکیه دارد:

  1. Neil Houlsby و همکاران، Parameter-Efficient Transfer Learning for NLP، ICML 2019؛ معرفی Adapterهای bottleneck برای انتقال کم‌پارامتر.
  2. Edward J. Hu و همکاران، LoRA: Low-Rank Adaptation of Large Language Models، ۲۰۲۱؛ مقالهٔ اصلی LoRA.
  3. Xiang Lisa Li و Percy Liang، Prefix-Tuning: Optimizing Continuous Prompts for Generation، ACL 2021.
  4. Elad Ben-Zaken، Shauli Ravfogel و Yoav Goldberg، BitFit: Simple Parameter-efficient Fine-tuning for Transformer-based Masked Language-models، ACL 2022.
  5. Haokun Liu و همکاران، Few-Shot Parameter-Efficient Fine-Tuning is Better and Cheaper than In-Context Learning، NeurIPS 2022؛ معرفی IA³.
  6. Tim Dettmers و همکاران، QLoRA: Efficient Finetuning of Quantized LLMs، ۲۰۲۳.
  7. Hugging Face، مستندات رسمی PEFT و مستندات LoRA؛ برای API، پیکربندی و مدیریت Adapterها.