דלג לתוכן
5/30פרק 5 מתוך 30

Backpropagation מאפס: קודם המנוע, אחר כך הרשת

בנו מנוע autodiff ב־120 שורות Python, בדקו אותו מול PyTorch עד 16 ספרות, וגלו מה zero_grad עושה כשמוחקים אותו.

בעמוד הזה

ארבעה פרקים פנימה, ויש חור באמצע הקורס.

פרק 3 נתן לנו gradient descent: כדי לשפר פרמטר, מוצאים את השיפוע של ה־loss ביחס אליו וצועדים במורד. פרק 4 נתן לנו loss ששווה לרדת לאורכו. אבל בשניהם, הנגזרת חושבה ביד — מודל אחד, פרמטר אחד, שורת חשבון דיפרנציאלי אחת, והכול נכנס לעמוד.

עכשיו נערום שתי שכבות. הפלט של הראשונה מזין את השנייה, ולכן כל משקל בשכבה הראשונה משפיע על ה־loss דרך כל נוירון בשנייה. ברשת עם שתי שכבות חבויות של מאה יחידות כל אחת יש בערך עשרים אלף פרמטרים, וכל אחד צריך נגזרת חלקית משלו של אותו loss. לעשות את זה ביד הוא לא מייגע; זה בלתי אפשרי, וזה נשאר בלתי אפשרי לכל ארכיטקטורה בהמשך הקורס הזה.

הדרך החוצה אינה סימון טוב יותר. היא ההבנה שאת הנגזרת של הרכבה אפשר לחשב מכנית, באמצעות תוכנית, מתוך מבנה החישוב עצמו — ושאם עושים זאת בכיוון הנכון, מקבלים את כל עשרים אלף הנגזרות בערך במחיר של חישוב ה־loss פעם אחת.

המנגנון הזה הוא בידול אוטומטי במצב הפוך. כשהוא מיושם על רשת נוירונים קוראים לו backpropagation, ועד סוף הפרק הזה תכתבו אחד כזה בכ־120 שורות Python, בלי ספריות, תבדקו אותו מול PyTorch, ותשתמשו בו כדי לפתור את בעיית XOR שהרגה את ה־perceptron בפרק 1.

קודם כול: למה בכלל חייבת להיות אי־ליניאריות

קישור למקטע: קודם כול: למה בכלל חייבת להיות אי־ליניאריות

לפני שבונים את המכונה, צריך לסגור שאלה אחת, כי אם התשובה הייתה הפוכה לא היה מה לבנות.

ה־perceptron נכשל ב־XOR כי קו אחד לא יכול להפריד בין ארבע הנקודות. התיקון המתבקש הוא לערום: להעביר את הקלט דרך שכבה ליניארית אחת, ואז דרך עוד אחת. זה עוזר?

לא, וההוכחה היא שתי שורות. שכבה ליניארית היא 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. ערימה של שכבות ליניאריות היא שכבה ליניארית אחת. עשר כאלה, אלף כאלה: עדיין קו אחד, עדיין לא מסוגל לעשות 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. הן לא קישוט ביולוגי או טריק נרמול. בלי אחת כזו, השכבה השנייה היא תפאורה.

כלל השרשרת, על הנייר, עם צומת משותף

קישור למקטע: כלל השרשרת, על הנייר, עם צומת משותף

עכשיו המתמטיקה, והיא כלל אחד שאתם כבר מכירים, מיושם במקום מעט פחות מוכר.

כלל השרשרת במשתנה יחיד אומר שאם LL תלוי ב־cc ו־cc תלוי ב־xx, אז dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. נגזרות מוכפלות לאורך שרשרת.

החלק שחשוב כאן הוא מה קורה כשמשתנה מזין יותר מנתיב downstream אחד. אם xx משפיע על LL דרך aa וגם דרך bb, התרומות מתווספות:

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() שמפילה כל מי שכותב את לולאת האימון הראשונה שלו — הוא תוצאה ישירה של המילה השנייה הזאת.

קחו מעגל קונקרטי של חמש פעולות, עם 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. חיבור מפיץ את ה־gradient הנכנס ללא שינוי לשני הקלטים.

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. כפל מחליף: ה־gradient של כל קלט מוכפל בערך של הקלט האחר.

דרך 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

שמרו את המספר הזה. בעוד כמה עמודים תוכנית תפיק אותו בלי שנספר לה שום דבר מזה.

התובנה שהופכת את זה לתכנותי: כל אחד מהצעדים האלה היה מקומי. כדי לדחוף gradient דרך צומת הכפל, הייתם צריכים את ה־gradient הנכנס ואת שני ערכי הקלט השמורים — שום דבר על שאר המעגל. כל פעולה יודעת איך לגזור את עצמה.

אז ניצור מספר שזוכר מה יצר אותו.

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 שכל פעולה מתקינה: היא יודעת איך לדחוף את ה־gradient של הצומת הזה צעד אחד אחורה אל הקלטים שלו.

כל אופרטור עוקב אחרי אותה צורה: מחשבים את הפלט, רושמים את ההורים, מתקינים את הכלל המקומי.

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 כטבלה, ודפוסי הזרימה מהגזירה על הנייר נמצאים ממש שם:

פעולהמה היא עושה ל־gradient
+מפיצה — אותו gradient לכל קלט
*מחליפה — כל קלט מוכפל בערך של האחר
reluמנתבת — מעבירה אותו או חוסמת אותו לגמרי
tanhמחלישה — מכפילה ב־1t21 - t^2, שהוא לכל היותר 1 ובדרך כלל פחות

כל אחת מהן משתמשת ב־+= ולעולם לא ב־=. זה כלל ״לסכם בין נתיבים״, מקודד. צומת שמזין שני צרכנים נקרא פעמיים, ושתי התרומות מצטברות מעצמן.

ואז הדרייבר, שהוא החלק היחיד עם ידע גלובלי כלשהו:

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 של צומת, ה־gradient שלו עצמו כבר שלם — כל צרכן downstream כבר תרם. טעו בסדר ותדחפו אחורה gradient חצי־גמור, מה שמייצר תשובה שגויה בלי הודעת שגיאה.

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 של 64 ביט: שני המנועים מבצעים חשבון זהה. שמרו את הבדיקה הנומרית בכיס — זה הכלי לניפוי באגים במעבר ה־backward של שכבה חדשה, וזו הסיבה שבכלל אפשר למצוא gradient שגוי.

אותו מעגל, קלטים שונים. קבעו 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

ה־gradient שחוצה את צומת tanh\tanh ירד בפקטור של 9,945. כל מה שנמצא upstream ממנו — ברשת אמיתית, כל שכבה שלפניו — מקבל כמעט כלום. שני הנתיבים דרך המעגל השתתקו; רק החיבור הישיר שעוקף את tanh\tanh עדיין נושא אות.

זו בעיית ה־vanishing gradient, בצומת אחד. ערמו ארבעים שכבות של tanh\tanh והכפילו ארבעים פקטורים כאלה יחד, והשכבות המוקדמות מפסיקות ללמוד לגמרי. זה גם, אגב, טיעון בעד skip connections שאפשר לראות כאן במיניאטורה: הנתיב שעקף את האי־ליניאריות הוא היחיד ששרד.

מה zero_grad באמת עושה, ולמה הבאג מסתתר

קישור למקטע: מה zero_grad באמת עושה, ולמה הבאג מסתתר

כל _backward משתמש ב־+=. זה נכון — כך נתיבים מסתכמים. אבל יש לזה תוצאה שתופסת את כולם: gradients מצטברים גם בין קריאות ל־backward(). למנוע אין מושג שהקריאה השנייה שלכם היא צעד אימון חדש ולא עוד נתיב באותו גרף.

לכן לולאת אימון חייבת לנקות אותם:

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, והעצה הרגילה היא ששכחה שלו שוברת את האימון. אז נמחק את שתי השורות האלה ונראה עד כמה זה שבור. אותם seeds, אותו הכול, 200 צעדים של XOR:

learning rateseedעם איפוסבלי איפוס
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 rates הקטנים, הגרסה הבאגית מנצחת בכל שורה. היא מתכנסת כשהגרסה הנכונה נתקעת.

זה לא מקרה, ושווה להבין את זה, כי זה מסביר למה הבאג הזה כל כך קשה לתפיסה. אם אף פעם לא מנקים את ה־gradient, אז בצעד kk הפרמטר מתעדכן לפי סכום כל gradient שחושב עד כה. ב־loss שממשיך להצביע פחות או יותר לאותו כיוון, הסכום הזה גדל בהתמדה, והאפקט הוא learning rate שעולה מעצמו. ב־η=0.05\eta = 0.05, כשהאלגוריתם הנכון זוחל, גודל הצעד הבורח נראה בדיוק כמו תיקון.

ואז הסתכלו על שלוש השורות התחתונות. ב־η=0.3\eta = 0.3 אותו מנגנון מפרק את המודל — loss 8.0 הוא הציון שמודל שקרס לקבוע ±1\pm 1 מקבל — חצי מה־16 שארבע תשובות שגויות באופן מקסימלי היו עולות — — בזמן שהגרסה הנכונה מתכנסת עכשיו נקי.

אז ההצהרה הכנה אינה ״תמיד קראו ל־zero_grad או שהמודל שלכם לא יתאמן״. היא: בלעדיו אתם כבר לא מריצים gradient descent. אתם מריצים משהו שגודל הצעד שלו נסחף כלפי מעלה בקצב שאף אחד לא בחר, והוא ייראה כאילו הוא עובד, לפעמים טוב יותר מהדבר האמיתי, ממש עד שהוא לא — ואז תאשימו את ה־learning rate, את האתחול או את הנתונים. זו הצורה של הבאגים הגרועים ביותר ב־machine learning: הם לא קורסים, הם משנים את האלגוריתם לאלגוריתם אחר שמדי פעם משיג ציון טוב יותר.

כשהמנוע מוכן, רשת נוירונים היא כמעט בלי קוד. נוירון הוא מכפלה פנימית, 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 pass בכל זה. אפילו לא שורה אחת. מחלקת Value כבר יודעת איך לגזור כל דבר שהמחלקות האלה במקרה בונות, וזו הנקודה של לכתוב אותה קודם: מנוע autodiff לא יודע שמשתמשים בו לרשת נוירונים.

עכשיו הבעיה מפרק 1. שני קלטים, שתי יחידות חבויות, פלט אחד, תשעה פרמטרים:

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 לא יכול לחשב — כפי שהוכחנו בפרק 1 באמצעות ארבעה אי־שוויונים שדרשו ש־bb יהיה גם חיובי וגם שלילי — מחושבת באמצעות תשעה מספרים שנמצאו אוטומטית.

החלק המספק הוא לא שזה עובד. הוא היכולת לראות איך, כי עם שתי יחידות חבויות הייצוג הביניים הוא נקודה במישור ואפשר פשוט להדפיס אותו.

אחרי אימון ל־loss של 0.001241, הנה המקום שאליו כל קלט נוחת אחרי השכבה החבויה, ומה נוירון הפלט עושה איתו:

קלטפלט השכבה החבויהציון פלטתווית
(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, אותו אובייקט מפרק 1, ללא שינוי. הוא לא יכול היה לפתור XOR אז והוא לא יכול עכשיו. מה שהשתנה הוא שהוא כבר לא מסתכל על הקלט; הוא מסתכל על מרחב שהשכבה הראשונה בנתה עבורו, שבו הבעיה ניתנת להפרדה ליניארית.

זה מה שהוא ייצוג נלמד, ושווה לדייק כי הביטוי ישמש באופן רופף בשאר הקורס, ובשאר התחום. זה לא דחיסה, סיכום או embedding במובן מיסטי כלשהו. זה שינוי קואורדינטות, נלמד ולא מתוכנן, שתפקידו היחיד הוא להקל על השכבה הבאה.

משפט הקירוב האוניברסלי, ומה הוא לא אומר

קישור למקטע: משפט הקירוב האוניברסלי, ומה הוא לא אומר

יש כאן משפט, ובדרך כלל מצטטים אותו רע.

Cybenko ב־1989 ו־Hornik ב־1991 הוכיחו שרשת feedforward עם שכבה חבויה יחידה ופונקציית activation מתאימה יכולה לקרב כל פונקציה רציפה על קבוצה קומפקטית, לכל דיוק שתרצו, בהינתן מספיק יחידות חבויות.34 זו תוצאה אמיתית וחשובה: היא אומרת שהארכיטקטורה אינה המגבלה.

עכשיו קראו מה הוא משמיט. הוא לא אומר כמה יחידות — החסם יכול להיות אסטרונומי. הוא לא אומר שאפשר למצוא את המשקלים; הוא טוען לקיום, ו־gradient descent מנקודת התחלה אקראית אינו אורקל. והוא לא אומר כלום על התנהגות על נתונים שלא ראיתם, שזה החצי השני של פרק 6.

הפער בין ״קיים״ ל־״ניתן למציאה״ אינו אקדמי. הנה אותה בעיית XOR, 50 אתחולים אקראיים לכל מצב, 1000 צעדים, כשרק גודל השכבה החבויה השתנה:

יחידות חבויותאתחולים שהגיעו ל־4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

עם הארכיטקטורה המינימלית שעובדת, ריצה אחת מתוך ארבע אף פעם לא מגיעה לשם — היא מתייצבת בתצורה שהיא לא יכולה לרדת ממנה, בדיוק המינימום המקומי שפרק 3 הראה על משטח חד־ממדי. הוסיפו יחידה אחת והכישלונות כמעט נעלמים, לא כי הרשת נעשתה יותר אקספרסיבית (שתי יחידות כבר מספיקות — 38 ריצות מוכיחות זאת) אלא כי ממדים נוספים נותנים לירידה עוד כיוונים לברוח דרכם.

ואז שמונה יחידות מצליחות מעט פחות מארבע. ב־learning rate ותקציב צעדים קבועים, יותר קיבולת אינה טובה יותר באופן מונוטוני. מי שאומר לכם שהתיקון לרשת תקועה הוא תמיד רשת גדולה יותר מסיק מסקנה מוגזמת מאמצע הטבלה הזאת.

זה אותו לקח כמו משפט ההתכנסות בפרק 1, וזה יהיה אותו לקח בפרק 10 על חוקי scaling, בצורה שהפרק ההוא נותן לו: תחזית של ה־loss אינה תחזית של היכולת שעליה אתם משלמים, והמרחק בין השתיים הוא המקום שבו ההנדסה חיה.

הצגת פרטים

אופציונלי: הצורה המטריציונית, ולמה הקוד למעלה לא משתמש בה.

כל מה שכאן נכתב סקלר אחד בכל פעם, וזו הדרך הברורה ביותר לראות את המנגנון והאיטית ביותר להריץ אותו. בפועל שכבה היא כפל מטריצות, ומעבר ה־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}}

הטרנספוזיציות אינן טריק שצריך לזכור; הן איך שנראה כלל סכום־על־נתיבים כשהנתיבים מאונדקסים לפי איברי מטריצה. האובייקט הכללי הוא ה־Jacobian, מטריצת כל הנגזרות החלקיות של כל הפלטים ביחס לכל הקלטים, ומצב הפוך הוא בדיוק חישוב של מכפלת וקטור־Jacobian בלי לבנות אי פעם את ה־Jacobian — וזה חשוב, כי לשכבה עם 4096 קלטים ו־4096 פלטים יש במטריצה הזאת שישה־עשר מיליון איברים, ולעולם לא משתלם לבנות אותה.

לא צריך שום דבר מזה כדי לעקוב אחרי הפרקים הבאים; הגרסה הסקלרית עושה את כל מה שהגרסה המטריציונית עושה, לאט יותר. זה נעשה הכרחי בפרק 9, שבו הצורות מפסיקות להיות מובנות מאליהן.

עכשיו יש לכם רשת שמתאמנת. זה הישג קטן יותר מכפי שהוא מרגיש, כי הרשת שיש לכם מתאמנת על ארבע דוגמאות ונמדדת על אותן ארבע.

הריצו את אותו קוד על dataset אמיתי ומופיעה מערכת חדשה של בעיות, שאף אחת מהן אינה קשורה ל־gradients. ה־loss יורד לזמן מה ואז נעצר. או שהוא יורד על נתוני האימון ועולה על כל השאר. או שהוא לא זז בכלל מהצעד הראשון, והסיבה מתגלה כטווח המשקלים האקראיים הראשוניים. או שהקלט של יחידה אחת נסחף לשלילי בכל דוגמה באפוק השלישי ומאז היא מתה, בשקט, ולוקחת איתה חתיכה מהקיבולת של המודל.

אלה לא כשלים אקזוטיים; הם המצב הרגיל של רשת שזה עתה נכתבה, ואף אחד מהם לא מכריז על עצמו. ה־gradient נכון — בדקתם אותו מול PyTorch עד שש־עשרה ספרות אחרי הנקודה — והמודל עדיין לא לומד.

פרק 6 עוסק בזה: אתחול, נרמול, overfitting ו־regularisation, והרגל האבחון של לשאול איזה מהם קורה לפני שמשנים משהו. זה ההבדל בין רשת שרצה לבין רשת שעובדת.


מחלקת Value בפרק הזה היא צאצאית ישירה של micrograd של Andrej Karpathy, והווידאו שלו The spelled-out intro to neural networks and backpropagation: building micrograd הוא שלוש השעות הטובות ביותר שתוכלו להשקיע בחומר הזה אם תרצו הסבר שני ממישהו אחר. הפוסט שלו מ־2016, Yes you should understand backprop, מציג את הטיעון בעד כתיבת אחד כזה בעצמכם והוא קריאת חובה ב־CS224n של Stanford. ההערות של CS231n על backpropagation (cs231n.github.io/optimization-2) הן הטיפול הקאנוני בדפוסי הזרימה שהוצגו בטבלה למעלה. למתמטיקה כחשבון דיפרנציאלי על גרף ולא כפולקלור של רשתות נוירונים, פרק 5.6 ב־Mathematics for Machine Learning מאת Deisenroth, Faisal ו־Ong ברור במיוחד; והסקירה של Baydin, Pearlmutter, Radul ו־Siskind, Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767), היא הרפרנס לתחום כולו, כולל פשרת 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). צבירה במצב הפוך, שש־עשרה שנים לפני שהגיעה לתחום הזה ומתוך מוטיבציה שונה לחלוטין.

  2. Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). המאמר שהפך את השיטה למוכרת, והמקור לקריאה של יחידות חבויות כייצוגים נלמדים, שעליה סעיף מה השכבה החבויה עשתה בפרק הזה מבסס את המדידות שלו.

  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 אינה פולינומית, לא בכך שהיא סיגמואידית.


נוצר על ידי

David Vicente Campos

מייסד NeuraLIA Labs ושותף-מייסד MyRealFood

אני מהנדס מחשבים, בוגר אוניברסיטת לאון. הייתי שותף בהקמת MyRealFood, שם, כסמנכ״ל טכנולוגיות, בניתי את האפליקציה שמיליוני אנשים השתמשו בה כדי לאכול בריא יותר, והקמתי את NeuraLIA Labs, שם אני בונה מוצרי בינה מלאכותית. כאן אני כותב על מה שהייתי צריך להבין לאורך הדרך, כפי שהייתי רוצה שמישהו היה מסביר לי בזמנו.

עוד על המחבר

פורסם על ידי NeuraLIA Labs.

פוסטים חדשים ישירות לתיבת הדואר

חדשות AI, מדריכים ועדכוני מוצר — מייל קצר כשאנחנו מפרסמים משהו ששווה את הזמן שלך.

תוכן הקורס

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev10 דקות קריאה

מודל ה-AI Jev נבנה להחלטות, לא לפרוזה

Jev של TypeSafe AI מושך תשומת לב כי הוא מתייחס לאינטליגנציית תוכנה כאל בעיית הסתברות: לבחור את ההסתעפות הנכונה, להצמיד ביטחון, ולהימנע מתשלום ל-LLM כדי שיכתוב טקסט כשהקוד צריך החלטה.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering10 דקות קריאה

הנדסת הקשר לסוכני AI ארוכי־טווח

סוכנים שרצים לאורך זמן לא נכשלים רק כי החלון קטן. הם נכשלים כשקבצים, פלטי כלים והיסטוריה מיושנת דוחקים החוצה את המשימה שהסוכן היה אמור להשלים.

מוכנים לתת ל-LIA לבחור?

בנו עם כל מודלי ה-AI במקום אחד — התחילו בחינם עוד היום.