الوحدة 3 — الانتباه متعدّد الرؤوس
انتهت الوحدة السابقة إلى انتباه ذي رأس واحدة: يستقبل تسلسلًا، ويُنتج تمثيلًا جديدًا لكلّ جتون بالنظر إلى الآخرين. يعمل هذا الترتيب، لكنّه يعاني قيدًا ضمنيًّا: رأس واحدة تلتقط علاقة واحدة. رأس متخصّص في المطابقة النحوية لا يستطيع، في الوقت ذاته، أن يلتقط الاقتران الدلالي البعيد. والاختيار بين هذَين قرار خاسر.
الحلّ الذي طرحه Transformer ليس أن نُوسّع الرأس، بل أن نُشغّل عدّة رؤوس على التوازي بأبعاد أصغر لكلّ منها، ثمّ نُدمج مخرجاتها. هذا ما تُسمّيه الورقة الانتباه متعدّد الرؤوس، وهو ما سنبنيه في هذه الوحدة كلبنة ثانية في المشروع الموجّه.
الفكرة الحسابية: تقسيم البُعد لا زيادته
الحلّ الساذج سيكون تشغيل رأسَين بأبعاد لكلّ منهما. لكنّ ذلك يُضاعف كلفة المصفوفات بعدد الرؤوس، ويفجّر الحسابات. الاختيار الأنيق للمعمارية أن نُقسّم البُعد الأصلي:
حيث عدد الرؤوس. مثال قياسي: و، فتصبح لكلّ رأس . المخرجات جميعها لها مقاس ؛ يُصفّ الجدع بينها فيُنتج متّجهًا واحدًا مقاسه ، أي بالضبط من جديد. الكلفة الحسابية الإجمالية قريبة جدًّا من كلفة رأس واحدة بمقاس 512، لكنّ التنوّع مفتوح.
هذه الضربة الذكيّة تظهر جليّة في العدّ:
| كمّية | رأس واحدة، | ثمان رؤوس، |
|---|---|---|
| معاملات لرأس | ||
| الإجمالي على كلّ الرؤوس | ||
| إسقاط الإخراج |
الأعداد متطابقة، لكنّ الأولى تتعلّم علاقة واحدة، والثانية ثمانيًا. المشكلة الوحيدة المُنشأة إسقاط الإخرا ج الذي يخلط ما تعلّمته الرؤوس. لكن نموذج بغير هذا الإسقاط يبقى ثمانية رؤوس متجاورة لا تلتقي، وهو ما يُفسد الغاية.
ما تتعلّمه الرؤوس عمليًّا
الأنماط التي تُظهرها خرائط الانتباه المُدرَّبة معروفة الآن بفضل أعمال Clark وآخرين على BERT (2019). لكنّها ليست مبرمجة: بل تنبثق من التدريب.
- رأس التركيب النحوي: تُنتج وزنًا عاليًا بين الفعل وفاعله، أو بين الاسم وصفته.
- رأس مرجع الضمير: تُنشئ رابطًا بين ضمير و مرجعه في الجملة السابقة.
- رأس الجتون الحالي: تُركّز على الجتون نفسه، فتُشبه هوية طفيفة.
- رأس الجتون التالي: تنقل معلومة الجتون التالي إلى الحالي، مما يُشبه شبكة تكرارية مصغّرة.
- رؤوس بلا تفسير مبسّط: أغلبها. النموذج يوزّع مهامًا لا يمكن دائمًا تسميتها بلغة نحويّة.
هذا التنوّع هو الرد على السؤال «لماذا لا نكتفي برأس؟»: لأنّ اللغة تحوي عدّة علاقات متزامنة، وتخصيص رأس لكلّ نوع منها موازٍ يجعل الطبقة تُلخّص جانبًا أكثر ثراءً من التسلسل.
الحساب مصفوفيًّا
لنكتب الصيغة الكاملة. من أجل رأس :
ثمّ نجمع الرؤوس ونُطبّق الإسقاط النهائي:
هذه الصياغة عزيزة على القلب، لكنّ التنفيذ الفعّال يتحاشى الحلقة على الرؤوس. الحيلة أن نُنشئ بأبعاد ، ثمّ نُعيد تشكيلها إلى ونُبدّل المحاور لتصبح . عندئذ يُطبَّق ضرب المصفوفات على البُعدَين الأخيرين، وكلّ الرؤوس تُحسَب في ضربة واحدة.
التنفيذ ببايتورش
هذه هي الكتلة الثانية من مشروعنا الموجّه. سنمتنع عمدًا عن nn.MultiheadAttention لنُبيّن الميكانيك بالكامل، لكنّه ما يقوم به بايتورش خلف الكواليس:
import torch
import torch.nn as nn
import torch.nn.functional as F
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int):
super().__init__()
assert d_model % num_heads == 0, "d_model يجب أن يقبل القسمة على num_heads"
self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads
self.W_q = nn.Linear(d_model, d_model, bias=False)
self.W_k = nn.Linear(d_model, d_model, bias=False)
self.W_v = nn.Linear(d_model, d_model, bias=False)
self.W_o = nn.Linear(d_model, d_model, bias=False)
def _split(self, x: torch.Tensor) -> torch.Tensor:
# (B, n, d_model) -> (B, h, n, d_k)
B, n, _ = x.shape
return x.view(B, n, self.num_heads, self.d_k).transpose(1, 2)
def _merge(self, x: torch.Tensor) -> torch.Tensor:
# (B, h, n, d_k) -> (B, n, d_model)
B, h, n, d_k = x.shape
return x.transpose(1, 2).contiguous().view(B, n, h * d_k)
def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor,
mask: torch.Tensor | None = None) -> torch.Tensor:
Q = self._split(self.W_q(q))
K = self._split(self.W_k(k))
V = self._split(self.W_v(v))
scores = Q @ K.transpose(-2, -1) / (self.d_k ** 0.5) # (B, h, n, n)
if mask is not None:
scores = scores.masked_fill(mask == 0, float("-inf"))
weights = F.softmax(scores, dim=-1)
context = weights @ V # (B, h, n, d_k)
return self.W_o(self._merge(context))
if __name__ == "__main__":
torch.manual_seed(0)
mha = MultiHeadAttention(d_model=64, num_heads=8)
x = torch.randn(2, 10, 64) # حزمة 2، 10 جتونات، بُعد 64
y = mha(x, x, x) # انتباه ذاتي
print(y.shape) # torch.Size([2, 10, 64])
انتبه إلى ثلاث نقاط عمليّة. الأولى، أنّنا استقبلنا منفصلين، لأنّ الانتباه المتقاطع (وحدة 8) سيمرّر منفصلين أيضًا. أمّا في الانتباه الذاتي فنُمرّر نفس ثلاث مرّات. الثانية، أنّ القناع (وحدة 7) يُطبَّق على الدرجات لا على الأوزان، لأنّ تطبيقه بعد softmax لا يُعيد التوزيع إلى مجموع 1. الثالثة، أنّ contiguous() قبل view ضرورية بعد transpose، وإلّا رفعت view استثناء لأنّ الذاكرة لم تعد متعاقبة.
الكلفة في المعاملات وفي الحوسبة
لنُقيّم ما نُدفعه ثمنًا للتعدّد:
- المعاملات: أربع مصفوفات ، أي . من أجل : مليون معامل لكلّ كتلة انتباه.
- الحوسبة: تقريبًا أساسًا بسبب حساب الدرجات . سنعود إلى هذه الكلفة التربيعية في الوحدة 9 لأنّها العائق الأكبر أمام السياقات الطويلة.
- الذاكرة: مصفوفة الأوزان هي المستهلك الرئيسي. حزمة 32، ثمانية رؤوس، 1024 جتون تستهلك وحدها 32 × 8 × 1024 × 1024 × 4 بايت جيغابايت في float32.
مضاعفة الرؤوس لا تُحسّن الأداء تلقائيًّا. في الأوراق الجادّة يُلاحَظ أنّ أكثر من ثمانية إلى ستّة عشر رأسًا يبدأ في تفتيت الأبعاد لدرجة يفشل معها كلّ رأس في التقاط شيء ذي معنى. وتظهر أوراق تشذيب (pruning) تجرّبت حذف حتى نصف الرؤوس المدرَّبة دون خسارة تُذكر، بل أحيانًا مع تحسّن طفيف. القاعدة العملية: اقتَنِ من مجموعة حسب ، ولا تُضاعف دون قياس.