فاز 3 · درس 6

پیاده‌سازی بهینه‌سازهای شبکه عصبی از صفر: از SGD تا AdamW

نوع
ساختنی
زمان
حدود 75 دقیقه
زبان‌ها
Python
پیش‌نیاز
درس 03.05 (توابع زیان)
یک شیب‌سنج فلزی و یک مسیر شیب‌دار سنگی متقاطع که جریان‌های حرکتی متفاوتی را در یک دره باریک نشان می‌دهند
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. گرادیان کاهشی تصادفی (SGD)
  5. تکانه (Momentum)
  6. RMSProp
  7. Adam: ترکیب تکانه و RMSProp
  8. AdamW: کاهش وزن به روش درست
  9. نرخ یادگیری: حیاتی‌ترین هایپرپارامتر
  10. مقایسه‌ی بهینه‌سازها
  11. موارد برتری هر بهینه‌ساز
  12. پیاده‌سازی گام‌به‌گام
  13. گام 1: گرادیان کاهشی ساده (Vanilla SGD)
  14. گام 2: SGD مجهز به تکانه
  15. گام 3: Adam
  16. گام 4: AdamW
  17. گام 5: مقایسه‌ی فرایند آموزش
  18. پیاده‌سازی کاربردی با ابزارها
  19. خروجی نهایی
  20. تمرین‌ها
  21. اصطلاحات کلیدی
  22. برای مطالعه‌ی بیشتر

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

الگوریتم‌های مدرن مانند Adam و AdamW با ترکیب تکانه و نرخ یادگیری انطباقی به ازای هر پارامتر، این موانع را برطرف کرده‌اند. در این درس، سازوکار ریاضی و منطق بهینه‌سازهای پرکاربرد از جمله SGD، Momentum، RMSProp، Adam و تفاوت حیاتی AdamW را بررسی می‌کنیم و هر یک را از صفر در Python پیاده‌سازی خواهیم کرد.

گرادیان کاهشی به شما می‌گوید به کدام سمت بروید، اما درباره‌ی این‌که چقدر یا با چه سرعتی حرکت کنید هیچ حرفی نمی‌زند. SGD شبیه یک قطب‌نماست؛ اما Adam درست مثل یک GPS مجهز به داده‌های ترافیک زنده عمل می‌کند.

اهداف یادگیری

  • پیاده‌سازی بهینه‌سازهای SGD، SGD مجهز به تکانه (momentum)، Adam و AdamW از صفر در Python
  • توضیح نحوه‌ی جبران مقداردهی اولیه‌ی صفر در گشتاورهای Adam با کمک تصحیح سوگیری (bias correction) در گام‌های آغازین آموزش
  • بررسی دلیل تعمیم بهتر AdamW در مقایسه با Adam مجهز به منظم‌سازی L2 در یک مسئله‌ی یکسان
  • انتخاب بهینه‌ساز مناسب و هایپرپارامترهای پیش‌فرض برای ترنسفورمرها، CNNها، GANها و فاین‌تیونینگ

صورت مسئله

گرادیان‌ها را محاسبه کرده‌اید و می‌دانید وزن شماره‌ی 4,721 باید 0.003 واحد کم شود تا تابع زیان (loss) کاهش یابد. اما این 0.003 با چه مقیاسی سنجیده می‌شود؟ ضریب تغییرات آن چیست؟ و آیا باید در گام 1 دقیقاً به همان اندازه‌ی گام 1,000 جابه‌جا شوید؟

گرادیان کاهشی ساده (Vanilla gradient descent) در هر گام، یک نرخ یادگیری یکسان را روی تمام پارامترها اعمال می‌کند: w=w−lr⋅gradientw = w - \text{lr} \cdot \text{gradient}. این رویکرد سه مشکل به وجود می‌آورد که در عمل آموزش شبکه‌های عصبی را عذاب‌آور می‌کنند:

نخست، نوسان. چشم‌انداز زیان (loss landscape) به‌ندرت شبیه یک کاسه‌ی هموار است؛ بیشتر به دره‌ای کشیده و باریک شباهت دارد. بردار گرادیان معمولاً در جهت دیواره‌های دره (راستای شیب تند) است، نه در امتداد بستر آن (راستای کم‌شیب). در نتیجه گرادیان کاهشی مدام در راستای این بُعد کم‌عرض به این سو و آن سو تاب می‌خورد و در امتداد مسیر اصلی پیشرفت بسیار ناچیزی دارد. حتماً این حالت را دیده‌اید: تابع زیان ابتدا با شیب زیادی افت می‌کند و سپس به یک سطح ثابت می‌رسد؛ نه به این خاطر که مدل همگرا شده، بلکه از شدت نوسان در جا می‌زند.

دوم، ناکارآمدیِ یک نرخ یادگیری ثابت برای همه‌ی پارامترها. بعضی از وزن‌ها به به‌روزرسانی‌های بزرگ نیاز دارند (چون در مراحل اولیه‌ی کم‌برازش قرار دارند). در مقابل، برخی وزن‌ها به تغییرات بسیار ریزی محتاج‌اند (چون به مقدار بهینه‌ی خود رسیده‌اند). نرخ یادگیری مناسب برای گروه اول، دسته‌ی دوم را خراب می‌کند و برعکس.

سوم، نقاط زینی (saddle points). در فضاهای چندبُعدی، چشم‌انداز زیان بخش‌های مسطح و پهناوری دارد که گرادیان در آن‌ها نزدیک به صفر است. گرادیان کاهشی ساده با سرعتی هم‌اندازه‌ی خود گرادیان در این دشت‌ها می‌خزد؛ یعنی عملاً متوقف می‌شود. به نظر می‌رسد مدل گیر کرده، اما این‌طور نیست؛ فقط در دشتی آرام افتاده که کمی جلوتر از آن، مسیر فرود مفیدی کشیده شده است. با این حال SGD هیچ سازوکاری برای push کردن و عبور از این نواحی ندارد.

الگوریتم Adam هر سه مشکل را حل می‌کند. این بهینه‌ساز برای هر پارامتر دو میانگین متحرک ذخیره می‌کند: میانگین گرادیان (تکانه، برای مهار نوسان) و میانگین مجذور گرادیان (نرخ انطباقی، برای هماهنگی با مقیاس‌های مختلف). این دو ویژگی در کنار تصحیح سوگیری برای گام‌های آغازین، بهینه‌سازی می‌سازند که در 80% مسائل با همان هایپرپارامترهای پیش‌فرض جواب می‌دهد. در این درس آن را از صفر می‌سازیم تا متوجه شوید چرا و چه زمانی در آن 20% باقی‌مانده شکست می‌خورد.

مفاهیم پایه

گرادیان کاهشی تصادفی (SGD)

ساده‌ترین بهینه‌ساز ممکن: گرادیان را روی یک دسته‌ی کوچک (mini-batch) حساب کنید و در جهت مخالف آن گام بردارید.

w=w−lr⋅gradientw = w - \text{lr} \cdot \text{gradient}

کلمه‌ی «تصادفی» یعنی به جای کل داده‌ها، از یک زیرمجموعه‌ی تصادفی (دسته‌ی کوچک) برای برآورد گرادیان استفاده می‌کنید. این نویز در عمل اتفاقاً مفید است و به فرار از کمینه‌های محلی تند کمک می‌کند؛ اما از سوی دیگر نوسان هم به راه می‌اندازد.

نرخ یادگیری تنها پیچ تنظیم شماست. اگر خیلی زیاد باشد، تابع زیان واگرا می‌شود؛ اگر خیلی کم باشد، فرایند آموزش تا ابد طول می‌کشد. مقدار بهینه به معماری، داده‌ها، اندازه‌ی دسته (batch size) و فاز فعلی آموزش بستگی دارد. برای SGD ساده روی شبکه‌های مدرن، مقادیر رایج بین 0.01 تا 0.1 تغییر می‌کنند؛ اما حتی در طول یک چرخه‌ی آموزش، نرخ یادگیری ایده‌آل ثابت نمی‌ماند.

تکانه (Momentum)

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

mt=β mt−1+gradientw=w−lr⋅mt\begin{aligned} m_t &= \beta \, m_{t-1} + \text{gradient} \\ w &= w - \text{lr} \cdot m_t \end{aligned}

ضریب بتا (معمولاً 0.9) مشخص می‌کند چه مقدار از تاریخچه‌ی قبل نگه‌داری شود. وقتی β=0.9\beta = 0.9 باشد، تکانه تقریباً برابر میانگین 10 گرادیان اخیر خواهد بود (1/(1−0.9)=101 / (1 - 0.9) = 10).

دلیل مهار نوسان توسط تکانه این است: گرادیان‌های هم‌جهت با هم جمع و تقویت می‌شوند، ولی گرادیان‌هایی که تغییر جهت می‌دهند اثر یکدیگر را خنثی می‌کنند. در آن دره‌ی باریک، مؤلفه‌ی عمودی در هر گام علامت عوض می‌کند و میرا می‌شود؛ در عوض مؤلفه‌ی طولی ثابت می‌ماند و شتاب می‌گیرد. نتیجه، پیشروی روان و پرشتاب در مسیر مفید است.

یک عدد واقعی: اجرای SGD ساده روی چشم‌انداز زیانی با شرایط نامساعد شاید به 10,000 گام نیاز داشته باشد. اجرای SGD همراه با تکانه (β=0.9\beta = 0.9) روی همان مسئله معمولاً بین 3,000 تا 5,000 گام حل می‌شود؛ یعنی شتاب حاصل به هیچ وجه جزئی نیست.

RMSProp

اولین روش نرخ یادگیری انطباقی به ازای هر پارامتر که در عمل جواب داد. جفری هینتون این روش را در یکی از دوره‌های Coursera مطرح کرد و هرگز در قالب مقاله‌ای رسمی به چاپ نرسید.

st=β st−1+(1−β)⋅gradient2w=w−lr⋅gradientst+ϵ\begin{aligned} s_t &= \beta \, s_{t-1} + (1 - \beta) \cdot \text{gradient}^2 \\ w &= w - \text{lr} \cdot \frac{\text{gradient}}{\sqrt{s_t} + \epsilon} \end{aligned}

متغیر sts_t میانگین متحرک مجذور گرادیان‌ها را ثبت می‌کند. پارامترهایی که مداوم گرادیان‌های بزرگ دارند، بر عددی بزرگ تقسیم می‌شوند (نرخ یادگیری مؤثرِ کمتر) و پارامترهایی با گرادیان‌های ریز، بر عددی کوچک تقسیم می‌شوند (نرخ یادگیری مؤثرِ بیشتر).

این سازوکار مشکل «نرخ یادگیری یکسان برای همه‌ی پارامترها» را حل می‌کند. وزنی که تا الان آپدیت‌های درشتی گرفته، احتمالاً نزدیک به مقصد است؛ پس باید سرعتش را کم کنیم. در مقابل، وزنی که تغییرات ریزی داشته، احتمالاً هنوز خوب آموزش ندیده است؛ پس باید سرعتش را بالا ببریم.

اپسیلون (معمولاً 1e-8) جلوی خطای تقسیم بر صفر را در پارامترهایی می‌گیرد که هنوز به‌روزرسانی نشده‌اند.

Adam: ترکیب تکانه و RMSProp

الگوریتم Adam هر دو رویکرد را با هم درمی‌آمیزد و برای هر پارامتر دو میانگین متحرک نمایی را حفظ می‌کند:

mt=β1mt−1+(1−β1)⋅gradient(first moment: mean)vt=β2vt−1+(1−β2)⋅gradient2(second moment: variance)\begin{aligned} m_t &= \beta_1 m_{t-1} + (1 - \beta_1) \cdot \text{gradient} && \text{(first moment: mean)} \\ v_t &= \beta_2 v_{t-1} + (1 - \beta_2) \cdot \text{gradient}^2 && \text{(second moment: variance)} \end{aligned}

تصحیح سوگیری همان جزئیات تعیین‌کننده‌ای است که بیشتر منابع از آن رد می‌شوند. در گام اول، m1=(1−β1)⋅gradientm_1 = (1 - \beta_1) \cdot \text{gradient}. با β1=0.9\beta_1 = 0.9، این مقدار به 0.1⋅gradient0.1 \cdot \text{gradient} می‌رسد؛ یعنی ده برابر کوچک‌تر از اندازه‌ی واقعی، چون میانگین متحرک هنوز گرم نشده است. تصحیح سوگیری این کاستی را جبران می‌کند:

m^=mt1−β1tv^=vt1−β2t\begin{aligned} \hat{m} &= \frac{m_t}{1 - \beta_1^t} \\ \hat{v} &= \frac{v_t}{1 - \beta_2^t} \end{aligned}

در گام 1 با فرض β1=0.9\beta_1 = 0.9، مقدار m^=m1/(1−0.9)=m1/0.1=\hat{m} = m_1 / (1 - 0.9) = m_1 / 0.1 = دقیقاً با گرادیان واقعی برابر می‌شود. در گام 100، (1−0.9100)(1 - 0.9^{100}) تقریباً به 1.0 می‌رسد؛ بنابراین اثر تصحیح از بین می‌رود. تصحیح سوگیری صرفاً در حدود 10 گام آغازین حیاتی است و بعد از حدود 50 گام هیچ تأثیری ندارد.

به‌روزرسانی نهایی:

w=w−lr⋅m^v^+ϵw = w - \text{lr} \cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon}

مقادیر پیش‌فرض در Adam: lr=0.001\text{lr} = 0.001، β1=0.9\beta_1 = 0.9، β2=0.999\beta_2 = 0.999 و ϵ=10−8\epsilon = 10^{-8}. این تنظیمات برای 80% سناریوها کافی هستند. اگر جواب نگرفتید، اول نرخ یادگیری را تغییر دهید؛ بعد به سراغ β2\beta_2 بروید. تقریباً هیچ‌وقت به β1\beta_1 یا ϵ\epsilon دست نزنید.

AdamW: کاهش وزن به روش درست

منظم‌سازی L2 عبارت λw2\lambda w^2 را به تابع زیان اضافه می‌کند. در SGD ساده، این کار عیناً معادل کاهش وزن (weight decay) یعنی کم کردن λw\lambda w از وزن در هر گام است؛ اما در Adam این برابری نقض می‌شود.

یافته‌ی کلیدی لاشچیلُف و هوتر (Loshchilov & Hutter) این بود: اگر L2 را به تابع زیان بیفزایید و سپس Adam گرادیان را پردازش کند، نرخ یادگیری انطباقی ناخواسته جمله‌ی جریمه را هم تغییر مقیاس می‌دهد. در نتیجه، پارامترهایی با واریانس گرادیان بالا جریمه‌ی کمتری می‌گیرند و پارامترهایی با واریانس پایین، بیش از حد منقبض می‌شوند. این رویکرد نادرست است؛ شما فارغ از آمار گرادیان هر پارامتر، جریمه‌ای یکنواخت می‌خواهید.

الگوریتم AdamW با اعمال مستقیم کاهش وزن روی خودِ وزن‌ها پس از اتمام به‌روزرسانی Adam، این ضعف را رفع می‌کند:

w=w−lr⋅m^v^+ϵ−lr⋅λww = w - \text{lr} \cdot \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon} - \text{lr} \cdot \lambda w

در این ساختار، جمله‌ی کاهش وزن (lr⋅λw\text{lr} \cdot \lambda w) تحت تأثیر ضریب انطباقی Adam قرار نمی‌گیرد و همه‌ی وزن‌ها به تناسبی مساوی مهار می‌شوند.

شاید تغییری جزئی به نظر برسد، اما واقعاً این‌طور نیست. AdamW تقریباً در تمام تسک‌ها نسبت به ترکیب Adam و تنظیم‌کننده‌ی L2 به نقطه‌ی بهینه‌ی بهتری همگرا می‌شود. هم‌اکنون این روش بهینه‌ساز پیش‌فرض PyTorch برای آموزش ترنسفورمرها، مدل‌های انتشار و اغلب شبکه‌های مدرن است. مدل‌های BERT، GPT، LLaMA و Stable Diffusion همگی با AdamW آموزش دیده‌اند.

نرخ یادگیری: حیاتی‌ترین هایپرپارامتر

نرخ یادگیریبیش از حد بالا (lr 0.01)مناسببیش از حد پایین (lr < 0.00001)انفجار تابع زیان (loss)وزن‌های NaNتوقف آموزشکاهش پیوسته زیانرسیدن به کمینه مطلوبتعمیم‌پذیری خوبکاهش کند زیانگیر افتادن در کمینه نامطلوباتلاف توان پردازشیمعمولاً نیازمند زمان‌بندیWarmup: افزایش از 0 تا بیشینه1-10% ابتدای آموزشDecay: کاهش به مرور زمانکسینوسی یا خطی

اگر قرار است فقط یک متغیر را دستکاری کنید، آن متغیر نرخ یادگیری است. تغییر 10 برابری نرخ یادگیری، اثرگذارتر از هر تصمیم معماری دیگری خواهد بود که فکرش را بکنید. مقادیر پیش‌فرض رایج عبارت‌اند از:

  • برای SGD: مقدار lr = 0.01 تا 0.1
  • برای Adam/AdamW: مقدار lr = 1e-4 تا 3e-4
  • برای فاین‌تیونینگ مدل‌های ازپیش‌آموزش‌دیده: lr = 1e-5 تا 5e-5
  • گرم‌کردن نرخ یادگیری (warmup): افزایش خطی در بازه‌ی 1 تا 10 درصد نخست گام‌ها

مقایسه‌ی بهینه‌سازها

مسیر بهینه‌سازیSGDنوسان در عرض درهکند اما یافتن کمینه‌های مسطحSGD + تکانهمسیر روان‌تر3 برابر سریع‌تر از SGDAdamتطبیق به ازای هر پارامترهمگرایی سریعAdamWAdam + زوال وزن مناسببهترین تعمیم‌پذیری

موارد برتری هر بهینه‌ساز

ترنسفورمر / LLMCNN / ResNetGANفاین‌تیونینگهنوز مشخص نیستچه چیزی را آموزش می‌دهید؟نوع مدل؟AdamWlr=1e-4, wd=0.01-0.1SGD + تکانهlr=0.1, momentum=0.9Adamlr=2e-4, beta1=0.5AdamWlr=2e-5, wd=0.01شروع با AdamWlr=3e-4, wd=0.01
مسیر حرکت بهینه‌سازبهینه‌ساز را انتخاب کنید، lr را تغییر دهید
OPTIMIZER TRAJECTORYThe loss is a narrow ravine: gentle along x, twenty times steeper along y. Plain SGD bounces across the steep walls and crawls down the valley. Momentum smooths the bounce; Adam normalizes each direction so both axes converge at a similar rate.
تابع زیان (loss) مانند یک دره‌ی باریک است: در راستای x شیب ملایم و در راستای y بیست برابر تندتر است. SGD معمولی مدام بین دیواره‌های پرشیب نوسان می‌کند و به‌کندی در امتداد دره پیش می‌رود. تکانه (Momentum) این نوسانات را هموار می‌کند؛ Adam نیز هر جهت را نرمال‌سازی می‌کند تا هر دو محور با نرخی مشابه همگرا شوند.

پیاده‌سازی گام‌به‌گام

گام 1: گرادیان کاهشی ساده (Vanilla SGD)

class SGD:
    def __init__(self, lr=0.01):
        self.lr = lr

    def step(self, params, grads):
        for i in range(len(params)):
            params[i] -= self.lr * grads[i]

گام 2: SGD مجهز به تکانه

class SGDMomentum:
    def __init__(self, lr=0.01, beta=0.9):
        self.lr = lr
        self.beta = beta
        self.velocities = None

    def step(self, params, grads):
        if self.velocities is None:
            self.velocities = [0.0] * len(params)
        for i in range(len(params)):
            self.velocities[i] = self.beta * self.velocities[i] + grads[i]
            params[i] -= self.lr * self.velocities[i]

گام 3: Adam

import math

class Adam:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.epsilon = epsilon
        self.m = None
        self.v = None
        self.t = 0

    def step(self, params, grads):
        if self.m is None:
            self.m = [0.0] * len(params)
            self.v = [0.0] * len(params)

        self.t += 1

        for i in range(len(params)):
            self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
            self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2

            m_hat = self.m[i] / (1 - self.beta1 ** self.t)
            v_hat = self.v[i] / (1 - self.beta2 ** self.t)

            params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)

گام 4: AdamW

class AdamW:
    def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, epsilon=1e-8, weight_decay=0.01):
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.epsilon = epsilon
        self.weight_decay = weight_decay
        self.m = None
        self.v = None
        self.t = 0

    def step(self, params, grads):
        if self.m is None:
            self.m = [0.0] * len(params)
            self.v = [0.0] * len(params)

        self.t += 1

        for i in range(len(params)):
            self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * grads[i]
            self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * grads[i] ** 2

            m_hat = self.m[i] / (1 - self.beta1 ** self.t)
            v_hat = self.v[i] / (1 - self.beta2 ** self.t)

            params[i] -= self.lr * m_hat / (math.sqrt(v_hat) + self.epsilon)
            params[i] -= self.lr * self.weight_decay * params[i]

گام 5: مقایسه‌ی فرایند آموزش

همان شبکه‌ی دولایه‌ی درس 05 را با هر چهار بهینه‌ساز روی مجموعه‌داده‌ی دایره‌ها آموزش دهید و روند همگرایی آن‌ها را با هم بسنجید.

import random

def sigmoid(x):
    x = max(-500, min(500, x))
    return 1.0 / (1.0 + math.exp(-x))

def make_circle_data(n=200, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], label))
    return data


class OptimizerTestNetwork:
    def __init__(self, optimizer, hidden_size=8):
        random.seed(0)
        self.hidden_size = hidden_size
        self.optimizer = optimizer

        self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        self.b1 = [0.0] * hidden_size
        self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
        self.b2 = 0.0

    def get_params(self):
        params = []
        for row in self.w1:
            params.extend(row)
        params.extend(self.b1)
        params.extend(self.w2)
        params.append(self.b2)
        return params

    def set_params(self, params):
        idx = 0
        for i in range(self.hidden_size):
            for j in range(2):
                self.w1[i][j] = params[idx]
                idx += 1
        for i in range(self.hidden_size):
            self.b1[i] = params[idx]
            idx += 1
        for i in range(self.hidden_size):
            self.w2[i] = params[idx]
            idx += 1
        self.b2 = params[idx]

    def forward(self, x):
        self.x = x
        self.z1 = []
        self.h = []
        for i in range(self.hidden_size):
            z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
            self.z1.append(z)
            self.h.append(max(0.0, z))

        self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
        self.out = sigmoid(self.z2)
        return self.out

    def compute_grads(self, target):
        eps = 1e-15
        p = max(eps, min(1 - eps, self.out))
        d_loss = -(target / p) + (1 - target) / (1 - p)
        d_sigmoid = self.out * (1 - self.out)
        d_out = d_loss * d_sigmoid

        grads = [0.0] * (self.hidden_size * 2 + self.hidden_size + self.hidden_size + 1)
        idx = 0
        for i in range(self.hidden_size):
            d_relu = 1.0 if self.z1[i] > 0 else 0.0
            d_h = d_out * self.w2[i] * d_relu
            grads[idx] = d_h * self.x[0]
            grads[idx + 1] = d_h * self.x[1]
            idx += 2

        for i in range(self.hidden_size):
            d_relu = 1.0 if self.z1[i] > 0 else 0.0
            grads[idx] = d_out * self.w2[i] * d_relu
            idx += 1

        for i in range(self.hidden_size):
            grads[idx] = d_out * self.h[i]
            idx += 1

        grads[idx] = d_out
        return grads

    def train(self, data, epochs=300):
        losses = []
        for epoch in range(epochs):
            total_loss = 0.0
            correct = 0
            for x, y in data:
                pred = self.forward(x)
                grads = self.compute_grads(y)
                params = self.get_params()
                self.optimizer.step(params, grads)
                self.set_params(params)

                eps = 1e-15
                p = max(eps, min(1 - eps, pred))
                total_loss += -(y * math.log(p) + (1 - y) * math.log(1 - p))
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            avg_loss = total_loss / len(data)
            accuracy = correct / len(data) * 100
            losses.append((avg_loss, accuracy))
            if epoch % 75 == 0 or epoch == epochs - 1:
                print(f"    Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
        return losses

پیاده‌سازی کاربردی با ابزارها

بهینه‌سازهای PyTorch وظیفه‌ی گروه‌بندی پارامترها، برش گرادیان (gradient clipping) و برنامه‌ریزی نرخ یادگیری را به خوبی بر دوش می‌کشند:

import torch
import torch.optim as optim

model = torch.nn.Sequential(
    torch.nn.Linear(784, 256),
    torch.nn.ReLU(),
    torch.nn.Linear(256, 10),
)

optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)

scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100)

for epoch in range(100):
    optimizer.zero_grad()
    output = model(torch.randn(32, 784))
    loss = torch.nn.functional.cross_entropy(output, torch.randint(0, 10, (32,)))
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    scheduler.step()

این الگو را همیشه به همین ترتیب به خاطر بسپارید: zero_grad، بعد forward، سپس loss، بعد backward، در صورت نیاز clip، بعد step و در آخر schedule. برهم‌زدن این توالی (مثل فراخوانی scheduler.step() قبل از optimizer.step()) یکی از رایج‌ترین علت‌های باگ‌های نامحسوس است.

در شبکه‌های CNN، هنوز هم مهندسان ترجیح می‌دهند از SGD همراه با تکانه (lr=0.1، momentum=0.9 و weight_decay=1e-4) با زمان‌بندی کسینوسی یا پله‌ای استفاده کنند؛ چون SGD نقاط کمینه‌ی مسطح‌تری پیدا می‌کند که غالباً قابلیت تعمیم بهتری دارند. برای ترنسفورمرها و LLMها، AdamW در ترکیب با warmup و افت کسینوسی پیش‌فرض بلامنازع است؛ بدون داشتن دلایل تجربی دقیق برخلاف جریان عمل نکنید.

خروجی نهایی

در این درس به این فایل دست می‌یابید:

  • outputs/prompt-optimizer-selector.md -- یک پرامپت تصمیم‌گیری برای انتخاب بهینه‌ساز و نرخ یادگیری متناسب با هر نوع معماری

تمرین‌ها

  1. تکانه‌ی نستروف (Nesterov momentum) را پیاده‌سازی کنید؛ روشی که گرادیان را به جای موقعیت فعلی، در نقطه‌ی آینده‌نگر (w−lr⋅βvw - \text{lr} \cdot \beta v) محاسبه می‌کند. سرعت همگرایی آن را با تکانه‌ی معمولی روی مجموعه‌داده‌ی دایره مقایسه کنید.

  2. برنامه‌ی زمان‌بندی نرخ یادگیری با دوره‌ی گرم‌کردن (warmup) بسازید: افزایش خطی از 0 تا max_lr در 10% آغازین گام‌ها و سپس افت کسینوسی تا 0. مدل را در دو حالت Adam با warmup و بدون آن آموزش دهید؛ سپس بشمارید رسیدن به دقت 90% روی داده‌های دایره چند ایپاک زمان می‌برد.

  3. نرخ یادگیری مؤثر هر پارامتر را در طول آموزش با Adam ثبت کنید. فرمول این نرخ برابر است با lr⋅m^/(v^+ϵ)\text{lr} \cdot \hat{m} / (\sqrt{\hat{v}} + \epsilon). نمودار توزیع این مقادیر را بعد از 10، 50 و 200 گام رسم کنید. آیا تمام وزن‌ها با سرعت یکسانی به‌روزرسانی می‌شوند؟

  4. برش گرادیان را بر اساس نُرم سراسری بنویسید و سقف آن را روی 1.0 تنظیم کنید. مدل را یک بار با برش گرادیان و یک بار بدون آن، با نرخ یادگیری بالا (lr=0.01 در Adam) آموزش دهید. بررسی کنید در 10 سید تصادفی، تابع زیان چند بار واگرا می‌شود (به NaN می‌رسد).

  5. بهینه‌سازهای Adam و AdamW را روی شبکه‌ای با وزن‌های اولیه بزرگ بررسی کنید. تمام وزن‌ها را در بازه‌ی تصادفی [-5, 5] قرار دهید (خیلی بزرگ‌تر از حد معمول). مدل را برای 200 ایپاک با weight_decay=0.1 آموزش دهید. نُرم L2 وزن‌ها را حین آموزش برای هر دو روش رسم کنید؛ AdamW باید وزن‌ها را با سرعت بیشتری مهار کند و کاهش دهد.

اصطلاحات کلیدی

اصطلاح در گفتگوها چه می‌گویند مفهوم دقیق آن در عمل
Learning rate «اندازه‌ی گام» ضریب عددی در به‌روزرسانی گرادیان؛ تأثیرگذارترین هایپرپارامتر در روند آموزش
SGD «گرادیان کاهشی مقدماتی» گرادیان کاهشی تصادفی: اصلاح وزن‌ها با کم کردن lr⋅gradient\text{lr} \cdot \text{gradient} که روی یک دسته‌ی کوچک محاسبه می‌شود
Momentum «تشبیه توپ غلتان» میانگین متحرک نمایی گرادیان‌های پیشین؛ نوسان‌ها را کم می‌کند و به مسیرهای یکنواخت سرعت می‌دهد
RMSProp «نرخ یادگیری انطباقی» گرادیان هر پارامتر را بر ریشه‌ی میانگین مجذورات گرادیان‌های اخیرش تقسیم می‌کند تا نرخ‌ها هم‌تراز شوند
Adam «بهینه‌ساز پیش‌فرض» ترکیب تکانه (گشتاور اول) و RMSProp (گشتاور دوم) همراه با تصحیح سوگیری در گام‌های ابتدایی
AdamW «نسخه‌ی درست Adam» مدل Adam با کاهش وزن تفکیک‌شده؛ جریمه‌ی منظم‌سازی را مستقیم به وزن‌ها می‌زند نه از طریق گرادیان
Bias correction «گرم‌کردن میانگین متحرک» تقسیم بر (1−βt)(1 - \beta^t) برای جبران مقداردهی اولیه‌ی صفر در برآوردهای گشتاور در الگوریتم Adam
Weight decay «کوچک کردن وزن‌ها» کسر کردن نسبتی از وزن در هر گام؛ سازوکاری برای جلوگیری از بیش‌ازحد بزرگ شدن پارامترها
Learning rate schedule «تغییر نرخ در طول زمان» برنامه‌ی زمان‌بندی نرخ یادگیری حین آموزش؛ الگوی warmup همراه با افت کسینوسی استاندارد مدرن است
Gradient clipping «بستن سقف برای نُرم گرادیان» مقیاس‌بندی و کوچک کردن بردار گرادیان در صورت عبور از یک آستانه؛ مانع از انفجار گرادیان می‌شود

برای مطالعه‌ی بیشتر

  • مقاله‌ی کینگما و با (Kingma & Ba) تحت عنوان "Adam: A Method for Stochastic Optimization" (سال 2014) -- مقاله‌ی اصلی Adam همراه با تحلیل همگرایی و اثبات فرمول تصحیح سوگیری
  • مقاله‌ی لاشچیلُف و هوتر (Loshchilov & Hutter) با عنوان "Decoupled Weight Decay Regularization" (سال 2017) -- اثبات عدم هم‌ارزی منظم‌سازی L2 و کاهش وزن در Adam، و معرفی رسمی AdamW
  • مقاله‌ی اسمیت (Smith) به نام "Cyclical Learning Rates for Training Neural Networks" (سال 2017) -- معرفی تست بازه‌ی نرخ یادگیری و زمان‌بندی‌های چرخه‌ای که وابستگی به تنظیم یک نرخ ثابت را برطرف می‌کنند
  • مقاله‌ی رودر (Ruder) تحت عنوان "An Overview of Gradient Descent Optimization Algorithms" (سال 2016) -- بهترین مقاله‌ی مروری بر تمامی گونه‌های گرادیان کاهشی با تحلیل‌های بصری و دقیق

سؤالات متداول

تفاوت اصلی بین Adam و AdamW چیست؟

در Adam منظم‌سازی L2 به گرادیان افزوده شده و با نرخ انطباقی تغییر مقیاس می‌یابد که به جریمه‌ی نابرابر پارامترها ختم می‌شود؛ اما در AdamW کاهش وزن (Weight Decay) مستقیماً روی وزن‌ها اعمال می‌شود و جریمه‌ای یکنواخت ایجاد می‌کند.

چرا در مراحل ابتدایی Adam از تصحیح سوگیری (Bias Correction) استفاده می‌شود؟

چون میانگین‌های متحرک با صفر مقداردهی اولیه می‌شوند و در گام‌های آغازین مقدار تخمینی بسیار کوچک‌تر از واقعیت است؛ تصحیح سوگیری این اثر را خنثی می‌کند و پس از حدود 50 گام خودبه‌خود محو می‌شود.

برای معماری‌های مختلف کدام بهینه‌ساز انتخاب بهتری است؟

برای ترنسفورمرها و مدل‌های زبانی بزرگ، AdamW همراه با warmup و افت کسینوسی استاندارد است؛ در حالی که برای شبکه‌های پیچشی (CNN) اغلب SGD مجهز به تکانه به دلیل یافتن کمینه‌های مسطح‌تر ترجیح داده می‌شود.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.