Backpropagation از صفر: اول موتور، بعد شبکه
یک موتور autodiff ۱۲۰خطی با Python خالص بسازید، با PyTorch تا ۱۶ رقم اعشار بسنجید و با حذف zero_grad بفهمید چه میکند.
در این صفحه
چهار فصل جلو آمدهایم و حالا وسط دوره یک حفره وجود دارد.
فصل ۳ به ما gradient descent داد: برای بهتر کردن یک پارامتر، شیب زیان را نسبت به آن پیدا کن و رو به پایین حرکت کن. فصل ۴ زیانی داد که ارزش پایین رفتن داشته باشد. اما در هر دو، مشتق دستی محاسبه شد — یک مدل، یک پارامتر، یک خط حساب دیفرانسیل، و همهاش در یک صفحه جا میشد.
حالا دو لایه را روی هم بگذارید. خروجی اولی وارد دومی میشود، پس هر وزن در لایه اول از طریق هر نورون در لایه دوم روی زیان اثر میگذارد. شبکهای با دو لایه پنهانِ صدواحدی حدود بیست هزار پارامتر دارد، و هرکدام مشتق جزئیِ مخصوص خودش را از همان زیان میخواهد. انجام دادن این کار با دست خستهکننده نیست؛ غیرممکن است، و برای هر معماری در ادامه این دوره هم غیرممکن میماند.
راه خروج، نمادگذاری بهتر نیست. فهم این نکته است که مشتق یک ترکیب را میتوان بهصورت مکانیکی، با یک برنامه، از ساختار خودِ محاسبه به دست آورد — و اگر آن را در جهت درست انجام دهید، همه آن بیست هزار مشتق را تقریباً با هزینه یک بار محاسبه زیان میگیرید.
این سازوکار reverse-mode automatic differentiation است. وقتی روی یک شبکه عصبی اعمال شود به آن backpropagation میگویند، و تا پایان این فصل یکی از آن را در حدود ۱۲۰ خط Python، بدون هیچ کتابخانهای، نوشتهاید؛ با PyTorch سنجیدهاید؛ و از آن برای حل مسئله XOR استفاده کردهاید، همان مسئلهای که perceptron را در فصل ۱ زمین زد.
اول: چرا اصلاً باید غیرخطیبودنی در کار باشد
لینک به بخش: اول: چرا اصلاً باید غیرخطیبودنی در کار باشدپیش از ساختن ماشین، باید تکلیف یک سؤال روشن شود، چون اگر پاسخ خلاف این بود چیزی برای ساختن وجود نداشت.
perceptron روی XOR شکست خورد چون یک خط نمیتواند چهار نقطه را جدا کند. راهحل بدیهی این است که لایهها را stack کنیم: ورودی را از یک لایه خطی عبور دهیم، بعد از یکی دیگر. آیا کمک میکند؟
نه، و اثباتش دو خط است. یک لایه خطی است. آن را به یکی دیگر بدهید، ، و جایگذاری کنید:
ترکیب، است با و . یک stack از لایههای خطی، یک لایه خطی واحد است. دهتا، هزارتا: هنوز یک خط، هنوز ناتوان از انجام XOR.
ارزشش را دارد که این اتفاق را ببینید، نه اینکه فقط باورش کنید:
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]))-4.612963371048 -4.612963371048 0.00e+00تقریباً برابر نیستند. بیتبهبیت یکساناند، چون همان حساب است که بازچینی شده.
پس عمق بهتنهایی چیزی نمیخرد. آنچه چیزی میخرد، گذاشتن یک تابع غیرخطی بین لایههاست — و کل دلیل وجود activation function همین است. آنها تزئین زیستی یا ترفند normalisation نیستند. بدون یکی از آنها، لایه دوم فقط دکور است.
قاعده زنجیرهای، روی کاغذ، با یک گره مشترک
لینک به بخش: قاعده زنجیرهای، روی کاغذ، با یک گره مشترکحالا ریاضیات؛ و فقط یک قاعده است که از قبل میشناسید، در جایی کمی ناآشنا به کار رفته.
قاعده زنجیرهای تکمتغیره میگوید اگر به وابسته باشد و به ، آنوقت . مشتقها در امتداد یک زنجیره در هم ضرب میشوند.
بخشی که اینجا مهم است، چیزی است که وقتی یک متغیر به بیش از یک مسیر پاییندست خوراک میدهد رخ میدهد. اگر از طریق و همچنین از طریق روی اثر بگذارد، سهمها جمع میشوند:
در امتداد یک مسیر ضرب کنید، در میان مسیرها جمع کنید. این کل backpropagation است، و هر جزئیات پیادهسازی در ادامه این فصل — از جمله += در کد و فراخوانی zero_grad() که هر کسی را در اولین training loopش گیر میاندازد — پیامد مستقیم همان کلمه دوم است.
یک مدار مشخص با پنج عملیات بگیرید، با و :
دقت کنید سه بار ظاهر میشود: در ، در ، و مستقیم در . گذر backward را روی کاغذ، از راست به چپ، با شروع از انجام دهید:
از طریق جمع
لینک به بخش: از طریق جمع، پس و مسیر مستقیم سهم میدهد. جمع گرادیان ورودی را بدون تغییر به هر دو ورودی پخش میکند.
از طریق tanh
لینک به بخش: از طریق tanhبا ، پس .
از طریق ضرب
لینک به بخش: از طریق ضرب، پس و . ضرب جابهجا میکند: گرادیان هر ورودی با مقدار ورودی دیگر مقیاس میشود.
جمع کردن سه مسیر در x
لینک به بخش: جمع کردن سه مسیر در xاز طریق : . از طریق : . مستقیم: .
این عدد را نگه دارید. چند صفحه دیگر، یک برنامه آن را تولید میکند بیآنکه هیچکدام از اینها به آن گفته شده باشد.
ساختن موتور
لینک به بخش: ساختن موتوربینشی که آن را برنامهپذیر میکند: هرکدام از آن گامها محلی بود. برای عبور دادن گرادیان از گره ضرب، به گرادیان ورودی و دو مقدار ورودیِ ذخیرهشده نیاز داشتید — هیچ چیزی درباره بقیه مدار. هر عملیات میداند چطور خودش را مشتقگیری کند.
پس عددی بسازید که به یاد داشته باشد چه چیزی آن را تولید کرده است.
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 مقدار را انباشته میکند. _prev مجموعه Valueهایی است که این یکی از آنها محاسبه شده — یالهای گراف. و _backward یک closure است که هر عملیات نصب میکند: میداند چطور گرادیان این گره را یک گام به عقب، به ورودیهایش، هل بدهد.
هر operator همان شکل را دنبال میکند: خروجی را محاسبه کن، والدها را ثبت کن، قاعده محلی را نصب کن.
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 | تضعیف میکند — با مقیاس میکند، که حداکثر ۱ است و معمولاً کمتر |
تکتک آنها از += استفاده میکنند و هرگز از = نه. این همان قاعده «جمع میان مسیرها» است که کد شده. گرهای که به دو مصرفکننده خوراک میدهد دو بار فراخوانی میشود، و دو سهم خودبهخود جمع میشوند.
بعد driver، که تنها بخشی است که دانش سراسری دارد:
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 یک گره را صدا میزنید، گرادیان خودش از قبل کامل است — هر مصرفکننده پاییندست آن از قبل سهمش را داده. اگر ترتیب را اشتباه بگیرید، گرادیانی نیمهکاره را به عقب هل میدهید، و جواب غلطی میگیرید بدون هیچ پیام خطایی.
آیا با کاغذ موافق است؟
لینک به بخش: آیا با کاغذ موافق است؟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)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.40331.8212. همان عدد، از برنامهای که فقط قاعده +، قاعده *، قاعده tanh را به آن گفته بودند، و هیچ چیزی درباره این مدار نه.
دو بررسی مستقل، چون «با چیزی که خودم مشتق گرفتم جور است» وقتی همان آدم هر دو کار را کرده آزمون ضعیفی است.
مشتقگیری عددی. ورودی را کمی تکان بدهید و اندازه بگیرید. تفاضل مرکزی مشتق را بدون هیچ حساب دیفرانسیلی تخمین میزند:
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 صنعتی دارد و آدمهایی نوشتهاند که کارشان همین است:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16توافق در ، که machine epsilon برای float ۶۴بیتی است: دو موتور دقیقاً همان حساب را انجام میدهند. بررسی عددی را در جیبتان نگه دارید — ابزار debug کردن گذر backward یک لایه جدید است، و دلیل اینکه یک گرادیان غلط اصلاً قابل پیدا کردن است.
اشباع، اندازهگیریشده
لینک به بخش: اشباع، اندازهگیریشدههمان مدار، ورودیهای متفاوت. و را بگذارید، که میسازد:
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گرادیانی که از گره عبور میکند با ضریب ۹٬۹۴۵ افت کرد. هر چیزی بالادست آن — در یک شبکه واقعی، هر لایه پیش از آن — عملاً هیچ چیز دریافت نمیکند. دو مسیر از مدار خاموش شدهاند؛ فقط اتصال مستقیمی که از رد نمیشود هنوز سیگنال حمل میکند.
این مسئله vanishing gradient است، در یک گره. چهل لایه را stack کنید و چهل چنین ضریبی را در هم ضرب کنید، و لایههای اولیه کاملاً از یادگیری میایستند. همچنین، اتفاقاً، استدلالی برای skip connection است که اینجا در اندازه کوچک میبینید: مسیری که غیرخطیبودن را دور زد تنها مسیری بود که زنده ماند.
zero_grad واقعاً چه میکند، و چرا باگ پنهان میماند
لینک به بخش: zero_grad واقعاً چه میکند، و چرا باگ پنهان میماندهر _backward از += استفاده میکند. درست است — مسیرها همینطور جمع میشوند. اما پیامدی دارد که همه را گیر میاندازد: گرادیانها میان فراخوانیهای backward() هم انباشته میشوند. موتور هیچ تصوری ندارد که فراخوانی دوم شما یک گام آموزشی تازه است، نه مسیری دیگر در همان گراف.
پس یک training loop باید آنها را پاک کند:
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 rate | seed | با reset | بدون reset |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
در learning rateهای کوچک، نسخه باگدار در تکتک سطرها برنده میشود. وقتی نسخه درست گیر میکند، این یکی همگرا میشود.
این تصادفی نیست و ارزش فهمیدن دارد، چون توضیح میدهد چرا گرفتن این باگ اینقدر سخت است. اگر هرگز گرادیان را پاک نکنید، آنوقت در گام پارامتر با جمع همه گرادیانهایی که تا آن لحظه محاسبه شدهاند بهروزرسانی میشود. روی زیانی که تقریباً مدام به یک جهت اشاره میکند، آن جمع پیوسته رشد میکند، و اثرش learning rateای است که خودبهخود زیاد میشود. در ، جایی که الگوریتم درست دارد میخزد، اندازه گامِ فراری دقیقاً شبیه یک اصلاح به نظر میرسد.
بعد سه سطر پایین را ببینید. در همان سازوکار مدل را از هم میپاشاند — loss 8.0 امتیازی است که مدلی collapsed به یک ثابت میگیرد — نصفِ ۱۶ که چهار پاسخ حداکثر غلط هزینه میدادند — — در حالی که نسخه درست حالا تمیز همگرا میشود.
پس بیان صادقانه این نیست که «همیشه zero_grad را صدا بزنید وگرنه مدلتان train نمیشود». این است: بدون آن دیگر gradient descent اجرا نمیکنید. دارید چیزی را اجرا میکنید که اندازه گامش با نرخی که هیچکس انتخاب نکرده رو به بالا drift میکند، و گاهی به نظر میرسد کار میکند، حتی بهتر از چیز واقعی، درست تا وقتی که دیگر کار نکند — و آن موقع شما learning rate، مقداردهی اولیه، یا داده را مقصر میدانید. شکل بدترین باگها در machine learning همین است: crash نمیکنند، الگوریتم را به الگوریتمی دیگر تبدیل میکنند که گاهی امتیاز بهتری میگیرد.
شبکه، و بالاخره XOR
لینک به بخش: شبکه، و بالاخره XORوقتی موتور تمام شد، شبکه عصبی تقریباً هیچ کدی نیست. یک نورون یک dot product، یک bias و یک activation است؛ یک لایه فهرستی از نورونهاست؛ یک شبکه فهرستی از لایههاست.
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 نمیداند دارد برای یک شبکه عصبی استفاده میشود.
حالا مسئله فصل ۱. دو ورودی، دو واحد پنهان، یک خروجی، نه پارامتر:
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ای نمیتواند محاسبه کند — در فصل ۱ با چهار نامساوی ثابت شد که از میخواست هم مثبت باشد و هم منفی — با نه عددی محاسبه میشود که خودکار پیدا شدهاند.
لایه پنهان چه کرد
لینک به بخش: لایه پنهان چه کردبخش رضایتبخش این نیست که کار میکند. این است که میتوانید ببینید چطور، چون با دو واحد پنهان، نمایش میانی یک نقطه در صفحه است و میتوانید فقط printش کنید.
پس از آموزش تا loss برابر 0.001241، هر ورودی بعد از لایه پنهان اینجا فرود میآید، و نورون خروجی با آن چنین میکند:
| input | خروجی لایه پنهان | امتیاز خروجی | label |
|---|---|---|---|
به سطر اول و چهارم نگاه کنید. ورودیهای و گوشههای قطریِ مخالفِ مربعاند — تا جایی که دو نقطه در این مسئله میتوانند از هم دور باشند — و لایه پنهان آنها را به و نگاشت میکند. تقریباً همان نقطه. لایه صفحه را تا کرده تا دو گوشه ردشده روی هم بیفتند، و وقتی در یک مکان قرار گرفتند، یک خط آنها را از دو تای دیگر جدا میکند.
و نورون خروجی دقیقاً همان خط است. پارامترهای یادگرفتهشدهاش ، هستند، پس مرز تصمیمش این است:
که یک خط راست است — یک perceptron، همان شیء فصل ۱، بدون تغییر. آن زمان نمیتوانست XOR را حل کند و حالا هم نمیتواند. چیزی که تغییر کرده این است که دیگر به ورودی نگاه نمیکند؛ به فضایی نگاه میکند که لایه اول برایش ساخته، فضایی که در آن مسئله linearly separable است.
این همان learned representation است، و ارزش دارد دقیق باشیم چون این عبارت در ادامه این دوره، و در کل این حوزه، شل و گسترده استفاده میشود. فشردهسازی، خلاصه، یا embedding به معنایی رازآلود نیست. تغییر مختصات است، یادگرفتهشده بهجای طراحیشده، که تنها کارش آسان کردن کار لایه بعدی است.
قضیه تقریب جهانشمول، و آنچه نمیگوید
لینک به بخش: قضیه تقریب جهانشمول، و آنچه نمیگویداینجا قضیهای وجود دارد، و معمولاً بد نقل میشود.
Cybenko در ۱۹۸۹ و Hornik در ۱۹۹۱ ثابت کردند که یک شبکه feedforward با یک لایه پنهان و activation function مناسب میتواند هر تابع پیوسته روی یک مجموعه فشرده را با هر دقتی که بخواهید تقریب بزند، به شرط داشتن واحدهای پنهان کافی.34 این نتیجهای واقعی و مهم است: میگوید معماری محدودیت نیست.
حالا ببینید چه چیزهایی را حذف میکند. نمیگوید چند واحد — کران میتواند نجومی بزرگ باشد. نمیگوید وزنها میتوانند پیدا شوند؛ وجود را ادعا میکند، و gradient descent از شروع تصادفی oracle نیست. و درباره رفتار روی دادهای که ندیدهاید هیچ نمیگوید، که نیمه دوم فصل ۶ است.
شکاف میان «وجود دارد» و «قابل پیدا کردن است» دانشگاهی نیست. این همان مسئله XOR است، هرکدام با ۵۰ مقداردهی اولیه تصادفی، ۱۰۰۰ گام، فقط اندازه لایه پنهان تغییر کرده:
| واحدهای پنهان | مقداردهیهای اولیهای که به 4/4 رسیدند |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
با حداقل معماری قابلقبول، یک اجرا از هر چهار اجرا هرگز به مقصد نمیرسد — در پیکربندیای مینشیند که نمیتواند از آن پایین بیاید، دقیقاً همان کمینه محلی که فصل ۳ روی یک سطح یکبعدی نشان داد. یک واحد اضافه کنید و شکستها تقریباً ناپدید میشوند، نه چون شبکه expressiveتر شده (دو واحد از قبل کافیاند — ۳۸ اجرا ثابت میکنند) بلکه چون ابعاد اضافه، جهتهای بیشتری برای فرار به descent میدهند.
و بعد هشت واحد کمی بدتر از چهار میشود. با learning rate و بودجه گام ثابت، ظرفیت بیشتر بهصورت یکنواخت بهتر نیست. هر کسی که به شما بگوید راهحل شبکه گیرکرده همیشه شبکه بزرگتر است، دارد از وسط آن جدول extrapolate میکند.
این همان درس قضیه همگرایی در فصل ۱ است، و در فصل ۱۰ درباره scaling lawها نیز همین درس خواهد بود، به شکلی که آن فصل میدهد: پیشبینی loss، پیشبینی قابلیتی نیست که بابتش پول میدهید، و فاصله بین این دو همان جایی است که مهندسی زندگی میکند.
نمایش جزئیات
اختیاری: فرم ماتریسی، و اینکه چرا کد بالا از آن استفاده نمیکند.
همهچیز اینجا تکبهتک و scalar نوشته شده، که روشنترین راه برای دیدن سازوکار و کندترین راه برای اجرای آن است. در عمل یک لایه matrix multiply است، و گذر backward برای چنین است:
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 که بالا بحث شد.
ارجاعات
لینک به بخش: ارجاعات-
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، شانزده سال پیش از رسیدنش به این حوزه و با انگیزهای کاملاً متفاوت. ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). مقالهای که روش را مشهور کرد، و سرچشمه خوانش واحدهای پنهان بهعنوان learned representation که بخش لایه پنهان چه کرد در این فصل اندازهگیریهایش را صرف آن میکند. ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). تعمیم Cybenko: نتیجه به غیرچندجملهای بودن activation وابسته است، نه به sigmoidal بودن آن. ↩