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

الوحدة 7 — فاكّ الترميز: GPT والتوليد

في الوحدة 6 رأينا أنّ المرمّز يقرأ التسلسل بأكمله في وقت واحد ويُنتج تمثيلات ثنائيّة الاتّجاه. فاكّ الترميز يقلب هذا الحيادَ عمدًا: إنّه يُدرَّب على مهمّة توقّع الجتون التالي، وهذا يفرض قيدًا حاسمًا — لا يجوز لأيّ جتون أن يرى المستقبل. هذا القيد يُفرَض بقناع بسيط اسمه القناع السببي، وهو الفرق الوحيد المعماري بين المرمّز وفاكّ الترميز.

من هذا القيد الصغير خرجت عائلة GPT كلّها، وخرجت بعدها كلّ نماذج التوليد التي نشاهدها اليوم. هذه الوحدة تشرح الميكانيك بيدنا، وتُبيّن كيف نسنقذ ميكانيكيّة التوليد.

القناع السببي: منع رؤية المستقبل

في الانتباه العادي، الجتون في الموقع ii ينظر إلى جتونات المواقع كلّها من 1 إلى nn. في فاكّ الترميز، لا يجوز له النظر إلّا إلى المواقع من 1 إلى ii. تقنيًّا، نُنشئ مصفوفة قناع مثلّثيّة MM حجمها n×nn \times n:

Mij={1إذا ji0إذا j>iM_{ij} = \begin{cases} 1 & \text{إذا } j \le i \\ 0 & \text{إذا } j > i \end{cases}

ثمّ نطبّقها على الدرجات قبل softmax، لا بعده:

scoresij=إذا كانت Mij=0\text{scores}_{ij} = -\infty \quad \text{إذا كانت } M_{ij} = 0

-\infty يجعل softmax ينتج 0 على المواقع الممنوعة، والباقي يُقسم مجموعه على 1. هذا هو كلّ الفرق بين المرمّز وفاكّ الترميز.

def causal_mask(size: int) -> torch.Tensor:
"""يُنتج قناعًا مثلّثيًّا سفليًّا (B=1, 1, size, size)."""
return torch.tril(torch.ones(size, size)).unsqueeze(0).unsqueeze(0)


if __name__ == "__main__":
m = causal_mask(5)
print(m.squeeze())
# tensor([[1., 0., 0., 0., 0.],
# [1., 1., 0., 0., 0.],
# [1., 1., 1., 0., 0.],
# [1., 1., 1., 1., 0.],
# [1., 1., 1., 1., 1.]])

نستعمل هذا القناع مع MultiHeadAttention من الوحدة 3، حيث ترى دالّة forward تنفيذ masked_fill(mask == 0, -inf).

نسيان القناع السببي علّة صامتة

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

مهمّة توقّع الجتون التالي

من أجل تسلسل جتونات [t1,t2,,tn][t_1, t_2, \dots, t_n]، يُدرَّب فاكّ الترميز على توقّع ti+1t_{i+1} من [t1,,ti][t_1, \dots, t_i]، لكلّ ii. أي أنّ خسارة واحدة تُوزّع على كلّ المواقع في التسلسل، وهذا ما يجعل التدريب فعّالًا بذاته.

الترتيب في الكود:

import torch
import torch.nn as nn
import torch.nn.functional as F

# نفترض التسلسل tokens من (B, n)
# tokens_in = tokens[:, :-1] المُدخَل: بلا آخر جتون
# tokens_out = tokens[:, 1:] الهدف: بلا أوّل جتون

def language_model_loss(logits: torch.Tensor, tokens: torch.Tensor) -> torch.Tensor:
logits_in = logits[:, :-1, :].contiguous() # (B, n-1, V)
targets = tokens[:, 1:].contiguous() # (B, n-1)
return F.cross_entropy(logits_in.view(-1, logits.size(-1)), targets.view(-1))

هذا التسييف بمقدار جتون واحد هو الحيلة التي تجعل نموذجًا واحدًا يُنتج، لكلّ موقع، توقّعًا للجتون التالي دون تسرّب. لا يوجد أيّ استعلام رجعي: بمجرّد أن يُطبَّق القناع السببي، الترتيب صحيح تلقائيًّا.

بنية فاكّ الترميز في المشروع الموجّه

كتلة فاكّ الترميز مماثلة لكتلة المرمّز، لكن بقناع سببي دائمًا:

class DecoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=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, causal):
x = self.res1(x, lambda z: self.self_attn(z, z, z, mask=causal))
x = self.res2(x, self.ff)
return x


class TransformerDecoder(nn.Module):
def __init__(self, vocab_size, d_model=128, num_heads=4, num_layers=4,
d_ff=512, max_len=512, dropout=0.1):
super().__init__()
self.embed = nn.Embedding(vocab_size, d_model)
self.pos = SinusoidalPositionalEncoding(d_model, max_len)
self.layers = nn.ModuleList([
DecoderLayer(d_model, num_heads, d_ff, dropout)
for _ in range(num_layers)
])
self.norm = nn.LayerNorm(d_model)
self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
self.d_model = d_model

def forward(self, tokens):
n = tokens.size(1)
causal = causal_mask(n).to(tokens.device)
x = self.embed(tokens) * (self.d_model ** 0.5)
x = self.pos(x)
for layer in self.layers:
x = layer(x, causal)
x = self.norm(x)
return self.lm_head(x) # (B, n, vocab_size)

هذه هي كتلة GPT مصغّرة. الفارق الوحيد بينها وبين GPT-2 الحقيقي هو الأحجام: dmodel=768d_{\text{model}} = 768، وh=12h = 12، وN=12N = 12 في GPT-2 الأساسي.

استراتيجيّات التوليد

عندما ندرّب النموذج، نعرف الهدف. عندما نُوَلّد، لا نعرف. علينا أن نختار كلّ جتون من توزيع الاحتمالات على المفردات p(ti+1t1:i)p(t_{i+1} | t_{1:i}). عدّة استراتيجيّات:

  • التوليد الجشع (greedy): argmax\arg\max في كلّ خطوة. سريع لكنّه يُنتج نصًّا رتيبًا، ويميل إلى تكرار عبارات.
  • البحث الشعاعي (beam search): يُحافظ على kk فرضيّات متوازية. جودة أعلى، لكنّه لا يزال حتميًّا ورتيبًا في التوليد الطويل.
  • العيّنة بدرجة حرارة (temperature sampling): نُقسّم اللوغاريتمات على TT قبل softmax. T<1T < 1 يُحدِّد الاختيار (أقرب إلى الجشع)، وT>1T > 1 يُوسّعه (أكثر تنوّعًا).
  • العيّنة بأعلى-k أو top-p (nucleus): نُقصر التوزيع على أعلى kk جتونات، أو على أدنى مجموعة تُغطي احتمال pp. تُبعد الجتونات النادرة جدًّا مع الحفاظ على تنوّع النصّ.
@torch.no_grad()
def generate(model, tokens, max_new_tokens=50, temperature=1.0, top_k=None):
for _ in range(max_new_tokens):
logits = model(tokens)[:, -1, :] / temperature
if top_k is not None:
v, _ = torch.topk(logits, top_k)
logits[logits < v[:, [-1]]] = -float("inf")
probs = F.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
tokens = torch.cat([tokens, next_token], dim=1)
return tokens

ذاكرة KV cache

المشكلة العملية لهذا التوليد: كلّ خطوة تُعيد حساب الانتباه على كامل التسلسل مجدّدًا. من أجل الجتون الألف نُعيد حساب 1000 جداء نقطي لا حاجة لها، لأنّ KK وVV للجتونات السابقة لن تتغيّر أبدًا.

الحلّ ذاكرة KV: نحفظ للطبقة الواحدة مصفوفتَي KK وVV لكلّ الجتونات المولّدة سابقًا. عند إضافة جتون جديد، نُحسب qnew,knew,vnewq_{\text{new}}, k_{\text{new}}, v_{\text{new}} لهذا الجتون فقط، ثمّ نضيف knew,vnewk_{\text{new}}, v_{\text{new}} إلى الذاكرة. جداء qnewq_{\text{new}} في KK الكاملة بعد التسلسل يُنتج انتباه الجتون الجديد.

الكلفة تنتقل من O(n2)O(n^2) في كلّ خطوة إلى O(n)O(n) في كلّ خطوة، أي تسريع بمعامل nn. من أجل تسلسل 2048 جتون، نتحدّث عن تسريع ثنائي إلى ثلاثي المرتبة.

الثمن: ذاكرة إضافية بحجم 2Nhndk2 \cdot N \cdot h \cdot n \cdot d_k بايتات لكلّ عيّنة، حيث NN عدد الطبقات وhh عدد الرؤوس. من أجل GPT-3 (175 مليار معامل، 96 طبقة، 96 رأس، dk=128d_k = 128) وسياق 2048، تصل الذاكرة إلى نحو 4,7 غيغابايت للعيّنة الواحدة في float16. هذا هو السبب الذي يجعل خدمة GPT-3 على GPU واحدة صعبة رغم أنّ الأوزان لا تحتاج إلّا 350 جيغابايت.

عائلة GPT: قِراءة سريعة

النموذجالعامالمعاملاتالملاحظة
GPT-12018117 مليونإثبات مفهوم التدريب المسبق
GPT-220191,5 مليارالحدّ العلوي المنشور آنذاك، أثار جدلًا حول النشر المفتوح
GPT-32020175 مليارورقة «Few-shot Learners»، نشأة الاستدلال بالسياق
GPT-42023غير معلنمتعدّد الوسائط، تعليم بتعزيز من ملاحظات بشرية

النماذج المفتوحة المكافئة (LLaMA، Mistral، Qwen، Falcon) تحمل نفس المعمارية العامّة لفاكّ ترميز Transformer، مع تفاصيل حديثة: RoPE بدل الجيبي، SwiGLU بدل GELU، RMSNorm بدل LayerNorm.

في الخلاصة

  • القناع السببي يمنع الجتون في الموقع ii من النظر إلى المواقع j>ij > i؛ وهو الفرق المعماري الوحيد بين المرمّز وفاكّ الترميز.
  • المهمّة أثناء التدريب هي توقّع الجتون التالي؛ الخسارة تُحسَب على كلّ المواقع باستعمال تسييف بمقدار جتون واحد.
  • التوليد يمرّ بعدّة استراتيجيّات: جشع، شعاع، عيّنة بدرجة حرارة، top-k، top-p؛ لا توجد أفضل استراتيجية بشكل مطلق، بل تعتمد على المهمّة.
  • ذاكرة KV ضروريّة لتوليد فعّال: تُحوّل كلفة كلّ خطوة من O(n2)O(n^2) إلى O(n)O(n)، مقابل ذاكرة إضافية معتبرة.

الوحدة التالية: نجمع المرمّز وفاكّ الترميز معًا، ونُدخل الانتباه المتقاطع، ونعرف لماذا اختار T5 عبارة «كلّ شيء نصّ إلى نصّ».