فاز 3 · درس 1

پرسپترون چیست؟ پیاده‌سازی ساده‌ترین واحد یادگیری ماشین از صفر در پایتون

نوع
ساختنی
زمان
حدود 60 دقیقه
زبان‌ها
Python
پیش‌نیاز
فاز 1 (شهود جبر خطی)
یک ترازو یا کلید دوتایی مکانیکی بر روی سطحی چوبی که یک خط کش مستقیم فضا را به دو نیمه سیاه و سفید تقسیم کرده است
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. یک نورون، یک تصمیم
  5. مرز تصمیم‌گیری
  6. قاعده‌ی یادگیری
  7. مسئله‌ی XOR
  8. پیاده‌سازی از صفر
  9. گام 1: کلاس Perceptron
  10. گام 2: آموزش روی گیت‌های منطقی
  11. گام 3: مشاهده‌ی شکست در XOR
  12. گام 4: حل XOR با دو لایه
  13. گام 5: آموزش یک شبکه‌ی دولایه
  14. استفاده از ابزارهای واقعی
  15. خروجی این درس
  16. تمرین‌ها
  17. اصطلاحات کلیدی
  18. برای مطالعه‌ی بیشتر

بسیاری از برنامه‌نویسان یادگیری عمیق را با فریم‌ورک‌های سطح بالا شروع می‌کنند؛ مدل‌هایی چندمیلیون پارامتری که با چند خط کد ساخته و آموزش داده می‌شوند، اما سازوکار پشت صحنه‌ی آن‌ها مثل یک جعبه‌ی سیاه باقی می‌ماند. برای فهم عمیق شبکه‌های عصبی، باید به نقطه‌ی آغاز برگردیم: پرسپترون، ساده‌ترین واحد پردازشی که تمام شبکه‌های مدرن از کنار هم قرار گرفتن آن ساخته شده‌اند.

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

پرسپترون اتمِ شبکه‌های عصبی (neural networks) است؛ اگر بشکافیدش، به وزن‌ها، بایاس و یک تصمیم می‌رسید.

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

  • پیاده‌سازی پرسپترون از صفر در Python، شامل قاعده‌ی به‌روزرسانی وزن‌ها و تابع فعال‌سازی پله‌ای
  • توضیح اینکه چرا یک پرسپترونِ تنها فقط مسائل تفکیک‌پذیر خطی را حل می‌کند و نمایش نمونه‌ی شکست در مسئله‌ی XOR
  • ساخت یک پرسپترون چندلایه با ترکیب گیت‌های OR، NAND و AND برای حل مسئله‌ی XOR
  • آموزش یک شبکه‌ی دولایه با تابع فعال‌سازی سیگموئید (sigmoid) و پس‌انتشار خطا (backpropagation) برای یادگیری خودکار XOR

صورت مسئله

با بردارها و ضرب داخلی (dot product) آشنا هستید. می‌دانید که ماتریس ورودی‌ها را به خروجی‌ها تبدیل می‌کند. اما یک ماشین چطور یاد می‌گیرد که از کدام تبدیل استفاده کند؟

پرسپترون پاسخ این پرسش است؛ ساده‌ترین ماشین یادگیرنده‌ی ممکن: چند ورودی می‌گیرد، آن‌ها را در وزن‌ها ضرب می‌کند، بایاس را می‌افزاید و تصمیمی دودویی می‌گیرد. سپس خود را تنظیم می‌کند. همه‌چیز همین است. هر شبکه‌ی عصبی که تاکنون ساخته شده، چیدنِ لایه‌به‌لایه‌ی همین ایده روی یکدیگر است.

درک پرسپترون یعنی درک معنای واقعی «یادگیری» در دنیای کد: تغییردادن اعداد تا زمانی که خروجی با واقعیت تطبیق پیدا کند.

مفاهیم پایه

یک نورون، یک تصمیم

پرسپترون nn ورودی می‌گیرد، هر کدام را در یک وزن ضرب می‌کند، حاصل را با هم جمع می‌زند، بایاس را می‌افزاید و نتیجه را از یک تابع فعال‌سازی (activation function) عبور می‌دهد.

w1w2w3x1Σ(wi*xi) + bx2x3بایاسstep(z)خروجی (0 یا 1)

تابع پله‌ای (step function) رفتار قاطعی دارد: اگر مجموع وزن‌دار به‌اضافه‌ی بایاس ≥0\ge 0 باشد، خروجی 1 است؛ در غیر این صورت خروجی 0 خواهد بود.

step(z)={1if z≥00if z<0\text{step}(z) = \begin{cases} 1 & \text{if } z \ge 0 \\ 0 & \text{if } z < 0 \end{cases}

این یک دسته‌بند خطی (linear classifier) است. وزن‌ها و بایاس خطی (یا در ابعاد بالاتر، ابرصفحه‌ای) تعریف می‌کنند که فضای ورودی را به دو ناحیه تقسیم می‌کند.

مرز تصمیم‌گیری

برای دو ورودی، پرسپترون خطی در فضای دوبعدی رسم می‌کند:

  x2
  ┤
  │  Class 1        /
  │    (0)          /
  │                /
  │               / w1·x1 + w2·x2 + b = 0
  │              /
  │             /     Class 2
  │            /        (1)
  ┼───────────/──────────── x1

هر نقطه‌ای در یک سمتِ خط خروجی 0 و در سمت دیگر خروجی 1 می‌گیرد. فرایند آموزش، این خط را آن‌قدر جابه‌جا می‌کند تا کلاس‌ها به‌درستی از هم تفکیک شوند.

قاعده‌ی یادگیری

قاعده‌ی یادگیری پرسپترون ساده است:

For each training example (x, y_true):
    y_pred = predict(x)
    error = y_true - y_pred

    For each weight:
        w_i = w_i + learning_rate * error * x_i
    bias = bias + learning_rate * error

اگر پیش‌بینی درست باشد (error=0\text{error} = 0)، چیزی تغییر نمی‌کند. اگر 0 پیش‌بینی شود ولی مقدار واقعی 1 باشد، وزن‌ها افزایش می‌یابند. اگر 1 پیش‌بینی شود ولی مقدار واقعی 0 باشد، وزن‌ها کاهش پیدا می‌کنند. نرخ یادگیری (learning rate) بزرگیِ هر بار تنظیم را مشخص می‌کند.

مسئله‌ی XOR

اینجا نقطه‌ای است که کار به بن‌بست می‌رسد. این گیت‌های منطقی را ببینید:

AND gate:           OR gate:            XOR gate:
x1  x2  out         x1  x2  out         x1  x2  out
0   0   0           0   0   0           0   0   0
0   1   0           0   1   1           0   1   1
1   0   0           1   0   1           1   0   1
1   1   1           1   1   1           1   1   0

گیت‌های AND و OR تفکیک‌پذیر خطی (linearly separable) هستند: می‌توان با یک خط راست صفرها را از یک‌ها جدا کرد. اما XOR این‌طور نیست؛ هیچ تک‌خطی نمی‌تواند [0,1] و [1,0] را از [0,0] و [1,1] جدا کند.

AND (separable):        XOR (not separable):

  x2                      x2
  1 ┤  0     1            1 ┤  1     0
    │     /                 │
  0 ┤  0 / 0              0 ┤  0     1
    ┼──/──────── x1         ┼──────────── x1
       line works!          no single line works!

این یک محدودیت بنیادین است: یک پرسپترونِ تنها فقط از پسِ مسائل تفکیک‌پذیر خطی برمی‌آید. مینسکی و پاپرت در سال 1969 این موضوع را اثبات کردند و همین باعث شد پژوهش‌های شبکه‌های عصبی نزدیک به یک دهه عملاً متوقف شود.

راه‌حل: چیدن پرسپترون‌ها در لایه‌های مختلف. پرسپترون چندلایه با ترکیب دو تصمیم خطی و تبدیل آن‌ها به یک تصمیم غیرخطی، مسئله‌ی XOR را حل می‌کند.

مرز پرسپترونوزن‌ها را بکشید
PERCEPTRON BOUNDARYA perceptron predicts by the sign of w·x + b, so its decision surface is a straight line. Drag the weights and bias to rotate and shift that line until every blue circle sits on the positive side and every grey square on the negative one.
پرسپترون بر اساس علامت w·x + b پیش‌بینی می‌کند، بنابراین رویه تصمیم‌گیری آن یک خط مستقیم است. وزن‌ها و بایاس را بکشید تا آن خط بچرخد و جابه‌جا شود تا زمانی که هر دایره آبی در سمت مثبت و هر مربع خاکستری در سمت منفی قرار گیرد.

پیاده‌سازی از صفر

گام 1: کلاس Perceptron

class Perceptron:
    def __init__(self, n_inputs, learning_rate=0.1):
        self.weights = [0.0] * n_inputs
        self.bias = 0.0
        self.lr = learning_rate

    def predict(self, inputs):
        total = sum(w * x for w, x in zip(self.weights, inputs))
        total += self.bias
        return 1 if total >= 0 else 0

    def train(self, training_data, epochs=100):
        for epoch in range(epochs):
            errors = 0
            for inputs, target in training_data:
                prediction = self.predict(inputs)
                error = target - prediction
                if error != 0:
                    errors += 1
                    for i in range(len(self.weights)):
                        self.weights[i] += self.lr * error * inputs[i]
                    self.bias += self.lr * error
            if errors == 0:
                print(f"Converged at epoch {epoch + 1}")
                return
        print(f"Did not converge after {epochs} epochs")

گام 2: آموزش روی گیت‌های منطقی

and_data = [
    ([0, 0], 0),
    ([0, 1], 0),
    ([1, 0], 0),
    ([1, 1], 1),
]

or_data = [
    ([0, 0], 0),
    ([0, 1], 1),
    ([1, 0], 1),
    ([1, 1], 1),
]

not_data = [
    ([0], 1),
    ([1], 0),
]

print("=== AND Gate ===")
p_and = Perceptron(2)
p_and.train(and_data)
for inputs, _ in and_data:
    print(f"  {inputs} -> {p_and.predict(inputs)}")

print("\n=== OR Gate ===")
p_or = Perceptron(2)
p_or.train(or_data)
for inputs, _ in or_data:
    print(f"  {inputs} -> {p_or.predict(inputs)}")

print("\n=== NOT Gate ===")
p_not = Perceptron(1)
p_not.train(not_data)
for inputs, _ in not_data:
    print(f"  {inputs} -> {p_not.predict(inputs)}")

گام 3: مشاهده‌ی شکست در XOR

xor_data = [
    ([0, 0], 0),
    ([0, 1], 1),
    ([1, 0], 1),
    ([1, 1], 0),
]

print("\n=== XOR Gate (single perceptron) ===")
p_xor = Perceptron(2)
p_xor.train(xor_data, epochs=1000)
for inputs, expected in xor_data:
    result = p_xor.predict(inputs)
    status = "OK" if result == expected else "WRONG"
    print(f"  {inputs} -> {result} (expected {expected}) {status}")

مدل هرگز همگرا نمی‌شود. این اثبات عینی ناتوانی یک پرسپترون تنها در یادگیری XOR است.

گام 4: حل XOR با دو لایه

ترفند ماجرا: XOR=(x1 OR x2) AND NOT (x1 AND x2)\text{XOR} = (x_1 \text{ OR } x_2) \text{ AND NOT } (x_1 \text{ AND } x_2). سه پرسپترون را با هم ترکیب می‌کنیم:

x1نورون ORنورون NANDx2نورون ANDخروجی
def xor_network(x1, x2):
    or_neuron = Perceptron(2)
    or_neuron.weights = [1.0, 1.0]
    or_neuron.bias = -0.5

    nand_neuron = Perceptron(2)
    nand_neuron.weights = [-1.0, -1.0]
    nand_neuron.bias = 1.5

    and_neuron = Perceptron(2)
    and_neuron.weights = [1.0, 1.0]
    and_neuron.bias = -1.5

    hidden1 = or_neuron.predict([x1, x2])
    hidden2 = nand_neuron.predict([x1, x2])
    output = and_neuron.predict([hidden1, hidden2])
    return output


print("\n=== XOR Gate (multi-layer network) ===")
for inputs, expected in xor_data:
    result = xor_network(inputs[0], inputs[1])
    print(f"  {inputs} -> {result} (expected {expected})")

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

گام 5: آموزش یک شبکه‌ی دولایه

در گام 4 وزن‌ها را دستی چیدیم. این کار برای XOR جواب می‌دهد، اما در مسائل واقعی که وزن‌های درست را از پیش نمی‌دانیم شدنی نیست. راه‌حل: تابع پله‌ای را با سیگموئید جایگزین کنیم و وزن‌ها را خودکار از طریق پس‌انتشار خطا یاد بگیریم.

class TwoLayerNetwork:
    def __init__(self, learning_rate=0.5):
        import random
        random.seed(0)
        self.w_hidden = [[random.uniform(-1, 1), random.uniform(-1, 1)] for _ in range(2)]
        self.b_hidden = [random.uniform(-1, 1), random.uniform(-1, 1)]
        self.w_output = [random.uniform(-1, 1), random.uniform(-1, 1)]
        self.b_output = random.uniform(-1, 1)
        self.lr = learning_rate

    def sigmoid(self, x):
        import math
        x = max(-500, min(500, x))
        return 1.0 / (1.0 + math.exp(-x))

    def forward(self, inputs):
        self.inputs = inputs
        self.hidden_outputs = []
        for i in range(2):
            z = sum(w * x for w, x in zip(self.w_hidden[i], inputs)) + self.b_hidden[i]
            self.hidden_outputs.append(self.sigmoid(z))
        z_out = sum(w * h for w, h in zip(self.w_output, self.hidden_outputs)) + self.b_output
        self.output = self.sigmoid(z_out)
        return self.output

    def train(self, training_data, epochs=10000):
        for epoch in range(epochs):
            total_error = 0
            for inputs, target in training_data:
                output = self.forward(inputs)
                error = target - output
                total_error += error ** 2

                d_output = error * output * (1 - output)

                saved_w_output = self.w_output[:]
                hidden_deltas = []
                for i in range(2):
                    h = self.hidden_outputs[i]
                    hd = d_output * saved_w_output[i] * h * (1 - h)
                    hidden_deltas.append(hd)

                for i in range(2):
                    self.w_output[i] += self.lr * d_output * self.hidden_outputs[i]
                self.b_output += self.lr * d_output

                for i in range(2):
                    for j in range(len(inputs)):
                        self.w_hidden[i][j] += self.lr * hidden_deltas[i] * inputs[j]
                    self.b_hidden[i] += self.lr * hidden_deltas[i]
net = TwoLayerNetwork(learning_rate=2.0)
net.train(xor_data, epochs=10000)
for inputs, expected in xor_data:
    result = net.forward(inputs)
    predicted = 1 if result >= 0.5 else 0
    print(f"  {inputs} -> {result:.4f} (rounded: {predicted}, expected {expected})")

دو تفاوت کلیدی با گام 4 وجود دارد. اول اینکه سیگموئید جای تابع پله‌ای را می‌گیرد؛ تابعی هموار است، در نتیجه گرادیان‌ها (gradients) تعریف شده‌اند. دوم اینکه متد train خطا را از خروجی به لایه‌ی پنهان به عقب می‌فرستد و هر وزن را متناسب با سهمش در خطا اصلاح می‌کند. این یعنی پس‌انتشار خطا در 20 خط کد.

اینجا پل ورود به درس 03 است. ریاضیِ پشتِ d_output و hidden_deltas همان قاعده‌ی زنجیره‌ای (chain rule) روی گراف شبکه است که در درس بعدی به‌دقت اثباتش می‌کنیم.

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

تمام آنچه از صفر پیاده کردید، تنها با یک دستور import در دسترس است:

from sklearn.linear_model import Perceptron as SkPerceptron
import numpy as np

X = np.array([[0,0],[0,1],[1,0],[1,1]])
y = np.array([0, 0, 0, 1])

clf = SkPerceptron(max_iter=100, tol=1e-3)
clf.fit(X, y)
print([clf.predict([x])[0] for x in X])

تنها پنج خط کد؛ درحالی‌که کلاس 30خطیِ Perceptron شما هم دقیقاً همین کار را می‌کند. نسخه‌ی sklearn قابلیت‌هایی مثل بررسی همگرایی، چندین تابع زیان (loss) و پشتیبانی از ورودی‌های تنک (sparse) را افزوده است؛ اما حلقه‌ی اصلی کاملاً یکسان است: مجموع وزن‌دار، تابع پله‌ای، و به‌روزرسانی وزن‌ها در صورت بروز خطا.

شکاف واقعی در مقیاس‌های بزرگ آشکار می‌شود؛ تغییراتی که در شبکه‌های عملیاتی رخ می‌دهد:

  • تابع پله‌ای جای خود را به سیگموئید، ReLU یا دیگر توابع هموار می‌دهد
  • وزن‌ها به‌طور خودکار از طریق پس‌انتشار خطا یاد گرفته می‌شوند (درس 03)
  • لایه‌ها عمیق‌تر می‌شوند: 3، 10، 100 لایه یا بیشتر
  • اصل ماجرا تغییر نمی‌کند: هر لایه از خروجی‌های لایه‌ی قبلی ویژگی‌های جدیدی می‌سازد

یک پرسپترونِ تنها فقط خط راست می‌کشد؛ آن‌ها را روی هم بگذارید، تا هر شکلی را برایتان رسم کنند.

خروجی این درس

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

  • outputs/skill-perceptron.md - مهارتی در زمینه‌ی شناخت زمان نیاز به معماری‌های تک‌لایه در برابر چندلایه

تمرین‌ها

  1. یک پرسپترون را روی گیت NAND آموزش دهید (گیت عمومی که هر مدار منطقی را می‌توان با آن ساخت). بررسی کنید که وزن‌ها و بایاس آن مرز تصمیم‌گیری معتبری تشکیل دهند.
  2. کلاس Perceptron را طوری تغییر دهید که در هر دوره (epoch) مرز تصمیم‌گیری (w1x1+w2x2+b=0w_1 x_1 + w_2 x_2 + b = 0) را ثبت کند. جابه‌جایی خط را در طول آموزش روی گیت AND در خروجی چاپ کنید.
  3. پرسپترونی با 3 ورودی بسازید که خروجی آن فقط وقتی 1 باشد که دست‌کم 2 ورودی از 3 ورودی، 1 باشند (تابع رأی‌گیری اکثریت). آیا این مسئله تفکیک‌پذیر خطی است؟ چرا؟

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

اصطلاح در گفت‌وگوهای عامیانه معنای واقعی و دقیق
پرسپترون (Perceptron) «یک نورون مصنوعی» یک دسته‌بند خطی: ضرب داخلیِ ورودی‌ها و وزن‌ها، به‌اضافه‌ی بایاس، که از یک تابع پله‌ای عبور می‌کند
وزن (Weight) «میزان اهمیت یک ورودی» ضریبی که اثر هر ورودی را در تصمیم‌گیری نهایی مقیاس‌بندی می‌کند
بایاس (Bias) «آستانه (threshold)» مقداری ثابت که مرز تصمیم‌گیری را جابه‌جا می‌کند و اجازه می‌دهد پرسپترون حتی با ورودی‌های صفر هم فعال شود
تابع فعال‌سازی (Activation function) «چیزی که مقادیر را فشرده می‌کند» تابعی که پس از مجموع وزن‌دار اعمال می‌شود؛ در پرسپترون تابع پله‌ای، و در شبکه‌های نوین سیگموئید یا ReLU
تفکیک‌پذیر خطی (Linearly separable) «می‌شود بینشان خط کشید» مجموعه‌داده‌ای که در آن یک ابرصفحه می‌تواند کلاس‌ها را کاملاً از هم جدا کند
مسئله‌ی XOR (XOR problem) «کاری که پرسپترون نمی‌تواند بکند» اثباتی بر اینکه شبکه‌های تک‌لایه نمی‌توانند توابع تفکیک‌ناپذیر خطی را یاد بگیرند
مرز تصمیم‌گیری (Decision boundary) «جایی که دسته‌بند تغییر حالت می‌دهد» ابرصفحه‌ی w⋅x+b=0w \cdot x + b = 0 که فضای ورودی را به دو کلاس تفکیک می‌کند
پرسپترون چندلایه (Multi-layer perceptron) «یک شبکه‌ی عصبی واقعی» پرسپترون‌هایی که در چند لایه چیده شده‌اند؛ به‌طوری‌که خروجی هر لایه، ورودیِ لایه‌ی بعدی می‌شود

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

  • Frank Rosenblatt, "The Perceptron: A Probabilistic Model for Information Storage and Organization in the Brain" (1958) -- مقاله‌ی اصلی که همه‌چیز از آنجا آغاز شد.
  • Minsky & Papert, "Perceptrons" (1969) -- کتابی که اثبات کرد XOR با شبکه‌های تک‌لایه حل‌نشدنی است و تحقیقات شبکه‌های عصبی را برای یک دهه به محاق برد.
  • Michael Nielsen, "Neural Networks and Deep Learning", Chapter 1 (http://neuralnetworksanddeeplearning.com/) -- رایگان و آنلاین؛ بهترین توضیح بصری درباره‌ی چگونگی ترکیب پرسپترون‌ها برای ساخت شبکه‌ها.

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

چرا پرسپترون تک‌لایه نمی‌تواند مسئله‌ی XOR را حل کند؟

پرسپترون یک دسته‌بند خطی است و فقط می‌تواند داده‌هایی را تفکیک کند که با یک خط مستقیم (یا ابرصفحه) از هم جدا می‌شوند. از آنجا که خروجی‌های XOR تفکیک‌پذیر خطی نیستند، هیچ تک‌خطی نمی‌تواند داده‌های آن را درست جدا کند.

تفاوت پرسپترون کلاسیک با شبکه‌های عصبی امروزی چیست؟

پرسپترون کلاسیک از تابع پله‌ای غیرقابل‌مشتق‌گیری استفاده می‌کند و فقط با خط راست تفکیک انجام می‌دهد، اما شبکه‌های نوین با توابع فعال‌سازی هموار (مثل سیگموئید و ReLU)، لایه‌های متعدد و الگوریتم پس‌انتشار خطا آموزش می‌بینند.

حل مسئله XOR در شبکه‌های عصبی به چه صورت ممکن شد؟

با چیدن پرسپترون‌ها در لایه‌های متوالی (پرسپترون چندلایه) و ترکیب چند تصمیم خطی، مرزهای تصمیم‌گیری غیرخطی پدید می‌آیند و مسئله XOR به‌راحتی حل می‌شود.

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

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