پرش به محتوا
5/30فصل 5 از 30

Backpropagation از صفر: اول موتور، بعد شبکه

یک موتور autodiff ۱۲۰خطی با Python خالص بسازید، با PyTorch تا ۱۶ رقم اعشار بسنجید و با حذف zero_grad بفهمید چه می‌کند.

در این صفحه

چهار فصل جلو آمده‌ایم و حالا وسط دوره یک حفره وجود دارد.

فصل ۳ به ما gradient descent داد: برای بهتر کردن یک پارامتر، شیب زیان را نسبت به آن پیدا کن و رو به پایین حرکت کن. فصل ۴ زیانی داد که ارزش پایین رفتن داشته باشد. اما در هر دو، مشتق دستی محاسبه شد — یک مدل، یک پارامتر، یک خط حساب دیفرانسیل، و همه‌اش در یک صفحه جا می‌شد.

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

راه خروج، نمادگذاری بهتر نیست. فهم این نکته است که مشتق یک ترکیب را می‌توان به‌صورت مکانیکی، با یک برنامه، از ساختار خودِ محاسبه به دست آورد — و اگر آن را در جهت درست انجام دهید، همه آن بیست هزار مشتق را تقریباً با هزینه یک بار محاسبه زیان می‌گیرید.

این سازوکار reverse-mode automatic differentiation است. وقتی روی یک شبکه عصبی اعمال شود به آن backpropagation می‌گویند، و تا پایان این فصل یکی از آن را در حدود ۱۲۰ خط Python، بدون هیچ کتابخانه‌ای، نوشته‌اید؛ با PyTorch سنجیده‌اید؛ و از آن برای حل مسئله XOR استفاده کرده‌اید، همان مسئله‌ای که perceptron را در فصل ۱ زمین زد.

اول: چرا اصلاً باید غیرخطی‌بودنی در کار باشد

لینک به بخش: اول: چرا اصلاً باید غیرخطی‌بودنی در کار باشد

پیش از ساختن ماشین، باید تکلیف یک سؤال روشن شود، چون اگر پاسخ خلاف این بود چیزی برای ساختن وجود نداشت.

perceptron روی XOR شکست خورد چون یک خط نمی‌تواند چهار نقطه را جدا کند. راه‌حل بدیهی این است که لایه‌ها را stack کنیم: ورودی را از یک لایه خطی عبور دهیم، بعد از یکی دیگر. آیا کمک می‌کند؟

نه، و اثباتش دو خط است. یک لایه خطی h=W1x+b1\mathbf{h} = W_1\mathbf{x} + \mathbf{b}_1 است. آن را به یکی دیگر بدهید، y=W2h+b2\mathbf{y} = W_2\mathbf{h} + \mathbf{b}_2، و جایگذاری کنید:

y=W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)\mathbf{y} = W_2(W_1\mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = (W_2W_1)\mathbf{x} + (W_2\mathbf{b}_1 + \mathbf{b}_2)

ترکیب، Wx+bW\mathbf{x} + \mathbf{b} است با W=W2W1W = W_2W_1 و b=W2b1+b2\mathbf{b} = W_2\mathbf{b}_1 + \mathbf{b}_2. یک stack از لایه‌های خطی، یک لایه خطی واحد است. ده‌تا، هزار‌تا: هنوز یک خط، هنوز ناتوان از انجام XOR.

ارزشش را دارد که این اتفاق را ببینید، نه اینکه فقط باورش کنید:

linear_is_linear.pyPYTHON
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)

two_layers = W2 @ (W1 @ x + b1) + b2
one_layer  = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))
TEXT
-4.612963371048  -4.612963371048  0.00e+00

تقریباً برابر نیستند. بیت‌به‌بیت یکسان‌اند، چون همان حساب است که بازچینی شده.

پس عمق به‌تنهایی چیزی نمی‌خرد. آنچه چیزی می‌خرد، گذاشتن یک تابع غیرخطی بین لایه‌هاست — و کل دلیل وجود activation function همین است. آن‌ها تزئین زیستی یا ترفند normalisation نیستند. بدون یکی از آن‌ها، لایه دوم فقط دکور است.

قاعده زنجیره‌ای، روی کاغذ، با یک گره مشترک

لینک به بخش: قاعده زنجیره‌ای، روی کاغذ، با یک گره مشترک

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

قاعده زنجیره‌ای تک‌متغیره می‌گوید اگر LL به cc وابسته باشد و cc به xx، آن‌وقت dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. مشتق‌ها در امتداد یک زنجیره در هم ضرب می‌شوند.

بخشی که اینجا مهم است، چیزی است که وقتی یک متغیر به بیش از یک مسیر پایین‌دست خوراک می‌دهد رخ می‌دهد. اگر xx از طریق aa و همچنین از طریق bb روی LL اثر بگذارد، سهم‌ها جمع می‌شوند:

dLdx=Laax+Lbbx\frac{dL}{dx} = \frac{\partial L}{\partial a}\frac{\partial a}{\partial x} + \frac{\partial L}{\partial b}\frac{\partial b}{\partial x}

در امتداد یک مسیر ضرب کنید، در میان مسیرها جمع کنید. این کل backpropagation است، و هر جزئیات پیاده‌سازی در ادامه این فصل — از جمله += در کد و فراخوانی zero_grad() که هر کسی را در اولین training loopش گیر می‌اندازد — پیامد مستقیم همان کلمه دوم است.

یک مدار مشخص با پنج عملیات بگیرید، با x=0.5x = 0.5 و y=1.4y = 1.4:

a=xy,b=x+y,c=ab,d=tanh(c),L=d+xa = xy, \quad b = x + y, \quad c = ab, \quad d = \tanh(c), \quad L = d + x

دقت کنید xx سه بار ظاهر می‌شود: در aa، در bb، و مستقیم در LL. گذر backward را روی کاغذ، از راست به چپ، با شروع از dLdL=1\frac{dL}{dL} = 1 انجام دهید:

L=d+xL = d + x، پس Ld=1\frac{\partial L}{\partial d} = 1 و مسیر مستقیم Lx=1\frac{\partial L}{\partial x} = 1 سهم می‌دهد. جمع گرادیان ورودی را بدون تغییر به هر دو ورودی پخش می‌کند.

d=tanh(c)d = \tanh(c) با c=ab=0.7×1.9=1.33c = ab = 0.7 \times 1.9 = 1.33، پس dLdc=1tanh2(1.33)=0.2444\frac{dL}{dc} = 1 - \tanh^2(1.33) = 0.2444.

c=abc = ab، پس dLda=dLdcb=0.2444×1.9=0.4644\frac{dL}{da} = \frac{dL}{dc} \cdot b = 0.2444 \times 1.9 = 0.4644 و dLdb=dLdca=0.2444×0.7=0.1711\frac{dL}{db} = \frac{dL}{dc} \cdot a = 0.2444 \times 0.7 = 0.1711. ضرب جابه‌جا می‌کند: گرادیان هر ورودی با مقدار ورودی دیگر مقیاس می‌شود.

از طریق aa: dLday=0.4644×1.4=0.6501\frac{dL}{da} \cdot y = 0.4644 \times 1.4 = 0.6501. از طریق bb: dLdb1=0.1711\frac{dL}{db} \cdot 1 = 0.1711. مستقیم: 11.

dLdx=0.6501+0.1711+1.0000=1.8212\frac{dL}{dx} = 0.6501 + 0.1711 + 1.0000 = 1.8212

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

بینشی که آن را برنامه‌پذیر می‌کند: هرکدام از آن گام‌ها محلی بود. برای عبور دادن گرادیان از گره ضرب، به گرادیان ورودی و دو مقدار ورودیِ ذخیره‌شده نیاز داشتید — هیچ چیزی درباره بقیه مدار. هر عملیات می‌داند چطور خودش را مشتق‌گیری کند.

پس عددی بسازید که به یاد داشته باشد چه چیزی آن را تولید کرده است.

value.pyPYTHON
class Value:
    """A number that remembers where it came from."""

    def __init__(self, data, _children=(), _op=""):
        self.data = data
        self.grad = 0.0
        self._backward = lambda: None
        self._prev = set(_children)      
        self._op = _op

چهار فیلد. data مقدار است. grad مقدار Lself\frac{\partial L}{\partial \text{self}} را انباشته می‌کند. _prev مجموعه Valueهایی است که این یکی از آن‌ها محاسبه شده — یال‌های گراف. و _backward یک closure است که هر عملیات نصب می‌کند: می‌داند چطور گرادیان این گره را یک گام به عقب، به ورودی‌هایش، هل بدهد.

هر operator همان شکل را دنبال می‌کند: خروجی را محاسبه کن، والدها را ثبت کن، قاعده محلی را نصب کن.

value.py (continued)PYTHON
    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

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

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

        out._backward = _backward
        return out

    def relu(self):
        out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")

        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad

        out._backward = _backward
        return out

چهار بدنه _backward را مثل یک جدول بخوانید و الگوهای جریان از مشتق‌گیری کاغذی همان‌جا جلوی چشم‌اند:

operationبا گرادیان چه می‌کند
+پخش می‌کند — همان گرادیان به هر ورودی
*جابه‌جا می‌کند — هر ورودی با مقدار دیگری مقیاس می‌شود
reluمسیر می‌دهد — آن را عبور می‌دهد یا کاملاً مسدود می‌کند
tanhتضعیف می‌کند — با 1t21 - t^2 مقیاس می‌کند، که حداکثر ۱ است و معمولاً کمتر

تک‌تک آن‌ها از += استفاده می‌کنند و هرگز از = نه. این همان قاعده «جمع میان مسیرها» است که کد شده. گره‌ای که به دو مصرف‌کننده خوراک می‌دهد دو بار فراخوانی می‌شود، و دو سهم خودبه‌خود جمع می‌شوند.

بعد driver، که تنها بخشی است که دانش سراسری دارد:

value.py (continued)PYTHON
    def backward(self):
        order, seen = [], set()

        def build(v):
            if v in seen:
                return
            seen.add(v)
            for child in v._prev:
                build(child)
            order.append(v)

        build(self)
        self.grad = 1.0
        for v in reversed(order):       
            v._backward()               

build یک ترتیب توپولوژیک از گراف تولید می‌کند: هر گره بعد از همه ورودی‌هایش می‌آید. پیمایش آن فهرست در جهت معکوس تضمین می‌کند وقتی _backward یک گره را صدا می‌زنید، گرادیان خودش از قبل کامل است — هر مصرف‌کننده پایین‌دست آن از قبل سهمش را داده. اگر ترتیب را اشتباه بگیرید، گرادیانی نیمه‌کاره را به عقب هل می‌دهید، و جواب غلطی می‌گیرید بدون هیچ پیام خطایی.

check.pyPYTHON
x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)
TEXT
forward:  a=0.7000  b=1.9000  c=1.3300  d=0.8692  L=1.3692
backward: dL/dd=1.0000  dL/dc=0.2444  dL/da=0.4644  dL/db=0.1711
          dL/dx=1.8212   dL/dy=0.4033

1.8212. همان عدد، از برنامه‌ای که فقط قاعده +، قاعده *، قاعده tanh را به آن گفته بودند، و هیچ چیزی درباره این مدار نه.

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

مشتق‌گیری عددی. ورودی را کمی تکان بدهید و اندازه بگیرید. تفاضل مرکزی L(x+h)L(xh)2h\frac{L(x+h) - L(x-h)}{2h} مشتق را بدون هیچ حساب دیفرانسیلی تخمین می‌زند:

TEXT
dL/dx:  analytic=1.821202805  numeric=1.821202805  |diff|=1.80e-10
dL/dy:  analytic=0.403269235  numeric=0.403269235  |diff|=7.64e-12

در برابر PyTorch، که یک موتور autodiff صنعتی دارد و آدم‌هایی نوشته‌اند که کارشان همین است:

TEXT
torch dL/dx=1.821202805316   ours=1.821202805316   |diff|=2.22e-16
torch dL/dy=0.403269234753   ours=0.403269234753   |diff|=1.11e-16

توافق در 2×10162 \times 10^{-16}، که machine epsilon برای float ۶۴بیتی است: دو موتور دقیقاً همان حساب را انجام می‌دهند. بررسی عددی را در جیب‌تان نگه دارید — ابزار debug کردن گذر backward یک لایه جدید است، و دلیل اینکه یک گرادیان غلط اصلاً قابل پیدا کردن است.

همان مدار، ورودی‌های متفاوت. x=2x = 2 و y=3y = -3 را بگذارید، که c=6c = 6 می‌سازد:

TEXT
x=0.5, y=1.4:  dL/dc = 0.244400     three paths into x:  0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025     three paths into x:  0.0001 + -0.0001 + 1.0000 = 0.9999

گرادیانی که از گره tanh\tanh عبور می‌کند با ضریب ۹٬۹۴۵ افت کرد. هر چیزی بالادست آن — در یک شبکه واقعی، هر لایه پیش از آن — عملاً هیچ چیز دریافت نمی‌کند. دو مسیر از مدار خاموش شده‌اند؛ فقط اتصال مستقیمی که از tanh\tanh رد نمی‌شود هنوز سیگنال حمل می‌کند.

این مسئله vanishing gradient است، در یک گره. چهل لایه tanh\tanh را stack کنید و چهل چنین ضریبی را در هم ضرب کنید، و لایه‌های اولیه کاملاً از یادگیری می‌ایستند. همچنین، اتفاقاً، استدلالی برای skip connection است که اینجا در اندازه کوچک می‌بینید: مسیری که غیرخطی‌بودن را دور زد تنها مسیری بود که زنده ماند.

zero_grad واقعاً چه می‌کند، و چرا باگ پنهان می‌ماند

لینک به بخش: zero_grad واقعاً چه می‌کند، و چرا باگ پنهان می‌ماند

هر _backward از += استفاده می‌کند. درست است — مسیرها همین‌طور جمع می‌شوند. اما پیامدی دارد که همه را گیر می‌اندازد: گرادیان‌ها میان فراخوانی‌های backward() هم انباشته می‌شوند. موتور هیچ تصوری ندارد که فراخوانی دوم شما یک گام آموزشی تازه است، نه مسیری دیگر در همان گراف.

پس یک training loop باید آن‌ها را پاک کند:

train.pyPYTHON
for step in range(steps):
    ys = [model(x) for x, _ in DATA]
    loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))

    for p in model.parameters():   
        p.grad = 0.0

    loss.backward()
    for p in model.parameters():
        p.data -= lr * p.grad

این همان optimizer.zero_grad() در PyTorch است، و توصیه معمول این است که فراموش کردنش آموزش را خراب می‌کند. پس بیایید آن دو خط را حذف کنیم و ببینیم چقدر خراب می‌شود. همان seedها، همه‌چیز همان، ۲۰۰ گام XOR:

learning rateseedبا resetبدون reset
0.051337loss 3.255088, 3/4loss 0.000000, 4/4
0.057loss 2.144820, 2/4loss 0.000000, 4/4
0.0542loss 2.126074, 2/4loss 0.000000, 4/4
0.11337loss 0.038597, 4/4loss 0.000000, 4/4
0.17loss 2.055048, 2/4loss 0.000000, 4/4
0.142loss 2.049876, 2/4loss 0.000073, 4/4
0.31337loss 4.512310, 2/4loss 8.000000, 2/4
0.37loss 0.015247, 4/4loss 4.000000, 3/4
0.342loss 0.005478, 4/4loss 4.000000, 3/4

در learning rateهای کوچک، نسخه باگ‌دار در تک‌تک سطرها برنده می‌شود. وقتی نسخه درست گیر می‌کند، این یکی همگرا می‌شود.

این تصادفی نیست و ارزش فهمیدن دارد، چون توضیح می‌دهد چرا گرفتن این باگ این‌قدر سخت است. اگر هرگز گرادیان را پاک نکنید، آن‌وقت در گام kk پارامتر با جمع همه گرادیان‌هایی که تا آن لحظه محاسبه شده‌اند به‌روزرسانی می‌شود. روی زیانی که تقریباً مدام به یک جهت اشاره می‌کند، آن جمع پیوسته رشد می‌کند، و اثرش learning rateای است که خودبه‌خود زیاد می‌شود. در η=0.05\eta = 0.05، جایی که الگوریتم درست دارد می‌خزد، اندازه گامِ فراری دقیقاً شبیه یک اصلاح به نظر می‌رسد.

بعد سه سطر پایین را ببینید. در η=0.3\eta = 0.3 همان سازوکار مدل را از هم می‌پاشاند — loss 8.0 امتیازی است که مدلی collapsed به یک ثابت ±1\pm 1 می‌گیرد — نصفِ ۱۶ که چهار پاسخ حداکثر غلط هزینه می‌دادند — — در حالی که نسخه درست حالا تمیز همگرا می‌شود.

پس بیان صادقانه این نیست که «همیشه zero_grad را صدا بزنید وگرنه مدل‌تان train نمی‌شود». این است: بدون آن دیگر gradient descent اجرا نمی‌کنید. دارید چیزی را اجرا می‌کنید که اندازه گامش با نرخی که هیچ‌کس انتخاب نکرده رو به بالا drift می‌کند، و گاهی به نظر می‌رسد کار می‌کند، حتی بهتر از چیز واقعی، درست تا وقتی که دیگر کار نکند — و آن موقع شما learning rate، مقداردهی اولیه، یا داده را مقصر می‌دانید. شکل بدترین باگ‌ها در machine learning همین است: crash نمی‌کنند، الگوریتم را به الگوریتمی دیگر تبدیل می‌کنند که گاهی امتیاز بهتری می‌گیرد.

وقتی موتور تمام شد، شبکه عصبی تقریباً هیچ کدی نیست. یک نورون یک dot product، یک bias و یک activation است؛ یک لایه فهرستی از نورون‌هاست؛ یک شبکه فهرستی از لایه‌هاست.

nn.pyPYTHON
class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0.0)

    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()

    def parameters(self):
        return self.w + [self.b]


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

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

    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]


class MLP:
    def __init__(self, nin, nouts):
        sizes = [nin] + nouts
        self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]

    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x

    def parameters(self):
        return [p for layer in self.layers for p in layer.parameters()]

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

حالا مسئله فصل ۱. دو ورودی، دو واحد پنهان، یک خروجی، نه پارامتر:

TEXT
step   1: loss 4.156690
step  10: loss 4.005572
step  50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597

[0, 0] -> -0.9081  (target -1)  ok
[0, 1] -> +0.8934  (target +1)  ok
[1, 0] -> +0.8906  (target +1)  ok
[1, 1] -> -0.9207  (target -1)  ok

چهار از چهار. تابعی که هیچ perceptronای نمی‌تواند محاسبه کند — در فصل ۱ با چهار نامساوی ثابت شد که از bb می‌خواست هم مثبت باشد و هم منفی — با نه عددی محاسبه می‌شود که خودکار پیدا شده‌اند.

بخش رضایت‌بخش این نیست که کار می‌کند. این است که می‌توانید ببینید چطور، چون با دو واحد پنهان، نمایش میانی یک نقطه در صفحه است و می‌توانید فقط printش کنید.

پس از آموزش تا loss برابر 0.001241، هر ورودی بعد از لایه پنهان اینجا فرود می‌آید، و نورون خروجی با آن چنین می‌کند:

inputخروجی لایه پنهانامتیاز خروجیlabel
(0,0)(0, 0)(+0.8206, 0.8474)(+0.8206,\ -0.8474)2.4045-2.40451-1
(0,1)(0, 1)(+0.9985, +0.8564)(+0.9985,\ +0.8564)+2.3049+2.3049+1+1
(1,0)(1, 0)(0.8401, 0.9990)(-0.8401,\ -0.9990)+2.3006+2.3006+1+1
(1,1)(1, 1)(+0.8368, 0.8550)(+0.8368,\ -0.8550)2.4786-2.47861-1

به سطر اول و چهارم نگاه کنید. ورودی‌های (0,0)(0,0) و (1,1)(1,1) گوشه‌های قطریِ مخالفِ مربع‌اند — تا جایی که دو نقطه در این مسئله می‌توانند از هم دور باشند — و لایه پنهان آن‌ها را به (0.82,0.85)(0.82, -0.85) و (0.84,0.86)(0.84, -0.86) نگاشت می‌کند. تقریباً همان نقطه. لایه صفحه را تا کرده تا دو گوشه ردشده روی هم بیفتند، و وقتی در یک مکان قرار گرفتند، یک خط آن‌ها را از دو تای دیگر جدا می‌کند.

و نورون خروجی دقیقاً همان خط است. پارامترهای یادگرفته‌شده‌اش w=(3.1153, +3.0893)\mathbf{w} = (-3.1153,\ +3.0893)، b=+2.7697b = +2.7697 هستند، پس مرز تصمیمش این است:

3.1153h1+3.0893h2+2.7697=0-3.1153\,h_1 + 3.0893\,h_2 + 2.7697 = 0

که یک خط راست است — یک perceptron، همان شیء فصل ۱، بدون تغییر. آن زمان نمی‌توانست XOR را حل کند و حالا هم نمی‌تواند. چیزی که تغییر کرده این است که دیگر به ورودی نگاه نمی‌کند؛ به فضایی نگاه می‌کند که لایه اول برایش ساخته، فضایی که در آن مسئله linearly separable است.

این همان learned representation است، و ارزش دارد دقیق باشیم چون این عبارت در ادامه این دوره، و در کل این حوزه، شل و گسترده استفاده می‌شود. فشرده‌سازی، خلاصه، یا embedding به معنایی رازآلود نیست. تغییر مختصات است، یادگرفته‌شده به‌جای طراحی‌شده، که تنها کارش آسان کردن کار لایه بعدی است.

قضیه تقریب جهان‌شمول، و آنچه نمی‌گوید

لینک به بخش: قضیه تقریب جهان‌شمول، و آنچه نمی‌گوید

اینجا قضیه‌ای وجود دارد، و معمولاً بد نقل می‌شود.

Cybenko در ۱۹۸۹ و Hornik در ۱۹۹۱ ثابت کردند که یک شبکه feedforward با یک لایه پنهان و activation function مناسب می‌تواند هر تابع پیوسته روی یک مجموعه فشرده را با هر دقتی که بخواهید تقریب بزند، به شرط داشتن واحدهای پنهان کافی.34 این نتیجه‌ای واقعی و مهم است: می‌گوید معماری محدودیت نیست.

حالا ببینید چه چیزهایی را حذف می‌کند. نمی‌گوید چند واحد — کران می‌تواند نجومی بزرگ باشد. نمی‌گوید وزن‌ها می‌توانند پیدا شوند؛ وجود را ادعا می‌کند، و gradient descent از شروع تصادفی oracle نیست. و درباره رفتار روی داده‌ای که ندیده‌اید هیچ نمی‌گوید، که نیمه دوم فصل ۶ است.

شکاف میان «وجود دارد» و «قابل پیدا کردن است» دانشگاهی نیست. این همان مسئله XOR است، هرکدام با ۵۰ مقداردهی اولیه تصادفی، ۱۰۰۰ گام، فقط اندازه لایه پنهان تغییر کرده:

واحدهای پنهانمقداردهی‌های اولیه‌ای که به 4/4 رسیدند
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

با حداقل معماری قابل‌قبول، یک اجرا از هر چهار اجرا هرگز به مقصد نمی‌رسد — در پیکربندی‌ای می‌نشیند که نمی‌تواند از آن پایین بیاید، دقیقاً همان کمینه محلی که فصل ۳ روی یک سطح یک‌بعدی نشان داد. یک واحد اضافه کنید و شکست‌ها تقریباً ناپدید می‌شوند، نه چون شبکه expressiveتر شده (دو واحد از قبل کافی‌اند — ۳۸ اجرا ثابت می‌کنند) بلکه چون ابعاد اضافه، جهت‌های بیشتری برای فرار به descent می‌دهند.

و بعد هشت واحد کمی بدتر از چهار می‌شود. با learning rate و بودجه گام ثابت، ظرفیت بیشتر به‌صورت یکنواخت بهتر نیست. هر کسی که به شما بگوید راه‌حل شبکه گیرکرده همیشه شبکه بزرگ‌تر است، دارد از وسط آن جدول extrapolate می‌کند.

این همان درس قضیه همگرایی در فصل ۱ است، و در فصل ۱۰ درباره scaling lawها نیز همین درس خواهد بود، به شکلی که آن فصل می‌دهد: پیش‌بینی loss، پیش‌بینی قابلیتی نیست که بابتش پول می‌دهید، و فاصله بین این دو همان جایی است که مهندسی زندگی می‌کند.

نمایش جزئیات

اختیاری: فرم ماتریسی، و اینکه چرا کد بالا از آن استفاده نمی‌کند.

همه‌چیز اینجا تک‌به‌تک و scalar نوشته شده، که روشن‌ترین راه برای دیدن سازوکار و کندترین راه برای اجرای آن است. در عمل یک لایه matrix multiply است، و گذر backward برای y=Wx\mathbf{y} = W\mathbf{x} چنین است:

LW=Lyx,Lx=WLy\frac{\partial L}{\partial W} = \frac{\partial L}{\partial \mathbf{y}}\mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{x}} = W^\top\frac{\partial L}{\partial \mathbf{y}}

transposeها ترفندی برای حفظ کردن نیستند؛ شکل قانون sum-over-paths هستند وقتی مسیرها با درایه‌های ماتریس index می‌شوند. شیء کلی Jacobian است، ماتریس همه مشتق‌های جزئی همه خروجی‌ها نسبت به همه ورودی‌ها، و reverse mode دقیقاً محاسبه یک vector-Jacobian product است بدون اینکه هرگز خود Jacobian را بسازد — و این مهم است، چون برای لایه‌ای با 4096 ورودی و 4096 خروجی، آن ماتریس شانزده میلیون درایه دارد و ساختنش هرگز ارزش ندارد.

برای دنبال کردن فصل‌های بعدی به هیچ‌کدام از این‌ها نیاز ندارید؛ نسخه scalar همه کارهای نسخه ماتریسی را انجام می‌دهد، فقط کندتر. در فصل ۹ لازم می‌شود، جایی که شکل‌ها دیگر بدیهی نیستند.

حالا شبکه‌ای دارید که train می‌شود. این دستاورد کوچک‌تر از چیزی است که حس می‌شود، چون شبکه‌ای که دارید روی چهار مثال train می‌شود و روی همان چهار مثال سنجیده می‌شود.

همان کد را روی یک dataset واقعی اجرا کنید و مجموعه تازه‌ای از مسئله‌ها ظاهر می‌شود، هیچ‌کدام درباره گرادیان نیست. loss مدتی پایین می‌رود و بعد می‌ایستد. یا روی داده آموزش پایین می‌رود و روی همه‌چیز دیگر بالا می‌رود. یا از همان گام اول اصلاً تکان نمی‌خورد، و علتش در نهایت دامنه وزن‌های تصادفی اولیه از آب درمی‌آید. یا ورودی یک واحد در epoch سوم روی همه مثال‌ها منفی drift کرده و از آن زمان مرده مانده، بی‌صدا، و تکه‌ای از ظرفیت مدل را هم با خودش برده.

این‌ها شکست‌های عجیب‌وغریب نیستند؛ وضعیت عادی شبکه‌ای هستند که تازه نوشته شده، و هیچ‌کدام خودشان را اعلام نمی‌کنند. گرادیان درست است — آن را با PyTorch تا شانزده رقم اعشار سنجیده‌اید — و مدل هنوز یاد نمی‌گیرد.

فصل ۶ درباره همین است: initialisation، normalisation، overfitting و regularisation، و عادت تشخیصیِ پرسیدن اینکه کدام‌یک از این‌ها دارد رخ می‌دهد پیش از آنکه چیزی را تغییر دهید. تفاوت میان شبکه‌ای است که اجرا می‌شود و شبکه‌ای که کار می‌کند.


کلاس Value در این فصل مستقیماً از microgradِ Andrej Karpathy می‌آید، و ویدیوی او The spelled-out intro to neural networks and backpropagation: building micrograd بهترین سه ساعتی است که اگر می‌خواهید این مطلب را بار دوم از زبان شخص دیگری بشنوید می‌توانید صرف کنید. پست ۲۰۱۶ او Yes you should understand backprop از نوشتن یکی توسط خودتان دفاع می‌کند و در Stanford CS224n خواندنیِ تعیین‌شده است. یادداشت‌های CS231n درباره backpropagation (cs231n.github.io/optimization-2) روایت canonical الگوهای جریان جدول‌بندی‌شده بالا هستند. برای ریاضیات به‌عنوان حساب دیفرانسیل روی گراف، نه folklore شبکه عصبی، فصل 5.6 از Mathematics for Machine Learning نوشته Deisenroth، Faisal و Ong به‌طور غیرمعمولی روشن است؛ و survey بایْدین، Pearlmutter، Radul و Siskind با عنوان Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767) مرجع کل حوزه است، از جمله trade-off forward/reverse که بالا بحث شد.

  1. Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. پایان‌نامه کارشناسی ارشد، University of Helsinki (1970). انباشت reverse-mode، شانزده سال پیش از رسیدنش به این حوزه و با انگیزه‌ای کاملاً متفاوت.

  2. Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). مقاله‌ای که روش را مشهور کرد، و سرچشمه خوانش واحدهای پنهان به‌عنوان learned representation که بخش لایه پنهان چه کرد در این فصل اندازه‌گیری‌هایش را صرف آن می‌کند.

  3. Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989).

  4. Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). تعمیم Cybenko: نتیجه به غیرچندجمله‌ای بودن activation وابسته است، نه به sigmoidal بودن آن.


تهیه‌شده توسط

David Vicente Campos

بنیان‌گذار NeuraLIA Labs و هم‌بنیان‌گذار MyRealFood

من مهندس کامپیوتر و فارغ‌التحصیل دانشگاه لئون هستم. هم‌بنیان‌گذار MyRealFood بودم، جایی که به‌عنوان مدیر ارشد فناوری اپلیکیشنی را ساختم که میلیون‌ها نفر برای سالم‌تر غذا خوردن از آن استفاده کرده‌اند، و NeuraLIA Labs را بنیان‌گذاری کردم؛ جایی که محصولات هوش مصنوعی می‌سازم. اینجا از چیزهایی می‌نویسم که در طول مسیر باید می‌فهمیدم، همان‌طور که دوست داشتم کسی برایم توضیح می‌داد.

بیشتر درباره نویسنده

منتشرشده توسط NeuraLIA Labs.

پست‌های جدید را در ایمیل خود دریافت کنید

اخبار AI، راهنماها و به‌روزرسانی‌های محصول — هر وقت چیزی ارزشمند منتشر کنیم، یک ایمیل کوتاه می‌فرستیم.

فهرست دوره

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev12 دقیقه مطالعه

مدل هوش مصنوعی Jev برای تصمیم ساخته شده، نه نثر

Jev از TypeSafe AI توجه‌ها را جلب کرده چون هوشمندی نرم‌افزار را مسئله‌ای احتمالاتی می‌بیند: شاخه درست را انتخاب کنید، میزان اطمینان را کنار آن بگذارید، و وقتی کد به یک تصمیم نیاز دارد برای نوشتن متن به یک LLM پول ندهید.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering13 دقیقه مطالعه

مهندسی کانتکست برای عامل‌های AI بلندافق

عامل‌های طولانی‌اجرا فقط به‌خاطر کوچک بودن پنجره شکست نمی‌خورند. وقتی فایل‌ها، خروجی ابزارها و تاریخچهٔ کهنه وظیفه‌ای را که عامل قرار بود تمام کند کنار می‌زنند، شکست رخ می‌دهد.

آماده‌اید انتخاب مدل را به LIA بسپارید؟

با همه مدل‌های هوش مصنوعی در یک جا بسازید — همین امروز رایگان شروع کنید.