ژرفا — خودآموز یادگیری عمیق و مهندسی هوش مصنوعی (متوسط)

فصل ۳ از ۸

پیشرفت ترم
۰٪

ترم ۲ · آموزشِ درست: تعمیم و آزمایشِ منظم

چرا شبکهٔ عمیق بدونِ این‌ها آموزش نمی‌بیند

فصل ۳پیش‌نمایش رایگان
۱۰ دقیقه مطالعه فصل ۳

در این فصل چه یاد می‌گیری#

در ترمِ ۱ فصلِ ۸ دیدی که دادهٔ نرمال‌نشده همان مدل را از خطای ۰٫۶۴ به ۵۱۱ می‌برد. ولی آنجا نگفتیم چرا و کِی. این فصل جوابش را می‌دهد — و نشان می‌دهد که قاعده آن‌طور که معمولاً گفته می‌شود ساده نیست.

بعد یک قدم جلوتر می‌رویم: نرمال‌سازی داخلِ خودِ شبکه، با BatchNorm و LayerNorm. و با عدد می‌بینی که در یک شبکهٔ شش‌لایه، بدونشان سیگنال در عمق آب می‌رود.

چند لولهٔ هم‌قطر در برابر لوله‌هایی که هرچه جلوتر می‌روند باریک‌تر می‌شوند

آخر این فصل می‌توانی:

  • بگویی نرمال‌سازیِ ورودی کِی حیاتی است و کِی تقریباً بی‌اثر
  • BatchNorm و LayerNorm را در جای درست به‌کار ببری
  • بزرگیِ فعال‌سازی‌ها را در عمق اندازه بگیری
  • بگویی مقداردهیِ اولیهٔ وزن‌ها چرا اهمیت دارد

قبل از شروع#

از ترمِ ۱ فصل ۸: اثرِ دادهٔ نرمال‌نشده.

از فصل ۱ همین ترم: accuracy و سنجش با مجموعهٔ اعتبارسنجی.

📓 نوت‌بوک: نوت‌بوک این فصل را در Colab باز کن — همهٔ کدهای این فصل آماده و به‌ترتیب داخلش هست.

۱. کِی نرمال‌سازیِ ورودی حیاتی است#

import torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader


@torch.no_grad()
def accuracy(model, X, y):
    model.eval()
    return float((model(X).argmax(1) == y).float().mean())


def plain(seed=0):
    torch.manual_seed(seed)
    return nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 10))


def fit(model, X, y, Xv, epochs=15, lr=0.01):
    """عمداً SGD، نه Adam — دلیلش پایینِ همین بخش."""
    loader = DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True)
    opt = torch.optim.SGD(model.parameters(), lr=lr)
    loss_fn = nn.CrossEntropyLoss()
    for _ in range(epochs):
        model.train()
        for xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
    return accuracy(model, Xv, yva)


loud = int(Xtr.std(0).argmax())            # پرنوسان‌ترین پیکسل
skew = torch.ones(64)
skew[loud] = 500.0                          # فقط همان یکی را ۵۰۰ برابر کن
Xs, Xsv = Xtr * skew, Xva * skew

mu, sd = Xs.mean(0), Xs.std(0).clamp_min(1e-6)
mu2, sd2 = Xtr.mean(0), Xtr.std(0).clamp_min(1e-6)

print("پرنوسان‌ترین ستون:", loud)
print("همگون،  بدون نرمال‌سازی:", round(fit(plain(), Xtr, ytr, Xva), 4))
print("همگون،  استانداردشده  :", round(fit(plain(), (Xtr - mu2) / sd2, ytr, (Xva - mu2) / sd2), 4))
print("ناهمگون، بدون نرمال‌سازی:", round(fit(plain(), Xs, ytr, Xsv), 4))
print("ناهمگون، استانداردشده  :", round(fit(plain(), (Xs - mu) / sd, ytr, (Xsv - mu) / sd), 4))
پرنوسان‌ترین ستون: 42
همگون،  بدون نرمال‌سازی: 0.7214
همگون،  استانداردشده  : 0.8217
ناهمگون، بدون نرمال‌سازی: 0.2423
ناهمگون، استانداردشده  : 0.8217

چهار عدد، و سه چیزِ مهم در آن‌ها:

  • وقتی همهٔ ستون‌ها هم‌مقیاس‌اند، نرمال‌سازی کمک می‌کند ولی حیاتی نیست — ۰٫۷۲ به ۰٫۸۲.
  • وقتی فقط یک ستون ۵۰۰ برابر بزرگ‌تر است، بدونِ نرمال‌سازی همه‌چیز فرو می‌ریزد — ۰٫۲۴، یعنی کمی بهتر از حدسِ تصادفی بینِ ده کلاس.
  • و مهم‌ترینش: دو عددِ استانداردشده دقیقاً یکی‌اند. ۰٫۸۲۱۷ در هر دو سطر. استانداردسازی تفاوتِ مقیاس را کاملاً پاک کرد — انگار آن ستونِ ۵۰۰ برابری اصلاً وجود نداشت.

چک کن: دو عددِ «استانداردشده» باید دقیقاً یکی باشند. اگر نبودند، میانگین و انحراف را از دادهٔ اشتباهی گرفته‌ای — باید از همان مجموعه‌ای باشد که مدل رویش آموزش می‌بیند، و همان را روی اعتبارسنجی هم به‌کار ببری. گرفتنِ آمار از کلِ داده، نشتی است.

پس قاعدهٔ درست این نیست که «همیشه نرمال کن چون خوب است». قاعدهٔ درست این است: نرمال‌سازی مدل را به مقیاسِ ورودی‌ها بی‌اعتنا می‌کند — و هرچه ورودی‌هایت ناهمگون‌تر باشند، این بی‌اعتنایی حیاتی‌تر است.

🌱 ریشه‌اش کجاست: کلِ این فصل روی دو عدد می‌چرخد — میانگین و انحرافِ معیار — و ریشه، ترم ۶ فصل ۶ — میانگین، میانه، پراکندگی هر دو را از پایه می‌سازد. نکتهٔ کلیدی همان‌جاست: انحرافِ معیار «فاصلهٔ متوسطِ داده‌ها از میانگین» است. پس یعنی «این نقطه چند برابرِ پراکندگیِ معمول از مرکز فاصله دارد» — و همین است که واحد را از معادله بیرون می‌اندازد و سال و گیگابایت را قابلِ مقایسه می‌کند.

⚠️ مواظب باش: آن کامنتِ «عمداً SGD» را جدی بگیر. Adam برای هر پارامتر نرخِ خودش را تنظیم می‌کند و بخشِ بزرگی از این مشکل را پنهان می‌کند — با Adam همان آزمایش تفاوتِ خیلی کمتری نشان می‌دهد. این یعنی Adam تو را از نرمال‌سازی بی‌نیاز نمی‌کند، فقط علامتش را کم‌رنگ می‌کند تا وقتی که در جای دیگری به دردسر بخوری.

۲. سیگنال در عمق آب می‌رود#

def deep(bn=False, ln=False, depth=6, width=128, seed=0):
    torch.manual_seed(seed)
    layers = [nn.Linear(64, width)]
    for _ in range(depth - 1):
        if bn:
            layers.append(nn.BatchNorm1d(width))
        if ln:
            layers.append(nn.LayerNorm(width))
        layers += [nn.ReLU(), nn.Linear(width, width)]
    return nn.Sequential(*layers, nn.Linear(width, 10))


for label, net in [("بدون BatchNorm", deep()), ("با BatchNorm  ", deep(bn=True))]:
    net.train()
    h = Xtr[:64]
    sizes = []
    with torch.no_grad():
        for layer in net:
            h = layer(h)
            if isinstance(layer, nn.Linear):
                sizes.append(round(float(h.std()), 3))
    print(f"{label}:", sizes)
بدون BatchNorm: [0.269, 0.117, 0.072, 0.06, 0.056, 0.056, 0.051]
با BatchNorm  : [0.269, 0.401, 0.41, 0.404, 0.407, 0.409, 0.242]

این هفت عدد کلِ دلیلِ وجودِ BatchNorm را نشان می‌دهند.

بدونِ آن، انحرافِ معیارِ خروجیِ هر لایه از ۰٫۲۶۹ به ۰٫۰۵۱ می‌رسد — یعنی سیگنال در شش لایه به یک‌پنجم آب می‌رود. و چون گرادیان‌ها از همین مسیر برمی‌گردند، گرادیانِ لایه‌های اول به‌شدت کوچک می‌شود و آن لایه‌ها عملاً آموزش نمی‌بینند.

با BatchNorm، همان عدد در کلِ عمق حولِ ۰٫۴ ثابت می‌ماند.

کارِ BatchNorm دقیقاً همین است: خروجیِ هر لایه را می‌گیرد، میانگینش را صفر و انحرافش را یک می‌کند، و بعد با دو پارامترِ آموختنی دوباره مقیاسش را تنظیم می‌کند. نه فقط ورودیِ شبکه، بلکه ورودیِ هر لایه.

for name, model in [("بدون نرمال‌سازی", deep()), ("BatchNorm", deep(bn=True)), ("LayerNorm", deep(ln=True))]:
    loader = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True)
    opt = torch.optim.Adam(model.parameters(), lr=0.01)
    loss_fn = nn.CrossEntropyLoss()
    for _ in range(15):
        model.train()
        for xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
    print(f"{name:>16}: دقت اعتبارسنجی = {accuracy(model, Xva, yva):.4f}")
 بدون نرمال‌سازی: دقت اعتبارسنجی = 0.9081
       BatchNorm: دقت اعتبارسنجی = 0.9610
       LayerNorm: دقت اعتبارسنجی = 0.9526

پنج درصد بهبود، فقط از افزودنِ لایه‌های نرمال‌سازی. همان معماری، همان seed، همان تعدادِ epoch.

۳. BatchNorm در برابرِ LayerNorm#

BatchNorm LayerNorm
روی چه چیزی میانگین می‌گیرد هر ویژگی، در طولِ batch هر نمونه، در طولِ ویژگی‌ها
به batch_size وابسته است بله — با batch کوچک بی‌ثبات نه
رفتارش در train و eval متفاوت یکسان
کجا رایج است بینایی (CNN) زبان (transformer)

آن ردیفِ سوم دامِ عملیِ BatchNorm است. در آموزش، میانگین و انحرافِ همان batch را به‌کار می‌برد؛ در ارزیابی، میانگینِ متحرکی که در طولِ آموزش جمع کرده. پس اگر model.eval() را جا بیندازی، عددِ ارزیابی‌ات نه‌فقط بد، بلکه به batch_size هم وابسته می‌شود — و همین باعث می‌شود باگ ظاهراً «تصادفی» به‌نظر برسد.

و ردیفِ چهارم دلیلِ تاریخی دارد: transformerها با دنباله‌هایی به طول‌های متفاوت کار می‌کنند و batchشان اغلب کوچک است. LayerNorm که به batch کاری ندارد، آنجا انتخابِ طبیعی است — و در ترمِ ۴ دوباره می‌بینی‌اش.

۴. مقداردهیِ اولیهٔ وزن‌ها#

torch.manual_seed(0)
default_layer = nn.Linear(64, 128)
print("پیش‌فرض PyTorch: انحراف معیار وزن =", round(float(default_layer.weight.std()), 4))


def with_init(bad=False, seed=0):
    torch.manual_seed(seed)
    model = nn.Sequential(nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, 10))
    if bad:
        for layer in model:
            if isinstance(layer, nn.Linear):
                nn.init.normal_(layer.weight, std=1.0)
    return model


def fit_adam(model, epochs=15, lr=0.01):
    loader = DataLoader(TensorDataset(Xtr, ytr), batch_size=64, shuffle=True)
    opt = torch.optim.Adam(model.parameters(), lr=lr)
    loss_fn = nn.CrossEntropyLoss()
    for _ in range(epochs):
        model.train()
        for xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
    return accuracy(model, Xva, yva)


print("پیش‌فرض:", round(fit_adam(with_init(False)), 4))
print("بد     :", round(fit_adam(with_init(True)), 4))
پیش‌فرض PyTorch: انحراف معیار وزن = 0.0719
پیش‌فرض: 0.9777
بد     : 0.9276

پیش‌فرضِ PyTorch وزن‌ها را حولِ ۰٫۰۷ می‌سازد، نه ۱. این عدد تصادفی نیست: از تعدادِ ورودی‌های لایه حساب می‌شود، دقیقاً به این هدف که بزرگیِ سیگنال در عبور از لایه ثابت بماند — همان چیزی که در بخشِ ۲ اندازه گرفتیم.

با مقداردهیِ std=1، وزن‌ها حدودِ چهارده برابر بزرگ‌ترند و نتیجه پنج درصد بدتر شد. در شبکهٔ عمیق‌تر این تفاوت به «اصلاً آموزش نمی‌بیند» می‌رسد.

📏 اندازه بگیر: خبرِ خوب: پیش‌فرض‌های PyTorch امروز خوب‌اند و در بیشترِ کارها لازم نیست دستشان بزنی. ولی اگر روزی شبکه‌ای ساختی که lossش از همان قدمِ اول ثابت مانده یا nan شده، مقداردهیِ اولیه یکی از سه چیزی است که باید چک کنی — کنارِ نرخِ یادگیری و نرمال‌سازیِ ورودی. و راهِ چکش همان کدِ بخشِ ۲ است: انحرافِ معیارِ خروجیِ هر لایه را چاپ کن.

🔧 اگر کار نکرد: BatchNorm1d با batch تک‌نمونه‌ای خطا می‌دهد (Expected more than 1 value per channel) — چون نمی‌تواند از یک نمونه انحرافِ معیار بگیرد؛ یا batch را بزرگ‌تر کن یا drop_last=True بگذار. دقتِ ارزیابی با BatchNorm عجیب پایین استmodel.eval() جا مانده. LayerNorm خطای شکل می‌دهد — بُعدِ آخر را می‌خواهد، نه تعدادِ کانال‌ها.

🤖 از دستیارت بپرس: «BatchNorm را قبل از ReLU بگذارم یا بعدش؟» جوابِ قطعی وجود ندارد و همین جالب است — مقالهٔ اصلی یک‌جور گفته و عملِ رایج جورِ دیگر. جوابش را بگیر و بعد هر دو را در همین چارچوب امتحان کن؛ تفاوتشان روی این داده احتمالاً کوچک است، و همان کوچک بودن هم یک نتیجهٔ معتبر است.

واژه‌های تازهٔ این فصل#

کلمه تلفظ به حروف فارسی یعنی چه
standardization استانداردایزیشن میانگین صفر و انحرافِ معیار یک کردن
BatchNorm بچ‌نرم نرمال‌سازیِ هر ویژگی در طولِ batch
LayerNorm لیرنرم نرمال‌سازیِ هر نمونه در طولِ ویژگی‌ها
initialization اینیشیالایزیشن مقدارِ اولیهٔ وزن‌ها قبل از آموزش
vanishing signal ونیشینگ سیگنال کوچک شدنِ فعال‌سازی‌ها در عمق

تمرین‌ها

اول خودت فکر کن یا امتحان کن — بعد اینجا را باز کن.

در فصل بعد#

نرمال‌سازی، مسیرِ سیگنال را هموار کرد. حالا سراغِ عددی می‌رویم که از همهٔ hyperparameterهای دیگر مهم‌تر است: نرخِ یادگیری. راهِ سیستماتیکِ پیدا کردنش، و scheduler — که با یک خط کد، دقت را از ۰٫۹۶۳۸ به ۰٫۹۸۶۱ می‌برد.

به آخر این فصل رسیدی!

اگر ساختی و جواب داد، این دکمه مال توست.