در این فصل چه یاد میگیری#
چهار خطِ دستیِ فصلِ ۳ حالا اسمِ رسمی و بستهبندیِ آماده پیدا میکنند. 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 را تعریف میکند، و ساختارِ استانداردی میسازد که تا آخرِ دوره همان میماند.
به آخر این فصل رسیدی!
اگر ساختی و جواب داد، این دکمه مال توست.