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

فصل ۶ از ۹

پیشرفت ترم
۰٪

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

`loss` و `optimizer`

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

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

چهار خطِ دستیِ فصلِ ۳ حالا اسمِ رسمی و بسته‌بندیِ آماده پیدا می‌کنند. loss می‌گوید چقدر بد کار می‌کنیم و optimizer می‌گوید چطور قدم برداریم — و چون فصلِ ۳ را با دست نوشتی، هیچ‌کدامشان طلسم نیستند.

و یک خطای واقعی را با عدد می‌بینی: دادنِ احتمال به CrossEntropyLoss به‌جای logit — که هیچ خطایی نمی‌دهد و فقط مدلت را بدتر می‌کند.

دو گِیج روی یک دستگاه: یکی فاصله تا هدف را نشان می‌دهد، دیگری اندازهٔ قدم را

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

  • MSELoss و CrossEntropyLoss را در جای درست به‌کار ببری
  • بگویی چرا خروجیِ خام به تابعِ خطا داده می‌شود، نه احتمال
  • SGD و Adam را بشناسی و منصفانه مقایسه کنی
  • حلقهٔ آموزش را با optimizer بنویسی

قبل از شروع#

از فصل ۳: حلقهٔ دستی — backward، قدم، zero_grad.

از فصل ۵: logit و softmax.

از ریشه ترمِ ۸ فصل ۹ و ۱۰: تابعِ هزینه، و cross-entropy.

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

۱. MSELoss: همان چیزی که با دست نوشتی#

import torch
import torch.nn as nn

pred = torch.tensor([[2.0], [4.0], [6.0]])
target = torch.tensor([[3.0], [3.0], [5.0]])

mse = nn.MSELoss()
print("MSELoss     :", mse(pred, target).item())
print("دستی        :", ((pred - target) ** 2).mean().item())
MSELoss     : 1.0
دستی        : 1.0

هیچ تفاوتی نیست. nn.MSELoss دقیقاً همان ((pred - target) ** 2).mean() است که در فصلِ ۳ نوشتی.

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

🌱 ریشه‌اش کجاست: اینکه چرا خطا را مربع می‌کنیم — و نه قدرمطلق، و نه خودِ خطا — همان سؤالی است که ریشه، ترم ۸ فصل ۹ — تابعِ هزینه از پایه جوابش می‌دهد: مربع کردن علامت را حذف می‌کند، خطای بزرگ را بیشتر جریمه می‌کند، و برخلافِ قدرمطلق در همه‌جا مشتق‌پذیر است. آن سومی است که gradient descent را ممکن می‌کند.

۲. CrossEntropyLoss برای طبقه‌بندی#

logits = torch.tensor([[2.0, 1.0, 0.1], [0.5, 2.5, 0.3]])
labels = torch.tensor([0, 1])

ce = nn.CrossEntropyLoss()
print("CrossEntropyLoss:", round(ce(logits, labels).item(), 4))

probs = torch.softmax(logits, dim=1)
manual = -torch.log(probs[range(2), labels]).mean()
print("دستی            :", round(manual.item(), 4))
CrossEntropyLoss: 0.3185
دستی            : 0.3185

دوباره هیچ جادویی نیست. CrossEntropyLoss دو کار را با هم می‌کند: softmax می‌زند، و بعد احتمالِ کلاسِ درست را می‌گیرد.

توجه کن labels از نوعِ int64 است و شمارهٔ کلاس را می‌گوید، نه بردارِ صفر-و-یک. اولین نمونه کلاسِ ۰ است و دومی کلاسِ ۱.

🌱 ریشه‌اش کجاست: آن دقیقاً همان فرمولی است که ریشه، ترم ۸ فصل ۱۰ — آنتروپی و cross-entropy از پایه می‌سازد: مقدارِ غافلگیریِ جوابِ درست، از دیدِ مدل. و همان‌جا می‌بینی چرا مدلی که با اطمینان اشتباه می‌کند جریمهٔ بی‌نهایت می‌گیرد. و اگر خودِ log برایت مبهم است، ریشه، ترم ۴ فصل ۸ — لگاریتم از صفر می‌سازدش؛ تنها چیزی که اینجا لازم داری همین است: لگاریتمِ عددِ نزدیک به یک، نزدیکِ صفر است، و لگاریتمِ عددِ نزدیک به صفر به منفیِ بی‌نهایت می‌رود. جریمهٔ «مطمئن بودم و غلط بود» از همین یک خاصیت می‌آید.

۳. خطای واقعی: دادنِ احتمال به‌جای logit#

wrong_input = torch.softmax(logits, dim=1)      # اشتباه: قبلاً softmax خورده

print("احتمال‌ها:\n", wrong_input.round(decimals=4))
print("CE روی احتمال‌ها:", round(ce(wrong_input, labels).item(), 4))
print("CE روی logitها  :", round(ce(logits, labels).item(), 4))
احتمال‌ها:
 tensor([[0.6590, 0.2424, 0.0986],
        [0.1086, 0.8025, 0.0889]])
CE روی احتمال‌ها: 0.745
CE روی logitها  : 0.3185

۰٫۷۴۵ در برابرِ ۰٫۳۱۸۵ — بیش از دو برابر، و هیچ خطایی نگرفتیم.

اتفاقی که افتاد: CrossEntropyLoss روی چیزی که قبلاً احتمال بود دوباره softmax زد. و چون softmax عددهای بینِ ۰ و ۱ را به هم نزدیک‌تر می‌کند، مدل عملاً همیشه «مردد» به‌نظر می‌رسد.

نتیجه‌اش در آموزشِ واقعی: گرادیان‌ها ضعیف می‌شوند، مدل کندتر یاد می‌گیرد، و هیچ نشانه‌ای جز «چرا این‌قدر کند است؟» نمی‌بینی.

⚠️ مواظب باش: این باگ در پروژه‌های واقعی خیلی رایج است، دقیقاً چون خطا نمی‌دهد. قاعدهٔ حفظ‌کردنی: CrossEntropyLoss خودش softmax دارد؛ لایهٔ آخرِ مدل هیچ فعال‌سازی‌ای ندارد. برای دیدنِ احتمال‌ها موقعِ پیش‌بینی، softmax را جدا و بیرونِ آموزش صدا بزن.

کار تابعِ خطا خروجیِ مدل
پیش‌بینیِ عدد MSELoss یک عددِ خام
طبقه‌بندیِ چندکلاسه CrossEntropyLoss logit برای هر کلاس
طبقه‌بندیِ دودویی BCEWithLogitsLoss یک logit
پیش‌بینیِ عدد با دادهٔ پرت‌دار L1Loss یا HuberLoss یک عددِ خام

آن ردیفِ آخر پلی به سرنخ است: L1Loss همان میانگینِ قدرِمطلقِ خطاست — کمتر تحتِ تأثیرِ نقطهٔ پرت، دقیقاً به همان دلیلی که میانه از میانگین مقاوم‌تر است.

۴. optimizer: چهار خط به یک خط#

def train(optimizer_name, steps=60):
    torch.manual_seed(0)
    model = nn.Linear(1, 1)
    if optimizer_name == "SGD":
        opt = torch.optim.SGD(model.parameters(), lr=0.05)
    else:
        opt = torch.optim.Adam(model.parameters(), lr=0.05)

    X = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
    Y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])
    loss_fn = nn.MSELoss()

    for _ in range(steps):
        loss = loss_fn(model(X), Y)
        opt.zero_grad()      # همان w.grad.zero_() فصل ۳
        loss.backward()      # همان
        opt.step()           # همان w -= lr * w.grad، برای همهٔ پارامترها
    return loss.item()


for name in ["SGD", "Adam"]:
    print(f"{name:>4}: loss بعد از ۶۰ قدم = {train(name):.5f}")
 SGD: loss بعد از ۶۰ قدم = 0.00109
Adam: loss بعد از ۶۰ قدم = 0.27820

و اینجا نتیجه‌ای است که انتظارش را نداشتی: SGD از Adam بهتر شد، آن هم ۲۵۰ برابر.

Adam معمولاً پیش‌فرضِ توصیه‌شده است. پس چه شد؟ دلیلش این است که Adam برای هر پارامتر یک حالتِ داخلی نگه می‌دارد که در چند ده قدمِ اول باید «گرم» شود. برای مسئله‌ای به این کوچکی — دو پارامتر و شصت قدم — آن گرم شدن، خودش کلِ بودجه را می‌خورد.

و سؤالِ طبیعیِ بعدی همین است: اگر بودجه را بیشتر کنیم جبران می‌کند؟ حدس نزن؛ بشمار. به‌جای «loss بعد از فلان‌قدر قدم» عددِ پایدارتری می‌گیریم: هر کدام چند قدم لازم دارد تا loss از یک آستانه پایین‌تر برود.

def steps_to_reach(optimizer_name, goal, limit=5000):
    torch.manual_seed(0)
    model = nn.Linear(1, 1)
    maker = torch.optim.SGD if optimizer_name == "SGD" else torch.optim.Adam
    opt = maker(model.parameters(), lr=0.05)
    X = torch.tensor([[1.0], [2.0], [3.0], [4.0]])
    Y = torch.tensor([[3.0], [5.0], [7.0], [9.0]])
    loss_fn = nn.MSELoss()
    for step in range(1, limit + 1):
        loss = loss_fn(model(X), Y)
        opt.zero_grad()
        loss.backward()
        opt.step()
        if loss.item() < goal:
            return step
    return limit


for goal in (1e-1, 1e-2, 1e-3, 1e-4):
    print(f"  تا loss < {goal:<7}: SGD {steps_to_reach('SGD', goal):>4} قدم | "
          f"Adam {steps_to_reach('Adam', goal):>4} قدم")
  تا loss < 0.1    : SGD    3 قدم | Adam  154 قدم
  تا loss < 0.01   : SGD    4 قدم | Adam  351 قدم
  تا loss < 0.001  : SGD   63 قدم | Adam  493 قدم
  تا loss < 0.0001 : SGD  140 قدم | Adam  611 قدم

پس نه: روی این مسئله Adam جبران نمی‌کند. در هر چهار آستانه SGD زودتر رسیده، و در سخت‌ترینشان با ۱۴۰ قدم در برابرِ ۶۱۱ قدم. این «۶۰ قدم کم بود» نیست؛ این یعنی SGD برای این مسئله واقعاً ابزارِ بهتری است.

دلیلش هم همان است: مسئله دو پارامتر دارد و کاملاً خوش‌رفتار است. نرخِ تطبیقیِ Adam وقتی می‌ارزد که پارامترها مقیاس‌های خیلی متفاوت داشته باشند — و اینجا ندارند، پس فقط هزینه‌اش را می‌دهیم بی‌آنکه فایده‌اش را ببریم.

درسِ واقعی این نیست که Adam بد است. درس این است:

📏 اندازه بگیر: «معمولاً بهتر است» یک ادعای آماری روی مسائلِ بزرگ است، نه ضمانتی برای مسئلهٔ تو. ما همین الان دو بار اندازه گرفتیم — یک بار با بودجهٔ ثابت و یک بار با هدفِ ثابت — و هر دو بار جوابْ یکی درآمد، که خودش نشانهٔ محکم بودنِ نتیجه است. هر بار که کسی گفت فلان انتخاب بهتر است، بپرس «روی چه مسئله‌ای، با چه بودجه‌ای، و چقدر بهتر؟» — و اگر جواب نداشت، خودت اندازه بگیر. این همان قاعدهٔ اولِ کلِ این دوره است.

SGD Adam
چه می‌کند همان قدمِ سادهٔ فصلِ ۳ نرخِ تطبیقی برای هر پارامتر
حافظهٔ اضافه ندارد دو برابرِ تعدادِ پارامتر
حساسیت به lr زیاد کمتر
کجا داده و مدلِ بزرگ، آموزشِ طولانی پیش‌فرضِ خوب برای شروع

۵. الگویی که تا آخرِ دوره تکرار می‌شود#

opt.zero_grad()      # ۱. گرادیان‌های دورِ قبل را پاک کن
loss.backward()      # ۲. گرادیان‌های تازه را حساب کن
opt.step()           # ۳. یک قدم بردار

این سه خط، به همین ترتیب، در هر حلقهٔ آموزشی که تا آخرِ عمر می‌نویسی تکرار می‌شوند.

ترتیبشان اجباری است و جابه‌جا کردنش خطای خاموش می‌دهد:

  • zero_grad بعد از backward یعنی گرادیانِ تازه‌ات را پاک کرده‌ای و قدمی برنمی‌داری.
  • step قبل از backward یعنی با گرادیانِ دورِ قبل قدم برداشته‌ای.

چک کن: ساده‌ترین تستِ سلامتِ حلقه‌ات: loss را روی یک batch کوچکِ ثابت آموزش بده و ببین به نزدیکِ صفر می‌رسد یا نه. اگر شبکه نتواند ده نمونه را حفظ کند، مشکل از داده یا معماری نیست — از خودِ حلقه است. این تستِ سی‌ثانیه‌ای در فصلِ ۸ اولین قدمِ چک‌لیستِ اشکال‌زدایی است.

🔧 اگر کار نکرد: loss.backward() خطای element 0 of tensors does not require grad یعنی مدل را داخلِ no_grad صدا زده‌ای. optimizer got an empty parameter list یعنی model.parameters() خالی است — super().__init__() جا مانده (فصلِ ۴). loss اصلاً تکان نمی‌خورد یعنی یا opt.step() را ننوشته‌ای یا lr عملاً صفر است.

🤖 از دستیارت بپرس: «تفاوتِ SGD با momentum و Adam در یک پاراگراف چیست؟» بعد آزمایشِ بخشِ ۴ را با SGD(momentum=0.9) هم اجرا کن و جدولِ سه‌تایی بکش. و این سؤال را هم بپرس: «چرا Adam برای هر پارامتر دو عدد اضافه نگه می‌دارد؟» — جوابش توضیح می‌دهد چرا آموزشِ مدلِ بزرگ با Adam سه برابرِ حافظهٔ خودِ مدل لازم دارد، که در ترمِ ۶ واقعاً به آن برمی‌خوری.

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

کلمه تلفظ به حروف فارسی یعنی چه
loss function لاس فانکشن عددی که می‌گوید مدل چقدر بد کار می‌کند
optimizer آپتیمایزر چیزی که پارامترها را به‌روز می‌کند
SGD اس‌جی‌دی همان قدمِ سادهٔ gradient descent
Adam آدام optimizer با نرخِ تطبیقی برای هر پارامتر
momentum مومنتوم نگه‌داشتنِ بخشی از جهتِ قدمِ قبلی

تمرین‌ها

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

در فصل بعد#

همهٔ قطعه‌ها را داری، ولی هنوز کلِ داده را یکجا به مدل می‌دهی. برای دادهٔ واقعی این ممکن نیست. فصلِ بعد Dataset و DataLoader را می‌آورد، batch و epoch را تعریف می‌کند، و ساختارِ استانداردی می‌سازد که تا آخرِ دوره همان می‌ماند.

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

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