Part 03 · Deep Learning
Project: Build a Neural Network from Scratch
শুধু 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 এর জন্য সে যা চালায় তার প্রতিটা লাইন তুমি নিজে লিখে ফেলবে।
যা আগে জানা লাগবে
- Neural Networks and Perceptrons: layer, ReLU, softmax।
- Forward Pass and Backpropagation: network এর ভেতর দিয়ে chain rule।
- Training Neural Networks: mini-batch, He init।
- NumPy সহ Python, আর একটা
datafolder এ MNIST এর চারটা file (প্রায় 11 MB, যেকোনো MNIST mirror থেকে)।
Architecture
| File | কাজ |
|---|---|
mnist.py | আসল gzip IDX file গুলো array তে পড়া, pixel কে 0..1 এ আনা |
net.py | network: init, forward, loss, backward, step, predict |
main.py | gradient check, 5 epoch training, test accuracy, ASCII তে একটা ভুল আঁকা |
test_net.py | shape, 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 ছোঁবে একবার |
আরো এগিয়ে নাও
- দ্বিতীয় একটা hidden layer (128 → 64) যোগ করো আর তার backward pass লেখো। Train করার আগে gradient check কে সেটা পর্যন্ত বাড়াও।
- L2 weight decay (
gW += λ·W) যোগ করো, আর training set থেকে কেটে নেওয়া 5,000 ছবির একটা validation split এ early stopping। - SGD এর বদলে হাতে momentum লেখো: প্রতিটা parameter এর জন্য একটা velocity রাখো। প্রতিটা epoch এর পরে accuracy মেলাও।
- Test set এ 10 × 10 এর একটা confusion matrix print করো। কোন digit এর জোড়া network সবচেয়ে বেশি গুলিয়ে ফেলে, আর তোমার চোখেও কি সেগুলো একরকম লাগে?
Station stamp
Chapter শেষ করো
পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +20 XP পেতে।
Pro নিয়ে XP পাও →