ساختن یه ترنسفورمر کوچیک، خط به خط
جواب کوتاه
یه ترنسفورمر کامل رو میشه با حدود صد خط پایتون، بی هیچ کتابخونه، ساخت. شش بخش داره؛ تبدیل حرفها به عدد، یه کلاس کوچیک برای عددهایی که یادشون میمونه از کجا اومدن تا مشتق خودکار حساب بشه، وزنهای تصادفی، مدل (جاسازی حرف و جا، توجه دوسر با ماسک، میونبر و پیشخور، و خروجی)، حلقهی آموزش با خطای آنتروپی متقاطع و بهینهساز آدام، و نوشتن حرف به حرف. ما همین کد رو اجرا کردیم؛ با ۳۶۸۰ پارامتر و حدود یه دقیقه، خطا از ۳٫۲۱ به حدود ۰٫۳۵ رسید و مدل نوشت «باد میوزه. ماه ماه. باد میوزه».
یعنی چی
تا خودتون یه بار نسازیدش، ترنسفورمر یه کم جادوئه. توی این مسیر همهی تیکههاش رو جدا یاد گرفتیم؛ توجه، ماسک، میونبر، پیشخور، آموزش. حالا میخوایم همه رو کنار هم بذاریم و یه ترنسفورمر واقعی، کوچیک ولی کامل، از صفر بنویسیم.
کل کد ۹۴ خط پایتونه، و هیچ کتابخونهای جز دو تا کتابخونهی همراه خود پایتون، یعنی ریاضی و عدد تصادفی، لازم نداره. حتی مشتق گرفتن رو هم خودمون مینویسیم. ما همین کد رو روی یه لپتاپ معمولی اجرا کردیم، و همهی عددهایی که توی این درس میبینید، از همون اجراست.
مدل یه متن کوتاه فارسی رو حرف به حرف یاد میگیره و بعد خودش ادامه میده. مثل یه بچه که یه شعر کوتاه رو اونقدر میشنوه تا بتونه خودش بخونه. کد رو بخش به بخش میبینیم، و کنار هر بخش میگیم به کدوم درس قبلی وصله.
نقشه
فایل شش بخش داره، که هر کدوم با یه شماره توی کد علامت خورده:
یک، داده؛ متن به عدد تبدیل میشه. دو، یه کلاس کوچیک به اسم Val، یعنی «مقدار»، که هر عدد یادش میمونه از چی ساخته شده، تا بعدا بشه مشتق گرفت. سه، وزنها؛ چند جدول عدد تصادفی. چهار، مدل؛ از حرفها، احتمال حرف بعدی رو حساب میکنه. پنج، آموزش؛ هزار بار حدس، اندازهگیری خطا، و هل دادن وزنها. شش، نوشتن؛ مدل حرف به حرف یه متن تازه میسازه.
داده
اول متن آموزش. چهار جملهی خیلی کوتاه، «بارون میاد. باد میوزه. ماه میتابه. آب میره.»، که بیست بار تکرار شده تا مدل چیزی برای یاد گرفتن داشته باشه. بعد فهرست حرفهای متفاوتش رو میسازیم و به هر حرف یه شماره میدیم:
import math, random
random.seed(42)
# 1. data: characters become numbers
text = "بارون میاد. باد میوزه. ماه میتابه. آب میره. " * 20
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
data = [stoi[c] for c in text]
V, T, D, H = len(chars), 8, 16, 2 # vocab, context, width, heads
stoi یعنی «حرف به عدد» و itos برعکسش. متن ما ۱۵ حرف متفاوت داره، با فاصله و نقطه و نیمفاصله، پس V، یعنی اندازهی واژگان، ۱۵ه. T طول پنجرهست؛ مدل هر بار ۸ حرف رو میبینه. D پهنای بردارهاست، ۱۶، و H تعداد سرهای توجه، ۲. توی مدلهای واقعی این عددها دهها هزار، چند هزار و چند دهان، ولی فکرشون همینه.
چرا حرف به حرف
مدلهای واقعی با تکهها کار میکنن، که معمولا تیکهای از یه کلمهن. ما برای سادگی هر حرف رو یه تکه گرفتیم. خوبیش اینه که به هیچ برنامهی تکهسازی نیاز نداریم و واژگان خیلی کوچیکه. بدیش اینه که متنها خیلی بلندتر میشن؛ یه کلمهی پنجحرفی، پنج تکهست، نه یکی.
برای یه متن کوچیک و یه آزمایش، این بهترین انتخابه. اگه بخواید همین کد رو روی یه کتاب کامل امتحان کنید، میبینید با پنجرهی ۸ حرفی، مدل فقط میتونه تیکههای خیلی کوتاه رو یاد بگیره، و برای فهمیدن بیشتر، پنجرهی بلندتر و مدل بزرگتر لازمه.
عددی که یادش میمونه
این مهمترین و عجیبترین بخش کده. برای پسانتشار، باید بدونیم اگه هر وزن یه ذره عوض بشه، خطا چقدر عوض میشه. کتابخونههای یادگیری عمیق این کار رو خودکار میکنن. ما خودمون مینویسیمش:
# 2. a number that remembers how it was made
class Val:
def __init__(self, x, kids=(), grads=()):
self.x, self.g, self.kids, self.grads = x, 0.0, kids, grads
def __add__(a, b):
b = b if isinstance(b, Val) else Val(b)
return Val(a.x + b.x, (a, b), (1.0, 1.0))
def __mul__(a, b):
b = b if isinstance(b, Val) else Val(b)
return Val(a.x * b.x, (a, b), (b.x, a.x))
def __pow__(a, k): return Val(a.x ** k, (a,), (k * a.x ** (k - 1),))
def exp(a): e = math.exp(a.x); return Val(e, (a,), (e,))
def log(a): return Val(math.log(a.x), (a,), (1 / a.x,))
def relu(a): return Val(max(0.0, a.x), (a,), (float(a.x > 0),))
__radd__ = __add__; __rmul__ = __mul__
def __neg__(a): return a * -1
def __sub__(a, b): return a + (-b)
def __truediv__(a, b): return a * b ** -1
def backward(self):
order, seen = [], set()
def visit(v):
if id(v) not in seen:
seen.add(id(v))
for k in v.kids: visit(k)
order.append(v)
visit(self); self.g = 1.0
for v in reversed(order):
for k, d in zip(v.kids, v.grads): k.g += d * v.g
هر Val چهار چیز داره؛ x خود عدده، g مشتق خطا نسبت به این عدده که بعدا پر میشه، kids عددهاییان که این عدد از اونها ساخته شده، و grads مشتق این عدد نسبت به هر کدوم از اونها. مثلا وقتی a ضرب در b میشه، مشتق نتیجه نسبت به a، خود bه، و نسبت به b، خود a. همین رو توی __mul__ نوشتیم.
تابع backward از آخر شروع میکنه. اول همهی عددها رو به ترتیبی میچینه که هر عدد بعد از سازندههاش بیاد. بعد از آخر به اول میره، و مشتق هر عدد رو، ضرب در مشتق محلی، به سازندههاش اضافه میکنه. این همون قاعدهی زنجیرهایه، و چون اضافه میکنه، نه جایگزین، اگه یه عدد چند جا استفاده شده باشه، اثر همهی جاها جمع میشه.
مشتق، با عدد
بیاید با یه مثال کوچیک ببینیم. فرض کنید a برابر ۲ و b برابر ۳ باشه، و c رو اینطوری بسازیم؛ a ضرب در b، بهعلاوهی a. پس c میشه ۶ بهعلاوهی ۲، یعنی ۸:
حالا c.backward(). مشتق c نسبت به خودش یکه. c از جمع دو چیز ساخته شده؛ مشتق جمع نسبت به هر دو طرف یکه، پس به حاصلضرب ۱ میرسه و به a هم ۱. حاصلضرب، به a، مقدار b یعنی ۳ رو ضرب در ۱ میده، و به b، مقدار a یعنی ۲ رو. پس a روی هم ۱ بهعلاوهی ۳، یعنی ۴ میگیره، و b میگیره ۲.
امتحانش سادهست؛ اگه a رو یه ذره، مثلا ۰٫۰۰۱، زیاد کنیم، c میشه ۲٫۰۰۱ ضرب در ۳ بهعلاوهی ۲٫۰۰۱، یعنی ۸٫۰۰۴؛ چهار برابر تغییر a. درست همون ۴. کل مدل ما، با هزاران عدد، فقط یه نسخهی خیلی بزرگتر از همین گرافه.
از کجا بدونیم مشتق درسته
وقتی خودتون مشتق خودکار مینویسید، یه غلط کوچیک، مثلا یه علامت منفی جاافتاده، باعث میشه مدل یاد نگیره و هیچ خطایی هم نشون داده نشه. یه آزمون ساده هست که بهش آزمون مشتق میگن. یه وزن رو برمیدارید، یه ذره، مثلا ۰٫۰۰۰۱، زیادش میکنید و خطا رو حساب میکنید، بعد همونقدر کمش میکنید و دوباره حساب میکنید.
فرق این دو خطا، تقسیم بر دو برابر اون ذره، تقریب مشتقه. اگه با چیزی که backward حساب کرده، تا چند رقم اعشار یکی بود، کدتون درسته. این آزمون کنده، چون برای هر وزن دو بار کل مدل رو اجرا میکنه، پس فقط برای چند وزن تصادفی انجامش میدن. همون مثال a و b بالا، یه آزمون مشتق دستی بود.
وزنها
حالا وزنهای مدل. هر کدوم یه جدول عدد تصادفی کوچیکه، با میانگین صفر و پراکندگی ۰٫۳. mat یه جدول با r ردیف و c ستون میسازه:
# 3. the weights: small random numbers
def mat(r, c): return [[Val(random.gauss(0, 0.3)) for _ in range(c)] for _ in range(r)]
W = {"tok": mat(V, D), "pos": mat(T, D),
"q": mat(D, D), "k": mat(D, D), "v": mat(D, D), "o": mat(D, D),
"up": mat(D, 4 * D), "down": mat(4 * D, D), "out": mat(D, V)}
params = [p for m in W.values() for row in m for p in row]
tok جدول جاسازی حرفهاست؛ برای هر کدوم از ۱۵ حرف، یه بردار ۱۶تایی. pos جدول جاست؛ برای هر کدوم از ۸ جا، یه بردار ۱۶تایی. q، k، v و o برای ساختن پرسش و کلید و مقدار و برگردوندن خروجی توجهن. up و down دو لایهی پیشخورن، و out آخر کار هر بردار رو به ۱۵ امتیاز، یکی برای هر حرف، تبدیل میکنه. params همهی این عددها رو توی یه فهرست جمع میکنه تا موقع آموزش راحت بهشون دسترسی داشته باشیم.
شمردن پارامترها
بیاید بشمریم، مثل درس پارامترها. tok، ۱۵ ضرب در ۱۶، یعنی ۲۴۰. pos، ۸ ضرب در ۱۶، یعنی ۱۲۸. q و k و v و o، هر کدوم ۱۶ ضرب در ۱۶، یعنی ۲۵۶، روی هم ۱۰۲۴. up، ۱۶ ضرب در ۶۴، یعنی ۱۰۲۴، و down هم همینقدر. out، ۱۶ ضرب در ۱۵، یعنی ۲۴۰.
جمع همه میشه ۳۶۸۰، و برنامه هم آخر کار دقیقا همین عدد رو چاپ کرد. ببینید بیشترین سهم، ۲۰۴۸ تا، مال پیشخوره، بیش از نصف؛ همون چیزی که توی مدلهای بزرگ هم هست. توجه ۱۰۲۴ تا داره، و جاسازیها و خروجی بقیه. یه مدل زبانی کوچیک امروزی چند میلیارد پارامتر داره؛ یعنی حدود یک میلیون برابر این.
سه ابزار
سه تابع کمکی که همهجا لازم میشن:
def lin(x, m): return [sum((x[i] * m[i][j] for i in range(len(x))), Val(0)) for j in range(len(m[0]))]
def softmax(z):
mx = max(v.x for v in z); e = [(v - mx).exp() for v in z]; s = sum(e, Val(0))
return [v / s for v in e]
def norm(x):
ms = sum((v * v for v in x), Val(0)) * (1 / len(x))
return [v * (ms + 1e-5) ** -0.5 for v in x]
lin ضرب یه بردار در یه جدوله؛ برای هر ستون، جمع ضربهای عنصر به عنصر. همهی لایههای خطی مدل همینن. softmax امتیازها رو به احتمال تبدیل میکنه؛ اول بزرگترین امتیاز رو از همه کم میکنه، که جواب رو عوض نمیکنه ولی جلوی بزرگ شدن بیش از حد توان رو میگیره، بعد توان میگیره و تقسیم بر جمع میکنه.
norm نسخهی سادهی نرمالسازیه؛ هر بردار رو تقسیم بر ریشهی میانگین مربعهاش میکنه، تا اندازهش نزدیک یک بمونه. این نوع، که بهش نرمالسازی با ریشهی میانگین مربع میگن، میانگین رو کم نمیکنه و سادهتره. ۰٫۰۰۰۰۱ هم اضافه شده تا هیچوقت بر صفر تقسیم نشه.
جاسازی
حالا قلب کار، تابع forward. ورودیش idsه، یعنی شمارهی چند حرف پشت هم، و خروجیش برای هر جا، ۱۵ امتیاز برای حرف بعدی:
# 4. the model: embed, attend, think, guess
def forward(ids):
xs = [[a + b for a, b in zip(W["tok"][t], W["pos"][p])] for p, t in enumerate(ids)]
hs = [norm(x) for x in xs]
q = [lin(h, W["q"]) for h in hs]; k = [lin(h, W["k"]) for h in hs]; v = [lin(h, W["v"]) for h in hs]
hd = D // H
for i in range(len(ids)):
out = []
for hh in range(H):
sl = slice(hh * hd, (hh + 1) * hd)
sc = [sum((a * b for a, b in zip(q[i][sl], k[j][sl])), Val(0)) * (1 / math.sqrt(hd)) for j in range(i + 1)]
w = softmax(sc) # only j <= i: the mask
out += [sum((w[j] * v[j][sl][d] for j in range(i + 1)), Val(0)) for d in range(hd)]
xs[i] = [a + b for a, b in zip(xs[i], lin(out, W["o"]))] # shortcut
m = [u.relu() for u in lin(norm(xs[i]), W["up"])]
xs[i] = [a + b for a, b in zip(xs[i], lin(m, W["down"]))] # shortcut
return [lin(norm(x), W["out"]) for x in xs]
خط اول، برای هر حرف، بردار حرف و بردار جاش رو جمع میکنه. حالا xs یه فهرست ۸ تایی از بردارهای ۱۶تاییه؛ همون جریان اصلی که از بلوک رد میشه. خط دوم، قبل از توجه، هر بردار نرمال میشه؛ این همون پیشنرمالسازیه که مدلهای امروزی دارن.
پرسش، کلید و مقدار
خط سوم، از هر بردار نرمالشده، با سه ضرب جدا، پرسش، کلید و مقدار ساخته میشه. هر کدوم یه فهرست ۸ تایی از بردارهای ۱۶تاییه:
بعد حلقه روی هر جا، i، شروع میشه. چون دو سر داریم، هر بردار ۱۶تایی به دو نیمهی ۸تایی بریده میشه؛ hd، یعنی اندازهی هر سر، ۸ه، و sl نیمهی مربوط به هر سر رو برمیداره. برای هر سر، امتیاز پرسش i با کلید هر جا ضرب نقطهای میشه و تقسیم بر ریشهی ۸، تا امتیازها خیلی بزرگ نشن؛ همون توجه مقیاسدار.
دو سر
هر سر، امتیازهای خودش، وزنهای خودش و خروجی ۸تایی خودش رو داره، و آخر کار، دو خروجی پشت هم گذاشته میشن تا دوباره ۱۶تایی بشن. این همون توجه چندسره؛ هر سر میتونه دنبال یه چیز متفاوت بگرده، مثلا یکی حرف قبلی، یکی شروع کلمه.
بعد این بردار ۱۶تایی در جدول o ضرب میشه، که اطلاعات دو سر رو با هم قاطی میکنه. اگه بخواید یه سر داشته باشید، کافیه H رو ۱ کنید؛ اگه چهار سر، ۴. فقط D باید بر H بخشپذیر باشه.
ماسک، توی یه کلمه
دنبال ماسک میگردید؟ توی کد ما هیچ جدول ماسکی نیست. فقط یه چیز هست؛ range(i + 1). یعنی برای جای i، امتیاز فقط با جاهای ۰ تا i حساب میشه، نه بعدش:
همین یه کلمه، ماسک آینده رو کامل پیاده میکنه. حرف سوم فقط به حرف اول و دوم و خودش نگاه میکنه، و هیچوقت نمیتونه حرف چهارم رو، که باید پیشبینیش کنه، ببینه. توی کتابخونههای واقعی، چون همهی جاها با هم و با ماتریس حساب میشن، جدول کامل حساب میشه و نیمهی بالاش با منفی بینهایت پر میشه؛ نتیجه همونه، فقط سریعتر.
بعد سافتمکس روی همین امتیازها، و خروجی هر سر، جمع وزندار مقدارهای همون جاهای مجاز. خط out += دقیقا همین رو میگه؛ برای هر عدد از ۸ عدد سر، جمع وزن j ضرب در مقدار j، روی همهی jهای مجاز.
میونبر و پیشخور
بعد از توجه، خروجی در o ضرب میشه و به خود xs[i] اضافه میشه، نه اینکه جاش رو بگیره. این همون میونبره؛ اطلاعات اصلی همیشه رد میشه و توجه فقط یه چیزی بهش اضافه میکنه.
بعد، پیشخور؛ دوباره نرمالسازی، ضرب در up که بردار رو از ۱۶ به ۶۴ میبره، relu که عددهای منفی رو صفر میکنه، و ضرب در down که دوباره ۱۶ میکنه. این هم با میونبر اضافه میشه. این دو خط، با توجه بالاشون، یه بلوک کامل ترنسفورمرن. مدل ما فقط یه بلوک داره؛ مدلهای بزرگ دهها تا پشت هم دارن.
خروجی
خط آخر forward، هر بردار رو یه بار دیگه نرمال میکنه و در out ضرب میکنه، که ۱۵ عدد میده؛ امتیاز هر حرف برای جای بعدی. به این امتیازهای خام لاجیت میگن، و هنوز احتمال نیستن؛ سافتمکس بعدا اونها رو احتمال میکنه.
یه نکتهی مهم؛ forward برای هر ۸ جا، با هم، پیشبینی میکنه. جای اول حرف دوم رو پیشبینی میکنه، جای دوم حرف سوم رو، و همینطور. یعنی از یه تیکهی ۸ حرفی، ۸ تمرین با هم داریم، و چون ماسک هست، هیچکدوم جواب رو از قبل نمیبینه. این همون آموزش موازیه که ترنسفورمر رو از شبکههای بازگشتی تندتر کرد.
آموزش
حالا حلقهی آموزش. هزار قدم، و هر قدم پنج کار:
# 5. training: guess the next character, nudge every weight
mom = [0.0] * len(params); sq = [0.0] * len(params)
for step in range(1, 1001):
s = random.randrange(len(data) - T - 1)
ids, tgt = data[s:s + T], data[s + 1:s + T + 1]
logits = forward(ids)
loss = sum((-softmax(l)[t].log() for l, t in zip(logits, tgt)), Val(0)) * (1 / T)
for p in params: p.g = 0.0
loss.backward()
for n, p in enumerate(params): # Adam
mom[n] = 0.9 * mom[n] + 0.1 * p.g; sq[n] = 0.99 * sq[n] + 0.01 * p.g ** 2
p.x -= 0.01 * (mom[n] / (1 - 0.9 ** step)) / ((sq[n] / (1 - 0.99 ** step)) ** 0.5 + 1e-8)
if step == 1 or step % 200 == 0: print(step, round(loss.x, 3))
یک، یه جای تصادفی از متن انتخاب میکنیم؛ ids هشت حرف از اونجا، و tgt همون هشت حرف ولی یکی جلوتر، یعنی جوابها. دو، forward. سه، خطا. چهار، همهی مشتقهای قبلی رو صفر میکنیم، چون backward اضافه میکنه و اگه صفر نکنیم، مشتق قدمهای قبلی هم میمونه؛ بعد backward. پنج، هر وزن رو با آدام یه ذره هل میدیم.
خطا
خطا برای هر جا، منفی لگاریتم احتمالیه که مدل به حرف درست داده. اگه مدل مطمئن باشه و به حرف درست احتمال ۱ بده، لگاریتمش صفره و خطا صفر. اگه احتمال ۰٫۱ بده، خطا حدود ۲٫۳. هر چی احتمال حرف درست کمتر، خطا بیشتر. به این خطا آنتروپی متقاطع میگن، و میانگینش روی ۸ جا گرفته میشه.
یه عدد مرجع خوب؛ اگه مدل هیچی ندونه و به هر ۱۵ حرف احتمال برابر بده، خطاش لگاریتم ۱۵، یعنی حدود ۲٫۷۱ میشه. اولین خطای مدل ما ۳٫۲۱ بود؛ حتی بدتر از حدس برابر، چون وزنهای تصادفی بعضی حرفها رو بیدلیل مطمئنتر پیشبینی میکردن. هر عددی زیر ۲٫۷۱ یعنی مدل داره چیزی یاد میگیره.
آدام
سادهترین راه هل دادن وزن، کاهش شیبه؛ هر وزن، منهای یه عدد کوچیک ضرب در مشتقش. ما از نسخهی بهترش، آدام، استفاده کردیم که سال ۲۰۱۴ معرفی شد. آدام برای هر وزن دو چیز یادش میمونه؛ mom، میانگین لغزان مشتقها، که جهت کلی رو نگه میداره، و sq، میانگین لغزان مربع مشتقها، که اندازهی معمول تغییر رو.
قدم هر وزن، mom تقسیم بر ریشهی sqه، ضرب در نرخ یادگیری ۰٫۰۱. این یعنی وزنی که مشتقش همیشه کوچیکه، قدم بزرگتری برمیداره، و وزنی که مشتقش پرنوسانه، قدم کوچیکتر. اون تقسیمها بر یک منهای ۰٫۹ به توان step، اصلاح سوگیریان؛ چون mom و sq از صفر شروع میشن، قدمهای اول بی این اصلاح خیلی کوچیک میشدن.
منحنی آموزش
این عددهاییان که برنامه موقع اجرا چاپ کرد؛ خطا توی قدمهای ۱، ۲۰۰، ۴۰۰، ۶۰۰، ۸۰۰ و ۱۰۰۰:
۳٫۲۱، ۰٫۳۷، ۰٫۵۴، ۰٫۳۷، ۰٫۲۶ و ۰٫۳۵. توی ۲۰۰ قدم اول، خطا سریع پایین اومد؛ مدل الگوهای ساده رو، مثل اینکه بعد از «م» معمولا «ی» میاد، یاد گرفت. بعد از اون، عددها بالا و پایین میرن. دلیلش اینه که هر قدم فقط یه تیکهی ۸ حرفی میبینه؛ بعضی تیکهها، مثل وسط یه کلمه، آسونن و بعضی، مثل اول یه جملهی تازه، سختتر. مدلهای واقعی برای همین هر قدم صدها تیکه رو با هم میبینن و میانگین میگیرن.
کل این هزار قدم، روی یه لپتاپ، حدود یه دقیقه طول کشید. با فقط ۳۰۰ قدم، حدود ۱۸ ثانیه، متن ساختهشده هنوز پر از غلط بود. اگه خودتون اجرا کنید، چون seed، یعنی بذر تصادفی، ۴۲ ثابت شده، باید دقیقا همین عددها رو ببینید.
نوشتن
و آخر، نوشتن. با یه حرف، «ب»، شروع میکنیم و ۴۰ بار حرف بعدی رو میسازیم:
# 6. writing: one character at a time
ids = [stoi["ب"]]
for _ in range(40):
probs = softmax(forward(ids[-T:])[-1])
ids.append(random.choices(range(V), weights=[p.x for p in probs])[0])
print("".join(itos[i] for i in ids))
print("params", len(params), "vocab", V)هر بار، آخرین ۸ حرف رو به مدل میدیم، چون بیشتر از ۸ تا رو نمیشناسه، احتمال حرف بعدی رو از آخرین جا میگیریم، و با random.choices یه حرف به نسبت احتمالش برمیداریم. یعنی حرف محتملتر بیشتر انتخاب میشه، ولی گاهی یه حرف کماحتمال هم. این همون نمونهگیریه.
بعد از هزار قدم آموزش، مدل نوشت «باد میوزه. ماه ماه. باد میوزه. ماه ماد.». بیشترش درسته، ولی دو جا گیر کرده؛ «ماه ماه» و «ماد». با پنجرهی ۸ حرفی، وقتی به «ماه » میرسه، دیگه یادش نیست جمله از کجا شروع شده بود. بعد از ۳۰۰ قدم، نوشته بود «باره. میرو. متاون بارون میتابه»؛ چیزهایی شبیه کلمه، ولی هنوز قاطی. اینکه یه مدل ۳۶۸۰ پارامتری، بی اینکه کسی بهش املا یا جمله یاد بده، فقط با پیشبینی حرف بعدی به اینجا برسه، همون اتفاقیه که توی مدلهای بزرگ، با متن خیلی بیشتر، به فهم زبان میرسه.
دما
یه تغییر کوچیک که خودتون میتونید امتحان کنید؛ دما. اگه امتیازهای خام رو قبل از سافتمکس تقسیم بر یه عدد کنید، مثلا ۰٫۵، فرقها دو برابر میشن و مدل تقریبا همیشه محتملترین حرف رو انتخاب میکنه؛ متن تکراریتر و درستتر. اگه تقسیم بر ۲ کنید، فرقها نصف میشن و انتخابها تصادفیتر؛ متن خلاقتر و پرغلطتر.
برای امتحان، توی بخش شش، بهجای softmax(forward(...)[-1])، اول هر عدد خروجی رو در یک تقسیم بر دما ضرب کنید. با دمای خیلی کم، احتمالا مدل یه جمله رو مدام تکرار میکنه، چون توی متن آموزش هم همینطور بود.
چرا اینقدر کنده
یه دقیقه برای ۳۶۸۰ پارامتر زیاده. دلیلش اینه که هر عدد یه Val جداست. یه بار forward، برای ۸ جا، هر کدوم چند ضرب ۱۶ در ۱۶ و ۱۶ در ۶۴، دهها هزار Val میسازه، و backward باید همه رو یکییکی، با حلقهی پایتون، رد کنه. پایتون برای این کار ساخته نشده؛ هر عملیات کوچیک چند ده برابر کندتر از زبانی مثل Cه.
کتابخونههای واقعی دو کار میکنن. یکی اینکه بهجای هر عدد، یه جدول کامل رو یه Val میگیرن؛ پس یه ضرب ماتریسی ۱۶ در ۱۶، بهجای ۲۵۶ گره، یه گرهست. دوم اینکه اون ضرب رو با کد خیلی سریع، روی کارت گرافیک، انجام میدن که هزاران عدد رو با هم ضرب میکنه. همین دو تا، آموزش رو میلیونها برابر تندتر میکنه، و برای همین مدلهای واقعی رو با این کد نمیشه ساخت، ولی فکرشون رو چرا.
یاد گرفته یا حفظ کرده
راستش رو بخواید، مدل ما متن رو بیشتر حفظ کرده تا یاد گرفته. متن آموزش فقط چهار جملهست که بیست بار تکرار شده، و هیچ متن دیگهای برای آزمون نداریم. توی بیشبرازش دیدیم که برای فهمیدن اینکه مدل واقعا یاد گرفته، باید روی دادهای که ندیده امتحانش کرد.
برای یه آزمایش واقعیتر، یه متن بلندتر بردارید، مثلا چند صفحه، ۹۰ درصدش رو برای آموزش و ۱۰ درصدش رو برای آزمون بذارید، و هر چند قدم، خطا رو روی بخش آزمون هم حساب کنید. اگه خطای آموزش پایین میره ولی خطای آزمون بالا، مدل داره حفظ میکنه. با این مدل کوچیک و پنجرهی ۸ حرفی، روی متن واقعی، معمولا خطای آزمون خیلی بالاتر از این عددها میمونه؛ و این طبیعیه.
خودتون اجرا کنید
برای اجرا، فقط پایتون ۳ لازمه، که روی خیلی از کامپیوترها از قبل نصبه. یک، همهی تیکههای کد این درس رو، به ترتیب، توی یه فایل به اسم tiny.py بذارید. دو، توی خط فرمان، به پوشهی همون فایل برید. سه، دستور python3 tiny.py رو بزنید.
حدود یه دقیقه صبر کنید. باید شش خط خطا ببینید، بعد یه متن چهلحرفی، و آخر عدد ۳۶۸۰. چون بذر تصادفی ثابته، عددهاتون باید دقیقا مثل این درس باشه؛ اگه نبود، احتمالا یه جای کد رو اشتباه کپی کردید. برای کار کردن با متن خودتون، فقط خط text رو عوض کنید؛ بقیهی کد، تعداد حرفها رو خودش حساب میکنه.
فرقش با مدل واقعی
این مدل همهی تیکههای یه ترنسفورمر واقعی رو داره، ولی چند فرق مهم هست:
یک، اندازه؛ واقعیها دهها لایه، هزاران پهنا و میلیاردها پارامتر دارن. دو، سرعت؛ ما هر عدد رو جدا توی پایتون حساب میکنیم، ولی کتابخونههای واقعی همهچیز رو ضرب ماتریسی بزرگ میکنن و روی کارت گرافیک میبرن؛ میلیونها برابر تندتر. سه، دسته؛ ما هر قدم یه تیکه میبینیم، واقعیها صدها. چهار، تکهسازی؛ بهجای حرف، تکههای کلمه.
پنج، چند ترفند آموزش که ما نداریم؛ گرم کردن نرخ یادگیری و کم کردنش بهمرور، جلوگیری از بزرگ شدن وزنها، و دراپاوت. شش، موقع نوشتن، ما هر بار کل ۸ حرف رو از اول حساب میکنیم؛ مدلهای واقعی با کش کلید و مقدار، حسابهای قبلی رو نگه میدارن. ولی فکر اصلی، از جاسازی تا ماسک تا آدام، دقیقا همینه.
اشتباههای رایج کد
اگه خودتون از صفر بنویسید، این چند اشتباه خیلی رایجن. اولی، فراموش کردن ماسک؛ مدل خطای خیلی خوبی میگیره، چون جواب رو میبینه، ولی موقع نوشتن هیچی بلد نیست. اگه خطا خیلی زود به نزدیک صفر رسید، اول ماسک رو چک کنید. دومی، کم نکردن بیشینه توی سافتمکس؛ با امتیازهای بزرگ، توان بینهایت میشه و همهچیز خراب.
سومی، صفر نکردن مشتقها پیش از هر backward؛ مشتقها روی هم جمع میشن و آموزش بیثبات میشه. چهارمی، اشتباه یکی جابهجا کردن جوابها؛ اگه tgt همون ids باشه، نه یکی جلوتر، مدل فقط یاد میگیره حرف فعلی رو تکرار کنه. و پنجمی، نرخ یادگیری خیلی بزرگ؛ خطا بهجای کم شدن، بالا و پایین میپره یا بینهایت میشه. با ۰٫۰۱ شروع کنید و اگه لازم شد کمش کنید.
تمرین
اینها رو خودتون روی کد امتحان کنید. یک، H رو ۱ و بعد ۴ کنید و خطای آخر رو مقایسه کنید. دو، T رو ۴ کنید؛ مدل با پنجرهی کوتاهتر چی مینویسه؟ سه، متن رو عوض کنید، مثلا یه بیت شعر رو تکرار کنید. چهار، یه بلوک دوم اضافه کنید؛ برای این کار، q، k، v، o، up و down رو دو دسته کنید و حلقهی i رو دو بار اجرا کنید.
و چند سوال برای فکر کردن. اگه D رو ۳۲ کنید، پارامترها تقریبا چند برابر میشن؟ جواب؛ چون بیشترشون جدولهای D در D یا D در ۴Dن، حدود چهار برابر. اگه range(i + 1) رو range(len(ids)) کنید، چی میشه؟ مدل آینده رو میبینه، خطای آموزش خیلی کم میشه، ولی متن ساختهشده خراب. چرا خطای اول ۳٫۲۱ بود نه ۲٫۷۱؟ چون وزنهای تصادفی، حدس رو برابر نمیکنن.
جمعبندی. آنچه از این درس با خودتان میبرید.
- شش بخش؛ داده، مشتق، وزن، مدل، آموزش، نوشتن.
- هر عدد یادش میمونه از چی ساخته شده.
- ماسک فقط یه range(i + 1)ه.
- خطا: منفی لگاریتم احتمال حرف درست.
- آدام هر وزن رو کمی هل میده.
- مدل واقعی همینه، فقط خیلی بزرگتر و تندتر.
خودتون رو بسنجید
۱۵ پرسش، هر بار تازه از میان ۳۰ پرسش این درس. آخرش فقط کارنامه رو میبینید: چند تا درست، چند تا نادرست.