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

الوحدة 6 — GRU: تبسيط فعّال

طوَت LSTM سبع عشرة سنة من الهيمنة على المتتاليات قبل أن تُقتَرَح GRU (Gated Recurrent Unit) عام 2014 من فريق يوشوا بنجيو. الفكرة كانت جريئة: هل تحتاج فعلًا إلى ثلاث بوّابات ومسارين للحالة، أم تكفي بنية أخفّ؟ نصف عقد من التجارب أعطى الجواب: تكفي في معظم الحالات.

البنية المُبسَّطة

تتخلّى GRU عن حالة الخلية المستقلّة: حالة مخفية واحدة hth_t تُدير الذاكرة الطويلة والمُخرَج معًا. وتحتفظ ببوّابتين لا ثلاث:

  • بوّابة التحديث ztz_t: تُقرّر إلى أيّ حدّ نستبدل الحالة القديمة بمرشّح جديد
  • بوّابة الإرجاع rtr_t: تُقرّر إلى أيّ حدّ ننسى الحالة القديمة قبل حساب المرشّح
zt=σ(Wz[ht1,xt]),rt=σ(Wr[ht1,xt])z_t = \sigma\left(W_z\,[h_{t-1}, x_t]\right), \qquad r_t = \sigma\left(W_r\,[h_{t-1}, x_t]\right) h~t=tanh(Wh[rtht1,  xt])\tilde{h}_t = \tanh\left(W_h\,[\,r_t \odot h_{t-1},\; x_t\,]\right) ht=(1zt)ht1+zth~th_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t

التركيبة الأخيرة عبقرية في بساطتها: مجموع محدَّب بين الحالة السابقة والمرشّح الجديد. إن كانت zt0z_t \to 0 فالحالة تعبر بلا تعديل (ht=ht1h_t = h_{t-1})، والتدرّج يمرّ. إن كانت zt1z_t \to 1 فالحالة تُعاد كتابتها بالمرشّح الجديد. الوسيط zz يُغذّي الجزءين المتكاملين معًا، وهذا التقييد بالضبط هو ما يوفّر البوّابة الثالثة.

التدرّج، لماذا يمرّ

مشتقّة الحالة تعطي مباشرة:

htht1(1zt)+مساهمة عبر h~t\frac{\partial h_t}{\partial h_{t-1}} \approx (1 - z_t) + \text{مساهمة عبر } \tilde{h}_t

المصطلح الأوّل (1zt)(1 - z_t) هو مسار مباشر، مثله مثل ftf_t في LSTM. حين تختار الشبكة أن تُبقي حالتها (zt0z_t \approx 0)، ينتقل التدرّج بمعامل قريب من 1. حلّ التدرّج المتلاشي يبقى صحيحًا بالمبدأ نفسه.

مقارنة بوسائط متكافئة

مع نفس FF وHH، عدد وسائط GRU:

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

مقابل 4H(F+H+1)4H(F+H+1) لـLSTM. الفارق 25% ذاكرةً وحسابًا. على الفيل الأحمر مع F=2F = 2 وH=64H = 64، هذا يعني 12 864 وسيطًا لـGRU مقابل 17 152 لـLSTM.

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

اختبار عملي على الاستهلاك

نضع النموذجين وجهًا لوجه على نفس نافذة المُدخَل (168 ساعة) والهدف (24 ساعة قادمة) والميزانية التقريبية.

import torch
import torch.nn as nn

class Predicteur(nn.Module):
def __init__(self, cellule, n_vars=2, hidden=64, horizon=24):
super().__init__()
self.rnn = cellule(n_vars, hidden, batch_first=True)
self.tete = nn.Linear(hidden, horizon)

def forward(self, x):
sortie = self.rnn(x)
# LSTM يعيد (sorties, (h, c)) بينما GRU يعيد (sorties, h)
h_T = sortie[1][0] if isinstance(sortie[1], tuple) else sortie[1]
return self.tete(h_T.squeeze(0))

lstm = Predicteur(nn.LSTM)
gru = Predicteur(nn.GRU)

print("LSTM:", sum(p.numel() for p in lstm.parameters()))
print("GRU :", sum(p.numel() for p in gru.parameters()))
# LSTM: 18 632
# GRU : 14 296

بعد التدريب على 500 دفعة، على مجموعة تحقّق من ثلاثة أشهر:

النموذجMAE (kWh)زمن حقبةوسائط
Persistence (نسخ أمس)6.80
RNN بسيط5.912 ث4 353
GRU3.418 ث14 296
LSTM3.322 ث18 632

الفارق بين GRU وLSTM هامشيّ (0.1 kWh)، بينما GRU أسرع بـ20%. القاعدة العامّة تتأكّد: جرِّب GRU أوّلًا، لا تنتقل إلى LSTM إلّا إن رأيت فرقًا ملموسًا على تحقّقك.

متى نُفضِّل LSTM رغم ذلك

هناك ثلاث حالات تُبرِّر LSTM على GRU:

  • متتاليات طويلة جدًّا (آلاف الخطوات): حالة الخلية المستقلّة تُوفِّر ذاكرة أكثر متانة على أفق بعيد
  • مهام لغوية معقّدة: أدبيات معالجة اللغة قبل عصر المحوّلات تعطي LSTM أفضلية طفيفة على مهام دقيقة (توسيم أدوار دلالية، ترجمة)
  • حين يكون النموذج جزءًا من معمارية مُتّبعة: بعض الأنابيب الجاهزة (بعض معالجات الكلام مثلًا) مضبوطة على LSTM ونقلها إلى GRU يستلزم إعادة تدريب

في كلّ الحالات الأخرى — التنبّؤ بالسلاسل الزمنية، التصنيف على متتاليات قصيرة، المكوّنات التكرارية داخل معمارية أوسع — GRU خيار افتراضي أفضل.

nn.GRU في PyTorch

الاستعمال متطابق مع nn.RNN، لكن مع الحالة المخفية الوحيدة (لا زوج (h, c)):

gru = nn.GRU(input_size=2, hidden_size=64, num_layers=2, dropout=0.3, batch_first=True)

X = torch.randn(32, 168, 2)
sorties, h_T = gru(X)

print(sorties.shape) # torch.Size([32, 168, 64]) - كل الخطوات
print(h_T.shape) # torch.Size([2, 32, 64]) - حالة نهاية كل طبقة

الوسيط num_layers=2 يُنشئ طبقتَي GRU متراكبتَين، والوسيط dropout=0.3 يُطبّق إسقاطًا بين الطبقتين (لا بين الخطوات، وهو ما ينتظره كثيرون خطأً). الإسقاط عبر الزمن — dropout المتكرِّر — موضوع الوحدة القادمة.

قاعدة قرار بسيطة

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

في الخلاصة

  • تجمع GRU حالة مخفية واحدة وبوّابتين (zz للتحديث، rr للإرجاع)، مع مجموع محدَّب ht=(1zt)ht1+zth~th_t = (1-z_t) h_{t-1} + z_t \tilde{h}_t يُتيح المسار المباشر للتدرّج.
  • عدد وسائطها 3H(F+H+1)3H(F+H+1)، أخفّ من LSTM بـ25% مع أداء متكافئ إحصائيًا في معظم المهام.
  • على الفيل الأحمر، GRU وLSTM يتقاربان (3.4 و3.3 kWh)، ويتقدّمان بوضوح على RNN البسيط والمرجعية الساذجة.
  • افتراضيًا جرّب GRU أوّلًا؛ اللجوء إلى LSTM حين تكون التبعيات طويلة جدًّا، أو المهمّة لغوية دقيقة، أو المعمارية موروثة.

الوحدة التالية: كيف نجعل الشبكة تنظر إلى ما وراء الخطوة الحالية بالاتّجاهين، ومتى يمنعك التنبّؤ من ذلك.