فاز 3 · درس 11

آموزش مقدماتی PyTorch: مفاهیم پایه، تنسورها و حلقه‌ی آموزش

نوع
ساختنی
زمان
حدود 75 دقیقه
زبان‌ها
Python
پیش‌نیاز
درس 03.10 (ساخت مینی‌فریم‌ورک اختصاصی)
یک صفحه شفاف شیشه‌ای با ساختار شبکه‌ای روشن که نشانگر پردازش داده‌ها و حلقه‌های تکرار در هسته سیستم است
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. چرا PyTorch برنده شد؟
  5. تنسورها (Tensors)
  6. مشتق‌گیری خودکار (Autograd)
  7. ماژول‌های شبکه (nn.Module)
  8. توابع زیان و بهینه‌سازها
  9. حلقه‌ی آموزش
  10. ساختار Dataset و DataLoader
  11. آموزش روی GPU
  12. مقایسه: مینی‌فریم‌ورک در برابر PyTorch و JAX
  13. پیاده‌سازی عملی
  14. گام 1: بارگذاری داده‌های خام MNIST
  15. گام 2: تعریف ساختار مدل
  16. گام 3: حلقه‌ی آموزش
  17. گام 4: اتصال تمام اجزا به یکدیگر
  18. استفاده‌ی عملی
  19. مقایسه‌ی سریع: مینی‌فریم‌ورک در برابر PyTorch
  20. ذخیره و بارگذاری مدل‌ها
  21. زمان‌بندی نرخ یادگیری (Learning Rate Scheduling)
  22. تحویل پروژه
  23. تمرین‌ها
  24. اصطلاحات کلیدی
  25. برای مطالعه‌ی بیشتر

در گام‌های پیشین دیدید که چگونه می‌توان اجزای یک شبکه عصبی را با پایتون خالص از صفر ساخت. با این حال، اجرای همان ساختارها در ابعاد واقعی و با حجم داده‌های سنگین روی پایتون خام به‌شدت کند است و ساعت‌ها زمان می‌برد. PyTorch با حفظ همان منطق و انتزاع‌های ذهنی، محاسبات سنگین را به کرنل‌های فوق‌العاده بهینه‌ی C++ و CUDA می‌سپارد تا آموزش مدل‌ها صدها برابر سریع‌تر شود.

این فریم‌ورک با فلسفه‌ی اجرای مشتاقانه (eager execution) کار دیباگ را شبیه کدنویسی روزمره با پایتون کرده است. بدون نیاز به کامپایل گراف‌های پیچیده، می‌توانید در لحظه جریان داده و گرادیان‌ها را بررسی کنید. یادگیری این ابزار، استاندارد صنعتی امروز برای ورود به دنیای مدل‌های زبانی، پردازش تصویر و سیستم‌های هوش مصنوعی مدرن است.

برای اجرای کدهای این درس و تمرین‌های پایه، نیازی به کارت گرافیک خارجی یا اجاره‌ی GPUهای گران‌قیمت ابری ندارید؛ مدل نمونه‌ی این آموزش روی همان پردازنده‌ی اصلی (CPU) سیستم شخصی شما ظرف چند ده ثانیه به‌راحتی اجرا می‌شود. اگر در نصب بسته‌ها از PyPI با ناپایداری ارتباط مواجه شدید، استفاده از میرورهای داخلی پایتون روند دانلود را تسهیل می‌کند.

شما موتور را با پیستون‌ها و میل‌لنگ ساختید؛ حالا وقت آن است موتوری را یاد بگیرید که همه در دنیای واقعی با آن رانندگی می‌کنند.

اهداف یادگیری

  • ساخت و آموزش شبکه‌های عصبی با استفاده از nn.Module، nn.Sequential و autograd در PyTorch
  • استفاده از تنسورهای PyTorch، شتاب‌دهی GPU و حلقه‌ی استاندارد آموزش (zero_grad، forward، loss، backward، step)
  • تبدیل بخش‌های مینی‌فریم‌ورک دست‌ساز خودتان به معادل‌های آن‌ها در PyTorch
  • پروفایلینگ و مقایسه‌ی سرعت آموزش بین فریم‌ورک پایتون خالص خودتان و PyTorch روی یک مسئله‌ی یکسان

صورت مسئله

حالا یک مینی‌فریم‌ورک کاملاً کاربردی در اختیار دارید: لایه‌های خطی، ReLU، دراپ‌اوت (dropout)، نرمال‌سازی دسته‌ای (batch norm)، بهینه‌ساز Adam، یک DataLoader و یک حلقه‌ی آموزش. این فریم‌ورک با پایتون خالص، یک شبکه‌ی 4 لایه را برای مسئله‌ی دسته‌بندی دایره‌ها آموزش می‌دهد.

اما همین فریم‌ورک، روی مسئله‌ی یکسان 500 برابر کندتر از PyTorch عمل می‌کند.

مینی‌فریم‌ورک شما هر بار یک نمونه را با حلقه‌های تودرتوی پایتون پردازش می‌کند. در مقابل، PyTorch همان عملیات را به کرنل‌های (kernels) بهینه‌شده‌ی C++/CUDA می‌فرستد که روی GPU اجرا می‌شوند. روی یک کارت گرافیک NVIDIA A100، آموزش مدل ResNet-50 با 25.6 میلیون پارامتر روی مجموعه‌داده‌ی ImageNet با 1.28 میلیون تصویر در PyTorch حدود 6 ساعت زمان می‌برد. فریم‌ورک شما برای همین تسک تقریباً به 3,000 ساعت زمان نیاز خواهد داشت؛ تازه اگر قبل از آن حافظه‌اش تمام نشود و کرش نکند.

سرعت تنها تفاوت موجود نیست. فریم‌ورک شما هیچ پشتیبانی‌ای از GPU ندارد؛ از مشتق‌گیری خودکار (automatic differentiation) بی‌بهره است و برای تک‌تک ماژول‌ها متد backward() را دستی نوشتید؛ قابلیت سریال‌سازی (serialization) ندارد؛ از آموزش توزیع‌شده پشتیبانی نمی‌کند؛ فاقد دقت ترکیبی (mixed precision) است؛ و راهی هم برای دیباگ جریان گرادیان به‌جز دستورات print ندارد.

PyTorch تمام این کاستی‌ها را برطرف می‌کند؛ آن هم در حالی که دقیقاً همان مدل ذهنی قبلی شما را حفظ کرده است: Module، forward()، parameters()، backward() و optimizer.step(). مفاهیم یک‌به‌یک منتقل می‌شوند و سینتکس تقریباً یکی است. تفاوت فقط اینجاست که PyTorch یک دهه مهندسی سیستم را پشت همان رابط کاربری پیاده کرده که شما از صفر طراحی کرده بودید.

مفاهیم پایه

چرا PyTorch برنده شد؟

در سال 2015، TensorFlow از شما می‌خواست پیش از اجرای هر کاری، ابتدا یک گراف محاسباتی (computational graph) ایستا تعریف کنید. گراف را می‌ساختید، کامپایل می‌کردید و سپس داده را از درونش عبور می‌دادید. دیباگ کردن یعنی زل زدن به تصویر گراف؛ تغییر معماری هم یعنی ساختن کل گراف از اول.

PyTorch در سال 2017 با فلسفه‌ای متفاوت وارد میدان شد: اجرای مشتاقانه (eager execution). شما کد پایتون می‌نویسید و بلافاصله اجرا می‌شود. دستور y = model(x) واقعاً خروجی y را همین حالا محاسبه می‌کند، نه اینکه «یک گره به گرافی اضافه کند که بعداً y را حساب خواهد کرد». این یعنی ابزارهای استاندارد دیباگ پایتون به‌راحتی کار می‌کردند؛ دستور print() جواب می‌داد، pdb کار می‌کرد و استفاده از دستورات شرطی if/else داخل گذر رو به جلو (forward pass) ممکن بود.

تا سال 2020، بازار حرف نهایی‌اش را زد. سهم PyTorch در مقالات پژوهشی یادگیری ماشین از 7% در سال 2017 به بالای 75% در سال 2022 رسید. شرکت‌های Meta، Google DeepMind، OpenAI، Anthropic و Hugging Face همگی PyTorch را فریم‌ورک اصلی خود کرده‌اند. در پاسخ به این موج، TensorFlow 2.x نیز اجرای مشتاقانه را به سیستم خود آورد؛ تاییدی ضمنی بر اینکه طراحی PyTorch از ابتدا درست بوده است.

درس ماجرا: تجربه‌ی توسعه‌دهنده اثر فزاینده دارد. فریم‌ورکی که در اجرا 10% کندتر اما دیباگ کردنش 50% سریع‌تر باشد، همیشه برنده است.

تنسورها (Tensors)

تنسور (tensor) یک آرایه‌ی چندبعدی است که سه ویژگی بسیار مهم دارد: شکل (shape)، نوع داده (dtype) و دستگاه پردازشی (device).

import torch

x = torch.zeros(3, 4)           # shape: (3, 4), dtype: float32, device: cpu
x = torch.randn(2, 3, 224, 224) # batch of 2 RGB images, 224x224
x = torch.tensor([1, 2, 3])     # from a Python list

شکل (Shape) همان ابعاد آرایه است. یک اسکالر شکل () دارد، یک بردار (n,)، یک ماتریس (m, n) و یک دسته از تصاویر به شکل (batch, channels, height, width) است.

نوع داده (Dtype) دقت عددی و مصرف حافظه را کنترل می‌کند.

dtype بیت دامنه کاربرد
float32 32 حدود 7 رقم اعشار آموزش پیش‌فرض
float16 16 حدود 3.3 رقم اعشار دقت ترکیبی
bfloat16 16 دامنه‌ی یکسان با float32، دقت کمتر آموزش مدل‌های زبانی بزرگ (LLM)
int8 8 از -128 تا 127 استنتاج کوانتیزه‌شده

دستگاه (Device) مشخص می‌کند محاسبات در کجا انجام شوند.

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = torch.randn(3, 4, device=device)
x = x.to("cuda")
x = x.cpu()

هر عملیاتی نیازمند این است که همه‌ی تنسورها روی یک دستگاه مشترک باشند. این شایع‌ترین خطای مبتدیان در PyTorch است: RuntimeError: Expected all tensors to be on the same device. برای حل آن، قبل از شروع محاسبات، همه‌ی تنسورها را به یک دستگاه منتقل کنید.

تغییر شکل (Reshaping) با مرتبه‌ی زمانی ثابت انجام می‌شود؛ چون فقط متادیتا را تغییر می‌دهد، نه خود داده‌ها را.

x = torch.randn(2, 3, 4)
x.view(2, 12)      # reshape to (2, 12) -- must be contiguous
x.reshape(6, 4)    # reshape to (6, 4) -- works always
x.permute(2, 0, 1) # reorder dimensions
x.unsqueeze(0)     # add dimension: (1, 2, 3, 4)
x.squeeze()        # remove size-1 dimensions

مشتق‌گیری خودکار (Autograd)

در مینی‌فریم‌ورک خودتان مجبور بودید متد backward() را برای تک‌تک ماژول‌ها بنویسید. در PyTorch نیازی به این کار نیست. PyTorch همه‌ی عملیات روی تنسورها را در یک گراف جهت‌دار بدون دور (گراف محاسباتی) ثبت می‌کند و سپس با پیمایش معکوس این گراف، گرادیان‌ها را به‌طور خودکار به دست می‌آورد.

.backward()گرادیانگرادیانگرادیانx (برگ)*w (برگ، requires_grad)+b (برگ، requires_grad)تابع زیان (loss)

تفاوت اساسی با فریم‌ورک شما: PyTorch از مشتق‌گیری خودکار مبتنی بر نوار (tape-based autodiff) استفاده می‌کند. هر عملیات در طول گذر رو به جلو به یک «نوار» اضافه می‌شود و با فراخوانی متد .backward()، این نوار در جهت عکس بازپخش خواهد شد.

x = torch.randn(3, requires_grad=True)
y = x ** 2 + 3 * x
z = y.sum()
z.backward()
print(x.grad)  # dz/dx = 2x + 3

سه قاعده‌ی autograd:

  1. فقط تنسورهای برگ (leaf tensors) که requires_grad=True دارند، گرادیان جمع می‌کنند
  2. گرادیان‌ها به‌طور پیش‌فرض با هم جمع می‌شوند؛ پس قبل از هر گذر رو به عقب، حتماً optimizer.zero_grad() را صدا بزنید
  3. دستور torch.no_grad() ردیابی گرادیان را غیرفعال می‌کند (که حین ارزیابی مدل کاربرد دارد)

ماژول‌های شبکه (nn.Module)

کلاس nn.Module کلاس پایه‌ی تمام اجزای شبکه‌ی عصبی در PyTorch است. شما این انتزاع را در درس 10 ساختید. نسخه‌ی PyTorch امکاناتی مثل ثبت خودکار پارامترها، کشف بازگشتی ماژول‌ها، مدیریت دستگاه پردازشی و سریال‌سازی state dict را اضافه دارد.

import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.layer1 = nn.Linear(input_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.layer2 = nn.Linear(hidden_dim, output_dim)

    def forward(self, x):
        x = self.layer1(x)
        x = self.relu(x)
        x = self.layer2(x)
        return x

وقتی یک nn.Module یا nn.Parameter را به‌عنوان ویژگی در متد __init__ تعریف می‌کنید، PyTorch آن را به‌طور خودکار ثبت می‌کند. متد model.parameters() تمام پارامترهای ثبت‌شده را به‌صورت بازگشتی جمع‌آوری می‌کند. به همین دلیل هرگز نیازی نیست وزن‌ها را به‌صورت دستی جمع کنید؛ کاری که در مینی‌فریم‌ورک ناچار به انجامش بودید.

بلوک‌های ساختمانی اصلی:

ماژول عملکرد پارامترها
nn.Linear(in, out) Wx+bWx + b in*out + out
nn.Conv2d(in_ch, out_ch, k) کانولوشن دوبعدی in_chout_chk*k + out_ch
nn.BatchNorm1d(features) نرمال‌سازی فعال‌سازی‌ها 2 * features
nn.Dropout(p) صفر کردن تصادفی مقادیر 0
nn.ReLU() max⁡(0,x)\max(0, x) 0
nn.GELU() خطی با خطای گاوسی (Gaussian error linear) 0
nn.Embedding(vocab, dim) جدول لوک‌آپ (امبدینگ) vocab * dim
nn.LayerNorm(dim) نرمال‌سازی به ازای هر نمونه 2 * dim

توابع زیان و بهینه‌سازها

PyTorch نسخه‌های آماده‌ی محیط پروداکشن از تمام ابزارهایی را که ساختید، در خود دارد.

توابع زیان (Loss functions) (از ماژول torch.nn):

تابع زیان (loss) تسک ورودی
nn.MSELoss() رگرسیون هر شکلی
nn.CrossEntropyLoss() دسته‌بندی چندکلاسه لاجیت‌ها (نه سافت‌مکس)
nn.BCEWithLogitsLoss() دسته‌بندی دوکلاسه لاجیت‌ها (نه سیگموئید)
nn.L1Loss() رگرسیون (مقاوم به داده‌های پرت) هر شکلی
nn.CTCLoss() هم‌ترازی توالی‌ها لاگ احتمال‌ها

نکته: CrossEntropyLoss در داخل خود LogSoftmax و NLLLoss را ترکیب می‌کند. ورودی آن باید لاجیت‌های خام (logits) باشد، نه خروجی سافت‌مکس. این یکی از خطاهای رایجی است که بی‌صدا گرادیان‌های غلط تولید می‌کند.

بهینه‌سازها (Optimizers) (از ماژول torch.optim):

بهینه‌ساز زمان استفاده نرخ یادگیری معمول
SGD(params, lr, momentum) مدل‌های CNN، پایپ‌لاین‌های تنظیم‌شده 0.01--0.1
Adam(params, lr) نقطه‌ی شروع پیش‌فرض 1e-3
AdamW(params, lr, weight_decay) مدل‌های ترنسفورمر، فاین‌تیونینگ 1e-4--1e-3
LBFGS(params) مقیاس کوچک، بهینه‌سازی مرتبه دوم 1.0

حلقه‌ی آموزش

هر حلقه‌ی آموزشی در PyTorch از یک الگوی 5 مرحله‌ای همیشگی پیروی می‌کند. این مراحل را از درس 10 می‌شناسید.

بهینه‌سازتابع زیان (loss)مدلDataLoaderبهینه‌سازتابع زیان (loss)مدلDataLoaderloop[هر دوره (Epoch)]batch = next(dataloader)predictions = model(batch)loss = criterion(predictions, targets)loss.backward()optimizer.step()optimizer.zero_grad()

الگوی استاندارد:

for epoch in range(num_epochs):
    model.train()
    for inputs, targets in train_loader:
        inputs, targets = inputs.to(device), targets.to(device)
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()

پنج خط کد درون حلقه‌ی دسته‌ها؛ همان پنج خطی که پایه‌ی آموزش مدل‌هایی مثل GPT-4، Stable Diffusion و LLaMA بوده‌اند. معماری تغییر می‌کند، داده عوض می‌شود، اما این پنج خط ثابت می‌مانند.

ساختار Dataset و DataLoader

کلاس Dataset در PyTorch یک کلاس انتزاعی با دو متد است: __len__ و __getitem__. کلاس DataLoader نیز با دسته‌بندی، بر زدن داده‌ها و بارگذاری چندپردازشی، آن را در بر می‌گیرد.

from torch.utils.data import Dataset, DataLoader

class MNISTDataset(Dataset):
    def __init__(self, images, labels):
        self.images = images
        self.labels = labels

    def __len__(self):
        return len(self.labels)

    def __getitem__(self, idx):
        return self.images[idx], self.labels[idx]

loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=4)

آرگومان num_workers=4 باعث می‌شود 4 پردازه به‌طور موازی داده‌ها را بارگذاری کنند؛ درست هم‌زمان با اینکه GPU روی دسته‌ی فعلی آموزش می‌بیند. در تسک‌هایی که گلوگاه آن‌ها دیسک است (مثل تصاویر حجیم یا فایل‌های صوتی)، فقط همین پارامتر می‌تواند سرعت آموزش را دو برابر کند.

آموزش روی GPU

انتقال مدل به GPU:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

این دستور تمام پارامترها و بافرهای مدل را به‌صورت بازگشتی به GPU می‌برد. سپس هنگام آموزش، باید هر دسته را هم منتقل کنید:

inputs, targets = inputs.to(device), targets.to(device)

دقت ترکیبی (Mixed precision) در کارت‌های گرافیک مدرن (مثل A100، H100 و RTX 4090)، با اجرای محاسبات رفت و برگشت در قالب float16 و حفظ وزن‌های اصلی در قالب float32، مصرف حافظه را نصف و خروجی پردازش را دو برابر می‌کند:

from torch.amp import autocast, GradScaler

scaler = GradScaler()
for inputs, targets in loader:
    with autocast(device_type="cuda"):
        outputs = model(inputs)
        loss = criterion(outputs, targets)
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()
    optimizer.zero_grad()

مقایسه: مینی‌فریم‌ورک در برابر PyTorch و JAX

قابلیت مینی‌فریم‌ورک (درس 10) PyTorch JAX
مشتق‌گیری خودکار نوشتن دستی backward() مبتنی بر نوار (autograd) تبدیل‌های تابعی (Functional transforms)
نحوه اجرا مشتاقانه (حلقه‌های پایتون) مشتاقانه (کرنل‌های C++) ردیابی‌شده + کامپایل JIT
پشتیبانی از GPU ندارد دارد (CUDA, ROCm, MPS) دارد (CUDA, TPU)
سرعت (MNIST روی MLP) حدود 300 ثانیه در هر ایپاک حدود 0.5 ثانیه در هر ایپاک حدود 0.3 ثانیه در هر ایپاک
سیستم ماژولار کلاس اختصاصی Module nn.Module توابع بدون وضعیت (Flax/Equinox)
دیباگ کردن با دستور print() با print()، pdb، breakpoint() سخت‌تر (ردیابی JIT مانع چاپ print می‌شود)
اکوسیستم ندارد Hugging Face، Lightning، timm Flax، Optax، Orbax
منحنی یادگیری خودتان ساختید متوسط شیب تند (رویکرد تابعی)
کاربرد در پروداکشن مسائل کوچک آموزشی Meta، OpenAI، Anthropic، HF Google DeepMind، Midjourney
ماسک دراپ‌اوتنرخ را بکشید
DROPOUT MASKDropout zeroes a fraction p of units each step so the network cannot lean on any single one. Because only the survivors pass signal, they are scaled up by 1/(1−p) to keep the expected activation the same, and at test time the full layer runs with no scaling.
دراپ‌اوت در هر گام کسر p از واحدها را صفر می‌کند تا شبکه نتواند به هیچ واحد تکی وابسته شود. از آنجا که فقط واحدهای باقی‌مانده سیگنال را عبور می‌دهند، با ضریب 1/(1−p) مقیاس‌بندی می‌شوند تا میانگین فعال‌سازی یکسان بماند؛ در زمان تست نیز کل لایه بدون هیچ مقیاس‌بندی اجرا می‌شود.

پیاده‌سازی عملی

یک شبکه‌ی پرسپترون چندلایه (MLP) سه‌لایه را روی MNIST، فقط با متدهای پایه‌ای PyTorch آموزش می‌دهیم؛ بدون ابزارهای سطح بالا و بدون استفاده از torchvision.datasets. داده‌های خام را خودمان دانلود و پارس می‌کنیم.

گام 1: بارگذاری داده‌های خام MNIST

مجموعه‌داده‌ی MNIST در قالب 4 فایل فشرده با پسوند gz منتشر می‌شود: تصاویر آموزش (60,000 در ابعاد 28 در 28)، برچسب‌های آموزش، تصاویر آزمون (10,000 در ابعاد 28 در 28) و برچسب‌های آزمون. آن‌ها را دانلود کرده و فرمت باینری‌شان را پارس می‌کنیم.

import torch
import torch.nn as nn
import struct
import gzip
import urllib.request
import os

def download_mnist(path="./mnist_data"):
    base_url = "https://storage.googleapis.com/cvdf-datasets/mnist/"
    files = [
        "train-images-idx3-ubyte.gz",
        "train-labels-idx1-ubyte.gz",
        "t10k-images-idx3-ubyte.gz",
        "t10k-labels-idx1-ubyte.gz",
    ]
    os.makedirs(path, exist_ok=True)
    for f in files:
        filepath = os.path.join(path, f)
        if not os.path.exists(filepath):
            urllib.request.urlretrieve(base_url + f, filepath)

def load_images(filepath):
    with gzip.open(filepath, "rb") as f:
        magic, num, rows, cols = struct.unpack(">IIII", f.read(16))
        data = f.read()
        images = torch.frombuffer(bytearray(data), dtype=torch.uint8)
        images = images.reshape(num, rows * cols).float() / 255.0
    return images

def load_labels(filepath):
    with gzip.open(filepath, "rb") as f:
        magic, num = struct.unpack(">II", f.read(8))
        data = f.read()
        labels = torch.frombuffer(bytearray(data), dtype=torch.uint8).long()
    return labels

گام 2: تعریف ساختار مدل

یک MLP سه‌لایه با ساختار: 784 -> 256 -> 128 -> 10. از تابع فعال‌ساز ReLU و لایه‌ی دراپ‌اوت برای منظم‌سازی استفاده می‌کنیم؛ برای حفظ سادگی، خبری از نرمال‌سازی دسته‌ای نیست.

class MNISTModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(784, 256),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(128, 10),
        )

    def forward(self, x):
        return self.net(x)

لایه‌ی خروجی، 10 لاجیت خام تولید می‌کند (یکی به ازای هر رقم). از سافت‌مکس استفاده نمی‌کنیم، چون CrossEntropyLoss این بخش را به‌صورت داخلی مدیریت می‌کند.

تعداد کل پارامترها: 784⋅256+256+256⋅128+128+128⋅10+10=235,146784 \cdot 256 + 256 + 256 \cdot 128 + 128 + 128 \cdot 10 + 10 = 235{,}146. این عدد با معیارهای امروزی بسیار کوچک است؛ مثلاً نسخه‌ی کوچک GPT-2 حدود 124 میلیون پارامتر دارد. به همین دلیل این مدل ظرف چند ثانیه آموزش می‌بیند.

گام 3: حلقه‌ی آموزش

الگوی استاندارد: forward، سپس loss، بعد backward و در نهایت step.

def train_one_epoch(model, loader, criterion, optimizer, device):
    model.train()
    total_loss = 0
    correct = 0
    total = 0
    for images, labels in loader:
        images, labels = images.to(device), labels.to(device)
        optimizer.zero_grad()
        outputs = model(images)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * images.size(0)
        _, predicted = outputs.max(1)
        correct += predicted.eq(labels).sum().item()
        total += labels.size(0)
    return total_loss / total, correct / total


def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0
    correct = 0
    total = 0
    with torch.no_grad():
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)
            total_loss += loss.item() * images.size(0)
            _, predicted = outputs.max(1)
            correct += predicted.eq(labels).sum().item()
            total += labels.size(0)
    return total_loss / total, correct / total

به استفاده از torch.no_grad() در زمان ارزیابی دقت کنید. این کار مشتق‌گیری خودکار را غیرفعال کرده و مصرف حافظه را کاهش داده و سرعت استنتاج را بالا می‌برد. بدون این دستور، PyTorch یک گراف محاسباتی می‌سازد که هرگز استفاده‌اش نمی‌کنید.

گام 4: اتصال تمام اجزا به یکدیگر

def main():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

    download_mnist()
    train_images = load_images("./mnist_data/train-images-idx3-ubyte.gz")
    train_labels = load_labels("./mnist_data/train-labels-idx1-ubyte.gz")
    test_images = load_images("./mnist_data/t10k-images-idx3-ubyte.gz")
    test_labels = load_labels("./mnist_data/t10k-labels-idx1-ubyte.gz")

    train_dataset = torch.utils.data.TensorDataset(train_images, train_labels)
    test_dataset = torch.utils.data.TensorDataset(test_images, test_labels)
    train_loader = torch.utils.data.DataLoader(
        train_dataset, batch_size=64, shuffle=True
    )
    test_loader = torch.utils.data.DataLoader(
        test_dataset, batch_size=256, shuffle=False
    )

    model = MNISTModel().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    num_params = sum(p.numel() for p in model.parameters())
    print(f"Device: {device}")
    print(f"Parameters: {num_params:,}")
    print(f"Train samples: {len(train_dataset):,}")
    print(f"Test samples: {len(test_dataset):,}")
    print()

    for epoch in range(10):
        train_loss, train_acc = train_one_epoch(
            model, train_loader, criterion, optimizer, device
        )
        test_loss, test_acc = evaluate(
            model, test_loader, criterion, device
        )
        print(
            f"Epoch {epoch+1:2d} | "
            f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | "
            f"Test Loss: {test_loss:.4f} | Test Acc: {test_acc:.4f}"
        )

    torch.save(model.state_dict(), "mnist_mlp.pt")
    print(f"\nModel saved to mnist_mlp.pt")
    print(f"Final test accuracy: {test_acc:.4f}")

خروجی مورد انتظار پس از 10 ایپاک: حدود 97.8% دقت تست. زمان آموزش روی CPU: حدود 30 ثانیه؛ روی GPU: حدود 5 ثانیه؛ و روی مینی‌فریم‌ورک دست‌ساز خودتان با همین معماری: حدود 45 دقیقه.

استفاده‌ی عملی

مقایسه‌ی سریع: مینی‌فریم‌ورک در برابر PyTorch

مینی‌فریم‌ورک (درس 10) PyTorch
model = Sequential(Linear(784, 256), ReLU(), ...) model = nn.Sequential(nn.Linear(784, 256), nn.ReLU(), ...)
pred = model.forward(x) pred = model(x)
optimizer.zero_grad() optimizer.zero_grad()
grad = criterion.backward() سپس model.backward(grad) loss.backward()
optimizer.step() optimizer.step()
بدون GPU model.to("cuda")
نوشتن دستی backward برای هر ماژول سیستم Autograd همه‌چیز را مدیریت می‌کند

همان‌طور که می‌بینید، رابط برنامه‌نویسی تقریباً یکسان است؛ تفاوت اصلی در سازوکارهای پشت صحنه نهفته است.

ذخیره و بارگذاری مدل‌ها

torch.save(model.state_dict(), "model.pt")

model = MNISTModel()
model.load_state_dict(torch.load("model.pt", weights_only=True))
model.eval()

همیشه state_dict() (دیکشنری پارامترها) را ذخیره کنید، نه شیء کلی مدل را. ذخیره‌ی مستقیم شیء از pickle استفاده می‌کند که با تغییر ساختار کدها در آینده از کار می‌افتد، اما state dictها کاملاً قابل‌انتقال هستند.

زمان‌بندی نرخ یادگیری (Learning Rate Scheduling)

scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=10
)
for epoch in range(10):
    train_one_epoch(model, train_loader, criterion, optimizer, device)
    scheduler.step()

کتابخانه‌ی PyTorch بیش از 15 نوع اسکدولر مختلف ارائه می‌دهد: StepLR، ExponentialLR، CosineAnnealingLR، OneCycleLR و ReduceLROnPlateau. همگی آن‌ها به رابط استاندارد بهینه‌ساز متصل می‌شوند.

تحویل پروژه

این درس دو خروجی تولید می‌کند:

  • outputs/prompt-pytorch-debugger.md -- پرامپتی برای عیب‌یابی خطاهای رایج آموزش در PyTorch
  • outputs/skill-pytorch-patterns.md -- یک مرجع مهارتی برای الگوهای آموزش در PyTorch

تمرین‌ها

  1. اضافه کردن نرمال‌سازی دسته‌ای: لایه‌ی nn.BatchNorm1d را بعد از هر لایه‌ی خطی (و قبل از تابع فعال‌ساز) قرار دهید. دقت تست و سرعت آموزش را با نسخه‌ی صرفاً مبتنی بر دراپ‌اوت مقایسه کنید. نرمال‌سازی دسته‌ای باید در تعداد ایپاک‌های کمتری به دقت بالای 98% برسد.

  2. پیاده‌سازی ابزار یافتن نرخ یادگیری (LR Finder): مدل را برای یک ایپاک با نرخ یادگیری که به‌صورت نمایی افزایش می‌یابد (از 1e-7 تا 1.0) آموزش دهید. نمودار تابع زیان (loss) بر حسب نرخ یادگیری را رسم کنید. بهترین نرخ یادگیری، درست قبل از نقطه‌ای است که در آن مقدار زیان شروع به بالا رفتن می‌کند. با این روش، نرخ بهینه‌تری برای مدل MNIST انتخاب کنید.

  3. انتقال به GPU همراه با دقت ترکیبی: قابلیت‌های torch.amp.autocast و GradScaler را به حلقه‌ی آموزش اضافه کنید. توان پردازش (تعداد نمونه بر ثانیه) را روی GPU در دو حالت با و بدون دقت ترکیبی بسنجید. روی کارت A100، منتظر حدود 2 برابر افزایش سرعت باشید.

  4. ساخت یک Dataset سفارشی: مجموعه‌داده‌ی Fashion-MNIST را دانلود کنید (فرمت آن مشابه MNIST است اما تصاویر لباس دارد). یک کلاس به نام FashionMNISTDataset(Dataset) بسازید و متدهای __getitem__ و __len__ را در آن پیاده کنید. همان شبکه‌ی MLP را آموزش دهید و دقت آن را مقایسه کنید. مسئله‌ی Fashion-MNIST سخت‌تر است؛ انتظار دقتی حدود 88% در برابر 98% داشته باشید.

  5. جایگزینی Adam با SGD به همراه تکانه (momentum): مدل را با SGD(params, lr=0.01, momentum=0.9) آموزش دهید. منحنی‌های یادگیری (learning curves) را مقایسه کنید. سپس یک اسکدولر CosineAnnealingLR اضافه کنید و ببینید آیا تا ایپاک 10، SGD می‌تواند خودش را به دقت Adam برساند یا خیر.

اصطلاحات کلیدی

اصطلاح اصطلاح عامیانه معنای دقیق فنی
تنسور (Tensor) «یک آرایه‌ی چندبعدی» آرایه‌ای دارای نوع مشخص و آگاه از سخت‌افزار (device) که پشتیبانی از مشتق‌گیری خودکار در تک‌تک عملیاتش گنجانده شده است
مشتق‌گیری خودکار (Autograd) «بک‌پراپ خودکار» سیستمی مبتنی بر نوار ضبط که عملیات گذر رو به جلو را ذخیره کرده و با بازپخش معکوس آن‌ها، گرادیان‌های دقیق را محاسبه می‌کند
ماژول (nn.Module) «یک لایه» کلاس پایه برای هر بلوک محاسباتی مشتق‌پذیر؛ پارامترها را ثبت می‌کند، از ماژول‌های تودرتو پشتیبانی می‌کند و حالت‌های train/eval را مدیریت می‌کند
دیکشنری وزن‌ها (state_dict) «وزن‌های مدل» یک شیء از نوع OrderedDict که نام پارامترها را به تنسورها متصل می‌کند؛ نمایشی استاندارد و قابل سریال‌سازی از یک مدل آموزش‌دیده
متد .backward() «محاسبه‌ی گرادیان‌ها» پیمایش معکوس گراف محاسباتی جهت محاسبه و انباشت گرادیان‌ها برای همه‌ی تنسورهای برگ با شرط requires_grad=True
متد .to(device) «انتقال به GPU» انتقال بازگشتی همه‌ی پارامترها و بافرها به سخت‌افزار مشخص‌شده (CPU، CUDA، MPS)
پایپ‌لاین داده (DataLoader) «پایپ‌لاین داده» یک پیمایشگر (iterator) که عملیات دسته‌بندی، بر زدن و بارگذاری داده‌ها از Dataset را به شکل اختیاری موازی‌سازی می‌کند
دقت ترکیبی (Mixed precision) «استفاده از float16» آموزش با فرمت float16 در رفت و برگشت به منظور افزایش سرعت، در حالی که وزن‌های اصلی برای پایداری عددی با فرمت float32 نگه داشته می‌شوند
اجرای مشتاقانه (Eager execution) «اجرای فوری» اجرای بلادرنگ عملیات بلافاصله پس از فراخوانی، بدون موکول شدن به مرحله‌ی کامپایل بعدی؛ اصلی‌ترین تفاوت طراحی PyTorch با نسخه‌ی 1.x در TensorFlow
صفر کردن گرادیان (zero_grad) «ریست کردن گرادیان‌ها» صفر کردن گرادیان همه‌ی پارامترها پیش از گذر معکوس بعدی، زیرا PyTorch به‌طور پیش‌فرض گرادیان‌ها را انباشته می‌کند

برای مطالعه‌ی بیشتر

  • مقاله‌ی Paszke و همکاران، با عنوان «PyTorch: An Imperative Style, High-Performance Deep Learning Library» (سال 2019)؛ مقاله‌ی اصلی که مصالحه‌های به‌کاررفته در طراحی معماری PyTorch را توضیح می‌دهد
  • آموزش‌های رسمی PyTorch: صفحه‌ی «Learning PyTorch with Examples» در (https://pytorch.org/tutorials/beginner/pytorch_with_examples.html)؛ مسیر رسمی حرکت از شناخت تنسورها تا یادگیری nn.Module
  • راهنمای بهینه‌سازی عملکرد PyTorch در (https://pytorch.org/tutorials/recipes/recipes/tuning_guide.html)؛ دقت ترکیبی، ورکر‌های DataLoader، حافظه‌ی پین‌شده (pinned memory) و سایر روش‌های بهینه‌سازی در محیط پروداکشن
  • مقاله‌ی Horace He با عنوان «Making Deep Learning Go Brrrr» در (https://horace.io/brrr_intro.html)؛ بررسی دلایل سرعت بالای آموزش روی GPU به همراه ترفندهای اختصاصی بهینه‌سازی در PyTorch

سؤالات متداول

چرا قبل از هر مرحله backward باید optimizer.zero_grad صدا زده شود؟

چون PyTorch به‌طور پیش‌فرض گرادیان‌ها را روی هم انباشته (جمع) می‌کند. اگر گرادیان‌ها را پیش از محاسبه‌ی دور جدید صفر نکنید، گرادیان دسته‌های قبلی با دسته‌ی فعلی ترکیب شده و مقادیر نادرست تولید می‌شود.

تفاوت اصلی ذخیره‌سازی مدل با state_dict در برابر ذخیره‌ی کل شیء مدل چیست؟

دیکشنری state_dict فقط پارامترها و وزن‌ها را نگهداری می‌کند و مستقل از فایل کد است؛ اما ذخیره‌ی کامل شیء به ماژول پایتونی وابستگی شدید دارد و با بازنویسی یا جابه‌جایی فایل‌های پروژه با خطا مواجه می‌شود.

چرا خروجی مدل برای تابع CrossEntropyLoss نباید از لایه‌ی Softmax رد شود؟

تابع CrossEntropyLoss در داخل خود عملیات LogSoftmax و محاسبه‌ی زیان را ترکیب کرده است. ورودی این تابع باید لاجیت‌های خام باشند و اعمال دوباره‌ی سافت‌مکس نتایج اشتباه به بار می‌آورد.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.