فاز 7 · درس 2

آموزش پیاده‌سازی خوداتنشن (Self-Attention) از صفر با پایتون

نوع
ساختنی
زمان
حدود 90 دقیقه
زبان‌ها
Python
چندین باریکه‌ی نور متمرکز که روی شبکه‌ای از نقاط هندسی در یک زمینه تاریک تابیده شده‌اند و نقاط را به هم پیوند می‌دهند
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. شباهت با جست‌وجو در پایگاه داده
  5. محاسبه‌ی Q، K و V
  6. ماتریس اتنشن
  7. چرا مقیاس‌بندی می‌کنیم؟
  8. تبدیل امتیازها به وزن با سافت‌مکس
  9. مجموع وزنی مقادیر
  10. پایپ‌لاین کامل
  11. پیاده‌سازی از صفر
  12. گام 1: پیاده‌سازی سافت‌مکس از صفر
  13. گام 2: اتنشن ضرب داخلی مقیاس‌شده
  14. گام 3: کلاس خوداتنشن با تصویر کردن‌های یادگرفتنی
  15. گام 4: اجرا روی یک جمله
  16. گام 5: مصورسازی اتنشن با هیت‌مپ متنی (ASCII)
  17. استفاده از ابزارهای واقعی
  18. خروجی این درس
  19. تمرین‌ها
  20. اصطلاحات کلیدی
  21. برای مطالعه‌ی بیشتر

مدل‌های زبانی مدرن و معماری‌های پیشرفته ترنسفورمر، تسلط امروز خود را مدیون مکانیزم خوداتنشن (Self-Attention) هستند. اگر تلاش کرده باشید معماری شبکه‌های RNN یا LSTM را برای دنباله‌های طولانی به کار ببرید، حتماً با مشکل فراموشی اطلاعات در گام‌های دور یا کندی پردازش ترتیبی روبه‌رو شده‌اید؛ جایی که یک بردار با اندازه‌ی ثابت به گلوگاهی سخت برای یادگیری روابط میان کلمات دور تبدیل می‌شود.

خوداتنشن با حذف گام‌های ترتیبی و فراهم‌کردن امکان توجه هم‌زمان و موازی همه‌ی کلمات به یکدیگر، این گلوگاه را از میان برداشت. در این درس، به جای تکیه بر لایه‌های آماده و سربسته، فرمول مشهور اتنشن ضرب داخلی مقیاس‌شده (Scaled Dot-Product Attention) را با بردارهای پرس‌وجو (Query)، کلید (Key) و مقدار (Value) به کمک کتابخانه‌ی NumPy از صفر پیاده‌سازی می‌کنید تا طرز کار واقعی این موتور محرک هوش مصنوعی را عمیقاً درک کنید.

پیاده‌سازی این درس کاملاً متکی بر محاسبات برداری پایه در CPU شخصی است و برای اجرای کدهای ریاضی آن نیازی به GPU یا ابزارهای ابری نخواهید داشت؛ هرچند که در ادامه، معادل صنعتی آن در PyTorch را نیز مرور خواهیم کرد.

اتنشن مثل یک جدول جست‌وجو است که در آن هر کلمه می‌پرسد «چه کسی برای من اهمیت دارد؟» — و پاسخ را یاد می‌گیرد.

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

  • پیاده‌سازی خوداتنشن (self-attention) با ضرب داخلی مقیاس‌شده از صفر و فقط با استفاده از NumPy؛ شامل تصویر کردن (projection) پرس‌وجو/کلید/مقدار و مجموع وزنی سافت‌مکس
  • ساخت یک لایه‌ی اتنشن چندسر (multi-head attention) که سرها را جدا می‌کند، اتنشن موازی را محاسبه می‌کند و نتایج را به هم پیوند می‌زند
  • ردگیری نحوه‌ی ثبت روابط توکن‌ها در ماتریس اتنشن و توضیح اینکه چرا مقیاس‌بندی با dk\sqrt{d_k} جلوی اشباع سافت‌مکس را می‌گیرد
  • اعمال ماسک علی (causal masking) برای تبدیل اتنشن دوطرفه به اتنشن خودبازگشتی (سبک دیکودر)

صورت مسئله

شبکه‌های عصبی بازگشتی (RNN) توالی‌ها را توکن به توکن پردازش می‌کنند. وقتی به توکن 50 می‌رسید، اطلاعات توکن 1 از 50 مرحله فشرده‌سازی رد شده است. وابستگی‌های دوربرد در یک وضعیت پنهان (hidden state) با اندازه‌ی ثابت فشرده و خرد می‌شوند؛ گلوگاهی که هیچ گیت‌بندی‌ای در LSTM هم نمی‌تواند آن را کامل حل کند.

مقاله‌ی اتنشن بهدنوا (Bahdanau) در سال 2014 راه‌حل را نشان داد: به دیکودر اجازه دهید به تک‌تک موقعیت‌های انکودر نگاه کند و ببیند کدام موقعیت‌ها برای گام فعلی مهم هستند. اما این سازوکار هنوز روی یک RNN سوار بود. مقاله‌ی سال 2017 با عنوان «Attention Is All You Need» سؤالی هوشمندانه‌تر پرسید: چه می‌شود اگر اتنشن تنها سازوکار مدل باشد؟ بدون هیچ بازگشتی. بدون کانولوشن. فقط اتنشن.

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

مفاهیم پایه

شباهت با جست‌وجو در پایگاه داده

اتنشن را مثل یک جست‌وجوی نرم در پایگاه داده در نظر بگیرید:

Traditional database:
  Query: "capital of France"  -->  exact match  -->  "Paris"

Attention:
  Query: "capital of France"  -->  similarity to ALL keys  -->  weighted blend of ALL values

هر توکن سه بردار تولید می‌کند:

  • پرس‌وجو یا Query (Q): «من دنبال چه می‌گردم؟»
  • کلید یا Key (K): «من چه اطلاعاتی دارم؟»
  • مقدار یا Value (V): «اگر انتخاب شوم، چه اطلاعاتی ارائه می‌دهم؟»

ضرب داخلی میان یک پرس‌وجو و همه‌ی کلیدها، امتیازهای اتنشن را می‌سازد. امتیاز بالا یعنی «این کلید با پرس‌وجوی من همخوانی دارد». این امتیازها به مقدارها وزن می‌دهند و خروجی نهایی، مجموع وزنی این مقدارهاست.

محاسبه‌ی Q، K و V

امبدینگ هر توکن از طریق سه ماتریس وزن یادگرفتنی تصویر می‌شود:

Input embeddings (sequence of n tokens, each d-dimensional):

  X = [x1, x2, x3, ..., xn]       shape: (n, d)

Three weight matrices:

  Wq  shape: (d, dk)
  Wk  shape: (d, dk)
  Wv  shape: (d, dv)

Projections:

  Q = X @ Wq    shape: (n, dk)      each token's query
  K = X @ Wk    shape: (n, dk)      each token's key
  V = X @ Wv    shape: (n, dv)      each token's value

تصویر مفهومی برای یک توکن:

             Wq
  x_i ------[*]------> q_i    "What am I looking for?"
       |
       |     Wk
       +----[*]------> k_i    "What do I contain?"
       |
       |     Wv
       +----[*]------> v_i    "What do I offer?"

ماتریس اتنشن

وقتی Q، K و V را برای همه‌ی توکن‌ها داشته باشید، امتیازهای اتنشن یک ماتریس را تشکیل می‌دهند:

Scores = Q @ K^T    shape: (n, n)

              k1    k2    k3    k4    k5
        +-----+-----+-----+-----+-----+
   q1   | 2.1 | 0.3 | 0.1 | 0.8 | 0.2 |   <- how much q1 attends to each key
        +-----+-----+-----+-----+-----+
   q2   | 0.4 | 1.9 | 0.7 | 0.1 | 0.3 |
        +-----+-----+-----+-----+-----+
   q3   | 0.2 | 0.6 | 2.3 | 0.5 | 0.1 |
        +-----+-----+-----+-----+-----+
   q4   | 0.9 | 0.1 | 0.4 | 1.7 | 0.6 |
        +-----+-----+-----+-----+-----+
   q5   | 0.1 | 0.3 | 0.2 | 0.5 | 2.0 |
        +-----+-----+-----+-----+-----+

Each row: one token's attention over the entire sequence

ببینید چطور هر بار یک پرس‌وجو کلیدها را پیمایش می‌کند: هر سطر به تمام توکن‌ها امتیاز می‌دهد، سافت‌مکس این امتیازها را به وزن تبدیل می‌کند، و بردار بافتار (context vector) حاصل ترکیب وزنی مقدارهاست.

چرا مقیاس‌بندی می‌کنیم؟

حاصل‌ضرب‌های داخلی با افزایش بُعد dkd_k بزرگ‌تر می‌شوند. اگر dk=64d_k = 64 باشد، ضرب داخلی می‌تواند به اعدادی در حدود چند ده برسد و ورودی سافت‌مکس را به مناطقی ببرد که گرادیان در آن‌ها ناپدید می‌شود. راه‌حل ساده است: تقسیم بر dk\sqrt{d_k}.

Scaled scores=QKTdk\text{Scaled scores} = \frac{Q K^T}{\sqrt{d_k}}

این کار مقادیر را در محدوده‌ای نگه می‌دارد که سافت‌مکس گرادیان‌های مفیدی تولید کند.

تبدیل امتیازها به وزن با سافت‌مکس

سافت‌مکس امتیازهای خام را در هر سطر به یک توزیع احتمالی تبدیل می‌کند:

Raw scores for q1:   [2.1, 0.3, 0.1, 0.8, 0.2]
                            |
                         softmax
                            |
Attention weights:   [0.52, 0.09, 0.07, 0.14, 0.08]   (sums to ~1.0)

حالا هر توکن مجموعه‌ای از وزن‌ها دارد که نشان می‌دهد چقدر باید به تک‌تک توکن‌های دیگر توجه کند.

مجموع وزنی مقادیر

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

output_i = sum( attention_weight[i][j] * v_j  for all j )

For token 1:
  output_1 = 0.52 * v1 + 0.09 * v2 + 0.07 * v3 + 0.14 * v4 + 0.08 * v5

پایپ‌لاین کامل

X (ورودی)Q = X · WqK = X · WkV = X · WvQ · Kᵀ / √dkسافت‌مکسمجموع وزنیخروجی

فرمول در یک خط:

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \mathop{\mathrm{softmax}}\left( \frac{Q K^T}{\sqrt{d_k}} \right) V
مقیاس‌بندی سافت‌مکستغییر وضعیت مقسوم‌علیه d_k√
SOFTMAX SCALINGDot products grow with the head dimension, so without scaling the scores get large and the softmax saturates onto one token, killing the gradient. Dividing by the square root of d_k cancels that growth and keeps the attention distribution calibrated across any dimension.
ضرب‌های داخلی با ابعاد سر رشد می‌کنند، بنابراین بدون مقیاس‌بندی امتیازها بزرگ می‌شوند و سافت‌مکس روی یک توکن اشباع شده و گرادیان را از بین می‌برد. تقسیم بر ریشه دوم d_k این رشد را خنثی کرده و توزیع اتنشن را در هر بعدی کالیبره نگه می‌دارد.

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

گام 1: پیاده‌سازی سافت‌مکس از صفر

سافت‌مکس لاجیت‌های خام را به احتمال تبدیل می‌کند. برای پایداری عددی (numerical stability)، بیشینه‌ی مقدار را از لاجیت‌ها کم کنید.

import numpy as np

def softmax(x):
    shifted = x - np.max(x, axis=-1, keepdims=True)
    exp_x = np.exp(shifted)
    return exp_x / np.sum(exp_x, axis=-1, keepdims=True)

logits = np.array([2.0, 1.0, 0.1])
print(f"logits:  {logits}")
print(f"softmax: {softmax(logits)}")
print(f"sum:     {softmax(logits).sum():.4f}")

گام 2: اتنشن ضرب داخلی مقیاس‌شده

تابع اصلی. ماتریس‌های Q، K و V را می‌گیرد و خروجی اتنشن را همراه با ماتریس وزن‌ها برمی‌گرداند.

def scaled_dot_product_attention(Q, K, V):
    dk = Q.shape[-1]
    scores = Q @ K.T / np.sqrt(dk)
    weights = softmax(scores)
    output = weights @ V
    return output, weights

گام 3: کلاس خوداتنشن با تصویر کردن‌های یادگرفتنی

یک ماژول کامل خوداتنشن با ماتریس‌های وزن Wq، Wk و Wv که با مقیاس‌بندی خاویر (Xavier) مقداردهی اولیه‌ شده‌اند.

class SelfAttention:
    def __init__(self, d_model, dk, dv, seed=42):
        rng = np.random.default_rng(seed)
        scale = np.sqrt(2.0 / (d_model + dk))
        self.Wq = rng.normal(0, scale, (d_model, dk))
        self.Wk = rng.normal(0, scale, (d_model, dk))
        scale_v = np.sqrt(2.0 / (d_model + dv))
        self.Wv = rng.normal(0, scale_v, (d_model, dv))
        self.dk = dk

    def forward(self, X):
        Q = X @ self.Wq
        K = X @ self.Wk
        V = X @ self.Wv
        output, weights = scaled_dot_product_attention(Q, K, V)
        return output, weights

گام 4: اجرا روی یک جمله

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

sentence = ["The", "cat", "sat", "on", "the", "mat"]
n_tokens = len(sentence)
d_model = 8
dk = 4
dv = 4

rng = np.random.default_rng(42)
X = rng.normal(0, 1, (n_tokens, d_model))

attn = SelfAttention(d_model, dk, dv, seed=42)
output, weights = attn.forward(X)

print("Attention weights (each row: where that token looks):\n")
print(f"{'':>6}", end="")
for token in sentence:
    print(f"{token:>6}", end="")
print()

for i, token in enumerate(sentence):
    print(f"{token:>6}", end="")
    for j in range(n_tokens):
        w = weights[i][j]
        print(f"{w:6.3f}", end="")
    print()

گام 5: مصورسازی اتنشن با هیت‌مپ متنی (ASCII)

وزن‌های اتنشن را روی کاراکترها نگاشت کنید تا تصویری سریع از وضعیت آن داشته باشید.

def ascii_heatmap(weights, tokens, chars=" ░▒▓█"):
    n = len(tokens)
    print(f"\n{'':>6}", end="")
    for t in tokens:
        print(f"{t:>6}", end="")
    print()

    for i in range(n):
        print(f"{tokens[i]:>6}", end="")
        for j in range(n):
            level = int(weights[i][j] * (len(chars) - 1) / weights.max())
            level = min(level, len(chars) - 1)
            print(f"{'  ' + chars[level] + '   '}", end="")
        print()

ascii_heatmap(weights, sentence)

استفاده از ابزارهای واقعی

ماژول nn.MultiheadAttention در PyTorch دقیقاً همان کاری را می‌کند که ساختیم؛ به اضافه‌ی تقسیم سرها و تصویر کردن خروجی:

import torch
import torch.nn as nn

d_model = 8
n_heads = 2
seq_len = 6

mha = nn.MultiheadAttention(embed_dim=d_model, num_heads=n_heads, batch_first=True)

X_torch = torch.randn(1, seq_len, d_model)

output, attn_weights = mha(X_torch, X_torch, X_torch)

print(f"Input shape:            {X_torch.shape}")
print(f"Output shape:           {output.shape}")
print(f"Attention weight shape: {attn_weights.shape}")
print(f"\nAttn weights (averaged over heads):")
print(attn_weights[0].detach().numpy().round(3))

تفاوت کلیدی این است: اتنشن چندسر چندین تابع اتنشن را به طور موازی اجرا می‌کند که هر کدام تصویرهای Q، K و V اختصاصی خود را با اندازه‌ی dk=dmodel/nheadsd_k = d_{\text{model}} / n_{\text{heads}} دارند، سپس نتایج را به هم پیوند می‌زند. این کار به مدل اجازه می‌دهد هم‌زمان به انواع مختلفی از روابط توجه کند.

خروجی این درس

این درس این خروجی را تولید می‌کند:

  • outputs/prompt-attention-explainer.md - پرامپتی برای توضیح اتنشن با تکیه بر استعاره‌ی جست‌وجو در پایگاه داده

تمرین‌ها

  1. تابع scaled_dot_product_attention را طوری تغییر دهید که یک ماتریس ماسک اختیاری بگیرد و پیش از اعمال سافت‌مکس، برخی موقعیت‌ها را برابر با منفی بی‌نهایت بگذارد (ماسک علی/دیکودر به همین شیوه کار می‌کند).
  2. اتنشن چندسر را از صفر پیاده‌سازی کنید: Q، K و V را به بخش‌های n_heads تقسیم کنید، اتنشن را روی هر بخش اجرا کنید، نتایج را به هم پیوند بزنید، و در نهایت از یک ماتریس وزن نهایی Wo عبور دهید.
  3. دو جمله‌ی مختلف با طول یکسان بردارید، آن‌ها را به یک نمونه‌ی واحد از SelfAttention بدهید، و الگوهای اتنشن آن‌ها را مقایسه کنید. چه چیزی تغییر می‌کند؟ چه چیزی ثابت می‌ماند؟

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

اصطلاح در گفت‌وگوهای عامیانه معنای دقیق فنی
پرس‌وجو / Query (Q) «بردار سؤال» تصویری یادگرفتنی از ورودی که نشان می‌دهد این توکن دنبال چه اطلاعاتی می‌گردد
کلید / Key (K) «بردار برچسب» تصویری یادگرفتنی که نشان می‌دهد این توکن چه اطلاعاتی در خود دارد و در برابر پرس‌وجوها سنجیده می‌شود
مقدار / Value (V) «بردار محتوا» تصویری یادگرفتنی حامل محتوای اصلی که بر اساس امتیازهای اتنشن تجمیع می‌شود
اتنشن ضرب داخلی مقیاس‌شده (Scaled dot-product attention) «فرمول اتنشن» فرمول softmax(QKT/dk)V\mathop{\mathrm{softmax}}(QK^T / \sqrt{d_k}) V - مقیاس‌بندی جلوی اشباع سافت‌مکس را در ابعاد بالا می‌گیرد
خوداتنشن (Self-attention) «توکن به خودش و بقیه نگاه می‌کند» نوعی از اتنشن که در آن Q، K و V همگی از یک توالی می‌آیند و به هر موقعیت اجازه می‌دهند به تمام موقعیت‌های دیگر توجه کند
وزن‌های اتنشن (Attention weights) «میزان توجه» توزیع احتمالی روی موقعیت‌ها که حاصل اعمال سافت‌مکس روی ضرب‌های داخلی مقیاس‌شده است
اتنشن چندسر (Multi-head attention) «اتنشن موازی» اجرای چند تابع اتنشن با تصویر کردن‌های مختلف و سپس پیوند زدن نتایج به هم برای رسیدن به بازنمایی‌های غنی‌تر

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

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسچرا خوداتنشن پایه ضرب داخلی را در 1/sqrt(d_k) مقیاس‌بندی می‌کند؟
  2. پیش از درسسه تصویر کردن (projection) در خوداتنشن کدامند؟
  3. پس از درسدر اتنشن چندسر با 8 سر و d_model=512، بعد هر سر چقدر است؟
  4. پس از درسماسک علّی (causal mask) در اتنشن خودبازگشتی از چه چیزی جلوگیری می‌کند؟
  5. پس از درسچرا پیچیدگی خوداتنشن بر حسب طول توالی n برابر با O(n^2) است؟

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

بردارهای پرس‌وجو (Q)، کلید (K) و مقدار (V) در خوداتنشن چه نقشی دارند؟

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

چرا در اتنشن ضرب داخلی، حاصل بر رادیکال بعد کلید تقسیم می‌شود؟

با افزایش ابعاد بردارها، مقدار حاصل‌ضرب نقطه‌ای بزرگ می‌شود و ورودی سافت‌مکس را به مناطقی با شیب بسیار کم می‌برد که منجر به ناپدید شدن گرادیان (Saturation) می‌شود. تقسیم بر رادیکال بعد، مقادیر را در بازه‌ای مناسب نگه می‌دارد.

ماسک علی (Causal Masking) در اتنشن چه کاربردی دارد؟

ماسک علی از ارتباط توکن‌های فعلی با توکن‌های آینده جلوگیری می‌کند تا مدل در کاربردهای خودبازگشتی و تولید متن (مانند دیکودرها)، نتواند کلمات جلوتر را پیش از موعد ببیند.

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

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