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

الوحدة 10 — تنفيذ كتلة Transformer كاملة

بعد تسع وحدات نظريّة وكتلًا مكتوبة بيدنا، وقت الجمع قد حان. هذه الوحدة تجمع كلّ ما بنيناه في نموذج seq2seq مكتمل، ثمّ تُدرّبه أوّلًا على مهمّة لعبة (النسخ المعكوس)، ثمّ على مهمّة أكثر واقعيّة: ترجمة تواريخ من صيغة إلى صيغة، مثلًا «3 مارس 2026» إلى «2026-03-03». وسنعرض خرائط الانتباه التي يتعلّمها لنرى، بأمّ العَين، ما تفعله الرؤوس.

المشروع الموجّه: تجميع نهائي

الكتل الجاهزة من الوحدات 2 إلى 8:

  • MultiHeadAttention (الوحدة 3)
  • SinusoidalPositionalEncoding (الوحدة 4)
  • FeedForward وResidualBlock (الوحدة 5)
  • EncoderLayer (الوحدة 5)
  • Seq2SeqDecoderLayer (الوحدة 8)
  • causal_mask (الوحدة 7)

نُركّبها في نموذج Transformer seq2seq كامل ونحفظه في ملفّ transformer.py. الكود بأكمله دون 200 سطر:

import math, torch, torch.nn as nn, torch.nn.functional as F

class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads):
super().__init__()
assert d_model % num_heads == 0
self.d_model, self.num_heads = d_model, 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):
B, n, _ = x.shape
return x.view(B, n, self.num_heads, self.d_k).transpose(1, 2)

def _merge(self, x):
B, h, n, d_k = x.shape
return x.transpose(1, 2).contiguous().view(B, n, h * d_k)

def forward(self, q, k, v, mask=None):
Q, K, V = self._split(self.W_q(q)), self._split(self.W_k(k)), self._split(self.W_v(v))
scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, float("-inf"))
weights = F.softmax(scores, dim=-1)
return self.W_o(self._merge(weights @ V)), weights


class SinusoidalPositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
super().__init__()
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float()
div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div)
pe[:, 1::2] = torch.cos(position * div)
self.register_buffer("pe", pe.unsqueeze(0))

def forward(self, x):
return x + self.pe[:, : x.size(1), :]


class FeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout):
super().__init__()
self.fc1, self.fc2 = nn.Linear(d_model, d_ff), nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)

def forward(self, x):
return self.fc2(self.dropout(F.gelu(self.fc1(x))))


class EncoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout):
super().__init__()
self.attn = MultiHeadAttention(d_model, num_heads)
self.ff = FeedForward(d_model, d_ff, dropout)
self.n1, self.n2 = nn.LayerNorm(d_model), nn.LayerNorm(d_model)
self.drop = nn.Dropout(dropout)

def forward(self, x, mask=None):
a, _ = self.attn(self.n1(x), self.n1(x), self.n1(x), mask=mask)
x = x + self.drop(a)
return x + self.drop(self.ff(self.n2(x)))


class DecoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, num_heads)
self.cross_attn = MultiHeadAttention(d_model, num_heads)
self.ff = FeedForward(d_model, d_ff, dropout)
self.n1, self.n2, self.n3 = (nn.LayerNorm(d_model) for _ in range(3))
self.drop = nn.Dropout(dropout)

def forward(self, x, memory, causal, src_mask=None):
a, _ = self.self_attn(self.n1(x), self.n1(x), self.n1(x), mask=causal)
x = x + self.drop(a)
c, cross_w = self.cross_attn(self.n2(x), memory, memory, mask=src_mask)
x = x + self.drop(c)
x = x + self.drop(self.ff(self.n3(x)))
return x, cross_w


class Transformer(nn.Module):
def __init__(self, src_vocab, tgt_vocab, d_model=64, num_heads=4,
num_layers=2, d_ff=128, dropout=0.1, max_len=64):
super().__init__()
self.d_model = d_model
self.src_emb = nn.Embedding(src_vocab, d_model)
self.tgt_emb = nn.Embedding(tgt_vocab, d_model)
self.pos = SinusoidalPositionalEncoding(d_model, max_len)
self.enc = nn.ModuleList([EncoderLayer(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)])
self.dec = nn.ModuleList([DecoderLayer(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)])
self.norm_e = nn.LayerNorm(d_model)
self.norm_d = nn.LayerNorm(d_model)
self.head = nn.Linear(d_model, tgt_vocab, bias=False)

def encode(self, src):
x = self.pos(self.src_emb(src) * math.sqrt(self.d_model))
for layer in self.enc:
x = layer(x)
return self.norm_e(x)

def decode(self, tgt, memory):
n = tgt.size(1)
causal = torch.tril(torch.ones(n, n, device=tgt.device)).view(1, 1, n, n)
x = self.pos(self.tgt_emb(tgt) * math.sqrt(self.d_model))
cross_w = None
for layer in self.dec:
x, cross_w = layer(x, memory, causal)
return self.head(self.norm_d(x)), cross_w

def forward(self, src, tgt):
return self.decode(tgt, self.encode(src))

اختبار وحدة للأشكال

قبل أيّ تدريب، فحص الأشكال يمنع 90% من الأخطاء:

def test_shapes():
model = Transformer(src_vocab=20, tgt_vocab=15, d_model=64,
num_heads=4, num_layers=2, d_ff=128)
src = torch.randint(0, 20, (2, 10)) # حزمة 2، مصدر 10
tgt = torch.randint(0, 15, (2, 8)) # حزمة 2، هدف 8
logits, cross_w = model(src, tgt)
assert logits.shape == (2, 8, 15), f"logits: {logits.shape}"
assert cross_w.shape == (2, 4, 8, 10), f"cross: {cross_w.shape}"
print("جميع الأشكال صحيحة.")


if __name__ == "__main__":
test_shapes()

عند الركض نتحقّق: مخرجات (B,ntgt,Vtgt)(B, n_{\text{tgt}}, V_{\text{tgt}}) ومصفوفة الانتباه المتقاطع (B,h,ntgt,nsrc)(B, h, n_{\text{tgt}}, n_{\text{src}}). أيّ اختلاف يعني عطلًا في view أو transpose أو ضربًا خاطئًا.

مهمّة اللعبة: النسخ المعكوس

أوّل مهمّة للتدرّب: تعطي التسلسل، يُعيد ترتيبه معكوسًا. مثلًا [3, 7, 1, 4][4, 1, 7, 3]. هذه المهمّة تُثبت أنّ Transformer يستطيع تعلّم بنية موضعيّة رغم أنّه لا يعرف الترتيب طبيعيًّا (بفضل ترميز الموضع).

def generate_reverse_batch(batch_size, seq_len, vocab):
src = torch.randint(2, vocab, (batch_size, seq_len)) # 0 = pad, 1 = bos
tgt_in = torch.cat([torch.ones(batch_size, 1, dtype=torch.long),
src.flip(dims=[1])[:, :-1]], dim=1)
tgt_out = src.flip(dims=[1])
return src, tgt_in, tgt_out


def train_reverse():
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = Transformer(src_vocab=10, tgt_vocab=10).to(device)
opt = torch.optim.Adam(model.parameters(), lr=3e-4)

for step in range(1000):
src, tgt_in, tgt_out = generate_reverse_batch(32, 8, vocab=10)
src, tgt_in, tgt_out = src.to(device), tgt_in.to(device), tgt_out.to(device)
logits, _ = model(src, tgt_in)
loss = F.cross_entropy(logits.view(-1, 10), tgt_out.view(-1))
opt.zero_grad(); loss.backward(); opt.step()
if step % 100 == 0:
print(f"step {step}: loss = {loss.item():.4f}")
return model

النموذج يهبط من خسارة نحو 2,3 (احتمالات موحّدة) إلى أقلّ من 0,1 في نحو 500 خطوة. إن لم يهبط، فالخلل غالبًا في:

  1. القناع السببي غير مُطبَّق.
  2. تسييف tgt_in وtgt_out خاطئ.
  3. lr عالٍ جدًّا (جرّب 10410^{-4}).

المهمّة الحقيقيّة: ترجمة تواريخ

المهمّة الأكثر تعبيرًا: ترجمة تواريخ من صيغة طبيعيّة إلى صيغة ISO. مثلًا:

  • «3 مارس 2026» → 2026-03-03
  • «15 janvier 2019» → 2019-01-15
  • «7 أبريل 2000» → 2000-04-07

توليد بيانات التدريب صريح تمامًا:

import random
from datetime import date

MONTHS_AR = ["يناير","فبراير","مارس","أبريل","مايو","يونيو",
"يوليو","أغسطس","سبتمبر","أكتوبر","نوفمبر","ديسمبر"]

def make_date():
d = date(random.randint(1990, 2030), random.randint(1, 12), random.randint(1, 28))
src = f"{d.day} {MONTHS_AR[d.month - 1]} {d.year}"
tgt = d.isoformat() # YYYY-MM-DD
return src, tgt

الجتنة على مستوى الحرف تُبسّط الحياة: معجم من نحو 40 رمزًا (أرقام، فراغ، أحرف عربيّة، شرطة). مع بيانات لا نهائية (نُولّد على الطاير)، النموذج يتقن المهمّة في نحو 20 حقبة، مع دقّة فوق 99%.

عرض خرائط الانتباه

الفائدة الكبرى لهذه المهمّة أنّها تُنتج خرائط انتباه ذات معنى قابل للفحص بالعَين. الجتون المولّد في الموقع الأوّل (خانة العشرات في السنة، مثلًا «2») يجب أن يستعلم من جتون السنة في المصدر («2026»).

import matplotlib.pyplot as plt

def show_cross_attention(model, src_text, tgt_text, tokenizer):
src = tokenizer.encode(src_text).unsqueeze(0)
tgt = tokenizer.encode(tgt_text).unsqueeze(0)
with torch.no_grad():
_, cross_w = model(src, tgt)
weights = cross_w[0, 0].cpu() # الرأس الأولى
plt.imshow(weights, aspect="auto", cmap="viridis")
plt.xlabel("جتونات المصدر")
plt.ylabel("جتونات الهدف")
plt.colorbar(); plt.tight_layout(); plt.savefig("cross_attention.png")

عند الفحص نرى نمطًا غير متوقّع لكنّه واضح المعنى. الجتون الأوّل في 2026-03-03 (الرقم «2») ينظر بشدّة إلى الأرقام الأربعة الأخيرة من المصدر (السنة). الشرطة - تنظر إلى جتون الفراغ. الشهر 03 ينظر بشدّة إلى «مارس». هذا الاصطفاف لم يُبرمَج: بل انبثق من الخسارة.

اقرأ خريطة الانتباه بأربع أسئلة

عند فحص خريطة، اطرح: (1) هل هي مُتَنوّعة؟ خريطة منتظمة تعني أنّ الرأس لا تفعل شيئًا. (2) هل تعكس بنية المهمّة؟ في الترجمة، النمط شبه قطري متوقّع. (3) هل تتغيّر بين الرؤوس؟ إن تشابهت كلّ الرؤوس، فأنت تدفع كلفة تعدّد رؤوس بلا فائدة. (4) هل تنظر لجتون خاصّ؟ CLS أو نظائره غالبًا يتلقّى انتباهًا كثيرًا، وهذا سلوك طبيعي وليس عطلًا.

قواعد أخيرة قبل الوحدة الحادية عشرة

  • model.eval() أثناء التوليد. الإسقاط يفسد الاستدلال.
  • لا تنسَ torch.no_grad() في التوليد. يُوفّر ذاكرة كبيرة.
  • إن كان أداء التدريب ممتازًا والاستدلال سيّئًا، الشكوك الأولى: القناع السببي غائب أثناء التدريب، أو البيانات مُسرَّبة بين مدخل وهدف.
  • احفظ النموذج والمُسوّي في نفس المصنوع (torch.save({...})). فقدان أيّ منهما يُبطل الاستدلال.

في الخلاصة

  • Transformer كامل ببايتورش يزيد قليلًا على 150 سطرًا؛ التجميع من الكتل الست يجعل الأمر مسألة تركيب لا كتابة من الصفر.
  • اختبار وحدة للأشكال قبل التدريب يمنع أغلب الأعطال؛ لا يستحقّ أبدًا تأجيله.
  • مهمّتا اللعبة (نسخ معكوس ثمّ ترجمة تواريخ) تُثبتان أنّ النموذج يعمل، وتفتحان الباب على عرض خرائط الانتباه التي تُعطي المعمارية معنى مرئيًّا.
  • التمييز بين التدريب والاستدلال حاسم: eval()، القناع السببي، no_grad؛ هذه الأخطاء صامتة لكنّها كارثيّة.

الوحدة التالية: المراجعة الشاملة، والامتحان النهائي المكوّن من 40 سؤالًا، وشهادة الإتمام.