عنوان
در مقالهٔ قبلی به یک مدل «دنیادیده» رسیدیم؛ مدلی که قبل از ملاقات با مسئلهٔ ما، روی حجم بزرگی از دادههای متنوع آموزش دیده و الگوهای عمومی زیادی یاد گرفته است.
اما عمومیبودن همیشه کافی نیست.
مدلی مثل TimesFM ممکن است روند، فصل، نوسان و شوک را از تعداد زیادی سری زمانی یاد گرفته باشد، ولی دادهٔ ما احتمالاً ویژگیهای خاص خودش را دارد. رفتار قیمت یک دارایی با مصرف برق یک شهر یکی نیست. حتی دو بازار مالی هم میتوانند ساختار، نویز و رژیمهای متفاوتی داشته باشند.
پس یک سؤال مهم شکل میگیرد:
چطور دانش عمومی مدل را حفظ کنیم، اما آن را با مسئلهٔ خودمان هم آشنا کنیم؟
سادهترین جواب این است که مدل را دوباره آموزش بدهیم. ولی وقتی مدل صدها میلیون یا چند میلیارد پارامتر دارد، عبارت «دوباره آموزش بدهیم» دیگر آنقدر ساده نیست.
این مقاله دقیقاً دربارهٔ انتخاب میان همین راههاست: از Full Fine-tuning که تقریباً همهٔ وزنها را تغییر میدهد، تا روشهای PEFT که فقط بخش کوچکی از مدل را آموزش میدهند. در میان روشهای PEFT هم دو ایده را دقیقتر باز میکنیم: Adapter و LoRA.
مدل ازپیشآموزشدیده یک نقطهٔ شروع قدرتمند است. 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، وزنهای اصلی عمدتاً ثابت میمانند و فقط بخش کوچکی از پارامترها یاد میگیرند.
منطق یادگیری عوض نشده است. مدل روی دادهٔ ما Forward Pass انجام میدهد، پیشبینی میسازد، با جواب واقعی مقایسه میشود و Backpropagation گرادیان پارامترهای قابلآموزش را حساب میکند.
تفاوت روشها در یک سؤال است:
کدام پارامترها اجازه دارند با این گرادیان تغییر کنند؟
در Full Fine-tuning جواب این است:
تقریباً همهٔ وزنهای مدل
در Adapter Tuning جواب میشود:
وزنهای Adapter و احتمالاً Head خروجی
و در LoRA:
ماتریسهای کوچک A و B و احتمالاً Head خروجی
پارامتر ثابت هم در Forward Pass حضور دارد. خروجی تولید میکند و گرادیان باید از مسیرش عبور کند تا به بخشهای قابلآموزش برسد؛ فقط خود آن پارامتر با optimizer بهروزرسانی نمیشود.
در Full Fine-tuning مدل از وزنهای پیشآموزشدیده شروع میکند، اما تقریباً تمام آن وزنها روی دادهٔ جدید قابلتغییرند.
اگر وزنهای مدل را با θ و دادهٔ مسئلهٔ خودمان را با D نشان بدهیم، هدف این است:
هدف Full Fine-tuning
یعنی آموزش از یک مقدار رندوم شروع نمیشود. نقطهٔ شروع همان دانش قبلی است، اما optimizer اجازه دارد تمام فضای وزنها را جستوجو کند.
بزرگترین مزیتش آزادی است. مدل مجبور نیست تغییر مسئلهٔ جدید را در چند پارامتر کوچک جا بدهد. اگر دادهٔ کافی داشته باشیم، دامنهٔ جدید واقعاً با pretraining متفاوت باشد و منابع محاسباتی مناسب در اختیارمان باشد، تغییر همهٔ لایهها میتواند بیشترین ظرفیت سازگاری را ایجاد کند.
مثلاً شاید نمایش ورودی، Attention و Head خروجی همگی برای مسئلهٔ جدید نیاز به تغییر جدی داشته باشند. Full Fine-tuning جلوی هیچکدام را نمیگیرد.
مشکل اول هزینهٔ حافظه است. برای آموزش، فقط خود وزنها را نگه نمیداریم. معمولاً به gradient، وضعیتهای optimizer و activationهای Forward Pass هم نیاز داریم.
برای Adam، بهصورت تقریبی ممکن است برای هر پارامتر این موارد را داشته باشیم:
وزن مدل
گرادیان
نسخهٔ دقیقتر وزن در بعضی تنظیمهای mixed precision
میانگین اول Adam
میانگین دوم Adam
در یک برآورد رایج، فقط وزنها، gradient و stateهای Adam میتوانند حدود ۱۶ بایت بهازای هر پارامتر مصرف کنند. مقدار واقعی به precision، optimizer و روش توزیع آموزش وابسته است و activationها هم جدا به این حافظه اضافه میشوند.
برآورد سادهٔ حافظهٔ وضعیت آموزش
این عدد هنوز activationها، batch، بافرهای موقت و سربار framework را حساب نکرده است. برای مدلهای چندمیلیاردپارامتری، هزینه خیلی سریع بزرگ میشود.
مشکل دوم ذخیرهسازی است. اگر مدل را برای ده مسئله Full Fine-tune کنیم، معمولاً ده نسخهٔ تقریباً کامل از وزنها خواهیم داشت.
Backbone عمومی + وزنهای کامل مسئلهٔ A
Backbone عمومی + وزنهای کامل مسئلهٔ B
Backbone عمومی + وزنهای کامل مسئلهٔ C
مشکل سوم overfitting است. یک مدل بزرگ آزادی زیادی برای حفظکردن dataset کوچک ما دارد. تعداد بیشتر پارامترهای قابلآموزش همیشه به معنی یادگیری بهتر نیست.
مشکل چهارم فراموشی است. اگر وزنها را بیشازحد به سمت یک دامنهٔ کوچک ببریم، ممکن است بخشی از توانایی عمومی مدل ضعیف شود. به این پدیده معمولاً catastrophic forgetting میگویند.
قبل از اضافهکردن پارامتر جدید، یک راه ساده این است که بیشتر مدل را freeze کنیم و فقط چند لایهٔ آخر، LayerNormها یا Head خروجی را آموزش بدهیم.
لایههای ابتدایی → frozen
لایههای میانی → frozen
چند لایهٔ آخر → trainable
Head خروجی → trainable
این روش تعداد پارامترهای قابلآموزش را کم میکند و پیادهسازی سادهای دارد. اما انتخاب اینکه کدام لایهها باز شوند همیشه واضح نیست. اگر تغییر موردنیاز در سراسر مدل پخش شده باشد، بازکردن فقط لایههای آخر ممکن است کافی نباشد.
PEFT تلاش میکند با طراحی پارامترهای کوچک و هدفمند، راه انعطافپذیرتری ارائه دهد.
PEFT مخفف Parameter-Efficient Fine-Tuning است؛ یعنی Fine-tuning با تعداد کمی پارامتر قابلآموزش.
ایدهٔ اصلی این است:
Backbone بزرگ و ازپیشآموزشدیده → frozen
بخش کوچک مخصوص مسئله → trainable
بهجای اینکه برای هر مسئله یک کپی کامل از مدل ذخیره کنیم، یک Backbone مشترک نگه میداریم و برای هر مسئله فقط یک فایل کوچک ذخیره میکنیم:
یک Backbone مشترک
├── پارامترهای کوچک مسئلهٔ فروش
├── پارامترهای کوچک مسئلهٔ ترافیک
├── پارامترهای کوچک مسئلهٔ انرژی
└── پارامترهای کوچک مسئلهٔ مالی
این ایده سه فایدهٔ مهم دارد:
اما یک سوءتفاهم مهم را همینجا برطرف کنیم: PEFT به این معنی نیست که خود مدل بزرگ دیگر لازم نیست در حافظه باشد. Backbone هنوز باید Forward Pass را انجام بدهد. PEFT بیشتر هزینهٔ آموزش و ذخیرهٔ پارامترهای مخصوص هر وظیفه را کم میکند؛ مقدار کاهش حافظه و سرعت به معماری و پیادهسازی وابسته است.
کلمهٔ Adapter در متنها و کتابخانهها با دو معنی دیده میشود.
معنی دقیق معماری این است:
یک ماژول عصبی کوچک که داخل یا کنار لایههای مدل قرار میگیرد
مثلاً Bottleneck Adapter که کمی جلوتر میسازیم.
اما در بعضی APIها، واژهٔ adapter به شکل عمومیتری برای هر بستهٔ کوچک PEFT استفاده میشود. در این کاربرد، فایل LoRA هم ممکن است «LoRA adapter» نامیده شود.
پس وقتی جایی نوشته شده adapter، باید از متن تشخیص بدهیم منظور کدام است:
در این مقاله، هرجا Adapter را بهعنوان نام یک روش مینویسیم، منظور همان Bottleneck Adapter است.
فرض کن در یک Transformer، خروجی یک block برداری با اندازهٔ ۱۲۸۰ است. Adapter ابتدا این بردار را به یک فضای کوچکتر، مثلاً ۶۴بعدی، میبرد؛ آن را پردازش میکند و دوباره به ۱۲۸۰ بعد برمیگرداند.
بردار 1280بعدی
↓ Down Projection
بردار 64بعدی
↓ Activation
بردار 64بعدی
↓ Up Projection
بردار 1280بعدی
↓
جمع با مسیر اصلی
به فضای کوچک وسط bottleneck میگوییم؛ چون اطلاعات مجبور میشود از یک گلوگاه باریک عبور کند.
اگر ورودی Adapter را h بنامیم، شکل سادهٔ آن این است:
خروجی Bottleneck Adapter
عبارت h + up همان residual connection است. مسیر اصلی دستنخورده باقی میماند و Adapter فقط یک اصلاح کوچک به آن اضافه میکند.
یک جایگذاری رایج این است که Adapter بعد از Attention و بعد از Feed-Forward Network قرار بگیرد:
Self-Attention
↓
Adapter
↓
Feed-Forward Network
↓
Adapter
اما طراحیهای دیگری هم وجود دارند؛ ممکن است در هر block فقط یک Adapter، در مسیر موازی یا در محل دیگری قرار بگیرد. بنابراین وقتی تعداد پارامترها یا سرعت دو روش Adapter را مقایسه میکنیم، باید جایگذاری دقیق آنها را هم بدانیم.
اگر اندازهٔ نمایش اصلی را D و اندازهٔ bottleneck را m بنامیم، دو ماتریس اصلی داریم:
تعداد پارامترهای اصلی یک Adapter
برای D = 1280 و m = 64:
رابطهٔ اصلی
جایگذاری مقادیر
این محاسبه biasها و LayerNormهای احتمالی را کنار گذاشته است. اگر در هرکدام از ۲۰ block دو Adapter بگذاریم، تقریباً ۶.۵ میلیون پارامتر اضافه میشود؛ یعنی حدود ۳.۳ درصد یک مدل ۲۰۰میلیونی. طراحی واقعی میتواند کمتر یا بیشتر باشد.
در Forward Pass، داده از Backbone ثابت و Adapterهای جدید عبور میکند. Loss مثل همیشه محاسبه میشود. در Backpropagation گرادیان از کل مسیر رد میشود، اما optimizer فقط وزنهای Adapter و بخشهای دیگری را که عمداً باز گذاشتهایم تغییر میدهد.
Backbone → در Forward Pass فعال، اما frozen
Adapter → در Forward Pass فعال و trainable
Head → بسته به طراحی، frozen یا trainable
این تفاوت میان «گرادیان از یک بخش عبور میکند» و «پارامترهای آن بخش بهروزرسانی میشوند» خیلی مهم است.
Adapter یک ماژول مستقل و قابلفهم است. میتوانیم برای هر وظیفه Adapter جدا داشته باشیم، بدون اینکه وزنهای اصلی مدل را تغییر بدهیم. تعویض مسئله میتواند به معنی تعویض همین ماژول کوچک باشد.
همچنین Adapter یک شبکهٔ غیرخطی واقعی دارد. بهخاطر activation داخل bottleneck، میتواند تغییرات پیچیدهتری از یک تبدیل خطی ساده بسازد.
Adapter عملیات جدیدی وارد Forward Pass میکند. چون داده باید از Down Projection، activation و Up Projection عبور کند، ممکن است latency استنتاج افزایش پیدا کند.
Adapter را معمولاً نمیتوان مثل LoRA بهسادگی در یک ماتریس خطی موجود ادغام کرد؛ چون میان دو projection یک تابع غیرخطی قرار دارد. همچنین اندازهٔ bottleneck و محل قرارگیری آن روی کیفیت و هزینه اثر زیادی دارند.
حالا به یکی از مهمترین روشهای PEFT میرسیم: Low-Rank Adaptation یا LoRA.
برای فهم LoRA یک لایهٔ خطی معمولی را در نظر بگیر:
لایهٔ خطی اصلی
در Full Fine-tuning خود ماتریس بزرگ W تغییر میکند. میتوانیم وزن جدید را اینطور بنویسیم:
تغییر وزن در Fine-tuning
اگر W یک ماتریس بسیار بزرگ باشد، خود ΔW هم به همان اندازه بزرگ است. LoRA یک فرض مهم وارد میکند:
شاید تغییر موردنیاز برای سازگارکردن مدل، در تمام جهتهای ممکن فضای وزن پخش نشده باشد. شاید بتوان آن را با تعداد کمی جهت مهم توضیح داد.
پس بهجای یادگرفتن مستقیم ΔW، آن را حاصلضرب دو ماتریس باریک میگیریم:
ایدهٔ اصلی LoRA
در اینجا:
W وزن اصلی و ثابت مدل است.A ورودی را به یک فضای کوچک با اندازهٔ r میبرد.B آن نمایش کوچک را به اندازهٔ خروجی برمیگرداند.r همان rank انتخابی LoRA است.α ÷ r مقیاسی است که شدت تغییر LoRA را کنترل میکند.اینجا همان بخش سادهای قرار دارد که کل 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 Fine-tuning تعداد پارامترهای تغییر با ضرب دو بُعد بزرگ به دست میآید. در LoRA تعداد پارامترها جمع اندازهٔ دو ماتریس باریک است. چون rank یعنی ۸ بسیار کوچکتر از ۱۲۸۰ است، این جمع خیلی کوچکتر از ماتریس کامل میشود.
پس کمشدن پارامترها صرفاً بهخاطر وجود دو ماتریس نیست؛ به این دلیل است که فضای میانی میان آنها عمداً باریک انتخاب شده است:
1280 → 8 → 1280
اگر rank را بهاندازهٔ بُعد اصلی بزرگ کنیم، بخش زیادی از مزیت پارامتری LoRA از بین میرود.
Rank در اینجا یعنی تعداد جهتهای مستقلی که برای بیان تغییر وزن در اختیار LoRA میگذاریم.
اگر r = 1 باشد، LoRA فقط یک مسیر فشرده برای تغییر رفتار لایه دارد. اگر r = 8 باشد، هشت جهت میانی دارد. rank بزرگتر ظرفیت بیشتری میدهد، اما پارامتر و هزینهٔ بیشتری هم ایجاد میکند.
میتوانیم آن را اینطور تصور کنیم:
Full Fine-tuning:
تغییر آزاد در یک فضای بسیار بزرگ
LoRA با rank پایین:
تغییر مدل در چند جهت یادگرفتنی و مهم
LoRA ادعا نمیکند خود وزن اصلی W کمرتبه است. فرضش این است که تغییر موردنیاز برای Adaptation را میتوان تقریباً با یک ساختار کمرتبه بیان کرد.
فرض کن یک لایهٔ خطی ورودی و خروجی ۱۲۸۰بعدی دارد. ماتریس اصلی آن این تعداد پارامتر دارد:
رابطهٔ اصلی
جایگذاری مقادیر
حالا LoRA را با rank برابر ۸ اضافه کنیم. ماتریس A شکل 8 × 1280 و ماتریس B شکل 1280 × 8 دارد:
رابطهٔ اصلی
جایگذاری مقادیر
نسبت این دو:
رابطهٔ اصلی
جایگذاری مقادیر
پس برای این لایه، بهجای تغییر بیش از ۱.۶ میلیون پارامتر، فقط حدود ۲۰ هزار پارامتر آموزش میبینند.
این نسبت فقط مربوط به یک ماتریس است. نسبت کل مدل به این بستگی دارد که LoRA را روی کدام ماتریسها، در چند لایه و با چه rankی قرار بدهیم.
در Transformer چند ماتریس خطی مهم داریم:
Attention:
Wq, Wk, Wv, Wo
Feed-Forward Network:
Wup, Wdown و ماتریسهای مشابه
در بعضی تنظیمها LoRA فقط روی Query و Value قرار میگیرد. در بعضی دیگر روی تمام projectionهای Attention یا حتی همهٔ لایههای خطی اعمال میشود.
هدفگرفتن ماتریسهای بیشتر ظرفیت سازگاری را بالا میبرد، اما تعداد پارامترهای قابلآموزش و مصرف حافظه هم بیشتر میشود. بنابراین عبارت «از LoRA استفاده کردیم» هنوز توضیح کاملی نیست. باید حداقل این موارد را بدانیم:
α چه مقداری داشته است؟در یک مقداردهی اولیهٔ رایج، A با مقدارهای کوچک یا تصادفی شروع میشود و B از صفر شروع میشود.
شروع LoRA بدون تغییر خروجی مدل
در لحظهٔ شروع، شاخهٔ LoRA چیزی به خروجی اضافه نمیکند و مدل دقیقاً مثل Backbone اصلی رفتار میکند. سپس Backpropagation کمکم A و B را طوری تغییر میدهد که اصلاح مناسب مسئلهٔ جدید ساخته شود.
جزئیات دقیق initialization میتواند میان پیادهسازیها فرق کند، اما اصل مهم این است که شاخهٔ LoRA معمولاً بهصورت یک تغییر خنثی آغاز میشود.
Forward Pass این دو مسیر را با هم جمع میکند:
مسیر اصلی: x → W → Wx
مسیر LoRA: x → A → B → BAx
↓
جمع دو مسیر
Loss از خروجی نهایی محاسبه میشود. گرادیان به هر دو مسیر میرسد، اما W دارای requires_grad = False است. بنابراین optimizer فقط A و B را تغییر میدهد.
پارامترهای قابلآموزش در LoRA
در عمل ممکن است Head خروجی، بعضی biasها یا LayerNormها را هم باز بگذاریم، اما این یک انتخاب جداست و باید صریح گزارش شود.
چون W و BA هر دو تبدیل خطیاند، بعد از آموزش میتوانیم آنها را در یک ماتریس ادغام کنیم:
ادغام LoRA با وزن اصلی
بعد از merge دیگر لازم نیست شاخهٔ A و B جداگانه در Forward Pass اجرا شود. به همین دلیل LoRA میتواند بدون latency اضافهٔ قابلتوجهِ شاخهٔ جدا، برای inference استفاده شود.
البته نگهداشتن LoRA بهصورت جدا هم مزیت دارد: یک Backbone را در حافظه نگه میداریم و 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 و محل تزریق پارامترها وابسته است.
نه. این دو فقط دو شاخهٔ مهماند.
بهجای تغییر وزنهای مدل، چند بردار قابلآموزش شبیه token مجازی به ورودی اضافه میشود. این بردارها متن واقعی نیستند؛ پارامترهاییاند که optimizer یاد میگیرد.
بردارهای قابلآموزش بهصورت prefix در لایههای Attention در اختیار مدل قرار میگیرند. مدل میتواند هنگام ساخت خروجی به این prefixهای مجازی توجه کند.
بهجای ماتریسهای LoRA، چند بردار کوچک یاد گرفته میشود که activationهای Key، Value و Feed-Forward را مقیاس میدهند.
بیشتر وزنها ثابت میمانند و فقط biasها آموزش میبینند. این روش بسیار ساده و کمپارامتر است، ولی ظرفیت سازگاری آن هم محدودتر است.
این روشها را میتوان بر اساس نوع تغییر اینطور دید:
اضافهکردن ماژول جدید → Bottleneck Adapter
بازپارامتردهی تغییر وزن → LoRA
یادگیری ورودی مجازی → Prompt / Prefix Tuning
مقیاسدادن activationها → IA³
انتخاب چند پارامتر موجود → BitFit
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 سری زمانی بهترین باشد.
این کد فقط خود ایده را نشان میدهد و به معماری خاصی وابسته نیست:
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 اضافه کنیم.
کتابخانهٔ 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. برای بارگذاری دوباره، همچنان به همان مدل پایهٔ سازگار نیاز داریم.
همهٔ Foundation Modelها با کلاسهای استاندارد Hugging Face Transformers ساخته نشدهاند. ممکن است مدل سری زمانی ما PyTorch سفارشی داشته باشد یا نام لایههایش برای PEFT شناختهشده نباشد.
در این حالت سه راه داریم:
اصل ریاضی LoRA به زبان یا سری زمانی وابسته نیست؛ هرجا یک تبدیل خطی مناسب داشته باشیم میتوانیم تغییر کمرتبه اضافه کنیم. اما انتخاب لایهٔ درست و اتصال سالم آن به optimizer بخش مهم کار است.
در یک 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 واقعاً لازم بوده یا نه.
در متن و تصویر، میتوانیم نمونهها را تصادفی تقسیم کنیم؛ اما در سری زمانی، split تصادفی اغلب آینده را وارد گذشته میکند.
ترتیب درست معمولاً شبیه این است:
گذشتهٔ قدیمیتر → Train
بازهٔ بعدی → Validation
جدیدترین بازه → Test
تمام تصمیمها، از انتخاب rank گرفته تا learning rate و تعداد epoch، باید با Train و Validation گرفته شوند. Test باید تا پایان دستنخورده بماند.
اگر چند دارایی یا چند سری داریم، دو نوع تعمیم را باید جدا کنیم:
یک مدل ممکن است در اولی عالی و در دومی ضعیف باشد. این دو سؤال علمی متفاوتاند.
هیچ روش Adaptation نمیتواند دادهٔ بد را جادویی اصلاح کند. اگر timestampها اشتباه باشند، leakage داشته باشیم، target را نادرست ساخته باشیم یا دورهٔ Train نمایندهٔ Test نباشد، LoRA و Adapter فقط همان مشکل را دقیقتر یاد میگیرند.
در سری زمانی باید قبل از آموزش این موارد روشن باشند:
PEFT روش بهینهسازی پارامترهاست؛ جای تعریف درست مسئله را نمیگیرد.
rank کوچک پارامتر و حافظهٔ کمتری مصرف میکند، اما ظرفیت تغییر محدودتری دارد. rank بزرگ ظرفیت بیشتری ایجاد میکند، ولی ممکن است هزینه و خطر overfitting را بالا ببرد.
برای شروع میتوان چند مقدار مثل ۴، ۸، ۱۶ و ۳۲ را در یک بودجهٔ آزمایش مشخص مقایسه کرد. اما عدد مناسب یک قانون جهانی نیست. rank باید همراه با target moduleها دیده شود؛ LoRA با rank هشت روی تمام لایههای خطی ممکن است از LoRA با rank شانزده فقط روی دو projection پارامتر بیشتری داشته باشد.
مقایسهٔ منصفانه باید حداقل این موارد را گزارش کند:
rank
alpha
target modules
trainable parameter count
learning rate
training steps
validation protocol
در 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.
Backbone frozen هنوز Forward Pass انجام میدهد و activation تولید میکند. همچنین برای رساندن گرادیان به Adapterهای میانی، بخشی از گراف محاسباتی لازم است. پس کاهش پارامتر قابلآموزش الزاماً به همان نسبت آموزش را سریع نمیکند.
اگر target_modules اشتباه باشد، ممکن است هیچ لایهٔ مؤثری آموزش نبیند یا LoRA روی بخش نامناسبی قرار بگیرد. همیشه تعداد پارامترهای trainable و نام ماژولهای هدف را بررسی کن.
هدف Adaptation حفظکردن Train نیست. باید Validation و Test زمانی داشته باشیم. در dataset کوچک، روش پرظرفیت ممکن است Training Loss عالی و تعمیم ضعیف تولید کند.
فریزکردن Backbone جلوی تغییر مستقیم وزنهای اصلی را میگیرد، اما Adapter میتواند خروجی مدل را شدیداً منحرف کند. PEFT خطر فراموشی را کاهش میدهد، نه اینکه آن را از نظر رفتاری غیرممکن کند.
فایل LoRA کوچک است، اما برای اجرا همچنان Backbone لازم است. یک LoRA دهمگابایتی بهتنهایی مدل کامل نیست.
روی دادهٔ کم، آزادی زیاد 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
این درخت حکم قطعی نیست؛ نقطهٔ شروع آزمایش است.
برای اینکه نتیجهٔ علمی و قابلاعتماد باشد، میتوانیم این ترتیب را اجرا کنیم:
خروجی نهایی فقط یک جدول 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 با وزن اصلی را دارد. هیچکدام همیشه برنده نیستند.
انتخاب درست از اسم روش شروع نمیشود؛ از یک سؤال روشن شروع میشود:
مدل برای سازگارشدن با دادهٔ ما دقیقاً به چه مقدار آزادی نیاز دارد، و ما حاضر هستیم برای آن آزادی چه هزینهای بپردازیم؟
این مقاله با روایت و مثالهای تألیفی نوشته شده و مفاهیم فنی آن بر منابع اولیه و مستندات رسمی زیر تکیه دارد: