برای بررسی معماری GPT، ابتدا باید بدانیم که در سال ۲۰۱۷، نویسندگانی از گوگل مقالهای به نام Attention is All You Need منتشر کردند که در آن معماری ترنسفورمر (Transformer) را معرفی کردند. این معماری جدید به موفقیتهای بینظیری در وظایف ترجمه زبان دست یافت و این مقاله به سرعت به متنی ضروری برای هر کسی که در این حوزه فعالیت میکند تبدیل شد. مانند بسیاری دیگر، وقتی برای اولین بار مقاله را خواندم، ارزش ایدههای نوآورانه آن را درک کردم، اما متوجه نشدم که این مقاله چقدر در حوزههای دیگر زیر چتر گسترده هوش مصنوعی تحولآفرین خواهد بود. در عرض چند سال، محققان معماری ترنسفورمر را برای بسیاری از وظایف دیگر به جز ترجمه زبان، از جمله طبقهبندی تصاویر، تولید تصویر و مسائل تاخوردگی پروتئین، سازگار کردند. به طور خاص، معماری ترنسفورمر تولید متن را متحول کرد و راه را برای مدل GPT و رشد نمایی که در حال حاضر در هوش مصنوعی تجربه میکنیم هموار کرد.
با توجه به فراگیر بودن مدلهای ترنسفورمر در حال حاضر، هم در صنعت و هم در دانشگاه، درک جزئیات نحوه عملکرد آنها مهارتی مهم برای هر متخصص هوش مصنوعی است. این مقاله از بخش آموزش هوش مصنوعی عمدتاً بر معماری مدل GPT تمرکز خواهد داشت که با استفاده از زیرمجموعهای از معماری ترنسفورمر اصلی ساخته شدهاند، اما در انتها به ترنسفورمر اصلی نیز خواهد پرداخت.
این مقاله در رابطه با معماری GPT برای دانشمندان داده باتجربه و مهندسان یادگیری ماشین در نظر گرفته شده است. به طور خاص، فرض میکنیم که شما در جبر تنسوری مسلط هستید، شبکههای عصبی را از ابتدا پیادهسازی کردهاید و با پایتون راحت هستید.
فراخوانی یک مدل GPT
قبل از اینکه به نحوه ساخت یک مدل GPT بپردازیم، اجازه دهید با درک نحوه فراخوانی آن شروع کنیم. فعلاً فرض میکنیم که یک مدل GPT آماده داریم و بر نحوه آمادهسازی ورودی، فراخوانی مدل و تفسیر خروجی تمرکز میکنیم. ایده کلی این است که چند کلمه به عنوان ورودی برای شروع فرآیند تولید ارائه دهیم و متنی را که احتمالاً به دنبال آن ورودی میآید، دریافت کنیم. به عنوان مثال، اگر به یک مدل GPT ورودی “A long time ago” را بدهیم، مدل ممکن است خروجی “in a galaxy far, far away” را برگرداند.
بیایید به کدی که برای فراخوانی مدل خود استفاده میکنیم نگاهی بیندازیم، ورودی “A long time ago” را به آن میدهیم و ۱۰ توکن جدید تولید میکنیم. من از کامنتها برای نشان دادن شکل (shape) هر تنسور استفاده کردهام. جزئیات بیشتر را بعد از کد توضیح خواهم داد.
import tiktoken
def tokenize(text, batch_size):
"""Convert text to numerical tokens and repeat batch_size times."""
encoding = tiktoken.encoding_for_model("davinci")
token_list = encoding.encode(text)
token_tensor = torch.tensor(token_list, dtype=torch.long) # (input_seq_len)
token_tensor = token_tensor.unsqueeze(0) # (1, input_seq_len)
token_tensor = token_tensor.repeat(batch_size, 1) # (batch_size, input_seq_len)
return encoding, token_tensor
def limit_sequence_length(input_tokens, block_size):
"""Limit the input to at most block_size tokens."""
input_seq_len = input_tokens.size(1)
seq_len = min(input_seq_len, block_size)
block_tokens = input_tokens[:, -seq_len:] # (batch_size, seq_len)
return block_tokens
def generate_next_token(model, tokens):
"""Use the highest probability from the Transformer model to choose the next token."""
mask = subsequent_mask(tokens.size(1)) # (1, seq_len, seq_len)
decoder_output = model.decode(tokens, mask) # (batch_size, seq_len, vocab_size)
distribution = model.generator(decoder_output[:, -1, :]) # (batch_size, vocab_size)
next_token = torch.argmax(distribution, dim=1, keepdim=True) # (batch_size, 1)
return next_token
# Define constants.
input_text = "A long time ago"
new_token_count = 10
batch_size = 1
block_size = 1024
# Tokenize the input text.
encoding, tokens = tokenize(input_text, batch_size)
# Create the model.
model = make_model(encoding.n_vocab)
# Iterate until we've generated enough new tokens.
for _ in range(new_token_count):
block_tokens = limit_sequence_length(tokens, block_size) # (batch_size, seq_len)
next_token = generate_next_token(model, block_tokens) # (batch_size, 1)
tokens = torch.cat([tokens, next_token], dim=1) # (batch_size, input_seq_len + 1)
# Print each of the generated token sequences.
print(tokens)
for row in tokens:
print(encoding.decode(row.tolist()))
از آنجا که با رشته “A long time ago” شروع میکنیم، ممکن است تصور کنید که یک ترنسفورمر یک رشته را به عنوان ورودی دریافت میکند. با این حال، درست مانند سایر شبکههای عصبی، یک ترنسفورمر به ورودیهای عددی نیاز دارد، بنابراین رشته ورودی ابتدا باید به توالی از اعداد تبدیل شود. ما این تبدیل را در تابع tokenize با استفاده از یک tokenizer (در مثال ما tiktoken از OpenAI) انجام میدهیم که متن را به تکههایی از چند حرف تقسیم کرده و عددی به نام token را به هر تکه منحصربهفرد اختصاص میدهد. برای دریافت ورودی صحیح برای ترنسفورمر خود، توالی توکنها را در یک تانسور قرار میدهیم و آن را گسترش میدهیم تا شامل بعد دستهای (batch dimension) شود. دلیل آن این است که درست مانند انواع دیگر شبکههای عصبی، ترنسفورمر را میتوان با استفاده از دستهها (batches) برای بهرهبرداری از محاسبات موازی روی GPUها، به کارآمدترین شکل آموزش داد. کد مثال ما در حال اجرای استنتاج روی یک توالی است، بنابراین batch_size ما یک است، اما اگر میخواهید چندین توالی را همزمان تولید کنید، میتوانید با اعداد بزرگتر آزمایش کنید.
پس از اینکه ورودی خود را توکنگذاری کردیم، مدل ترنسفورمر را با استفاده از تابع make_model ایجاد میکنیم که بعداً با جزئیات در مورد آن بحث خواهیم کرد. ممکن است فکر کنید که فراخوانی مدل چندین توکن را در خروجی برمیگرداند، زیرا این سناریوی معمول تولید متن است. با این حال، ترنسفورمر تنها قادر است در هر بار فراخوانی، یک توکن تولید کند. از آنجا که میخواهیم چندین توکن تولید کنیم، از یک حلقه for برای فراخوانی مکرر آن استفاده میکنیم و در هر تکرار، توکن تازه تولید شده را با استفاده از torch.cat به توالی اصلی توکنها اضافه میکنیم.

مدلهای ترنسفورمر به سبک GPT معمولاً دارای یک محدودیت توکن مشخص هستند: به عنوان مثال، gpt-35-turbo (Chat GPT) دارای محدودیت ۴۰۹۶ توکن و gpt-4-32k دارای محدودیت ۳۲۷۶۸ توکن است. از آنجا که ما الحاق توکنهای ورودی و تمام توکنهای خروجی تولید شده تا کنون را به مدل ترنسفورمر منتقل میکنیم، محدودیت توکن مدل به کل تعداد توکنهای ورودی به علاوه خروجی اشاره دارد. در کد خود، ما این محدودیت توکن را با استفاده از ثابت block_size تعریف میکنیم و با توالیهای طولانیتر از توکنها، صرفاً با کوتاه کردن آنها به حداکثر طول پشتیبانی شده در تابع limit_sequence_length برخورد میکنیم.
ما مدل ترنسفورمر را در تابع generate_next_token با فراخوانی model.decode و سپس model.generator فراخوانی میکنیم که با دو بخش اصلی معماری ترنسفورمر مطابقت دارند. بخش رمزگشایی (decoding) انتظار یک ماسک را دارد که ما با استفاده از تابع subsequent_mask آن را ایجاد میکنیم. ما تمام این توابع را بعداً در این مقاله با جزئیات تحلیل خواهیم کرد. مرحله تولید توالی از توزیعهای احتمال را برمیگرداند و ما آخرین مورد را انتخاب میکنیم (بعداً خواهیم دید که چرا)، که از آن برای پیشبینی توکن بعدی استفاده میکنیم. این توزیع حاوی یک مقدار احتمال برای هر توکن ممکن است که نشان میدهد احتمال آمدن آن توکن در ادامه جمله چقدر است.

برای ساده و خوانا کردن کد مثالمان، توکنی را انتخاب میکنیم که بالاترین احتمال را در توزیع خروجی دارد (با استفاده از torch.argmax). در مدلهای واقعی GPT، توکن بعدی با نمونهبرداری از توزیع احتمال انتخاب میشود که مقداری تنوع در خروجی ایجاد میکند و باعث میشود متن طبیعیتر به نظر برسد. اگر به “Completions playground” در Azure AI Studio دسترسی داشته باشید، ممکن است متوجه لغزندههای “Temperature” و “Top probabilities” شده باشید که به شما کنترل میدهند که این نمونهبرداری چگونه انجام شود.
tensor([[ 32, 890, 640, 2084, 3556, 48241, 26430, 34350, 28146, 43264, 3556, 6787, 45859, 13884]]) A long time ago</ spaghetti Rapiddx Rav unresolved</ rail MUCHkeeper
در این مقاله به جای کد آموزش مدل، روی کد معماری مدل ترنسفورمر تمرکز خواهیم کرد، زیرا بخش عمدهای از نوآوریهای ترنسفورمر در آنجا نهفته است. در انتها نکاتی را برای آموزش مدل به شما خواهم داد، در صورتی که علاقهمند به گسترش این کد برای تولید نتایج بهتر باشید.
اکنون درک خوبی از ورودیها و خروجیهای مدل ترنسفورمر خود و نحوه نمونهسازی و فراخوانی آن داریم. در مرحله بعد، به جزئیات پیادهسازی خود مدل خواهیم پرداخت.
مروری بر معماری GPT
بیایید با معماری سطح بالای ترنسفورمر GPT آشنا شویم:

در این نمودار، طبق روال سنتی در تصویرسازیهای ترنسفورمر، دادهها از پایین به بالا جریان مییابند. در ابتدا، توکنهای ورودی ما تحت چند مرحله رمزگذاری قرار میگیرند: آنها با استفاده از یک لایه Embedding رمزگذاری میشوند، پس از آن یک لایه Positional Encoding میآید و سپس دو رمزگذاری با هم جمع میشوند. در مرحله بعد، ورودیهای رمزگذاری شده ما از توالی N مرحله رمزگشایی (decoding) عبور میکنند و به دنبال آن یک لایه نرمالسازی (normalization) میآید. و در نهایت، دادههای رمزگشایی شده خود را از طریق یک لایه خطی (linear) و یک سافتمکس (softmax) ارسال میکنیم و در نهایت به توزیع احتمالی میرسیم که میتوانیم از آن برای انتخاب توکن بعدی استفاده کنیم.
در بخشهای بعدی، نگاه دقیقتری به هر یک از اجزای این معماری خواهیم داشت.
Embedding در معماری GPT
لایه Embedding هر توکن در توالی ورودی را به برداری به طول d_model تبدیل میکند. ورودی ترنسفورمر شامل دستههایی از توالیهای توکن است و شکلی به صورت (batch_size, seq_len) دارد. لایه Embedding هر توکن را که یک عدد واحد است میگیرد، امبدینگ آن را محاسبه میکند که توالی از اعداد به طول d_model است، و تانسوری حاوی هر امبدینگ را به جای توکن اصلی مربوطه برمیگرداند. بنابراین، خروجی این لایه دارای شکلی به صورت (batch_size, seq_len, d_model) است.
import torch.nn as nn class Embeddings(nn.Module): def __init__(self, d_model, vocab_size): super(Embeddings, self).__init__() self.lut = nn.Embedding(vocab_size, d_model) self.d_model = d_model # input x: (batch_size, seq_len) # output: (batch_size, seq_len, d_model) def forward(self, x): out = self.lut(x) * math.sqrt(self.d_model) return out
هدف از استفاده از امبدینگ به جای توکن اصلی، اطمینان از این است که نمایش بردار ریاضی مشابهی برای توکنهایی که از نظر معنایی مشابه هستند، داشته باشیم. به عنوان مثال، کلمات “she” و “her” را در نظر بگیرید. این کلمات از نظر معنایی مشابه هستند، به این معنا که هر دو به یک زن یا دختر اشاره دارند، اما توکنهای مربوطه میتوانند کاملاً متفاوت باشند (مثلاً هنگام استفاده از توکنایزر tiktoken شرکت OpenAI، کلمه “she” با توکن ۷۰۹۱ و “her” با توکن ۳۷۲ مطابقت دارد). امبدینگهای این دو توکن نیز در ابتدا بسیار متفاوت از یکدیگر خواهند بود، زیرا وزنهای لایه امبدینگ به صورت تصادفی مقداردهی اولیه شده و در طول آموزش آموخته میشوند. اما اگر دو کلمه مکرراً در دادههای آموزشی در نزدیکی هم ظاهر شوند، در نهایت نمایشهای امبدینگ به سمت مشابه شدن همگرا میشوند.
Positional Encoding در معماری GPT
لایه Positional Encoding اطلاعاتی در مورد موقعیت مطلق و فاصله نسبی هر توکن در توالی اضافه میکند. برخلاف شبکههای عصبی بازگشتی (RNNs) یا شبکههای عصبی کانولوشنال (CNNs)، ترنسفورمرها ذاتا هیچ تصوری از اینکه هر توکن در کجای توالی ظاهر میشود ندارند. بنابراین، برای ثبت ترتیب توکنها در توالی، ترنسفورمرها به یک Positional Encoding متکی هستند.
راههای زیادی برای رمزگذاری موقعیت توکنها وجود دارد. به عنوان مثال، اگر به جای مقدار هر توکن، موقعیت هر توکن را به عنوان ورودی ارسال کنیم، میتوانیم لایه Positional Encoding را با استفاده از یک ماژول امبدینگ دیگر (مشابه لایه قبلی) پیادهسازی کنیم. بار دیگر، کار را با وزنهای انتخاب شده به صورت تصادفی در این امبدینگ شروع میکنیم. سپس در مرحله آموزش، وزنها یاد میگیرند که موقعیت هر توکن را ثبت کنند.
نویسندگان کد The Annotated Transformer تصمیم گرفتند الگوریتم پیچیدهتری را پیادهسازی کنند که یک نمایش را برای موقعیت توکنها در توالی از پیش محاسبه میکند. از آنجا که ما میخواهیم تا حد امکان دقیقاً از کد آنها پیروی کنیم، از همان رویکرد استفاده خواهیم کرد:
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout, max_len=5000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1) # (max_len, 1)
div_term = torch.exp(
torch.arange(0, d_model, 2) *
-(math.log(10000.0) / d_model)) # (d_model/2)
pe[:, 0::2] = torch.sin(position * div_term) # (max_len, d_model)
pe[:, 1::2] = torch.cos(position * div_term) # (max_len, d_model)
pe = pe.unsqueeze(0) # (1, max_len, d_model)
self.register_buffer('pe', pe)
# input x: (batch_size, seq_len, d_model)
# output: (batch_size, seq_len, d_model)
def forward(self, x):
x = x + Variable(self.pe[:, :x.size(1)], requires_grad=False)
return self.dropout(x)
این رمزگذاری موقعیتی از سینوسها و کسینوسهایی با فرکانسهای مختلف برای پر کردن یک تانسور pe استفاده میکند. به عنوان مثال، در تصویر زیر، مقادیر آبی و قرمز با استفاده از موجهای سینوسی دو فرکانس مختلف محاسبه شدهاند و مقادیر نارنجی و سبز با استفاده از موجهای کسینوسی همان فرکانسها محاسبه شدهاند.
مقادیر نمودارهای سینوسی و کسینوسی در نهایت ستونهای تانسور pe را پر میکنند، همانطور که در زیر نشان داده شده است:

سپس در فاز “forward”، نتیجه x از لایه Embedding قبلی را به عنوان ورودی دریافت میکنیم و مجموع x و pe را برمیگردانیم.
مزیت اصلی پیشمحاسبه مقادیر برای رمزگذاری موقعیتی (به جای استفاده از یک Embedding قابل آموزش) این است که مدل ما در نهایت پارامترهای کمتری برای آموزش خواهد داشت. این کاهش در پارامترها منجر به بهبود عملکرد آموزش میشود که هنگام کار با مدلهای زبانی بزرگ بسیار حائز اهمیت است.
Decoder در معماری GPT
همانطور که در نمای کلی معماری ترنسفورمر دیدیم، مرحله بعدی پس از لایههای Embedding و Positional Encoding، ماژول Decoder است. دیکودر شامل N کپی از یک Decoder Layer و به دنبال آن یک Layer Norm است. در اینجا کلاس Decoder آورده شده است که یک نمونه واحد از DecoderLayer را به عنوان ورودی به سازنده کلاس میگیرد:
class Decoder(nn.Module): def __init__(self, layer, N): super(Decoder, self).__init__() self.layers = clones(layer, N) self.norm = LayerNorm(layer.size) def forward(self, x, mask): for layer in self.layers: x = layer(x, mask) return self.norm(x)
تابع “clones” به سادگی یک لیست PyTorch حاوی N کپی از یک ماژول ایجاد میکند:
def clones(module, N): return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
لایه Layer Norm ورودی با شکل (batch_size, seq_len, d_model) را میگیرد و آن را روی آخرین بعدش نرمالسازی میکند. در نتیجه این مرحله، هر توزیع امبدینگ به عنوان یک نرمال واحد (متمرکز در اطراف صفر و با انحراف معیار یک) شروع میشود. سپس در طول آموزش، شکل توزیع با بهینهسازی پارامترهای a_2 و b_2 برای سناریوی ما تغییر میکند. میتوانید در مقاله Layer Normalization از سال ۲۰۱۶ اطلاعات بیشتری در مورد Layer Norm کسب کنید.
class LayerNorm(nn.Module): def __init__(self, features, eps=1e-6): super(LayerNorm, self).__init__() self.a_2 = nn.Parameter(torch.ones(features)) self.b_2 = nn.Parameter(torch.zeros(features)) self.eps = eps def forward(self, x): mean = x.mean(-1, keepdim=True) std = x.std(-1, keepdim=True) return self.a_2 * (x - mean) / (std + self.eps) + self.b_2
کلاس DecoderLayer که ما کلون میکنیم دارای معماری زیر است:

در اینجا کد مربوطه آمده است:
class DecoderLayer(nn.Module): def __init__(self, size, self_attn, feed_forward, dropout): super(DecoderLayer, self).__init__() self.size = size self.self_attn = self_attn self.feed_forward = feed_forward self.sublayer = clones(SublayerConnection(size, dropout), 2) def forward(self, x, mask): x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask)) return self.sublayer[1](x, self.feed_forward)
در سطح بالا، یک DecoderLayer شامل دو مرحله اصلی است: مرحله (attention) که مسئول ارتباط بین توکنها است، و مرحله پیشرو (feed forward) که مسئول محاسبه توکنهای پیشبینی شده است. در اطراف هر یک از این مراحل، ما اتصالات باقیمانده یا پرشی (residual or skip connections) داریم که با علامتهای مثبت در نمودار نشان داده شدهاند. اتصالات باقیمانده مسیر جایگزینی برای جریان داده در شبکه عصبی فراهم میکنند که اجازه میدهد برخی از لایهها نادیده گرفته شوند. دادهها میتوانند از طریق لایههای درون اتصال باقیمانده جریان یابند، یا میتوانند مستقیماً از طریق اتصال باقیمانده عبور کرده و لایههای درون آن را نادیده بگیرند. در عمل، اتصالات باقیمانده اغلب با شبکههای عصبی عمیق استفاده میشوند، زیرا به همگرایی بهتر آموزش کمک میکنند. میتوانید در مقاله Deep residual learning for image recognition از سال ۲۰۱۵ در مورد اتصالات باقیمانده بیشتر بیاموزید. ما این اتصالات باقیمانده را با استفاده از ماژول SublayerConnection پیادهسازی میکنیم:
class SublayerConnection(nn.Module): def __init__(self, size, dropout): super(SublayerConnection, self).__init__() self.norm = LayerNorm(size) self.dropout = nn.Dropout(dropout) def forward(self, x, sublayer): return x + self.dropout(sublayer(self.norm(x)))
مرحله feed-forward با استفاده از دو لایه خطی با یک تابع فعالساز Rectified Linear Unit (ReLU) در بین آنها پیادهسازی میشود:
class PositionwiseFeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super(PositionwiseFeedForward, self).__init__() self.w_1 = nn.Linear(d_model, d_ff) self.w_2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): return self.w_2(self.dropout(F.relu(self.w_1(x))))
مرحله attention مهمترین بخش ترنسفورمر است، بنابراین بخش بعدی را به آن اختصاص میدهیم.
Masked multi-headed self-attention در معماری GPT
بخش توجه چند-سره در نمودار قبلی را میتوان به معماری زیر بسط داد:

همانطور که از نام آن پیداست، ماژول multi-headed self-attention چندین نمونه از محاسبات attention را به صورت موازی با مقداری پیشپردازش و پسپردازش اضافی دادهها انجام میدهد.
class MultiHeadedAttention(nn.Module): def __init__(self, h, d_model, dropout=0.1): super(MultiHeadedAttention, self).__init__() assert d_model % h == 0 self.d_k = d_model // h self.h = h self.linears = clones(nn.Linear(d_model, d_model), 4) self.attn = None self.dropout = nn.Dropout(p=dropout) def forward(self, query, key, value, mask=None): if mask is not None: mask = mask.unsqueeze(1) # (1, 1, seq_len, seq_len) nbatches = query.size(0) # batch_size # (batch_size, seq_len, d_model) => (batch_size, h, seq_len, d_k) query, key, value = [l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2) for l, x in zip(self.linears, (query, key, value))] # (batch_size, h, seq_len, d_k) x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout) # (batch_size, h, seq_len, d_k) => (batch_size, seq_len, d_model) x = x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k) return self.linears[-1](x)
ورودیهای لایه multi-headed attention شامل سه تانسور به نامهای query (Q) ، key (K) و value (V) است. در مدل خاص ما، تانسور یکسانی را برای هر سه این پارامترها ارسال میکنیم: خروجی x از لایه قبلی که شکلی به صورت (batch_size, seq_len, d_model) دارد (به همین دلیل به آن self-attention یا خود-توجهی میگوییم). ما این سه تانسور را با عبور دادن هر کدام از یک لایه خطی پیشپردازش میکنیم، سپس آنها را به h سر (attention) با اندازه d_k تقسیم میکنیم که در آن h*d_k = d_model، و در نتیجه تانسورهایی با شکل (batch_size, seq_len, h, d_k) به دست میآید. سپس ابعاد ۱ و ۲ را جابهجا میکنیم تا تانسورهایی با شکل (batch_size, h, seq_len, d_k) تولید شود. مرحله بعد، attention را برای هر سر محاسبه میکنیم که منجر به تانسورهایی با همان شکل میشود. و در نهایت، پسپردازش ما تمام سرها را دوباره به تانسورهایی با شکل (batch_size, seq_len, d_model) الحاق میکند و آنها را از یک لایه خطی دیگر عبور میدهد. با استفاده از عملیات تانسوری برای انجام تمام محاسبات توجه در هر سر به صورت موازی، میتوانیم از مزیت کامل GPU بهرهمند شویم.
attention با استفاده از فرمول زیر محاسبه میشود:

در اینجا کدی که فرمول را پیادهسازی میکند آمده است:
# Dimensions of query, key, and value: (batch_size, h, seq_len, d_k) # Dimensions of mask: (1, 1, seq_len, seq_len) def attention(query, key, value, mask=None, dropout=None): d_k = query.size(-1) # (batch_size, h, seq_len, d_k) x (batch_size, h, d_k, seq_len) -> (batch_size, h, seq_len, seq_len) scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) p_attn = F.softmax(scores, dim=-1) # (batch_size, h, seq_len, seq_len) if dropout is not None: p_attn = dropout(p_attn) # (batch_size, h, seq_len, seq_len) x (batch_size, h, seq_len, d_k) -> (batch_size, h, seq_len, d_k) return torch.matmul(p_attn, value), p_attn
در سطح بالا، الگوریتم attention تعیین میکند که به کدام توکنها در توالی ورودی باید توجه بیشتری داشته باشد و سپس از آن اطلاعات برای پیشبینی توکن بعدی استفاده میکند. در تصویر زیر، سایههای تیرهتر نارنجی نشاندهنده توکنهایی هستند که در پیشبینی مرتبطتر هستند.

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

این در طول استنتاج کمی بیهوده به نظر میرسد زیرا ما فقط به آخرین پیشبینی علاقهمندیم. با این حال، این در طول آموزش فوقالعاده مفید است. اگر به ترنسفورمر n توکن به عنوان ورودی بدهید، برای دریافت ورودیهایی با طولهای 1 تا n-1 آموزش میبیند، بنابراین مدل در آینده بهتر میتواند ورودیهایی با طولهای مختلف را مدیریت کند.
ایده در نمودار بالا توسط تانسور p_attn در کد نمایش داده شده است. این تانسور شکلی به صورت (batch_size, h, seq_len, seq_len) دارد، اما بیایید فعلاً اندازه دسته و تعداد سرها را نادیده بگیریم (هر دسته و هر سر به طور یکسان کار میکنند) و فقط یک برش تانسور با شکل (seq_len, seq_len) را در نظر بگیریم. هر ردیف در تانسور p_attn حاوی یک توزیع احتمال است که نشان میدهد تمام توکنهای کلید دیگر چقدر برای توکن پرسوجو (query) مربوط به آن ردیف جالب هستند. تانسور حاصل تمام مقادیر نشان داده شده در تصویر قبلی را در بر میگیرد:

شما میتوانید در کد دقیقاً ببینید که این تانسور چگونه محاسبه میشود. ما ابتدا یک ضرب ماتریسی بین query و key ترانهاده انجام میدهیم. اگر اندازه دسته و تعداد سرها را نادیده بگیریم، پرسوجو و کلید شامل توالی از امبدینگهای با شکل (seq_len, d_k) هستند که نتیجه ارسال ورودی x از طریق لایههای خطی مختلف هستند. وقتی تانسور پرسوجو با شکل (seq_len, d_k) را در تانسور کلید ترانهاده با شکل (d_k, seq_len) ضرب میکنیم، اساساً در حال انجام یک ضرب داخلی (dot-product) بین هر امبدینگ در پرسوجو و تمام امبدینگهای دیگر در کلید هستیم و به یک تانسور scores با شکل (seq_len, seq_len) ختم میشویم. مقدار بزرگ ضرب داخلی نشان میدهد که یک امبدینگ خاص در پرسوجو به یک امبدینگ خاص در کلید “علاقهمند شده است” یا به عبارت دیگر، مدل تمایلی بین دو موقعیت در توالی ورودی کشف کرده است. به عبارت سادهتر، ما اکنون تانسوری داریم که نشان میدهد هر توکن چقدر تمام توکنهای دیگر در توالی را “جالب” یا “مهم” مییابد.
مرحله بعدی اعمال ماسک بر تانسور scores است که باعث میشود مقادیر در مثلث بالایی آن نادیده گرفته شوند. ما این کار را انجام میدهیم زیرا در سناریوی تولید متن به سبک GPT، مدل هنگام پیشبینی توکن بعدی فقط به توکنهای گذشته نگاه میکند. ما از کد زیر برای تعریف ماسکی استفاده میکنیم که حاوی مقدار True در قطر و مثلث پایینی و False در مثلث بالایی آن است:
def subsequent_mask(size): """ Mask out subsequent positions. """ attn_shape = (1, size, size) subsequent_mask = torch.triu(torch.ones(attn_shape), diagonal=1).type(torch.uint8) return subsequent_mask == 0
ما این ماسک را با استفاده از تابع masked_fill بر تانسور scores اعمال میکنیم و تمام مقادیر مثلث بالایی را با یک عدد منفی با بزرگی بسیار زیاد جایگزین میکنیم.
در آخر، یک سافتمکس اعمال میکنیم که هر ردیف در تانسور را به یک توزیع احتمال تبدیل میکند. فرمول سافتمکس را به خاطر دارید؟

از آنجا که e به توان یک عدد منفی بسیار بزرگ نزدیک به صفر است، تمام مقادیر در مثلث بالایی تانسور p_attn اساساً صفر میشوند. مقادیر باقیمانده (در مثلث پایینی و قطر) به احتمالات تبدیل میشوند که مجموع آنها در هر ردیف برابر با یک است.
ممکن است متوجه شده باشید که در کد، هنگامی که تانسورهای پرسوجو و کلید را ضرب کردیم، تمام مقادیر موجود در ماتریس حاصل را بر جذر d_k تقسیم کردیم. ما این کار را انجام دادیم تا واریانس را نزدیک به یک نگه داریم، که تضمین میکند سافتمکس مقادیر احتمالی را به ما میدهد که به خوبی در کل محدوده، از صفر تا یک، توزیع شدهاند. اگر این کار را نمیکردیم، توزیعهای محاسبه شده توسط سافتمکس میتوانستند به بردارهای one-hot نزدیک شوند، جایی که یک مقدار یک است و بقیه همه صفر هستند – که باعث میشود خروجی مدل قابل پیشبینی و رباتیک به نظر برسد.
در این مرحله، ما یک تانسور p_attn داریم که حاوی توزیعهای احتمال در امتداد ردیفهای خود است و نشان میدهد که توکنها چقدر برای یکدیگر جالب هستند. مرحله بعدی استفاده از این معیار علاقه برای تعیین میزان توجهی است که باید به هر توکن ورودی در حین تولید توکن خروجی داشته باشیم. به طور طبیعی، ما به جالبترین توکنها توجه بیشتری خواهیم کرد. ما توکن بعدی را با ضرب تانسور احتمالات در تانسور مقدار (value) خود تولید میکنیم که حاوی امبدینگهای توکن ورودی x پس از اعمال یک لایه خطی است. تانسور حاصل حاوی یک پیشبینی برای هر زیرتوالیه توکن خواهد بود:

درک شهودی این نمودار به این صورت است: برای زیرتوالیه ورودی “A”، ما توجه کامل را به تنها توکن ورودی معطوف میکنیم و ممکن است پیشبینی توکن بعدی مانند “person” را تولید کنیم. برای زیرتوالیه ورودی “A long”، مدل ما آموزش دیده است تا کمی بیشتر به توکن “long” توجه کند تا به توکن “A” ، و ممکن است پیشبینی توکن بعدی “dress” را تولید کند. و به همین ترتیب. هنگام انجام استنتاج میخواهیم توالی ورودی کامل “A long time ago” را در نظر بگیریم، بنابراین فقط به آخرین ردیف در این نمودار اهمیت میدهیم. ما بیشترین توجه را به “ago” داریم، کمی کمتر به “long” توجه میکنیم، کمترین توجه را به دو توکن دیگر داریم و پیشبینی توکن بعدی “in” را تولید میکنیم.
پس از اینکه توجه را برای تمام سرها محاسبه کردیم و نتایج را دوباره به هم الحاق کردیم، یک تانسور خروجی با ابعاد (batch_size, seq_len, d_model) داریم. این تانسور حاوی پیشبینی توکن برای هر زیرتوالی است و تقریباً آماده بازگشت به کاربر است. اما قبل از انجام این کار، به یک مرحله نهایی برای نهایی کردن شکل و محتویات آن نیاز داریم.
Generator در معماری GPT
آخرین مرحله در ترنسفورمر ما Generator است که شامل یک لایه خطی و یک سافتمکس است که به صورت متوالی اجرا میشوند:
class Generator(nn.Module): def __init__(self, d_model, vocab): super(Generator, self).__init__() self.proj = nn.Linear(d_model, vocab) def forward(self, x): return F.log_softmax(self.proj(x), dim=-1)
هدف لایه خطی این است که بعد سوم تانسور ما را از بعد امبدینگ d_model که فقط داخلی است به بعد vocab_size تبدیل کند که توسط کدی که ترنسفورمر ما را فراخوانی میکند قابل درک است. نتیجه یک بعد تانسور (batch_size, seq_len, vocab_size) است. هدف سافتمکس تبدیل مقادیر در بعد سوم تانسور به توزیع احتمال است. این تانسور از توزیعهای احتمال چیزی است که ما به کاربر برمیگردانیم.
شاید به خاطر داشته باشید که در ابتدای این مقاله توضیح دادیم که ورودی ترنسفورمر شامل دستههایی از توالیهای توکن به شکل (batch_size, seq_len) است. و اکنون میدانیم که خروجی ترنسفورمر شامل دستههایی از توالیهای توزیع احتمال به شکل (batch_size, seq_len, vocab_size) است. هر دسته شامل توزیعی است که توکن بعد از اولین توکن ورودی را پیشبینی میکند، توزیع دیگری که توکن بعد از اولین و دومین توکن ورودی را پیشبینی میکند، و به همین ترتیب. آخرین توزیع احتمال هر دسته ما را قادر میسازد توکنی را که به دنبال کل توالی ورودی میآید پیشبینی کنیم، که همان چیزی است که هنگام انجام استنتاج به آن اهمیت میدهیم.
ژنراتور (Generator) آخرین قطعه از معماری ترنسفورمر ما است، بنابراین آمادهایم تا همه را با هم ترکیب کنیم.
کنار هم قرار دادن همه چیز
ما از ماژول DecoderModel برای کپسولهسازی سه قطعه اصلی معماری ترنسفورمر استفاده میکنیم: امبدینگها، دیکودر و ژنراتور.
def __init__(self, decoder, embed, generator): super(DecoderModel, self).__init__() self.embed = embed self.decoder = decoder self.generator = generator def forward(self, x, mask): return self.decode(x, mask) def decode(self, x, mask): return self.decoder(self.embed(x), mask)
فراخوانی decode فقط امبدینگها و دیکودر را اجرا میکند، بنابراین اگر بخواهیم تمام مراحل ترنسفورمر را اجرا کنیم، باید decode و به دنبال آن generator را فراخوانی کنیم. این دقیقاً همان کاری است که در تابع generate_next_token کد استنتاج که در ابتدای این پست نشان دادم، انجام دادیم.
کد استنتاج همچنین یک تابع make_model را فراخوانی میکند که یک نمونه از DecoderModel را برمیگرداند. این تابع تمام مؤلفههایی را که تاکنون در مورد آنها بحث کردیم مقداردهی اولیه کرده و طبق نمودار معماری در ابتدای این پست، آنها را در کنار هم قرار میدهد:
def make_model(vocab_size, N=6, d_model=512, d_ff=2048, h=8, dropout=0.1): c = copy.deepcopy attn = MultiHeadedAttention(h, d_model) ff = PositionwiseFeedForward(d_model, d_ff, dropout) position = PositionalEncoding(d_model, dropout) model = DecoderModel( Decoder(DecoderLayer(d_model, c(attn), c(ff), dropout), N), nn.Sequential(Embeddings(d_model, vocab_size), c(position)), Generator(d_model, vocab_size)) for p in model.parameters(): if p.dim() > 1: nn.init.xavier_uniform_(p) return model
ما اکنون تمام قطعات مورد نیاز برای پیادهسازی معماری ترنسفورمر به سبک GPT را در اختیار داریم!
کار را با چند فکر در مورد آموزش به پایان میرسانیم و مقایسهای کوتاه بین ترنسفورمر کامل و زیرمجموعه به سبک GPT انجام خواهیم داد.
آموزش مدل GPT
کد آموزش ترنسفورمر به سبک GPT همانند کد آموزش هر شبکه عصبی دیگری است – با این تفاوت که در سناریوی ما، برای هر توالی ورودی از توکنها، انتظار داریم خروجی توالی باشد که یک موقعیت از سمت راست شروع میشود. به عنوان مثال، اگر به آن “A long time ago” را به عنوان ورودی بدهیم، انتظار داریم نمونهبرداری از احتمالات بازگردانده شده توسط مدل، “long time ago in” را تولید کند.
اگر تجربه آموزش شبکههای عصبی را دارید و میخواهید ترنسفورمر را خودتان آموزش دهید، میتوانید از هر کدی که در گذشته نوشتهاید مجدداً استفاده کرده و آن را با سناریوی ما مطابقت دهید. اگر نیاز به راهنمایی دارید، توصیه میکنم از کد و توضیحات در بخش دوم Annotated Transformer پیروی کنید. در هر صورت، به دسترسی به یک GPU سریع، به صورت محلی یا در فضای ابری نیاز دارید. من البته به آموزش در فضای ابری روی Azure علاقه دارم!
مقایسه با معماری GPT ترانسفورماتور کامل
هنگامی که معماری ترنسفورمر به سبک GPT را درک کردید، تنها یک قدم کوتاه با درک ترنسفورمر کامل همانطور که در مقاله Attention is all you need ارائه شده است، فاصله دارید. در زیر میتوانید نمودار معماری ترنسفورمر ارائه شده در مقاله را مشاهده کنید که بخشهایی را که در این پست پوشش دادیم با کادر نارنجی محصور شده است.

ترنسفورمر کامل یک بخش رمزگذار (encoder) در سمت چپ و یک بخش رمزگشا (decoder) در سمت راست دارد. قصد اصلی مقاله ارائه معماری برای ترجمه ماشینی بود. در آن زمینه، انکودر برای پردازش زبان ورودی و دیکودر برای تولید زبان خروجی استفاده میشد.
میتوانید ببینید که علاوه بر خود-توجهی چند-سره ماسکشده که در ترنسفورمر به سبک GPT استفاده میشود، ترنسفورمر کامل دارای دو بلوک توجه چند-سره دیگر است. بلوک موجود در انکودر ماسک نشده است، به این معنی که تانسور p_attn که در بخش توجه دیدیم، مقادیر بخش مثلثی بالاییاش صفر نشده است. دلیل آن این است که در ترجمه ماشینی، تولید یک توکن واحد از زبان خروجی ممکن است مستلزم آن باشد که مدل به توکنهای زبان ورودی در تمام موقعیتهای توالی، از جمله موقعیتهای قبلی و بعدی، توجه کند. بلوک توجه چند-سره اضافی در بخش دیکودر، یک لایه “cross-attention” (در مقابل “self-attention”) است، به این معنی که کلید و مقدار آن از منبعی متفاوت از پرسوجو میآیند، همانطور که در نمودار میبینید. دلیل آن این است که مدل باید در حین پیشبینی توکنها در زبان خروجی، درک کند که چقدر باید به هر توکن در زبان ورودی توجه کند. بقیه قطعات نمودار مشابه بخشهای ترنسفورمر به سبک GPT هستند و قبلاً در این پست توضیح داده شدهاند.
نتیجهگیری
در این مقاله، ما معماری یک مدل ترنسفورمر به سبک GPT را با جزئیات مورد بحث قرار دادیم و معماری ترنسفورمر اصلی را در سطح کلی پوشش دادیم. با توجه به محبوبیت روزافزون مدلهای GPT در صنعت و اینکه چقدر تکرارهای مدل ترنسفورمر اصلی در مقالات اخیر تکرار میشوند، امیدوارم این دانش در شغل یا تحصیل شما مفید باشد. اگر میخواهید عمیقتر شوید، شما را تشویق میکنم که پروژه مرتبط گیتهاب را کلون کرده و کد را بررسی کنید. در هر بخشی از کد که برای شما واضح نیست Breakpoint بگذارید، آن را اجرا کنید و متغیرهایش را بازرسی کنید. اگر به GPU دسترسی دارید، کدی بنویسید که آن را آموزش دهد و بهبود عملکرد پیشبینیهایش را مشاهده کنید.
با دانش جدیدی که به دست آوردهاید، امیدوارم بتوانید به جامعه هوش مصنوعی کمک کنید تا برخی از تصورات غلط مرتبط با این مدلها را ابهامزدایی کند. بسیاری از مردم در عامه فکر میکنند که این مدلها درک سطح بالاتری از جهان دارند، که اکنون میدانید اینطور نیست. آنها صرفاً از عملیات ریاضی و آماری تشکیل شدهاند که هدفشان پیشبینی توکن بعدی بر اساس دادههای آموزشی قبلی است. شاید نسخههای آینده مدلهای مولد درک بهتری از جهان داشته باشند و پیشبینیهای حتی بهتری ایجاد کنند؟ اگر چنین باشد، من اینجا خواهم بود تا همه چیز را در مورد آن به شما بگویم.
سوالات متداول درباره مدل GPT
تفاوت اصلی بین مدل GPT و معماری ترنسفورمر اصلی چیست؟
مدلهای GPT صرفاً از بخش دیکودر (Decoder) ترنسفورمر استفاده میکنند، در حالی که معماری اصلی ترنسفورمر که برای ترجمه طراحی شده بود، شامل هر دو بخش انکودر و دیکودر است.
چرا در خود-توجهی (Self-Attention) مدلهای GPT از ماسک استفاده میشود؟
ماسک کردن باعث میشود که مدل در هنگام پیشبینی یک توکن، فقط به توکنهای قبل از آن دسترسی داشته باشد و نتواند توکنهای “آینده” را ببیند، که این موضوع برای فرآیند تولید متن ضروری است.
نقش لایه Positional Encoding در ترنسفورمر چیست؟
از آنجا که ترنسفورمرها برخلاف مدلهای قدیمیتر (مانند RNN)، دادهها را به صورت موازی و نه ترتیبی پردازش میکنند، Positional Encoding اطلاعات مربوط به موقعیت و ترتیب هر کلمه در جمله را به مدل اضافه میکند.
آیا مدل GPT میتواند چندین توکن را به صورت همزمان تولید کند؟
خیر، ترنسفورمرهای مولد در هر بار فراخوانی تنها یک توکن تولید میکنند. برای تولید یک متن طولانی، مدل باید در یک حلقه مکرراً فراخوانی شود و هر توکن جدید به ورودی قبلی اضافه گردد.
مسیر تخصص در مدل های زبانی
درک عمیق معماری ترنسفورمر و جزئیات ریاضیاتی پشت مدل GPT، شما را در میان درصدی اندک از متخصصانی قرار میدهد که واقعاً میدانند در قلب هوش مصنوعی چه میگذرد. اما دانشِ معماری، تنها قطعه اول از این پازل بزرگ است؛ چالش واقعی زمانی آغاز میشود که بخواهید این ساختارها را برای حل مسائل دنیای واقعی بهینهسازی کنید، آنها را بر روی دادههای اختصاصی آموزش دهید و سیستمهای هوشمندی بسازید که فراتر از پیشبینی یک توکن ساده عمل میکنند.
برای تبدیل شدن به یک متخصص ارشد در دنیای مدلهای زبانی بزرگ، نیاز به تجربهای فراتر از مطالعه مقالات دارید. با آموزش LLM و NLP مسیری را طراحی کردهایم که در آن، مفاهیم تئوریکِ ترنسفورمرها را به مهارتهای عملیاتی در سطح محصولات جهانی گره میزنیم تا بتوانید پیشرفتهترین سیستمهای مبتنی بر متن را از صفر تا صد پیادهسازی و مدیریت کنید.
- آموزش کاربردی و پیشرفته ترنسفورمرها، تکنیکهای Fine-Tuning و ساخت سیستمهای مدرن مبتنی بر LLM مانند RAG.
- ساخت پروژههای جذاب و حرفهای نظیر سیستمهای پرسش و پاسخ پیشرفته برای متون فارسی و انگلیسی و توسعه چتباتهای هوشمند.

