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

فصل ۵ از ۹

پیشرفت ترم
۰٪

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

بدونِ تابعِ فعال‌سازی، شبکه فقط یک خط است

فصل ۵پیش‌نمایش رایگان
۱۰ دقیقه مطالعه فصل ۵

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

در فصلِ قبل یک torch.relu وسطِ forward گذاشتیم و گفتیم بعداً توضیحش می‌دهیم. این فصل همان بعداً است — و ادعایش تند است: شبکه‌ای با صد لایه و بدونِ تابعِ فعال‌سازی، دقیقاً به‌اندازهٔ یک لایه توانایی دارد.

این را نه با استدلال، بلکه با کد ثابت می‌کنیم: دو لایهٔ پشتِ‌سرِ‌هم را می‌سازیم و یک لایهٔ معادلش را، و می‌بینیم عددهایشان یکی است.

دو خط‌کشِ صاف که هرچه پشتِ سرِ هم بگذاری‌شان باز هم یک خطِ صاف می‌سازند

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

  • بگویی چرا ترکیبِ چند تبدیلِ خطی باز هم خطی است
  • ReLU، sigmoid و tanh را بشناسی و بدانی کجا به‌کار می‌روند
  • softmax را بفهمی و بدانی چرا خروجیِ خام را logit می‌گویند
  • با عدد نشان بدهی که تابعِ فعال‌سازی چه چیزی اضافه می‌کند

قبل از شروع#

از فصل ۴: nn.Module، nn.Linear، و اینکه یک لایه فقط است.

از فصل ۳: حلقهٔ دستیِ gradient descent — در بخشِ ۵ همان را به‌کار می‌بریم.

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

۱. اثبات: دو لایه = یک لایه#

import torch
import torch.nn as nn

torch.manual_seed(0)
a = nn.Linear(2, 4)
b = nn.Linear(4, 1)
x = torch.randn(3, 2)

stacked = b(a(x))                                    # دو لایه، پشتِ سرِ هم

W = b.weight @ a.weight                              # یک ماتریسِ معادل
bias = b(a.bias.unsqueeze(0)).squeeze(0)             # و یک bias معادل
single = x @ W.T + bias

print("دو لایهٔ پشت‌سرهم:", stacked.detach().flatten())
print("یک لایهٔ معادل   :", single.detach().flatten())
print("یکی هستند:", bool(torch.allclose(stacked, single, atol=1e-6)))
دو لایهٔ پشت‌سرهم: tensor([ 0.0330, -0.2449,  0.0089])
یک لایهٔ معادل   : tensor([ 0.0330, -0.2449,  0.0089])
یکی هستند: True

چک کن: خطِ آخر باید True باشد. اگر False شد، atol را کمی بزرگ‌تر کن — این اختلافِ ریاضی نیست، خطای گردکردنِ float32 است و همان چیزی است که در بخشِ ۳ روی جمعِ softmax هم می‌بینی.

عددها یکی‌اند، تا شش رقمِ اعشار.

دلیلش ریاضیِ ساده است: اگر لایهٔ اول باشد و دومی ، حاصلشان می‌شود

و آن هم دقیقاً شکلِ یک لایهٔ خطیِ دیگر است، با ماتریسِ و بایاسِ جدید.

پس صد لایهٔ خطیِ پشتِ سرِ هم هم فقط یک ماتریسِ بزرگ‌ترند. عمق بدونِ تابعِ فعال‌سازی هیچ توانایی‌ای اضافه نمی‌کند — فقط پارامترهای بیشتر برای یادگرفتنِ همان چیز.

🌱 ریشه‌اش کجاست: «ترکیبِ دو تبدیل باز هم یک تبدیل است» دقیقاً همان چیزی است که ریشه، ترم ۷ فصل ۵ اثباتش می‌کند — و آنجا می‌گوید ضربِ ماتریس برای همین این‌طور تعریف شده. یعنی این محدودیتِ شبکه‌های عصبی نیست؛ یک واقعیتِ جبرِ خطی است که شبکه‌ها باید از آن فرار کنند.

۲. راهِ فرار: یک تابعِ غیرخطی وسط#

v = torch.tensor([-2.0, -0.5, 0.0, 0.5, 2.0])

print("ورودی  :", v)
print("ReLU   :", torch.relu(v))
print("sigmoid:", torch.sigmoid(v).round(decimals=3))
print("tanh   :", torch.tanh(v).round(decimals=3))
ورودی  : tensor([-2.0000, -0.5000,  0.0000,  0.5000,  2.0000])
ReLU   : tensor([0.0000, 0.0000, 0.0000, 0.5000, 2.0000])
sigmoid: tensor([0.1190, 0.3780, 0.5000, 0.6220, 0.8810])
tanh   : tensor([-0.9640, -0.4620,  0.0000,  0.4620,  0.9640])
تابع چه می‌کند بازه کجا
ReLU منفی‌ها را صفر می‌کند پیش‌فرضِ لایه‌های پنهان
sigmoid هر عددی را به بینِ ۰ و ۱ می‌برد خروجیِ طبقه‌بندیِ دودویی
tanh مثلِ sigmoid ولی حولِ صفر گاهی در لایه‌های پنهان
softmax چند عدد را به یک توزیعِ احتمال جمعشان ۱ خروجیِ طبقه‌بندیِ چندکلاسه

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

sigmoid و tanh برای عددهای بزرگ اشباع می‌شوند: مشتقشان تقریباً صفر می‌شود و یادگیری در آن ناحیه متوقف. همین یک دلیل است که شبکه‌های عمیقِ دههٔ ۲۰۰۰ سخت آموزش می‌دیدند و ReLU بخشِ بزرگی از حلش بود.

⚠️ مواظب باش: ReLU در مشتقِ ریاضیِ تعریف‌شده ندارد — چپ و راستش فرق دارند. PyTorch یک انتخابِ دلبخواهی کرده (صفر می‌گذارد) و در عمل هیچ مشکلی نمی‌سازد، چون احتمالِ افتادنِ دقیقاً روی صفر ناچیز است. ولی بدان که یک قرارداد است، نه یک حقیقتِ ریاضی — همان چیزی که در تمرینِ ستاره‌دارِ فصلِ ۲ دیدی.

۳. softmax و کلمهٔ logit#

logits = torch.tensor([2.0, 1.0, 0.1])
probs = torch.softmax(logits, dim=0)

print("logits :", logits)
print("softmax:", probs.round(decimals=4))
print("جمعشان :", probs.sum().item())
logits : tensor([2.0000, 1.0000, 0.1000])
softmax: tensor([0.6590, 0.2424, 0.0986])
جمعشان : 1.0000001192092896

softmax سه عددِ دلبخواه را به سه احتمال تبدیل کرد که جمعشان ۱ است. بزرگ‌ترین ورودی بزرگ‌ترین احتمال را گرفت، ولی نه به‌طورِ خطی — اختلافِ ۲ و ۱ به نسبتِ ۰٫۶۵۹ به ۰٫۲۴۲ تبدیل شد، یعنی حدودِ ۲٫۷ برابر.

آن جمعِ 1.0000001 هم اشتباه نیست: float32 دقتِ محدود دارد و این خطای گردکردنِ طبیعی است. هیچ‌وقت روی برابریِ دقیقِ اعداد اعشاری شرط نگذار؛ torch.allclose برای همین وجود دارد.

به خروجیِ خام قبل از softmax می‌گویند logit — و این کلمه در کلِ ادامهٔ دوره تکرار می‌شود.

big = torch.tensor([12.0, 11.0, 10.1])
print("softmax روی عددهای بزرگ‌تر:", torch.softmax(big, dim=0).round(decimals=4))
print("همان اختلاف‌ها، همان جواب:", bool(torch.allclose(torch.softmax(big, dim=0), probs)))
softmax روی عددهای بزرگ‌تر: tensor([0.6590, 0.2424, 0.0986])
همان اختلاف‌ها، همان جواب: True

ده واحد به همهٔ ورودی‌ها اضافه کردیم و خروجی ذره‌ای عوض نشد. softmax فقط به اختلافها اهمیت می‌دهد، نه به مقدارِ مطلق.

این خاصیت در فصلِ بعد به دردت می‌خورد: چون خروجیِ مطلق مهم نیست، مدل لازم نیست عددهای معنادار بدهد و همان چیزی که می‌دهد کافی است.

🌱 ریشه‌اش کجاست: softmax از تابعِ نمایی ساخته شده و رفتارِ عجیبش — اینکه اختلافِ کوچک در ورودی به اختلافِ بزرگ در خروجی تبدیل می‌شود — مستقیماً از شکلِ همان تابع می‌آید. ریشه، ترم ۴ فصل ۷ — رشدِ نمایی شهودش را می‌دهد.

۴. جای درستِ هر کدام#

# غلط: sigmoid روی خروجی، و بعد CrossEntropyLoss
out = torch.sigmoid(self.last_layer(x))
loss = nn.CrossEntropyLoss()(out, labels)

# درست: خروجیِ خام (logit) را بده؛ تابعِ خطا خودش softmax می‌زند
out = self.last_layer(x)
loss = nn.CrossEntropyLoss()(out, labels)

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

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

۵. حالا با عدد: چقدر فرق می‌کند#

می‌خواهیم یک شبکه را یاد بگیرد — منحنی‌ای که قطعاً خطی نیست.

class NoAct(nn.Module):
    def __init__(self):
        super().__init__()
        self.a = nn.Linear(1, 16)
        self.b = nn.Linear(16, 1)

    def forward(self, x):
        return self.b(self.a(x))


class WithAct(nn.Module):
    def __init__(self):
        super().__init__()
        self.a = nn.Linear(1, 16)
        self.b = nn.Linear(16, 1)

    def forward(self, x):
        return self.b(torch.relu(self.a(x)))


X = torch.linspace(-3, 3, 100).unsqueeze(1)
Y = X ** 2


def fit(model_class, steps=3000, lr=0.02):
    torch.manual_seed(0)
    model = model_class()
    for _ in range(steps):
        loss = ((model(X) - Y) ** 2).mean()
        loss.backward()
        with torch.no_grad():
            for p in model.parameters():
                p -= lr * p.grad
                p.grad.zero_()
    return loss.item()


for name, cls in [("بدون فعال‌سازی", NoAct), ("با ReLU     ", WithAct)]:
    print(f"{name}: MSE نهایی = {fit(cls):.4f}")
بدون فعال‌سازی: MSE نهایی = 7.4916
با ReLU     : MSE نهایی = 0.0125

۷٫۴۹ در برابرِ ۰٫۰۱ — تقریباً ششصد برابر. همان معماری، همان تعدادِ پارامتر، همان تعدادِ قدم، همان seed. تنها تفاوت یک torch.relu.

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

📏 اندازه بگیر: این الگو را نگه دار: ادعا را با آزمایشِ کنترل‌شده بسنج، نه با استدلال. دو مدل که در همه‌چیز جز یک تفاوت یکسان‌اند، تنها راهِ اثباتِ اثرِ آن تفاوت است. اسمِ رسمی‌اش ablation است و در ترمِ ۲ فصلِ ۷ به‌عنوان یک روشِ کاری کاملاً بازش می‌کنیم. هر بار که کسی گفت «این بهترش می‌کند»، همین را از او بخواه.

🔧 اگر کار نکرد: اگر loss هر دو مدل nan شد، lr را نصف کن. اگر مدلِ ReLU هم بد ماند، relu را در forward جا انداخته‌ای — چاپ کردنِ خودِ مدل کمکی نمی‌کند، چون torch.relu لایه نیست و در print(model) دیده نمی‌شود. این یکی از معدود جاهایی است که کد چیزی را نشان نمی‌دهد و باید forward را با چشم بخوانی.

🤖 از دستیارت بپرس: «چه تابع‌های فعال‌سازیِ دیگری هست و هرکدام چه مشکلی از ReLU را حل می‌کنند؟» (LeakyReLU، GELU، SiLU را خواهی دید.) بعد یکی‌شان را در WithAct جایگزین کن و همان آزمایشِ بخشِ ۵ را تکرار کن. اگر بهتر نشد، این هم یک نتیجهٔ معتبر است — و در ترمِ ۴ خواهی دید که GELU در transformerها انتخابِ رایج است، به دلیلی که آنجا می‌بینی.

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

کلمه تلفظ به حروف فارسی یعنی چه
activation function اکتیویشن فانکشن تابعِ غیرخطیِ بینِ لایه‌ها
ReLU رِلو «منفی را صفر کن»
sigmoid / tanh سیگموید / تانژانت هایپربولیک فشرده کردن به یک بازهٔ محدود
softmax سافت‌مکس تبدیلِ چند عدد به توزیعِ احتمال
logit لاجیت خروجیِ خامِ مدل، قبل از softmax
saturation سچوریشن ناحیه‌ای که مشتق تقریباً صفر می‌شود

تمرین‌ها

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

در فصل بعد#

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

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

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