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

الوحدة 5 — LSTM: بوّابات النسيان والإدخال والإخراج

اقترحت الوحدة السابقة أنّ حلّ التدرّج المتلاشي معماريّ. LSTM (اختصار Long Short-Term Memory)، التي اقترحها هوخرايتر وشميدهوبر عام 1997، هي هذا الحلّ: تُضيف مسارًا جمعيًا مباشرًا يعبره التدرّج بلا ترشيح متكرّر، وتُدير هذا المسار بثلاث بوّابات تُقرِّر ماذا نُبقي، ماذا نستقبل، وماذا نُخرِج.

حالة خلية وحالة مخفية: قلب المعمارية

الفرق الأوّل مع RNN البسيط أنّ LSTM تحمل حالتين لا واحدة عند كلّ خطوة زمنية:

  • حالة الخلية ctc_t: خطّ الذاكرة الطويلة، تنتقل من خطوة إلى أخرى بجَمْع بلا تنشيط
  • الحالة المخفية hth_t: المُخرَج القابل للاستعمال، وهي أيضًا ما يُطعَم للخطوة التالية

على الفيل الأحمر، تحمل ctc_t ما يشبه «متوسّط سعري نشاط للأيّام الماضية»، بينما hth_t هي «تلخيص للحظة الحالية للاستهلاك». الأولى تتراكم ببطء، والثانية تتقلّب مع كلّ ساعة.

البوّابات الثلاث

كلّ بوّابة سيغمويدية تُنتج قيمة بين 0 و1 لكلّ عصبون، وتعمل بمثابة مضخّم انتقائي: 0 يُطفئ، 1 يُمرِّر بلا تعديل. تحسب البوّابات من مُدخَل الخطوة xtx_t ومن الحالة المخفية السابقة ht1h_{t-1}، بأوزان مستقلّة لكلّ منها.

بوّابة النسيان ftf_t تُقرّر ما نمحوه من الذاكرة الطويلة:

ft=σ(Wf[ht1,xt]+bf)f_t = \sigma\left(W_f\,[h_{t-1}, x_t] + b_f\right)

بوّابة الإدخال iti_t تُقرِّر ماذا نستقبل من المرشّح الجديد:

it=σ(Wi[ht1,xt]+bi),c~t=tanh(Wc[ht1,xt]+bc)i_t = \sigma\left(W_i\,[h_{t-1}, x_t] + b_i\right), \qquad \tilde{c}_t = \tanh\left(W_c\,[h_{t-1}, x_t] + b_c\right)

تحديث حالة الخلية يجمع الاثنين:

ct=ftct1+itc~tc_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t

هذه الصيغة الأخيرة هي قلب LSTM. الجمع، لا الضرب، هو ما يجعل التدرّج قادرًا على العبور. إن قرّبت ft1f_t \to 1 وit0i_t \to 0 عند خطوة ما، فحالة الخلية تعبر بلا تعديل: ct=ct1c_t = c_{t-1}. مسارٌ مباشر، ومشتقّة تساوي 1 بالضبط.

بوّابة الإخراج oto_t تُقرّر ما تُخرجه إلى الخطوة التالية والدنيا:

ot=σ(Wo[ht1,xt]+bo),ht=ottanh(ct)o_t = \sigma\left(W_o\,[h_{t-1}, x_t] + b_o\right), \qquad h_t = o_t \odot \tanh(c_t)

الحالة المخفية hth_t هي إذن نسخة مرشَّحة ومُنشَّطة من حالة الخلية. تُتاح لتُستهلَك (طبقة إخراج، أو طبقة LSTM ثانية)، بينما تبقى ctc_t مصونة كذاكرة طويلة.

لماذا يعبر التدرّج

الصيغة الجوهرية ct=ftct1+itc~tc_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t تُعطي:

ctct1=ft\frac{\partial c_t}{\partial c_{t-1}} = f_t

فإن كانت ftf_t قريبة من 1 عبر خطوات كثيرة، جداء ft\prod f_t يبقى قريبًا من 1، والتدرّج يمرّ. مقارنة بـtanh(z)1\tanh'(z) \le 1 في RNN البسيط الذي يتلاشى دائمًا، هذا فرق ضخم: الشبكة تختار متى تدع التدرّج يمرّ.

هذا يفسّر أيضًا اصطلاحًا معماريًا: تُهيَّأ بوّابة النسيان بانحياز موجب (bf=1b_f = 1 في العادة)، فتبدأ الشبكة في حالة «تذكّري كلّ شيء»؛ ثمّ تتعلّم متى تنسى. لو بدأت في حالة ft0.5f_t \approx 0.5 لضاع التدرّج قبل أن تُتاح لها فرصة التعلّم.

استعمال LSTM في PyTorch

الواجهة نفسها التي في nn.RNN، مع فارق واحد جوهري: تنتقل الآن حالة مزدوجة (h, c).

import torch
import torch.nn as nn

class PredicteurJournee(nn.Module):
"""يقرأ أسبوعا من الاستهلاك وينبأ الأربع والعشرين ساعة القادمة."""
def __init__(self, n_vars=2, hidden=64, horizon=24):
super().__init__()
self.lstm = nn.LSTM(n_vars, hidden, batch_first=True)
self.tete = nn.Linear(hidden, horizon)

def forward(self, x): # x: (N, 168, n_vars)
_, (h_T, c_T) = self.lstm(x) # نأخذ الحالة النهائية فقط
h_T = h_T.squeeze(0) # (N, hidden)
return self.tete(h_T) # (N, 24)

modele = PredicteurJournee()
print(sum(p.numel() for p in modele.parameters())) # 18 632 وسيطًا

يجب ملاحظة أنّ الحالة النهائية عبارة عن زوج (h_T, c_T)، وأنّ كليهما بشكل (num_layers, N, hidden). نسيان squeeze(0) أحد أشيع أخطاء البدء.

عدد الوسائط، كلفة LSTM

لكلّ بوّابة أوزانها. أربع «كتل» (البوّابات الثلاث ومرشّح الخلية)، لكلٍّ منها H(F+H)+HH(F + H) + H:

وسائط LSTM=4H(F+H+1)\text{وسائط LSTM} = 4 H (F + H + 1)

مع F=2F = 2 وH=64H = 64، هذا 4×64×67=171524 \times 64 \times 67 = 17\,152 وسيطًا، أربع أضعاف RNN بنفس الأبعاد. الكلفة الحسابية تتضاعف بالمقدار نفسه. لهذا لا يُختار LSTM إلّا حين تكون التبعيات الطويلة حقيقية؛ على متتاليات قصيرة تُنافس شبكة كثيفة مُهندَسة جيّدًا أو التفافية أحادية البعد.

nn.LSTM أمام nn.LSTMCell

nn.LSTM معالج مُحسَّن يُطبَّق على كامل المتتالية في استدعاء واحد ويستفيد من التوازي على المعالج الرسومي. nn.LSTMCell يُنفّذ خطوة واحدة، وينبغي أن يُلَفَّ بحلقة for.

استعمل LSTM في التدريب العادي؛ استعمل LSTMCell حين تحتاج إلى حقن تدخّل بين خطوة وأخرى (فاكّ ترميز مع اختيار جشِع في الوحدة 8، أو إعادة تسمين متتالية على أساس تنبّؤ سابق). الاستقرار العددي هو نفسه، والفرق حسابيّ فقط.

استعمال طبقة Dropout بعد LSTM لا يفعل ما تظنّه

كتابة nn.Sequential(nn.LSTM(...), nn.Dropout(0.3), nn.Linear(...)) تخطئ بصمت. الإسقاط يُطبَّق على شكل غير متوقّع، لا على مُخرَج LSTM بمعناه المفيد. الحلّ الصحيح استعمال الوسيط dropout= داخل nn.LSTM (على طبقاتها الداخلية)، ووضع طبقة Dropout بعد الاختزال إلى (N, hidden). سنعود إلى هذا في الوحدة 7 مع الشبكات المتراكبة.

في الخلاصة

  • تحمل LSTM حالتين: خلية ctc_t للذاكرة الطويلة تنقل جمعيًا، ومخفية hth_t للمُخرَج، مع ثلاث بوّابات سيغمويدية تُقرِّر ما نُبقي وما نستقبل وما نُخرِج.
  • الصيغة ct=ftct1+itc~tc_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t هي الجواب على تلاشي التدرّج: مسار جمعي مباشر بمشتقّة ftf_t، لا tanh\tanh'.
  • تُهيَّأ بوّابة النسيان بانحياز موجب حتى تبدأ الشبكة في حالة «تذكّري»، والتقاسم النموذجي في PyTorch يعطي (h_T, c_T) بشكل (num_layers, N, hidden).
  • عدد الوسائط 4H(F+H+1)4H(F+H+1) أربعة أضعاف RNN؛ يُختار LSTM حين تكون التبعيات الطويلة موجودة فعلًا، وإلّا فبديل أخفّ يكفي.

الوحدة التالية: GRU، تبسيط عام 2014 يحذف بوّابة ويدمج حالتين، ويُنافس LSTM بأداء متكافئ وميزانية أقلّ.