فاز 10 · درس 4

آموزش پیش‌آموزش یک مینی جی‌پی‌تی (124M پارامتر)

نوع
ساختنی
زمان
حدود 120 دقیقه
زبان‌ها
Python (with numpy)
طرح مفهومی از لایه‌های ماتریسی شفاف و موازی که توکن‌ها را در قالبی مهندسی و منظم از میان خود عبور می‌دهند
فهرست مطالب
  1. اهداف یادگیری
  2. مسئله چیست؟
  3. مفاهیم پایه
  4. معماری GPT
  5. بلوک ترنسفورمر
  6. اتنشن: سازوکار اصلی
  7. کش کلید-مقدار (KV Cache): دلیل سرعت بالای استنتاج
  8. مقایسه‌ی Prefill و Decode: دو فاز استنتاج
  9. حلقه‌ی آموزش
  10. ارقام و ابعاد در GPT-2 Small
  11. پیاده‌سازی از صفر
  12. گام 1: لایه‌ی امبدینگ
  13. گام 2: خوداتنشن همراه با ماسک علّی
  14. گام 3: اتنشن چندسر
  15. گام 4: بلوک ترنسفورمر
  16. گام 5: مدل کامل GPT
  17. گام 6: حلقه‌ی آموزش
  18. گام 7: تولید متن
  19. به کارگیری عملی
  20. دمو کامل آموزش و تولید متن
  21. ارائه‌ی محصول نهایی
  22. تمرین‌ها
  23. اصطلاحات کلیدی
  24. برای مطالعه‌ی بیشتر

استفاده از API مدل‌های زبانی یا بارگذاری وزن‌های آماده، کار با یک جعبه‌ی سیاه است. وقتی مدلی دچار توهم می‌شود، کلمات تکراری تولید می‌کند یا رفتاری دور از انتظار نشان می‌دهد، تنها کسانی ریشه‌ی خطا را پیدا می‌کنند که جریان دقیق داده‌ها و محاسبات ماتریسی داخل لایه‌ها را درک کرده باشند.

در این درس معماری کامل GPT-2 Small با 124 میلیون پارامتر را بدون تکیه بر انتزاع‌های آماده‌ی فریم‌ورک‌ها پیاده‌سازی می‌کنید؛ به گونه‌ای که تک‌تک بخش‌ها از امبدینگ و ماسک علّی گرفته تا اتنشن چندسر و کش کلید-مقدار (KV Cache) با کدهای پایه قابل ردیابی و مشاهده باشند.

برای اجرای کدهای این آموزش نیازی به GPUهای ابری گران‌قیمت یا پرداخت ارزی نیست؛ بخش مفهومی و آزمایش‌های این درس روی یک مدل مینیاتوری در محیط محلی و پردازنده‌ی معمولی سیستم شخصی شما اجرا می‌شود. هرچند برای پیش‌آموزش کامل یک مدل 124 میلیونی در ابعاد صنعتی، دسترسی به سخت‌افزار مناسب و ابزارهای ابری ضروری خواهد بود.

مدل GPT-2 Small دقیقاً 124 میلیون پارامتر دارد؛ یعنی 12 لایه‌ی ترنسفورمر (transformer)، 12 سرِ اتنشن (attention head) و امبدینگ‌های (embedding) 768بُعدی. می‌توانید آن را روی یک GPU تکی ظرف چند ساعت از صفر آموزش دهید. اما بیشتر افراد هرگز این کار را نمی‌کنند؛ آن‌ها سراغ چک‌پوینت‌های (checkpoint) از‌پیش‌آموزش‌دیده می‌روند. ولی تا وقتی خودتان مدلی را آموزش ندهید، سازوکار واقعی مدلی را که رویش محصول می‌سازید، درک نخواهید کرد.

اهداف یادگیری

  • پیاده‌سازی معماری کامل GPT-2 (با 124 میلیون پارامتر) از صفر: امبدینگ‌های توکن، امبدینگ‌های مکانی (positional embeddings)، بلوک‌های ترنسفورمر و سرِ مدل زبانی (language model head)
  • آموزش یک مدل GPT روی مجموعه‌ای از متون با استفاده از پیش‌بینی توکن بعدی (next-token prediction) و تابع زیان آنتروپی متقاطع (cross-entropy loss)
  • پیاده‌سازی تولید متن خودبازگشتی (autoregressive) با نمونه‌برداری دمایی (temperature sampling) و فیلترهای top-k/top-p
  • پایش نمودارهای تابع زیان (loss) آموزش و اعتبارسنجی یادگیری الگوهای منسجم زبانی در مدل

مسئله چیست؟

می‌دانید ترنسفورمر چیست. دیاگرام‌هایش را دیده‌اید. می‌توانید مقاله‌ی «Attention Is All You Need» را از حفظ بازگو کنید و روی تخته‌سفید مستطیل‌هایی با برچسب «اتنشن چندسر» (Multi-Head Attention) بکشید.

اما هیچ‌کدام از این‌ها یعنی هنوز نمی‌دانید وقتی مدل متن تولید می‌کند، در پشت صحنه چه اتفاقی می‌افتد.

مدل GPT-2 Small دقیقاً 124,438,272 پارامتر دارد (با احتساب اشتراک وزن‌ها یا weight tying). تک‌تک این پارامترها با اجرای یک حلقه‌ی آموزش مقدار گرفته‌اند: گذر رو به جلو (forward pass)، محاسبه‌ی زیان، گام رو به عقب (backward pass) و به‌روزرسانی وزن‌ها. دوازده بلوک ترنسفورمر، دوازده سرِ اتنشن در هر بلوک، یک فضای امبدینگ 768بُعدی و واژگانی با 50,257 توکن (token). هر بار که مدل یک توکن تولید می‌کند، تمام این 124 میلیون پارامتر در زنجیره‌ای از ضرب‌های ماتریسی شرکت دارند؛ زنجیره‌ای که دنباله‌ای از شناسه‌های توکن را می‌گیرد و توزیع احتمال توکن بعدی را تحویل می‌دهد.

اگر تا به حال این سازوکار را از صفر نساخته‌اید، عملاً با یک جعبه‌ی سیاه کار می‌کنید. از API استفاده می‌کنید و مدل را فاین‌تیون می‌کنید؛ اما وقتی مشکلی پیش می‌آید — مثلاً وقتی مدل دچار توهم می‌شود، خودش را تکرار می‌کند یا از دستورها پیروی نمی‌کند — هیچ مدل ذهنی روشنی از «چرایی» ماجرا ندارید.

در این درس، GPT-2 Small را از صفر می‌سازیم؛ آن هم نه در PyTorch، بلکه با NumPy. تک‌تک ضرب‌های ماتریسی پیش چشمتان خواهد بود و همه‌ی گرادیان‌ها (gradient) با کد خودتان محاسبه می‌شوند. دقیقاً خواهید دید که چگونه 124 میلیون عدد دست‌به‌دست هم می‌دهند تا کلمه‌ی بعدی را پیش‌بینی کنند.

مفاهیم پایه

معماری GPT

مدل GPT یک مدل زبانی خودبازگشتی است. منظور از «خودبازگشتی» این است که توکن‌ها را یکی‌یکی تولید می‌کند و هر توکن وابسته به تمام توکن‌های پیشین است. این معماری از پشته‌ای از بلوک‌های دیکودر ترنسفورمر تشکیل شده است.

اینجا گراف محاسباتی کامل از شناسه‌های توکن تا احتمالات توکن بعدی را می‌بینید:

  1. ورود شناسه‌های توکن. ابعاد: (batch_size, seq_len).
  2. جستجوی امبدینگ توکن. هر شناسه به یک بردار 768بُعدی نگاشت می‌شود. ابعاد: (batch_size, seq_len, 768).
  3. جستجوی امبدینگ مکانی. هر موقعیت (0، 1، 2، ...) به یک بردار 768بُعدی نگاشت می‌شود؛ با همان ابعاد.
  4. جمع امبدینگ‌های توکن + امبدینگ‌های مکانی.
  5. عبور از 12 بلوک ترنسفورمر.
  6. نرمال‌سازی لایه‌ایِ (Layer Normalization) نهایی.
  7. تصویر کردن خطی (linear projection) به اندازه‌ی واژگان. ابعاد: (batch_size, seq_len, vocab_size).
  8. اعمال سافت‌مکس (softmax) برای رسیدن به احتمالات.

کل مدل همین است؛ بدون هیچ کانولوشن یا لایه‌ی بازگشتی. فقط امبدینگ، اتنشن، شبکه‌های پیش‌خور (feedforward) و نرمال‌سازی لایه‌ای که 12 بار روی هم چیده شده‌اند.

شناسه توکن‌ها(batch, seq_len)امبدینگ‌های توکن(batch, seq_len, 768)امبدینگ‌های موقعیت(batch, seq_len, 768)جمعبلاک ترنسفورمر 1بلاک ترنسفورمر 2...بلاک ترنسفورمر 12نرمال‌سازی لایهسر خطی(768 - 50257)سافت‌مکساحتمالات توکن بعدی

بلوک ترنسفورمر

هر یک از 12 بلوک از یک الگوی ثابت پیروی می‌کنند: معماری پیش‌نرمال‌سازی (pre-norm). مدل GPT-2 برخلاف ترنسفورمر اصلی که از پس‌نرمال‌سازی (post-norm) استفاده می‌کرد، پیش‌نرمال‌سازی را به کار می‌برد:

  1. نرمال‌سازی لایه‌ای (LayerNorm)
  2. خوداتنشن چندسر (Multi-Head Self-Attention)
  3. اتصال باقیمانده (residual connection)؛ با افزودن دوباره‌ی ورودی
  4. نرمال‌سازی لایه‌ای (LayerNorm)
  5. شبکه‌ی پیش‌خور (FFN یا MLP)
  6. اتصال باقیمانده؛ با افزودن دوباره‌ی ورودی

اتصالات باقیمانده نقشی حیاتی دارند. بدون آن‌ها، گرادیان‌ها در طول پس‌انتشار خطا (backpropagation) پیش از رسیدن به بلوک 1 صفر می‌شوند (محو شدن گرادیان). به لطف این اتصالات، گرادیان‌ها می‌توانند از مسیر میان‌بر مستقیماً از تابع زیان به هر لایه‌ای منتقل شوند. دقیقاً به همین دلیل است که می‌توان 12، 32 یا حتی 96 بلوک را روی هم چید (شایعه شده GPT-4 از 120 بلوک استفاده می‌کند).

اتنشن: سازوکار اصلی

خوداتنشن (Self-Attention) به هر توکن امکان می‌دهد به تمام توکن‌های قبلی نگاه کند و بسنجد که چقدر باید به هر کدام توجه نشان دهد. فرمول ریاضی‌اش از این قرار است:

برای هر موقعیتِ توکن، سه بردار از ورودی به دست می‌آید:

  • کوئری یا پرس‌وجو (Q): «دنبال چه چیزی هستم؟»
  • کِی یا کلید (K): «چه داده‌ای در خود دارم؟»
  • ولیو یا ارزش (V): «چه اطلاعاتی را حمل می‌کنم؟»
Q = input @ W_q    (768 -> 768)
K = input @ W_k    (768 -> 768)
V = input @ W_v    (768 -> 768)

attention_scores = Q @ K^T / sqrt(d_k)
attention_scores = mask(attention_scores)   # causal mask: -inf for future positions
attention_weights = softmax(attention_scores)
output = attention_weights @ V

همین ماسک علّی (causal mask) است که GPT را به مدلی خودبازگشتی تبدیل می‌کند. برای مثال، موقعیت 5 می‌تواند به موقعیت‌های 0 تا 5 نگاه کند، اما حق دسترسی به موقعیت‌های 6، 7، 8 و بعد از آن را ندارد. این محدودیت مانع از آن می‌شود که مدل حین آموزش با دیدن توکن‌های آینده «تقلب» کند.

اتنشن چندسر فضای 768بُعدی را به 12 سرِ مجزا با ابعاد 64 تقسیم می‌کند. هر سر، الگوی اتنشن متفاوتی را یاد می‌گیرد. یک سر ممکن است روابط نحوی را دنبال کند (مثل مطابقت فاعل و فعل). سرِ دیگر تشابه معنایی (کلمات مترادف) را پایش کند و سرِ بعدی به مجاورت مکانی (کلمات نزدیک به هم) حساس باشد. در نهایت، خروجی‌های هر 12 سر الحاق شده و دوباره به بُعد 768 تصویر می‌شوند.

محاسبات هر سراتنشن چندسر (12 سر)ورودی (768)تقسیم به 12 سرسر 1(64 بُعد)سر 2(64 بُعد)...سر 12(64 بُعد)الحاق (768)تصویر کردن خروجی(768 - 768)Q = X @ W_qامتیازها = Q @ K^T / 8K = X @ W_kاعمال ماسک علّیسافت‌مکسوزن‌ها @ VV = X @ W_v

تقسیم بر dk\sqrt{d_k} — یعنی 64=8\sqrt{64} = 8 — همان عملیات مقیاس‌گذاری است. بدون این مقیاس‌گذاری، ضرب داخلی بردارهای با ابعاد بالا خیلی بزرگ می‌شود و سافت‌مکس را به مناطقی می‌راند که گرادیان در آن‌ها نزدیک به صفر است. این نکته یکی از مهم‌ترین بینش‌های مقاله‌ی اصلی «Attention Is All You Need» بود.

کش کلید-مقدار (KV Cache): دلیل سرعت بالای استنتاج

در مرحله‌ی آموزش، کل دنباله را یک‌باره پردازش می‌کنید. اما در زمان استنتاج، توکن‌ها یکی‌یکی تولید می‌شوند. بدون بهینه‌سازی، تولید توکن NN مستلزم محاسبه‌ی دوباره‌ی اتنشن برای تمام N−1N-1 توکن قبلی است. این کار هزینه‌ی محاسباتی O(N2)O(N^2) را به ازای هر توکنِ تولیدشده یا در مجموع O(N3)O(N^3) را برای دنباله‌ای به طول NN به همراه دارد.

کش کلید-مقدار این مشکل را حل می‌کند. کافی است پس از محاسبه‌ی K و V برای هر توکن، آن‌ها را ذخیره کنید. هنگام تولید توکن N+1N+1، فقط باید Q را برای توکن جدید محاسبه کرده و K و V کش‌شده‌ی تمام توکن‌های قبلی را بخوانید. با این کار، هزینه‌ی محاسباتی K و V به ازای هر توکن از O(N)O(N) به O(1)O(1) کاهش می‌یابد. البته محاسبه‌ی امتیاز اتنشن همچنان O(N)O(N) خواهد بود، چون به تمام موقعیت‌های قبلی نگاه می‌کنید؛ اما دیگر خبری از ضرب‌های ماتریسی تکراری روی ورودی نیست.

برای GPT-2 با 12 لایه و 12 سر، کش کلید-مقدار در هر توکن 18,432 مقدار را نگه می‌دارد (2 مقدار برای K و V ضرب‌در 12 لایه ضرب‌در 12 سر ضرب‌در بُعد 64). این مقدار برای دنباله‌ای 1024توکنی در فرمت FP32 حدود 75 مگابایت فضا می‌گیرد. در مدلی مثل Llama 3 405B با 128 لایه، حافظه‌ی کش کلید-مقدار برای یک دنباله می‌تواند از 10 گیگابایت فراتر برود. دلیل اینکه استنتاج در بافت‌های طولانی به پهنای باند حافظه گره خورده، دقیقاً همین است.

مقایسه‌ی Prefill و Decode: دو فاز استنتاج

وقتی پرامپتی (prompt) را به یک LLM می‌فرستید، استنتاج در دو فاز متمایز انجام می‌شود.

فاز Prefill تمام پرامپت شما را موازی پردازش می‌کند. چون همه‌ی توکن‌ها معلوم هستند، مدل می‌تواند اتنشن تمام موقعیت‌ها را به‌طور هم‌زمان حساب کند. این فاز متکی به توان پردازشی (compute-bound) است؛ یعنی پردازنده‌ی گرافیکی با تمام نرخ پردازش (throughput) خود مشغول ضرب ماتریسی است. برای یک پرامپت 1000توکنی روی کارت A100، فاز Prefill حدود 20 تا 50 میلی‌ثانیه طول می‌کشد.

فاز Decode توکن‌ها را یکی‌یکی تولید می‌کند. تولید هر توکن به تمام توکن‌های پیشین وابسته است. گلوگاه این فاز، پهنای باند حافظه است (memory-bound)؛ چرا که خواندن وزن‌های مدل و کش کلید-مقدار از حافظه‌ی GPU زمان می‌برد، نه خود محاسبات ماتریسی. در این وضعیت، هسته‌های پردازشی GPU بیشتر وقت‌ها بیکار منتظر خواندن داده‌ها از حافظه می‌مانند. در GPT-2، زمان هر گام دیکود تقریباً بدون توجه به تعداد فلاپسِ (FLOPs) ضرب‌های ماتریسی یکسان است، چون محدودیت اصلی پهنای باند حافظه است.

این تفاوت در سیستم‌های عملیاتی اهمیت زیادی دارد. نرخ پردازش فاز Prefill با قدرت محاسباتی GPU بالا می‌رود (FLOPS بیشتر = Prefill سریع‌تر)، در حالی که نرخ پردازش فاز Decode به پهنای باند حافظه بستگی دارد (حافظه‌ی سریع‌تر = Decode سریع‌تر). تمرکز اصلی شرکت NVIDIA در کارت H100 روی ارتقای پهنای باند حافظه نسبت به A100 به همین دلیل است؛ تغییری که مستقیماً سرعت تولید توکن را بالا می‌برد.

مرحله 2: رمزگشاییمرحله 1: پیش‌پرکردنتکرارپرامپت کامل(همه توکن‌ها مشخص است)محاسبه موازی(محدود به توان پردازشی)ساخت کش کلید-مقدارتولید توکن Nخواندن کش کلید-مقدار(محدود به حافظه)افزودن به کش کلید-مقدارتولید توکن N+1

حلقه‌ی آموزش

آموزش مدل‌های زبانی بر پایه‌ی پیش‌بینی توکن بعدی است. با داشتن توکن‌های [0, 1, 2, ..., N-1]، باید توکن‌های [1, 2, 3, ..., N] را پیش‌بینی کنیم. تابع زیان همان آنتروپی متقاطع بین توزیع احتمال پیش‌بینی‌شده‌ی مدل و توکن بعدی واقعی است.

یک گام آموزش شامل این مراحل است:

  1. گذر رو به جلو (Forward pass): عبور بچ (batch) از تمام 12 بلوک و به دست آوردن لاجیت‌ها (logits یا امتیازهای قبل از سافت‌مکس) برای هر موقعیت.
  2. محاسبه‌ی زیان: گرفتن آنتروپی متقاطع بین لاجیت‌ها و توکن‌های هدف (همان ورودی که یک موقعیت به جلو شیفت پیدا کرده است).
  3. گام رو به عقب (Backward pass): محاسبه‌ی گرادیان‌ها برای همه‌ی 124 میلیون پارامتر با استفاده از پس‌انتشار خطا.
  4. گام بهینه‌ساز: به‌روزرسانی وزن‌ها. مدل GPT-2 از بهینه‌ساز Adam همراه با وارم‌آپ (پیش‌گرمایش) و افت کسینوسیِ نرخ یادگیری استفاده می‌کند.

برنامه‌ی زمان‌بندی نرخ یادگیری (learning rate schedule) بیش از آنچه فکرش را بکنید روی نتیجه اثر دارد. مدل GPT-2 کار را با نرخ یادگیری 0 آغاز می‌کند، طی 2,000 گام اول به نرخ یادگیری اوج (peak learning rate) می‌رسد و سپس بر اساس یک منحنی کسینوسی کاهش می‌یابد. شروع با نرخ یادگیری بالا باعث واگرایی مدل می‌شود و ثابت نگه‌داشتن یک نرخ بالا در گام‌های پایانی، نوسان‌های شدیدی در آموزش به وجود می‌آورد. این الگوی وارم‌آپ و افت تدریجی در تمام مدل‌های زبانی بزرگ استاندارد است.

ارقام و ابعاد در GPT-2 Small

بخش ابعاد تعداد پارامترها
امبدینگ‌های توکن (50257, 768) 38,597,376
امبدینگ‌های مکانی (1024, 768) 786,432
اتنشن در هر بلوک (Wq,Wk,Wv,WoutW_q, W_k, W_v, W_{\text{out}}) 4 x (768, 768) 2,359,296
شبکه‌ی FFN در هر بلوک (صعودی + نزولی) (768, 3072) + (3072, 768) 4,718,592
لایه‌های نرمال‌سازی در هر بلوک (2 عدد) 2 x 768 x 2 3,072
نرمال‌سازی لایه‌ای نهایی 768 x 2 1,536
مجموع هر بلوک 7,080,960
مجموع کل (12 بلوک) 85,054,464 + 39,383,808 = 124,438,272

لایه‌ی تصویر خروجی (سرِ لاجیت‌ها) با ماتریس امبدینگ توکن اشتراک وزن دارد. این سازوکار (weight tying) تعداد پارامترها را 38 میلیون کم می‌کند و کارایی مدل را ارتقا می‌دهد؛ زیرا مدل را وادار می‌سازد تا فضای بازنمایی یکسانی را برای ورودی و خروجی در نظر بگیرد.

پیاده‌سازی از صفر

گام 1: لایه‌ی امبدینگ

امبدینگ توکن، هر کدام از 50,257 توکن ممکن را به برداری 768بُعدی می‌نگارد. امبدینگ مکانی هم اطلاعات مربوط به موقعیت هر توکن در دنباله را اضافه می‌کند. در نهایت این دو با هم جمع می‌شوند.

import numpy as np

class Embedding:
    def __init__(self, vocab_size, embed_dim, max_seq_len):
        self.token_embed = np.random.randn(vocab_size, embed_dim) * 0.02
        self.pos_embed = np.random.randn(max_seq_len, embed_dim) * 0.02

    def forward(self, token_ids):
        seq_len = token_ids.shape[-1]
        tok_emb = self.token_embed[token_ids]
        pos_emb = self.pos_embed[:seq_len]
        return tok_emb + pos_emb

انحراف معیار 0.02 برای مقداردهی اولیه مستقیماً از مقاله‌ی GPT-2 آمده است. اگر این مقدار خیلی بزرگ باشد، گذر رو به جلوی اولیه مقادیر شدیدی تولید می‌کند که پایداری آموزش را به هم می‌زند. اگر خیلی کوچک باشد، خروجی‌های اولیه برای همه‌ی ورودی‌ها تقریباً یکسان می‌شوند و سیگنال‌های اولیه‌ی گرادیان عملاً بی‌فایده خواهند شد.

گام 2: خوداتنشن همراه با ماسک علّی

کار را با اتنشن تک‌سر شروع می‌کنیم. ماسک علّی، موقعیت‌های آینده را پیش از اعمال سافت‌مکس روی منفیِ بی‌نهایت می‌گذارد تا هر موقعیت فقط بتواند به خودش و موقعیت‌های پیشین نگاه کند.

def attention(Q, K, V, mask=None):
    d_k = Q.shape[-1]
    scores = Q @ K.transpose(0, -1, -2 if Q.ndim == 4 else 1) / np.sqrt(d_k)
    if mask is not None:
        scores = scores + mask
    weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
    weights = weights / weights.sum(axis=-1, keepdims=True)
    return weights @ V

در پیاده‌سازی سافت‌مکس، پیش از توان‌رسانی، بیشینه‌ی مقادیر را تفریق می‌کنیم. اگر این کار را نکنیم، تابع نمایی برای اعداد بزرگ دچار سرریز عددی (overflow) شده و بی‌نهایت می‌شود. این ترفند پایداری عددی تاثیری روی خروجی ندارد، زیرا به ازای هر مقدار ثابت cc، تساوی softmax(x−c)=softmax(x)\mathop{\mathrm{softmax}}(x - c) = \mathop{\mathrm{softmax}}(x) برقرار است.

گام 3: اتنشن چندسر

ورودی 768بُعدی را به 12 سرِ مجزا با ابعاد 64 تقسیم می‌کنیم. هر سر، محاسبات اتنشن را به شکل مستقل انجام می‌دهد. در پایان، خروجی‌ها به هم الحاق شده و دوباره به بُعد 768 تصویر می‌شوند.

class MultiHeadAttention:
    def __init__(self, embed_dim, num_heads):
        self.num_heads = num_heads
        self.head_dim = embed_dim // num_heads
        self.W_q = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_k = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_v = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_out = np.random.randn(embed_dim, embed_dim) * 0.02

    def forward(self, x, mask=None):
        batch, seq_len, d = x.shape
        Q = (x @ self.W_q).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
        K = (x @ self.W_k).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
        V = (x @ self.W_v).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)

        scores = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(self.head_dim)
        if mask is not None:
            scores = scores + mask
        weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
        weights = weights / weights.sum(axis=-1, keepdims=True)
        attn_out = weights @ V

        attn_out = attn_out.transpose(0, 2, 1, 3).reshape(batch, seq_len, d)
        return attn_out @ self.W_out

تغییر شکل و ترانهاده کردنِ (transpose) پیاپی تانسورها معمولاً گیج‌کننده‌ترین بخش اتنشن چندسر است. قضیه از این قرار است: تنسور (batch, seq_len, 768) ابتدا به (batch, seq_len, 12, 64) و سپس به (batch, 12, seq_len, 64) تبدیل می‌شود. حالا هر کدام از این 12 سر یک ماتریس (seq_len, 64) جداگانه دارند که اتنشن را روی آن اجرا کنند. بعد از اتنشن، مسیر را برعکس برمی‌گردیم: تنسور (batch, 12, seq_len, 64) ابتدا به (batch, seq_len, 12, 64) و در نهایت به (batch, seq_len, 768) تبدیل می‌شود.

گام 4: بلوک ترنسفورمر

یک بلوک کامل ترنسفورمر شامل این بخش‌هاست: نرمال‌سازی لایه‌ای، اتنشن چندسر همراه با اتصال باقیمانده، نرمال‌سازی لایه‌ای، و در نهایت شبکه‌ی پیش‌خور همراه با اتصال باقیمانده.

class LayerNorm:
    def __init__(self, dim, eps=1e-5):
        self.gamma = np.ones(dim)
        self.beta = np.zeros(dim)
        self.eps = eps

    def forward(self, x):
        mean = x.mean(axis=-1, keepdims=True)
        var = x.var(axis=-1, keepdims=True)
        return self.gamma * (x - mean) / np.sqrt(var + self.eps) + self.beta


class FeedForward:
    def __init__(self, embed_dim, ff_dim):
        self.W1 = np.random.randn(embed_dim, ff_dim) * 0.02
        self.b1 = np.zeros(ff_dim)
        self.W2 = np.random.randn(ff_dim, embed_dim) * 0.02
        self.b2 = np.zeros(embed_dim)

    def forward(self, x):
        h = x @ self.W1 + self.b1
        h = np.maximum(0, h)  # GELU approximation: ReLU for simplicity
        return h @ self.W2 + self.b2


class TransformerBlock:
    def __init__(self, embed_dim, num_heads, ff_dim):
        self.ln1 = LayerNorm(embed_dim)
        self.attn = MultiHeadAttention(embed_dim, num_heads)
        self.ln2 = LayerNorm(embed_dim)
        self.ffn = FeedForward(embed_dim, ff_dim)

    def forward(self, x, mask=None):
        x = x + self.attn.forward(self.ln1.forward(x), mask)
        x = x + self.ffn.forward(self.ln2.forward(x))
        return x

شبکه‌ی پیش‌خور ورودی 768بُعدی را به 3,072 بُعد گسترش می‌دهد (4 برابر)، یک تبدیل غیرخطی روی آن اعمال می‌کند و دوباره آن را به 768 بُعد برمی‌گرداند. این الگوی انبساط و انقباض به مدل بازنمایی درونیِ «عریض‌تری» در هر موقعیت می‌دهد. مدل GPT-2 از تابع فعال‌ساز GELU استفاده می‌کند؛ اما ما اینجا برای سادگی سراغ ReLU می‌رویم که برای فهم کلیت معماری فرق چندانی نمی‌کند.

گام 5: مدل کامل GPT

12 بلوک ترنسفورمر را پشت سر هم می‌چینیم. لایه‌ی امبدینگ را در ابتدا و لایه‌ی تصویر خروجی را در انتها قرار می‌دهیم.

class MiniGPT:
    def __init__(self, vocab_size=50257, embed_dim=768, num_heads=12,
                 num_layers=12, max_seq_len=1024, ff_dim=3072):
        self.embedding = Embedding(vocab_size, embed_dim, max_seq_len)
        self.blocks = [
            TransformerBlock(embed_dim, num_heads, ff_dim)
            for _ in range(num_layers)
        ]
        self.ln_f = LayerNorm(embed_dim)
        self.vocab_size = vocab_size
        self.embed_dim = embed_dim

    def forward(self, token_ids):
        seq_len = token_ids.shape[-1]
        mask = np.triu(np.full((seq_len, seq_len), -1e9), k=1)

        x = self.embedding.forward(token_ids)
        for block in self.blocks:
            x = block.forward(x, mask)
        x = self.ln_f.forward(x)

        logits = x @ self.embedding.token_embed.T
        return logits

    def count_parameters(self):
        total = 0
        total += self.embedding.token_embed.size
        total += self.embedding.pos_embed.size
        for block in self.blocks:
            total += block.attn.W_q.size + block.attn.W_k.size
            total += block.attn.W_v.size + block.attn.W_out.size
            total += block.ffn.W1.size + block.ffn.b1.size
            total += block.ffn.W2.size + block.ffn.b2.size
            total += block.ln1.gamma.size + block.ln1.beta.size
            total += block.ln2.gamma.size + block.ln2.beta.size
        total += self.ln_f.gamma.size + self.ln_f.beta.size
        return total

به اشتراک‌گذاری وزن‌ها توجه کنید: logits = x @ self.embedding.token_embed.T. لایه‌ی تصویر خروجی دوباره از همان ماتریس امبدینگ توکن (به صورت ترانهاده) استفاده می‌کند. این کار صرفاً ترفندی برای کم کردن پارامترها نیست؛ بلکه یعنی مدل از یک فضای برداری مشترک هم برای فهمیدن توکن‌ها (امبدینگ) و هم برای پیش‌بینی آن‌ها (خروجی) بهره می‌برد.

گام 6: حلقه‌ی آموزش

برای آموزش واقعی مدلی با 124 میلیون پارامتر، حتماً به GPU و PyTorch نیاز دارید. این حلقه‌ی آموزش، سازوکار فرآیند را روی مدلی کوچک در NumPy خالص نشان می‌دهد. برای امکان‌پذیر شدن اجرا، مدلی بسیار جمع‌وجور (4 لایه، 4 سر و 128 بُعد) در نظر گرفته‌ایم.

def cross_entropy_loss(logits, targets):
    batch, seq_len, vocab_size = logits.shape
    logits_flat = logits.reshape(-1, vocab_size)
    targets_flat = targets.reshape(-1)

    max_logits = logits_flat.max(axis=-1, keepdims=True)
    log_softmax = logits_flat - max_logits - np.log(
        np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
    )

    loss = -log_softmax[np.arange(len(targets_flat)), targets_flat].mean()
    return loss


def train_mini_gpt(text, vocab_size=256, embed_dim=128, num_heads=4,
                   num_layers=4, seq_len=64, num_steps=200, lr=3e-4):
    tokens = np.array(list(text.encode("utf-8")[:2048]))
    model = MiniGPT(
        vocab_size=vocab_size, embed_dim=embed_dim, num_heads=num_heads,
        num_layers=num_layers, max_seq_len=seq_len, ff_dim=embed_dim * 4
    )

    print(f"Model parameters: {model.count_parameters():,}")
    print(f"Training tokens: {len(tokens):,}")
    print(f"Config: {num_layers} layers, {num_heads} heads, {embed_dim} dims")
    print()

    for step in range(num_steps):
        start_idx = np.random.randint(0, max(1, len(tokens) - seq_len - 1))
        batch_tokens = tokens[start_idx:start_idx + seq_len + 1]

        input_ids = batch_tokens[:-1].reshape(1, -1)
        target_ids = batch_tokens[1:].reshape(1, -1)

        logits = model.forward(input_ids)
        loss = cross_entropy_loss(logits, target_ids)

        if step % 20 == 0:
            print(f"Step {step:4d} | Loss: {loss:.4f}")

    return model

مقدار تابع زیان در ابتدا نزدیک به ln(vocab_size) خواهد بود؛ این عدد برای واژگان 256توکنی در سطح بایت برابر با ln(256) = 5.55 است، چون مدلی با وزن‌های تصادفی احتمال یکسانی را به همه‌ی توکن‌ها نسبت می‌دهد. با ادامه‌ی آموزش، مقدار زیان افت می‌کند؛ چرا که مدل الگوهای متداول مثل آمدن «h» بعد از «t» یا فاصله بعد از نقطه را یاد می‌گیرد.

در محیط‌های عملیاتی، از بهینه‌ساز Adam همراه با انباشت گرادیان (gradient accumulation)، وارم‌آپ نرخ یادگیری و برش گرادیان (gradient clipping) استفاده می‌شود. با وجود پیشرفته‌تر بودن بهینه‌ساز، چرخه‌ی «گذر رو به جلو - محاسبه‌ی زیان - گام رو به عقب - به‌روزرسانی» دقیقاً همین است.

گام 7: تولید متن

برای تولید متن، مدل آموزش‌دیده توکن‌ها را دانه‌به‌دانه پیش‌بینی می‌کند. در هر مرحله، خروجی بر اساس توزیع احتمالِ پیش‌بینی‌شده نمونه‌برداری می‌شود (یا با argmax به شیوه‌ی حریصانه انتخاب می‌شود).

def generate(model, prompt_tokens, max_new_tokens=100, temperature=0.8):
    tokens = list(prompt_tokens)
    seq_len = model.embedding.pos_embed.shape[0]

    for _ in range(max_new_tokens):
        context = np.array(tokens[-seq_len:]).reshape(1, -1)
        logits = model.forward(context)
        next_logits = logits[0, -1, :]

        next_logits = next_logits / temperature
        probs = np.exp(next_logits - next_logits.max())
        probs = probs / probs.sum()

        next_token = np.random.choice(len(probs), p=probs)
        tokens.append(next_token)

    return tokens

پارامتر دما (temperature) میزان تصادفی بودن خروجی را کنترل می‌کند. در دمای 1.0 از همان توزیع خام استفاده می‌شود. دمای 0.5 توزیع را تیزتر و قطعی‌تر می‌کند (مدل گزینه‌های اولیه‌اش را بیشتر برمی‌گزیند). دمای 1.5 توزیع را پهن‌تر می‌سازد (خروجی تصادفی‌تر شده و شانس توکن‌های کم‌احتمال بالا می‌رود). دمای 0.0 هم همان رمزگشایی حریصانه (greedy decoding) است که در آن همیشه محتمل‌ترین توکن انتخاب خواهد شد.

پنجره‌ی tokens[-seq_len:] الزامی است، چون مدل سقف طول بافت معینی دارد (1024 برای GPT-2). به محض اینکه دنباله از این طول بگذرد، باید قدیمی‌ترین توکن‌ها را حذف کنید. این همان «پنجره‌ی بافت» (context window) است که همه‌جا از آن صحبت می‌شود.

دیکودر نمونه‌برداریtemperature ← top-k ← top-p
فرایند رمزگشایی (دیکودینگ) سه فیلتر را به ترتیب اجرا می‌کند. دما (temperature) توزیع را بازشکل‌دهی می‌کند، top-k تعداد کاندیداها را محدود می‌سازد، و top-p کوچک‌ترین مجموعه‌ای را نگه می‌دارد که احتمال p را پوشش دهد. در نهایت، گزینه‌های باقی‌مانده مجدداً نرمال‌سازی شده و از میان آن‌ها نمونه‌برداری می‌شود.

به کارگیری عملی

دمو کامل آموزش و تولید متن

corpus = """The transformer architecture has revolutionized natural language processing.
Attention mechanisms allow the model to focus on relevant parts of the input.
Self-attention computes relationships between all pairs of positions in a sequence.
Multi-head attention splits the representation into multiple subspaces.
Each attention head can learn different types of relationships.
The feedforward network provides nonlinear transformations at each position.
Residual connections enable gradient flow through deep networks.
Layer normalization stabilizes training by normalizing activations.
Position embeddings give the model information about token ordering.
The causal mask ensures autoregressive generation during training.
Pre-training on large text corpora teaches the model general language understanding.
Fine-tuning adapts the pre-trained model to specific downstream tasks."""

model = train_mini_gpt(corpus, num_steps=200)

prompt = list("The transformer".encode("utf-8"))
output_tokens = generate(model, prompt, max_new_tokens=100, temperature=0.8)
generated_text = bytes(output_tokens).decode("utf-8", errors="replace")
print(f"\nGenerated: {generated_text}")

طبیعتاً روی داده‌های کم و با مدلی کوچک، متن تولیدشده دست‌بالا نیمه‌منسجم خواهد بود. مدل در این شرایط الگوهایی در سطح بایت از متن آموزش می‌آموزد، اما نمی‌تواند مثل GPT-2 با 40 گیگابایت داده و معماری کامل 124 میلیون پارامتری تعمیم بدهد. هدف در اینجا کیفیت متن خروجی نیست؛ هدف این است که بتوانید ردِ تک‌تک مراحل را بگیرید: جستجوی امبدینگ، محاسبه‌ی اتنشن، تبدیلات پیش‌خور، تصویر کردن لاجیت‌ها، سافت‌مکس و نمونه‌برداری. حالا همه‌ی عملیات جلوی چشمتان است.

ارائه‌ی محصول نهایی

حاصل این درس فایل outputs/prompt-gpt-architecture-analyzer.md است — یک پرامپت تحلیلی که تصمیم‌های معماری را در هر مدلی با سبک GPT بررسی می‌کند. کافی است یک شناسنامه‌ی مدل (model card) یا گزارش فنی را به آن بدهید تا جزئیات تخصیص پارامترها، ساختار اتنشن و تصمیم‌های مقیاس‌گذاری را برایتان تحلیل کند.

تمرین‌ها

  1. ساختار مدل را تغییر دهید تا به جای 12 لایه و 12 سر، از 24 لایه و 16 سر استفاده کند. تعداد پارامترها را محاسبه کنید. دو برابر کردن عمق چه فرقی با دو برابر کردن عرض مدل (بُعد امبدینگ) دارد؟

  2. تابع فعال‌ساز GELU با فرمول (GELU(x)=x⋅0.5⋅(1+erf(x/2)))(\mathop{\mathrm{GELU}}(x) = x \cdot 0.5 \cdot (1 + \mathop{\mathrm{erf}}(x / \sqrt{2}))) را پیاده کرده و جایگزین ReLU در شبکه‌ی پیش‌خور کنید. آموزش را برای هر دو فعال‌ساز به مدت 500 گام اجرا کرده و تابع زیان نهایی را مقایسه کنید.

  3. کش کلید-مقدار را به تابع تولید متن اضافه کنید. تنسورهای K و V را بعد از نخستین گذر رو به جلو برای هر لایه ذخیره کرده و برای توکن‌های بعدی از آن‌ها استفاده کنید. میزان افزایش سرعت را اندازه بگیرید: 200 توکن را با و بدون کش تولید کرده و زمان اجرای واقعی را مقایسه کنید.

  4. روش‌های نمونه‌برداری top-k (صرفاً در نظر گرفتن k توکن محتمل‌تر) و top-p (نمونه‌برداری هسته‌ای: در نظر گرفتن کوچک‌ترین مجموعه‌ای از توکن‌ها که مجموع احتمالاتشان از p بیشتر است) را پیاده کنید. کیفیت خروجی را در دمای 0.8 بین دو حالت top-k=50 و top-p=0.95 بسنجید.

  5. برنامه‌ای برای رسم منحنی تابع زیان آموزش بنویسید. مدل را 1000 گام آموزش داده و نمودار زیان در برابر گام را رسم کنید. سه فاز اصلی را مشخص کنید: افت سریع اولیه (یادگیری بایت‌های پرتکرار)، فاز میانی کندتر (یادگیری الگوهای بایتی) و فاز اشباع (بیش‌برازش یا overfitting روی متن آموزشی محدود). شکل این منحنی در مدل 128بُعدی تفاوت چندانی با روند آموزش GPT-4 ندارد.

اصطلاحات کلیدی

اصطلاح در گفتار عامیانه معنای دقیق فنی
خودبازگشتی (Autoregressive) «کلمات را یکی‌یکی تولید می‌کند» هر توکن خروجی وابسته به تمام توکن‌های پیشین است؛ یعنی مدل توزیع احتمال [[M15_PLACEHOLDER_P]] <!-- REVIEW: Placeholder replaced with context tokens --> را مدل‌سازی می‌کند
ماسک علّی (Causal mask) «آینده را نمی‌بیند» یک ماتریس بالا‌مثلثی از مقادیر منفیِ بی‌نهایت که از توجه به موقعیت‌های بعدی در طول آموزش جلوگیری می‌کند
اتنشن چندسر (Multi-head attention) «الگوهای توجه چندگانه» تقسیم Q و K و V به چند سرِ موازی (مثلاً 12 سرِ 64بُعدی در GPT-2) تا هر سر انواع مختلفی از روابط را یاد بگیرد
کش کلید-مقدار (KV Cache) «کش کردن برای سرعت» ذخیره‌ی تنسورهای کلید و ارزشِ محاسبه‌شده از توکن‌های قبلی برای پیشگیری از محاسبات تکراری در حین تولید خودبازگشتی
فاز Prefill «پردازش پرامپت» فاز اول استنتاج که در آن تمام توکن‌های پرامپت به صورت موازی پردازش می‌شوند؛ محدود به توان محاسباتی GPU
فاز Decode «تولید توکن‌ها» فاز دوم استنتاج که توکن‌ها در آن دانه‌به‌دانه تولید می‌شوند؛ محدود به پهنای باند حافظه‌ی GPU
اشتراک وزن (Weight tying) «مشترک کردن امبدینگ‌ها» استفاده از یک ماتریس یکسان برای امبدینگ توکن‌های ورودی و لایه‌ی تصویر خروجی؛ صرفه‌جویی 38 میلیون پارامتر در GPT-2
اتصال باقیمانده (Residual connection) «اتصال جهشی (Skip connection)» افزودن مستقیم ورودی به خروجی یک زیرلایه (x + sublayer(x))؛ عاملی که امکان جریان گرادیان را در شبکه‌های عمیق فراهم می‌کند
نرمال‌سازی لایه‌ای (Layer normalization) «نرمال کردن فعال‌سازی‌ها» نرمال‌سازی روی بُعد ویژگی‌ها با میانگین 0 و واریانس 1، همراه با پارامترهای یادگیری‌پذیر مقیاس و بایاس
تابع زیان آنتروپی متقاطع (Cross-entropy loss) «میزان اشتباه پیش‌بینی‌ها» محاسبه‌ی −log⁡(probability assigned to the correct next token)-\log(\text{probability assigned to the correct next token}) به صورت میانگین روی تمام موقعیت‌ها؛ هدف استاندارد آموزش در مدل‌های زبانی

برای مطالعه‌ی بیشتر

سؤالات متداول

چرا مدل GPT-2 به جای post-norm از معماری pre-norm استفاده می‌کند؟

در ساختار pre-norm، نرمال‌سازی لایه‌ای قبل از اتنشن و شبکه‌ی پیش‌خور قرار می‌گیرد. این ساختار همراه با اتصالات باقیمانده مانع از محو شدن گرادیان در پس‌انتشار می‌شود و امکان آموزش پایدار شبکه‌های عمیق را فراهم می‌سازد.

نقش کش کلید-مقدار (KV Cache) در استنتاج متن چیست؟

کش کلید-مقدار تانسورهای محاسبه‌شده‌ی K و V برای توکن‌های قبلی را ذخیره می‌کند تا در زمان تولید توکن‌های بعدی از ضرب‌های ماتریسی تکراری جلوگیری شود و هزینه‌ی محاسبات به ازای هر توکن کاهش یابد.

اشتراک وزن (weight tying) چه مزیتی در GPT-2 دارد؟

این روش با به کار بردن یک ماتریس مشترک برای امبدینگ توکن‌های ورودی و لایه‌ی تصویر خروجی، 38 میلیون پارامتر از مدل کم می‌کند و مدل را به استفاده از یک فضای بازنمایی یکسان وادار می‌سازد.

تفاوت فاز Prefill با فاز Decode در استنتاج چیست؟

فاز Prefill کل پرامپت را به شکل موازی پردازش می‌کند و به توان محاسباتی خام (FLOPS) وابسته است، در حالی که فاز Decode توکن‌ها را یکی‌یکی می‌سازد و محدود به پهنای باند حافظه است.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.