فاز 3 · درس 10

ساخت فریم‌ورک یادگیری عمیق اختصاصی در پایتون

نوع
ساختنی
زمان
حدود 120 دقیقه
زبان‌ها
Python
پیش‌نیاز
تمام فاز 03 (درس‌های 01 تا 09)
یک موتور مکانیکی شفاف و مینیاتوری با چرخ‌دنده‌ها و اجزای دست‌ساز که روی میز چوبی کارگاهی مونتاژ شده است
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. مفهوم انتزاعی Module
  5. نگه‌دارنده‌ی Sequential
  6. حالت آموزش در برابر حالت ارزیابی
  7. بهینه‌ساز
  8. کلاس DataLoader
  9. معماری فریم‌ورک
  10. حلقه‌ی آموزش
  11. سلسله‌مراتب Module
  12. پیاده‌سازی
  13. گام 1: کلاس پایه‌ی Module
  14. گام 2: لایه‌ی Linear
  15. گام 3: ماژول‌های فعال‌سازی
  16. گام 4: ماژول Dropout
  17. گام 5: ماژول BatchNorm
  18. گام 6: نگه‌دارنده‌ی Sequential
  19. گام 7: توابع زیان
  20. گام 8: بهینه‌سازهای SGD و Adam
  21. گام 9: کلاس DataLoader
  22. گام 10: آموزش یک شبکه‌ی 4 لایه روی مسئله‌ی دسته‌بندی دایره‌ها
  23. استفاده از ابزارهای واقعی
  24. خروجی این درس
  25. تمرین‌ها
  26. اصطلاحات کلیدی
  27. برای مطالعه‌ی بیشتر

بسیاری از برنامه‌نویسان یادگیری ماشین کار خود را مستقیماً با ابزارهایی مانند PyTorch یا TensorFlow آغاز می‌کنند؛ اما به دلیل ماهیت لایه‌های زیرین، این فریم‌ورک‌ها برای آن‌ها مانند جعبه‌ای سیاه رفتار می‌کنند. کپی و پیست کردن کدهای پراکنده برای ساخت لایه‌ها، مدیریت گرادیان‌ها و نوشتن دستی حلقه‌ی آموزش، نه‌تنها نگهداری پروژه‌ها را دشوار می‌کند، بلکه مانع از فهم دقیق منطق دستورهایی مثل model.train() یا optimizer.zero_grad() می‌شود.

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

تا اینجا نورون‌ها، لایه‌ها، شبکه‌ها، پس‌انتشار (backpropagation)، توابع فعال‌سازی، تابع زیان (loss)، بهینه‌سازها (optimizers)، منظم‌سازی (regularization)، مقداردهی اولیه (initialization) و برنامه‌های زمان‌بندی نرخ یادگیری را پیاده‌سازی کرده‌اید؛ اما همه را در قالب قطعاتی مجزا. حالا وقت آن است که همه‌ی آن‌ها را در قالب یک فریم‌ورک به هم متصل کنید. نه PyTorch، نه TensorFlow؛ فریم‌ورک اختصاصی خودتان.

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

  • ساخت یک فریم‌ورک یادگیری عمیق کامل (حدود 500 خط) شامل کلاس‌های Module، Linear، ReLU، Sigmoid، Dropout، BatchNorm، Sequential، توابع زیان، بهینه‌سازها و DataLoader
  • درک مفهوم انتزاع Module (متدهای forward و backward و parameters) و دلیل نیاز به جابه‌جایی بین حالت آموزش و ارزیابی (train/eval mode)
  • اتصال تمام اجزا در یک حلقه‌ی آموزش کارآمد برای آموزش یک شبکه‌ی 4 لایه روی مسئله‌ی دسته‌بندی دایره‌ها
  • تطبیق هر جزء از فریم‌ورک خود با معادل آن در PyTorch (مانند nn.Module، nn.Sequential، optim.Adam و DataLoader)

صورت مسئله

حاصل ده درس گذشته، قطعاتی از کد است که در فایل‌های جداگانه پراکنده‌اند: کلاس Value در یک فایل، حلقه‌ی آموزش در فایلی دیگر، مقداردهی اولیه‌ی وزن‌ها جایی دیگر و برنامه‌ی زمان‌بندی نرخ یادگیری (learning rate schedule) در فایلی دیگر. برای آموزش یک شبکه، مجبورید کدهای پنج درس مختلف را کپی و پیست کنید و آن‌ها را دستی به هم وصل کنید.

فریم‌ورک‌ها دقیقاً همین مشکل را حل می‌کنند. PyTorch ابزارهایی مثل nn.Module، nn.Sequential، optim.Adam، DataLoader و یک الگوی استاندارد برای حلقه‌ی آموزش در اختیارتان می‌گذارد تا همه‌ی این‌ها را به هم پیوند دهد. TensorFlow هم keras.Layer، keras.Sequential و keras.optimizers.Adam را ارائه می‌دهد. این ابزارها جادو نمی‌کنند؛ الگوهای سازمان‌دهی کدی هستند که تعریف، آموزش و ارزیابی شبکه‌ها را ممکن می‌سازند تا نیازی نباشد هر بار چرخ را از نو بسازید.

قرار است همین ساختار را در حدود 500 خط پایتون پیاده کنید؛ بدون numpy و بدون هیچ وابستگی خارجی. فریم‌ورکی که بتواند هر شبکه‌ی پیش‌خور (feedforward) را تعریف کند، آن را با SGD یا Adam آموزش دهد، داده‌ها را دسته‌بندی (batch) کند، dropout و نرمال‌سازی دسته‌ای (batch normalization) را اعمال کند، از هر تابع فعال‌سازی دلخواهی بهره ببرد و نرخ یادگیری را طبق برنامه تغییر دهد.

پس از پایان این درس، وقتی در PyTorch می‌نویسید model = nn.Sequential(...)، دقیقاً می‌دانید در پشت صحنه چه می‌گذرد. می‌فهمید چرا متدهای model.train() و model.eval() وجود دارند. درک می‌کنید چرا optimizer.zero_grad() باید در یک فراخوانی جداگانه اجرا شود. همه‌چیز را خواهید فهمید، چون صفر تا صد آن را خودتان ساخته‌اید.

مفاهیم پایه

مفهوم انتزاعی Module

تمام لایه‌ها در PyTorch از کلاس nn.Module ارث‌بری می‌کنند. یک Module سه وظیفه‌ی اصلی دارد:

  1. forward() -- محاسبه‌ی خروجی بر اساس ورودی‌ها
  2. parameters() -- برگرداندن تمام وزن‌های قابل آموزش
  3. backward() -- محاسبه‌ی گرادیان‌ها (در PyTorch با autograd مدیریت می‌شود، اما در فریم‌ورک ما کاملاً صریح پیاده خواهد شد)

لایه‌ی Linear یک Module است. تابع فعال‌سازی ReLU یک Module است. لایه‌ی dropout و لایه‌ی نرمال‌سازی دسته‌ای نیز هر کدام یک Module هستند. رابط کاربری (interface) همه‌ی آن‌ها کاملاً یکسان است.

نگه‌دارنده‌ی Sequential

کلاس nn.Sequential چند Module را پشت سر هم زنجیر می‌کند. گذر رو به جلو (forward pass): داده‌ها را از Module اول، سپس دوم و بعد سوم عبور می‌دهد. گذر رو به عقب (backward pass): این زنجیره را برعکس طی می‌کند. خود این نگه‌دارنده نیز یک Module است؛ یعنی متدهای forward()، parameters() و backward() را دارد. این همان الگوی طراحی مرکب (composite pattern) است: زنجیره‌ای از Moduleها خودش یک Module به حساب می‌آید.

حالت آموزش در برابر حالت ارزیابی

لایه Dropout در زمان آموزش، برخی نورون‌ها را تصادفی صفر می‌کند، اما در زمان ارزیابی، همه‌ی داده‌ها را بدون تغییر عبور می‌دهد. لایه BatchNorm در زمان آموزش از آماره‌های دسته‌ی جاری استفاده می‌کند، اما در زمان ارزیابی، میانگین‌ها و واریانس‌های متحرکِ انباشته‌شده را به کار می‌گیرد. متدهای train() و eval() این رفتار را تغییر می‌دهند. هر Module یک پرچم به نام training دارد.

بهینه‌ساز

بهینه‌ساز پارامترها را با استفاده از گرادیان آن‌ها به‌روزرسانی می‌کند. در SGD رابطه به شکل param -= lr * grad است. در Adam، تخمینی از تکانه (momentum) و واریانس نگه‌داری و سپس پارامتر به‌روزرسانی می‌شود. بهینه‌ساز هیچ اطلاعی از معماری شبکه ندارد؛ تنها یک لیست تخت از پارامترها و گرادیان‌های آن‌ها را می‌بیند.

کلاس DataLoader

دسته‌بندی داده‌ها به دو دلیل اهمیت دارد: اول اینکه در مسائل بزرگ، کل مجموعه‌داده در حافظه جا نمی‌شود. دوم اینکه گرادیان کاهشی با دسته‌ی کوچک (mini-batch gradient descent) نویزی ایجاد می‌کند که به فرار مدل از کمینه‌های محلی (local minima) کمک می‌کند. کلاس DataLoader داده‌ها را به دسته‌ها تقسیم می‌کند و در صورت نیاز، پیش از هر دور (epoch) آن‌ها را برمی‌زند.

معماری فریم‌ورک

داده‌هابهینه‌سازهاتوابع زیانظرف‌هاماژول‌هاشامل می‌شودشامل می‌شودرفت و برگشتبه‌روزرسانی می‌کندتغذیه می‌کندLinearW*x + bReLUmax(0, x)Sigmoid1/(1+e^-x)Dropoutماسک صفر تصادفیBatchNormنرمال‌سازی فعال‌سازی‌هاSequentialزنجیره‌سازی ماژول‌هاMSELoss(pred - target)^2BCELossآنتروپی متقاطع دودوییSGDparam -= lr * gradAdamگشتاورهای تطبیقیDataLoaderدسته‌بندی + بُر زدن

حلقه‌ی آموزش

بهینه‌سازتابع زیان (loss)مدلDataLoaderبهینه‌سازتابع زیان (loss)مدلDataLoaderloop[هر دوره (Epoch)]دسته ورودی‌هاگذر رو به جلو (لایه به لایه)پیش‌بینی‌هامحاسبه زیانگذر رو به عقب (گرادیان‌ها)پارامترها + گرادیان‌هاپارامترهای به‌روزرسانی‌شدهصفر کردن گرادیان‌ها

سلسله‌مراتب Module

Module+forward(x)+backward(grad)+parameters()+train()+eval()Linear-weights-biases+forward(x)+backward(grad)ReLU+forward(x)+backward(grad)Sequential-modules[]+forward(x)+backward(grad)+parameters()
برش گرادیانآستانه و نُرم را بکشید
GRADIENT CLIPPINGWhen a gradient norm spikes, a single step can throw the weights off a cliff. Clipping rescales any gradient whose norm exceeds the threshold τ back down to τ, keeping direction but capping magnitude. Below τ the gradient is untouched; above it the update is tamed to min(‖g‖, τ).
وقتی نُرم گرادیان جهش ناگهانی پیدا می‌کند، یک گام منفرد می‌تواند وزن‌ها را به قعر پرتگاه پرتاب کند. برش گرادیان (Clipping) مقیاس هر گرادیانی را که نُرم آن از آستانه τ فراتر رود، تا سطح τ کاهش می‌دهد؛ بدین ترتیب جهت گرادیان حفظ شده اما بزرگی آن مهار می‌شود. در مقادیر زیر τ، گرادیان دست‌نخورده باقی می‌ماند و بالاتر از آن، مقدار به‌روزرسانی به min(‖g‖, τ) مهار می‌شود.

پیاده‌سازی

گام 1: کلاس پایه‌ی Module

رابط کاربری انتزاعی که تمام لایه‌ها آن را پیاده‌سازی می‌کنند.

class Module:
    def __init__(self):
        self.training = True

    def forward(self, x):
        raise NotImplementedError

    def backward(self, grad):
        raise NotImplementedError

    def parameters(self):
        return []

    def train(self):
        self.training = True

    def eval(self):
        self.training = False

گام 2: لایه‌ی Linear

بلوک ساختمانی اصلی شبکه. وزن‌ها و بایاس‌ها را ذخیره می‌کند، در گذر رو به جلو فرمول Wx + b را محاسبه کرده و در گذر رو به عقب گرادیان ورودی‌ها و وزن‌ها را به دست می‌آورد.

import math
import random


class Linear(Module):
    def __init__(self, fan_in, fan_out):
        super().__init__()
        std = math.sqrt(2.0 / fan_in)
        self.weights = [[random.gauss(0, std) for _ in range(fan_in)] for _ in range(fan_out)]
        self.biases = [0.0] * fan_out
        self.weight_grads = [[0.0] * fan_in for _ in range(fan_out)]
        self.bias_grads = [0.0] * fan_out
        self.fan_in = fan_in
        self.fan_out = fan_out
        self.input = None

    def forward(self, x):
        self.input = x
        output = []
        for i in range(self.fan_out):
            val = self.biases[i]
            for j in range(self.fan_in):
                val += self.weights[i][j] * x[j]
            output.append(val)
        return output

    def backward(self, grad):
        input_grad = [0.0] * self.fan_in
        for i in range(self.fan_out):
            self.bias_grads[i] += grad[i]
            for j in range(self.fan_in):
                self.weight_grads[i][j] += grad[i] * self.input[j]
                input_grad[j] += grad[i] * self.weights[i][j]
        return input_grad

    def parameters(self):
        params = []
        for i in range(self.fan_out):
            for j in range(self.fan_in):
                params.append((self.weights, i, j, self.weight_grads))
            params.append((self.biases, i, None, self.bias_grads))
        return params

گام 3: ماژول‌های فعال‌سازی

پیاده‌سازی ReLU، Sigmoid و Tanh در قالب Module. هر کدام داده‌های لازم برای گذر رو به عقب را در خود کش می‌کنند.

class ReLU(Module):
    def __init__(self):
        super().__init__()
        self.mask = None

    def forward(self, x):
        self.mask = [1.0 if v > 0 else 0.0 for v in x]
        return [max(0.0, v) for v in x]

    def backward(self, grad):
        return [g * m for g, m in zip(grad, self.mask)]


class Sigmoid(Module):
    def __init__(self):
        super().__init__()
        self.output = None

    def forward(self, x):
        self.output = []
        for v in x:
            v = max(-500, min(500, v))
            self.output.append(1.0 / (1.0 + math.exp(-v)))
        return self.output

    def backward(self, grad):
        return [g * o * (1 - o) for g, o in zip(grad, self.output)]


class Tanh(Module):
    def __init__(self):
        super().__init__()
        self.output = None

    def forward(self, x):
        self.output = [math.tanh(v) for v in x]
        return self.output

    def backward(self, grad):
        return [g * (1 - o * o) for g, o in zip(grad, self.output)]

گام 4: ماژول Dropout

در زمان آموزش، مقادیر ورودی را به شکل تصادفی صفر می‌کند. مقادیر باقی‌مانده را با ضریب 1/(1-p) مقیاس‌بندی می‌کند تا امید ریاضی آن‌ها ثابت بماند. در حالت ارزیابی هیچ تغییری روی داده‌ها ایجاد نمی‌کند.

class Dropout(Module):
    def __init__(self, p=0.5):
        super().__init__()
        self.p = p
        self.mask = None

    def forward(self, x):
        if not self.training:
            return x
        self.mask = [0.0 if random.random() < self.p else 1.0 / (1 - self.p) for _ in x]
        return [v * m for v, m in zip(x, self.mask)]

    def backward(self, grad):
        if self.mask is None:
            return grad
        return [g * m for g, m in zip(grad, self.mask)]

گام 5: ماژول BatchNorm

فعال‌سازی‌ها را به ازای هر ویژگی در سراسر دسته نرمال می‌کند تا میانگین به صفر و واریانس به یک برسد. آماره‌های متحرک را برای استفاده در حالت ارزیابی نگه‌داری می‌کند.

class BatchNorm(Module):
    def __init__(self, size, momentum=0.1, eps=1e-5):
        super().__init__()
        self.size = size
        self.gamma = [1.0] * size
        self.beta = [0.0] * size
        self.gamma_grads = [0.0] * size
        self.beta_grads = [0.0] * size
        self.running_mean = [0.0] * size
        self.running_var = [1.0] * size
        self.momentum = momentum
        self.eps = eps
        self.x_norm = None
        self.std_inv = None
        self.batch_input = None

    def forward_batch(self, batch):
        batch_size = len(batch)
        output_batch = []

        if self.training:
            mean = [0.0] * self.size
            for sample in batch:
                for j in range(self.size):
                    mean[j] += sample[j]
            mean = [m / batch_size for m in mean]

            var = [0.0] * self.size
            for sample in batch:
                for j in range(self.size):
                    var[j] += (sample[j] - mean[j]) ** 2
            var = [v / batch_size for v in var]

            self.std_inv = [1.0 / math.sqrt(v + self.eps) for v in var]

            self.x_norm = []
            self.batch_input = batch
            for sample in batch:
                normed = [(sample[j] - mean[j]) * self.std_inv[j] for j in range(self.size)]
                self.x_norm.append(normed)
                output = [self.gamma[j] * normed[j] + self.beta[j] for j in range(self.size)]
                output_batch.append(output)

            for j in range(self.size):
                self.running_mean[j] = (1 - self.momentum) * self.running_mean[j] + self.momentum * mean[j]
                self.running_var[j] = (1 - self.momentum) * self.running_var[j] + self.momentum * var[j]
        else:
            std_inv = [1.0 / math.sqrt(v + self.eps) for v in self.running_var]
            for sample in batch:
                normed = [(sample[j] - self.running_mean[j]) * std_inv[j] for j in range(self.size)]
                output = [self.gamma[j] * normed[j] + self.beta[j] for j in range(self.size)]
                output_batch.append(output)

        return output_batch

    def forward(self, x):
        result = self.forward_batch([x])
        return result[0]

    def backward(self, grad):
        if self.x_norm is None:
            return grad
        for j in range(self.size):
            self.gamma_grads[j] += self.x_norm[0][j] * grad[j]
            self.beta_grads[j] += grad[j]
        return [grad[j] * self.gamma[j] * self.std_inv[j] for j in range(self.size)]

    def parameters(self):
        params = []
        for j in range(self.size):
            params.append((self.gamma, j, None, self.gamma_grads))
            params.append((self.beta, j, None, self.beta_grads))
        return params

گام 6: نگه‌دارنده‌ی Sequential

ماژول‌ها را زنجیروار به هم متصل می‌کند. در گذر رو به جلو از چپ به راست و در گذر رو به عقب از راست به چپ حرکت می‌کند.

class Sequential(Module):
    def __init__(self, *modules):
        super().__init__()
        self.modules = list(modules)

    def forward(self, x):
        for module in self.modules:
            x = module.forward(x)
        return x

    def backward(self, grad):
        for module in reversed(self.modules):
            grad = module.backward(grad)
        return grad

    def parameters(self):
        params = []
        for module in self.modules:
            params.extend(module.parameters())
        return params

    def train(self):
        self.training = True
        for module in self.modules:
            module.train()

    def eval(self):
        self.training = False
        for module in self.modules:
            module.eval()

گام 7: توابع زیان

پیاده‌سازی MSE و آنتروپی متقاطع دودویی (Binary Cross-Entropy). هر کدام مقدار زیان را محاسبه کرده و با اجرای متد backward() گرادیان را برمی‌گردانند.

class MSELoss:
    def __call__(self, predicted, target):
        self.predicted = predicted
        self.target = target
        n = len(predicted)
        self.loss = sum((p - t) ** 2 for p, t in zip(predicted, target)) / n
        return self.loss

    def backward(self):
        n = len(self.predicted)
        return [2 * (p - t) / n for p, t in zip(self.predicted, self.target)]


class BCELoss:
    def __call__(self, predicted, target):
        self.predicted = predicted
        self.target = target
        eps = 1e-7
        n = len(predicted)
        self.loss = 0
        for p, t in zip(predicted, target):
            p = max(eps, min(1 - eps, p))
            self.loss += -(t * math.log(p) + (1 - t) * math.log(1 - p))
        self.loss /= n
        return self.loss

    def backward(self):
        eps = 1e-7
        n = len(self.predicted)
        grads = []
        for p, t in zip(self.predicted, self.target):
            p = max(eps, min(1 - eps, p))
            grads.append((-t / p + (1 - t) / (1 - p)) / n)
        return grads

گام 8: بهینه‌سازهای SGD و Adam

هر دو بهینه‌ساز لیستی از پارامترها را می‌گیرند و وزن‌ها را با استفاده از گرادیان‌ها به‌روزرسانی می‌کنند.

class SGD:
    def __init__(self, parameters, lr=0.01):
        self.params = parameters
        self.lr = lr

    def step(self):
        for container, i, j, grad_container in self.params:
            if j is not None:
                container[i][j] -= self.lr * grad_container[i][j]
            else:
                container[i] -= self.lr * grad_container[i]

    def zero_grad(self):
        for container, i, j, grad_container in self.params:
            if j is not None:
                grad_container[i][j] = 0.0
            else:
                grad_container[i] = 0.0


class Adam:
    def __init__(self, parameters, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8):
        self.params = parameters
        self.lr = lr
        self.beta1 = beta1
        self.beta2 = beta2
        self.eps = eps
        self.t = 0
        self.m = [0.0] * len(parameters)
        self.v = [0.0] * len(parameters)

    def step(self):
        self.t += 1
        for idx, (container, i, j, grad_container) in enumerate(self.params):
            if j is not None:
                g = grad_container[i][j]
            else:
                g = grad_container[i]

            self.m[idx] = self.beta1 * self.m[idx] + (1 - self.beta1) * g
            self.v[idx] = self.beta2 * self.v[idx] + (1 - self.beta2) * g * g

            m_hat = self.m[idx] / (1 - self.beta1 ** self.t)
            v_hat = self.v[idx] / (1 - self.beta2 ** self.t)

            update = self.lr * m_hat / (math.sqrt(v_hat) + self.eps)

            if j is not None:
                container[i][j] -= update
            else:
                container[i] -= update

    def zero_grad(self):
        for container, i, j, grad_container in self.params:
            if j is not None:
                grad_container[i][j] = 0.0
            else:
                grad_container[i] = 0.0

گام 9: کلاس DataLoader

داده‌ها را به دسته‌های کوچک تقسیم می‌کند و می‌تواند در هر دور آن‌ها را بربزند.

class DataLoader:
    def __init__(self, data, batch_size=32, shuffle=True):
        self.data = data
        self.batch_size = batch_size
        self.shuffle = shuffle

    def __iter__(self):
        indices = list(range(len(self.data)))
        if self.shuffle:
            random.shuffle(indices)
        for start in range(0, len(indices), self.batch_size):
            batch_indices = indices[start:start + self.batch_size]
            batch = [self.data[i] for i in batch_indices]
            inputs = [item[0] for item in batch]
            targets = [item[1] for item in batch]
            yield inputs, targets

    def __len__(self):
        return (len(self.data) + self.batch_size - 1) // self.batch_size

گام 10: آموزش یک شبکه‌ی 4 لایه روی مسئله‌ی دسته‌بندی دایره‌ها

اتصال تمام اجزا به یکدیگر. مدل را تعریف کنید، تابع زیان و بهینه‌ساز را برگزینید و حلقه‌ی آموزش را اجرا کنید.

def make_circle_data(n=500, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], [label]))
    return data


def train():
    random.seed(42)

    model = Sequential(
        Linear(2, 16),
        ReLU(),
        Linear(16, 16),
        ReLU(),
        Linear(16, 8),
        ReLU(),
        Linear(8, 1),
        Sigmoid(),
    )

    criterion = BCELoss()
    optimizer = Adam(model.parameters(), lr=0.01)

    data = make_circle_data(500)
    split = int(len(data) * 0.8)
    train_data = data[:split]
    test_data = data[split:]

    loader = DataLoader(train_data, batch_size=16, shuffle=True)

    model.train()

    for epoch in range(100):
        total_loss = 0
        total_correct = 0
        total_samples = 0

        for batch_inputs, batch_targets in loader:
            batch_loss = 0
            for x, t in zip(batch_inputs, batch_targets):
                pred = model.forward(x)
                loss = criterion(pred, t)
                batch_loss += loss

                optimizer.zero_grad()
                grad = criterion.backward()
                model.backward(grad)
                optimizer.step()

                predicted_class = 1.0 if pred[0] >= 0.5 else 0.0
                if predicted_class == t[0]:
                    total_correct += 1
                total_samples += 1

            total_loss += batch_loss

        avg_loss = total_loss / total_samples
        accuracy = total_correct / total_samples * 100

        if epoch % 10 == 0 or epoch == 99:
            print(f"Epoch {epoch:3d} | Loss: {avg_loss:.6f} | Train Accuracy: {accuracy:.1f}%")

    model.eval()
    correct = 0
    for x, t in test_data:
        pred = model.forward(x)
        predicted_class = 1.0 if pred[0] >= 0.5 else 0.0
        if predicted_class == t[0]:
            correct += 1
    test_accuracy = correct / len(test_data) * 100
    print(f"\nTest Accuracy: {test_accuracy:.1f}% ({correct}/{len(test_data)})")

    return model, test_accuracy

استفاده از ابزارهای واقعی

در ادامه معادل کدی که ساختید را در PyTorch می‌بینید:

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

model = nn.Sequential(
    nn.Linear(2, 16),
    nn.ReLU(),
    nn.Linear(16, 16),
    nn.ReLU(),
    nn.Linear(16, 8),
    nn.ReLU(),
    nn.Linear(8, 1),
    nn.Sigmoid(),
)

criterion = nn.BCELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

for epoch in range(100):
    model.train()
    for inputs, targets in dataloader:
        optimizer.zero_grad()
        predictions = model(inputs)
        loss = criterion(predictions, targets)
        loss.backward()
        optimizer.step()

    model.eval()
    with torch.no_grad():
        test_predictions = model(test_inputs)

ساختار این کد کاملاً یکسان است: Sequential، Linear، ReLU، Sigmoid، BCELoss، Adam، zero_grad، backward، step، train، eval. تمام مفاهیم یک‌به‌یک نظیر هم هستند. تفاوت در این است که PyTorch گرادیان‌گیری خودکار دارد (نیازی به پیاده‌سازی backward() در هر ماژول نیست)، روی GPU اجرا می‌شود و سال‌ها بهینه‌سازی فنی را پشت سر گذاشته است؛ اما ساختار اصلی همان است.

حالا با دیدن هر کدی در PyTorch، دقیقاً می‌دانید در هر خط چه اتفاقی می‌افتد و این درک عمیق، همان هدف اصلی این درس است.

خروجی این درس

از این درس خروجی زیر را به دست می‌آورید:

  • outputs/prompt-framework-architect.md -- پرامپتی برای طراحی معماری شبکه‌های عصبی با استفاده از مفاهیم انتزاعی فریم‌ورک

تمرین‌ها

  1. یک کلاس SoftmaxCrossEntropyLoss برای دسته‌بندی چندکلاسه بنویسید. روی پیش‌بینی‌ها تابع سافت‌مکس (Softmax) را اعمال کنید، تابع زیان آنتروپی متقاطع (Cross-Entropy) را به دست آورید و گذر رو به عقبِ ترکیبی آن را پیاده‌سازی کنید. مدل را روی مجموعه‌داده‌ی مارپیچ 3 کلاسه آزمایش کنید.

  2. برنامه‌ریزی نرخ یادگیری را در بهینه‌ساز پیاده کنید: یک متد set_lr() بیفزایید و برنامه‌ی کسینوسی درس 09 را به آن متصل کنید. دسته‌بند دایره‌ها را با پیش‌گرمایش (warmup) به همراه برنامه‌ی کسینوسی آموزش دهید و نتایج را با نرخ یادگیری ثابت مقایسه کنید.

  3. دو متد save() و load() به Sequential بیفزایید که تمام وزن‌ها را در یک فایل JSON ذخیره و بازیابی کند. بررسی کنید که آیا مدل بارگذاری‌شده دقیقاً همان پیش‌بینی‌های مدل اولیه را تولید می‌کند یا خیر.

  4. کاهش وزن (weight decay به عنوان منظم‌سازی L2) را در بهینه‌ساز Adam پیاده کنید. پارامتر weight_decay را اضافه کنید تا وزن‌ها در هر گام به سمت صفر فشرده شوند. آموزش را در دو حالت decay=0 و decay=0.01 مقایسه کنید.

  5. حلقه‌ی آموزش تک‌نمونه‌ای را با انباشت گرادیان (Gradient Accumulation) دسته‌ای مناسب جایگزین کنید: گرادیان‌ها را در تمام نمونه‌های یک دسته جمع‌آوری کنید، سپس آن‌ها را بر اندازه‌ی دسته تقسیم کرده و یک گام بهینه‌سازی بردارید. بررسی کنید که آیا این کار سرعت همگرایی را تغییر می‌دهد یا خیر.

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

اصطلاح در گفتگوی عامیانه معنای دقیق فنی
ماژول (Module) «یک لایه» مفهوم پایه‌ای انتزاعی در یک فریم‌ورک؛ هر ساختاری که متدهای forward()، backward() و parameters() را داشته باشد
ترتیبی (Sequential) «چیدن لایه‌ها پشت سر هم» نگه‌دارنده‌ای که ماژول‌ها را زنجیر می‌کند و ورودی را در گذر رو به جلو به ترتیب و در گذر رو به عقب به صورت معکوس اعمال می‌نماید
گذر رو به جلو (Forward pass) «اجرای شبکه» محاسبه‌ی خروجی از طریق عبور دادن داده‌های ورودی از تمام ماژول‌ها به ترتیب
گذر رو به عقب (Backward pass) «محاسبه‌ی گرادیان‌ها» بازانتشار گرادیانِ تابع زیان به صورت معکوس در طول ماژول‌ها برای محاسبه‌ی گرادیان پارامترها
پارامترها (Parameters) «وزن‌های قابل آموزش» تمام مقادیر عددی در شبکه که بهینه‌ساز توانایی به‌روزرسانی آن‌ها را دارد؛ یعنی وزن‌ها و بایاس‌ها
بهینه‌ساز (Optimizer) «چیزی که وزن‌ها را تغییر می‌دهد» الگوریتمی برای به‌روزرسانی پارامترها با استفاده از گرادیان‌ها، مانند SGD یا Adam
بارگذار داده (DataLoader) «چیزی که داده‌ها را می‌خواند» یک تکرارکننده (iterator) که مجموعه‌داده را به دسته‌های کوچک تقسیم می‌کند و در صورت نیاز بین دوره‌ها آن را برمی‌زند
حالت آموزش (Training mode) «دستور model.train()» پرچمی که رفتارهای تصادفی مانند dropout و نرمال‌سازی با استفاده از آماره‌های دسته‌ی جاری را فعال می‌کند
حالت ارزیابی (Evaluation mode) «دستور model.eval()» پرچمی که dropout را خاموش کرده و در نرمال‌سازی دسته‌ای، آماره‌های متحرکِ ذخیره‌شده را به کار می‌گیرد
صفر کردن گرادیان (Zero grad) «پاک کردن گرادیان‌ها» ریست کردن گرادیان تمام پارامترها به صفر، پیش از شروع محاسبه‌ی گرادیان‌ها برای دسته‌ی جدید

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

  • مقاله‌ی پاشکه و همکاران، «PyTorch: An Imperative Style, High-Performance Deep Learning Library» (سال 2019) -- مقاله‌ای که تصمیمات طراحی معماری PyTorch را توضیح می‌دهد
  • شوله، «Deep Learning with Python, Second Edition» (سال 2021) -- فصل 3 به سازوکار داخلی Keras با همین ساختار انتزاعی ماژول/لایه می‌پردازد
  • جانسون، ریپازیتوری «Tiny-DNN» در آدرس (https://github.com/tiny-dnn/tiny-dnn) -- یک فریم‌ورک یادگیری عمیق در زبان ++C تنها با فایل‌های هدر، مناسب برای درک ساختار داخلی فریم‌ورک‌ها

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

چرا باید متدهای train() و eval() را در فریم‌ورک پیاده کنیم؟

برخی لایه‌ها مانند Dropout و BatchNorm در زمان آموزش و ارزیابی رفتار متفاوتی دارند. متد train این رفتارهای تصادفی و وابسته به دسته را فعال می‌کند، در حالی که eval آن‌ها را خاموش یا به آماره‌های متحرک منتقل می‌نماید.

وظیفه‌ی نگه‌دارنده‌ی Sequential در فریم‌ورک چیست؟

کلاس Sequential ماژول‌ها را زنجیر می‌کند؛ به این صورت که در گذر رو به جلو (forward) خروجی هر لایه را ورودی لایه‌ی بعد قرار می‌دهد و در گذر رو به عقب (backward) گرادیان‌ها را به ترتیب معکوس بازانتشار می‌دهد.

آیا اجرای کد این درس به GPU یا کتابخانه‌های سنگین نیاز دارد؟

خیر، این فریم‌ورک در حدود 500 خط کد استاندارد پایتون و بدون وابستگی به هیچ کتابخانه‌ای (حتی بدون NumPy) نوشته شده و کاملاً روی CPU اجرا می‌شود.

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

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