الوحدة 5 — الوصلات المتبقّية والتسوية الطبقية
نملك الآن كتلة انتباه متعدّدة الرؤوس (وحدة 3) وترميز موضع (وحدة 4). لو كوّمنا هذه الكتل عشر مرّات وبدأنا التدريب، لواجهنا فورًا مشكلتَين قديمتَين قدم الشبكات العميقة: تلاشي التدرّج واستقرار غير مأمون في التنشيطات. الحلّ لم يُخترع لأجل Transformer، لكنّه أعيد استعماله فيه بذكاء: الوصلات المتبقّية التي أدخلها ResNet في 2015، والتسوية الطبقية التي أدخلها Ba وKiros وHinton في السنة نفسها.
هذه الوحدة تشرح كيف يتغلّف الانتباه بهذين المكوّنين، وتوضّح الفارق بين النسختين الشائعتين — pre-norm وpost-norm — والذي أصبح افتراضًا مختلفًا في النماذج الحديثة.
الوصلة المتبقّية: طريق مفتوح للتدرّج
الفكرة بسيطة إلى حدّ الخداع. بدل تعلّم دالّة ، تُتعلّم الفارق: . فيصير التدرّج المُعاد نشره:
الحدّ هو المفتاح. حتى لو تلاشى إلى صفر، لا يتلاشى التدرّج بالكامل: تبقى الوصلة المتبقّية طريقًا مباشرًا يمرّ فيه. في شبكة من 12 طبقة (BERT الأساسي) هذا التصميم يجعل التدرّج المنتشر من الطبقة الأخيرة إلى الأولى مرورًا بـ12 حدًّا يقود إلى مجموع لا إلى جداء، ويحفظ ما ينخفض إلى الصفر بضرب متعاقب.
في Transformer، تُغلَّف كلّ كتلة انتباه أو كتلة أمامية بوصلة متبقّية. الشكل العامّ:
حيث SubLayer إمّا انتباه متعدّد الرؤوس، وإمّا شبكة أمامية. هذا التغليف يجعل من الممكن أن يتعلّم النموذج بلا خشية من انهيار التدرّج.
التسوية الطبقية: استقرار المقياس
المشكلة الثانية أخبث. بعد كلّ عمليّة (انتباه أو مصفوفة)، يمكن أن تتغيّر مقاييس التنشيطات: بعض القيم تنفجر، وأخرى تتقلّص. هذا التغيّر يجعل معدّل التعلّم غير مناسب لأنّه أُختير من أجل مقياس معيّن. الحلّ تسوية التنشيطات كي تحافظ على متوسط 0 وانحراف معياري 1، ثمّ ندع النموذج يستعيد المقياس إن احتاج.
الفارق الجوهري بين LayerNorm وBatchNorm يقع في محور التسوية:
| كائن | يسوّى على أيّ محور | معلومة استعمال |
|---|---|---|
| BatchNorm | على بُعد الحزمة، من أجل كلّ خاصّية | يعمل جيّدًا في الرؤية بحزم كبيرة، وينهار في اللغة بحزم صغيرة أو بأطوال متغيّرة |
| LayerNorm | على بُعد الخاصّية، من أجل كلّ عيّنة | مستقلّ عن الحزمة، لذلك اختير في Transformer |
في LayerNorm، من أجل متّجه :
المعاملان و قابلان للتعلّم، ويسمحان للنموذج بأن يُعطّل التسوية جزئيًّا إن كانت التسوية تضرّه. لكنّه لن يفعل في الغالب: التدريب يبقيها قائمة لأنّها تُخفّف انفجار التدرّجات ومسار التعلّم.
كثيرون يخلطون. BatchNorm يحسب إحصاءات على مجموع الحزمة، ثمّ يحفظ متوسطات جارية للاستدلال. LayerNorm يحسب إحصاءات على العيّنة نفسها ولا يحفظ متوسطات جارية على الإطلاق: التدريب والاستدلال يقومان بالحساب نفسه. ونسيان model.eval() يؤثّر على BatchNorm، ولا يؤثّر على LayerNorm.
Pre-norm مقابل Post-norm
الترتيب داخل الكتلة كان في الأصل:
هذا ما اعتمدَته ورقة 2017 وBERT الأصلي. لكنّ التدريب على شبكات عميقة (24 طبقة وأكثر) كشف عن مشكلة: يجب استعمال دفعة تسخين طويلة لمعدّل التعلّم، وإلّا فشل التدريب في الحقبات الأولى.
الحلّ الذي انتشر لاحقًا (GPT-2 وT5 وLLaMA) هو التبديل:
هنا التسوية تسبق الطبقة الفرعية، والوصلة المتبقّية غير مسوّاة. النتيجة أنّ التدرّج يمرّ عبر مسار نقي غير مسوّى، وأنّ التدريب يستقرّ من الحقبة الأولى دون تسخين طويل. الثمن نسبي: pre-norm يُصعّب بعض التحسينات النهائية، ويستلزم LayerNorm إضافية في نهاية الشبكة. لكنّه صار افتراضًا لكلّ نموذج جادّ منذ 2020.
الشبكة الأمامية
بين كلّ كتلتَي انتباه توجد كتلة تُسمّى الشبكة الأمامية (feed-forward network أو FFN). صيغتها البسيطة:
حيث توسّع البُعد من إلى (غالبًا )، و ترجعه، و دالّة تنشيط. في Vaswani كانت ReLU؛ وفي GPT-2 وBERT صارت GELU؛ وفي LLaMA صارت SwiGLU. الاختيار يُغيّر الأداء بضع نقاط مئوي ة.
هذه الشبكة الأمامية تعمل على كلّ جتون بمعزل عن الآخرين. لا تُخلط فيها المعلومات بين جتونات مختلفة (هذا دور الانتباه)، بل تُطبَّق نفس الدالّة على كلّ متّجه جتون. لهذا يُوصف Transformer أحيانًا بأنّه انتباه ثمّ MLP لكلّ جتون، ثمّ تكرار.
بناء ResidualBlock موحّدة
نُنشئ في المشروع الموجّه كتلة قابلة لإعادة الاستعمال تُغلّف أيّ طبقة فرعيّة بنمط pre-norm ووصلة متبقّية:
import torch
import torch.nn as nn
import torch.nn.functional as F
# نفترض MultiHeadAttention من الوحدة 3
class FeedForward(nn.Module):
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
super().__init__()
self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.fc2(self.dropout(F.gelu(self.fc1(x))))
class ResidualBlock(nn.Module):
"""
Pre-norm + وصلة متبقّية + إسقاط.
sublayer دالّة تستقبل x بعد التسوية وتُعيد تنسورًا بنفس المقاس.
"""
def __init__(self, d_model: int, dropout: float = 0.1):
super().__init__()
self.norm = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x: torch.Tensor, sublayer) -> torch.Tensor:
return x + self.dropout(sublayer(self.norm(x)))
class EncoderLayer(nn.Module):
def __init__(self, d_model: int, num_heads: int, d_ff: int, dropout: float = 0.1):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads)
self.ff = FeedForward(d_model, d_ff, dropout)
self.res1 = ResidualBlock(d_model, dropout)
self.res2 = ResidualBlock(d_model, dropout)
def forward(self, x, mask=None):
x = self.res1(x, lambda z: self.self_attn(z, z, z, mask=mask))
x = self.res2(x, self.ff)
return x
if __name__ == "__main__":
layer = EncoderLayer(d_model=64, num_heads=8, d_ff=256)
x = torch.randn(2, 10, 64)
y = layer(x)
print(y.shape) # torch.Size([2, 10, 64])
مع هذه الكتلة الرابعة في المشروع الموجّه، نملك كلّ ما يلزم لبناء المرمّز في الوحدة 6. لاحظ أنّ الإسقاط يُطبَّق على مخرج الطبقة الفرعيّة قبل الجمع بالوصلة المتبقّية، لا بعده. هذا اصطلاح Vaswani، ويُبقي مقياس الوصلة سليمًا.
قواعد لضبط الأداء
- ثبِّت
d_ff = 4 * d_modelمبدئيًّا. في LLaMA وMistral يُخفَّض إلى لأنّ SwiGLU تُعطي أداءً مماثلًا بأقلّ معاملات. - اجعل
dropout = 0.1أثناء التدريب، صفرًا أثناء الاستدلال (يفرضهmodel.eval()). لا تنسَ التبديل. - استعمل
nn.LayerNorm(d_model)القياسي بايتورش، وليس تنفيذًا يدويًّا: التطبيق المبني على CUDA أسرع بنسبة معقولة. - في نموذج بأكثر من 24 طبقة، pre-norm إلزامي. post-norm يتدرّب بصعوبة كبيرة على هذا العمق.