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

فصل ۴ از ۸

پیشرفت ترم
۰٪

ترم ۲ · آموزشِ درست: تعمیم و آزمایشِ منظم

مهم‌ترین `hyperparameter`

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

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

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

و یک نتیجهٔ صادقانه هم می‌گیری: یکی از تکنیک‌های محبوبِ این حوزه روی این مسئله هیچ کمکی نمی‌کند — و همان هم درسی است.

یک شیرِ تنظیمِ جریان با درجه‌بندی، در کنارِ نموداری که در طولِ زمان بسته می‌شود

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

  • بازهٔ مناسبِ نرخ را با یک آزمایشِ کوتاه پیدا کنی
  • scheduler را به‌کار ببری و بگویی چرا کمک می‌کند
  • warmup را بشناسی و بدانی کِی لازم است
  • ادعای «فلان تکنیک بهتر است» را روی مسئلهٔ خودت بسنجی

قبل از شروع#

از ترمِ ۱ فصل ۳ و ۸: اثرِ نرخ روی همگرایی و واگرایی.

از فصل ۱ همین ترم: سنجش با مجموعهٔ اعتبارسنجی.

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

۱. جاروبِ کامل: چه چیزی واقعاً اتفاق می‌افتد#

import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

loss_fn = nn.CrossEntropyLoss()


def plain(seed=0):
    torch.manual_seed(seed)
    return nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))


@torch.no_grad()
def accuracy(model, X, y):
    model.eval()
    return float((model(X).argmax(1) == y).float().mean())


def fit_adam(model, epochs=15, lr=0.01):
    loader = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    for _ in range(epochs):
        model.train()
        for xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
    return accuracy(model, Xva, yva)


for lr in [1e-4, 1e-3, 1e-2, 1e-1, 1.0]:
    print(f"lr={lr:<7}: دقت اعتبارسنجی = {fit_adam(plain(), epochs=15, lr=lr):.4f}")
lr=0.0001 : دقت اعتبارسنجی = 0.7967
lr=0.001  : دقت اعتبارسنجی = 0.9331
lr=0.01   : دقت اعتبارسنجی = 0.9610
lr=0.1    : دقت اعتبارسنجی = 0.9387
lr=1.0    : دقت اعتبارسنجی = 0.1003

یک منحنیِ کوهانی‌شکل، با قلهٔ روشن در ۰٫۰۱.

  • ۰٫۰۰۰۱ خیلی کوچک: مدل در پانزده epoch به جایی نرسید. مشکل کیفیت نیست، وقت است.
  • ۰٫۰۱ بهترین.
  • ۱٫۰ فاجعه: دقتِ ۰٫۱۰۰۳ یعنی دقیقاً حدسِ تصادفی بینِ ده کلاس. مدل هیچ‌چیز یاد نگرفت.

و به فاصله‌ها دقت کن: هر پله ده برابر است، نه ۱۰ درصد. نرخِ یادگیری در مقیاسِ لگاریتمی جست‌وجو می‌شود؛ تفاوتِ ۰٫۰۱ و ۰٫۰۱۱ عملاً هیچ است، ولی تفاوتِ ۰٫۰۱ و ۰٫۱ واقعی.

🌱 ریشه‌اش کجاست: نرخِ یادگیری در چیزی ضرب می‌شود که اسمش گرادیان است — یک بردار، نه یک عدد — و ریشه، ترم ۸ فصل ۷ — مشتقِ جزئی و بردارِ گرادیان می‌سازدش. و همان‌جا دلیلِ حساسیتِ این فصل هم روشن می‌شود: گرادیان جهتِ تندترین شیب را می‌دهد و بزرگی‌اش هم در قدم ضرب می‌شود — ولی آن بزرگی به واحدِ loss است، نه به واحدِ قدمی که باید برداری. lr همان ضریبِ تبدیل است، و چون هیچ‌چیز در خودِ مسئله مقدارش را تعیین نمی‌کند، باید پیدایش کنی. برای همین این یک عدد این‌قدر تعیین‌کننده است.

۲. آزمایشِ کوتاه: بدونِ آموزشِ کامل#

جاروبِ بالا پنج آموزشِ کامل خواست. راهِ خیلی ارزان‌ترش این است: هر نرخ را فقط چند ده قدم اجرا کن و ببین loss چه می‌کند.

for lr in [1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1.0]:
    model = plain()
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    loader = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True)
    model.train()
    losses = []
    for i, (xb, yb) in enumerate(loader):
        opt.zero_grad()
        loss = loss_fn(model(xb), yb)
        loss.backward()
        opt.step()
        losses.append(loss.item())
        if i == 16:
            break
    print(f"lr={lr:<7}: loss اول={losses[0]:.3f} | بعد از ۱۷ قدم={losses[-1]:.3f}")
lr=1e-05  : loss اول=2.324 | بعد از ۱۷ قدم=2.304
lr=0.0001 : loss اول=2.324 | بعد از ۱۷ قدم=2.291
lr=0.001  : loss اول=2.324 | بعد از ۱۷ قدم=2.134
lr=0.01   : loss اول=2.324 | بعد از ۱۷ قدم=0.752
lr=0.1    : loss اول=2.324 | بعد از ۱۷ قدم=0.728
lr=1.0    : loss اول=2.324 | بعد از ۱۷ قدم=3.098

هفده قدم — یعنی کمتر از یک epoch — و کلِ تصویر پیداست:

  • تا 0.001، loss تقریباً تکان نمی‌خورد. خیلی کند.
  • 0.01 و 0.1 سریع پایین می‌آورند. ناحیهٔ مفید همین‌جاست.
  • 1.0 بالاتر از نقطهٔ شروع رفت. ناپایدار.

قاعدهٔ عملی: بزرگ‌ترین نرخی را بردار که هنوز loss را سریع پایین می‌آورد، و بعد یک پله کوچک‌ترش کن. اینجا یعنی جایی حولِ ۰٫۰۱ تا ۰٫۰۳ — و جاروبِ کاملِ بخشِ ۱ همین را تأیید کرد.

📏 اندازه بگیر: این آزمایش چند ثانیه طول می‌کشد و جاروبِ کامل چند دقیقه. روی یک مدلِ واقعی، همین نسبت می‌شود چند دقیقه در برابرِ چند روز. قبل از هر آموزشِ طولانی، این آزمایشِ کوتاه را بکن — و اگر loss در بیست قدمِ اول تکان نخورد، ادامه دادن هدر دادنِ وقت است.

۳. نرخ لازم نیست ثابت بماند#

def fit_scheduled(use_scheduler, epochs=30, lr=0.05):
    torch.manual_seed(0)
    model = plain()
    loader = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs) if use_scheduler else None
    for _ in range(epochs):
        model.train()
        for xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
        if scheduler:
            scheduler.step()
    return accuracy(model, Xva, yva), opt.param_groups[0]["lr"]


acc_fixed, lr_fixed = fit_scheduled(False)
acc_sched, lr_sched = fit_scheduled(True)
print(f"نرخ ثابت    : دقت={acc_fixed:.4f} | نرخ پایانی={lr_fixed:.5f}")
print(f"با scheduler: دقت={acc_sched:.4f} | نرخ پایانی={lr_sched:.5f}")
نرخ ثابت    : دقت=0.9638 | نرخ پایانی=0.05000
با scheduler: دقت=0.9861 | نرخ پایانی=0.00000

چک کن: نرخِ پایانیِ ستونِ دوم باید عملاً صفر باشد. اگر همان ۰٫۰۵ اولیه ماند، scheduler.step() صدا زده نشده — و scheduler بی‌صدا هیچ کاری نکرده است.

۰٫۹۶۳۸ به ۰٫۹۸۶۱ — بیش از دو درصد، از یک خط کد.

منطقش شهودی است: در ابتدا از جواب دوری و قدمِ بزرگ لازم داری؛ در انتها نزدیکی و قدمِ بزرگ فقط دورِ جواب می‌پراند. scheduler نرخ را در طولِ آموزش کم می‌کند.

opt = torch.optim.SGD([torch.zeros(1, requires_grad=True)], lr=0.1)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=10)

trace = []
for _ in range(10):
    trace.append(round(opt.param_groups[0]["lr"], 4))
    opt.step()
    scheduler.step()
print("نرخ در ده epoch:", trace)
نرخ در ده epoch: [0.1, 0.0976, 0.0905, 0.0794, 0.0655, 0.05, 0.0345, 0.0206, 0.0095, 0.0024]

اول آرام کم می‌شود، وسط تندتر، آخر دوباره آرام — شکلِ نیمِ یک کسینوس. CosineAnnealing امروز رایج‌ترین انتخاب است؛ گزینه‌های دیگر StepLR (پله‌ای) و ReduceLROnPlateau (وقتی اعتبارسنجی بهتر نشد، نصف کن) هستند.

⚠️ مواظب باش: scheduler.step() جای مشخصی دارد و جابه‌جا کردنش خطای خاموش می‌دهد. CosineAnnealingLR یک بار در هر epoch صدا زده می‌شود، نه در هر batch — اگر داخلِ حلقهٔ batch بگذاری‌اش، نرخ ده‌ها برابر سریع‌تر صفر می‌شود و آموزشت وسطِ راه متوقف می‌شود بدونِ اینکه بفهمی چرا. و همیشه بعد از opt.step()، نه قبلش.

۴. warmup: و یک نتیجهٔ صادقانه#

warmup یعنی نرخ را از یک مقدارِ خیلی کوچک شروع کنی و در چند epoch اول به‌تدریج به مقدارِ اصلی برسانی. در آموزشِ مدل‌های بزرگ تقریباً استاندارد است. ببینیم اینجا چه می‌کند:

def fit_warmup(use_warmup, epochs=30, base=0.05):
    torch.manual_seed(0)
    model = plain()
    loader = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True)
    opt = torch.optim.Adam(model.parameters(), lr=base)
    if use_warmup:
        scheduler = torch.optim.lr_scheduler.SequentialLR(
            opt,
            [torch.optim.lr_scheduler.LinearLR(opt, start_factor=0.05, total_iters=5),
             torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs - 5)],
            milestones=[5],
        )
    else:
        scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
    for _ in range(epochs):
        model.train()
        for xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
        scheduler.step()
    return accuracy(model, Xva, yva)


print("بدون warmup:", round(fit_warmup(False), 4))
print("با warmup  :", round(fit_warmup(True), 4))
بدون warmup: 0.9861
با warmup  : 0.9805

warmup نتیجه را بدتر کرد.

و این را همین‌طور صریح می‌نویسیم، چون درسِ اصلیِ این بخش همین است. warmup تکنیکِ بی‌ارزشی نیست — مشکلی را حل می‌کند که این مسئله ندارد: در مدل‌های خیلی بزرگ با batch بزرگ، قدم‌های اولِ آموزش می‌توانند وزن‌ها را به ناحیهٔ بدی ببرند و دیگر برنگردند. مدلِ ما دو لایه دارد و چنین شکنندگی‌ای ندارد.

📏 اندازه بگیر: هر تکنیکی مسئله‌ای را حل می‌کند؛ اگر آن مسئله را نداری، حل کردنش فقط هزینه است. و راهِ فهمیدنش هم آزمایش است، نه خواندنِ توصیه. قاعدهٔ کاریِ این ترم: هیچ تکنیکی را چون «استاندارد است» به کدت اضافه نکن، مگر اینکه روی مسئلهٔ خودت اندازه گرفته باشی که کمک می‌کند. فصلِ ۷ روشِ منظمِ این اندازه‌گیری را می‌دهد.

۵. ترتیبِ تنظیم#

اولویت چه چیزی چطور
۱ نرخِ یادگیری آزمایشِ کوتاهِ بخشِ ۲، بعد جاروبِ لگاریتمی
۲ تعدادِ epoch با توقفِ زودهنگام خودکارش کن
۳ scheduler CosineAnnealing پیش‌فرضِ خوبی است
۴ batch_size معمولاً بزرگ‌ترین چیزی که در حافظه جا شود
۵ معماری و regularization فصل‌های ۱ و ۲

و یک رابطهٔ عملی که خیلی به کارت می‌آید: اگر batch_size را دو برابر کردی، نرخ را هم باید بزرگ‌تر کنی — چون گرادیانِ میانگین‌گیری‌شده روی batch بزرگ‌تر، کم‌نویزتر و کوچک‌تر است. قاعدهٔ سرانگشتی: نرخ را با همان نسبت (یا جذرش) بالا ببر، و بعد بسنج.

🔧 اگر کار نکرد: scheduler هیچ اثری نداردscheduler.step() را صدا نزده‌ای، یا optimizer را دوباره ساخته‌ای. نرخ زودتر از انتظار صفر شدT_max را با تعدادِ epoch هماهنگ نکرده‌ای، یا step() را در حلقهٔ batch گذاشته‌ای. دقت بینِ اجراها خیلی فرق می‌کندseed جا مانده، و فصلِ بعد دقیقاً دربارهٔ همین است.

🤖 از دستیارت بپرس: «ReduceLROnPlateau چطور کار می‌کند و چه فرقی با CosineAnnealingLR دارد؟» بعد جایگزینش کن و روی همین مسئله بسنج. و این سؤالِ سخت‌تر را هم بپرس: «چرا ReduceLROnPlateau به مجموعهٔ اعتبارسنجی نیاز دارد ولی CosineAnnealingLR نه؟» — جوابش یک تفاوتِ مفهومیِ مهم است: یکی از داده تصمیم می‌گیرد و دیگری فقط از زمان.

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

کلمه تلفظ به حروف فارسی یعنی چه
learning rate schedule لرنینگ ریت شِدول برنامهٔ تغییرِ نرخ در طولِ آموزش
cosine annealing کساین انیلینگ کم کردنِ نرخ با شکلِ نیم‌کسینوس
warmup وارم‌آپ شروع با نرخِ کوچک و رسیدنِ تدریجی به نرخِ اصلی
LR range test ال‌آر رنج تست آزمایشِ کوتاه برای یافتنِ بازهٔ مفیدِ نرخ
plateau پلاتو جایی که معیار دیگر بهتر نمی‌شود

تمرین‌ها

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

در فصل بعد#

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

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

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