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

فصل ۶ از ۸

پیشرفت ترم
۰٪

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

نتیجه‌ای که فردا هم همان است

فصل ۶پیش‌نمایش رایگان
۹ دقیقه مطالعه فصل ۶

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

پنج فصل، ده‌ها آزمایش، و ده‌ها عدد. سؤالِ ناراحت‌کننده: کدامشان فردا هم همان درمی‌آید؟

در سرنخ ترمِ ۵ فصلِ ۱ همین را برای scikit-learn دیدی. در یادگیری عمیق مسئله جدی‌تر است: منابعِ تصادف بیشترند، و روی GPU چیزهایی هست که حتی seed هم مهارشان نمی‌کند. در این فصل هر سه کتابخانه را قفل می‌کنی، مرزِ صداقت را می‌شناسی، و یک checkpoint می‌سازی که واقعاً کامل است.

یک قفلِ چندزبانه که سه کلید لازم دارد

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

  • هر سه منبعِ تصادف را قفل کنی
  • بگویی کجا seed کافی نیست و چرا
  • checkpoint کامل ذخیره و بازیابی کنی
  • بگویی چه چیزهایی باید کنارِ مدل ثبت شوند

قبل از شروع#

از سرنخ ترمِ ۵ فصل ۱: seed، سنجاقِ نسخه، اثرِ انگشتِ داده.

از فصل‌های ۱ تا ۵: همهٔ عددهایی که تا اینجا گرفتی.

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

۱. با و بدونِ seed#

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

loss_fn = nn.CrossEntropyLoss()


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


def build(dropout=0.0):
    layers = [nn.Linear(64, 128), nn.ReLU()]
    if dropout:
        layers.append(nn.Dropout(dropout))
    return nn.Sequential(*layers, nn.Linear(128, 10))


def run(seed=0, dropout=0.0, sched=False, epochs=30, subset=200):
    torch.manual_seed(seed)
    model = build(dropout)
    loader = DataLoader(TensorDataset(Xtr[:subset], ytr[:subset]), batch_size=32, shuffle=True)
    opt = torch.optim.Adam(model.parameters(), lr=0.01)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs) if sched else None
    for _ in range(epochs):
        model.train()
        for xb, yb in loader:
            opt.zero_grad()
            loss_fn(model(xb), yb).backward()
            opt.step()
        if scheduler:
            scheduler.step()
    return accuracy(model, Xva, yva)


print("سه اجرا با seedهای متفاوت:", [round(run(seed=torch.seed() % 10000), 4) for _ in range(3)])
سه اجرا با seedهای متفاوت: [0.9415, 0.9443, 0.9304]

این سه عدد برای تو متفاوت درمی‌آیند — و همین نکتهٔ فصل است. torch.seed() هر بار از منبعِ تصادفِ سیستم‌عامل مقدار می‌گیرد، پس هیچ‌کس نمی‌تواند این خروجی را تکرار کند. حالا همان کار با seed ثابت:

print("سه اجرا با seed=0:", [round(run(seed=0), 4) for _ in range(3)])
سه اجرا با seed=0: [0.9499, 0.9499, 0.9499]

با seed ثابت، هر سه اجرا دقیقاً یکی‌اند. بدونش نوسان دارد — در سه عددِ بالا حدودِ یک‌ونیم واحد، و پیشِ تو عددهای دیگری. مقدارِ دقیقش مهم نیست؛ اینکه صفر نیست مهم است.

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

۲. سه منبعِ تصادف، سه قفل#

منبع چه چیزی را تصادفی می‌کند قفلش
torch وزن‌های اولیه، dropout، بُر زدنِ DataLoader torch.manual_seed(s)
numpy تقسیمِ داده، هر نمونه‌گیریِ خودت np.random.seed(s)
random کتابخانه‌های جانبی، shuffle پایتونی random.seed(s)

سلولِ راه‌اندازیِ این ترم هر سه را با هم قفل می‌کند. یکی جامانده کافی است تا نتیجه غیرقابلِ تکرار شود — و چون تفاوت کوچک است، ممکن است ماه‌ها متوجهش نشوی.

torch.manual_seed(0)
first = nn.Linear(4, 3).weight.sum().item()
torch.manual_seed(0)
second = nn.Linear(4, 3).weight.sum().item()
print("وزنِ اولیه با همان seed یکی است:", first == second)

gen = torch.Generator().manual_seed(7)
a = torch.randperm(6, generator=gen).tolist()
gen = torch.Generator().manual_seed(7)
b = torch.randperm(6, generator=gen).tolist()
print("ترتیبِ بُر زدن، دو بار:", a, b)
وزنِ اولیه با همان seed یکی است: True
ترتیبِ بُر زدن، دو بار: [3, 0, 1, 5, 2, 4] [3, 0, 1, 5, 2, 4]

آن torch.Generator() الگوی مهمی است: یک منبعِ تصادفِ مستقل که به حالتِ سراسری کاری ندارد. در فصلِ ۵ برای augmentation دقیقاً از همین استفاده کردیم — تا تصادفِ تبدیل، تصادفِ وزن‌ها را جابه‌جا نکند و دو آزمایش قابلِ مقایسه بمانند.

۳. جایی که seed کافی نیست#

اینجا مرزِ صداقت است، و بیشترِ آموزش‌ها از کنارش رد می‌شوند.

منبع آیا seed حلش می‌کند
وزن‌های اولیه بله
ترتیبِ batchها بله
dropout بله
ترتیبِ جمعِ اعشاری روی GPU نه
الگوریتم‌های cuDNN که خودشان انتخاب می‌شوند نه، مگر صریح خاموششان کنی
چند worker در DataLoader نه، مگر worker_init_fn بدهی

دلیلِ ردیفِ چهارم ریاضی است، نه باگ: جمعِ اعشاری شرکت‌پذیر نیست — همیشه دقیقاً برابرِ نیست. GPU هزاران عدد را به‌طورِ موازی جمع می‌زند و ترتیبِ رسیدنشان بینِ دو اجرا فرق می‌کند. نتیجه در رقمِ دهمِ اعشار فرق دارد، و بعد از هزاران قدم می‌تواند به تفاوتِ قابلِ‌مشاهده برسد.

# قطعیتِ کامل روی GPU — به قیمتِ سرعت
torch.use_deterministic_algorithms(True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

⚠️ مواظب باش: این سه خط اجرا را کندتر می‌کنند، گاهی چشمگیر — و بعضی عملیات‌ها اصلاً نسخهٔ قطعی ندارند و خطا می‌دهند. قاعدهٔ عملی: در پژوهش و اشکال‌زدایی روشنشان کن، در آموزشِ نهایی نه. و مهم‌تر: اگر نتیجه‌ات به این سه خط وابسته باشد، نتیجهٔ محکمی نیست. راهِ درستِ مقابله با نوسان، چند seed است، نه قطعیتِ کامل — و همان موضوعِ فصلِ بعد است.

۴. checkpoint کامل#

torch.manual_seed(0)
model = build()
opt = torch.optim.Adam(model.parameters(), lr=0.01)

checkpoint = {
    "epoch": 12,
    "model": model.state_dict(),
    "optimizer": opt.state_dict(),
    "seed": 0,
    "val_acc": 0.9499,
}
torch.save(checkpoint, "checkpoint.pt")

loaded = torch.load("checkpoint.pt", weights_only=False)
print("کلیدهای checkpoint:", sorted(loaded.keys()))

torch.manual_seed(999)
fresh = build()
print("اختلافِ وزن قبل از بارگذاری:", round(float((fresh[0].weight - model[0].weight).abs().max()), 4))
fresh.load_state_dict(loaded["model"])
print("اختلافِ وزن بعد از بارگذاری:", round(float((fresh[0].weight - model[0].weight).abs().max()), 4))
کلیدهای checkpoint: ['epoch', 'model', 'optimizer', 'seed', 'val_acc']
اختلافِ وزن قبل از بارگذاری: 0.2456
اختلافِ وزن بعد از بارگذاری: 0.0

یک مدلِ کاملاً متفاوت، بعد از load_state_dict دقیقاً همان مدلِ ذخیره‌شده شد.

و کلیدِ optimizer را جا نینداز. بیشترِ کدهایی که می‌بینی فقط model.state_dict() را ذخیره می‌کنند، و برای استفاده از مدل کافی است. ولی برای ادامهٔ آموزش نه:

print("optimizer فقط وزن نیست — کلیدهای حالتش:", sorted(loaded["optimizer"].keys()))
print("تعداد گروه‌های پارامتر:", len(loaded["optimizer"]["param_groups"]))
optimizer فقط وزن نیست — کلیدهای حالتش: ['param_groups', 'state']
تعداد گروه‌های پارامتر: 1

آن state همان حافظهٔ داخلیِ Adam است — میانگین‌های متحرکی که در ترمِ ۱ فصلِ ۶ دیدی. اگر بدونِ آن ادامه بدهی، Adam از صفر شروع می‌کند و loss در چند قدمِ اول می‌پرد — پدیده‌ای که بارها دیده می‌شود و همیشه به «آموزشِ ناپایدار» نسبت داده می‌شود، در حالی که فقط یک checkpoint ناقص است.

چک کن: بعد از بارگذاری، همان معیاری را که ذخیره کرده بودی دوباره حساب کن و مقایسه‌اش کن. اگر val_acc ذخیره‌شده با آنچه الان می‌گیری نمی‌خواند، یا داده عوض شده یا model.eval() جا مانده. این یک خطِ کد است و مچِ نیمی از باگ‌های بارگذاری را می‌گیرد.

۵. چه چیزی را کنارِ مدل ثبت کن#

ثبت کن چرا
seed تا اجرای دوباره ممکن باشد
نسخهٔ torch و python رفتارِ بعضی عملیات‌ها بینِ نسخه‌ها فرق می‌کند
اثرِ انگشتِ داده تا بدانی روی چه چیزی آموزش دیده
کلِ hyperparameterها lr، batch، epoch، dropout، …
معیارِ نهایی و بازه‌اش یک عدد از یک seed کافی نیست
state_dict مدل و optimizer برای ادامهٔ آموزش

🔧 اگر کار نکرد: Missing key(s) in state_dict — معماریِ فعلی با معماریِ ذخیره‌شده فرق دارد؛ همان تعریفِ دقیق را بساز. size mismatch — همان مشکل، ولی در ابعادِ لایه. torch.load هشدارِ امنیتی می‌دهد — فایلِ checkpoint کدِ اجرایی دارد؛ weights_only=True امن‌تر است ولی فقط وزن‌ها را می‌خواند. فایلِ ناشناس را باز نکن — همان هشدارِ joblib در سرنخ.

🤖 از دستیارت بپرس: «چه چیزهای دیگری در آموزشِ یک شبکه ممکن است بینِ دو اجرا فرق کند، حتی با seed ثابت؟» فهرستش را با جدولِ بخشِ ۳ مقایسه کن. و اگر num_workers در DataLoader را نگفت، خودت اضافه‌اش کن — هر worker منبعِ تصادفِ خودش را دارد و worker_init_fn تنها راهِ قفل کردنشان است.

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

کلمه تلفظ به حروف فارسی یعنی چه
checkpoint چک‌پوینت عکسِ کاملِ وضعیتِ آموزش
state_dict استیت-دیکت دیکشنریِ پارامترهای یک مدل یا optimizer
determinism دترمینیسم اینکه اجرای دوباره دقیقاً همان نتیجه را بدهد
generator جنریتور منبعِ تصادفِ مستقل از حالتِ سراسری
worker ورکر فرآیندِ جانبیِ DataLoader برای خواندنِ داده

تمرین‌ها

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

در فصل بعد#

نتیجه‌هایت حالا تکرارپذیرند. ولی سؤالِ سخت‌تر باقی است: از کجا می‌دانی تغییری که دادی واقعاً بهتر کرد و نه اینکه فقط شانس آورد؟ فصلِ بعد جوابش را می‌دهد — و نتیجه‌اش برای بیشترِ آزمایش‌هایی که تا اینجا کردیم، ناخوشایند است.

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

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