فاز 3 · درس 4

توابع فعال‌ساز در یادگیری عمیق: پیاده‌سازی از صفر و مقایسه

نوع
ساختنی
زمان
حدود 75 دقیقه
زبان‌ها
Python
پیش‌نیاز
درس 03.03 (پس‌انتشار)
نمودار مقایسه‌ای منحنی‌های توابع فعال‌ساز ریاضی روی کاغذ شطرنجی با نورپردازی آبی و نارنجی
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. چرا غیرخطی بودن ضروری است؟
  5. تابع Sigmoid
  6. تابع Tanh
  7. تابع ReLU: نقطه‌ی عطف بزرگ
  8. تابع Leaky ReLU
  9. تابع GELU: گزینه‌ی پیش‌فرض امروزی
  10. تابع Swish / SiLU
  11. تابع Softmax: فعال‌ساز لایه‌ی خروجی
  12. مقایسه‌ی شکل توابع
  13. مقایسه‌ی جریان گرادیان
  14. چه زمانی از کدام فعال‌ساز استفاده کنیم؟
  15. پیاده‌سازی گام‌به‌گام
  16. گام 1: پیاده‌سازی همه‌ی توابع فعال‌ساز و مشتق آن‌ها
  17. گام 2: مصورسازی نقاط مرگ گرادیان
  18. گام 3: آزمایش پدیده‌ی محو شدگی گرادیان
  19. گام 4: شناساگر نورون‌های مرده
  20. گام 5: مقایسه‌ی آموزش — Sigmoid در برابر ReLU و GELU
  21. نحوه‌ی استفاده‌ی کاربردی
  22. نتیجه و خروجی
  23. تمرین‌ها
  24. اصطلاحات کلیدی
  25. برای مطالعه‌ی بیشتر

اگر لایه‌های یک شبکه‌ی عصبی را صرفاً با ضرب ماتریسی روی هم بچینید، حتی یک مدل 100لایه‌ای هم توانایی یادگیری الگوهای پیچیده و غیرخطی مثل تفکیک داده‌های XOR یا بازشناسی تصویر را پیدا نمی‌کند؛ چون چند تبدیل خطی پیاپی در نهایت به یک تبدیل خطی تقلیل می‌یابند. توابع فعال‌ساز همان ابزاری هستند که این خطی بودن را در هم می‌شکنند و مرزهای تصمیم‌گیری شبکه را منعطف می‌سازند.

انتخاب ناآگاهانه‌ی تابع فعال‌ساز می‌تواند آموزش مدل را پیش از شروع متوقف کند؛ مشکلی که معمولاً خود را در قالب محو شدگی گرادیان در لایه‌های ابتدایی یا از کار افتادن و مرگ دائمی نورون‌ها نشان می‌دهد. شناخت رفتار ریاضی و مشتق هر تابع—از Sigmoid و Tanh گرفته تا گزینه‌های مدرنی مثل ReLU و GELU—مهارتی ضروری برای مهندسان یادگیری ماشین است تا در معماری‌هایی مانند ترنسفورمرها و CNNها دست به انتخاب درست بزنند.

کدها و آزمایش‌های این درس سبک طراحی شده‌اند و پیاده‌سازی آن‌ها به پردازنده‌ی ابری یا تجهیزات خاصی وابسته نیست؛ بنابراین می‌توانید تمام مراحل را به‌راحتی روی پردازنده‌ی استاندارد (CPU) سیستم محلی خود اجرا و بررسی کنید.

بدون غیرخطی بودن، شبکه‌ی 100لایه‌ای شما صرفاً یک ضرب ماتریسی پرزرق‌وبرق است. توابع فعال‌ساز همان درگاه‌هایی هستند که به شبکه‌های عصبی امکان می‌دهند خمیده فکر کنند.

اهداف یادگیری

  • پیاده‌سازی sigmoid ،tanh ،ReLU ،Leaky ReLU ،GELU ،Swish و softmax به‌همراه مشتق‌هایشان از صفر
  • ریشه‌یابی مسئله‌ی محو شدگی گرادیان (vanishing gradient) با اندازه‌گیری بزرگی مقادیر فعال‌سازی در بیش از 10 لایه با توابع فعال‌ساز گوناگون
  • شناسایی نورون‌های مرده (dead neurons) در یک شبکه‌ی ReLU و توضیح این‌که چرا GELU جلوی این مشکل را می‌گیرد
  • انتخاب تابع فعال‌ساز مناسب برای معماری‌های مختلف (ترنسفورمر، CNN، RNN یا لایه‌ی خروجی)

صورت مسئله

دو تبدیل خطی را پشت سر هم قرار دهید: y=W2(W1x+b1)+b2y = W_2(W_1 x + b_1) + b_2. آن را بسط دهید: y=W2W1x+W2b1+b2y = W_2 W_1 x + W_2 b_1 + b_2. نتیجه چیزی نیست جز y=Ax+cy = Ax + c؛ یعنی فقط یک تبدیل خطی واحد. فرقی نمی‌کند چند لایه‌ی خطی را روی هم بچینید، خروجی نهایی همیشه به یک ضرب ماتریسی تقلیل می‌یابد. شبکه‌ی 100لایه‌ای شما دقیقاً همان توانایی بازنمایی یک لایه‌ی تکی را دارد.

این موضوع صرفاً یک بحث تئوری جذاب نیست؛ یعنی یک شبکه‌ی عمیق خطی عملاً نمی‌تواند تابع XOR را یاد بگیرد، داده‌های مارپیچی را دسته‌بندی کند یا چهره‌ای را تشخیص دهد. بدون توابع فعال‌ساز، عمق شبکه سرابی بیش نیست.

توابع فعال‌ساز خطی بودن را در هم می‌شکنند. آن‌ها خروجی هر لایه را از یک تابع غیرخطی عبور می‌دهند و به شبکه توانایی می‌دهند تا مرزهای تصمیم‌گیری را خم کند، توابع دلخواه را تقریب بزند و واقعاً یاد بگیرد. اما اگر تابع فعال‌ساز اشتباهی انتخاب کنید، گرادیان‌هایتان محو شده و به صفر می‌رسند (مانند sigmoid در شبکه‌های عمیق)، منفجر شده و به بی‌نهایت میل می‌کنند (توابع فعال‌ساز بی‌کران بدون مقداردهی اولیه‌ی دقیق)، یا نورون‌هایتان برای همیشه خاموش می‌شوند (مانند ReLU با بایاس‌های منفی بزرگ). انتخاب تابع فعال‌ساز مستقیماً تعیین می‌کند که آیا شبکه‌ی شما اصلاً چیزی یاد می‌گیرد یا نه.

مفاهیم پایه

چرا غیرخطی بودن ضروری است؟

ضرب ماتریسی خاصیت ترکیب‌پذیری دارد. ضرب یک بردار در ماتریس AA و سپس ماتریس BB، دقیقاً مثل ضرب آن در ماتریس ABAB است. یعنی چیدن ده لایه‌ی خطی روی هم، از نظر ریاضی دقیقاً معادل یک لایه‌ی خطی با یک ماتریس بزرگ است. تمام آن پارامترها و همه‌ی آن عمق عملاً هدر رفته‌اند. به چیزی نیاز دارید که این زنجیره را بشکند؛ و این دقیقاً کاری است که توابع فعال‌ساز انجام می‌دهند.

اثبات ریاضی آن ساده است. یک لایه‌ی خطی عبارت f(x)=Wx+bf(x) = Wx + b را محاسبه می‌کند. دو لایه را روی هم بگذارید:

Layer 1:h=W1x+b1Layer 2:y=W2h+b2\begin{aligned} \text{Layer 1:} \quad h &= W_1 x + b_1 \\ \text{Layer 2:} \quad y &= W_2 h + b_2 \end{aligned}

جای‌گذاری کنید:

y=W2(W1x+b1)+b2y=(W2W1)x+(W2b1+b2)y=Ax+c\begin{aligned} y &= W_2 (W_1 x + b_1) + b_2 \\ y &= (W_2 W_1) x + (W_2 b_1 + b_2) \\ y &= A x + c \end{aligned}

دوباره همان یک لایه شد. حالا یک تابع فعال‌ساز غیرخطی g()g() بین لایه‌ها قرار دهید:

h=g(W1x+b1)y=W2h+b2\begin{aligned} h &= g(W_1 x + b_1) \\ y &= W_2 h + b_2 \end{aligned}

اکنون زنجیره‌ی جای‌گذاری از هم می‌پاشد. عبارت W2g(W1x+b1)+b2W_2 g(W_1 x + b_1) + b_2 را نمی‌توان به یک تبدیل خطی واحد ساده کرد. حالا شبکه می‌تواند توابع غیرخطی را بازنمایی کند. با افزودن هر لایه‌ی جدید مجهز به تابع فعال‌ساز، ظرفیت بازنمایی مدل بیشتر می‌شود.

تابع Sigmoid

نخستین تابع فعال‌ساز در شبکه‌های عصبی.

sigmoid(x)=11+e−x\text{sigmoid}(x) = \frac{1}{1 + e^{-x}}

بازه‌ی خروجی: (0, 1). نرم، مشتق‌پذیر و نگاشت‌کننده‌ی هر عدد حقیقی به مقداری شبیه احتمال.

مشتق آن:

sigmoid′(x)=sigmoid(x)⋅(1−sigmoid(x))\text{sigmoid}'(x) = \text{sigmoid}(x) \cdot (1 - \text{sigmoid}(x))

بیشترین مقدار این مشتق 0.25 است که در x=0x = 0 رخ می‌دهد. در پس‌انتشار، گرادیان‌ها لایه‌به‌لایه در یکدیگر ضرب می‌شوند. ده لایه با تابع sigmoid یعنی گرادیان حداکثر ده بار در 0.25 ضرب می‌شود:

0.2510=0.0000009536740.25^{10} = 0.000000953674

کمتر از یک‌میلیونیوم سیگنال اولیه! این همان مسئله‌ی محو شدگی گرادیان است. گرادیان‌ها در لایه‌های ابتدایی آن‌قدر ناچیز می‌شوند که وزن‌ها عملاً به‌روزرسانی نمی‌شوند. در ظاهر به نظر می‌رسد شبکه یاد می‌گیرد—چون تابع زیان (loss) در لایه‌های آخر کم می‌شود—اما لایه‌های نخست کاملاً قفل شده‌اند. شبکه‌های عمیق مبتنی بر sigmoid اصلاً آموزش‌پذیر نیستند.

مشکل دیگر: خروجی‌های sigmoid همیشه مثبت هستند (بین 0 تا 1)، یعنی علامت گرادیان‌های وزن‌ها همواره یکسان می‌ماند. این مسئله حین گرادیان کاهشی (gradient descent) نوسان‌های زیگزاگی شدیدی ایجاد می‌کند.

تابع Tanh

نسخه‌ی مرکزِ صفر شده‌ی تابع sigmoid.

tanh⁡(x)=ex−e−xex+e−x\tanh(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}}

بازه‌ی خروجی: (-1, 1). با مرکزیت صفر (Zero-centered)، که مشکل حرکات زیگزاگی را از بین می‌برد.

مشتق آن:

tanh⁡′(x)=1−tanh⁡(x)2\tanh'(x) = 1 - \tanh(x)^2

بیشترین مقدار مشتق در نقطه‌ی x=0x = 0 برابر 1.0 است؛ یعنی چهار برابر بهتر از sigmoid. با این حال مسئله‌ی محو شدگی گرادیان همچنان پابرجاست. برای ورودی‌های مثبت یا منفی خیلی بزرگ، مشتق به سمت صفر میل می‌کند. ده لایه پشت سر هم باز هم گرادیان را ناچیز می‌کند، فقط کمی ملایم‌تر.

تابع ReLU: نقطه‌ی عطف بزرگ

واحد خطی یکسوساز (Rectified Linear Unit). این تابع که در سال 2010 توسط نایر و هینتون برای یادگیری عمیق فراگیر شد (ریشه‌ی خود تابع به کارهای فوکوشیما در سال 1969 برمی‌گردد)، همه‌چیز را دگرگون کرد.

relu(x)=max⁡(0,x)\text{relu}(x) = \max(0, x)

بازه‌ی خروجی: [0,∞)[0, \infty). مشتق آن به‌طور شگفت‌انگیزی ساده است:

relu′(x)={1if x>00if x≤0\text{relu}'(x) = \begin{cases} 1 & \text{if } x > 0 \\ 0 & \text{if } x \le 0 \end{cases}

برای ورودی‌های مثبت هیچ محو شدگی گرادیانی اتفاق نمی‌افتد. گرادیان دقیقاً عدد 1 است و مستقیماً عبور می‌کند. به همین دلیل آموزش شبکه‌های عمیق ممکن شد؛ چرا که ReLU بزرگی گرادیان را در میان لایه‌ها حفظ می‌کند.

اما یک حالت شکست وجود دارد: مسئله‌ی نورون‌های مرده. اگر ورودی وزن‌دار یک نورون همیشه منفی بماند (مثلاً به دلیل بایاس منفی بزرگ یا مقداردهی اولیه‌ی نامناسب وزن‌ها)، خروجی‌اش همیشه صفر، گرادیانش همواره صفر و دیگر هرگز به‌روزرسانی نخواهد شد؛ یعنی برای همیشه می‌میرد. در عمل، ممکن است 10 تا 40 درصد از نورون‌های یک شبکه‌ی ReLU حین آموزش از کار بیفتند.

تابع Leaky ReLU

ساده‌ترین راه‌حل برای مشکل نورون‌های مرده.

leaky_relu(x)={xif x>0αxif x≤0\text{leaky\_relu}(x) = \begin{cases} x & \text{if } x > 0 \\ \alpha x & \text{if } x \le 0 \end{cases}

در اینجا α\alpha عددی ثابت و کوچک است که معمولاً 0.01 در نظر گرفته می‌شود. سمت منفی به جای صفر، شیب اندکی دارد؛ در نتیجه نورون‌های مرده همچنان سیگنال گرادیان دریافت می‌کنند و می‌توانند احیا شوند.

تابع GELU: گزینه‌ی پیش‌فرض امروزی

واحد خطی خطای گوسی (Gaussian Error Linear Unit). این تابع در سال 2016 توسط هندریکس و گیمپل معرفی شد و اکنون تابع فعال‌ساز پیش‌فرض در BERT ،GPT و بیشتر ترنسفورمرهای مدرن است.

gelu(x)=x⋅Φ(x)\text{gelu}(x) = x \cdot \Phi(x)

که در آن Φ(x)\Phi(x) تابع توزیع تجمعی توزیع نرمال استاندارد است. تقریبی که در عمل استفاده می‌شود به این صورت است:

gelu(x)≈0.5⋅x⋅(1+tanh⁡(2/π⋅(x+0.044715⋅x3)))\text{gelu}(x) \approx 0.5 \cdot x \cdot \left(1 + \tanh\left(\sqrt{2/\pi} \cdot (x + 0.044715 \cdot x^3)\right)\right)

تابع GELU در تمام نقاط نرم است، برخلاف ReLU که مقادیر منفی را کاملاً قطع و صفر می‌کند اجازه می‌دهد مقادیر منفی کوچک عبور کنند و تفسیری احتمالاتی دارد: هر ورودی را بر اساس احتمال مثبت بودنش تحت یک توزیع گوسی وزن‌دهی می‌کند. این گیت‌بندی نرم در معماری‌های ترنسفورمر عملکرد بهتری نسبت به ReLU دارد؛ زیرا جریان گرادیان بهتری ایجاد می‌کند و مسئله‌ی نورون‌های مرده را هم به‌کلی از بین می‌برد.

تابع Swish / SiLU

تابع فعال‌ساز خودگیت‌بندی‌شده (Self-gated) که راماشاندران و همکارانش در سال 2017 از طریق جست‌وجوی خودکار کشف کردند.

swish(x)=x⋅sigmoid(x)\text{swish}(x) = x \cdot \text{sigmoid}(x)

فرمول رسمی Swish به صورت x⋅sigmoid(x)x \cdot \text{sigmoid}(x) است. گوگل آن را با جست‌وجوی خودکار در فضای توابع فعال‌ساز پیدا کرد؛ شبکه‌ی عصبی‌ای که قطعات شبکه‌های عصبی دیگر را طراحی می‌کند!

این تابع هم مانند GELU نرم و غیریکنواخت است و مقادیر منفی کوچک را نگه می‌دارد. تفاوت بسیار ظریف است: Swish از sigmoid برای گیت‌بندی استفاده می‌کند، در حالی که GELU از تابع توزیع تجمعی گوسی بهره می‌برد. در عمل بازدهی این دو تقریباً یکسان است. Swish بیشتر در EfficientNet و برخی مدل‌های بینایی ماشین استفاده می‌شود، اما GELU در مدل‌های زبانی حاکم است.

تابع Softmax: فعال‌ساز لایه‌ی خروجی

این تابع در لایه‌های پنهان کاربردی ندارد. سافت‌مکس برداری از امتیازهای خام (لاجیت‌ها) را به یک توزیع احتمال تبدیل می‌کند.

softmax(xi)=exi∑jexj\text{softmax}(x_i) = \frac{e^{x_i}}{\sum_j e^{x_j}}

همه‌ی خروجی‌ها بین 0 و 1 قرار می‌گیرند و جمع کل خروجی‌ها برابر 1 می‌شود. همین ویژگی آن را به گزینه‌ی استاندارد نهایی برای دسته‌بندی چندکلاسه تبدیل کرده است. بزرگ‌ترین لاجیت بیشترین احتمال را می‌گیرد، اما برخلاف argmax، سافت‌مکس مشتق‌پذیر است و اطلاعات مربوط به میزان اطمینان نسبی به هر کلاس را نگه می‌دارد.

مقایسه‌ی شکل توابع

توابع فعال‌سازیمحوشدگی گرادیانخفیف‌تر اماهمچنان محو می‌شودگرادیان = 1برای x > 0گرادیان نرمدر همه‌جاSigmoidبازه: (0,1)اشباع در دو انتهاTanhبازه: (-1,1)مرکز صفرReLUبازه: [0,inf)نورون‌های مردهGELUبازه: ~(-0.17,inf)گیتینگ نرمشبکه‌های عمیقآموزش نمی‌بینندشبکه‌های عمیقسریع آموزش می‌بینند

مقایسه‌ی جریان گرادیان

گرادیان در لایه 1سیگنال ورودیلایه 1لایه 5لایه 10خروجیSigmoid: ~0.000001Tanh: ~0.001ReLU: ~1.0GELU: ~0.8

چه زمانی از کدام فعال‌ساز استفاده کنیم؟

لایه‌های پنهانلایه خروجیترنسفورمر / NLPCNN / بینایی ماشینRNN / LSTMMLP سادهطبقه‌بندی دودوییطبقه‌بندی چندکلاسهرگرسیونچه چیزی می‌سازید؟لایه‌های پنهانیا خروجی؟معماری؟نوع تسک؟استفاده از GELUاستفاده از ReLU یا Swishاستفاده از Tanhاستفاده از ReLUاستفاده از Sigmoidاستفاده از سافت‌مکساستفاده از خطی (بدون فعال‌سازی)
دمای سافت‌مکسT را بکشید
دما لاجیت‌ها را قبل از توان‌رسانی تقسیم می‌کند. مقادیر زیر 1 توزیع را به سمت برترین توکن تیزتر می‌کنند؛ مقادیر بالای 1 آن را به سمت توزیع یکنواخت هموار می‌سازند. در T→0 معادل argmax است؛ در T→∞ مانند پرتاب سکه (کاملاً تصادفی) است.

پیاده‌سازی گام‌به‌گام

گام 1: پیاده‌سازی همه‌ی توابع فعال‌ساز و مشتق آن‌ها

هر تابع یک عدد اعشاری تک می‌گیرد و یک عدد اعشاری برمی‌گرداند. توابع مشتق هم همان ورودی را می‌گیرند و گرادیان را تحویل می‌دهند.

import math

def sigmoid(x):
    x = max(-500, min(500, x))
    return 1.0 / (1.0 + math.exp(-x))

def sigmoid_derivative(x):
    s = sigmoid(x)
    return s * (1 - s)

def tanh_act(x):
    return math.tanh(x)

def tanh_derivative(x):
    t = math.tanh(x)
    return 1 - t * t

def relu(x):
    return max(0.0, x)

def relu_derivative(x):
    return 1.0 if x > 0 else 0.0

def leaky_relu(x, alpha=0.01):
    return x if x > 0 else alpha * x

def leaky_relu_derivative(x, alpha=0.01):
    return 1.0 if x > 0 else alpha

def gelu(x):
    return 0.5 * x * (1 + math.tanh(math.sqrt(2 / math.pi) * (x + 0.044715 * x ** 3)))

def gelu_derivative(x):
    phi = 0.5 * (1 + math.erf(x / math.sqrt(2)))
    pdf = math.exp(-0.5 * x * x) / math.sqrt(2 * math.pi)
    return phi + x * pdf

def swish(x):
    return x * sigmoid(x)

def swish_derivative(x):
    s = sigmoid(x)
    return s + x * s * (1 - s)

def softmax(xs):
    max_x = max(xs)
    exps = [math.exp(x - max_x) for x in xs]
    total = sum(exps)
    return [e / total for e in exps]

گام 2: مصورسازی نقاط مرگ گرادیان

گرادیان را در 100 نقطه‌ی هم‌فاصله در بازه‌ی 5- تا 5 محاسبه کنید. یک هیستوگرام متنی چاپ کنید تا مشخص شود گرادیان هر تابع در چه نقاطی نزدیک به صفر است.

def gradient_scan(name, derivative_fn, start=-5, end=5, n=100):
    step = (end - start) / n
    near_zero = 0
    healthy = 0
    for i in range(n):
        x = start + i * step
        g = derivative_fn(x)
        if abs(g) < 0.01:
            near_zero += 1
        else:
            healthy += 1
    pct_dead = near_zero / n * 100
    print(f"{name:15s}: {healthy:3d} healthy, {near_zero:3d} near-zero ({pct_dead:.0f}% dead zone)")

gradient_scan("Sigmoid", sigmoid_derivative)
gradient_scan("Tanh", tanh_derivative)
gradient_scan("ReLU", relu_derivative)
gradient_scan("Leaky ReLU", leaky_relu_derivative)
gradient_scan("GELU", gelu_derivative)
gradient_scan("Swish", swish_derivative)

گام 3: آزمایش پدیده‌ی محو شدگی گرادیان

سیگنالی را در یک گذر رفت (forward-pass) از N لایه با تابع sigmoid در برابر ReLU عبور دهید و بررسی کنید بزرگی مقادیر فعال‌سازی چگونه تغییر می‌کند.

import random

def vanishing_gradient_experiment(activation_fn, name, n_layers=10, n_inputs=5):
    random.seed(42)
    values = [random.gauss(0, 1) for _ in range(n_inputs)]

    print(f"\n{name} through {n_layers} layers:")
    for layer in range(n_layers):
        weights = [random.gauss(0, 1) for _ in range(n_inputs)]
        z = sum(w * v for w, v in zip(weights, values))
        activated = activation_fn(z)
        magnitude = abs(activated)
        bar = "#" * int(magnitude * 20)
        print(f"  Layer {layer+1:2d}: magnitude = {magnitude:.6f} {bar}")
        values = [activated] * n_inputs

vanishing_gradient_experiment(sigmoid, "Sigmoid")
vanishing_gradient_experiment(relu, "ReLU")
vanishing_gradient_experiment(gelu, "GELU")

گام 4: شناساگر نورون‌های مرده

یک شبکه‌ی ReLU بسازید، ورودی‌های تصادفی به آن بدهید و تعداد نورون‌هایی را که هرگز فعال نمی‌شوند بشمارید.

def dead_neuron_detector(n_inputs=5, hidden_size=20, n_samples=1000):
    random.seed(0)
    weights = [[random.gauss(0, 1) for _ in range(n_inputs)] for _ in range(hidden_size)]
    biases = [random.gauss(0, 1) for _ in range(hidden_size)]

    fire_counts = [0] * hidden_size

    for _ in range(n_samples):
        inputs = [random.gauss(0, 1) for _ in range(n_inputs)]
        for neuron_idx in range(hidden_size):
            z = sum(w * x for w, x in zip(weights[neuron_idx], inputs)) + biases[neuron_idx]
            if relu(z) > 0:
                fire_counts[neuron_idx] += 1

    dead = sum(1 for c in fire_counts if c == 0)
    rarely_fire = sum(1 for c in fire_counts if 0 < c < n_samples * 0.05)
    healthy = hidden_size - dead - rarely_fire

    print(f"\nDead Neuron Report ({hidden_size} neurons, {n_samples} samples):")
    print(f"  Dead (never fired):     {dead}")
    print(f"  Barely alive (<5%):     {rarely_fire}")
    print(f"  Healthy:                {healthy}")
    print(f"  Dead neuron rate:       {dead/hidden_size*100:.1f}%")

    for i, c in enumerate(fire_counts):
        status = "DEAD" if c == 0 else "WEAK" if c < n_samples * 0.05 else "OK"
        bar = "#" * (c * 40 // n_samples)
        print(f"  Neuron {i:2d}: {c:4d}/{n_samples} fires [{status:4s}] {bar}")

dead_neuron_detector()

گام 5: مقایسه‌ی آموزش — Sigmoid در برابر ReLU و GELU

یک شبکه‌ی دولایه‌ی یکسان را روی مجموعه‌داده‌ی دایره‌ها (نقاط داخل دایره = کلاس 1، نقاط بیرون = کلاس 0) با سه تابع فعال‌ساز مختلف آموزش دهید و سرعت همگرایی آن‌ها را با هم مقایسه کنید.

def make_circle_data(n=200, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], label))
    return data


class ActivationNetwork:
    def __init__(self, activation_fn, activation_deriv, hidden_size=8, lr=0.1):
        random.seed(0)
        self.act = activation_fn
        self.act_d = activation_deriv
        self.lr = lr
        self.hidden_size = hidden_size

        self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        self.b1 = [0.0] * hidden_size
        self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
        self.b2 = 0.0

    def forward(self, x):
        self.x = x
        self.z1 = []
        self.h = []
        for i in range(self.hidden_size):
            z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
            self.z1.append(z)
            self.h.append(self.act(z))

        self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
        self.out = sigmoid(self.z2)
        return self.out

    def backward(self, target):
        error = self.out - target
        d_out = error * self.out * (1 - self.out)

        for i in range(self.hidden_size):
            d_h = d_out * self.w2[i] * self.act_d(self.z1[i])
            self.w2[i] -= self.lr * d_out * self.h[i]
            for j in range(2):
                self.w1[i][j] -= self.lr * d_h * self.x[j]
            self.b1[i] -= self.lr * d_h
        self.b2 -= self.lr * d_out

    def train(self, data, epochs=200):
        losses = []
        for epoch in range(epochs):
            total_loss = 0
            correct = 0
            for x, y in data:
                pred = self.forward(x)
                self.backward(y)
                total_loss += (pred - y) ** 2
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            avg_loss = total_loss / len(data)
            accuracy = correct / len(data) * 100
            losses.append(avg_loss)
            if epoch % 50 == 0 or epoch == epochs - 1:
                print(f"    Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
        return losses


data = make_circle_data()

configs = [
    ("Sigmoid", sigmoid, sigmoid_derivative),
    ("ReLU", relu, relu_derivative),
    ("GELU", gelu, gelu_derivative),
]

results = {}
for name, act_fn, act_d_fn in configs:
    print(f"\n=== Training with {name} ===")
    net = ActivationNetwork(act_fn, act_d_fn, hidden_size=8, lr=0.1)
    losses = net.train(data, epochs=200)
    results[name] = losses

print("\n=== Final Loss Comparison ===")
for name, losses in results.items():
    print(f"  {name:10s}: start={losses[0]:.4f} -> end={losses[-1]:.4f} (improvement: {(1 - losses[-1]/losses[0])*100:.1f}%)")

نحوه‌ی استفاده‌ی کاربردی

کتابخانه‌ی PyTorch همه‌ی این موارد را هم در قالب توابع و هم در قالب ماژول ارائه می‌دهد:

import torch
import torch.nn as nn
import torch.nn.functional as F

x = torch.randn(4, 10)

relu_out = F.relu(x)
gelu_out = F.gelu(x)
sigmoid_out = torch.sigmoid(x)
swish_out = F.silu(x)

logits = torch.randn(4, 5)
probs = F.softmax(logits, dim=1)

model = nn.Sequential(
    nn.Linear(10, 64),
    nn.GELU(),
    nn.Linear(64, 32),
    nn.GELU(),
    nn.Linear(32, 5),
)

لایه‌های پنهان در ترنسفورمر: GELU. لایه‌های پنهان در CNN: تابع ReLU. لایه‌ی خروجی برای دسته‌بندی: softmax. لایه‌ی خروجی برای رگرسیون: بدون تابع (خطی). لایه‌ی خروجی برای خروجی‌های احتمالی: sigmoid. اصل کار همین است. کار را با همین پیش‌فرض‌ها شروع کنید و فقط وقتی تغییرشان دهید که شواهد (evidence) کافی داشته باشید.

شبکه‌های RNN و LSTM از tanh برای وضعیت پنهان (hidden state) و از sigmoid برای گیت‌ها استفاده می‌کنند، اما اگر امروز سیستمی را از صفر طراحی می‌کنید احتمالاً سراغ RNN نمی‌روید. اگر نورون‌ها در شبکه‌ی ReLU شما از کار می‌افتند، سراغ GELU بروید. بی‌دلیل به سمت Leaky ReLU نروید—GELU مسئله‌ی نورون‌های مرده را کاملاً حل می‌کند و جریان گرادیان بهتری هم دارد.

نتیجه و خروجی

خروجی این درس:

  • فایل outputs/prompt-activation-selector.md — یک پرامپت با قابلیت استفاده‌ی مجدد برای انتخاب تابع فعال‌ساز درست متناسب با هر معماری

تمرین‌ها

  1. تابع Parametric ReLU (PReLU) را که در آن شیب منفی α\alpha یک پارامتر یادگرفتنی است پیاده‌سازی کنید. آن را روی مجموعه‌داده‌ی دایره آموزش دهید و با Leaky ReLU مقایسه کنید.

  2. آزمایش محو شدگی گرادیان را این بار به جای 10 لایه با 50 لایه اجرا کنید. بزرگی سیگنال را در هر لایه برای توابع sigmoid ،tanh ،ReLU و GELU رسم کنید. سیگنال هر تابع در کدام لایه عملاً به صفر می‌رسد؟

  3. تابع ELU (واحد خطی نمایی) را پیاده‌سازی کنید: اگر x>0x \gt 0 بود elu(x)=x\text{elu}(x) = x، و اگر x≤0x \le 0 بود α(ex−1)\alpha (e^x - 1). نرخ نورون‌های مرده‌ی آن را با ReLU روی یک شبکه‌ی یکسان مقایسه کنید.

  4. یک «پایشگر سلامت گرادیان» بنویسید که حین آموزش اجرا شود: در هر دوره (epoch)، میانگین بزرگی گرادیان را در هر لایه محاسبه کند. اگر گرادیان لایه‌ای کمتر از 0.001 یا بیشتر از 100 شد، هشدار چاپ کند.

  5. مقایسه‌ی آموزشی را طوری تغییر دهید که به جای داده‌های دایره، از مجموعه‌داده‌ی XOR درس 01 استفاده کند. کدام تابع سریع‌تر از بقیه روی داده‌های XOR همگرا می‌شود؟ چرا نتیجه با نتایج داده‌های دایره فرق دارد؟

اصطلاحات کلیدی

اصطلاح در گفت‌وگوهای عامیانه معنای دقیق فنی
تابع فعال‌ساز (Activation function) «بخش غیرخطی مدل» تابعی که روی خروجی هر نورون اعمال می‌شود تا خطی بودن را بشکند و به شبکه امکان یادگیری نگاشت‌های غیرخطی را بدهد
محو شدگی گرادیان (Vanishing gradient) «از بین رفتن گرادیان‌ها در شبکه‌های عمیق» افت نمایی گرادیان‌ها در طول لایه‌ها در شرایطی که مشتق تابع فعال‌ساز کمتر از 1 باشد؛ اتفاقی که آموزش لایه‌های اولیه را غیرممکن می‌کند
انفجار گرادیان (Exploding gradient) «سر به فلک کشیدن گرادیان‌ها» رشد نمایی گرادیان‌ها در طول لایه‌ها وقتی ضریب مؤثر از 1 بزرگ‌تر شود؛ پدیده‌ای که پایداری آموزش را به هم می‌زند
نورون مرده (Dead neuron) «نورونی که یادگیری را متوقف کرده» نورونی مبتنی بر ReLU که ورودی‌اش برای همیشه منفی شده، خروجی صفر می‌دهد و گرادیانش هم صفر است
سیگموئید (Sigmoid) «فشردن مقادیر بین 0 تا 1» تابع لجستیک 1/(1+e−x)1/(1+e^{-x})، دارای اهمیت تاریخی اما عامل اصلی محو شدگی گرادیان در شبکه‌های عمیق
رلو (ReLU) «صفر کردن مقادیر منفی» تابع max⁡(0,x)\max(0, x)؛ فعال‌سازی که با حفظ بزرگی گرادیان، یادگیری عمیق کاربردی را محقق کرد
گلو (GELU) «فعال‌ساز ترنسفورمرها» واحد خطی خطای گوسی؛ تابعی هموار که ورودی‌ها را براساس احتمال مثبت بودنشان وزن‌دهی می‌کند
سوئیش (Swish/SiLU) «ReLU خودگیت‌بندی‌شده» تابع x⋅sigmoid(x)x \cdot \text{sigmoid}(x) که با جست‌وجوی خودکار کشف شد و در EfficientNet به کار می‌رود
سافت‌مکس (Softmax) «تبدیل امتیازها به احتمال» بردار لاجیت‌ها را نرمال‌سازی کرده و به توزیع احتمالی تبدیل می‌کند که همه‌ی مقادیر در بازه‌ی (0,1) باشند و مجموعشان 1 شود
لیکی رلو (Leaky ReLU) «ReLU نامیرا» تابع max⁡(αx,x)\max(\alpha x, x) که در آن α\alpha مقداری کوچک است (0.01) و با ایجاد گرادیان منفی کوچک جلوی مرگ نورون‌ها را می‌گیرد
اشباع (Saturation) «قسمت صاف تابع sigmoid» بخش‌هایی از تابع که مشتق در آن‌ها به صفر میل می‌کند و مانع جریان گرادیان می‌شود
لاجیت (Logit) «امتیاز خام پیش از softmax» خروجی نرمال‌نشده‌ی لایه‌ی پایانی، درست قبل از اعمال سافت‌مکس یا sigmoid

برای مطالعه‌ی بیشتر

  • مقاله‌ی Nair & Hinton، با عنوان «Rectified Linear Units Improve Restricted Boltzmann Machines» (سال 2010) — مقاله‌ای که ReLU را معرفی کرد و امکان آموزش شبکه‌های عمیق را به وجود آورد
  • مقاله‌ی Hendrycks & Gimpel، با عنوان «Gaussian Error Linear Units (GELUs)» (سال 2016) — مقاله‌ای که تابع فعال‌ساز پیش‌فرض ترنسفورمرها را معرفی کرد
  • مقاله‌ی Ramachandran et al.، با عنوان «Searching for Activation Functions» (سال 2017) — استفاده از جست‌وجوی خودکار برای کشف Swish، نشان‌دهنده‌ی این‌که طراحی توابع فعال‌ساز می‌تواند خودکار انجام شود
  • مقاله‌ی Glorot & Bengio، با عنوان «Understanding the difficulty of training deep feedforward neural networks» (سال 2010) — مقاله‌ای که مسئله‌ی محو شدگی و انفجار گرادیان را ریشه‌یابی کرد و مقداردهی اولیه‌ی خاویر (Xavier initialization) را پیشنهاد داد
  • کتاب «یادگیری عمیق» اثر Goodfellow ،Bengio و Courville، فصل 6.3 (https://www.deeplearningbook.org/) — بررسی دقیق و ریاضیاتی واحدهای پنهان و توابع فعال‌ساز

سؤالات متداول

چرا شبکه‌های عصبی بدون توابع فعال‌ساز نمی‌توانند الگوهای پیچیده را یاد بگیرند؟

چون ضرب ماتریس‌ها خاصیت ترکیب‌پذیری دارد؛ چیدن چند لایه‌ی خطی روی هم از نظر ریاضی با یک لایه‌ی خطی منفرد تفاوتی ندارد و شبکه بدون توابع غیرخطی قادر به خم کردن مرزهای تصمیم‌گیری نخواهد بود.

مشکل نورون مرده (Dead Neuron) در تابع ReLU چیست و چگونه حل می‌شود؟

اگر ورودی یک نورون ReLU همواره منفی باشد، خروجی و گرادیان آن صفر می‌شود و وزن‌هایش هرگز به‌روزرسانی نخواهند شد. توابعی مانند Leaky ReLU با در نظر گرفتن شیب کوچک در بخش منفی یا تابع GELU این مشکل را رفع می‌کنند.

چرا در ترنسفورمرهای مدرن به جای ReLU از تابع GELU استفاده می‌شود؟

تابع GELU به‌صورت هموار ورودی‌ها را براساس احتمال مثبت بودنشان تحت توزیع گوسی وزن‌دهی می‌کند، مقادیر منفی کوچک را نگه می‌دارد و علاوه بر بهبود جریان گرادیان، مانع از کار افتادن دائمی نورون‌ها می‌شود.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.