یک شبکه‌ی عصبی ساده#

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

این پیاده سازی با الهام از کتاب Neural Networks and Deep Learning انجام شده است. توضیحات بیشتر را می‌توانید در آن کتاب پیدا کنید.

جزئیات پیاده سازی#

این راهنما به بررسی دقیق ساختار و منطق یک شبکه‌ی عصبی ساده که برای طبقه‌بندی تصاویر مجموعه داده‌ی MNIST طراحی شده، می‌پردازد. هر بخش از کد با توضیحات کامل همراه است.


۱. ساختار کلی کلاس Network#

class Network(object):
    def __init__(self, sizes):
        self.num_layers = len(sizes)
        self.sizes = sizes
        self.biases = [np.random.randn(y, 1) for y in sizes[1:]]
        self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])]

توضیح:#

  • class Network(object):
    تعریف کلاس شبکه‌ی عصبی. این کلاس شامل وزن‌ها، بایاس‌ها و توابع آموزش است.

  • def __init__(self, sizes):
    تابع سازنده که آرایه‌ای به نام sizes می‌گیرد، شامل تعداد نورون‌ها در هر لایه.

  • self.num_layers = len(sizes)
    تعیین تعداد لایه‌ها بر اساس طول آرایه‌ی sizes.

  • self.sizes = sizes
    ذخیره لیست ساختار لایه‌ها در ویژگی sizes.

  • self.biases = [np.random.randn(y, 1) for y in sizes[1:]]
    مقداردهی اولیه بایاس‌ها برای تمام لایه‌های غیر از ورودی.
    بایاس هر نورون از توزیع نرمال استاندارد (mean=0, std=1) مقداردهی می‌شود.

  • self.weights = [np.random.randn(y, x) for x, y in zip(sizes[:-1], sizes[1:])]
    مقداردهی اولیه وزن‌ها بین هر دو لایه‌ی متوالی.
    وزن‌ها ماتریس‌هایی با ابعاد (تعداد نورون‌های لایه‌ی فعلی × تعداد نورون‌های لایه‌ی قبلی) هستند.


۲. محاسبه تعداد پارامترها#

@property
def nParams(self):
    return sum(w.size for w in self.weights) + sum(b.size for b in self.biases)

توضیح:#

  • این ویژگی تعداد کل پارامترهای قابل یادگیری (وزن + بایاس) در شبکه را بازمی‌گرداند.

  • w.size و b.size تعداد عناصر هر وزن و بایاس را محاسبه می‌کند.


۳. پیش‌خور (Feedforward)#

def feedforward(self, a, verbose=False):
    for b, w in zip(self.biases, self.weights):
        if verbose:
            print("وزن:", w.shape, "بایاس:", b.shape)
        a = sigmoid(w @ a + b)
    return a

توضیح:#

  • a ورودی اولیه شبکه (تصویر مسطح شده) است.

  • در هر لایه:

    • ابتدا z = w @ a + b محاسبه می‌شود.

    • سپس سیگموئید روی آن اعمال می‌شود.

    • a برای لایه بعدی به‌روز می‌شود.


۴. انتشار معکوس (Backpropagation)#

def backprop(self, x, y):
    activation = x
    activations = [x]
    zs = []
    for b, w in zip(self.biases, self.weights):
        z = w.dot(activation) + b
        zs.append(z)
        activation = sigmoid(z)
        activations.append(activation)

    delta = self.cost_derivative(activations[-1], y) * sigmoid_prime(zs[-1])
    nabla_b[-1] = delta
    nabla_w[-1] = np.dot(delta, activations[-2].T)
    for l in range(2, self.num_layers):
        z = zs[-l]
        sp = sigmoid_prime(z)
        delta = np.dot(self.weights[-l+1].T, delta) * sp
        nabla_b[-l] = delta
        nabla_w[-l] = np.dot(delta, activations[-l-1].T)
    return (nabla_b, nabla_w)

توضیح:#

  • مرحله feedforward را تکرار می‌کند و z و activation‌ها را ذخیره می‌کند.

  • در مرحله برگشتی:

    • گرادیان تابع هزینه محاسبه می‌شود.

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

    • از مشتق تابع سیگموئید استفاده می‌شود.


توضیحات مفصل تر
نقش این تابع چیست؟

این تابع مسئول اجرای انتشار معکوس (Backpropagation) است — قلب الگوریتم یادگیری شبکه‌های عصبی!

در طی این فرآیند، گرادیان تابع هزینه نسبت به پارامترها (وزن‌ها و بایاس‌ها) محاسبه می‌شود.


مراحل اجرای backprop:

  1. مرحله‌ی forward:

    • شبکه یک ورودی x را دریافت کرده و به ترتیب از لایه‌ها عبور می‌دهد.

    • zها (خروجی قبل از تابع فعال‌سازی) و activationها (خروجی پس از تابع فعال‌سازی) برای هر لایه ذخیره می‌شوند.

  2. محاسبه خطای لایه خروجی (delta):

    • برای لایه‌ی آخر:

      \[ \delta = \nabla_a C \cdot \sigma'(z) \]

      که در کد آمده:

      delta = self.cost_derivative(activations[-1], y) * sigmoid_prime(zs[-1])
      
  3. انتقال خطا به لایه‌های قبلی:

    • برای هر لایه‌ی قبلی، delta جدید با استفاده از وزن‌های لایه بعدی و مشتق سیگموئید محاسبه می‌شود:

      delta = np.dot(self.weights[-l+1].T, delta) * sp
      
  4. محاسبه گرادیان وزن و بایاس:

    • بایاس‌ها: همان delta هستند.

    • وزن‌ها: ضرب delta در transposed activation لایه‌ی قبل از آن.

۵. به‌روزرسانی وزن‌ها با Mini-Batch#

def update_mini_batch(self, mini_batch, eta):
    for x, y in mini_batch:
        delta_b, delta_w = self.backprop(x, y)
        nabla_b = [nb + db for nb, db in zip(nabla_b, delta_b)]
        nabla_w = [nw + dw for nw, dw in zip(nabla_w, delta_w)]
    self.weights = [w - (eta / len(mini_batch)) * nw for w, nw in zip(self.weights, nabla_w)]
    self.biases = [b - (eta / len(mini_batch)) * nb for b, nb in zip(self.biases, nabla_b)]

توضیح:#

  • برای هر mini-batch از داده‌های آموزشی:

    • گرادیان‌ها محاسبه می‌شود.

    • وزن‌ها و بایاس‌ها با میانگین گرادیان‌ها و نرخ یادگیری eta به‌روزرسانی می‌شوند.


توضیحات مفصل تر
مفهوم Batch و Mini-Batch

در آموزش شبکه‌های عصبی، داده‌ها معمولاً در بسته‌هایی (batchها) به شبکه داده می‌شوند:

  • Batch: کل مجموعه‌ی داده‌ها یک‌باره به شبکه داده می‌شود.

  • Mini-Batch: مجموعه‌ی داده به قسمت‌های کوچکتر تقسیم شده و هر بار فقط یک قسمت (مثلاً ۱۰۰ داده) به شبکه داده می‌شود.
    این روش باعث افزایش پایداری و سرعت یادگیری می‌شود.

در این تابع، mini_batch یک لیست از زوج‌های (x, y) است که در هر epoch انتخاب می‌شوند.


∂ مفهوم مشتق در یادگیری ماشین

مشتق تابع هزینه نسبت به پارامترهای شبکه (وزن و بایاس)، مشخص می‌کند که تغییر آن پارامتر چه اثری بر روی مقدار خطا دارد.

  • مشتق تابع هزینه (Cost function) = نرخ تغییر خطا نسبت به وزن‌ها یا بایاس‌ها

  • هدف الگوریتم گرادیان کاهشی (Gradient Descent) کمینه کردن تابع هزینه است.

فرمول اصلی به‌روزرسانی وزن‌ها:

\[ w = w - \eta \cdot \frac{\partial C}{\partial w} \]

در اینجا:

  • \( \eta \) نرخ یادگیری (learning rate) است.

  • \( \frac{\partial C}{\partial w} \) گرادیان تابع هزینه نسبت به وزن \( w \) است.


پیاده‌سازی در کد
  • self.backprop(x, y) گرادیان وزن‌ها و بایاس‌ها را برای یک نمونه (x, y) محاسبه می‌کند.

  • سپس delta_b و delta_w که خروجی‌های backprop هستند، با nabla_b و nabla_w جمع می‌شوند تا مجموع گرادیان‌ها برای کل mini-batch بدست آید.

  • در نهایت، وزن‌ها و بایاس‌ها با میانگین گرادیان‌ها (تقسیم بر اندازه‌ی mini_batch) به‌روزرسانی می‌شوند.


۶. توابع سیگموئید و مشتق آن#

def sigmoid(z): return 1.0 / (1.0 + np.exp(-z))
def sigmoid_prime(z): return sigmoid(z) * (1 - sigmoid(z))

توضیح:#

  • سیگموئید تابع فعال‌سازی استاندارد برای نورون‌هاست.

  • مشتق آن برای backpropagation ضروری است.


تابع cost_derivative#

def cost_derivative(self, output_activations, y):
    return (output_activations - y)
  • گرادیان تابع هزینه نسبت به خروجی شبکه: [ rac{\partial C}{\partial a} = a - y ]

  • فرض شده که تابع هزینه از نوع Mean Squared Error است.


کد کامل کلاس شبکه‌ی عصبی#

در زیر کد کامل کلاس را می‌توانید به صورت یکجا ملاحظه و اجرا نمایید.

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

# کتابخانه‌های استاندارد
import random

# کتابخانه‌های لازم
import numpy as np

class Network(object):
    """کلاس شبکه عصبی"""

    def __init__(self, sizes):
        """مقداردهی اولیه شبکه عصبی
        
        پارامترها:
        sizes -- لیستی که تعداد نورون‌های هر لایه را مشخص می‌کند.
                مثال:
                [2, 3, 1] برای شبکه‌ای با 2 نورون ورودی،
                3 نورون در لایه پنهان و 1 نورون در لایه خروجی
        """
        self.num_layers = len(sizes)
        self.sizes = sizes
        # مقداردهی تصادفی بایاس‌ها با توزیع نرمال
        self.biases = [np.random.randn(y, 1) for y in sizes[1:]]
        # مقداردهی تصادفی وزن‌ها با توزیع نرمال
        self.weights = [np.random.randn(y, x)
                        for x, y in zip(sizes[:-1], sizes[1:])]

    def feedforward(self, a , verbose=False):
        """محاسبه خروجی شبکه برای ورودی داده شده
        
        پارامترها:
        a -- ورودی شبکه
        
        برگشت:
        خروجی شبکه
        """
        for b, w in zip(self.biases, self.weights):
            if verbose:
                print("ورودی لایه:", a , a.shape)
            a = sigmoid( w@a + b)
            if verbose:
                print("وزن‌ها:", w , w.shape)
                print("بایاس‌ها:", b , b.shape)
                print("خروجی لایه:", a , a.shape)
            
        return a

    def __call__(self, a):
        """اجرا کردن شبکه با ورودی داده شده
        
        پارامترها:
        a -- ورودی شبکه
        
        برگشت:
        خروجی شبکه
        """
        return self.feedforward(a , False)
    
    @property
    def nParams(self):
        """محاسبه تعداد پارامترهای شبکه
        
        برگشت:
        تعداد کل پارامترها (وزن‌ها و بایاس‌ها)
        """
        return sum(w.size for w in self.weights) + sum(b.size for b in self.biases)

    def SGD(self, training_data, epochs, mini_batch_size, eta,
            test_data=None):
        """آموزش شبکه با گرادیان کاهش تصادفی روی دسته‌های کوچک
        
        پارامترها:
        training_data -- لیست داده‌های آموزشی به صورت (ورودی, خروجی مطلوب)
        epochs -- تعداد دوره‌های آموزشی
        mini_batch_size -- اندازه هر دسته کوچک
        eta -- نرخ یادگیری
        test_data -- داده‌های آزمون (اختیاری)
        """
        if test_data: n_test = len(test_data)
        n = len(training_data)
        for j in range(epochs):
            random.shuffle(training_data)
            # ایجاد دسته‌های کوچک
            mini_batches = [
                training_data[k:k+mini_batch_size]
                for k in range(0, n, mini_batch_size)]
            # آموزش روی هر دسته کوچک
            for mini_batch in mini_batches:
                self.update_mini_batch(mini_batch, eta)
            # نمایش پیشرفت آموزش
            if test_data:
                print("دوره {0}: {1} / {2}".format(
                    j, self.evaluate(test_data), n_test))
            else:
                print("دوره {0} تکمیل شد".format(j))

    def update_mini_batch(self, mini_batch, eta):
        """به‌روزرسانی وزن‌ها و بایاس‌ها برای یک دسته کوچک
        
        پارامترها:
        mini_batch -- یک دسته کوچک از داده‌های آموزشی
        eta -- نرخ یادگیری
        """
        nabla_b = [np.zeros(b.shape) for b in self.biases]
        nabla_w = [np.zeros(w.shape) for w in self.weights]
        for x, y in mini_batch:
            # محاسبه گرادیان‌ها با انتشار معکوس
            delta_nabla_b, delta_nabla_w = self.backprop(x, y)
            nabla_b = [nb+dnb for nb, dnb in zip(nabla_b, delta_nabla_b)]
            nabla_w = [nw+dnw for nw, dnw in zip(nabla_w, delta_nabla_w)]
        # به‌روزرسانی وزن‌ها و بایاس‌ها
        self.weights = [w-(eta/len(mini_batch))*nw
                        for w, nw in zip(self.weights, nabla_w)]
        self.biases = [b-(eta/len(mini_batch))*nb
                       for b, nb in zip(self.biases, nabla_b)]

    def backprop(self, x, y):
        """الگوریتم انتشار معکوس برای محاسبه گرادیان‌ها
        
        پارامترها:
        x -- ورودی آموزشی
        y -- خروجی مطلوب
        
        برگشت:
        یک تاپل (nabla_b, nabla_w) که گرادیان‌های تابع هزینه را نشان می‌دهد
        """
        nabla_b = [np.zeros(b.shape) for b in self.biases]
        nabla_w = [np.zeros(w.shape) for w in self.weights]
        # مرحله پیش‌خور
        activation = x
        activations = [x]  # ذخیره فعال‌سازی‌های هر لایه
        zs = []  # ذخیره ورودی‌های هر لایه قبل از اعمال تابع فعال‌سازی
        for b, w in zip(self.biases, self.weights):
            z = np.dot(w, activation)+b
            zs.append(z)
            activation = sigmoid(z)
            activations.append(activation)
        # مرحله پس‌خور
        delta = self.cost_derivative(activations[-1], y) * sigmoid_prime(zs[-1])
        nabla_b[-1] = delta
        nabla_w[-1] = np.dot(delta, activations[-2].transpose())
        # محاسبه گرادیان‌ها برای لایه‌های قبلی
        for l in range(2, self.num_layers):
            z = zs[-l]
            sp = sigmoid_prime(z)
            delta = np.dot(self.weights[-l+1].transpose(), delta) * sp
            nabla_b[-l] = delta
            nabla_w[-l] = np.dot(delta, activations[-l-1].transpose())
        return (nabla_b, nabla_w)

    def evaluate(self, test_data):
        """ارزیابی عملکرد شبکه روی داده آزمون
        
        پارامترها:
        test_data -- داده‌های آزمون
        
        برگشت:
        تعداد پیش‌بینی‌های صحیح
        """
        #test_results = [ (self.feedforward(x)-y)**2
        #                for (x, y) in test_data]
        test_results = [(np.argmax(self.feedforward(x)), np.argmax(y) )
                        for (x, y) in test_data]
        return sum(int(x == y) for (x, y) in test_results)
        #return sum(test_results) / len(test_data)
    

    def cost_derivative(self, output_activations, y):
        """محاسبه مشتق تابع هزینه
        
        پارامترها:
        output_activations -- فعال‌سازی‌های لایه خروجی
        y -- خروجی مطلوب
        
        برگشت:
        مشتق تابع هزینه نسبت به فعال‌سازی‌های خروجی
        """
        return (output_activations-y)

# توابع کمکی
def sigmoid(z):
    """تابع فعال‌سازی سیگموئید"""
    return 1.0/(1.0+np.exp(-z))

def sigmoid_prime(z):
    """مشتق تابع سیگموئید"""
    return sigmoid(z)*(1-sigmoid(z))

بارگذاری داده ها#

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

#A simple code to load and show MNIST dataset

#### Libraries
# Standard library
import pickle
import gzip

# Third-party libraries
import numpy as np
import matplotlib.pyplot as plt

def download_dataset(url, filename):
    """
    Download the dataset from the given URL if it is not already present.
    """
    import os
    import requests

    # make sure the directory exists
    os.makedirs(os.path.dirname(filename), exist_ok=True)

    # Check if the file already exists
    # If it does not exist, download it
    # If it does exist, do nothing
    if not os.path.exists(filename):
        print(f"Downloading {filename}...")
        response = requests.get(url)
        with open(filename, 'wb') as f:
            f.write(response.content)
        print(f"Downloaded {filename}")
    return True

def load_data():
    """
    Load the MNIST dataset from a gzipped pickle file.
    """ 

    # Download the dataset if it is not already present
    dataset_url = 'https://github.com/unexploredtest/neural-networks-and-deep-learning/raw/refs/heads/master/data/mnist.pkl.gz' #'http://deeplearning.net/data/mnist/mnist.pkl.gz'
    dataset_filename = '../data/mnist.pkl.gz'
    download_dataset(dataset_url, dataset_filename)

    f = gzip.open('../data/mnist.pkl.gz', 'rb')
    u = pickle._Unpickler(f)
    u.encoding = 'latin1'
    training_data, validation_data, test_data = u.load()
    f.close()
    return (training_data, validation_data, test_data)

def show_data(data , index=0 , ax=None):
    """
    Show the MNIST data.
    """
    # Extract the first image and label from the training data
    image, label = data[0][index], data[1][index] #loads the image and its label

    # data is stored as a flat array of 784 pixels (28x28)
    # Reshape the image to 28x28 pixels
    image = image.reshape(28, 28)

    # Display the image
    ax.imshow(image, cmap='gray')
    ax.set_title(f'Label: {label}')
    ax.axis('off')

training_data, validation_data, test_data = load_data()
Downloading ../data/mnist.pkl.gz...
Downloaded ../data/mnist.pkl.gz

اجرا و آموزش مدل#

حال که داده ها آماده هستند لازم است شکل آن‌ها را

yvalues = np.zeros( (50000 , 10))
yvalues[range(50000) , training_data[1] ] = 1

training_d = list(zip(training_data[0].reshape(-1 , 784 , 1), yvalues.reshape(-1 , 10 , 1)))
validation_d = list(zip(validation_data[0].reshape(-1 , 784 , 1), validation_data[1]))

yvalues_t = np.zeros( (10000 , 10))
yvalues_t[range(10000) , test_data[1] ] = 1

test_d = list(zip(test_data[0].reshape(-1 , 784 , 1), yvalues_t.reshape(-1 , 10 , 1)))
nn = Network([784, 30, 10])
nn.SGD(training_d, 5, 1000 , 1,
        test_data=test_d)
دوره 0: 1049 / 10000
دوره 1: 1517 / 10000
دوره 2: 1878 / 10000
دوره 3: 2299 / 10000
دوره 4: 2638 / 10000