Part 03 · Deep Learning

Project: Build a Neural Network from Scratch

NumPyBackpropagationMNISTGradient check

শুধু NumPy দিয়ে দুই layer এর একটা network, যেটা হাতে লেখা digit পড়ে: forward pass, backprop, তোমার maths ঠিক কিনা প্রমাণ করার gradient check, আর লাইন ধরে লেখা training loop।

⏱ 5-6 ঘণ্টা · Station 21 / 92

কী বানাবে

সাধারণ NumPy তে লেখা দুই layer এর একটা neural network, যেটা হাতে লেখা digit পড়ে। PyTorch নেই, autograd নেই: forward pass, backward pass আর update নিজে লিখবে, gradient check দিয়ে প্রমাণ করবে backward pass ঠিক আছে, আর train করবে MNIST এর 60,000 টা ছবিতে, হাতে লেখা digit এর ক্লাসিক dataset। Data র উপর পাঁচবার ঘুরেই 10,000 টা test digit এ প্রায় 96.6% পায়, laptop এর CPU তে কয়েক second এ।

এরপর PyTorch এর loss.backward() আর কখনো black box থাকবে না: এই ধরনের network এর জন্য সে যা চালায় তার প্রতিটা লাইন তুমি নিজে লিখে ফেলবে।

যা আগে জানা লাগবে

Architecture

digit image28 × 28 pixelsa handwritten 7 from the MNIST training setpixelsflatten784 numbers, 0..1the grid becomes one long vector; brightness scaled to 0..1xW1 + ReLU784 → 128100,480 weights find strokes and loops; ReLU keeps the useful oneshW2128 → 10 scores1,290 weights turn 128 features into one score per digitscoressoftmax10 probabilitiesscores become probabilities that sum to 1: p(7) = 0.97ploss−log p(true digit)backprop sends p − one_hot(7) back through W2, ReLU and W1forward for a prediction; forward + backward + step to learn
28×28 এর একটা digit এর ছবি চ্যাপ্টা হয়ে 784 টা সংখ্যা হয়, W1 দিয়ে গুণ (784 থেকে 128) আর ReLU পার, W2 দিয়ে গুণ (128 থেকে 10) করে প্রতিটা digit এর একটা score, softmax দিয়ে probability, আর cross-entropy দিয়ে সঠিক label এর সাথে তুলনা। Backprop p থেকে one-hot label বিয়োগ করে প্রতিটা layer এর ভেতর দিয়ে পেছনে পাঠায়।
Fileকাজ
mnist.pyআসল gzip IDX file গুলো array তে পড়া, pixel কে 0..1 এ আনা
net.pynetwork: init, forward, loss, backward, step, predict
main.pygradient check, 5 epoch training, test accuracy, ASCII তে একটা ভুল আঁকা
test_net.pyshape, gradient আর "আদৌ শিখতে পারে কিনা" এর test

মোট 101,770 টা parameter: প্রথম layer এ 784 × 128 + 128, দ্বিতীয়টায় 128 × 10 + 10।

ধাপে ধাপে বানাও

Step 1: data load। প্রতিটা IDX file এ ছোট একটা header (ছবির জন্য 16 byte, label এর জন্য 8), তারপর কাঁচা byte। Pixel কে 255 দিয়ে ভাগ করো যাতে input 0 থেকে 1 হয়।

"""Load MNIST from the original IDX files (gzip): 28x28 grayscale digits."""
import gzip
import numpy as np

def _read(path, offset):
    with gzip.open(path) as f:
        return np.frombuffer(f.read(), dtype=np.uint8, offset=offset)

def load(root="/data"):
    X_train = _read(f"{root}/train-images-idx3-ubyte.gz", 16).reshape(-1, 784) / 255.0
    y_train = _read(f"{root}/train-labels-idx1-ubyte.gz", 8)
    X_test = _read(f"{root}/t10k-images-idx3-ubyte.gz", 16).reshape(-1, 784) / 255.0
    y_test = _read(f"{root}/t10k-labels-idx1-ubyte.gz", 8)
    return X_train, y_train.astype(int), X_test, y_test.astype(int)

Step 2: network। তিনটা detail ই বেশিরভাগ ভার বহন করে:

  • W1 এর জন্য He init (scale √(2/784)), যাতে ReLU activation গুলো শুরু থেকেই ঠিকঠাক সাইজের থাকে।
  • Stable softmax: exp এর আগে প্রতিটা row এর সবচেয়ে বড় score বিয়োগ করো।
  • Backward pass শুরু হয় p − one_hot(y) দিয়ে, softmax আর cross-entropy মিলিয়ে gradient, batch size দিয়ে ভাগ করা। তারপর শুধু chain rule: gW2 = hᵀ·d2, তারপর W2 পার হয়ে, ReLU পার হয়ে (gradient শুধু যেখানে z1 > 0), আর gW1 = Xᵀ·dz1 এ।
"""A 784 -> 128 (ReLU) -> 10 (softmax) network: forward, backward and update, in NumPy."""
import numpy as np

class Net:
    def __init__(self, hidden=128, seed=0):
        rng = np.random.default_rng(seed)
        self.W1 = rng.normal(0, np.sqrt(2 / 784), (784, hidden))   # He init for the ReLU layer
        self.b1 = np.zeros(hidden)
        self.W2 = rng.normal(0, np.sqrt(1 / hidden), (hidden, 10))
        self.b2 = np.zeros(10)

    def forward(self, X):
        self.X = X
        self.z1 = X @ self.W1 + self.b1          # (batch, 128)
        self.h = np.maximum(0, self.z1)          # ReLU
        z2 = self.h @ self.W2 + self.b2          # (batch, 10) scores
        z2 = z2 - z2.max(axis=1, keepdims=True)  # stable softmax
        e = np.exp(z2)
        self.p = e / e.sum(axis=1, keepdims=True)
        return self.p

    def loss(self, y):
        return float(-np.log(self.p[np.arange(len(y)), y] + 1e-12).mean())

    def backward(self, y):
        n = len(y)
        d2 = self.p.copy()
        d2[np.arange(n), y] -= 1                  # softmax + cross-entropy gradient: p - one_hot(y)
        d2 /= n
        self.gW2 = self.h.T @ d2
        self.gb2 = d2.sum(axis=0)
        dh = d2 @ self.W2.T
        dz1 = dh * (self.z1 > 0)                  # ReLU passes gradient only where it was active
        self.gW1 = self.X.T @ dz1
        self.gb1 = dz1.sum(axis=0)

    def step(self, lr):
        for name in ("W1", "b1", "W2", "b2"):
            setattr(self, name, getattr(self, name) - lr * getattr(self, "g" + name))

    def predict(self, X):
        return self.forward(X).argmax(axis=1)

Step 3: আগে check, তারপর train। Backprop কে বিশ্বাস করার আগে একটা gradient কে নাড়িয়ে মাপা মানের সাথে মেলাও। তারপর 64 এর mini-batch আর learning rate 0.1 দিয়ে পাঁচ epoch train করো।

import numpy as np
from mnist import load
from net import Net

X_train, y_train, X_test, y_test = load()
print(f"train {X_train.shape}, test {X_test.shape}")

# Gradient check on a tiny batch before trusting backprop.
net = Net(seed=0)
xb, yb = X_train[:5], y_train[:5]
net.forward(xb); net.backward(yb)
i, j = np.unravel_index(np.abs(net.gW1).argmax(), net.gW1.shape)   # check the biggest gradient
eps = 1e-5
orig = net.W1[i, j]
net.W1[i, j] = orig + eps; net.forward(xb); up = net.loss(yb)
net.W1[i, j] = orig - eps; net.forward(xb); down = net.loss(yb)
net.W1[i, j] = orig
print(f"gradient check W1[{i},{j}]: backprop {net.gW1[i, j]:.6e}  numeric {(up - down) / (2 * eps):.6e}")

net.forward(X_test)
print(f"before training: loss {net.loss(y_test):.3f} (ln 10 = {np.log(10):.3f}), accuracy {np.mean(net.predict(X_test) == y_test):.1%}")

rng = np.random.default_rng(1)
for epoch in range(1, 6):
    order = rng.permutation(len(X_train))
    for k in range(0, len(order), 64):              # mini-batches of 64
        idx = order[k:k + 64]
        net.forward(X_train[idx]); net.backward(y_train[idx]); net.step(lr=0.1)
    net.forward(X_train); train_loss = net.loss(y_train)
    test_acc = np.mean(net.predict(X_test) == y_test)
    print(f"epoch {epoch}  train loss {train_loss:.3f}  test accuracy {test_acc:.2%}")

# Look at one mistake.
pred = net.predict(X_test)
wrong = np.flatnonzero(pred != y_test)
k = wrong[0]
print(f"\n{len(wrong)} of {len(y_test)} test digits wrong. Test digit #{k}: label {y_test[k]}, predicted {pred[k]}")
for row in X_test[k].reshape(28, 28)[4:24:2]:
    print("  " + "".join("#" if v > 0.5 else "." if v > 0.1 else " " for v in row[4:24]))

Output:

train (60000, 784), test (10000, 784)
gradient check W1[406,8]: backprop 6.311949e-02  numeric 6.311949e-02
before training: loss 2.353 (ln 10 = 2.303), accuracy 9.9%
epoch 1  train loss 0.235  test accuracy 93.32%
epoch 2  train loss 0.164  test accuracy 95.22%
epoch 3  train loss 0.131  test accuracy 95.88%
epoch 4  train loss 0.106  test accuracy 96.53%
epoch 5  train loss 0.096  test accuracy 96.64%

336 of 10000 test digits wrong. Test digit #8: label 5, predicted 6
                ... #.
          .###########
       #. .####..     
     ###              
   .##.               
   ###..              
   ##############     
       ..#########    
         .########    
           #####      

কী দেখলাম:

  • Gradient check পাস: সবচেয়ে বড় gradient ওয়ালা weight এ backprop আর নাড়িয়ে মাপা 7 significant digit পর্যন্ত মেলে। (Random একটা weight check করা একটা ফাঁদ: প্রথম layer এর বেশিরভাগ weight কিনারার pixel ছোঁয়, যেগুলো সবসময় 0, তাই তাদের gradient ঠিক 0, আর check কিছুই প্রমাণ করে না।)
  • Training এর আগে loss 2.353, ln 10 = 2.303 এর কাছে, মানে "10 টা digit এর সবগুলো নিয়েই সমান অনিশ্চিত" থাকার খরচ, আর accuracy 9.9%, আন্দাজের সমান। Train না করা loss যদি ln 10 থেকে অনেক দূরে থাকে, তাহলে init বা softmax ভুল।
  • এক epoch এই 93.3%; পাঁচটায় 96.6%, training loss 0.235 থেকে 0.096 এ নামে। এই machine এ পাঁচ epoch এ মোটামুটি দশ second লেগেছে।
  • ভুলগুলো মানুষের মতো দেখতে। 10,000 টা test digit এর 336 টা ভুল। Digit #8 এর label 5; ASCII তে আঁকলে উপরের টানটা হালকা আর loop টা 6 এর মতো বন্ধ হয়ে গেছে, network ও তাই বলেছে।

Test করো

import numpy as np
from net import Net

rng = np.random.default_rng(0)
X = rng.random((8, 784))
y = rng.integers(0, 10, 8)

def test_shapes_and_probabilities():
    p = Net().forward(X)
    assert p.shape == (8, 10) and np.allclose(p.sum(axis=1), 1)

def test_backprop_matches_numeric_gradient_everywhere_we_look():
    net = Net(hidden=16)
    net.forward(X); net.backward(y)
    for name, idx in (("W1", (100, 3)), ("b1", (5,)), ("W2", (7, 2)), ("b2", (9,))):
        W, g = getattr(net, name), getattr(net, "g" + name)
        old = W[idx]; W[idx] = old + 1e-5; net.forward(X); up = net.loss(y)
        W[idx] = old - 1e-5; net.forward(X); down = net.loss(y); W[idx] = old
        assert abs(g[idx] - (up - down) / 2e-5) < 1e-6, name

def test_can_overfit_one_tiny_batch():
    net = Net(hidden=32)
    for _ in range(500):
        net.forward(X); net.backward(y); net.step(0.1)
    assert (net.predict(X) == y).all()

if __name__ == "__main__":
    for name, fn in list(globals().items()):
        if name.startswith("test_"):
            fn()
            print("pass", name)
pass test_shapes_and_probabilities
pass test_backprop_matches_numeric_gradient_everywhere_we_look
pass test_can_overfit_one_tiny_batch
  • Shape আর probability: 8 টা input দেয় (8, 10) এর output, যার প্রতিটা row এর যোগফল 1।
  • চারটা parameter দলেই gradient check, শুধু একটা weight না: bias এর gradient এ bug (ভুলে যাওয়া .sum(axis=0)) শুধু W1 এর check এ ধরা পড়ত না।
  • একটা ছোট্ট batch এ overfit: ঠিক network কে 8 টা random example মুখস্থ করতে পারতেই হবে। না পারলে update এ কিছু ভাঙা। যেকোনো নতুন model এর জন্য এটাই সবচেয়ে কাজের sanity test: পুরো training এর আগে চালাও।

Trade-offs

Choiceভালো দিকখরচ
হাতে লেখা NumPyপ্রতিটা operation দেখা যায়; framework এর কোনো জাদু নেইGPU নেই, autograd নেই: প্রতিটা নতুন layer মানে নতুন backward code আর নতুন gradient check
চ্যাপ্টা pixel এ dense layerসহজ, 96.6%pixel এর যে প্রতিবেশী আছে সেটা মানে না; digit কয়েক pixel সরালেই ভোগে। পরের project এর CNN কম weight এ 99% পায়
সাধারণ SGD, স্থির learning rateবোঝা সহজschedule সহ Adam দ্রুত আর একটু বেশি উপরে converge করত
Regularisation নেইকম ঝামেলালম্বা run এ overfitting আটকানোর কিছু নেই; অনেক epoch train করার আগে weight decay বা early stopping দাও
Monitoring এ test setশেখার project এ চলেআসল কাজে validation split এ monitor করো, test set ছোঁবে একবার

আরো এগিয়ে নাও

  1. দ্বিতীয় একটা hidden layer (128 → 64) যোগ করো আর তার backward pass লেখো। Train করার আগে gradient check কে সেটা পর্যন্ত বাড়াও।
  2. L2 weight decay (gW += λ·W) যোগ করো, আর training set থেকে কেটে নেওয়া 5,000 ছবির একটা validation split এ early stopping।
  3. SGD এর বদলে হাতে momentum লেখো: প্রতিটা parameter এর জন্য একটা velocity রাখো। প্রতিটা epoch এর পরে accuracy মেলাও।
  4. Test set এ 10 × 10 এর একটা confusion matrix print করো। কোন digit এর জোড়া network সবচেয়ে বেশি গুলিয়ে ফেলে, আর তোমার চোখেও কি সেগুলো একরকম লাগে?

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +20 XP পেতে।

Pro নিয়ে XP পাও →