تخطَّ إلى المحتوى
5/30الفصل 5 من 30

Backpropagation من الصفر: المحرك أولًا، ثم الشبكة

اكتب محرك autodiff من 120 سطرًا في Python خالص، قارنه مع PyTorch حتى 16 منزلة عشرية، وافهم zero_grad بحذفه.

في هذه الصفحة

بعد أربعة فصول، توجد فجوة في منتصف الدورة.

قدّم لنا الفصل 3 gradient descent: لتحسين parameter، ابحث عن ميل loss بالنسبة إليه ثم اخطُ نزولًا. وقدّم لنا الفصل 4 loss يستحق النزول. لكن في كليهما، حُسبت المشتقة يدويًا — نموذج واحد، parameter واحد، سطر واحد من التفاضل، وكل ذلك اتسع في صفحة.

الآن رصّ طبقتين. خرج الأولى يغذّي الثانية، لذا يؤثر كل وزن في الأولى في loss عبر كل neuron في الثانية. شبكة فيها طبقتان مخفيتان من مئة وحدة لكل منهما تضم نحو عشرين ألف parameter، وكل واحد منها يحتاج مشتقته الجزئية الخاصة من نفس 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

ليستا متساويتين تقريبًا. متطابقتان bit-for-bit، لأنهما نفس الحسابات معاد ترتيبها.

إذًا العمق وحده لا يشتري شيئًا. ما يشتري شيئًا هو وضع دالة لاخطية بين الطبقات — وهذا هو السبب الكامل لوجود دوال activation. ليست لمسة بيولوجية ولا خدعة normalisation. من دون واحدة، تكون الطبقة الثانية مجرد زينة.

قاعدة السلسلة، على الورق، مع عقدة مشتركة

رابط إلى القسم: قاعدة السلسلة، على الورق، مع عقدة مشتركة

الآن الرياضيات، وهي قاعدة واحدة تعرفها مسبقًا مطبّقة في موضع غير مألوف قليلًا.

تقول قاعدة السلسلة لمتغير واحد إنه إذا كانت LL تعتمد على cc وcc تعتمد على xx، فإن dLdx=dLdcdcdx\frac{dL}{dx} = \frac{dL}{dc} \cdot \frac{dc}{dx}. المشتقات تتضاعف على طول السلسلة.

الجزء المهم هنا هو ما يحدث عندما يغذّي متغير أكثر من مسار لاحق واحد. إذا أثرت 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() الذي يوقع كل من يكتب أول 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. نفّذ المرور الخلفي على الورق، من اليمين إلى اليسار، بدءًا من 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 هذه العقدة خطوة واحدة إلى الخلف نحو مدخلاتها.

كل 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 الأربعة كجدول، وسترى أنماط التدفق من الاشتقاق الورقي موجودة هناك مباشرة:

العمليةماذا تفعل بـ 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 الخاص بها مكتملًا بالفعل — كل مستهلك أسفلها ساهم مسبقًا. أخطئ في الترتيب وستدفع 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 لعدد عائم 64-bit: المحركان ينفذان حسابات متطابقة. احتفظ بالفحص العددي في جيبك — إنه أداة تصحيح المرور الخلفي لطبقة جديدة، وهو السبب في إمكانية العثور على 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. كل ما قبلها — في شبكة حقيقية، كل طبقة قبلها — يتلقى عمليًا لا شيء. المساران عبر الدائرة صمتا؛ وحده الاتصال المباشر الذي يتجاوز tanh\tanh ما زال يحمل الإشارة.

هذه هي مشكلة vanishing gradient، في عقدة واحدة. رصّ أربعين طبقة من tanh\tanh واضرب أربعين عاملًا كهذا معًا، وستتوقف الطبقات المبكرة عن التعلم تمامًا. وهي أيضًا، بالمناسبة، حجة لصالح skip connections يمكنك رؤيتها هنا مصغّرة: المسار الذي تجاوز اللاخطية هو الوحيد الذي نجا.

ما الذي يفعله zero_grad فعليًا، ولماذا يختبئ الخطأ

رابط إلى القسم: ما الذي يفعله zero_grad فعليًا، ولماذا يختبئ الخطأ

كل _backward يستخدم +=. هذا صحيح — فهكذا تُجمع المسارات. لكن له نتيجة يقع فيها الجميع: gradients تتراكم عبر استدعاءات 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، والنصيحة المعتادة هي أن نسيانه يفسد التدريب. فلنحذف هذين السطرين ونرَ مدى الفساد. البذور نفسها، كل شيء نفسه، 200 خطوة من XOR:

معدل التعلمالبذرةمع إعادة التصفيرمن دون إعادة التصفير
0.051337الخسارة 3.255088، 3/4الخسارة 0.000000، 4/4
0.057الخسارة 2.144820، 2/4الخسارة 0.000000، 4/4
0.0542الخسارة 2.126074، 2/4الخسارة 0.000000، 4/4
0.11337الخسارة 0.038597، 4/4الخسارة 0.000000، 4/4
0.17الخسارة 2.055048، 2/4الخسارة 0.000000، 4/4
0.142الخسارة 2.049876، 2/4الخسارة 0.000073، 4/4
0.31337الخسارة 4.512310، 2/4الخسارة 8.000000، 2/4
0.37الخسارة 0.015247، 4/4الخسارة 4.000000، 3/4
0.342الخسارة 0.005478، 4/4الخسارة 4.000000، 3/4

عند معدلات التعلم الصغيرة، تفوز النسخة المعيبة في كل صف. تتقارب حين تتعثر النسخة الصحيحة.

هذا ليس مصادفة، ويستحق الفهم، لأنه يفسّر لماذا يصعب الإمساك بهذا الخطأ. إذا لم تمسح gradient مطلقًا، فعند الخطوة kk يُحدَّث parameter بـ مجموع كل gradient حُسب حتى الآن. في loss يظل يشير تقريبًا في الاتجاه نفسه، يكبر ذلك المجموع بثبات، ويكون الأثر معدل تعلم يزداد من تلقاء نفسه. عند η=0.05\eta = 0.05، حيث تزحف الخوارزمية الصحيحة، يبدو حجم الخطوة المنفلت تمامًا كأنه إصلاح.

ثم انظر إلى الصفوف الثلاثة الأخيرة. عند η=0.3\eta = 0.3 تفجّر الآلية نفسها النموذج — الخسارة 8.0 هي ما يسجله نموذج انهار إلى ثابت ±1\pm 1 — نصف الـ 16 التي كانت ستكلفها أربع إجابات خاطئة إلى أقصى حد — — بينما النسخة الصحيحة تتقارب الآن بوضوح.

لذلك ليست العبارة الصادقة «استدعِ zero_grad دائمًا وإلا فلن يتدرب نموذجك». بل: من دونه أنت لا تشغّل gradient descent بعد الآن. أنت تشغّل شيئًا ينجرف حجم خطوته صعودًا بمعدل لم يختره أحد، وسيبدو أنه يعمل، وأحيانًا أفضل من الشيء الحقيقي، إلى أن لا يعمل — وعندها ستلوم معدل التعلم، أو التهيئة، أو البيانات. هذا هو شكل أسوأ الأخطاء في تعلم الآلة: لا تنهار، بل تغيّر الخوارزمية إلى خوارزمية مختلفة تسجل أحيانًا نتائج أفضل.

بعد اكتمال المحرك، تصبح الشبكة العصبية مجرد مقدار قليل من الكود. neuron هو dot product، وbias، وactivation؛ والطبقة قائمة من neurons؛ والشبكة قائمة من الطبقات.

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()]

لا يوجد مرور خلفي في أي من ذلك. ولا سطر واحد. صنف Value يعرف مسبقًا كيف يشتق أي شيء تبنيه هذه الأصناف، وهذا هو الهدف من كتابته أولًا: محرك autodiff لا يعرف أنه يُستخدم لشبكة عصبية.

الآن مسألة الفصل 1. دخلان، وحدتان مخفيتان، خرج واحد، تسعة parameters:

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 موجبة وسالبة في الوقت نفسه — تُحسب بتسعة أرقام وُجدت تلقائيًا.

الجزء المُرضي ليس أنها تعمل. بل أن تستطيع رؤية كيف، لأنه مع وحدتين مخفيتين تكون representation الوسيطة نقطة في مستوى ويمكنك ببساطة طباعتها.

بعد التدريب إلى خسارة 0.001241، إليك أين يهبط كل دخل بعد الطبقة المخفية، وما يفعله neuron الخرج به:

الدخلخرج الطبقة المخفيةدرجة الخرجالتصنيف
(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). تقريبًا النقطة نفسها. لقد طوت الطبقة المستوى بحيث تهبط الزاويتان المرفوضتان فوق بعضهما، وما إن تصبحا في المكان نفسه، يفصلهما خط واحد عن النقطتين الأخريين.

وneuron الخرج هو ذلك الخط بالضبط. parameters المتعلمة لديه هي 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 آنذاك ولا يستطيع الآن. ما تغيّر هو أنه لم يعد ينظر إلى الدخل؛ بل ينظر إلى فضاء بنته له الطبقة الأولى، حيث تصبح المسألة قابلة للفصل خطيًا.

هذا هو معنى representation متعلمة، ومن المهم أن نكون دقيقين لأن العبارة ستُستخدم بمرونة في بقية هذه الدورة، وفي بقية المجال. ليست ضغطًا، ولا ملخصًا، ولا embedding بمعنى غامض. إنها تغيير إحداثيات، متعلم بدلًا من مصمم، وظيفته الوحيدة جعل مهمة الطبقة التالية سهلة.

مبرهنة التقريب الشامل، وما لا تقوله

رابط إلى القسم: مبرهنة التقريب الشامل، وما لا تقوله

توجد مبرهنة هنا، وغالبًا ما تُقتبس بطريقة سيئة.

أثبت Cybenko عام 1989 وHornik عام 1991 أن شبكة feedforward بطبقة مخفية واحدة ودالة activation مناسبة تستطيع تقريب أي دالة مستمرة على مجموعة مدمجة، إلى أي دقة تريدها، إذا توفر عدد كافٍ من الوحدات المخفية.34 إنها نتيجة حقيقية ومهمة: تقول إن البنية ليست هي القيد.

والآن اقرأ ما تحذفه. لا تقول كم وحدة — قد يكون الحد فلكيًا. ولا تقول إن الأوزان يمكن العثور عليها؛ إنها تثبت الوجود، وgradient descent من بداية عشوائية ليس oracle. ولا تقول شيئًا عن السلوك على بيانات لم ترها، وهذا هو النصف الثاني من الفصل 6.

الفجوة بين «موجود» و«قابل للعثور عليه» ليست أكاديمية. هذه مسألة XOR نفسها، 50 تهيئة عشوائية لكل حالة، 1000 خطوة، مع تغيير حجم الطبقة المخفية فقط:

الوحدات المخفيةالتهيئات التي وصلت إلى 4/4
238 / 50 (76 %)
349 / 50 (98 %)
450 / 50 (100 %)
847 / 50 (94 %)

مع الحد الأدنى من البنية القابلة للحياة، تشغيل واحد من كل أربعة لا يصل أبدًا — يستقر في تشكيل لا يستطيع النزول منه، وهو بالضبط الحد الأدنى المحلي الذي عرضه الفصل 3 على سطح أحادي البعد. أضف وحدة واحدة فتختفي الإخفاقات تقريبًا، ليس لأن الشبكة صارت أكثر تعبيرًا (وحدتان تكفيان بالفعل — 38 تشغيلًا تثبت ذلك) بل لأن الأبعاد الإضافية تمنح النزول اتجاهات أكثر للهرب عبرها.

ثم إن ثماني وحدات تؤدي أداءً أسوأ قليلًا من أربع. عند معدل تعلم وميزانية خطوات ثابتين، ليست السعة الأكبر أفضل رتيبًا. من يقول لك إن إصلاح شبكة عالقة هو دائمًا شبكة أكبر يستنتج من منتصف ذلك الجدول.

هذا هو الدرس نفسه مثل مبرهنة التقارب في الفصل 1، وسيكون الدرس نفسه في الفصل 10 عن قوانين scaling، بالصيغة التي يعطيها ذلك الفصل: توقع loss ليس توقعًا للقدرة التي تدفع مقابلها، والمسافة بين الاثنين هي حيث تعيش الهندسة.

عرض التفاصيل

اختياري: الصيغة المصفوفية، ولماذا لا يستخدمها الكود أعلاه.

كُتب كل شيء هنا scalar واحدًا في كل مرة، وهو أوضح طريق لرؤية الآلية وأبطأ طريق لتنفيذها. عمليًا، الطبقة هي ضرب مصفوفات، والمرور الخلفي لـ 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}}

عمليات النقل transposes ليست خدعة للحفظ؛ إنها شكل قاعدة الجمع عبر المسارات عندما تُفهرس المسارات بعناصر المصفوفة. الكائن العام هو Jacobian، مصفوفة كل المشتقات الجزئية لكل المخرجات بالنسبة إلى كل المدخلات، والنمط العكسي هو تحديدًا حساب vector-Jacobian product من دون تكوين Jacobian أبدًا — وهذا مهم، لأنه لطبقة فيها 4096 دخلًا و4096 خرجًا تضم تلك المصفوفة ستة عشر مليون عنصر ولا تستحق البناء أبدًا.

لا تحتاج إلى أي من هذا لمتابعة الفصول التالية؛ النسخة scalar تفعل كل ما تفعله النسخة المصفوفية، لكن ببطء أكبر. يصبح ذلك ضروريًا في الفصل 9، حيث تتوقف الأشكال عن كونها بديهية.

لديك الآن شبكة تتدرب. هذا إنجاز أصغر مما يبدو، لأن الشبكة التي لديك تتدرب على أربعة أمثلة وتُقاس على الأربعة نفسها.

شغّل الكود نفسه على dataset حقيقية وستظهر مجموعة جديدة من المشكلات، لا يتعلق أي منها بـ gradients. تنخفض loss لفترة ثم تتوقف. أو تنخفض على بيانات التدريب وترتفع على كل ما عداها. أو لا تتحرك إطلاقًا من الخطوة الأولى، ويتبين أن السبب هو مدى الأوزان العشوائية الأولية. أو انجرف دخل وحدة ما إلى السالب في كل مثال في epoch الثالثة وبقيت ميتة منذ ذلك الحين، بصمت، آخذة معها chunk من سعة النموذج.

هذه ليست إخفاقات غريبة؛ إنها الحالة الطبيعية لشبكة كُتبت للتو، ولا يعلن أي منها عن نفسه. gradient صحيح — وقد قارنته مع PyTorch حتى ست عشرة منزلة عشرية — ومع ذلك لا يتعلم النموذج.

الفصل 6 عن ذلك: التهيئة، وnormalisation، و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) هو مرجع المجال كله، بما في ذلك مفاضلة الأمامي/العكسي التي نوقشت أعلاه.

  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). الورقة التي جعلت الطريقة معروفة، ومصدر قراءة الوحدات المخفية كـ representations متعلمة، وهي القراءة التي ينفق عليها قسم ما الذي فعلته الطبقة المخفية في هذا الفصل قياساته.

  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.

هل أنت مستعد لتترك الاختيار لـ LIA؟

ابنِ بكل نماذج الذكاء الاصطناعي في مكان واحد — ابدأ مجانًا اليوم.