ابنِ محوّلًا صغيرًا
درس 7 من 8
7ابنِ محوّلًا صغيرًا
الصورة الكبيرةفي هذا الدرس تكتب محوّلًا كاملًا من الصفر في PyTorch، في نحو خمسين سطرًا: تضمين ومواضع، وخمس كتل، ورأس يحوّل كل موضع إلى احتمالات على المفردات. مليون معامل تقريبًا، صغير بما يكفي ليتدرّب على حاسوبك، وكامل بما يكفي ليكون نفس بنية GPT.
ماذا تشاهد في الرسم: النبضة تمرّ من التضمين عبر الكتل الخمس المتطابقة في بنيتها حتى الرأس الذي يخرج درجة لكل رمز ممكن. الرقمان في الأسفل هما فحصا ما قبل التدريب: عدد المعاملات كما حسبناه، وخسارة أولى قرب ln(V).
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
بعد أن فهمت كل قطعة وحدها، تركّبها الآن مثل محرّك صغير: تضمين، ثم موضع، ثم كتل متكررة، ثم طبقة تختار الكلمة التالية. نموذجك صغير جدًا مقارنة بالنماذج المشهورة، لكنه يعمل بنفس الفكرة تمامًا، وتستطيع أن تفتحه وترى كل جزء.
تبني بـPyTorch: nn.Embedding للرموز والمواضع، ثم N كتلة فيها انتباه سببي متعدد الرؤوس وMLP ووصلات متبقية وLayerNorm، ثم رأس خطي بحجم المفردات. تتحقق من الأشكال في كل خطوة ومن عدد المعاملات قرب المليون.
قبل أن تبدأ
🧱كتلة المحوّل
كتبت الكتلة بـNumPy: تطبيع، انتباه بقناع، جمع، ثم تطبيع، شبكة أمامية، جمع.
🔥الموتر والمشتقات التلقائية
تعرف nn.Module وforward، وأن PyTorch يحسب التدرّجات بنفسه.
🌱التدريب المسبق
تعرف أن الخسارة الأولى السليمة قرب ln(V)، وأن الهدف هو الرمز التالي.
الفكرة الجوهرية
في هذا الدرس تكتب الكتلة بـ PyTorch، ثم تبني حولها النموذج والرأس، ثم تجعله يولّد، وأخيرًا تجري الفحوص الثلاثة قبل التدريب.
1الكتلة
نبدأ من الداخل، بالقطعة التي تتكرّر خمس مرات. انظر إلى الرسم: الاستعلام والمفتاح والقيمة تخرج من طبقة واحدة ثم تُقسم.
نفس كتلة درس «كتلة المحوّل» بالضبط، لكن بطبقات PyTorch لتُحسب تدرّجاتها تلقائيًّا. qkv طبقة واحدة تحسب السؤال والمفتاح والقيمة معًا ثم نقسمها، وهي أسرع من ثلاث طبقات منفصلة.
import torch
import torch.nn as nn
import torch.nn.functional as F
class Block(nn.Module):
def __init__(self, d, heads, ctx):
super().__init__()
self.heads = heads
self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
self.qkv = nn.Linear(d, 3 * d)
self.proj = nn.Linear(d, d)
self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
self.register_buffer("mask", torch.tril(torch.ones(ctx, ctx, dtype=torch.bool)))
def attention(self, x):
B, T, C = x.shape
q, k, v = self.qkv(x).split(C, dim=2)
q, k, v = (t.view(B, T, self.heads, C // self.heads).transpose(1, 2) for t in (q, k, v))
att = (q @ k.transpose(-2, -1)) / (k.size(-1) ** 0.5)
att = att.masked_fill(~self.mask[:T, :T], float("-inf")).softmax(dim=-1)
return self.proj((att @ v).transpose(1, 2).reshape(B, T, C))
def forward(self, x):
x = x + self.attention(self.ln1(x))
return x + self.mlp(self.ln2(x))
register_buffer يحفظ القناع مع النموذج وينقله معه إلى البطاقة دون أن يكون معاملًا يتدرّب. والسطران الأخيران في forward هما Pre-norm مع الوصلة المتبقية، حرفيًّا كما في نسخة NumPy.
2الهيكل والرأس
النموذج كله أربعة أسطر في forward: تضمين الرمز مع تضمين الموضع، ثم 5 كتل، ثم تطبيع أخير، ثم رأس خطّي إلى حجم المفردات.
أضف هذا الصنف تحت Block في نفس الملف.
class TinyGPT(nn.Module):
def __init__(self, vocab, d=128, heads=4, layers=5, ctx=128):
super().__init__()
self.ctx = ctx
self.tok = nn.Embedding(vocab, d)
self.pos = nn.Embedding(ctx, d)
self.blocks = nn.Sequential(*[Block(d, heads, ctx) for _ in range(layers)])
self.ln = nn.LayerNorm(d)
self.head = nn.Linear(d, vocab, bias=False)
def forward(self, idx, targets=None):
T = idx.size(1)
x = self.tok(idx) + self.pos(torch.arange(T, device=idx.device))
logits = self.head(self.ln(self.blocks(x)))
if targets is None:
return logits, None
return logits, F.cross_entropy(logits.flatten(0, 1), targets.flatten())
@torch.no_grad()
def generate(self, idx, n, temperature=1.0):
for _ in range(n):
logits, _ = self(idx[:, -self.ctx:])
probs = (logits[:, -1] / temperature).softmax(-1)
idx = torch.cat([idx, torch.multinomial(probs, 1)], dim=1)
return idx
idx[:, -self.ctx:] يقصّ المدخل لآخر 128 رمزًا، لأن جدول المواضع لا يعرف أبعد من ذلك. وlogits[:, -1] يأخذ الموضع الأخير فقط، فهو الوحيد الذي يتنبّأ برمز جديد.
F.cross_entropy. هذه الدالة تنتظر الدرجات الخام وتطبّق softmax بنفسها، فتطبيقه مرتين يفسد الخسارة بصمت.3التوليد: تنبّأ، اسحب، أضف
دالة generate في الكود السابق حلقة بسيطة. تأخذ احتمالات آخر موضع، وتسحب رمزًا، وتضيفه إلى المدخل، ثم تعيد.
import torch
from gpt import TinyGPT
torch.manual_seed(0)
chars = sorted(set("ابتثجحخدذرزسشصضطظعغفقكلمنهوي ."))
model = TinyGPT(len(chars))
start = torch.tensor([[chars.index("ا")]])
out = model.generate(start, 60)[0]
print("".join(chars[i] for i in out))
هذا الكود يحتاج PyTorch على جهازك، فالناتج المعروض مسجَّل من تشغيل حقيقي. ثبّته بـpip install torch وشغّله بنفسك.
حروف عشوائية تمامًا، وهذا هو المتوقّع. احتفظ بهذا الناتج: في المشروع ستقارنه بما يولّده نفس النموذج بعد دقائق من التدريب.
انظر إلى الرسم: كل دورة تضيف رمزًا واحدًا. ودرجة الحرارة تقسم الدرجات قبل softmax، فتجعل السحب أجرأ أو أحذر.
4ثلاثة فحوص قبل التدريب
قبل أي تدريب تحقّق من ثلاثة أشياء: عدد المعاملات كما حسبته، وشكل المخرج (batch, tokens, vocab)، والخسارة الأولى قريبة من ln(V).
import math
import torch
from gpt import TinyGPT
torch.manual_seed(0)
vocab = 64
model = TinyGPT(vocab)
print("parameters:", sum(p.numel() for p in model.parameters()))
x = torch.randint(0, vocab, (8, 128)) # 8 sequences of 128 tokens
y = torch.randint(0, vocab, (8, 128))
logits, loss = model(x, y)
print("logits:", tuple(logits.shape), "-> (batch, tokens, vocab)")
print(f"loss before training: {loss.item():.2f} ln(vocab) = {math.log(vocab):.2f}")
هذا الكود يحتاج PyTorch على جهازك، فالناتج المعروض مسجَّل من تشغيل حقيقي. ثبّته بـpip install torch وشغّله بنفسك.
1,024,384 معاملًا، مليون تقريبًا كما حسبنا. والشكل (8, 128, 64) صحيح. والخسارة الأولى 4.32 قريبة من ln(64) = 4.16. الفرق الصغير لأن الأوزان الابتدائية العشوائية ليست متساوية تمامًا. إن رأيت خسارة أولى 20 أو 50، فأوزانك الابتدائية كبيرة أو الرأس خاطئ.
كيف يعمل، خطوة بخطوة: كيف يولّد TinyGPT نصًّا
تقصّ المدخل إلى آخر 128 رمزًا، لأن جدول المواضع لا يعرف أبعد من ذلك.
تمرّره في النموذج فتحصل على logits لكل موضع، وتأخذ الموضع الأخير فقط.
تقسم الدرجات على الحرارة وتطبّق softmax، ثم تسحب رمزًا واحدًا بـtorch.multinomial.
تلحق الرمز بالمدخل وتكرّر حتى تبلغ العدد المطلوب، كل ذلك داخل no_grad.
مثال محلول
كل ما بنيناه في الدروس السابقة يجتمع هنا. افتح ملفًّا باسم gpt.py، فسنكتب فيه صنفين: Block للكتلة، وTinyGPT للنموذج كاملًا، ونستخدمهما في هذا الدرس وفي المشروع.
model.eval() قبل التوليد في نموذج فيه Dropout، أو نسيان torch.no_grad(). الأول يجعل التوليد عشوائيًّا أكثر مما يجب، والثاني يبني رسمًا حسابيًّا لكل رمز مولَّد فتمتلئ الذاكرة.
جرّب بلا كود
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| GPT-2 معمارية مختلفة عن نموذجنا الصغير. | نفس البنية، وما يتغيّر هو الأرقام: عرض 768 و12 رأسًا و12 كتلة بدل 128 و4 و5. |
يجب أن نطبّق softmax داخل forward قبل حساب الخسارة. | F.cross_entropy تطبّقها داخليًّا بطريقة مستقرة، وتطبيقها مرتين يفسد التدريب بصمت. |
| الخسارة الأولى لا تهمّ، المهم أين تنتهي. | الخسارة الأولى فحص مجاني: إن بدأت عند 20 أو 50 بدل قرب ln(V) فالأوزان الأولية كبيرة أو الرأس خطأ. |
جرّب بنفسك
غيّرت layers من 5 إلى 10 دون تغيير العرض. كم يصير عدد المعاملات تقريبًا؟
معاملات الكتل 12 × layers × d²، فمضاعفة الكتل تضاعفها. التضمينات لا تتغيّر لكنها صغيرة هنا. أما مضاعفة العرض فتضرب الكتل في أربعة.
لماذا نأخذ logits[:, -1] فقط عند التوليد؟
كل موضع يتنبّأ بما بعده، والمواضع السابقة تتنبّأ برموز نعرفها بالفعل. الجديد الوحيد هو ما بعد الموضع الأخير.
بطاقات مراجعة سريعة
logits بشكل (batch, tokens, vocab): لكل موضع درجة لكل رمز ممكن تاليًا. softmax يحوّلها إلى احتمالات.ln(V). ثلاثة فحوص قبل أي تدريب.تحقق من فهمك
لماذا نضع bias=False في الرأس ولا نطبّق softmax داخل forward؟
الانحياز في الرأس لا يضيف كثيرًا بعد التطبيع الأخير، والنماذج الحديثة تحذفه. وsoftmax لا نطبّقه لأن F.cross_entropy يطبّقه داخليًّا بطريقة مستقرّة رقميًّا، وتطبيقه مرتين خطأ شائع يُفسد التدريب بصمت.
لماذا نسجّل القناع بـregister_buffer بدل أن يكون معاملًا عاديًّا؟
لأنه ثابت لا يتعلّم، لكنه يجب أن يُحفظ مع النموذج وينتقل معه إلى بطاقة الرسوميات. register_buffer يحقّق الأمرين دون أن يراه المُحسِّن.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.