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

فصل ۳ از ۹

پیشرفت ترم
۰٪

ترم ۱ · مکانیزمِ یادگیری: PyTorch از پایه

`gradient descent` با دست

فصل ۳پیش‌نمایش رایگان

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

autograd جهت را می‌دهد. حالا باید قدم برداری — و کلِ یادگیریِ ماشین همین است: تکرارِ «مشتق بگیر، کمی در جهتِ کاهش برو». در این فصل آن حلقه را خودت می‌نویسی، در پنج خط و بدونِ هیچ optimizerی، و بعد نرخِ یادگیری را آن‌قدر بالا می‌بری تا با چشمِ خودت ببینی کجا منفجر می‌شود.

این فصل عمداً قبل از optimizer.step() می‌آید. کسی که ترتیب را برعکس یاد بگیرد، تا آخرِ عمر آن یک خط را طلسم می‌داند.

یک توپ که روی سطحی شیب‌دار قدم‌به‌قدم به سمتِ گودی می‌رود

آخر این فصل می‌توانی:

  • حلقهٔ gradient descent را از پایه بنویسی
  • بگویی نرخِ یادگیری چه می‌کند و چرا هم کوچکش بد است هم بزرگش
  • واگراییِ ناشی از نرخِ زیاد را تشخیص بدهی
  • یک مدلِ خطی را با دست آموزش بدهی

قبل از شروع#

از فصل ۲: backward، .grad، zero_grad، no_grad.

از ریشه ترمِ ۸ فصل ۸: الگوریتمِ gradient descent — همان چیزی که اینجا کد می‌شود.

📓 نوت‌بوک: نوت‌بوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و به‌ترتیب داخلش هست.

۱. پنج خطی که همه‌چیز است#

می‌خواهیم را طوری پیدا کنیم که کمینه شود. جوابش را می‌دانیم — چهار — و همین خوب است: می‌خواهیم ببینیم الگوریتم خودش پیدایش می‌کند یا نه.

import torch

w = torch.tensor(0.0, requires_grad=True)
lr = 0.1

for step in range(5):
    loss = (w - 4.0) ** 2      # ۱. جلو: خطا را حساب کن
    loss.backward()            # ۲. عقب: مشتق را بگیر
    with torch.no_grad():
        w -= lr * w.grad       # ۳. قدم: در جهتِ کاهش برو
    w.grad.zero_()             # ۴. پاک کن، برای دورِ بعد
    print(f"قدم {step + 1}: w = {w.item():.4f} | loss = {loss.item():.4f}")
قدم 1: w = 0.8000 | loss = 16.0000
قدم 2: w = 1.4400 | loss = 10.2400
قدم 3: w = 1.9520 | loss = 6.5536
قدم 4: w = 2.3616 | loss = 4.1943
قدم 5: w = 2.6893 | loss = 2.6844

w از صفر دارد به سمتِ چهار می‌رود و loss یکنواخت پایین می‌آید.

هر چهار خطِ داخلِ حلقه لازم‌اند و هیچ‌کدام تزیینی نیست:

  • loss را حساب کن — و همین‌جا گراف ساخته می‌شود.
  • backward — گرادیان در w.grad می‌نشیند.
  • قدم داخلِ no_grad — چون خودِ به‌روزرسانی نباید بخشی از گرافِ بعدی شود.
  • zero_grad — وگرنه گرادیان‌ها انباشته می‌شوند (فصلِ ۲ بخشِ ۳).

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

🌱 ریشه‌اش کجاست: این حلقه دقیقاً همان الگوریتمی است که ریشه، ترم ۸ فصل ۸ با کاغذ و ماشین‌حساب می‌سازد، و اینکه گرادیان جهتِ بیشترین شیب است در فصل ۷ همان ترم ثابت می‌شود. اگر آن دو فصل را دیده باشی، این پنج خط برایت هیچ ابهامی ندارد.

۲. نرخِ یادگیری: تنها عددی که واقعاً مهم است#

for lr in [0.01, 0.1, 0.9, 1.1]:
    w = torch.tensor(0.0, requires_grad=True)
    trace = []
    for step in range(20):
        loss = (w - 4.0) ** 2
        loss.backward()
        with torch.no_grad():
            w -= lr * w.grad
        w.grad.zero_()
        if step < 4:
            trace.append(round(w.item(), 2))
    print(f"lr={lr}: چهار قدم اول {trace} ... بعد از ۲۰ قدم w={w.item():.2f}")
lr=0.01: چهار قدم اول [0.08, 0.16, 0.24, 0.31] ... بعد از ۲۰ قدم w=1.33
lr=0.1: چهار قدم اول [0.8, 1.44, 1.95, 2.36] ... بعد از ۲۰ قدم w=3.95
lr=0.9: چهار قدم اول [7.2, 1.44, 6.05, 2.36] ... بعد از ۲۰ قدم w=3.95
lr=1.1: چهار قدم اول [8.8, -1.76, 10.91, -4.29] ... بعد از ۲۰ قدم w=-149.35

چهار رفتارِ کاملاً متفاوت، از یک عدد.

  • 0.01 — خیلی کوچک. درست حرکت می‌کند، ولی بعد از بیست قدم هنوز در راه است. با داده و مدلِ واقعی یعنی ساعت‌ها انتظار.
  • 0.1 — درست. یکنواخت و سریع به جواب می‌رسد.
  • 0.9 — پُرنوسان، ولی همگرا. به عددهایش دقت کن: از ۰ به ۷٫۲ می‌پرد (از جواب رد می‌شود)، بعد به ۱٫۴۴ برمی‌گردد، بعد ۶٫۰۵. از دو طرف حولِ جواب نوسان می‌کند و آرام‌آرام نزدیک می‌شود.
  • 1.1 — واگرا. هر پرش از قبلی بزرگ‌تر است و به منفیِ ۱۴۹ می‌رسد. با ادامه دادن به inf و بعد nan می‌رسد.

و یک چیزِ ظریف: 0.1 و 0.9 بعد از بیست قدم دقیقاً به یک عدد رسیدند. تصادفی نیست، و برای این تابعِ خاص می‌شود دقیقاً نشانش داد. گرادیانِ برابرِ است، پس هر قدم این است:

یعنی فاصله تا جواب در هر قدم در عددِ ثابتِ ضرب می‌شود — و سرنوشتِ کلِ ماجرا فقط به قدرمطلقِ همان عدد بستگی دارد:

نرخ ضریب قدرمطلق چه می‌شود
۰٫۰۱ ۰٫۹۸ ۰٫۹۸ خیلی آرام کوچک می‌شود
۰٫۱ ۰٫۸ ۰٫۸ یکنواخت به جواب می‌رسد
۰٫۹ ۰٫۸- ۰٫۸ همان سرعت، ولی علامت هر بار عوض می‌شود
۱٫۱ ۱٫۲- ۱٫۲ فاصله هر قدم بزرگ‌تر می‌شود — واگرایی

دو ردیفِ وسط قدرمطلقِ یکسانی دارند و برای همین بعد از بیست قدم به یک عدد رسیدند — یکی آرام می‌خزد و دیگری از دو طرف نوسان می‌کند، ولی هر دو با یک سرعت نزدیک می‌شوند. و مرزِ واگرایی همان‌جایی است که قدرمطلق از ۱ رد می‌شود، یعنی برای این تابع.

پس «سریع‌تر همگرا شد» تنها معیارِ خوبی نیست؛ مسیرِ رسیدن هم مهم است، چون در مسائلِ واقعی آن نوسان می‌تواند به بی‌ثباتی تبدیل شود.

⚠️ مواظب باش: آن عددِ ۱ مالِ همین تابع است، نه یک ثابتِ جهانی. مرزِ واگرایی به شکلِ تابع بستگی دارد — برای تابعی با انحنای تندتر، همان نرخ می‌تواند فاجعه باشد. هیچ‌وقت نرخی را که روی یک مسئله جواب داده، بدونِ آزمایش به مسئلهٔ دیگری نبر.

📏 اندازه بگیر: نرخِ یادگیری تنها hyperparameterی است که اگر یکی را باید تنظیم کنی، همان است. قاعدهٔ عملی: از یک عدد شروع کن و ده برابر و یک‌دهمش را هم امتحان کن، نه ۵ درصد کم و زیاد. اگر loss بالا می‌رود یا nan می‌شود، اولین کار نصف کردنِ نرخ است. ترمِ ۲ فصلِ ۴ روشِ سیستماتیکِ پیدا کردنش را می‌دهد.

۳. حالا با داده: یک مدلِ خطیِ واقعی#

X = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
Y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])      # رابطهٔ واقعی: y = 2x + 1

w = torch.tensor(0.0, requires_grad=True)
b = torch.tensor(0.0, requires_grad=True)
lr = 0.05

for step in range(200):
    pred = w * X + b
    loss = ((pred - Y) ** 2).mean()
    loss.backward()
    with torch.no_grad():
        w -= lr * w.grad
        b -= lr * b.grad
    w.grad.zero_()
    b.grad.zero_()
    if step in (0, 9, 49, 199):
        print(f"قدم {step + 1:>3}: w={w.item():.3f} b={b.item():.3f} loss={loss.item():.5f}")
قدم   1: w=1.750 b=0.600 loss=41.00000
قدم  10: w=2.084 b=0.754 loss=0.01046
قدم  50: w=2.046 b=0.865 loss=0.00313
قدم 200: w=2.005 b=0.986 loss=0.00003

رابطهٔ واقعی بود و مدل به و رسید. هیچ‌جا به آن نگفتیم جواب چیست؛ فقط چهار نقطه دادیم و گفتیم خطا را کم کن.

و به سرعتِ کارش نگاه کن: loss از ۴۱ به ۰٫۰۱ در ده قدم رسید، و بقیهٔ ۱۹۰ قدم صرفِ صیقل دادنِ همان جواب شد. این الگو تقریباً همیشه تکرار می‌شود — بیشترِ بهبود در چند درصدِ اولِ آموزش اتفاق می‌افتد.

چک کن: w باید به ۲ و b به ۱ نزدیک شود و loss یکنواخت پایین بیاید. اگر loss در جایی بالا رفت، یک از این سه است: نرخ زیادی بزرگ است، zero_grad را جا انداخته‌ای، یا به‌روزرسانی را بیرونِ no_grad نوشته‌ای. هر سه‌شان نشانهٔ یکسانی دارند و علتِ متفاوتی — به ترتیب چکشان کن.

۴. آنچه اتفاق می‌افتد اگر zero_grad نباشد#

w = torch.tensor(0.0, requires_grad=True)
lr = 0.1

for step in range(3):
    loss = (w - 4.0) ** 2
    loss.backward()
    with torch.no_grad():
        w -= lr * w.grad
    print(f"قدم {step + 1} (بدون zero_grad): w = {w.item():.4f} | grad = {w.grad.item():.4f}")
قدم 1 (بدون zero_grad): w = 0.8000 | grad = -8.0000
قدم 2 (بدون zero_grad): w = 2.2400 | grad = -14.4000
قدم 3 (بدون zero_grad): w = 4.0320 | grad = -17.9200

بخشِ ۱ را با همین سه قدم مقایسه کن: آنجا w بعد از سه قدم ۱٫۹۵ بود، اینجا ۴٫۰۳.

و اینجا نکتهٔ خطرناک: در این مثالِ خاص، جوابِ غلط تصادفاً به جوابِ درست نزدیک‌تر شد. با ادامهٔ حلقه از آن رد می‌شود و بی‌ثبات می‌شود، ولی اگر فقط سه قدم می‌دیدی، فکر می‌کردی همه‌چیز بهتر هم شده.

درسِ واقعی این است: باگی که نتیجه‌اش گاهی بهتر به‌نظر می‌رسد، از باگی که خطا می‌دهد خیلی خطرناک‌تر است. و همین قاعدهٔ سومِ این دوره است — «وقتی کار کرد، اول شک کن».

۵. کجای این تصویر بعداً عوض می‌شود#

قطعه الان در فصل‌های بعد
پارامترها w و b دستی nn.Module (فصلِ ۴)
محاسبهٔ خطا فرمولِ دستی nn.MSELoss (فصلِ ۶)
قدم و zero_grad چهار خطِ دستی optimizer.step() (فصلِ ۶)
داده چهار نقطه در حافظه DataLoader (فصلِ ۷)
ایده گرادیان بگیر، قدم بردار دقیقاً همان

آن ردیفِ آخر پیامِ کلِ فصل است. هر چیزی که در ادامه یاد می‌گیری، فقط بسته‌بندیِ راحت‌ترِ همین حلقه است. وقتی در فصلِ ۶ بنویسی optimizer.step()، دقیقاً می‌دانی پشتش چه اتفاقی می‌افتد — و همان دانستن، تفاوتِ کسی است که می‌تواند اشکال‌زدایی کند با کسی که فقط کد را کپی می‌کند.

🔧 اگر کار نکرد: a leaf Variable that requires grad is being used in an in-place operation یعنی w -= ... را بیرونِ no_grad نوشته‌ای. loss برابرِ nan شد یعنی نرخ زیادی بزرگ است — نصفش کن. w.grad برابرِ None است یعنی backward هنوز صدا زده نشده یا requires_grad جا مانده.

🤖 از دستیارت بپرس: «برای تابعِ چه نرخِ یادگیری‌ای باعثِ واگرایی می‌شود و چرا دقیقاً همان عدد؟» جوابش را با آزمایش بسنج: نرخ را از ۰٫۹ تا ۱٫۱ با گام‌های کوچک بالا ببر و مرزِ دقیق را خودت پیدا کن. بعد بپرس آیا این مرز به شکلِ تابع بستگی دارد یا عددی جهانی است — و جوابش را با یک تابعِ دیگر مثلِ امتحان کن، نه اینکه باور کنی.

واژه‌های تازهٔ این فصل#

کلمه تلفظ به حروف فارسی یعنی چه
gradient descent گرِیدینت دیسنت تکرارِ «مشتق بگیر، در جهتِ کاهش قدم بردار»
learning rate لرنینگ ریت اندازهٔ هر قدم
convergence کانورجنس نزدیک شدن به جواب و ماندن آنجا
divergence دایورجنس دور شدن از جواب، تا inf و nan
step استپ یک بار به‌روزرسانیِ پارامترها

تمرین‌ها

اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.

در فصل بعد#

w و b دستی برای یک خط قابلِ تحمل است. برای شبکه‌ای با هزاران پارامتر نه. فصلِ بعد nn.Module را می‌آورد: راهی برای بسته‌بندیِ پارامترها و محاسبه در یک شیء — و می‌بینی که اولین شبکهٔ عصبی‌ات دقیقاً همان است، فقط با نامِ دیگر.

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

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