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

الوحدة 5 — الوصلات المتبقّية والتسوية الطبقية

نملك الآن كتلة انتباه متعدّدة الرؤوس (وحدة 3) وترميز موضع (وحدة 4). لو كوّمنا هذه الكتل عشر مرّات وبدأنا التدريب، لواجهنا فورًا مشكلتَين قديمتَين قدم الشبكات العميقة: تلاشي التدرّج واستقرار غير مأمون في التنشيطات. الحلّ لم يُخترع لأجل Transformer، لكنّه أعيد استعماله فيه بذكاء: الوصلات المتبقّية التي أدخلها ResNet في 2015، والتسوية الطبقية التي أدخلها Ba وKiros وHinton في السنة نفسها.

هذه الوحدة تشرح كيف يتغلّف الانتباه بهذين المكوّنين، وتوضّح الفارق بين النسختين الشائعتين — pre-norm وpost-norm — والذي أصبح افتراضًا مختلفًا في النماذج الحديثة.

الوصلة المتبقّية: طريق مفتوح للتدرّج

الفكرة بسيطة إلى حدّ الخداع. بدل تعلّم دالّة y=F(x)y = F(x)، تُتعلّم الفارق: y=x+F(x)y = x + F(x). فيصير التدرّج المُعاد نشره:

yx=1+Fx\frac{\partial y}{\partial x} = 1 + \frac{\partial F}{\partial x}

الحدّ 11 هو المفتاح. حتى لو تلاشى F/x\partial F / \partial x إلى صفر، لا يتلاشى التدرّج بالكامل: تبقى الوصلة المتبقّية طريقًا مباشرًا يمرّ فيه. في شبكة من 12 طبقة (BERT الأساسي) هذا التصميم يجعل التدرّج المنتشر من الطبقة الأخيرة إلى الأولى مرورًا بـ12 حدًّا يقود إلى مجموع لا إلى جداء، ويحفظ ما ينخفض إلى الصفر بضرب متعاقب.

في Transformer، تُغلَّف كلّ كتلة انتباه أو كتلة أمامية بوصلة متبقّية. الشكل العامّ:

y=x+SubLayer(x)y = x + \text{SubLayer}(x)

حيث SubLayer إمّا انتباه متعدّد الرؤوس، وإمّا شبكة أمامية. هذا التغليف يجعل من الممكن أن يتعلّم النموذج بلا خشية من انهيار التدرّج.

التسوية الطبقية: استقرار المقياس

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

الفارق الجوهري بين LayerNorm وBatchNorm يقع في محور التسوية:

كائنيسوّى على أيّ محورمعلومة استعمال
BatchNormعلى بُعد الحزمة، من أجل كلّ خاصّيةيعمل جيّدًا في الرؤية بحزم كبيرة، وينهار في اللغة بحزم صغيرة أو بأطوال متغيّرة
LayerNormعلى بُعد الخاصّية، من أجل كلّ عيّنةمستقلّ عن الحزمة، لذلك اختير في Transformer

في LayerNorm، من أجل متّجه xRdx \in \mathbb{R}^{d}:

LN(x)=γxμσ2+ϵ+β,μ=1dixi,σ2=1di(xiμ)2\text{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta, \quad \mu = \frac{1}{d}\sum_i x_i, \quad \sigma^2 = \frac{1}{d}\sum_i (x_i - \mu)^2

المعاملان γ\gamma وβ\beta قابلان للتعلّم، ويسمحان للنموذج بأن يُعطّل التسوية جزئيًّا إن كانت التسوية تضرّه. لكنّه لن يفعل في الغالب: التدريب يبقيها قائمة لأنّها تُخفّف انفجار التدرّجات ومسار التعلّم.

LayerNorm ليست BatchNorm بأبعاد مبدّلة

كثيرون يخلطون. BatchNorm يحسب إحصاءات على مجموع الحزمة، ثمّ يحفظ متوسطات جارية للاستدلال. LayerNorm يحسب إحصاءات على العيّنة نفسها ولا يحفظ متوسطات جارية على الإطلاق: التدريب والاستدلال يقومان بالحساب نفسه. ونسيان model.eval() يؤثّر على BatchNorm، ولا يؤثّر على LayerNorm.

Pre-norm مقابل Post-norm

الترتيب داخل الكتلة كان في الأصل:

y=LN(x+SubLayer(x))[post-norm]y = \text{LN}(x + \text{SubLayer}(x)) \quad \text{[post-norm]}

هذا ما اعتمدَته ورقة 2017 وBERT الأصلي. لكنّ التدريب على شبكات عميقة (24 طبقة وأكثر) كشف عن مشكلة: يجب استعمال دفعة تسخين طويلة لمعدّل التعلّم، وإلّا فشل التدريب في الحقبات الأولى.

الحلّ الذي انتشر لاحقًا (GPT-2 وT5 وLLaMA) هو التبديل:

y=x+SubLayer(LN(x))[pre-norm]y = x + \text{SubLayer}(\text{LN}(x)) \quad \text{[pre-norm]}

هنا التسوية تسبق الطبقة الفرعية، والوصلة المتبقّية غير مسوّاة. النتيجة أنّ التدرّج يمرّ عبر مسار نقي غير مسوّى، وأنّ التدريب يستقرّ من الحقبة الأولى دون تسخين طويل. الثمن نسبي: pre-norm يُصعّب بعض التحسينات النهائية، ويستلزم LayerNorm إضافية في نهاية الشبكة. لكنّه صار افتراضًا لكلّ نموذج جادّ منذ 2020.

الشبكة الأمامية

بين كلّ كتلتَي انتباه توجد كتلة تُسمّى الشبكة الأمامية (feed-forward network أو FFN). صيغتها البسيطة:

FFN(x)=W2σ(W1x+b1)+b2\text{FFN}(x) = W_2 \, \sigma(W_1 x + b_1) + b_2

حيث W1W_1 توسّع البُعد من dmodeld_{\text{model}} إلى dffd_{\text{ff}} (غالبًا 4dmodel4 d_{\text{model}})، وW2W_2 ترجعه، وσ\sigma دالّة تنشيط. في 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 يُخفَّض إلى 2,67×dmodel2{,}67 \times d_{\text{model}} لأنّ SwiGLU تُعطي أداءً مماثلًا بأقلّ معاملات.
  • اجعل dropout = 0.1 أثناء التدريب، صفرًا أثناء الاستدلال (يفرضه model.eval()). لا تنسَ التبديل.
  • استعمل nn.LayerNorm(d_model) القياسي بايتورش، وليس تنفيذًا يدويًّا: التطبيق المبني على CUDA أسرع بنسبة معقولة.
  • في نموذج بأكثر من 24 طبقة، pre-norm إلزامي. post-norm يتدرّب بصعوبة كبيرة على هذا العمق.

في الخلاصة

  • الوصلات المتبقّية y=x+F(x)y = x + F(x) تُبقي طريقًا مباشرًا للتدرّج، وتحفظ التدريب من التلاشي في الشبكات العميقة.
  • LayerNorm تُسوّى على بُعد الخاصّيات، مستقلّة عن الحزمة؛ وهي الاختيار الطبيعي في اللغة، على خلاف BatchNorm.
  • Pre-norm (LN داخل الوصلة، ثمّ الطبقة الفرعية) أصبح افتراض النماذج الحديثة لأنّه يُثبّت التدريب دون تسخين طويل.
  • الشبكة الأمامية بين كتل الانتباه تُطبَّق لكلّ جتون بمعزل؛ وتوسيع البُعد بمعامل 4 هو القاعدة، مع دالّة تنشيط GELU أو SwiGLU حسب النموذج.

الوحدة التالية: نُكوِّم عدّة كتل مرمِّز، ونستكشف عائلة BERT، ونرى كيف يجعل قناع الجتون منها آلة فهم شاملة للغة.