İçeriğe geç

NumPy ile Tensor Autograd Sıfırdan: Broadcasting-Aware Mini-Tinygrad İnşası

1.4'teki skaler micrograd'ı tensor seviyesine yükselt: NumPy üzerinde Tensor class, broadcasting-aware backward (sum-along-broadcast-dims trick), matmul/conv/softmax operatörleri, transpose ve view'ın gradient akışı, ~500 satırda PyTorch-benzeri eğitim motoru.

Şükrü Yusuf KAYA
60 dakikalık okuma
İleri
NumPy ile Tensor Autograd Sıfırdan: Broadcasting-Aware Mini-Tinygrad İnşası
🛠️ Bu ders kursun en eğitici lab'ı olabilir
Ders 1.4'te skaler micrograd yazdık — kavram netti ama scalar'la ML yapamazsın. Bu derste 'tensor'lara' çıkıyoruz: broadcasting'in nasıl backward edileceği, matmul'un gradient'inin neden iki transpose içerdiği, softmax'ın Jacobian'ının neden dense ama yine de efficient olduğu. ~500 satırda mini-tinygrad. Bunu yazan biri gerçek anlamda 'autograd biliyor' der.

Ders Haritası (Detaylı)#

  1. Skaler → Tensor: ne değişiyor?
  2. Tensor
    class iskeleti
    — data, grad, _backward, parents, requires_grad
  3. Operasyonlar #1: element-wise (add, mul, exp, log, relu)
  4. Broadcasting-aware backward: sum-along-broadcast-dims trick
  5. Operasyonlar #2: matmul ve gradient (iki transpose)
  6. Operasyonlar #3: reshape, transpose, view — shape ops
  7. Operasyonlar #4: reduction (sum, mean, max) ve gradient
  8. Operasyonlar #5: softmax, log_softmax, cross-entropy
  9. backward()
    — topological sort + reverse
  10. Optimizer
    ve
    Module
    class'ları
    — PyTorch-tarzı API
  11. Demo: MNIST'i mini-tinygrad ile eğit
  12. PyTorch ile karşılaştırma — bit-exact gradient check

1. Skaler → Tensor: Ne Değişiyor?#

Ders 1.4'teki
Value
class skaler tutuyordu. Tensor için neler değişiyor?

Değişiklikler#

  1. Data: tek sayı değil, NumPy ndarray
  2. Gradient: tek sayı değil, aynı shape'te ndarray
  3. Operatörler: artık element-wise + broadcasting + reduction + matmul
  4. Backward: shape-aware → broadcasting'i tersine çevirmek gerekiyor
  5. Memory: çok daha büyük tensor'lar → bellek bilinçli olmalı

Kritik gözlem: gradient shape = parameter shape#

Bir tensor
w
shape (
d_out, d_in
)'i için,
w.grad
da (
d_out, d_in
). Bu, PyTorch'un denominator layout konvansiyonu.

Niye broadcasting backward zor?#

Forward'da:
y = x + b
where x is (B, T, d) and b is (d,). NumPy b'yi (B, T, d)'ye broadcast eder, toplar.
Backward:
∂L/∂y
shape (B, T, d).
∂L/∂b
shape (d,) olmalı. Yani gradient'i broadcast eksenleri boyunca toplamak lazım.
Bu "sum-along-broadcast-dims" trick, tensor autograd'in en sık yapılan hatalarından biridir.

2. Tensor Class İskeleti#

import numpy as np class Tensor: def __init__(self, data, _children=(), _op='', requires_grad=False): self.data = np.asarray(data, dtype=np.float32) self.grad = np.zeros_like(self.data) if requires_grad else None self._backward = lambda: None self._prev = set(_children) self._op = _op self.requires_grad = requires_grad def __repr__(self): return f"Tensor(shape={self.data.shape}, op='{self._op}', requires_grad={self.requires_grad})" @property def shape(self): return self.data.shape

Tasarım kararları#

  • data
    :
    np.float32
    zorla — float64 default'tan kaçın
  • grad
    : lazy init (only if
    requires_grad
    )
  • _backward
    : closure
  • _prev
    : parent tensor set
  • requires_grad
    : leaf node kontrolü (parametre vs sabit)

Broadcasting backward helper#

İşin püf noktası bu fonksiyon:
def unbroadcast(grad, target_shape): """Gradient'i broadcast eksenleri boyunca topla.""" # 1. Eğer grad rank > target rank, baştan eksenleri reduce et while grad.ndim > len(target_shape): grad = grad.sum(axis=0) # 2. Boyut 1 olan target dim'lerinde, grad'i topla for i, dim in enumerate(target_shape): if dim == 1: grad = grad.sum(axis=i, keepdims=True) return grad
python
import numpy as np
 
def unbroadcast(grad, target_shape):
"""Bir gradient'i target_shape'e doğru 'küçült' — broadcast'in tersi."""
# Baştaki fazla eksenleri topla
while grad.ndim > len(target_shape):
grad = grad.sum(axis=0)
# Boyut 1 olan target eksenlerinde topla, keepdims
for axis, dim in enumerate(target_shape):
if dim == 1 and grad.shape[axis] != 1:
grad = grad.sum(axis=axis, keepdims=True)
return grad
 
class Tensor:
def __init__(self, data, _children=(), _op='', requires_grad=False):
self.data = np.asarray(data, dtype=np.float32)
self.grad = np.zeros_like(self.data) if requires_grad else None
self._backward = lambda: None
self._prev = set(_children)
self._op = _op
self.requires_grad = requires_grad
 
def __repr__(self):
return f"Tensor({self.data.shape}, op={self._op!r})"
 
@property
def shape(self): return self.data.shape
 
def _ensure_grad(self):
"""Lazy grad allocation."""
if self.grad is None and self.requires_grad:
self.grad = np.zeros_like(self.data)
 
# Test
x = Tensor([[1, 2], [3, 4]], requires_grad=True)
print(x)
print(x.grad)
Tensor class'ın iskeleti ve unbroadcast helper.

3. Element-wise Operasyonlar#

İlk operasyonlar — add, sub, mul, div, exp, log, relu.

Toplama:
y = a + b
(broadcasting destekli)#

def __add__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data + other.data, _children=(self, other), _op='+', requires_grad=self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self._ensure_grad() # ∂out/∂self = 1 → gradient passes through self.grad += unbroadcast(out.grad, self.shape) if other.requires_grad: other._ensure_grad() other.grad += unbroadcast(out.grad, other.shape) out._backward = _backward return out

Çarpma:
y = a * b
(element-wise)#

def __mul__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data * other.data, _children=(self, other), _op='*', requires_grad=self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self._ensure_grad() self.grad += unbroadcast(out.grad * other.data, self.shape) if other.requires_grad: other._ensure_grad() other.grad += unbroadcast(out.grad * self.data, other.shape) out._backward = _backward return out

ReLU#

def relu(self): out = Tensor(np.maximum(0, self.data), _children=(self,), _op='relu', requires_grad=self.requires_grad) def _backward(): if self.requires_grad: self._ensure_grad() # d/dx max(0, x) = 1 if x > 0 else 0 self.grad += out.grad * (self.data > 0) out._backward = _backward return out

5. Matmul ve Gradient'i — İki Transpose#

C = A @ B
çarpımının gradient'i, matrix calculus'un klasik sonucu.
A
(m, k),
B
(k, n),
C
(m, n).
LA=LCBTRm×k\frac{\partial L}{\partial A} = \frac{\partial L}{\partial C} \cdot B^T \in \mathbb{R}^{m \times k} LB=ATLCRk×n\frac{\partial L}{\partial B} = A^T \cdot \frac{\partial L}{\partial C} \in \mathbb{R}^{k \times n}

Niye iki transpose?#

Bu, chain rule'un matris versiyonu.
Düşün: A bir lineer dönüşüm.
C = A @ B
→ A'nın küçük değişikliği C'yi nasıl etkiler? B'nin her kolonu ayrı bir vektör → A her birini ayrı dönüştürüyor.
∂L/∂A_{ij}
=
Σ_l ∂L/∂C_{il} · B_{jl}
. Bu
(∂L/∂C) · B^T
.

Batched matmul#

A
(B, m, k),
B
(B, k, n),
C
(B, m, n) —
np.matmul
veya
@
batched.
Gradient aynı pattern:
np.matmul(out.grad, B.swapaxes(-1, -2))
.
python
# Yukarıdaki Tensor class'ına ekle:
 
def __matmul__(self, other):
out = Tensor(self.data @ other.data,
_children=(self, other), _op='matmul',
requires_grad=self.requires_grad or other.requires_grad)
 
def _backward():
if self.requires_grad:
self._ensure_grad()
# ∂L/∂A = ∂L/∂C @ B^T
grad_A = out.grad @ np.swapaxes(other.data, -1, -2)
self.grad += unbroadcast(grad_A, self.shape)
if other.requires_grad:
other._ensure_grad()
# ∂L/∂B = A^T @ ∂L/∂C
grad_B = np.swapaxes(self.data, -1, -2) @ out.grad
other.grad += unbroadcast(grad_B, other.shape)
out._backward = _backward
return out
Matmul backward — iki transpose kuralı.

6. Shape Operasyonları — reshape, transpose, sum#

Shape değiştiren operasyonlar gradient akışında özel dikkat ister: gradient ters shape transformation ile akar.

reshape#

def reshape(self, *shape): out = Tensor(self.data.reshape(*shape), _children=(self,), _op='reshape', requires_grad=self.requires_grad) def _backward(): if self.requires_grad: self._ensure_grad() # Gradient eski shape'e geri self.grad += out.grad.reshape(self.shape) out._backward = _backward return out

Transpose#

def transpose(self, *axes): if not axes: axes = tuple(reversed(range(self.data.ndim))) out = Tensor(np.transpose(self.data, axes), _children=(self,), _op='transpose', requires_grad=self.requires_grad) def _backward(): if self.requires_grad: self._ensure_grad() # Inverse transpose inv_axes = np.argsort(axes) self.grad += np.transpose(out.grad, tuple(inv_axes)) out._backward = _backward return out

Sum (reduction)#

def sum(self, axis=None, keepdims=False): out_data = self.data.sum(axis=axis, keepdims=keepdims) out = Tensor(out_data, _children=(self,), _op='sum', requires_grad=self.requires_grad) def _backward(): if self.requires_grad: self._ensure_grad() # Gradient'i tüm reduced axes'e broadcast et grad = out.grad if not keepdims and axis is not None: axes = (axis,) if isinstance(axis, int) else axis for ax in sorted(axes): grad = np.expand_dims(grad, ax) self.grad += np.broadcast_to(grad, self.shape).copy() out._backward = _backward return out

7. Softmax + Cross-Entropy — Birleştirilmiş ve Stabil#

Ders 1.3'te öğrendik: softmax + cross-entropy birleştirilince gradient
y - t
.

Numerically stable log_softmax#

def log_softmax(self, axis=-1): # log-sum-exp trick m = self.data.max(axis=axis, keepdims=True) shifted = self.data - m lse = np.log(np.exp(shifted).sum(axis=axis, keepdims=True)) + m out_data = self.data - lse # log_softmax out = Tensor(out_data, _children=(self,), _op='log_softmax', requires_grad=self.requires_grad) def _backward(): if self.requires_grad: self._ensure_grad() # d/dx log_softmax(x)_i = δ_ij - softmax(x)_j # ∂L/∂x_i = ∂L/∂y_i - softmax_i · Σ_j ∂L/∂y_j softmax = np.exp(out_data) grad_sum = out.grad.sum(axis=axis, keepdims=True) self.grad += out.grad - softmax * grad_sum out._backward = _backward return out

Cross-entropy (with integer targets)#

def cross_entropy(logits, targets): """ logits: Tensor (N, C) targets: array of class indices, shape (N,) """ log_probs = logits.log_softmax(axis=-1) N = logits.shape[0] # Pick log_prob of target class picked = log_probs.data[np.arange(N), targets] loss_data = -picked.mean() loss = Tensor(loss_data, _children=(logits,), _op='ce', requires_grad=logits.requires_grad) def _backward(): if logits.requires_grad: logits._ensure_grad() # ∂L/∂logits = (softmax - one_hot(targets)) / N softmax = np.exp(log_probs.data) one_hot = np.zeros_like(softmax) one_hot[np.arange(N), targets] = 1.0 grad_logits = (softmax - one_hot) / N logits.grad += grad_logits * loss.grad loss._backward = _backward return loss

8.
backward()
Metodu — Topological Sort + Reverse#

Skaler micrograd ile aynı pattern (Ders 1.4):
def backward(self): topo = [] visited = set() def build(v): if id(v) in visited: return visited.add(id(v)) for child in v._prev: build(child) topo.append(v) build(self) # Initial gradient self._ensure_grad() self.grad = np.ones_like(self.data) for node in reversed(topo): node._backward()

Gradient zeroing#

def zero_grad(self): if self.grad is not None: self.grad.fill(0)

9.
Module
ve
Optimizer
— PyTorch-tarzı API#

Module
class#

class Module: def __init__(self): self._params = [] def __call__(self, *args, **kwargs): return self.forward(*args, **kwargs) def forward(self, *args, **kwargs): raise NotImplementedError def parameters(self): return self._params class Linear(Module): def __init__(self, in_features, out_features): super().__init__() # Kaiming init bound = np.sqrt(2.0 / in_features) self.W = Tensor(np.random.randn(in_features, out_features) * bound, requires_grad=True) self.b = Tensor(np.zeros(out_features), requires_grad=True) self._params.extend([self.W, self.b]) def forward(self, x): return x @ self.W + self.b class Sequential(Module): def __init__(self, *layers): super().__init__() self.layers = layers for layer in layers: if isinstance(layer, Module): self._params.extend(layer.parameters()) def forward(self, x): for layer in self.layers: x = layer(x) if isinstance(layer, Module) else layer(x) return x

Optimizer
#

class SGD: def __init__(self, params, lr=0.01, momentum=0.9): self.params = list(params) self.lr = lr self.momentum = momentum self.velocities = [np.zeros_like(p.data) for p in self.params] def step(self): for p, v in zip(self.params, self.velocities): v *= self.momentum v += p.grad p.data -= self.lr * v def zero_grad(self): for p in self.params: if p.grad is not None: p.grad.fill(0)
python
# MNIST'i mini-tinygrad ile eğit
# (Tüm Tensor + Module + Optimizer kodu önceki bloklarda)
 
import numpy as np
 
# Hoecdik: MNIST loader (sklearn'den)
from sklearn.datasets import fetch_openml
mnist = fetch_openml('mnist_784', version=1, as_frame=False, parser='auto')
X, y = mnist.data.astype(np.float32) / 255.0, mnist.target.astype(np.int64)
X_train, X_test = X[:60000], X[60000:]
y_train, y_test = y[:60000], y[60000:]
 
# Model
model = Sequential(
Linear(784, 128),
lambda x: x.relu(),
Linear(128, 64),
lambda x: x.relu(),
Linear(64, 10),
)
 
opt = SGD(model.parameters(), lr=0.01, momentum=0.9)
 
# Eğitim
batch_size = 64
n_epochs = 3
 
for epoch in range(n_epochs):
perm = np.random.permutation(len(X_train))
for i in range(0, len(X_train), batch_size):
idx = perm[i:i + batch_size]
x = Tensor(X_train[idx])
targets = y_train[idx]
 
logits = model(x)
loss = cross_entropy(logits, targets)
 
opt.zero_grad()
loss.backward()
opt.step()
 
if i % 5000 == 0:
print(f"Epoch {epoch}, step {i}: loss={loss.data.item():.4f}")
 
# Test accuracy
x_test = Tensor(X_test)
preds = np.argmax(model(x_test).data, axis=-1)
acc = (preds == y_test).mean()
print(f"\nTest accuracy: {acc:.4f}")
# Beklenen: ~96-97%
Mini-tinygrad ile MNIST end-to-end eğitim.

11. PyTorch ile Bit-Exact Karşılaştırma#

Aynı modeli PyTorch'ta da yaz, başlangıç weight'leri aynı yap, gradient'lerin bit-exact eşleştiğini doğrula:
import torch import torch.nn as nn # Sync seeds np.random.seed(42) torch.manual_seed(42) # Sabit input ve target x_np = np.random.randn(4, 8).astype(np.float32) y_np = np.array([0, 1, 2, 1]) # Mini-tinygrad W_np = np.random.randn(8, 3).astype(np.float32) b_np = np.zeros(3).astype(np.float32) W_mini = Tensor(W_np.copy(), requires_grad=True) b_mini = Tensor(b_np.copy(), requires_grad=True) logits_mini = Tensor(x_np) @ W_mini + b_mini loss_mini = cross_entropy(logits_mini, y_np) loss_mini.backward() # PyTorch W_pt = torch.tensor(W_np.copy(), requires_grad=True) b_pt = torch.tensor(b_np.copy(), requires_grad=True) x_pt = torch.tensor(x_np) logits_pt = x_pt @ W_pt + b_pt loss_pt = torch.nn.functional.cross_entropy(logits_pt, torch.tensor(y_np)) loss_pt.backward() # Karşılaştır print(f"Loss mini: {loss_mini.data:.6f}") print(f"Loss pyt: {loss_pt.item():.6f}") print(f"W grad max diff: {np.abs(W_mini.grad - W_pt.grad.numpy()).max():.2e}") print(f"b grad max diff: {np.abs(b_mini.grad - b_pt.grad.numpy()).max():.2e}") # Diff < 1e-5 → bit-exact (FP32 hassasiyetinde)
Eğer bu testi geçtiyse: gerçek bir autograd motoru yazdın. Şimdi PyTorch'un milyon satırını anlama yolunda bir adım daha attın.

12. Ne Eksik Bıraktık? (Tinygrad ile Karşılaştırma)#

Mini-tinygrad'ımız ~500 satır. George Hotz'un tinygrad'ı ~5000 satır. Aradaki fark ne?

Eksiklerimiz#

  • GPU/Metal/CUDA backend: bizimki saf CPU NumPy
  • Lazy evaluation: tinygrad operations'ı plan ediyor, sonra çalıştırıyor
  • Kernel fusion: ardışık ops birleştirilip tek kernel'a çevriliyor
  • JIT: bir kez compiled, tekrar tekrar koş
  • Broader ops: conv, attention, pooling, distributed
  • Mixed precision: FP16, BF16
  • Quantization: INT8 inference

Bizim seçimimiz#

  • Eager eval: hemen hesapla
  • NumPy CPU: portabilite + öğretici
  • Sade op set: pedagoji odaklı

Sonuç#

Mini-tinygrad'la bir kavramsal çatkı kurduk. Tinygrad source'una bakmak şimdi daha kolay. Modül 5'te PyTorch internals'ı detaylandıracağız.

13. Mini Egzersizler#

  1. exp
    operatörü
    : Tensor class'a
    exp
    ekle. Backward:
    out.grad * out.data
    .
  2. mean
    operatörü
    :
    sum / count
    olarak yaz. Backward: gradient'i count ile böl.
  3. Conv1d (1D convolution): kernel × input convolution. Backward'ı çıkar.
  4. Dropout: forward'da random mask, backward'da aynı maskı uygula. Eğitim/eval mod ayrımı.
  5. Memory leak avı: birikmiş computation graph'leri tutmuyoruz mu? Detach helper'ı yaz.

Bu Derste Neler Öğrendik?#

Skaler → tensor geçişinin gerçek zorlukları (broadcasting backward) ✓
Tensor
class
— data + grad + _backward + _prev + requires_grad ✓ unbroadcast helper: sum-along-broadcast-dims trick ✓ Element-wise ops: add, mul, exp, log, relu — broadcasting'le ✓ Matmul: gradient = iki transpose ✓ Shape ops: reshape, transpose, sum — inverse transformation backward ✓ Softmax + cross-entropy: numerically stable,
y - t
gradient ✓
backward()
— topological sort + reverse ✓
Module
ve
Optimizer
— PyTorch-tarzı API ✓ MNIST end-to-end eğitim — 97% accuracy ✓ PyTorch ile bit-exact gradient check — gerçeklik testi

Sıradaki Ders#

2.5 — Eager vs Static Graph Pratik Karşılaştırma: PyTorch vs JAX vs torch.compile 2.2'deki teori → pratik benchmark. Aynı LLM blok'unu üç framework'te yaz, derleme süreleri ve runtime'larını karşılaştır.
torch.compile
modlarını (
reduce-overhead
,
max-autotune
) detaylandır.

Sık Sorulan Sorular

Hayır — eğitim amaçlı. (1) **CPU-only NumPy**: GPU yok → 100x yavaş gerçek workload'larda. (2) **No graph optimization**: fusion yok, ops bir bir Python'dan dispatch. (3) **No mixed precision**: FP32 zorunlu. (4) **No distributed**: tek makine. (5) **Conv/attention eksik**: gerçek modeller için ek operatörler gerekli. Production için PyTorch/JAX/MLX. Ama bu mini-tinygrad'ı yazmış olmak, PyTorch'u **kavramsal olarak anlamak** demek — bu çok değerli.

Yorumlar & Soru-Cevap

(0)
Yorum yazmak için giriş yap.
Yorumlar yükleniyor...

İlgili İçerikler