انتقل إلى المحتوى الرئيسي

الوحدة 3 — الانتشار الخلفي عبر الزمن

عرفنا في الوحدة السابقة كيف تحسب الشبكة التكرارية مُخرَجاتها. تُجيب هذه الوحدة عن السؤال الذي يليه مباشرة: كيف تتعلّم؟ الجواب هو الانتشار الخلفي عبر الزمن، وهو ليس خوارزمية جديدة بل تطبيق دقيق لقاعدة السلسلة على الشبكة المدفوعة في الزمن.

الحساب الذي نُشتقّه

بعد المرور الأمامي على نافذة من 168 ساعة من الاستهلاك الكهربائي، صار لنا حالة أخيرة hTh_T ومُخرَج y^\hat{y} وخسارة L\mathcal{L}. المطلوب هو تدرّج L\mathcal{L} بالنسبة إلى الأوزان المشتركة WxW_x وWhW_h وbb. الحالة أنّ كلّ وزن يظهر في كلّ خطوة زمنية، فمساهمته في الخسارة تمرّ عبر كلّ حالة مخفية من h1h_1 إلى hTh_T.

قاعدة السلسلة تُعطي مباشرة:

LWh=t=1TLhthtWh\frac{\partial \mathcal{L}}{\partial W_h} = \sum_{t=1}^{T} \frac{\partial \mathcal{L}}{\partial h_t} \cdot \frac{\partial h_t}{\partial W_h}

المصطلح الحقيقي يكمن في كيفية حساب L/ht\partial \mathcal{L}/\partial h_t: فمشتقّة الخسارة بالنسبة إلى الحالة عند الخطوة tt ليست محلّية، بل تعتمد على كلّ ما يحصل بعدها. كلّ حالة تُغذّي التالية، فيصعد الخطأ من hTh_T إلى h1h_1 في مسيرة عكسية، تشبه تمامًا مسيرة الانتشار الخلفي في شبكة كثيفة عميقة، مع فارق واحد: الطبقات هي نسخ زمنية من الوحدة نفسها.

المرور الخلفي، صعودًا في الزمن

يبدأ الحساب من الطبقة الأخيرة، أي من hTh_T. إذا لم يكن هناك مُخرَج إلّا في النهاية، فالخطأ الأوّلي:

δT=LhT\delta_T = \frac{\partial \mathcal{L}}{\partial h_T}

ثم نصعد خطوة بخطوة:

δt=(Whδt+1)f(zt+1)\delta_{t} = \left(W_h^{\top} \delta_{t+1}\right) \odot f'\left(z_{t+1}\right)

هذه هي بالضبط صيغة الانتشار الخلفي في الوحدة 4 من الدورة 7، منقولة من محور «الطبقات» إلى محور «الزمن». عمليتان لكلّ خطوة: إعادة توزيع بالمنقول WhW_h^{\top}، ثم ترشيح بمشتقّة التنشيط. جداء طويل من العوامل يتراكم كلّما تباعد tt عن TT، وهذا الجداء بالضبط هو ما سيُنتج تلاشي التدرّج أو انفجاره في الوحدة القادمة.

بمجرّد الحصول على δt\delta_t لكلّ خطوة، تُجمَع مساهمات كلّ خطوة في التدرّج النهائي:

LWh=t=1Tδtht1,LWx=t=1Tδtxt\frac{\partial \mathcal{L}}{\partial W_h} = \sum_{t=1}^{T} \delta_t \, h_{t-1}^{\top}, \qquad \frac{\partial \mathcal{L}}{\partial W_x} = \sum_{t=1}^{T} \delta_t \, x_t^{\top}

الملاحظة العملية: نحتاج كلّ حالة مخفية سابقة ht1h_{t-1} لحساب تدرّج WhW_h، وكلّ مُدخَل xtx_t لتدرّج WxW_x. لا يمكن تجاهلها بعد الاستعمال. من هنا تنبع كلفة الذاكرة.

كلفة الذاكرة، الحاجز الأوّل

loss.backward() على شبكة تكرارية يخزّن كلّ حالة وسيطة من المرور الأمامي. لدفعة من NN متتالية بطول TT وحجم مخفية HH، هذا يعني:

ذاكرةNTH4 بايت\text{ذاكرة} \sim N \cdot T \cdot H \cdot 4 \text{ بايت}

مع N=32N=32 وT=168T=168 وH=64H=64، هذا نحو 1.4 ميغابايت لطبقة تكرارية واحدة، معقول. لكن مع T=8000T=8000 (تسجيل صوتي طويل) وH=512H=512 وثلاث طبقات LSTM (بأربع حالات لكلّ منها)، الرقم يقفز إلى غيغابايتات، وتصير ذاكرة المعالج الرسومي عنق زجاجة.

هذا يفسّر ثلاث ملاحظات ستراها في كلّ كود إنتاجي:

  • حجم دفعة أصغر على المتتاليات الطويلة، بينما الشبكات الكثيفة تعمل مع دفعات كبيرة
  • حجم مخفية معتدل على LSTM: نادرًا ما يتجاوز 512
  • متتاليات مقطّعة إذا كانت طبيعية طويلة جدًّا، وهو موضوع الفقرة التالية

التقطيع الزمني: تقريب لا غنى عنه

على متتاليات طويلة جدًّا — كتاب كامل، أو أشهر من قراءات جهاز — الانتشار الخلفي الكامل عبر الزمن غير قابل للتطبيق. الحلّ الأشيع اسمه BPTT مقطّع: نُبقي المرور الأمامي كاملًا لكنّ الانتشار الخلفي يُقصر على نافذة من kk خطوة أخيرة.

المكافئ في PyTorch يمرّ عبر detach()، الذي يفصل موتِّرًا عن مخطّطه الحسابي:

h = torch.zeros(1, N, hidden)
perte_totale = 0.0

for morceau in decouper_en_morceaux(sequence_longue, taille=200):
# المرور الأمامي على المقطع، بحالة موروثة من المقطع السابق.
sorties, h = rnn(morceau, h)
perte = critere(sorties, cibles_de_morceau)

perte.backward() # التدرج لا يعبر ما قبل بداية المقطع
optimiseur.step()
optimiseur.zero_grad()

h = h.detach() # نقطع المخطط الحسابي هنا
perte_totale += perte.item()

القرار الحاسم هو h.detach(). بدونه يبني PyTorch مخطّطًا يمتدّ على كامل السلسلة، وينفجر استهلاك الذاكرة في المقطع الثاني أو الثالث. مع detach()، تنتقل قيمة hh من مقطع إلى آخر لكن تدرّجها لا يعود إلى الوراء أبعد من بداية المقطع الحالي. هذا التقريب مقبول لأنّ التبعيات الطويلة يصعب على RNN البسيط تعلّمها أصلًا (الوحدة 4)، ولأنّ الحالة hh تنقل ما يكفي من الملخّص عبر المقاطع.

حين تكون هناك مُخرَجات وسيطة

لم نُلمِّح حتى الآن إلى شكل الخسارة إلّا مضمرًا. في مهام كثيرة، لا يوجد مُخرَج إلّا في النهاية (تصنيف جملة، تنبّؤ بالساعة القادمة انطلاقًا من الأسبوع). في مهام أخرى، لكلّ خطوة مُخرَج (توسيم دلالي لكلّ كلمة، تنبّؤ بالساعة القادمة عند كلّ ساعة تدريب). في الحالة الثانية:

L=t=1T(y^t,yt)\mathcal{L} = \sum_{t=1}^{T} \ell(\hat{y}_t, y_t)

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

حذار من الشبكة التي «تتذكّر» ما تدرّبت عليه فقط

حين تُرسل حالة مخفية من دفعة إلى أخرى دون detach() بحيلة ما — مثلًا في محاولة نمذجة رواية كاملة — فأنت تبني مخطّطًا حسابيًا يمتدّ آلاف الخطوات، وأول ما ستراه هو رسالة CUDA out of memory في الحقبة الأولى. ثاني ما تراه، لو نجحت في تجاوز الذاكرة، تدرّجات كارثية بسبب الطول. القاعدة عمليًا: حالة تنتقل بالقيمة، تدرّجات تنقطع عند حدود المقطع.

في الخلاصة

  • الانتشار الخلفي عبر الزمن BPTT ليس إلّا قاعدة السلسلة على الشبكة المدفوعة في الزمن؛ كلّ وزن يظهر في كلّ خطوة، فتُجمَع مساهماته على TT.
  • الصعود من hTh_T إلى h1h_1 يضرب WhW_h^{\top} ويُرشِّح بـff' عند كلّ خطوة؛ الجداء الطويل لهذه العوامل هو مصدر تدرّجات الوحدة القادمة.
  • كلفة الذاكرة تنمو مع NTHN \cdot T \cdot H، وهو ما يفرض دفعات أصغر وحجم مخفية معتدلًا على المتتاليات الطويلة.
  • BPTT المقطّع عبر detach() هو الحلّ العملي على السلاسل الطويلة جدًّا: حالة تنتقل بالقيمة، تدرّجات محدودة بطول المقطع.

الوحدة التالية: ما الذي يحدث بالضبط لهذا الجداء الطويل من العوامل، ولماذا يُلغي الشبكاتِ التكراريةَ البسيطةَ على أفق يتجاوز عشرين خطوة.