گرادیان کاهشی و شبکهی کوچک بی کتابخانه
جواب کوتاه
گرادیان کاهشی و پسانتشار، که توی درسهای ریاضی دیدیم، توی پایتون فقط چند تا حلقهست. برای پیدا کردن خط، از یه خط صاف روی صفر شروع میکنیم و دو هزار بار این کار رو تکرار میکنیم: حدس بزن، خطا رو حساب کن، ببین هر کدوم از دو تا عدد خط رو کدوم طرف ببری خطا کمتر میشه، و یه قدم کوچیک ببرش. آخرش دقیقا به همون خطی میرسیم که ابزار آماده پیدا کرده بود. با همین روش یه شبکهی کوچیک با سه نورون پنهان هم میسازیم که مسئلهای رو یاد میگیره که یه نورون تنها نمیتونه. کتابخونهها هم همین کار رو میکنن، فقط خیلی سریعتر و بی اینکه مشتقها رو دستی بنویسید.
چرا از صفر
توی درس قبل، یه تابع آماده بهترین خط رو برامون پیدا کرد. ولی اون تابع توی دلش چی کار کرد؟ بیاید همون کار رو خودمون انجام بدیم.
توی درس گرادیان کاهشی دیدیم که یادگیری یعنی همین چرخه: یه حدس میزنیم، میبینیم چقدر اشتباه کردیم، حساب میکنیم هر عدد مدل رو کدوم طرف ببریم تا اشتباه کمتر بشه، و یه قدم کوچیک همون طرف میریم. بعد دوباره، و دوباره.
این درس همون چرخه رو با کد واقعی، بی هیچ کتابخونهای، مینویسه. دو تا کار میکنیم. اول همون خط ساعت درس و نمرهی پروژهی قبلی رو خودمون پیدا میکنیم. بعد یه شبکهی عصبی کوچیک میسازیم و با پسانتشار آموزشش میدیم. کل کد هر دو کمتر از پنجاه خطه.
چرا وقتی ابزار آماده هست، خودمون بنویسیم؟ چون وقتی یه بار ببینید توی دل این ابزارها چند تا حلقهی سادهست، دیگه هیچوقت براتون جعبهی جادویی نیستن. وقتی هم یه روز مدلتون یاد نگرفت، میدونید کجا رو باید نگاه کنید.
خط، خودمون
داده همون نه تا ردیف آموزش پروژهی قبله، که توی فهرست train هست. خط ما دو تا عدد داره: w که شیبه، و b که عرض از مبداه. هر دو رو صفر شروع میکنیم:
w, b = 0.0, 0.0
lr = 0.02
n = len(train)
for step in range(2000):
dw = db = 0.0
for h, s in train:
err = (w * h + b) - s
dw += 2 * err * h / n
db += 2 * err / n
w -= lr * dw
b -= lr * db
w, b # 1.52, 6.74خط به خط بریم. lr همون نرخ یادگیری هست، یعنی اندازهی هر قدم. بعد یه حلقهی بزرگ داریم که دو هزار بار میچرخه؛ هر دورش یه قدم یادگیریه.
توی هر دور، اول dw و db رو صفر میکنیم. این دو تا قراره بگن اگه w یا b رو یه کم بیشتر کنیم، خطا چقدر و کدوم طرف عوض میشه؛ یعنی همون گرادیان. بعد روی هر نه تا ردیف میریم. err فاصلهی حدس با نمرهی واقعیه؛ اگه مثبت باشه یعنی بیشتر از واقع حدس زدیم.
اون دو خط بعدی مشتق میانگین مربع خطاست. خطای هر ردیف به توان دو میرسه، و مشتق توان دو، دو برابر خودشه؛ اینجوری اون ۲ پیدا میشه. برای w یه ضرب در h هم داره، چون w توی حدس ضرب در ساعت شده؛ این همون قاعدهی زنجیرهای هست. تقسیم بر n هم میانگین میگیره.
دو خط آخر قلب کارن: w رو به اندازهی نرخ ضرب در شیبش، کم میکنیم. چرا کم؟ چون شیب سمتی رو نشون میده که خطا بیشتر میشه، و ما میخوایم برعکسش بریم؛ پایین دره. علامت -= یعنی «از خودش کم کن».
جواب بعد از دو هزار دور: ۱٫۵۲ و ۶٫۷۴. دقیقا همون عددهایی که تابع آماده توی پروژهی قبل داده بود. ما از یه خط صاف روی صفر شروع کردیم و فقط با تکرار همین چرخه، به همون بهترین خط رسیدیم.
خط میچرخه
اگه وسط کار، بعد از چند تا دور، خط رو بکشیم، میبینیم چطوری کمکم جاش رو پیدا میکنه:
قدم صفر یه خط صاف روی زمینه؛ هر کی هر چقدر خونده، مدل میگه صفر. میانگین مربع خطاش ۲۰۵٫۸ هست، خیلی بد. بعد از فقط یه قدم، خط یهو بلند میشه و شیبش میشه ۲٫۹۲؛ خطا میرسه به ۸٫۶. یعنی قدم اول بیشترین کار رو کرد، چون اول کار شیب خطا خیلی تند بود.
ولی خط بعد از قدم اول زیادی تنده؛ از پایین شروع میشه و بالا رو رد میکنه. توی صد قدم بعد، کمکم شیبش کمتر میشه و سرش بالاتر میاد: ۲٫۱۵ و ۳٫۲۱، با خطای ۲٫۵. و تا دو هزار قدم میرسه به ۱٫۵۲ و ۶٫۷۴ با خطای ۰٫۶۷، و دیگه از اونجا تکون نمیخوره، چون شیب خطا اونجا صفره؛ ته درهست.
این شکل برای خیلی از آموزشها آشناست: اولش خطا خیلی سریع کم میشه، بعد آرومآروم، و آخرش تقریبا ثابت میمونه. برای همین معمولا موقع آموزش، خطا رو هر چند دور یه بار نشون میدن تا ببینن کی دیگه کم نمیشه.
از کجا بدونیم مشتق درسته؟
خطهای dw و db رو از روی قاعدهی مشتق نوشتیم. ولی اگه یه جا اشتباه کرده باشیم، مثلا یه ۲ رو جا انداخته باشیم، کد بی هیچ خطایی اجرا میشه و فقط بد یاد میگیره؛ و این از اون اشتباههاییه که پیدا کردنش سخته. یه راه ساده برای چک کردن هست: بهجای فرمول، خود عدد رو یه ذره هل بدیم و ببینیم خطا چقدر عوض میشه.
train = [(5, 15), (8, 19), (1, 8), (6, 15), (4, 14), (5, 13), (3, 12), (7, 18), (4, 12)]
n = len(train)
def loss(w, b):
return sum(((w * h + b) - s) ** 2 for h, s in train) / n
w, b = 1.0, 2.0
dw = sum(2 * ((w * h + b) - s) * h for h, s in train) / n
eps = 0.0001
nudge = (loss(w + eps, b) - loss(w - eps, b)) / (2 * eps)
print(round(dw, 4), round(nudge, 4))-73.1111 -73.1111
اولین عدد از فرمول مشتق اومده، دومی از هل دادن: w رو یه بار ۰٫۰۰۰۱ بیشتر کردیم و یه بار ۰٫۰۰۰۱ کمتر، فرق دو تا خطا رو تقسیم بر فاصلهشون کردیم. هر دو منفی ۷۳٫۱۱۱۱ درومدن، پس فرمول درسته. منفی بودنش هم یعنی اگه w رو بیشتر کنیم خطا کم میشه؛ که با w برابر یک، خیلی کمتر از ۱٫۵۲، درسته.
چرا همیشه از همین هل دادن استفاده نکنیم؟ چون برای هر عدد مدل دو بار باید کل خطا رو حساب کنیم. خط ما دو تا عدد داره، مشکلی نیست؛ ولی یه شبکه با یه میلیون وزن، یعنی دو میلیون بار حساب کل خطا برای یه قدم. فرمول مشتق، با پسانتشار، همهی مشتقها رو با یه رفت و یه برگشت میده. برای همین هل دادن فقط برای چک کردنه، نه برای یاد گرفتن.
اندازهی قدم
حالا که کد خودمون دستمونه، میتونیم با یه عدد بازی کنیم و ببینیم چی میشه. فقط lr رو عوض کردیم و خطای چهل قدم اول رو کشیدیم:
با نرخ ۰٫۰۰۱ قدمها خیلی کوچیکن. خطا داره کم میشه، ولی بعد از چهل قدم هنوز ۸٫۴ هست؛ همون جایی که نرخ ۰٫۰۲ بعد از یه قدم رسیده بود. اگه صبر کنیم بالاخره میرسه، ولی خیلی دیر.
با نرخ ۰٫۰۵ اتفاق عجیبی میافته. قدم اول اونقدر بزرگه که از ته دره رد میشه و میافته اون طرف، بالاتر از جایی که بود. خطا به جای کم شدن میشه ۶۲۶. قدم بعد بزرگتر رد میشه، چون شیب اونجا تندتره: ۱۹۳۴، بعد ۶۰۰۰، و چند قدم بعد میلیونها. به این میگن منفجر شدن؛ عددها اونقدر بزرگ میشن که دیگه حسابکردنی نیستن.
فرق ۰٫۰۲ و ۰٫۰۵ کمتر از سه برابره، ولی یکی به جواب میرسه و یکی منفجر میشه. برای همین نرخ یادگیری یکی از مهمترین ابرپارامترهاست. اگه یه روز دیدید خطای مدلتون به جای کم شدن، بالا رفت یا یهو عجیب شد، اولین چیزی که باید امتحان کنید کوچیک کردن نرخه.
یه ترفند برای تندتر رسیدن
دیدیم با نرخ ۰٫۰۲ دو هزار قدم طول کشید. چرا اینقدر زیاد؟ چون ساعتها همه بین ۱ تا ۸ان، یعنی همه مثبت و دور از صفر. این باعث میشه w و b به هم گره بخورن: هر بار w یه کم عوض میشه، b باید جبران کنه، و دو تا عدد آروم آروم، زیگزاگی، به جواب میرسن. حالا اگه از هر ساعت، میانگینش رو، که ۴٫۷۸ هست، کم کنیم چی؟
با ساعت خام، تا وقتی که دیگه شیب تکون نخوره، ۱۷۰۹ قدم طول کشید. با ساعت وسطچین و همون نرخ، ۳۰۹ قدم. و چون حالا داده خوشرفتارتره، میشه نرخ رو ده برابر کرد بی اینکه منفجر بشه؛ با نرخ ۰٫۲، فقط ۲۶ قدم. شیب هر سه بار ۱٫۵۱۹ درومد؛ فقط عرض از مبدا وسطچین ۱۴ شد، که همون میانگین نمرههاست، و با یه حساب ساده، ۱۴ منهای ۱٫۵۲ ضرب در ۴٫۷۸، دوباره همون ۶٫۷۴ میشه.
به این کار میگن مقیاسبندی ورودیها، و یکی از سادهترین و پرفایدهترین کارها قبل از آموزشه. توی دادهی واقعی، یه ستون ممکنه متراژ باشه، بین ۵۰ و ۲۰۰، و یه ستون تعداد اتاق، بین ۱ و ۵. اگه اینها رو هماندازه نکنیم، یه نرخ یادگیری که برای یکی خوبه، برای اون یکی یا خیلی کنده یا منفجر میشه.
مسئلهی سختتر
خط برای ساعت و نمره کافی بود. حالا یه مسئله که هیچ خطی از پسش برنمیاد. همون مسئلهای که توی درس پرسپترون دیدیم: «یا من برم یا تو؛ نه هر دو، نه هیچکدوم».
دو تا ورودی داریم که هر کدوم صفر یا یکه. جواب وقتی یکه که دقیقا یکی از دو تا یک باشه. اسم این مسئله XOR هست. اگه چهار حالتش رو روی یه مربع بکشیم، دو تا نقطهی توپر روی یه قطرن و دو تا توخالی روی قطر دیگه.
حالا سعی کنید یه خط صاف بکشید که توپرها یه طرفش باشن و توخالیها طرف دیگه. نمیشه؛ هر جور بکشید، یه طرف یکی از هر کدوم میافته. یه نورون تنها هم فقط یه خط صاف بلده بکشه، پس این رو هیچوقت یاد نمیگیره.
ولی با دو تا خط میشه: یه نوار باریک که توپرها توشن و توخالیها بیرونش. یعنی اگه چند تا نورون داشته باشیم که هر کدوم یه خط بکشن، و یه نورون دیگه جواب اونها رو با هم ترکیب کنه، مسئله حله. این دقیقا یه شبکهی چندلایه هست.
شبکه
شبکهی ما کوچیکترین چیزیه که کار رو راه بندازه:
دو تا ورودی، سه تا نورون توی لایهی پنهان، و یه نورون خروجی. هر نورون پنهان دو تا ورودی رو در وزنهای خودش ضرب میکنه، جمع میکنه، بایاسش رو اضافه میکنه، و از یه تابع فعالسازی رد میکنه. ما از سیگموید استفاده میکنیم که هر عددی رو میبره بین صفر و یک. نورون خروجی هم همین کار رو با جواب سه تا نورون پنهان میکنه.
جمعا ۱۳ تا عدد داره که باید یاد گرفته بشن: ۶ تا وزن و ۳ تا بایاس برای لایهی پنهان، و ۳ تا وزن و یه بایاس برای خروجی. کار آموزش اینه که این سیزده تا عدد رو طوری تنظیم کنه که جواب شبکه برای هر چهار حالت درست باشه. دو تا نورون پنهان، روی کاغذ، کافیه؛ ولی با سه تا، آموزش راحتتر به جواب میرسه.
یه دور، با عدد
قبل از دیدن کل کد، یه بار یه مثال رو دستی از شبکه رد کنیم. وزنها شانسیان، با seed یک. ورودی ۰ و ۱ رو میدیم که جوابش باید ۱ باشه:
رفت: سه نورون پنهان، هر کدوم جمع وزندار ورودیها رو از سیگموید رد میکنن و ۰٫۶۶۷، ۰٫۳۸ و ۰٫۴۷۵ میدن. خروجی هم همین کار رو با این سه عدد میکنه و ۰٫۵۰۹ میده؛ یعنی شبکهی تازه، تقریبا شیر یا خط میندازه.
برگشت: اول میپرسیم خروجی رو کدوم طرف ببریم. dy منفی ۰٫۲۴۵ درمیاد؛ منفی یعنی «بیشترش کن». بعد هر وزن لایهی دوم به اندازهی نورونی که بهش وصله تقصیر میگیره: منفی ۰٫۱۶۴، منفی ۰٫۰۹۳ و منفی ۰٫۱۱۷، که با نرخ یک، همینقدر به وزنها اضافه میشه. نورون اول که خروجی بیشتری داشت، سهم بیشتری میگیره. بعد خبر به لایهی پنهان میرسه: منفی ۰٫۰۱۷، منفی ۰٫۰۳۳، و برای سومی مثبت ۰٫۰۵. چرا سومی مثبت؟ چون وزنش به خروجی منفیه، منفی ۰٫۸۱؛ پس برای بالا بردن خروجی، باید خود این نورون کمتر روشن بشه.
این کل پسانتشاره؛ یه بار رفتن، یه بار برگشتن، و هر وزن به اندازهی سهمش توی خطا یه کم جابهجا میشه. توی کد پایین، همین برای هر کدوم از چهار مثال، ۵۰۰۰ بار تکرار میشه.
کد شبکه
این کل کد شبکهست. طولانیتر از خط به نظر میاد، ولی همون چرخهست:
import math, random
data = [([0, 0], 0), ([0, 1], 1), ([1, 0], 1), ([1, 1], 0)]
def sig(z):
return 1 / (1 + math.exp(-z))
random.seed(1)
H = 3
w1 = [[random.uniform(-1, 1) for _ in range(2)] for _ in range(H)]
b1 = [0.0] * H
w2 = [random.uniform(-1, 1) for _ in range(H)]
b2 = 0.0
lr = 1.0
for step in range(5000):
for x, t in data:
# forward
h = [sig(w1[j][0] * x[0] + w1[j][1] * x[1] + b1[j]) for j in range(H)]
y = sig(sum(w2[j] * h[j] for j in range(H)) + b2)
# backward
dy = 2 * (y - t) * y * (1 - y)
for j in range(H):
dh = dy * w2[j] * h[j] * (1 - h[j])
w2[j] -= lr * dy * h[j]
w1[j][0] -= lr * dh * x[0]
w1[j][1] -= lr * dh * x[1]
b1[j] -= lr * dh
b2 -= lr * dyبالاش دادهست: چهار حالت و جواب درست هر کدوم. بعد تابع sig که همون سیگمویده. بعد وزنها رو با عددهای شانسی کوچیک بین منفی یک و یک شروع میکنیم. چرا شانسی و نه صفر؟ چون اگه همه صفر باشن، هر سه نورون پنهان دقیقا یه کار رو میکنن و دقیقا یه جور عوض میشن، و انگار فقط یه نورون داریم؛ همون چیزی که توی درس مقدار اولیهی وزنها دیدیم.
توی حلقه، برای هر مثال اول گذر رو به جلو رو داریم: جواب سه تا نورون پنهان، که اسمش h هست، و بعد جواب خروجی، y. بعد گذر رو به عقب. خط dy میگه خطا به ورودی نورون خروجی چقدر حساسه: مشتق مربع خطا، ضرب در مشتق سیگموید که y * (1 - y) هست. بعد برای هر نورون پنهان، dh همون حساسیت رو یه لایه عقبتر میبره: ضرب در وزنی که این نورون رو به خروجی وصل میکنه، و ضرب در مشتق سیگموید خودش. این همون قاعدهی زنجیرهایه، که اینجا قدم به قدم از آخر به اول میره.
بقیهی خطها همون کار خط قبلیه: هر وزن رو به اندازهی نرخ ضرب در شیبش کم کن. یه فرق کوچیک هم با کد خط داره: اینجا بعد از هر مثال وزنها رو عوض میکنیم، نه بعد از دیدن همه؛ یعنی گرادیان کاهشی تصادفی.
اگه هر پنجاه دور خطا رو چاپ کنیم، این شکل درمیاد. اولش حدود ۰٫۳۱ هست و صد دور تقریبا هیچ پیشرفتی نمیکنه؛ شبکه داره دنبال راه میگرده. بعد بین دور ۱۰۰ و ۲۰۰ یهو از ۰٫۲۴ میرسه به ۰٫۰۱۴. این جایی بود که نورونهای پنهان بالاخره خطهاشون رو پیدا کردن. تا دور پونصد ۰٫۰۰۲ و تا دور پنج هزار ۰٫۰۰۰۱. این صاف موندن اول کار و بعد یهو افتادن، توی آموزش شبکهها زیاد پیش میاد؛ پس اگه اولش دیدید خطا کم نمیشه، همیشه معنیش این نیست که چیزی خرابه.
نتیجه
بعد از پنج هزار دور، اگه چهار حالت رو به شبکه بدیم، اینها رو میگه:
جایی که جواب درست صفره، شبکه میگه ۰٫۰۱ یا ۰٫۰۲، و جایی که یکه، ۰٫۹۹. یعنی XOR رو یاد گرفت؛ چیزی که یه نورون تنها هیچوقت نمیتونست. هیچکس هم بهش نگفت از کدوم خطها استفاده کنه؛ خودش از روی چهار تا مثال پیداشون کرد.
کتابخونهها سه تا کار برامون میکنن. اول، مشتقها رو خودشون حساب میکنن؛ ما خطهای dy وdh رو دستی نوشتیم، ولی توی شبکهی واقعی با دهها لایه این شدنی نیست، و کتابخونهها گذر رو به جلو رو میبینن و گذر رو به عقب رو خودشون میسازن. دوم، بهجای حلقه، کل یه لایه رو با یهضرب ماتریس حساب میکنن، که خیلی سریعتره. ولی ایده همونه: حدس، خطا، شیب، یه قدم؛ شبکههای خیلی بزرگ هم دقیقا همین چرخه رو، با میلیاردها عدد، تکرار میکنن.
اشتباههای رایج
رایجترینش یه اشتباه کوچیک توی مشتقه که هیچ خطایی نمیده و فقط یادگیری رو خراب میکنه؛ برای همین چک با هل دادن رو یاد بگیرید. بعدی شروع همهی وزنها با صفر توی شبکه؛ اونوقت همهی نورونهای پنهان یه کار میکنن و هیچوقت از هم جدا نمیشن، برای همین وزنها رو شانسی شروع کردیم. بعدی هماندازه نکردن ورودیها، که دیدیم چقدر کند میکنه. بعدی نرخ یادگیری خیلی بزرگ برای خط، که توی درس دیدیم منفجر میشه. و آخری، کم گذاشتن نورون پنهان و ناامید شدن؛ گاهی فقط با یه شروع شانسی دیگه یا چند نورون بیشتر، همهچی درست میشه.
از کجا اومد
این چند خط کد، پشتش نزدیک صد و هشتاد سال تاریخه:
اوگوستن لویی کوشی سال ۱۸۴۷ گرادیان کاهشی رو برای حل معادلههای نجومی پیشنهاد داد. فرانک روزنبلات سال ۱۹۵۸ پرسپترون رو ساخت. ماروین مینسکی و سیمور پیپرت سال ۱۹۶۹ توی یه کتاب نشون دادن که یه لایه چیزهایی مثل XOR رو نمیتونه یاد بگیره، و این حرف علاقه به شبکههای عصبی رو سالها کم کرد. دیوید روملهارت، جفری هینتون و رونالد ویلیامز سال ۱۹۸۶ نشون دادن که با پسانتشار میشه شبکههای چندلایه رو آموزش داد، و همین مشکل رو حل کرد؛ همون کاری که ما با سه نورون پنهان کردیم.
اون ضرب ماتریسها وقتی شبکه بزرگ بشه، اینقدر زیاد میشن که کامپیوتر معمولی کم میاره. درس بعد میگه چرا برای یادگیری عمیق از یه جور پردازندهی دیگه استفاده میکنن: چرا GPU.
جمعبندی. آنچه از این درس با خودتان میبرید.
- حدس، خطا، شیب، یه قدم؛ و دوباره.
- از صفر شروع کردیم و به همون ۱٫۵۲ و ۶٫۷۴ رسیدیم.
- نرخ کوچیک کنده؛ نرخ بزرگ منفجر میشه.
- XOR با یه خط جدا نمیشه؛ شبکه با سه نورون پنهان یادش گرفت.
- کتابخونهها همین رو سریعتر و بی مشتق دستی انجام میدن.
خودتون رو بسنجید
۱۵ پرسش، هر بار تازه از میان ۳۰ پرسش این درس. آخرش فقط کارنامه رو میبینید: چند تا درست، چند تا نادرست.