فاز 3 · درس 3

پیاده‌سازی الگوریتم پس‌انتشار از صفر در پایتون

نوع
ساختنی
زمان
حدود 120 دقیقه
زبان‌ها
Python
پیش‌نیاز
درس 03.02 (شبکه‌های چندلایه)
مجموعه‌ای از گوی‌های به‌هم‌پیوسته که نوری درخشان به ترتیب در خلاف جهت اتصال‌ها به سمت مبدا جریان پیدا می‌کند
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. قاعده‌ی زنجیره‌ای در شبکه‌های عصبی
  5. گراف‌های محاسباتی
  6. مقایسه‌ی مسیر رفت و برگشت
  7. جریان گرادیان در طول شبکه
  8. محو گرادیان
  9. محاسبه‌ی گرادیان‌ها برای یک شبکه‌ی 2 لایه
  10. پیاده‌سازی از صفر
  11. گام 1: گره Value
  12. گام 2: عملیات ریاضی به همراه توابع Backward
  13. گام 3: تابع سیگموئید و تابع زیان
  14. گام 4: گام رو به عقب
  15. گام 5: لایه و شبکه
  16. گام 6: آموزش روی مسئله‌ی XOR
  17. گام 7: تفکیک داده‌های دایره‌ای
  18. استفاده از ابزارهای واقعی
  19. خروجی این درس
  20. تمرین‌ها
  21. اصطلاحات کلیدی
  22. برای مطالعه‌ی بیشتر

بسیاری از توسعه‌دهندگان کار با یادگیری ماشین را مستقیماً با فراخوانی متدهایی مثل `loss.backward()` در PyTorch آغاز می‌کنند، بدون اینکه سازوکار درونی آن را لمس کرده باشند. در عمل، وقتی مدل شما در یادگیری درجا می‌زند، با انفجار مقادیر عددی یا خطای NaN روبه‌رو می‌شود یا لایه‌های ابتدایی آن متوقف می‌مانند، انتزاع‌های کتابخانه‌ها دیگر کمکی نمی‌کنند و باید دقیقاً بدانید پشت صحنه چه می‌گذرد.

در این درس موتور مشتق‌گیری خودکار (Autograd) را از پایه‌ای‌ترین سطح، یعنی ساخت گره‌های محاسباتی تا مرتب‌سازی توپولوژیک و اِعمال قاعده‌ی زنجیره‌ای، با پایتون خالص می‌سازید. با اجرای این محاسبات روی مسائلی چون XOR و تفکیک داده‌های دایره‌ای، نه تنها منطق ریاضی پس‌انتشار را پیاده می‌کنید، بلکه ریشه‌ی خطاهایی مانند محو گرادیان را نیز به‌صورت تجربی لمس خواهید کرد.

کدها و پیاده‌سازی این درس کاملاً متکی به ساختار داده‌های ساده و محاسبات روی CPU شخصی هستند و هیچ وابستگی به سرویس‌های ابری خارجی یا پردازنده‌های گرافیکی سنگین ندارند؛ بنابراین می‌توانید تمام فرایند آموزش و آزمایش را مستقیماً روی محیط محلی خود اجرا کنید.

پس‌انتشار (Backpropagation) همان الگوریتمی است که یادگیری را ممکن می‌کند. بدون آن، شبکه‌های عصبی چیزی جز تولیدکننده‌های گران‌قیمت اعداد تصادفی نیستند.

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

  • پیاده‌سازی یک موتور مشتق‌گیری خودکار (autograd) مبتنی بر گره Value که گراف محاسباتی (Computational Graph) می‌سازد و گرادیان‌ها را با مرتب‌سازی توپولوژیک (Topological Sort) محاسبه می‌کند
  • به دست آوردن گام رو به عقب (backward pass) برای جمع، ضرب و تابع سیگموئید (sigmoid) با استفاده از قاعده‌ی زنجیره‌ای (chain rule)
  • آموزش یک شبکه‌ی چندلایه روی مسائل XOR و تفکیک داده‌های دایره‌ای، فقط با موتور پس‌انتشار دست‌نویس خودتان
  • شناسایی مشکل محو گرادیان (vanishing gradient problem) در شبکه‌های عمیق با تابع سیگموئید و توضیح چرایی افت نمایی گرادیان‌ها

صورت مسئله

شبکه‌ی شما یک لایه‌ی پنهان با 768 ورودی و 3072 خروجی دارد؛ یعنی 2,359,296 وزن. حالا شبکه یک پیش‌بینی اشتباه انجام داده است. کدام وزن‌ها باعث این خطا شده‌اند؟ آزمایش تک‌تک وزن‌ها به معنای 2.3 میلیون بار اجرای گذر رو به جلو (forward pass) است. اما پس‌انتشار همه‌ی این 2.3 میلیون گرادیان را تنها در یک گام رو به عقب محاسبه می‌کند. این فقط یک بهینه‌سازی (optimization) ساده نیست؛ مرز میان «آموزش‌پذیر بودن» و «غیرممکن بودن» است.

روش ساده‌انگارانه این است: یک وزن را بردارید، مقدارش را به اندازه‌ی بسیار ناچیزی تغییر دهید، گذر رو به جلو را دوباره اجرا کنید و ببینید تابع زیان (loss) بیشتر شد یا کمتر. این کار گرادیان همان یک وزن را به شما می‌دهد. حالا این کار را برای تک‌تک وزن‌های شبکه تکرار کنید؛ آن هم ضرب‌در هزاران گام آموزش و میلیون‌ها نمونه‌ی داده. برای آموزش هر مدل مفیدی به زمانی در مقیاس تاریخ زمین‌شناسی نیاز خواهید داشت!

پس‌انتشار این مشکل را حل می‌کند: یک گذر رو به جلو، یک گذر رو به عقب، و محاسبه‌ی تمام گرادیان‌ها. کل ماجرا به استفاده‌ی نظام‌مند از قاعده‌ی زنجیره‌ای در حسابان روی یک گراف محاسباتی برمی‌گردد. همین الگوریتم بود که یادگیری عمیق (deep learning) را به ابزاری کاربردی تبدیل کرد. بدون آن، هنوز درگیر مسائل پیش‌پاافتاده و اسباب‌بازی بودیم.

مفاهیم پایه

قاعده‌ی زنجیره‌ای در شبکه‌های عصبی

با قاعده‌ی زنجیره‌ای در درس 05 از فاز 01 آشنا شدید. مرور کوتاه: اگر y=f(g(x))y = f(g(x))، آنگاه dydx=f′(g(x))⋅g′(x)\frac{dy}{dx} = f'(g(x)) \cdot g'(x). شما مشتق‌ها را در طول زنجیره در هم ضرب می‌کنید.

در یک شبکه‌ی عصبی (neural network)، این «زنجیره» همان دنباله‌ی عملیات از ورودی تا تابع زیان است. هر لایه وزن‌ها را اِعمال می‌کند، بایاس‌ها را می‌افزاید و خروجی را از یک تابع فعال‌ساز می‌گذراند. در پایان، تابع زیان خروجی نهایی را با هدف مقایسه می‌کند. پس‌انتشار این زنجیره را به عقب بازمی‌گردد و سهم هر عمل در خطای نهایی را محاسبه می‌کند.

گراف‌های محاسباتی

هر گذر رو به جلو یک گراف می‌سازد. هر گره یک عمل ریاضی است (ضرب، جمع، سیگموئید) و هر یال در مسیر رفت حامل یک مقدار، و در مسیر برگشت حامل یک گرادیان است.

z1 = w*xz2 = z1 + ba = sigmoid(z2)x*w+bsigmoidتابع زیان (loss)هدف (target)

گذر رو به جلو: مقادیر از چپ به راست حرکت می‌کنند. xx و ww مقدار z1=wxz_1 = w x را می‌سازند. با افزودن bb به z2z_2 می‌رسیم. تابع سیگموئید مقدار فعال‌سازی aa را تولید می‌کند. در نهایت aa با استفاده از تابع زیان با هدفِ yy مقایسه می‌شود.

گذر رو به عقب: گرادیان‌ها از راست به چپ جریان می‌یابند. کار با ∂L/∂a\partial L/\partial a آغاز می‌شود (میزان تغییرات تابع زیان نسبت به فعال‌سازی). این مقدار در ∂a/∂z2\partial a/\partial z_2 (مشتق سیگموئید) ضرب می‌شود تا ∂L/∂z2\partial L/\partial z_2 به دست آید. سپس به دو شاخه‌ی ∂L/∂b\partial L/\partial b (که برابر با ∂L/∂z2\partial L/\partial z_2 است، چون z2=z1+bz_2 = z_1 + b) و ∂L/∂z1\partial L/\partial z_1 تقسیم می‌شود. پس از آن نوبت به ∂L/∂w=∂L/∂z1⋅x\partial L/\partial w = \partial L/\partial z_1 \cdot x و ∂L/∂x=∂L/∂z1⋅w\partial L/\partial x = \partial L/\partial z_1 \cdot w می‌رسد.

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

مقایسه‌ی مسیر رفت و برگشت

گذر رو به عقبگذر رو به جلوورودی xz = Wx + ba = sigmoid(z)Loss = (a - y)^2dL/dL = 1dL/da = 2(a-y)dL/dz = dL/da * a(1-a)dL/dW = dL/dz * xdL/db = dL/dz

گذر رو به جلو تمام مقادیر میانی را ذخیره می‌کند: z، a و ورودی‌های هر لایه. گام رو به عقب برای محاسبه‌ی گرادیان‌ها به این مقادیر ذخیره‌شده نیاز دارد. این همان مصالحه‌ی میان حافظه و محاسبات در قلب پس‌انتشار است: شما حافظه مصرف می‌کنید (با ذخیره‌ی مقادیر فعال‌سازی) تا در مقابل، سرعت بالایی به دست آورید (تنها یک گام محاسباتی به جای میلیون‌ها بار تکرار).

جریان گرادیان در طول شبکه

در یک شبکه‌ی 3 لایه، گرادیان‌ها در تمام لایه‌ها به صورت زنجیره‌ای حرکت می‌کنند:

dL/da3dL/dz3 = dL/da3 *sigmoid'(z3)dL/dz2 = dL/da2 *sigmoid'(z2)dL/dz1 = dL/da1 *sigmoid'(z1)تابع زیان (loss)لایه 3a3 = sigmoid(z3)لایه 2a2 = sigmoid(z2)لایه 1a1 = sigmoid(z1)ورودی

در هر لایه، گرادیان در مشتق سیگموئید ضرب می‌شود. مشتق سیگموئید برابر با a(1−a)a(1 - a) است که بیشینه‌ی مقدار آن تنها 0.25 خواهد بود (زمانی که a=0.5a = 0.5). در لایه‌ی سوم، گرادیان دست‌بالا در مقداری معادل 0.253=0.01560.25^3 = 0.0156 ضرب شده است. در لایه‌ی دهم: 0.2510=0.0000010.25^{10} = 0.000001.

محو گرادیان

این همان مشکل محو گرادیان است. تابع سیگموئید خروجی خود را بین 0 و 1 فشرده می‌کند و مشتق آن همیشه کمتر از 0.25 است. با روی هم چیدن لایه‌های سیگموئید پرتعداد، گرادیان‌ها عملاً به سمت صفر میل می‌کنند و ناپدید می‌شوند. لایه‌های ابتدایی چون گرادیان‌های نزدیک به صفر دریافت می‌کنند، تقریباً هیچ چیزی یاد نمی‌گیرند.

sigmoid(z):     Output range [0, 1]
sigmoid'(z):    Max value 0.25 (at z = 0)

After 5 layers:   gradient * 0.25^5 = 0.001x original
After 10 layers:  gradient * 0.25^10 = 0.000001x original

به همین دلیل است که آموزش شبکه‌های عمیق با تابع سیگموئید تقریباً غیرممکن است. راه‌حل این مشکل — یعنی تابع ReLU و نسخه‌های دیگر آن — موضوع درس 04 است. فعلاً این نکته را به خاطر داشته باشید که الگوریتم پس‌انتشار بی‌نقص کار می‌کند؛ مشکل از لایه‌هایی است که گرادیان باید از آن‌ها بگذرد.

محاسبه‌ی گرادیان‌ها برای یک شبکه‌ی 2 لایه

محاسبات ریاضی ملموس برای شبکه‌ای با ورودی xx، یک لایه‌ی پنهان با فعال‌ساز سیگموئید، لایه‌ی خروجی با فعال‌ساز سیگموئید و تابع زیان میانگین مربعات خطا (MSE):

گذر رو به جلو:

z1=W1x+b1a1=sigmoid(z1)z2=W2a1+b2a2=sigmoid(z2)L=(a2−y)2\begin{aligned} z_1 &= W_1 x + b_1 \\ a_1 &= \text{sigmoid}(z_1) \\ z_2 &= W_2 a_1 + b_2 \\ a_2 &= \text{sigmoid}(z_2) \\ L &= (a_2 - y)^2 \end{aligned}

گام رو به عقب (با اِعمال مرحله‌به‌مرحله‌ی قاعده‌ی زنجیره‌ای):

∂L∂a2=2(a2−y)∂a2∂z2=a2(1−a2)∂L∂z2=∂L∂a2⋅∂a2∂z2=2(a2−y)⋅a2(1−a2)∂L∂W2=∂L∂z2⋅a1∂L∂b2=∂L∂z2∂L∂a1=∂L∂z2⋅W2∂a1∂z1=a1(1−a1)∂L∂z1=∂L∂a1⋅∂a1∂z1∂L∂W1=∂L∂z1⋅x∂L∂b1=∂L∂z1\begin{aligned} \frac{\partial L}{\partial a_2} &= 2(a_2 - y) \\ \frac{\partial a_2}{\partial z_2} &= a_2 (1 - a_2) \\ \frac{\partial L}{\partial z_2} &= \frac{\partial L}{\partial a_2} \cdot \frac{\partial a_2}{\partial z_2} = 2(a_2 - y) \cdot a_2 (1 - a_2) \\[6pt] \frac{\partial L}{\partial W_2} &= \frac{\partial L}{\partial z_2} \cdot a_1 \\ \frac{\partial L}{\partial b_2} &= \frac{\partial L}{\partial z_2} \\[6pt] \frac{\partial L}{\partial a_1} &= \frac{\partial L}{\partial z_2} \cdot W_2 \\ \frac{\partial a_1}{\partial z_1} &= a_1 (1 - a_1) \\ \frac{\partial L}{\partial z_1} &= \frac{\partial L}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \\[6pt] \frac{\partial L}{\partial W_1} &= \frac{\partial L}{\partial z_1} \cdot x \\ \frac{\partial L}{\partial b_1} &= \frac{\partial L}{\partial z_1} \end{aligned}

هر گرادیان حاصل‌ضرب مشتق‌های جزئی (partial derivative) محلی است که از تابع زیان به عقب دنبال شده‌اند. کل ماهیت پس‌انتشار همین است.

محو شدن گرادیان‌هاتابع فعال‌سازی را انتخاب کرده و عمق را تغییر دهید
VANISHING GRADIENTSBackprop multiplies one activation derivative per layer. Sigmoid caps that derivative at 0.25 and tanh stays below one, so the product collapses toward zero in deep nets (note the log axis). ReLU keeps a derivative of one for active units, which is why it made deep training practical.
پس‌انتشار برای هر لایه یک مشتق تابع فعال‌سازی را ضرب می‌کند. تابع Sigmoid سقف این مشتق را روی 0.25 نگه می‌دارد و tanh زیر یک می‌ماند، بنابراین در شبکه‌های عمیق حاصل‌ضرب به سمت صفر میل می‌کند (به محور لگاریتمی دقت کنید). تابع ReLU برای واحدهای فعال مشتق را روی یک حفظ می‌کند، و به همین دلیل آموزش مدل‌های عمیق را عملی ساخت.

پیاده‌سازی از صفر

گام 1: گره Value

هر عدد در محاسبات ما به یک شیء Value تبدیل می‌شود. این شیء مقدار خود، گرادیانش و نحوه‌ی ساخته‌شدنش را نگهداری می‌کند (تا بداند در مسیر برگشت گرادیان‌ها را چگونه به سمت عقب محاسبه کند).

class Value:
    def __init__(self, data, children=(), op=''):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._children = set(children)
        self._op = op

    def __repr__(self):
        return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

در ابتدا گرادیانی وجود ندارد (0.0). تابع بازگشتی هم هنوز تعریف نشده است (یک تابع بی‌اثر). صفت _children مشخص می‌کند کدام اشیای Value این گره را ساخته‌اند تا بعداً بتوانیم گراف را به صورت توپولوژیک مرتب کنیم.

گام 2: عملیات ریاضی به همراه توابع Backward

هر عملیات یک شیء Value جدید می‌سازد و نحوه‌ی جریان گرادیان به سمت عقب را تعریف می‌کند.

def __add__(self, other):
    other = other if isinstance(other, Value) else Value(other)
    out = Value(self.data + other.data, (self, other), '+')

    def _backward():
        self.grad += out.grad
        other.grad += out.grad

    out._backward = _backward
    return out

def __mul__(self, other):
    other = other if isinstance(other, Value) else Value(other)
    out = Value(self.data * other.data, (self, other), '*')

    def _backward():
        self.grad += other.data * out.grad
        other.grad += self.data * out.grad

    out._backward = _backward
    return out

برای عمل جمع: ∂(a+b)/∂a=1\partial(a+b)/\partial a = 1 و ∂(a+b)/∂b=1\partial(a+b)/\partial b = 1. در نتیجه هر دو ورودی مستقیماً گرادیان خروجی را دریافت می‌کنند.

برای عمل ضرب: ∂(ab)/∂a=b\partial(ab)/\partial a = b و ∂(ab)/∂b=a\partial(ab)/\partial b = a. هر ورودی، مقدار ورودی دیگر ضرب‌در گرادیان خروجی را دریافت می‌کند.

استفاده از += حیاتی است؛ چون ممکن است یک Value در چندین عملیات مختلف به کار رفته باشد و گرادیان نهایی آن باید مجموع گرادیان‌های همه‌ی مسیرها باشد.

گام 3: تابع سیگموئید و تابع زیان

import math

def sigmoid(self):
    x = self.data
    x = max(-500, min(500, x))
    s = 1.0 / (1.0 + math.exp(-x))
    out = Value(s, (self,), 'sigmoid')

    def _backward():
        self.grad += (s * (1 - s)) * out.grad

    out._backward = _backward
    return out

مشتق سیگموئید برابر است با: sigmoid(x)⋅(1−sigmoid(x))\text{sigmoid}(x) \cdot (1 - \text{sigmoid}(x)). ما مقدار sigmoid(x)=s\text{sigmoid}(x) = s را در گذر رو به جلو محاسبه کرده‌ایم؛ پس بدون هیچ کار اضافی از همان مقدار دوباره استفاده می‌کنیم.

def mse_loss(predicted, target):
    diff = predicted + Value(-target)
    return diff * diff

تابع زیان MSE برای یک خروجی منفرد: (predicted−target)2(\text{predicted} - \text{target})^2. عمل تفریق را با جمع کردن یک Value قرینه‌شده پیاده‌سازی می‌کنیم.

گام 4: گام رو به عقب

مرتب‌سازی توپولوژیک تضمین می‌کند که گره‌ها به ترتیب درست پردازش شوند؛ یعنی قبل از انتشار گرادیان از یک گره، گرادیان‌های واردشده به آن به‌طور کامل جمع‌آوری شده باشند.

def backward(self):
    topo = []
    visited = set()

    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._children:
                build_topo(child)
            topo.append(v)

    build_topo(self)
    self.grad = 1.0
    for v in reversed(topo):
        v._backward()

محاسبه از تابع زیان آغاز می‌شود (گرادیان = 1.0، چون dL/dL=1dL/dL = 1). سپس در طول گراف مرتب‌شده به سمت عقب حرکت می‌کنیم. متد _backward در هر گره، گرادیان‌ها را به فرزندانش push می‌کند.

گام 5: لایه و شبکه

import random

class Neuron:
    def __init__(self, n_inputs):
        scale = (2.0 / n_inputs) ** 0.5
        self.weights = [Value(random.uniform(-scale, scale)) for _ in range(n_inputs)]
        self.bias = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.weights, x)), self.bias)
        return act.sigmoid()

    def parameters(self):
        return self.weights + [self.bias]


class Layer:
    def __init__(self, n_inputs, n_outputs):
        self.neurons = [Neuron(n_inputs) for _ in range(n_outputs)]

    def __call__(self, x):
        out = [n(x) for n in self.neurons]
        return out[0] if len(out) == 1 else out

    def parameters(self):
        params = []
        for n in self.neurons:
            params.extend(n.parameters())
        return params


class Network:
    def __init__(self, sizes):
        self.layers = []
        for i in range(len(sizes) - 1):
            self.layers.append(Layer(sizes[i], sizes[i + 1]))

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
            if not isinstance(x, list):
                x = [x]
        return x[0] if len(x) == 1 else x

    def parameters(self):
        params = []
        for layer in self.layers:
            params.extend(layer.parameters())
        return params

    def zero_grad(self):
        for p in self.parameters():
            p.grad = 0.0

یک نورون (Neuron) ورودی‌ها را می‌گیرد، مجموع وزن‌دار به‌علاوه‌ی بایاس را محاسبه می‌کند و تابع سیگموئید را روی آن اِعمال می‌کند. وزن‌ها با ضریب 2/ninputs\sqrt{2/n_{\text{inputs}}} مقداردهی اولیه می‌شوند تا از اشباع سیگموئید در شبکه‌های عمیق‌تر جلوگیری شود. کلاس Layer فهرستی از نورون‌هاست و Network فهرستی از لایه‌ها. متد parameters() تمام مقادیر Value آموزش‌پذیر را جمع‌آوری می‌کند تا بتوانیم آن‌ها را به‌روزرسانی کنیم.

گام 6: آموزش روی مسئله‌ی XOR

random.seed(42)
net = Network([2, 4, 1])

xor_data = [
    ([0.0, 0.0], 0.0),
    ([0.0, 1.0], 1.0),
    ([1.0, 0.0], 1.0),
    ([1.0, 1.0], 0.0),
]

learning_rate = 1.0

for epoch in range(1000):
    total_loss = Value(0.0)
    for inputs, target in xor_data:
        x = [Value(i) for i in inputs]
        pred = net(x)
        loss = mse_loss(pred, target)
        total_loss = total_loss + loss

    net.zero_grad()
    total_loss.backward()

    for p in net.parameters():
        p.data -= learning_rate * p.grad

    if epoch % 100 == 0:
        print(f"Epoch {epoch:4d} | Loss: {total_loss.data:.6f}")

print("\nXOR Results:")
for inputs, target in xor_data:
    x = [Value(i) for i in inputs]
    pred = net(x)
    print(f"  {inputs} -> {pred.data:.4f} (expected {target})")

کاهش تابع زیان را تماشا کنید. پیش‌بینی‌ها از مقادیر کاملاً تصادفی به خروجی‌های درست XOR می‌رسند؛ آن هم کاملاً با اتکا به الگوریتم پس‌انتشار که گرادیان‌ها را محاسبه می‌کند و وزن‌ها را در جهت درست تکان می‌دهد.

گام 7: تفکیک داده‌های دایره‌ای

در درس 02، وزن‌ها را برای تفکیک داده‌های دایره‌ای دستی تنظیم کردید. حالا اجازه دهید خود شبکه آن‌ها را یاد بگیرد.

random.seed(7)

def generate_circle_data(n=100):
    data = []
    for _ in range(n):
        x1 = random.uniform(-1.5, 1.5)
        x2 = random.uniform(-1.5, 1.5)
        label = 1.0 if x1 * x1 + x2 * x2 < 1.0 else 0.0
        data.append(([x1, x2], label))
    return data

circle_data = generate_circle_data(80)

circle_net = Network([2, 8, 1])
learning_rate = 0.5

for epoch in range(2000):
    random.shuffle(circle_data)
    total_loss_val = 0.0
    for inputs, target in circle_data:
        x = [Value(i) for i in inputs]
        pred = circle_net(x)
        loss = mse_loss(pred, target)
        circle_net.zero_grad()
        loss.backward()
        for p in circle_net.parameters():
            p.data -= learning_rate * p.grad
        total_loss_val += loss.data

    if epoch % 200 == 0:
        correct = 0
        for inputs, target in circle_data:
            x = [Value(i) for i in inputs]
            pred = circle_net(x)
            predicted_class = 1.0 if pred.data > 0.5 else 0.0
            if predicted_class == target:
                correct += 1
        accuracy = correct / len(circle_data) * 100
        print(f"Epoch {epoch:4d} | Loss: {total_loss_val:.4f} | Accuracy: {accuracy:.1f}%")

در اینجا از SGD برخط (online SGD) استفاده می‌کنیم؛ یعنی وزن‌ها را بعد از هر نمونه داده به‌روزرسانی می‌کنیم نه بعد از جمع‌آوری کل بسته (batch). این کار تقارن را سریع‌تر می‌شکند و مانع از اشباع سیگموئید روی کل چشم‌انداز زیان (loss landscape) می‌شود. بُر زدن داده‌ها در هر دور آموزش (epoch) نیز مانع از حفظ کردن ترتیب داده‌ها توسط شبکه می‌شود.

هیچ تنظیم دستی در کار نیست. شبکه خودش مرز تصمیم‌گیری (decision boundary) دایره‌ای را کشف می‌کند. قدرت واقعی پس‌انتشار همین است: شما معماری، تابع زیان و داده‌ها را مشخص می‌کنید؛ الگوریتم خودش وزن‌ها را پیدا می‌کند.

استفاده از ابزارهای واقعی

کتابخانه‌ی PyTorch تمام مراحل بالا را در چند خط خلاصه می‌کند. ایده‌ی اصلی دقیقاً یکی است: موتور autograd در گذر رو به جلو یک گراف محاسباتی می‌سازد و در مسیر برگشت با پیمایش معکوس آن، گرادیان‌ها را محاسبه می‌کند.

import torch
import torch.nn as nn

model = nn.Sequential(
    nn.Linear(2, 4),
    nn.Sigmoid(),
    nn.Linear(4, 1),
    nn.Sigmoid(),
)
optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
criterion = nn.MSELoss()

X = torch.tensor([[0,0],[0,1],[1,0],[1,1]], dtype=torch.float32)
y = torch.tensor([[0],[1],[1],[0]], dtype=torch.float32)

for epoch in range(1000):
    pred = model(X)
    loss = criterion(pred, y)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

print("PyTorch XOR Results:")
with torch.no_grad():
    for i in range(4):
        pred = model(X[i])
        print(f"  {X[i].tolist()} -> {pred.item():.4f} (expected {y[i].item()})")

متد loss.backward() همان تابع total_loss.backward() دست‌نویس شماست. دستور optimizer.step() معادلِ p.data -= lr * p.grad دستی شماست. همچنین optimizer.zero_grad() کارِ net.zero_grad() را انجام می‌دهد. همان الگوریتم است، اما با پیاده‌سازی صنعتی. کتابخانه‌ی PyTorch شتاب‌دهی GPU، دقت ترکیبی (mixed precision)، چک‌پوینت‌گذاری گرادیان (gradient checkpointing) و صدها نوع لایه‌ی گوناگون را مدیریت می‌کند. اما گام رو به عقب، همان قاعده‌ی زنجیره‌ای روی همان گراف محاسباتی است.

فرایند آموزش ابتدا گذر رو به جلو، سپس گام رو به عقب و بعد به‌روزرسانی وزن‌ها را اجرا می‌کند. اما فرایند استنتاج (inference) فقط گذر رو به جلو را اجرا می‌کند؛ بدون گرادیان و بدون به‌روزرسانی. درک این تفاوت مهم است؛ زیرا در محیط عملیاتی همیشه با استنتاج سروکار داریم. وقتی یک API مثل Claude یا GPT را فراخوانی می‌کنید، در حال اجرای استنتاج هستید: پرامپت شما در جهت مستقیم از شبکه عبور می‌کند و توکن‌ها از آن سو خارج می‌شوند. هیچ وزنی تغییر نمی‌کند. دلیل اهمیت فهم پس‌انتشار این است که تک‌تک وزن‌های همان شبکه را همین الگوریتم شکل داده است.

خروجی این درس

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

  • فایل outputs/prompt-gradient-debugger.md — یک پرامپت چندبارمصرف برای عیب‌یابی مشکلات گرادیان (محو شدن، انفجار یا تولید مقادیر NaN) در هر شبکه‌ی عصبی

تمرین‌ها

  1. متد __sub__ را به کلاس Value اضافه کنید (a−b=a+(−1⋅b))(a - b = a + (-1 \cdot b)). سپس متد __neg__ را پیاده‌سازی کنید. با مقایسه‌ی نتایج با محاسبات دستی برای یک عبارت ساده مانند (a−b)2(a - b)^2، بررسی کنید که گرادیان‌ها درست باشند.

  2. متد relu را به کلاس Value اضافه کنید (خروجی max⁡(0,x)\max(0, x)، با مشتق 1 در صورتی که x>0x \gt 0 و در غیر این صورت 0). در لایه‌های پنهان، سیگموئید را با relu جایگزین کنید و دوباره روی XOR آموزش دهید. سرعت همگرایی را مقایسه کنید. باید افزایش سرعت آموزش را ببینید؛ این تمرین پیش‌نمایشی از درس 04 است.

  3. متد __pow__ را برای توان‌های صحیح در کلاس Value پیاده‌سازی کنید. با استفاده از آن، mse_loss را با یک عبارت استاندارد مثل (predicted - target) ** 2 جایگزین کنید. بررسی کنید که گرادیان‌ها با پیاده‌سازی قبلی همخوانی داشته باشند.

  4. برش گرادیان (gradient clipping) را به حلقه‌ی آموزش اضافه کنید: بعد از فراخوانی backward()، تمام گرادیان‌ها را به بازه‌ی [-1, 1] محدود کنید. یک شبکه‌ی عمیق‌تر (4 لایه یا بیشتر با فعال‌ساز سیگموئید) را آموزش دهید و منحنی‌های تابع زیان را در دو حالتِ با و بدون برش گرادیان مقایسه کنید. این اولین خط دفاعی شما در برابر انفجار گرادیان است.

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

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

اصطلاح باور عامیانه معنای دقیق فنی
پس‌انتشار (Backpropagation) «شبکه یاد می‌گیرد» الگوریتمی که ∂L/∂w\partial L/\partial w را برای تمام وزن‌ها با اِعمال رو به عقبِ قاعده‌ی زنجیره‌ای در گراف محاسباتی محاسبه می‌کند
گراف محاسباتی (Computational graph) «ساختار شبکه» یک گراف جهت‌دار بدون دور که در آن گره‌ها نمایانگر عملیات ریاضی هستند و یال‌ها حامل مقادیر (در مسیر رفت) و گرادیان‌ها (در مسیر برگشت) هستند
قاعده‌ی زنجیره‌ای (Chain rule) «ضرب کردن مشتق‌ها» اگر y=f(g(x))y = f(g(x))، آنگاه dydx=f′(g(x))⋅g′(x)\frac{dy}{dx} = f'(g(x)) \cdot g'(x) — پایه‌ی ریاضی پس‌انتشار
گرادیان (Gradient) «جهت تندترین شیب صعودی» مشتق جزئیِ تابع زیان نسبت به یک پارامتر؛ به شما نشان می‌دهد برای کاهش زیان، آن پارامتر را چگونه تغییر دهید
محو گرادیان (Vanishing gradient) «شبکه‌های عمیق یاد نمی‌گیرند» افت نمایی گرادیان‌ها هنگام انتشار در لایه‌هایی با توابع فعال‌ساز اشباع‌شونده مانند سیگموئید
گذر رو به جلو (Forward pass) «اجرای شبکه» محاسبه‌ی خروجی از روی ورودی‌ها با اِعمال پیاپیِ عملیات هر لایه و ذخیره‌سازی مقادیر میانی
گام رو به عقب (Backward pass) «محاسبه‌ی گرادیان‌ها» پیمایش معکوس گراف محاسباتی و تجمیع گرادیان‌ها در هر گره با بهره‌گیری از قاعده‌ی زنجیره‌ای
نرخ یادگیری (Learning rate) «سرعت یادگیری شبکه» یک ضریب عددی برای کنترل اندازه‌ی گام هنگام به‌روزرسانی وزن‌ها: wnew=wold−lr⋅gradientw_{\text{new}} = w_{\text{old}} - \text{lr} \cdot \text{gradient}
مرتب‌سازی توپولوژیک (Topological sort) «ترتیب درست» چینشی از گره‌های گراف که در آن هر گره پس از همه‌ی وابستگی‌هایش قرار می‌گیرد؛ این کار تضمین می‌کند گرادیان‌ها پیش از انتشار بیشتر، کاملاً جمع‌آوری شده باشند
مشتق‌گیری خودکار (Autograd) «مشتق‌گیری خودکار محاسباتی» سیستمی که طی گذر رو به جلو گراف محاسباتی می‌سازد و گرادیان‌ها را به‌طور خودکار محاسبه می‌کند؛ دقیقاً همان کاری که موتور PyTorch انجام می‌دهد

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

  • مقاله‌ی Rumelhart, Hinton & Williams با عنوان «Learning representations by back-propagating errors» (سال 1986) — مقاله‌ای که پس‌انتشار را فراگیر کرد و مسیر آموزش شبکه‌های چندلایه را گشود
  • مجموعه‌ی ویدیویی «Neural Networks» از کانال 3Blue1Brown (به آدرس https://www.youtube.com/playlist?list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi) — بهترین توضیح تصویری درباره‌ی پس‌انتشار و جریان گرادیان در شبکه‌های عصبی

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

الگوریتم پس‌انتشار (Backpropagation) دقیقاً چه کاری انجام می‌دهد؟

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

چرا شبکه‌های عمیق با تابع سیگموئید دچار محو گرادیان می‌شوند؟

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

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

مرتب‌سازی توپولوژیک تضمین می‌کند که پیش از بازگشت گرادیان از یک گره، گرادیان‌های تمام شاخه‌ها و مسیرهای وابسته‌ی آن جمع‌آوری و محاسبه شده باشند.

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

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