সরাসরি পর্বে যান
পর্ব ০৫ · ৯১-এর মধ্যে · মডিউল ১

নিজে বানানো autograd — computational graph আর reverse mode

Autograd from Scratch — Computational Graphs, Forward vs Reverse Mode, a NumPy Tensor Engine and PyTorch

Taught in Bangla, this lesson builds automatic differentiation from the ground up: a computational graph, a reverse sweep in topological order, and a small scalar Value class. It compares forward mode with dual numbers against reverse mode, extends the idea to a NumPy tensor engine with matmul, broadcasting, ReLU and mean, trains an MLP that matches hand-written backward, and shows why zero_grad matters, in in-page Python. A real desktop PyTorch run covers define-by-run graphs.

এই পর্বে যা শিখবেন

  • একটি হিসাবকে computational graph-এ ভাঙা, আর topological ক্রম কেন লাগে
  • Local gradient গুণ করে পেছনে হাঁটা (reverse sweep), আর gradient কেন যোগ হয়, বসানো হয় না
  • ত্রিশ লাইনের কাছাকাছি একটি scalar autograd লেখা আর central difference দিয়ে যাচাই
  • Forward mode (dual number, Jacobian-vector product) বনাম reverse mode (vector-Jacobian product): কোনটির খরচ কখন কম
  • NumPy-তে tensor autograd, broadcasting-এর backward, নিজের engine দিয়ে MLP শেখানো, zero_grad আর PyTorch-এ define-by-run

AI-এর ভিত্তি কোর্সের পর্ব ৬৮-এ একটি ২-২-১ network-এ এক ধাপ backpropagation হাতে করেছেন, আর PyTorch-এর loss.backward() সেই হাতের হিসাবের সঙ্গে মিলেছিল। মেশিন লার্নিং কোর্সের পর্ব ১১-এ Jacobian, chain rule আর forward ও reverse mode-এর তত্ত্ব এসেছে। এই কোর্সের পর্ব ০৪ (Backpropagation হাতে-কলমে — chain rule থেকে পুরো gradient) পুরো batch-এর backward হাতে লেখে। এই পর্বের প্রশ্ন আরেক ধাপ গভীরে: backward() নিজে কীভাবে জানে কোন ক্রমে কী গুণ করতে হবে? আমরা কি নিজেরাই তা বানাতে পারি? চালানো যায় এমন চারটি ব্লক পাতাতেই চলে (ব্রাউজারের Python আর NumPy); প্রথমবার প্যাকেজ নামতে কয়েক সেকেন্ড লাগে। PyTorch পাতায় চলে না, তাই শেষে ডেস্কটপের একটি সত্যিকারের run দেখানো হয়েছে।

১ · Computational graph — একটি হিসাবকে ছোট ধাপের জালে ভাঙা

বাজারঘরের গুদামে একটি অর্ডারের দাম ঠিক হয় কয়েক ধাপে: পণ্যের দাম গুণ পরিমাণ, তার সঙ্গে ডেলিভারি চার্জ যোগ, শেষে ছাড়। হিসাবরক্ষক প্রতিটি ধাপ খাতায় আলাদা লাইনে লেখেন, কোন লাইন কোন লাইন থেকে এসেছে তাও। মাস শেষে কেউ যদি জিজ্ঞেস করেন “পণ্যের দাম ১ টাকা বাড়লে শেষ বিল কত বাড়ত?”, তিনি খাতার শেষ লাইন থেকে উল্টো দিকে প্রতিটি লাইনের ছোট প্রভাব গুণ করে উত্তর দেন। Autograd ঠিক এই খাতা: হিসাব চলার সময় প্রতিটি ধাপ লিখে রাখে, প্রশ্ন এলে পেছন থেকে পড়ে।

যেকোনো হিসাব, যত বড়ই হোক, কয়েকটি মৌলিক কাজের শিকল বা জাল: যোগ, গুণ, ঘাত, exp, log, tanh, ReLU. প্রতিটি কাজ একটি node; যে মানগুলো থেকে কাজটি হলো সেগুলো তার parent, আর তীর parent থেকে node-এর দিকে। শুরুর মানগুলো (input আর parameter) কোনো কাজ থেকে আসে না; এদের বলে leaf. কোনো মান নিজের ওপর নির্ভর করতে পারে না, তাই তীর ধরে এগিয়ে কখনো আগের জায়গায় ফেরা যায় না: graph-টি একটি DAG. এই পুরো ধারণার নাম computational graph, আর তার ওপর chain rule চালিয়ে নির্ভুল derivative বের করার কৌশল automatic differentiation; PyTorch-এ এর নাম autograd.

সারা পর্বে একটি ছোট উদাহরণ বারবার আসবে, একটি neuron-এর মতো: L = tanh(a·b + c), যেখানে a = ২, b = −১, c = ১.৫. এখানে a·b যেন weight গুণ input, c যেন bias, আর tanh activation. নিচের ছবিতে নীল সংখ্যা সামনের দিকের মান, লাল সংখ্যা পেছন থেকে আসা gradient; লাল সংখ্যাগুলো কীভাবে এলো, তা অংশ ২-এ।

L = tanh(a·b + c)-এর computational graph

Values flow forward (blue); gradients flow backward (red)

Computational graph of tanh(a*b + c) with forward values and backward gradients a = 2 b = −1 c = 1.5 × : −2 + : −0.5 tanh g −0.786 g 1.573 g 0.786 g 0.786 g 0.786 L, g = 1 L = −0.462 নীল: সামনের মান · লাল (g): ∂L/∂node
ছবি ৫.১ — তিনটি leaf, তিনটি কাজের node. সামনে যাওয়ার সময় প্রতিটি node নিজের মান হিসাব করে; পেছনে আসার সময় প্রতিটি node-এর gradient মানে “এই node-এর মান সামান্য বদলালে L কত গুণ বদলায়”।

নিচের stepper দেখায়, forward pass চলার সময় graph কীভাবে একটু একটু করে তৈরি হয়। গুরুত্বপূর্ণ কথাটি তৃতীয় কলামে: প্রতিটি node তৈরির মুহূর্তেই তার local gradient, অর্থাৎ নিজের parent-দের সাপেক্ষে নিজের ছোট derivative, হিসাব করে রাখা যায়, কারণ তখন সব মান হাতের কাছে আছে।

L = tanh(a·b + c) · প্রতিটি নতুন node আর তার local gradient
নতুন node
মান
local gradient
graph-এ তীর
  1. Leaf. তিনটি শুরুর মান তৈরি হলো। এদের কোনো parent নেই; শেখার সময় এরাই বদলাবে (weight আর bias), তাই এদের gradient-ই শেষ পর্যন্ত দরকার।
  2. গুণের node. m = a·b = −২. গুণফলের derivative: a-এর সাপেক্ষে অন্য গুণনীয়ক b = −১, b-এর সাপেক্ষে a = ২. দুটিই এখনই লিখে রাখা হলো।
  3. যোগের node. s = m + c = −০.৫. যোগের local gradient সবসময় ১ আর ১: যোগ নিজের gradient কোনো বদল ছাড়াই দুই parent-কে দিয়ে দেয়।
  4. tanh-এর node. L = tanh(−০.৫) = −০.৪৬২১. tanh-এর derivative 1 − tanh², তাই output L থেকেই বের হয়: ১ − ০.২১৩৬ = ০.৭৮৬৪.
  5. ক্রম. তৈরির ক্রমটিই একটি topological ক্রম: প্রতিটি node তার parent-দের পরে এসেছে। Backward এই ক্রমের উল্টো দিকে চলবে, যাতে কোনো node-এর gradient পুরো হওয়ার আগে কেউ তা ব্যবহার না করে।

২ · Reverse sweep — local gradient গুণ করে পেছনে হাঁটা

এখন উল্টো যাত্রা। প্রতিটি node-এর জন্য একটি সংখ্যা রাখি, grad = ∂L/∂(node). শুরুতে L-এর নিজের সাপেক্ষে derivative ১, বাকি সব ০. তারপর topological ক্রমের উল্টো দিকে প্রতিটি node v-এর জন্য একটিই নিয়ম:

প্রতিটি parent p-এর জন্য: p.grad += (∂v/∂p) × v.grad

এটি chain rule-এর সরাসরি রূপ: L-এ p-এর প্রভাব = (p থেকে v-তে প্রভাব) × (v থেকে L-এ প্রভাব)। দুটি খুঁটিনাটি এই নিয়মকে সঠিক রাখে। প্রথম, উল্টো topological ক্রম: v-এর gradient ব্যবহার করার আগে v-এর সব child তাদের অবদান যোগ করে ফেলেছে, তাই v.grad তখন পূর্ণ। দ্বিতীয়, “=” নয়, “+=”: একটি মান দুই জায়গায় ব্যবহৃত হলে L-এ পৌঁছানোর দুটি পথ থাকে, আর বহুচলকের chain rule বলে মোট প্রভাব দুই পথের যোগফল। যেমন y = x·x + x-এ x = ৩ হলে, গুণের দুই দিক থেকে ৩ + ৩ আর যোগ থেকে ১, মোট ৭ = 2x + 1. পুরো প্রক্রিয়াটি reverse-mode automatic differentiation, আর neural network-এ এর প্রচলিত নাম backpropagation.

ক্রম: L → s → c → m → b → a · local gradient অংশ ১ থেকে
কোন node
হিসাব
grad-এর অবস্থা
  1. বীজ. L.grad = ১, কারণ L-কে সামান্য বাড়ালে L ঠিক ততটাই বাড়ে। বাকি সব grad শূন্য থেকে শুরু, পরে যোগ হবে।
  2. tanh node. L-এর একটিই parent, s. local gradient ০.৭৮৬৪, তাই s.grad = ০.৭৮৬৪ × ১ = ০.৭৮৬৪.
  3. যোগের node. s-এর local gradient দুই parent-এর জন্যই ১, তাই m আর c দুজনেই হুবহু ০.৭৮৬৪ পায়। c-এর কাজ এখানেই শেষ: c একটি leaf, তার gradient চূড়ান্ত।
  4. গুণের node. a পায় “অন্য গুণনীয়ক × উপরের gradient” = (−১) × ০.৭৮৬৪ = −০.৭৮৬৪; b পায় ২ × ০.৭৮৬৪ = ১.৫৭২৯. মানে a একটু বাড়ালে L কমে, b একটু বাড়ালে L বাড়ে, আর b-এর প্রভাব a-এর দ্বিগুণ।
  5. যাচাই. a-কে ±০.০০০০১ সরিয়ে L দুবার হিসাব করে পার্থক্যকে ২h দিয়ে ভাগ করলে −০.৭৮৬৪৪৮, reverse sweep-এর সঙ্গে মিলে যায়। খেয়াল করুন: পুরো sweep-এ প্রতিটি node একবারই ছোঁয়া হয়েছে।

শেষ পর্যবেক্ষণটি এই পর্বের কেন্দ্রীয় কথা। Graph-এ N-টি node থাকলে forward pass N-টি কাজ, আর reverse sweep-ও প্রতিটি node-এ একবার, প্রতিটি parent-এ একটি গুণ আর একটি যোগ। তাই সব leaf-এর gradient একসঙ্গে পাওয়ার খরচ forward-এর একটি ছোট ধ্রুবক গুণ, leaf যত হাজার বা কোটিই হোক। Topological ক্রম বের করার জন্য depth-first search যথেষ্ট; DSA কোর্সের পর্ব ৪২-এ এর পুরো আলোচনা আছে। এখানে আমরা DFS-এর post-order নিই: একটি node-কে তালিকায় বসাই তার সব parent বসার পরে।

৩ · নিজের scalar autograd — একটি ছোট Value class

এখন খাতাটি কোডে লিখি। প্রতিটি সংখ্যাকে একটি Value object-এ মুড়ে রাখব, যেখানে থাকবে মান (data), gradient (grad), parent-দের তালিকা আর প্রতিটি parent-এর local gradient (local). Python-এ a * b লিখলে আসলে a.__mul__(b) চলে; একে বলে operator overloading (Python কোর্সের পর্ব ৩২-এ dunder method). তাই আমরা __mul__ নিজে লিখে দিলে সাধারণ দেখতে সূত্রই চুপচাপ graph বানাবে। ধারণাটি নতুন নয়; Andrej Karpathy-র ছোট শিক্ষামূলক প্রকল্প micrograd (২০২০ সালের দিকে প্রকাশিত) এমন একটি Value class জনপ্রিয় করেছে। নিচের রূপে একটি ছোট পার্থক্য: local gradient তৈরির সময়েই হিসাব হয়ে একটি tuple-এ থাকে, তাই backward-এর ভেতরের লুপ মাত্র এক লাইন।

import math

class Value:                       # একটি সংখ্যা, তার gradient, আর graph-এর তীর
    def __init__(self, data, parents=(), op='', local=()):
        self.data, self.grad, self.parents = float(data), 0.0, parents
        self.op, self.local = op, local          # local[i] = ∂(এই node)/∂(parents[i])
    def __add__(self, o):
        o = o if isinstance(o, Value) else Value(o)
        return Value(self.data + o.data, (self, o), '+', (1.0, 1.0))
    def __mul__(self, o):
        o = o if isinstance(o, Value) else Value(o)
        return Value(self.data * o.data, (self, o), '*', (o.data, self.data))
    __radd__, __rmul__ = __add__, __mul__
    def __pow__(self, k):          # k একটি সাধারণ সংখ্যা
        return Value(self.data ** k, (self,), f'**{k}', (k * self.data ** (k - 1),))
    def tanh(self): t = math.tanh(self.data); return Value(t, (self,), 'tanh', (1 - t * t,))
    def relu(self): return Value(max(self.data, 0.0), (self,), 'relu', (float(self.data > 0),))
    def exp(self): e = math.exp(self.data); return Value(e, (self,), 'exp', (e,))
    def log(self): return Value(math.log(self.data), (self,), 'log', (1 / self.data,))
    def backward(self):            # topological sort, তারপর উল্টো ক্রমে chain rule
        order, seen = [], set()
        def visit(v):
            if v not in seen:
                seen.add(v); [visit(p) for p in v.parents]; order.append(v)
        visit(self); self.grad = 1.0
        for v in reversed(order):
            for p, d in zip(v.parents, v.local): p.grad += d * v.grad
        return [v.op or 'leaf' for v in order]

a, b, c = Value(2.0), Value(-1.0), Value(1.5)
L = (a * b + c).tanh(); print("order:", L.backward())
print(f"L = {L.data:.4f} | ∂L/∂a = {a.grad:.4f}, ∂L/∂b = {b.grad:.4f}, ∂L/∂c = {c.grad:.4f}")
x = Value(3.0); y = x * x + x                # x দুই জায়গায় ব্যবহৃত
y.backward(); print("d(x*x + x)/dx, x = 3:", x.grad)

আউটপুট (পাতার Python-এ চালানো):

order: ['leaf', 'leaf', '*', 'leaf', '+', 'tanh']
L = -0.4621 | ∂L/∂a = -0.7864, ∂L/∂b = 1.5729, ∂L/∂c = 0.7864
d(x*x + x)/dx, x = 3: 7.0

প্রথম লাইন DFS post-order-এর ক্রম, অংশ ১-এর stepper-এর পঞ্চম ধাপের হুবহু: a, b, তাদের গুণ, তারপর c, যোগ, tanh. দ্বিতীয় লাইনের তিনটি gradient হাতে করা reverse sweep-এর সঙ্গে চার দশমিক পর্যন্ত এক। তৃতীয় লাইন “+=”-এর পরীক্ষা: x দুবার গুণে আর একবার যোগে এসেছে, তিন পথের অবদান ৩ + ৩ + ১ = ৭. কোডে “+=”-এর জায়গায় “=” লিখলে যে পথ শেষে লেখে শুধু সেটি টিকত; এখানে উত্তর আসত ৩. __radd__ আছে বলে 2 + x বা Python-এর sum()-ও কাজ করে, কারণ sum ০ থেকে শুরু করে।

কয়েকটি নকশাগত সিদ্ধান্ত লক্ষ করুন। ReLU-এর local gradient x = ০-তে আমরা ০ ধরেছি (x > 0), যদিও সেখানে derivative সংজ্ঞায়িত নয়; PyTorch-ও ReLU-তে ০-তে ০ দেয়। log-এর local gradient 1/x, তাই x খুব ছোট হলে বিশাল; বাস্তব library এমন জায়গায় সংখ্যাগত স্থিতির জন্য আলাদা সূত্র ব্যবহার করে। আর seen একটি set, যা object-এর পরিচয় ধরে কাজ করে: একই node দুবার তালিকায় ঢুকলে তার অবদান দুবার যোগ হতো।

৪ · Forward mode বনাম reverse mode — dual number, JVP আর VJP

মেশিন লার্নিং কোর্সের পর্ব ১১-এর এক লাইনের সারকথা: x → h1 → … → y শিকলের Jacobian J = Jk⋯J1; ডান দিক থেকে একটি input দিক v নিয়ে গুণ করলে পাওয়া যায় Jacobian-vector product Jv (forward mode), বাঁ দিক থেকে একটি output দিক u নিয়ে গুণ করলে vector-Jacobian product uᵀJ (reverse mode). আমাদের Value class reverse mode, কারণ প্রতিটি node-এর local gradient আসলে তার ছোট Jacobian, আর backward-এর লুপ সেগুলোকে বাঁ দিক থেকে গুণ করে।

Forward mode-এর সবচেয়ে সুন্দর রূপ dual number: প্রতিটি সংখ্যার সঙ্গে একটি “ছায়া” রাখি, x + ẋε, যেখানে ε একটি প্রতীক যার নিয়ম ε² = ০. তাহলে (a + ȧε)(b + ḃε) = ab + (ȧb + aḃ)ε, কারণ ε²-এর পদ বাদ। ছায়ার অংশ হুবহু গুণফলের derivative-এর নিয়ম! সাধারণভাবে f(x + ẋε) = f(x) + f′(x)ẋε. তাই যে input-এর সাপেক্ষে derivative চাই, তার ছায়া ১ আর বাকিদের ০ বসিয়ে একবার সামনে চালালেই output-এর ছায়ায় সেই derivative. এর জন্য কোনো graph মনে রাখতে হয় না, পেছনে হাঁটতেও হয় না।

খরচের নিয়ম (f: ℝn → ℝm)

পুরো Jacobian পেতে forward mode-এ n-টি pass লাগে (প্রতি input দিকে একটি), reverse mode-এ m-টি backward pass (প্রতি output দিকে একটি)। Neural network-এর training-এ m = ১ (একটি loss) আর n = লাখ বা কোটি parameter, তাই reverse mode অনেক গুণ সস্তা। বিনিময়ে reverse mode-কে forward-এর সব মাঝের মান মনে রাখতে হয়, forward mode-কে নয়।

এবার সংখ্যায় মাপি। নিচের ব্লক আগের ব্লকের Value-এর পাশে একটি Dual class লেখে, যা একই কাজগুলো (যোগ, গুণ, ঘাত, tanh, ReLU, exp, log) ছায়াসহ করে। একটি function f-এ ৫০টি input আর একটি output, আর সাতটি কাজই ব্যবহৃত। একই function তিনভাবে differentiate হবে: reverse mode একবারে, forward mode ৫০ বার, আর central difference ১০০টি অতিরিক্ত হিসাবে। প্রতিটি Dual object তৈরি হলে একটি counter বাড়ে, যাতে কাজের সংখ্যা গোনা যায়।

import math, random

class Dual:                        # forward mode: (মান, একটি দিকের derivative) একসঙ্গে
    ops = 0
    def __init__(self, v, d=0.0): self.v, self.d = v, d; Dual.ops += 1
    def __add__(self, o):
        o = o if isinstance(o, Dual) else Dual(o); return Dual(self.v + o.v, self.d + o.d)
    def __mul__(self, o):
        o = o if isinstance(o, Dual) else Dual(o)
        return Dual(self.v * o.v, self.d * o.v + self.v * o.d)
    __radd__, __rmul__ = __add__, __mul__
    def __pow__(self, k): return Dual(self.v ** k, k * self.v ** (k - 1) * self.d)
    def tanh(self): t = math.tanh(self.v); return Dual(t, (1 - t * t) * self.d)
    def relu(self): return Dual(max(self.v, 0.0), self.d * (self.v > 0))
    def exp(self): e = math.exp(self.v); return Dual(e, e * self.d)
    def log(self): return Dual(math.log(self.v), self.d / self.v)

def f(x):                          # n-টি input, একটি output, সব op ব্যবহৃত
    s = sum((0.3 * xi).exp() for xi in x)
    return s.log() + 0.01 * sum(xi.relu() for xi in x) ** 2 + (x[0] * x[1]).tanh()

random.seed(42); n = 50; x0 = [random.uniform(-1, 1) for _ in range(n)]
xs = [Value(v) for v in x0]; out = f(xs); nodes = len(out.backward())   # reverse: একবার
rev = [v.grad for v in xs]
fwd = [f([Dual(v, float(j == i)) for j, v in enumerate(x0)]).d for i in range(n)]  # n বার
h = 1e-5                                                       # central difference
num = [(f([Value(v + h * (j == i)) for j, v in enumerate(x0)]).data
        - f([Value(v - h * (j == i)) for j, v in enumerate(x0)]).data) / (2 * h) for i in range(n)]
print(f"f(x) = {out.data:.6f}, ∂f/∂x0 = {rev[0]:.6f}")
print(f"max |reverse − forward| = {max(abs(r - q) for r, q in zip(rev, fwd)):.1e}")
print(f"max |reverse − central| = {max(abs(r - q) for r, q in zip(rev, num)):.1e}")
print(f"forward mode: {n} pass, মোট {Dual.ops} Dual, প্রতি pass-এ {Dual.ops // n}")
print(f"reverse mode: 1 forward + 1 backward, graph-এ {nodes} node")

আউটপুট (পাতার Python-এ চালানো):

f(x) = 4.754515, ∂f/∂x0 = -0.668434
max |reverse − forward| = 8.7e-19
max |reverse − central| = 4.4e-11
forward mode: 50 pass, মোট 18000 Dual, প্রতি pass-এ 360
reverse mode: 1 forward + 1 backward, graph-এ 360 node

তিনটি পদ্ধতি একই gradient দিয়েছে। Forward আর reverse-এর পার্থক্য 8.7 × 10−19: দুটিই নির্ভুল derivative, পার্থক্য শুধু যোগের ক্রমে rounding. Central difference-এর পার্থক্য 4.4 × 10−11, কারণ সেটি আনুমানিক। খরচের লাইন দুটি আসল শিক্ষা: একটি forward pass-এ ৩৬০টি কাজ, আর graph-এও ৩৬০টি node. Reverse mode সেই ৩৬০টি node একবার সামনে আর একবার পেছনে ছুঁয়েছে; forward mode ৫০ বার পুরো ৩৬০টি কাজ চালিয়ে মোট ১৮,০০০টি Dual বানিয়েছে, প্রায় ৫০ গুণ বেশি। Input ৫০ লাখ হলে এই অনুপাত ৫০ লাখ গুণ, আর ঠিক এই কারণেই deep learning-এর সব framework training-এ reverse mode চালায়। (সংখ্যাগুলো এই একটি function আর এই একটি run-এর; সময় নয়, কাজের সংখ্যা গোনা হয়েছে।)

তাহলে forward mode কি অকেজো? না। Input কম আর output বেশি হলে (যেমন একটি parameter বদলালে একটি সিমুলেশনের হাজারটি output কতটা বদলায়) forward mode-ই সস্তা, আর তার memory খরচ প্রায় শূন্য, কারণ মাঝের মান মনে রাখতে হয় না। দ্বিতীয় ক্রমের derivative-এও দুটি mode মিলে কাজ করে: Hessian-vector product বের করার একটি প্রচলিত কৌশল reverse mode-এর gradient-এর ওপর forward mode চালানো। JAX-এর মতো library এজন্য jvp আর vjp দুটি আলাদা function হিসেবেই দেয়।

৫ · Scalar থেকে tensor — প্রতিটি কাজের VJP আর broadcasting-এর যোগফল

Scalar engine শেখার জন্য চমৎকার, কিন্তু ১৬টি hidden unit আর ২০০টি উদাহরণের একটি ছোট network-এও কয়েক হাজার Value object লাগবে, আর Python-এর লুপ খুব ধীর। বাস্তব framework node বানায় পুরো array-র ওপর: একটি matrix গুণ একটি node. তখন “local gradient” আর একটি সংখ্যা নয়, একটি Jacobian, যা বিশাল হতে পারে: (২০০ × ১৬) output আর (২ × ১৬) weight-এর Jacobian-এ ৩২০০ × ৩২টি ঘর। তাই tensor engine-এ পুরো Jacobian কখনো বানানো হয় না; প্রতিটি কাজের জন্য একটি ছোট function লেখা হয়, যা উপরের gradient G পেয়ে সরাসরি প্রতিটি parent-এর gradient ফেরত দেয়, অর্থাৎ VJP.

কাজ (forward)উপরের gradientParent-দের gradient (VJP)আকার যাচাই
C = A @ BG, আকার C-এর মতো (n × k)∂A = G Bᵀ, ∂B = Aᵀ G(n×k)(k×d) = n×d ✓; (d×n)(n×k) = d×k ✓
Z = A + b (broadcasting)G (n × k)∂A = G; ∂b = G-এর সারি ধরে যোগb (1 × k): sum(axis=0, keepdims)
H = relu(Z)G∂Z = G ⊙ 1[Z > 0]উপাদান ধরে গুণ, আকার একই
Q = R²G∂R = 2R ⊙ Gআকার একই
m = mean(R)g (একটি সংখ্যা)∂R = g/N, সব ঘরেR-এর আকারে ভরাট

Matrix গুণের নিয়মটি মুখস্থ না করে আকার মিলিয়ে পাওয়া যায়: ∂A-কে A-এর আকারের হতেই হবে, আর G ও B দিয়ে সেই আকার বানানোর একমাত্র উপায় G Bᵀ. দ্বিতীয় সারিটি সবচেয়ে বেশি বাগ তৈরি করে। Broadcasting-এ (AI-এর ভিত্তি কোর্সের পর্ব ১৫) bias b-এর একটি সারি batch-এর প্রতিটি সারিতে “কপি” হয়ে যোগ হয়। অর্থাৎ b-এর প্রতিটি ঘর n জায়গায় ব্যবহৃত, ঠিক x·x + x-এর x-এর মতো, আর তাই backward-এ n-টি অবদান যোগ হয়।

A: 3 × 2, b: 1 × 2Z = A + b

            
  1. আকার. A-তে তিনটি উদাহরণ (সারি), দুটি unit (কলাম)। b-এর আকার (১, ২); broadcasting দৈর্ঘ্য ১-এর অক্ষকে তিনে প্রসারিত ধরে, কোনো ডেটা কপি না করেই।
  2. Forward. প্রতিটি সারিতে একই b যোগ হলো। খেয়াল করুন b1 = ০.৫ তিনটি ঘরে (১.৫, ০.৫, ৩.৫) প্রভাব ফেলেছে।
  3. উপরের gradient. পরের স্তরগুলো থেকে G এসেছে, Z-এর হুবহু আকারে (৩ × ২)। এর মানগুলো এখানে উদাহরণ হিসেবে বসানো।
  4. A-এর gradient. যোগের local gradient ১, আর A-এর প্রতিটি ঘর Z-এর ঠিক একটি ঘরে গেছে, তাই ∂L/∂A = G, আকারও মেলে।
  5. b-এর gradient. b-এর প্রতিটি ঘর তিন সারিতে ব্যবহৃত, তাই তিন পথের অবদান যোগ: (০.১ + ০.৩ − ০.১, −০.২ + ০ + ০.৪) = (০.৩, ০.২). NumPy-তে G.sum(axis=0, keepdims=True); keepdims আকার (১, ২) ধরে রাখে।
  6. ভুলের দুই চেহারা. b (১, ২) আকারের হলে in-place আপডেট সরাসরি ValueError দেয়, এটি ভালো খবর। কিন্তু b যদি এক-মাত্রিক (২,) হয় আর b = b − lr*G লেখা হয়, b চুপচাপ (৩, ২) হয়ে যায়: প্রতিটি উদাহরণের নিজস্ব bias! Error নেই, শুধু model ভুল।

সাধারণ নিয়মটির নাম অনেকে দেন unbroadcast: gradient-এর বাড়তি সামনের অক্ষগুলো ধরে যোগ, তারপর যে অক্ষে মূল আকার ১ ছিল সেখানে keepdims দিয়ে যোগ। নিচের ব্লক এই নিয়মসহ একটি tensor engine লেখে। গঠন Value class-এর মতোই, শুধু local-এর জায়গায় প্রতিটি node-এ একটি vjp function. ডেটা বাজারঘরের কাল্পনিক ২০০টি ডেলিভারি: দূরত্ব (১ থেকে ১২ km) আর পণ্যের সংখ্যা (১ থেকে ৫) থেকে ডেলিভারির সময়, seed দিয়ে বানানো, ৩০ মিনিট এককে মাপা। Model একটি ২ → ১৬ → ১ MLP, ReLU আর mean squared error. বিয়োগের আলাদা কাজ না লিখে pred + T(-y) লেখা হয়েছে।

import numpy as np

def unbroadcast(g, shape):         # broadcasting-এর উল্টো: ছড়ানো অক্ষ ধরে যোগ
    while g.ndim > len(shape): g = g.sum(axis=0)
    return g.sum(axis=tuple(i for i, s in enumerate(shape) if s == 1), keepdims=True)

class T:                           # vjp(g): উপরের gradient g থেকে প্রতিটি parent-এর gradient
    def __init__(self, data, parents=(), vjp=lambda g: ()):
        self.data, self.grad, self.parents, self.vjp = np.asarray(data, float), None, parents, vjp
    def __add__(self, o): return T(self.data + o.data, (self, o),
        lambda g: (unbroadcast(g, self.data.shape), unbroadcast(g, o.data.shape)))
    def __matmul__(self, o): return T(self.data @ o.data, (self, o),
        lambda g: (g @ o.data.T, self.data.T @ g))
    def relu(self): return T(np.maximum(self.data, 0), (self,), lambda g: (g * (self.data > 0),))
    def square(self): return T(self.data ** 2, (self,), lambda g: (2 * self.data * g,))
    def mean(self): return T(self.data.mean(), (self,), lambda g: (np.full(self.data.shape, g / self.data.size),))
    def backward(self):
        order, seen = [], set()
        def visit(v):
            if id(v) not in seen:
                seen.add(id(v)); [visit(p) for p in v.parents]; order.append(v)
        visit(self); self.grad = np.ones_like(self.data)
        for v in reversed(order):
            for p, g in zip(v.parents, v.vjp(v.grad)):
                p.grad = g if p.grad is None else p.grad + g   # জমা হয়, মোছে না

rng = np.random.default_rng(42)    # বাজারঘর: দূরত্ব (km), পণ্যের সংখ্যা → ডেলিভারির সময়
d, k = rng.uniform(1, 12, 200), rng.integers(1, 6, 200)
X = np.column_stack([(d - 6) / 3, (k - 3) / 1.4])
y = ((20 + 2.5 * d + 6 * np.sqrt(k) + 0.25 * (d - 6) ** 2 + rng.normal(0, 2, 200)) / 30)[:, None]
P = {"W1": T(rng.normal(0, 0.7, (2, 16))), "b1": T(np.zeros((1, 16))),
     "W2": T(rng.normal(0, 0.25, (16, 1))), "b2": T(np.zeros((1, 1)))}
model = lambda X: ((T(X) @ P["W1"] + P["b1"]).relu() @ P["W2"]) + P["b2"]
loss = (model(X) + T(-y)).square().mean(); loss.backward()
print(f"loss {loss.data:.4f} |", {k: v.grad.shape for k, v in P.items()})

আউটপুট (পাতার Python-এ চালানো):

loss 2.4065 | {'W1': (2, 16), 'b1': (1, 16), 'W2': (16, 1), 'b2': (1, 1)}

শুরুর ওজনে loss ২.৪০৬৫: শুরুতে network প্রায় ০ অনুমান করে, আর লক্ষ্যের মান ১-এর আশপাশে। আসল পরীক্ষা আকারে: প্রতিটি gradient তার parameter-এর হুবহু আকারের। b1-এর gradient (১, ১৬), যদিও উপরের gradient ছিল (২০০, ১৬); unbroadcast ২০০টি সারি যোগ করে দিয়েছে। b2-এর ক্ষেত্রে দুটি অক্ষই যোগ হয়েছে। এই আকারগুলো ঠিক আছে মানেই সংখ্যাগুলো ঠিক, এমন নয়; সংখ্যার যাচাই পরের ব্লকে।

৬ · নিজের engine দিয়ে MLP শেখানো — হাতে লেখা backward-এর সঙ্গে মিল, আর zero_grad কেন

নিজের engine-কে বিশ্বাস করার আগে দুটি পরীক্ষা দরকার। প্রথম, একই ওজনে হাতে লেখা backward-এর সঙ্গে মেলানো; পর্ব ০৪-এ এই সূত্রগুলো derive করা হয়, এখানে শুধু ব্যবহার: output-এ D = 2(P − y)/N, তারপর ∂W2 = HᵀD, D1 = (DW2ᵀ) ⊙ 1[Z1 > 0], ∂W1 = XᵀD1, আর bias-এ সারি ধরে যোগ। দ্বিতীয়, আসলে শেখানো। দ্বিতীয় পরীক্ষার সঙ্গে একটি ইচ্ছাকৃত ভুলও চালানো হবে: প্রতিটি ধাপের আগে পুরোনো gradient মোছা (zero_grad) বাদ দিলে কী হয়।

কেন মুছতে হয়? আমাদের engine-এ (আর PyTorch-এও) backward নতুন gradient পুরোনোটার ওপর যোগ করে, কারণ অংশ ২-এ দেখেছেন, একই graph-এর ভেতরে যোগই সঠিক। কিন্তু parameter-গুলো ধাপ থেকে ধাপে একই object থাকে, তাই না মুছলে আগের সব ধাপের gradient-ও জমতে থাকে: t-তম ধাপের হালনাগাদ হয় η(g1 + g2 + … + gt), শুধু ηgt নয়। একে বলে gradient accumulation; ইচ্ছা করে করলে কাজের কৌশল, ভুলে হলে বাগ।

import numpy as np

def hand_grads():                  # পর্ব ০৪-এর ধাঁচে হাতে লেখা backward
    W1, b1, W2, b2 = (P[n].data for n in ("W1", "b1", "W2", "b2"))
    Z1 = X @ W1 + b1; H = np.maximum(Z1, 0); D = 2 * (H @ W2 + b2 - y) / len(y)
    D1 = (D @ W2.T) * (Z1 > 0)
    return {"W1": X.T @ D1, "b1": D1.sum(0, keepdims=True), "W2": H.T @ D, "b2": D.sum(0, keepdims=True)}
G = hand_grads()
print(f"max |autograd − hand| = {max(np.abs(P[n].grad - G[n]).max() for n in P):.1e}")

def train(steps, lr=0.1, zero_grad=True):
    for p, v in zip(P.values(), start): p.data = v.copy()
    hist = []
    for t in range(steps):
        if zero_grad:
            for p in P.values(): p.grad = None          # zero_grad()
        loss = (model(X) + T(-y)).square().mean(); loss.backward()
        for p in P.values(): p.data -= lr * p.grad      # optimizer.step()
        hist.append(loss.data)
    return hist

start = [p.data.copy() for p in P.values()]
for p in P.values(): p.grad = None
ok, bad = train(300), train(300, zero_grad=False)
print("baseline (গড় অনুমান) MSE:", f"{y.var():.4f}")
for t in (0, 9, 49, 299):
    print(f"step {t+1:3d}: zero_grad সহ {ok[t]:.4f} | zero_grad ছাড়া {bad[t]:.4g}")

আউটপুট (পাতার Python-এ চালানো):

max |autograd − hand| = 4.4e-16
baseline (গড় অনুমান) MSE: 0.0914
step   1: zero_grad সহ 2.4065 | zero_grad ছাড়া 2.407
step  10: zero_grad সহ 0.1082 | zero_grad ছাড়া 1.081
step  50: zero_grad সহ 0.0153 | zero_grad ছাড়া 0.09604
step 300: zero_grad সহ 0.0046 | zero_grad ছাড়া 0.08253

মিল: চারটি parameter-এর সব ঘরে নিজের autograd আর হাতে লেখা backward-এর সর্বোচ্চ পার্থক্য 4.4 × 10−16, float64-এর rounding-এর মাপ। অর্থাৎ প্রায় ২৫ লাইনের engine পর্ব ০৪-এর হাতের হিসাবই করছে, কোনো সূত্র না লিখেই। শেখা: zero_grad সহ ৩০০ ধাপে loss ২.৪১ থেকে ০.০০৪৬; সব ডেলিভারিতে গড় সময় বলে দেওয়ার baseline ০.০৯১৪, তার প্রায় বিশ ভাগের এক ভাগ। ডেটায় যে noise ইচ্ছা করে রাখা হয়েছে (standard deviation ২ মিনিট, এককে (২/৩০)² ≈ ০.০০৪৪), তার প্রায় সমান; এর নিচে নামা মানে noise মুখস্থ করা। zero_grad ছাড়া: প্রথম ধাপ হুবহু এক (তখনো জমার কিছু নেই), কিন্তু তারপর জমা gradient প্রতিটি ধাপকে বড় করে, model এদিক-ওদিক দোলে, আর ৩০০ ধাপ পরেও loss ০.০৮২৫, baseline-এর প্রায় সমান। কোনো error নেই, কোনো warning নেই; শুধু খারাপ model. (এক seed-এর এক run; সংখ্যাগুলো সাধারণ ফল নয়, ধাঁচটি দেখানোর জন্য।)

ইচ্ছাকৃত accumulation-এর সঠিক রূপ। GPU-তে বড় batch না ধরলে অনেকে কয়েকটি ছোট batch-এর gradient জমিয়ে একবার আপডেট করেন। তখন নিয়ম: প্রতিটি ছোট batch-এর loss-কে batch-এর সংখ্যা দিয়ে ভাগ করে backward, k-টি batch পরে একবার step, তারপরই zero_grad. ভুলটা হয় যখন zero_grad লুপের ভুল জায়গায় বসে, বা ভাগ করতে ভুলে যান। PyTorch-এ এই কৌশল আর এর ফাঁদগুলো পর্ব ০৮ (PyTorch autograd — requires_grad, backward আর gradient-এর ফাঁদ) আর পর্ব ১০ (Training loop — loss, optimizer, train/eval mode আর checkpoint)-এ আসবে।

৭ · PyTorch-এ একই graph — সত্যিকারের run, define-by-run আর static graph

এবার একই কাজ PyTorch-এ। requires_grad=True দিয়ে তৈরি tensor-গুলো আমাদের leaf-এর মতো: PyTorch এদের ওপর প্রতিটি কাজ graph-এ লিখে রাখে, প্রতিটি ফলাফলের grad_fn-এ সেই কাজের backward function. backward() ডাকলে graph ধরে পেছনে হাঁটে আর gradient জমায় প্রতিটি leaf tensor-এর .grad-এ। ডেস্কটপে uv দিয়ে একটি আলাদা project বানিয়ে CPU-র PyTorch install করা হলো (uv-এর পরিচয় AI-এর ভিত্তি কোর্সের পর্ব ৭-এ):

~/bazarghar-dl$ uv init --vcs none --author-from none --name bazarghar-dl --no-readme --python 3.14
Initialized project `bazarghar-dl`
# pyproject.toml-এ PyTorch-এর CPU wheel index যোগ করা হলো ([[tool.uv.index]], explicit = true)
~/bazarghar-dl$ uv add torch numpy
 …
 + numpy==2.5.4
 …
 + torch==2.15.0+cpu
 …

Script-টি চারটি জিনিস দেখে: একই L = tanh(a·b + c)-এর graph আর gradient; একই graph-এ দ্বিতীয়বার backward; Python-এর if থেকে graph বদলানো; আর অংশ ৫-এর হুবহু ডেটা আর শুরুর ওজনে MLP-এর gradient. bazarghar_data.py ফাইলে অংশ ৫-এর ব্লকের ডেটা আর ওজন বানানোর লাইনগুলো হুবহু কপি করা।

import numpy as np, torch
print("torch", torch.__version__, "| numpy", np.__version__)

# ১) একই graph: L = tanh(a*b + c)
a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(-1.0, requires_grad=True)
c = torch.tensor(1.5, requires_grad=True)
L = torch.tanh(a * b + c)
print("L =", L)
print("graph:", L.grad_fn.name(), "<-", L.grad_fn.next_functions[0][0].name(),
      "<-", L.grad_fn.next_functions[0][0].next_functions[0][0].name())
L.backward()
print(f"grad: a {a.grad.item():.4f}, b {b.grad.item():.4f}, c {c.grad.item():.4f}")

# ২) একই graph-এ দ্বিতীয়বার backward
try:
    L.backward()
except RuntimeError as e:
    print("RuntimeError:", str(e).split(". ")[0] + ".", "…", str(e).split(". ")[-1])

L2 = torch.tanh(a * b + c)
L2.backward(retain_graph=True); L2.backward()      # zero_grad নেই
print(f"a.grad, মোট তিনবার backward-এর পর: {a.grad.item():.4f}")

# ৩) define-by-run: Python-এর if থেকেই graph
def g(x): return x * x if x > 0 else torch.exp(x)
for v in (3.0, -3.0):
    x = torch.tensor(v, requires_grad=True); out = g(x); out.backward()
    print(f"x = {v}: grad_fn = {out.grad_fn.name()}, dg/dx = {x.grad.item():.4f}")

# ৪) MLP: torch autograd বনাম হাতে লেখা backward (একই ডেটা, একই ওজন)
from bazarghar_data import X, y, W1, b1, W2, b2
P = [torch.tensor(p, requires_grad=True) for p in (W1, b1, W2, b2)]
Xt, yt = torch.tensor(X), torch.tensor(y)
loss = ((torch.relu(Xt @ P[0] + P[1]) @ P[2] + P[3] - yt) ** 2).mean()
loss.backward()
Z1 = X @ W1 + b1; H = np.maximum(Z1, 0); D = 2 * (H @ W2 + b2 - y) / len(y)
D1 = (D @ W2.T) * (Z1 > 0)
hand = [X.T @ D1, D1.sum(0, keepdims=True), H.T @ D, D.sum(0, keepdims=True)]
print(f"loss {loss.item():.4f} | max |torch − hand| =",
      f"{max(np.abs(p.grad.numpy() - h).max() for p, h in zip(P, hand)):.1e}")

সত্যিকারের run (ডেস্কটপ, CPU, Python 3.14.4, PyTorch 2.15.0 CPU build, NumPy 2.5.4; লেখার সময়ের সংস্করণ, পরে বদলাতে পারে):

~/bazarghar-dl$ uv run python autograd_torch.py
torch 2.15.0+cpu | numpy 2.5.4
L = tensor(-0.4621, grad_fn=<TanhBackward0>)
graph: TanhBackward0 <- AddBackward0 <- MulBackward0
grad: a -0.7864, b 1.5729, c 0.7864
RuntimeError: Trying to backward through the graph a second time (or directly access saved tensors after they have already been freed). … Specify retain_graph=True if you need to backward through the graph a second time or if you need to access saved tensors after calling backward.
a.grad, মোট তিনবার backward-এর পর: -2.3593
x = 3.0: grad_fn = MulBackward0, dg/dx = 6.0000
x = -3.0: grad_fn = ExpBackward0, dg/dx = 0.0498
loss 2.4065 | max |torch − hand| = 4.4e-16

এক: graph-এর শিকল TanhBackward0 ← AddBackward0 ← MulBackward0, অংশ ১-এর ছবির হুবহু, আর gradient (−০.৭৮৬৪, ১.৫৭২৯, ০.৭৮৬৪) আমাদের Value class-এর সঙ্গে এক। দুই: আমাদের engine দ্বিতীয় backward-এ চুপচাপ আবার যোগ করত (অনুশীলন ৩ দেখুন); PyTorch তার বদলে error দেয়, কারণ memory বাঁচাতে প্রথম backward-এর পরই graph-এর মনে রাখা মাঝের মান মুছে ফেলে। দ্বিতীয়বার দরকার হলে retain_graph=True. তিন: ওই error-এর আগেই a.grad-এ −০.৭৮৬৪ বসে ছিল; নতুন graph-এ আরও দুটি backward যোগ হয়ে মোট −২.৩৫৯৩ = ৩ × (−০.৭৮৬৪), ঠিক অংশ ৬-এর accumulation. চার: একই function g দুটি input-এ দুটি ভিন্ন graph বানিয়েছে (গুণ বনাম exp), কারণ graph তৈরি হয় কোড চলার সময়। পাঁচ: MLP-এর loss ২.৪০৬৫, ব্লক ৩-এর সঙ্গে চার দশমিকে এক, আর gradient হাতে লেখা backward থেকে 4.4 × 10−16 দূরে।

Define-by-run বনাম static graph

“কোড চলার সময় graph তৈরি” পদ্ধতির নাম define-by-run (eager execution নামেও পরিচিত)। আমাদের Value আর T class-ও তাই: Python-এর লুপ, if, recursion সব স্বাভাবিকভাবে চলে, আর প্রতিটি forward pass নিজের নতুন graph বানায়। এর আগের প্রজন্মের জনপ্রিয় library, যেমন Université de Montréal-এর Theano (২০০৭–২০০৮ সালের দিকে প্রকাশিত) আর TensorFlow 1.x (২০১৫), সাধারণত উল্টো পথে চলত: আগে পুরো graph প্রতীকে সংজ্ঞায়িত করা, তারপর আলাদা ধাপে ডেটা ঢুকিয়ে চালানো। একে বলে static graph বা define-and-run. সুবিধা: চালানোর আগে পুরো graph দেখা যায়, তাই compiler কাজ জোড়া লাগাতে, memory আগে থেকে পরিকল্পনা করতে আর অন্য যন্ত্রে পাঠাতে পারে। অসুবিধা: debug কঠিন, আর ডেটা অনুযায়ী বদলানো গঠন (দৈর্ঘ্য বদলানো বাক্য, গাছের মতো ডেটা) লেখা কষ্টকর।

“Define-by-run” শব্দটি সাধারণত Preferred Networks-এর Chainer (২০১৫)-এর সঙ্গে যুক্ত বলে উল্লেখ করা হয়; PyTorch (২০১৬–২০১৭ সালের দিকে প্রকাশ্যে আসে) একই ধারণা জনপ্রিয় করে, আর TensorFlow 2.0 (২০১৯) eager execution-কে default করে। পরে দুই ধারা আবার কাছাকাছি এসেছে: PyTorch 2.0 (২০২৩)-এর torch.compile eager কোড চালানোর সময় graph ধরে নিয়ে optimize করে, আর JAX আর TensorFlow-এর tf.function একইভাবে tracing করে। তারিখ আর কৃতিত্ব উৎসভেদে একটু আলাদা লেখা হয়; মূল ধারণা হলো আজকের চর্চা প্রায়ই দুটির মিশ্রণ: লেখার সময় eager, চালানোর সময় দরকার হলে compile.

আরও গভীরে: Automatic differentiation-এর পূর্ণাঙ্গ পাঠ্যবই Andreas Griewank আর Andrea Walther-এর Evaluating Derivatives (দ্বিতীয় সংস্করণ ২০০৮); সেখানে প্রমাণিত একটি ফল প্রায়ই “cheap gradient principle” নামে উল্লেখ হয়: একটি scalar function-এর পুরো gradient-এর খরচ function-এর খরচের একটি ছোট ধ্রুবক গুণের বেশি নয়। Machine learning-এর দৃষ্টিতে একটি পরিচিত সমীক্ষা Baydin ও সহকর্মীদের ২০১৮ সালের Journal of Machine Learning Research-এর প্রবন্ধ। PyTorch-এর নিজের autograd-এর নকশা Paszke ও সহকর্মীদের ২০১৭ সালের NIPS workshop প্রবন্ধে বর্ণিত। Memory বাঁচাতে কিছু মাঝের মান মুছে backward-এর সময় আবার হিসাব করার কৌশল gradient checkpointing নামে পরিচিত (যেমন Chen ও সহকর্মীদের ২০১৬ সালের প্রবন্ধ)।

পরীক্ষা ও ইন্টারভিউতে:

  • f: ℝn → ℝm-এর পুরো Jacobian-এ forward আর reverse mode-এ কতটি pass? Forward-এ n, reverse-এ m; loss-এ m = ১, তাই reverse.
  • Backward-এ topological ক্রম কেন? যাতে কোনো node-এর gradient ব্যবহারের আগে তার সব child-এর অবদান জমা হয়ে যায়।
  • Gradient কেন “+=”? একটি মান কয়েক জায়গায় ব্যবহৃত হলে বহুচলকের chain rule অনুযায়ী সব পথের অবদান যোগ হয়।
  • Z = XW + b, b আকার (1, k)-এর gradient? উপরের gradient-এর সারি ধরে যোগ, G.sum(axis=0, keepdims=True).
  • PyTorch-এ zero_grad না দিলে? নতুন gradient পুরোনোটার সঙ্গে যোগ হয়, ফলে ভুল আপডেট; কোনো error আসে না।

৮ · AI-এর যুগে — AI-এর লেখা autograd আর training loop যাচাই

AI assistant এক মিনিটে একটি Value class বা একটি PyTorch training loop লিখে দিতে পারে, আর তা প্রায়ই চলে। সমস্যা হলো এই পর্বের প্রায় সব বাগ চুপচাপ: “=” আর “+=”-এর পার্থক্য, ভুল ক্রম, broadcasting-এর হারানো sum, ভুলে যাওয়া zero_grad. কোনোটিই error দেয় না, শুধু model খারাপ শেখে। তাই AI-এর কোড হাতে পেলে নিজের যাচাইয়ের অভ্যাস থাকা জরুরি।

AI প্রায়ই যা করেকেন ভুলকীভাবে ধরবেন
backward-এ p.grad = … লেখে, “+=” নয়একটি মান কয়েক জায়গায় থাকলে শুধু শেষ পথের অবদান টেকেx·x + x-এর মতো ছোট উদাহরণে উত্তর 2x + 1 আসে কি না দেখুন
Topological sort ছাড়া recursion-এ backward চালায়কোনো node-এর gradient পূর্ণ হওয়ার আগেই তা ব্যবহার হয়একটি node দুই পথে ব্যবহৃত এমন graph-এ central difference দিয়ে মেলান
Bias-এর gradient-এ sum ভুলে যায় বা ভুল axis-এ নেয়আকার ভুল, বা b চুপচাপ (batch, k) হয়ে যায়assert p.grad.shape == p.data.shape প্রতিটি parameter-এ
Loop-এ optimizer.zero_grad() ভুলে যায় বা ভুল জায়গায় বসায়Gradient ধাপ থেকে ধাপে জমেএক batch-এ দুবার backward চালিয়ে .grad দ্বিগুণ হয় কি না দেখুন; loss curve দেখুন
পুরোনো API দেয় (যেমন Variable দিয়ে মোড়ানো) বা retain_graph=True সব জায়গায় বসিয়ে error চাপা দেয়পুরোনো বা অপ্রয়োজনীয় কোড; memory বাড়ে, আসল বাগ লুকায়নিজের PyTorch version-এর docs পড়ুন; error কেন এলো আগে বুঝুন
Evaluation-এর সময় torch.no_grad() আর model.eval() ভুলে যায়অকারণে graph তৈরি হয়ে memory খরচ; dropout বা BatchNorm-এর আচরণ ভুলInference কোডে দুটিই আছে কি না দেখুন

AI-কে শুধু কোড নয়, পরীক্ষাসহ কোড চাইতে বলুন:

আমি NumPy দিয়ে একটি ছোট tensor autograd লিখছি (matmul, broadcasting-সহ যোগ, relu, mean). ১. প্রতিটি কাজের backward (vector-Jacobian product) সূত্র দাও, প্রতিটির পাশে আকার লিখে। ২. Broadcasting-এর backward-এ কোন অক্ষ ধরে যোগ করতে হবে, একটি (3, 2) + (1, 2) উদাহরণে সংখ্যা দিয়ে দেখাও। ৩. Backward-এ topological sort কেন লাগে আর gradient কেন += হয়, এক লাইনে করে বলো। ৪. Float64-এ seed দিয়ে একটি central-difference gradient check লেখো, যেখানে batch, input আর hidden-এর মাপ তিনটিই আলাদা। ৫. Training loop-এ zero_grad কোথায় বসবে, আর না বসালে কী হবে, তা একটি ছোট পরীক্ষা দিয়ে দেখাও। ৬. তোমার কোডের কোন অংশে ভুলের ঝুঁকি সবচেয়ে বেশি, সেটিও বলো।

AI-এর দেওয়া autograd বা training কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Reverse mode এত সস্তা হলে forward mode আজও কেন টিকে আছে, আর দুটির ইতিহাস কীভাবে আলাদা পথে এসেছে?

দুটি mode একই chain rule-এর দুই গুণের ক্রম, কিন্তু ইতিহাসে তারা আলাদা সমাজে বড় হয়েছে। Forward mode-এর একটি প্রথম দিকের পরিচিত বর্ণনা R. E. Wengert-এর ১৯৬৪ সালের ছোট প্রবন্ধে (Communications of the ACM), যেখানে প্রতিটি মাঝের মানের সঙ্গে তার derivative বয়ে নেওয়া হয়; আজ আমরা একে dual number-এর ভাষায় বলি। Reverse mode-এর আধুনিক রূপের কৃতিত্ব সাধারণত Seppo Linnainmaa-র ১৯৭০ সালের master's thesis-কে দেওয়া হয়, যেখানে rounding ভুলের প্রভাব মাপতে পেছনে হাঁটা হয়েছিল; Paul Werbos ১৯৭০-এর দশকে একে শেখার সমস্যায় প্রয়োগের কথা লেখেন, আর neural network-এ backpropagation নামে তা জনপ্রিয় হয় ১৯৮৬ সালে Rumelhart, Hinton আর Williams-এর Nature-এর প্রবন্ধে। কে কতটা আগে, তা নিয়ে উৎসভেদে মতভেদ আছে, কারণ নিয়ন্ত্রণ-তত্ত্ব আর সংখ্যাতত্ত্বে একই ধারণা আলাদাভাবে এসেছিল।

Forward mode টিকে আছে তিন কারণে। প্রথম, খরচের নিয়ম উল্টো দিকেও খাটে: input কম আর output বেশি হলে (একটি নকশার parameter বদলালে হাজারটি মাপ কীভাবে বদলায়) forward mode n-টি pass-এ পুরো Jacobian দেয়, reverse-এর m-টির চেয়ে অনেক কম। দ্বিতীয়, memory: forward mode-কে কোনো graph বা মাঝের মান মনে রাখতে হয় না, তাই খুব লম্বা হিসাবে (দীর্ঘ সিমুলেশন) এটি প্রায় বিনা খরচে চলে, যেখানে reverse mode-এর memory হিসাবের দৈর্ঘ্যের সঙ্গে বাড়ে। তৃতীয়, দুটি মিলে দ্বিতীয় ক্রমের তথ্য: reverse mode-এর gradient-এর ওপর forward mode চালালে পুরো Hessian না বানিয়েই Hessian-vector product পাওয়া যায়, যা Pearlmutter ১৯৯৪ সালে বর্ণনা করেন; second-order optimizer আর curvature বিশ্লেষণে এটি কাজে লাগে।

এ কারণে JAX-এর মতো library jvp আর vjp দুটিই সমান মর্যাদায় দেয়, আর PyTorch-এও forward-mode AD-এর ব্যবস্থা যোগ হয়েছে। Deep learning-এর training-এ reverse mode রাজত্ব করে কারণ সেখানে আকারটি চরম: একটি loss, কোটি parameter.

মূল উপলব্ধি: কোন mode “ভালো”, তা প্রশ্নের আকার ঠিক করে: input বেশি আর output কম হলে reverse, উল্টো হলে forward, আর memory কম থাকলে forward-এর দিকে ঝুঁকুন।

প্র ০২Static graph থেকে define-by-run, আবার compile-এ ফেরা — deep learning framework-এর এই দোলাচল থেকে কী শেখার আছে?

২০১০-এর দশকের শুরুতে গবেষকদের প্রধান হাতিয়ার ছিল Theano, যা Montréal-এর একটি গবেষণা দল তৈরি করেছিল। ধারণাটি ছিল compiler-এর মতো: Python-এ প্রতীক দিয়ে পুরো হিসাব সংজ্ঞায়িত করুন, Theano তা থেকে graph বানাবে, অপ্রয়োজনীয় অংশ ছাঁটবে, সংখ্যাগতভাবে স্থির সূত্রে বদলাবে, তারপর CPU বা GPU-র জন্য দ্রুত কোড তৈরি করবে। Google-এর TensorFlow (২০১৫) এই static graph ধারাকে শিল্পের মাপে নিয়ে যায়: graph একবার বানিয়ে session.run দিয়ে বারবার চালানো, বহু যন্ত্রে ভাগ করা, মোবাইলে পাঠানো।

কিন্তু গবেষকদের অভিজ্ঞতা ছিল কষ্টের। Error আসত graph চালানোর সময়, লেখার লাইনে নয়; সাধারণ Python-এর print বা debugger graph-এর ভেতরে দেখতে পারত না; আর বাক্যের দৈর্ঘ্য অনুযায়ী লুপের মতো সাধারণ জিনিসের জন্য বিশেষ control-flow কাজ লিখতে হতো। Chainer (২০১৫) আর পরে PyTorch অন্য পথ নেয়: graph তৈরি হবে কোড চলার সময়, তাই সাধারণ Python-ই মডেলের ভাষা। এই পর্বের Value class আর PyTorch run-এর if ঠিক এটাই দেখায়। অনেক গবেষক এই সহজতার কারণে PyTorch-এ সরে যান বলে বলা হয়, আর TensorFlow 2.0 (২০১৯) নিজেই eager execution-কে default করে।

তারপর গল্প আবার ঘোরে। Eager মোডে প্রতিটি কাজ আলাদা চলে, তাই কাজ জোড়া লাগানো (kernel fusion) বা পুরো graph-এর memory পরিকল্পনা কঠিন। তাই এল tracing আর compile: JAX-এর jit, TensorFlow-এর tf.function, আর PyTorch 2.0 (২০২৩)-এর torch.compile, যা eager কোড চলার সময় graph ধরে নিয়ে optimize করে। দামটা হলো নতুন ধরনের সূক্ষ্মতা: ডেটা-নির্ভর if থাকলে graph ভাঙে বা আবার compile হয়।

শিক্ষাটি software engineering-এর একটি পুরোনো trade-off: নমনীয়তা আর debug-যোগ্যতা বনাম বিশ্লেষণ আর optimization-এর সুযোগ। একই বিতর্ক interpreter বনাম compiler-এ, বা dynamic বনাম static typing-এ দেখা যায়।

মূল উপলব্ধি: লেখার সময় define-by-run, চালানোর সময় দরকার হলে compile, এই মিশ্রণই আজকের চর্চা; কিন্তু compile করার আগে eager-এ সঠিকতা যাচাই করুন।

প্র ০৩Reverse mode-এর আসল দাম memory — বড় model-এ মাঝের মান মনে রাখার সমস্যা কীভাবে সামলানো হয়?

অংশ ১-এ দেখেছেন, প্রতিটি node-এর local gradient-এর জন্য forward-এর মান লাগে: গুণের জন্য অন্য গুণনীয়ক, tanh-এর জন্য output, ReLU-এর জন্য input-এর চিহ্ন। Reverse sweep শুরু হয় forward পুরো শেষ হওয়ার পরে, তাই এই সব মান একসঙ্গে memory-তে রাখতে হয়। একটি network-এ L-টি স্তর আর প্রতিটি স্তরের activation-এর আকার A হলে memory প্রায় L × A × batch-এর অনুপাতে বাড়ে। বড় ছবির model বা লম্বা context-এর language model-এ প্রায়ই parameter নয়, এই activation-ই GPU-র memory ভরায়। PyTorch যে প্রথম backward-এর পর এই মানগুলো মুছে ফেলে (এই পর্বের RuntimeError), সেটিও এই চাপেরই ফল।

প্রধান সমাধান gradient checkpointing (rematerialization নামেও পরিচিত): forward-এর সময় সব মান না রেখে শুধু কিছু “checkpoint” স্তরের মান রাখা, আর backward-এর সময় দুই checkpoint-এর মাঝের অংশ আবার চালিয়ে মানগুলো ফিরিয়ে আনা। এর তাত্ত্বিক ভিত্তি AD সম্প্রদায়ে পুরোনো: Griewank ১৯৯২ সালে reverse mode-এ checkpoint-এর একটি তাত্ত্বিক বিশ্লেষণ দেন, আর পরে Walther-এর সঙ্গে “revolve” নামে একটি সর্বোত্তম checkpoint সূচি প্রকাশ করেন (২০০০ সালের দিকে)। Deep learning-এ Chen ও সহকর্মীদের ২০১৬ সালের প্রবন্ধ দেখায়, প্রায় √L-টি checkpoint রেখে memory প্রায় √L-এর অনুপাতে নামানো যায়, বিনিময়ে প্রায় একটি বাড়তি forward pass-এর খরচ। PyTorch-এ এর জন্য torch.utils.checkpoint আছে।

অন্য কৌশলগুলো: কম precision-এ activation রাখা (পর্ব ১২-এ mixed precision), invertible স্তর যার output থেকে input আবার বের করা যায় (তাই মনে রাখার দরকার নেই), আর batch ছোট করে gradient accumulation, যা অংশ ৬-এর সঠিক রূপ। প্রতিটির নিজস্ব দাম আছে: সময়, সংখ্যাগত নির্ভুলতা বা স্থাপত্যের সীমা।

এই trade-off দৈনন্দিন অভিজ্ঞতার সঙ্গেও মেলে: পরীক্ষার খাতা দেখার সময় প্রতিটি উত্তরের নম্বর আলাদা কাগজে লিখে রাখলে শেষে যোগ মেলানো সহজ, কিন্তু কাগজ বেশি লাগে; শুধু পাতার শেষের যোগফল রাখলে কাগজ বাঁচে, কিন্তু ভুল খুঁজতে পাতা আবার পড়তে হয়।

মূল উপলব্ধি: Reverse mode সময়ে সস্তা কিন্তু memory-তে দামি; checkpointing সেই memory-কে আবার হিসাবের সময় দিয়ে কিনে নেয়।

প্র ০৪Autograd নির্ভুল derivative দেয় — তাহলে NaN gradient, ভুল gradient আর custom backward-এর দরকার কেন পড়ে?

Autograd যে derivative দেয়, তা আপনার লেখা প্রোগ্রামের, আপনার মনের গাণিতিক সূত্রের নয়। দুটি আলাদা হলে ফলও আলাদা। প্রথম উদাহরণ সংখ্যাগত স্থিরতা: log(Σ exp(zi)) সরাসরি লিখলে বড় z-এ exp overflow করে inf দেয়, আর backward-এ inf/inf থেকে NaN আসে। গণিতে সূত্র ঠিক, প্রোগ্রামে নয়। সমাধান সূত্রটিকে log-sum-exp কৌশলে আবার লেখা (সবচেয়ে বড় z বিয়োগ করে নেওয়া), যা পর্ব ০৩-এ softmax-এর সঙ্গে আসে; library-র তৈরি function যেমন torch.logsumexp এটি ভেতরেই করে।

দ্বিতীয় উদাহরণ অমসৃণ বিন্দু: ReLU-এর ০, |x|-এর ০, √x-এর ০. আমাদের Value class ReLU-তে ০-এ ০ দেয়; এটি একটি সিদ্ধান্ত, গাণিতিক সত্য নয়। √x-এর derivative ০-তে অসীম, তাই একটি vector-এর norm শূন্য হলে gradient NaN হতে পারে। আরেকটি পরিচিত ফাঁদ: where(x > 0, √x, 0) লিখলে forward ঠিক থাকে, কিন্তু backward-এ দুই শাখার gradient-ই হিসাব হয়, আর বাতিল শাখার NaN গুণ শূন্য করেও NaN থাকে। সমাধান হলো আগে input-কে নিরাপদ মানে বদলে নেওয়া।

তৃতীয়, দক্ষতা আর নিয়ন্ত্রণ: কিছু কাজের derivative হাতে লিখলে অনেক কম memory বা সময় লাগে, বা ইচ্ছা করে gradient বদলাতে হয় (যেমন gradient-কে শুধু পার করে দেওয়া, যাকে straight-through estimator বলে)। এর জন্য PyTorch-এ torch.autograd.Function দিয়ে নিজের forward আর backward লেখা যায়, ঠিক যেমন আমরা T class-এ প্রতিটি কাজের vjp লিখেছি। তখন সেই হাতে লেখা backward-কে central difference দিয়ে যাচাই করা আবার জরুরি; PyTorch এর জন্য torch.autograd.gradcheck দেয়।

Andrej Karpathy ২০১৬ সালের একটি পরিচিত লেখায় backpropagation-কে “leaky abstraction” বলেন: ভেতর না বুঝলে vanishing gradient, dead ReLU আর NaN-এর মতো সমস্যা চোখে পড়ে না। এই পর্বে নিজের engine লেখার মূল্য এখানেই।

মূল উপলব্ধি: autograd গণিত নয়, প্রোগ্রাম differentiate করে; তাই সংখ্যাগতভাবে স্থির প্রোগ্রাম লেখা আর হাতে লেখা backward যাচাই করা আপনারই দায়িত্ব।

প্র ০৫PyTorch gradient default-ভাবে মোছে না, জমায় — এই নকশা ভুল-প্রবণ হলেও কেন বেছে নেওয়া হয়েছে?

প্রথমে ভাবলে মনে হয়, প্রতিটি backward-এর আগে নিজে নিজে মুছে দিলেই তো zero_grad ভুলে যাওয়ার বাগ থাকত না। কিন্তু জমানোর আচরণ কয়েকটি গুরুত্বপূর্ণ কাজের ভিত্তি। প্রথম, এক graph-এর ভেতরেই যোগ বাধ্যতামূলক: অংশ ২-এ দেখেছেন, একটি মান কয়েক জায়গায় ব্যবহৃত হলে সঠিক gradient সব পথের যোগফল। RNN-এ একই weight প্রতিটি সময়-ধাপে ব্যবহৃত হয় (পর্ব ২৮-এ backpropagation through time), language model-এ input আর output embedding প্রায়ই একই matrix ভাগ করে; এসব ক্ষেত্রে যোগই সঠিক উত্তর।

দ্বিতীয়, ইচ্ছাকৃত accumulation: memory-তে ১২৮-এর batch না ধরলে ৩২-এর চারটি ছোট batch-এ backward চালিয়ে gradient জমানো, তারপর একবার step. বড় model-এর fine-tuning-এ এটি খুব প্রচলিত। তৃতীয়, একাধিক loss: একটি model-এ দুটি আলাদা loss থাকলে প্রতিটির backward আলাদা ডাকলেও ফল যোগ হয়ে একসঙ্গে আপডেট হয়।

যদি framework নিজে মুছত, এসব কাজে ব্যবহারকারীকে নিজেই gradient কপি করে যোগ করতে হতো, যা আরও ভুল-প্রবণ। তাই PyTorch নিয়ম সরল রেখেছে: backward সবসময় যোগ করে, মোছার সিদ্ধান্ত আপনার। তবে ভুলের দাম কমাতে একটি বদল এসেছে: PyTorch 2.0 থেকে zero_grad()-এর default set_to_none=True, অর্থাৎ gradient শূন্যে ভরা হয় না, None করা হয় (এই পর্বের desktop run-এর version-এও signature তাই)। এতে memory-তে শূন্য লেখার কাজ বাঁচে, আর কোনো parameter-এ gradient না পৌঁছালে None দেখে তা বোঝাও যায়। আমাদের T class-ও একই কাজ করে: p.grad = None.

বাস্তব শিক্ষা: training loop-এর একটি স্থির ছাঁচ মুখস্থ রাখুন (zero_grad → forward → loss → backward → step), আর accumulation করলে loss ভাগ করা আর step-এর পরে zero_grad, এই দুটি জায়গায় বিশেষ নজর দিন।

মূল উপলব্ধি: জমানো graph-এর গণিতের স্বাভাবিক রূপ আর অনেক কৌশলের ভিত্তি; framework মোছার দায়িত্ব আপনাকে দেয়, কারণ কখন মুছতে হবে তা শুধু আপনিই জানেন।

অনুশীলন

প্রথমে খাতায় করুন, তারপর উত্তর মেলান। চাইলে অংশ ৩-এর Value class দিয়ে যাচাই করুন।

  1. পূর্ণ numerical: f = (x·y + y)², x = ২, y = −১. (ক) Graph-টি u = x·y, v = u + y, f = v² ধাপে লিখে forward মান বের করুন। (খ) Reverse sweep-এ ∂f/∂x আর ∂f/∂y বের করুন। (গ) Dual number দিয়ে x-এর ছায়া ১, y-এর ছায়া ০ বসিয়ে ∂f/∂x যাচাই করুন। (ঘ) Graph-এ কোন মান দুই জায়গায় ব্যবহৃত, আর তার প্রভাব কোথায় পড়ল?

    (ক) u = ২ × (−১) = −২, v = −২ + (−১) = −৩, f = ৯. (খ) f.grad = ১; v.grad = 2v = −৬; যোগ থেকে u.grad = −৬ আর y.grad += −৬; গুণ থেকে x.grad = y × u.grad = (−১)(−৬) = ৬, আর y.grad += x × u.grad = ২ × (−৬) = −১২. মোট y.grad = −৬ − ১২ = −১৮. যাচাই: f = y²(x + 1)², তাই ∂f/∂x = 2y²(x + 1) = ২ × ১ × ৩ = ৬ আর ∂f/∂y = 2y(x + 1)² = ২ × (−১) × ৯ = −১৮. (গ) x = ২ + ε, y = −১: u = −২ − ε, v = −৩ − ε, f = (−৩ − ε)² = ৯ + ৬ε, তাই ∂f/∂x = ৬. (ঘ) y দুই জায়গায় (গুণে আর যোগে), তাই তার gradient দুই পথের যোগফল; “=” লিখলে শুধু একটি পথ টিকত আর উত্তর −৬ বা −১২ আসত। Value class-এ চালালে ঠিক 9.0 6.0 -18.0 আসে।

  2. সমালোচনা ও নকশা: একজন সহকর্মী (বা একটি AI assistant) এমন backward লিখেছেন: def backward(self): for p, d in zip(self.parents, self.local): p.grad = d * self.grad; p.backward(), আর শুরুর node-এ আগে grad = 1 বসানো। ছোট শিকল-আকৃতির উদাহরণে উত্তর মেলে। কোথায় কোথায় সমস্যা, কোন উদাহরণে ধরা পড়বে, আর আপনি কীভাবে নকশা করবেন?

    সমস্যা ১, “=”: একটি মান কয়েক জায়গায় ব্যবহৃত হলে শেষ পথ আগের সবগুলো মুছে দেয়। সমস্যা ২, ক্রম: recursion প্রতিটি পথ ধরে আলাদা নামে, তাই কোনো node-এর gradient সব child-এর অবদান জমার আগেই তার parent-এ চলে যায়; একই node বারবার ঘোরা হয়, আর graph বড় হলে পথের সংখ্যা ঘাতের হারে বাড়তে পারে। সমস্যা ৩: শিকল (প্রতিটি node একবারই ব্যবহৃত) আকারে দুটি ভুলই অদৃশ্য, তাই সেখানে পরীক্ষা যথেষ্ট নয়। ধরার উদাহরণ: y = x·x + x (উত্তর ৭ না এসে ১ আসবে), বা f = (x·y + y)². নকশা: (১) একবার DFS post-order দিয়ে topological তালিকা, (২) উল্টো ক্রমে একটিই লুপ, (৩) “+=”, (৪) প্রতিটি নতুন কাজের জন্য central difference-এর একটি পরীক্ষা, যেখানে একটি মান অন্তত দুবার ব্যবহৃত, (৫) প্রতি backward-এর আগে সব gradient শূন্য করার একটি স্পষ্ট function.

  3. আউটপুট অনুমান করুন: অংশ ৩-এর Value class ধরে নিন (backward শুরুতে self.grad = 1.0 বসায়, তারপর parent-দের gradient-এ যোগ করে)। চালানোর আগে লিখে ফেলুন নিচের কোড কী ছাপবে, আর PyTorch-এ একই কাজ করলে কী হতো।
    x = Value(2.0)
    y = x * x
    y.backward()
    y.backward()
    print(x.grad)
    8.0

    প্রথম backward: y.grad = ১, গুণের দুই দিক থেকে x.grad = ২ + ২ = ৪. দ্বিতীয় backward: y.grad আবার ১ বসানো হয় (বসানো, যোগ নয়), কিন্তু x.grad আগের ৪-এর ওপর আরও ৪ যোগ করে: ৮. অর্থাৎ সঠিক derivative 2x = ৪-এর দ্বিগুণ; এটিই zero_grad ছাড়া accumulation. PyTorch-এ দ্বিতীয় y.backward() RuntimeError দিত (“Trying to backward through the graph a second time”), কারণ প্রথম backward-এর পর মাঝের মান মুছে যায়; প্রথমটিতে retain_graph=True দিলে PyTorch-ও ৮ দিত।

  4. কোড লিখুন: Value class-এ তিনটি কাজ যোগ করুন: sigmoid(), বিয়োগ (__sub__) আর ঋণাত্মক (__neg__)। Local gradient কী হবে লিখুন, তারপর x = ০.৫-এ L = sigmoid(x) − x-এর derivative হাতে আর কোডে মেলান।
    def sigmoid(self):
        s = 1 / (1 + math.exp(-self.data))
        return Value(s, (self,), 'sigmoid', (s * (1 - s),))
    def __neg__(self): return self * -1
    def __sub__(self, o): return self + (-o if isinstance(o, Value) else Value(-o))
    Value.sigmoid, Value.__neg__, Value.__sub__ = sigmoid, __neg__, __sub__
    
    x = Value(0.5); L = x.sigmoid() - x; L.backward()
    print(round(x.grad, 4))      # -0.765

    Sigmoid-এর local gradient σ(1 − σ), output থেকেই পাওয়া যায়, tanh-এর মতো। বিয়োগকে আলাদা কাজ না বানিয়ে যোগ আর −১ দিয়ে গুণে ভাঙা হয়েছে, তাই নতুন backward নিয়ম লাগেনি। হাতে: σ(০.৫) = ০.৬২২৫, σ(1 − σ) = ০.২৩৫০, আর −x-এর অবদান −১; মোট −০.৭৬৫০. Central difference দিয়েও মিলিয়ে নিন।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমনিজের autograd দিয়ে একটি training ধাপ সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. সব parameter-এর পুরোনো gradient মুছে ফেলা (zero_grad)
  2. Forward pass চালানো, যাতে প্রতিটি কাজ graph-এ node হিসেবে লেখা হয়
  3. Loss হিসাব করে তার grad = ১ বসানো
  4. Graph-এর topological ক্রম বের করা
  5. উল্টো ক্রমে প্রতিটি node-এর local gradient গুণ করে parent-এ যোগ করা
  6. প্রতিটি parameter থেকে learning rate × gradient বিয়োগ করা

একটি model-এর ১০০০টি parameter আর একটি scalar loss. Automatic differentiation দিয়ে পুরো gradient পেতে সাধারণত কতটি pass লাগে?

  • Forward mode-এ ১টি, reverse mode-এ ১০০০টি
  • Forward mode-এ ১০০০টি, reverse mode-এ একটি forward আর একটি backward
  • দুটিতেই ১০০০টি
  • দুটিতেই ২০০০টি, কারণ প্রতিটি parameter দুই দিকে সরাতে হয়

Reverse-mode autograd-এ backward চালানোর আগে graph-এর node-গুলো topological ক্রমে সাজানো হয় কেন?

  • যাতে memory কম লাগে
  • যাতে forward pass-এর মান সঠিক হয়
  • যাতে কোনো node-এর gradient ব্যবহারের আগে তার সব child-এর অবদান জমা হয়ে যায়
  • যাতে leaf node-গুলো বাদ পড়ে

Value class-এ x = ৩ নিয়ে y = x·x + x তৈরি করে y.backward() চালানো হলো। Backward-এ “+=”-এর জায়গায় “=” লেখা থাকলে আর সঠিক কোডে x.grad যথাক্রমে কত হতে পারে?

  • “=” হলে ৩, সঠিক কোডে ৭
  • দুটিতেই ৬
  • “=” হলে ৯, সঠিক কোডে ১২
  • দুটিতেই ৭

Z = A + b, যেখানে A-এর আকার (64, 16) আর b-এর আকার (1, 16). উপরের gradient G-এর আকার (64, 16). ∂L/∂b কোনটি?

  • G নিজেই
  • G.mean(axis=0, keepdims=True)
  • G.sum(axis=1, keepdims=True)
  • G.sum(axis=0, keepdims=True)

Dual number-এ ε² = ০. x = ৩ + ε (ছায়া ১) হলে x·x কত, আর তা থেকে কী পাওয়া যায়?

  • ৯, কোনো derivative নয়
  • ৯ + ৬ε, ছায়ার অংশ ৬ হলো x = ৩-এ x²-এর derivative
  • ১০ + ৬ε
  • ৯ + ৩ε

PyTorch-এ L.backward() একবার চালানোর পর একই L-এ আবার L.backward() (retain_graph ছাড়া) ডাকলে কী হয়?

  • কোনো error নেই, gradient চুপচাপ দ্বিগুণ হয়
  • আগের gradient মুছে নতুন করে হিসাব হয়
  • RuntimeError, কারণ প্রথম backward-এর পর graph-এর মাঝের মান মুছে গেছে
  • Leaf tensor-গুলোর requires_grad False হয়ে যায়

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Graph: প্রতিটি কাজ একটি node, তৈরির সময়েই তার local gradient লেখা থাকে; graph একটি DAG, তৈরির ক্রমই একটি topological ক্রম।
  2. Reverse sweep: L.grad = ১, তারপর উল্টো topological ক্রমে p.grad += (∂v/∂p) × v.grad. “+=” কারণ একাধিক পথের অবদান যোগ হয়।
  3. খরচ: f: ℝn → ℝm-এর Jacobian-এ forward mode (dual number, JVP) n-টি pass, reverse mode (VJP) m-টি; loss-এ m = ১, কিন্তু reverse-এর memory বেশি।
  4. Tensor: প্রতিটি কাজের একটি VJP: matmul-এ G Bᵀ আর Aᵀ G, broadcasting-এ বাড়তি অক্ষ ধরে sum (keepdims), ReLU-তে mask; gradient-এর আকার = parameter-এর আকার।
  5. Gradient জমে: প্রতি ধাপের আগে zero_grad; PyTorch একই graph-এ দ্বিতীয় backward-এ error দেয় (retain_graph ছাড়া), আর graph তৈরি হয় কোড চলার সময় (define-by-run).

পরের পর্বে: এখন আপনি জানেন একটি network কীভাবে শেখে। পর্ব ০৬ (Network কী শিখতে পারে — universal approximation, গভীরতা আর প্রস্থ) প্রশ্ন তুলবে, সে কী শিখতে পারে: universal approximation theorem আসলে কী বলে আর কী বলে না, ReLU দিয়ে হাতে function গড়া, আর গভীরতা কেন প্রস্থের চেয়ে কম parameter-এ বেশি ভাঁজ বানাতে পারে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান