leanpass 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: leanpass
3
+ Version: 0.1.0
4
+ Summary: A lightweight, transparent alternative to PyTorch/TensorFlow built on NumPy.
5
+ Author: LeanPass
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy
9
+
10
+ # leanpass
11
+
12
+ A minimal, transparent NumPy-based autograd library for small models.
13
+
14
+ ## Features
15
+
16
+ - `Tensor` with reverse-mode autodiff
17
+ - Operators: `+`, `-`, `*`, `/`, `**`, `@`
18
+ - Activations: `relu`, `sigmoid`, `softmax`
19
+ - `Linear` and `MLP` modules
20
+ - `SGD` and `Adam` optimizers
21
+ - Graph visualization via `Tensor.visualize_dot()`
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install .
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ ```python
32
+ from leanpass import Tensor, nn, optim
33
+
34
+ x = Tensor([[1.0, 2.0]], requires_grad=False)
35
+ model = nn.MLP([2, 16, 3])
36
+ logits = model(x)
37
+ ```
38
+
39
+ ## Run demo
40
+
41
+ ```bash
42
+ python demo.py
43
+ ```
44
+
45
+ ## Publish to PyPI
46
+
47
+ 1. Update `version` in `pyproject.toml`.
48
+ 2. Install build tools:
49
+
50
+ ```bash
51
+ python -m pip install --upgrade build twine
52
+ ```
53
+
54
+ 3. Build distribution files:
55
+
56
+ ```bash
57
+ python -m build
58
+ ```
59
+
60
+ 4. Upload to PyPI:
61
+
62
+ ```bash
63
+ python -m twine upload dist/*
64
+ ```
@@ -0,0 +1,55 @@
1
+ # leanpass
2
+
3
+ A minimal, transparent NumPy-based autograd library for small models.
4
+
5
+ ## Features
6
+
7
+ - `Tensor` with reverse-mode autodiff
8
+ - Operators: `+`, `-`, `*`, `/`, `**`, `@`
9
+ - Activations: `relu`, `sigmoid`, `softmax`
10
+ - `Linear` and `MLP` modules
11
+ - `SGD` and `Adam` optimizers
12
+ - Graph visualization via `Tensor.visualize_dot()`
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install .
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ from leanpass import Tensor, nn, optim
24
+
25
+ x = Tensor([[1.0, 2.0]], requires_grad=False)
26
+ model = nn.MLP([2, 16, 3])
27
+ logits = model(x)
28
+ ```
29
+
30
+ ## Run demo
31
+
32
+ ```bash
33
+ python demo.py
34
+ ```
35
+
36
+ ## Publish to PyPI
37
+
38
+ 1. Update `version` in `pyproject.toml`.
39
+ 2. Install build tools:
40
+
41
+ ```bash
42
+ python -m pip install --upgrade build twine
43
+ ```
44
+
45
+ 3. Build distribution files:
46
+
47
+ ```bash
48
+ python -m build
49
+ ```
50
+
51
+ 4. Upload to PyPI:
52
+
53
+ ```bash
54
+ python -m twine upload dist/*
55
+ ```
@@ -0,0 +1,5 @@
1
+ from .tensor import Tensor
2
+ from . import nn
3
+ from . import optim
4
+
5
+ __all__ = ["Tensor", "nn", "optim"]
@@ -0,0 +1,72 @@
1
+ import numpy as np
2
+
3
+ from .tensor import Tensor
4
+
5
+
6
+ class Module:
7
+ """A base class for layers and model containers."""
8
+
9
+ def parameters(self):
10
+ """Collect all trainable Tensor parameters recursively."""
11
+ params = []
12
+ for value in self.__dict__.values():
13
+ if isinstance(value, Tensor) and value.requires_grad:
14
+ params.append(value)
15
+ elif isinstance(value, Module):
16
+ params.extend(value.parameters())
17
+ return params
18
+
19
+ def zero_grad(self):
20
+ """Reset gradients before each optimization step."""
21
+ for param in self.parameters():
22
+ param.grad = np.zeros_like(param.data)
23
+
24
+
25
+ class Linear(Module):
26
+ """A single fully connected layer: y = x @ W + b."""
27
+
28
+ def __init__(self, in_features, out_features):
29
+ scale = 1.0 / np.sqrt(in_features)
30
+ self.weight = Tensor(
31
+ np.random.uniform(-scale, scale, size=(in_features, out_features)),
32
+ requires_grad=True,
33
+ )
34
+ self.bias = Tensor(np.zeros(out_features), requires_grad=True)
35
+
36
+ def forward(self, x: Tensor) -> Tensor:
37
+ return x @ self.weight + self.bias
38
+
39
+ def __call__(self, x: Tensor) -> Tensor:
40
+ return self.forward(x)
41
+
42
+
43
+ class MLP(Module):
44
+ """A simple feedforward network with ReLU activations."""
45
+
46
+ def __init__(self, layer_sizes):
47
+ self.layers = []
48
+ for in_dim, out_dim in zip(layer_sizes[:-1], layer_sizes[1:]):
49
+ self.layers.append(Linear(in_dim, out_dim))
50
+
51
+ def forward(self, x: Tensor) -> Tensor:
52
+ """Propagate the input through each layer and apply ReLU except last."""
53
+ for index, layer in enumerate(self.layers):
54
+ x = layer(x)
55
+ if index < len(self.layers) - 1:
56
+ x = x.relu()
57
+ return x
58
+
59
+ def __call__(self, x: Tensor) -> Tensor:
60
+ return self.forward(x)
61
+
62
+ def parameters(self):
63
+ """Return parameters from every Linear layer in the network."""
64
+ params = []
65
+ for layer in self.layers:
66
+ params.extend(layer.parameters())
67
+ return params
68
+
69
+
70
+ def mse_loss(prediction: Tensor, target: Tensor) -> Tensor:
71
+ """Mean squared error loss used for regression training."""
72
+ return ((prediction - target) ** 2).mean()
@@ -0,0 +1,53 @@
1
+ import numpy as np
2
+
3
+
4
+ class SGD:
5
+ """Simple stochastic gradient descent optimizer."""
6
+
7
+ def __init__(self, params, lr=1e-2):
8
+ self.params = list(params)
9
+ self.lr = lr
10
+
11
+ def step(self):
12
+ """Apply a plain gradient descent update to each parameter."""
13
+ for param in self.params:
14
+ if param.grad is None:
15
+ continue
16
+ param.data = param.data - self.lr * param.grad
17
+
18
+ def zero_grad(self):
19
+ """Zero out gradients so the next backward pass starts clean."""
20
+ for param in self.params:
21
+ param.grad = np.zeros_like(param.data)
22
+
23
+
24
+ class Adam:
25
+ """Adam optimizer with bias-corrected moment estimates."""
26
+
27
+ def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8):
28
+ self.params = list(params)
29
+ self.lr = lr
30
+ self.b1, self.b2 = betas
31
+ self.eps = eps
32
+ self.m = [np.zeros_like(p.data) for p in self.params]
33
+ self.v = [np.zeros_like(p.data) for p in self.params]
34
+ self.t = 0
35
+
36
+ def step(self):
37
+ """Update each parameter using Adam's adaptive moment estimates."""
38
+ self.t += 1
39
+ for i, param in enumerate(self.params):
40
+ if param.grad is None:
41
+ continue
42
+ g = param.grad
43
+ self.m[i] = self.b1 * self.m[i] + (1 - self.b1) * g
44
+ self.v[i] = self.b2 * self.v[i] + (1 - self.b2) * (g ** 2)
45
+
46
+ m_hat = self.m[i] / (1 - self.b1 ** self.t)
47
+ v_hat = self.v[i] / (1 - self.b2 ** self.t)
48
+ param.data = param.data - self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
49
+
50
+ def zero_grad(self):
51
+ """Zero out gradients for all tracked parameters."""
52
+ for param in self.params:
53
+ param.grad = np.zeros_like(param.data)
@@ -0,0 +1,530 @@
1
+ import numpy as np
2
+
3
+
4
+ def _ensure_array(data):
5
+ """Convert inputs to NumPy arrays so math is always numeric."""
6
+ if isinstance(data, Tensor):
7
+ return data.data
8
+ return np.array(data, dtype=np.float64)
9
+
10
+
11
+ def _sum_to_shape(grad, shape):
12
+ """Reduce broadcasted gradients back to the shape of the original tensor."""
13
+ if grad.shape == shape:
14
+ return grad
15
+
16
+ while grad.ndim > len(shape):
17
+ grad = grad.sum(axis=0)
18
+
19
+ for axis, size in enumerate(shape):
20
+ if size == 1 and grad.shape[axis] != 1:
21
+ grad = grad.sum(axis=axis, keepdims=True)
22
+
23
+ return grad.reshape(shape)
24
+
25
+
26
+ class Tensor:
27
+ """A tiny automatic differentiation tensor backed by NumPy."""
28
+
29
+ def __init__(self, data, requires_grad=False, name=None):
30
+ self.data = np.array(data, dtype=np.float64)
31
+ self.grad = np.zeros_like(self.data) if requires_grad else None
32
+ self.requires_grad = requires_grad
33
+ self._backward = lambda: None
34
+ self._prev = ()
35
+ self._op = ""
36
+ self._meta = {}
37
+ self.name = name
38
+
39
+ def __repr__(self):
40
+ name = f" name={self.name}" if self.name else ""
41
+ return f"Tensor(shape={self.data.shape}, requires_grad={self.requires_grad}{name})"
42
+
43
+ def _create_child(self, data, op, prev, name=None, meta=None):
44
+ out = Tensor(data, requires_grad=any(node.requires_grad for node in prev), name=name)
45
+ out._prev = tuple(prev)
46
+ out._op = op
47
+ out._meta = meta or {}
48
+ return out
49
+
50
+ def __add__(self, other):
51
+ other = other if isinstance(other, Tensor) else Tensor(other)
52
+ out = self._create_child(self.data + other.data, "+", (self, other))
53
+
54
+ def _backward():
55
+ if self.requires_grad:
56
+ self.grad += _sum_to_shape(out.grad, self.data.shape)
57
+ if other.requires_grad:
58
+ other.grad += _sum_to_shape(out.grad, other.data.shape)
59
+
60
+ out._backward = _backward
61
+ return out
62
+
63
+ def __radd__(self, other):
64
+ return self + other
65
+
66
+ def __neg__(self):
67
+ out = self._create_child(-self.data, "neg", (self,))
68
+
69
+ def _backward():
70
+ if self.requires_grad:
71
+ self.grad += _sum_to_shape(-out.grad, self.data.shape)
72
+
73
+ out._backward = _backward
74
+ return out
75
+
76
+ def __sub__(self, other):
77
+ other = other if isinstance(other, Tensor) else Tensor(other)
78
+ out = self._create_child(self.data - other.data, "-", (self, other))
79
+
80
+ def _backward():
81
+ if self.requires_grad:
82
+ self.grad += _sum_to_shape(out.grad, self.data.shape)
83
+ if other.requires_grad:
84
+ other.grad += _sum_to_shape(-out.grad, other.data.shape)
85
+
86
+ out._backward = _backward
87
+ return out
88
+
89
+ def __rsub__(self, other):
90
+ other = other if isinstance(other, Tensor) else Tensor(other)
91
+ return other - self
92
+
93
+ def __mul__(self, other):
94
+ other = other if isinstance(other, Tensor) else Tensor(other)
95
+ out = self._create_child(self.data * other.data, "*", (self, other))
96
+
97
+ def _backward():
98
+ if self.requires_grad:
99
+ self.grad += _sum_to_shape(out.grad * other.data, self.data.shape)
100
+ if other.requires_grad:
101
+ other.grad += _sum_to_shape(out.grad * self.data, other.data.shape)
102
+
103
+ out._backward = _backward
104
+ return out
105
+
106
+ def __rmul__(self, other):
107
+ return self * other
108
+
109
+ def __truediv__(self, other):
110
+ other = other if isinstance(other, Tensor) else Tensor(other)
111
+ out = self._create_child(self.data / other.data, "/", (self, other))
112
+
113
+ def _backward():
114
+ if self.requires_grad:
115
+ self.grad += _sum_to_shape(out.grad / other.data, self.data.shape)
116
+ if other.requires_grad:
117
+ other.grad += _sum_to_shape(-out.grad * self.data / (other.data ** 2), other.data.shape)
118
+
119
+ out._backward = _backward
120
+ return out
121
+
122
+ def __pow__(self, exponent):
123
+ exponent = float(exponent)
124
+ out = self._create_child(self.data ** exponent, "**", (self,), meta={"exponent": exponent})
125
+
126
+ def _backward():
127
+ if self.requires_grad:
128
+ self.grad += _sum_to_shape(out.grad * exponent * self.data ** (exponent - 1), self.data.shape)
129
+
130
+ out._backward = _backward
131
+ return out
132
+
133
+ def exp(self):
134
+ out = self._create_child(np.exp(self.data), "exp", {self})
135
+
136
+ def _backward():
137
+ if self.requires_grad:
138
+ self.grad += _sum_to_shape(out.grad * out.data, self.data.shape)
139
+
140
+ out._backward = _backward
141
+ return out
142
+
143
+ def log(self):
144
+ out = self._create_child(np.log(self.data), "log", {self})
145
+
146
+ def _backward():
147
+ if self.requires_grad:
148
+ self.grad += _sum_to_shape(out.grad / self.data, self.data.shape)
149
+
150
+ out._backward = _backward
151
+ return out
152
+
153
+ def sigmoid(self):
154
+ out = self._create_child(1 / (1 + np.exp(-self.data)), "sigmoid", {self})
155
+
156
+ def _backward():
157
+ if self.requires_grad:
158
+ sigmoid_grad = out.data * (1 - out.data)
159
+ self.grad += _sum_to_shape(out.grad * sigmoid_grad, self.data.shape)
160
+
161
+ out._backward = _backward
162
+ return out
163
+
164
+ def softmax(self, axis=-1):
165
+ shifted = self.data - self.data.max(axis=axis, keepdims=True)
166
+ exp_values = np.exp(shifted)
167
+ probabilities = exp_values / exp_values.sum(axis=axis, keepdims=True)
168
+ out = self._create_child(probabilities, "softmax", {self})
169
+
170
+ def _backward():
171
+ if self.requires_grad:
172
+ grad = out.grad
173
+ sum_grad = (grad * out.data).sum(axis=axis, keepdims=True)
174
+ self.grad += _sum_to_shape(out.data * (grad - sum_grad), self.data.shape)
175
+
176
+ out._backward = _backward
177
+ return out
178
+
179
+ def __matmul__(self, other):
180
+ other = other if isinstance(other, Tensor) else Tensor(other)
181
+ out = self._create_child(self.data @ other.data, "@", (self, other))
182
+
183
+ def _backward():
184
+ if self.requires_grad:
185
+ self.grad += _sum_to_shape(out.grad @ other.data.T, self.data.shape)
186
+ if other.requires_grad:
187
+ other.grad += _sum_to_shape(self.data.T @ out.grad, other.data.shape)
188
+
189
+ out._backward = _backward
190
+ return out
191
+
192
+ def relu(self):
193
+ out = self._create_child(np.maximum(0, self.data), "relu", {self})
194
+
195
+ def _backward():
196
+ if self.requires_grad:
197
+ grad_input = out.grad * (self.data > 0).astype(np.float64)
198
+ self.grad += _sum_to_shape(grad_input, self.data.shape)
199
+
200
+ out._backward = _backward
201
+ return out
202
+
203
+ def sum(self):
204
+ out = Tensor(self.data.sum(), requires_grad=self.requires_grad, name="sum")
205
+ out._prev = {self}
206
+ out._op = "sum"
207
+
208
+ def _backward():
209
+ if self.requires_grad:
210
+ self.grad += np.ones_like(self.data) * out.grad
211
+
212
+ out._backward = _backward
213
+ return out
214
+
215
+ def mean(self):
216
+ out = Tensor(self.data.mean(), requires_grad=self.requires_grad, name="mean")
217
+ out._prev = {self}
218
+ out._op = "mean"
219
+
220
+ def _backward():
221
+ if self.requires_grad:
222
+ scale = 1.0 / self.data.size
223
+ self.grad += np.ones_like(self.data) * out.grad * scale
224
+
225
+ out._backward = _backward
226
+ return out
227
+
228
+ def visualize(self):
229
+ """Return a simple text representation of the computation graph."""
230
+ nodes = []
231
+ edges = []
232
+ visited = set()
233
+
234
+ def build(v):
235
+ if v in visited:
236
+ return
237
+ visited.add(v)
238
+ label = v._op or "leaf"
239
+ if v.name:
240
+ label += f" ({v.name})"
241
+ nodes.append((id(v), label, v.data.shape))
242
+ for child in v._prev:
243
+ edges.append((id(child), id(v)))
244
+ build(child)
245
+
246
+ build(self)
247
+
248
+ lines = [f"Node {nid}: {label} shape={shape}" for nid, label, shape in nodes]
249
+ lines += [f"Edge {src} -> {dst}" for src, dst in edges]
250
+ return "\n".join(lines)
251
+
252
+ def visualize_dot(self):
253
+ """Return a Graphviz DOT representation of the computation graph."""
254
+ nodes, edges = self._graph_nodes()
255
+ lines = ["digraph computation_graph {", " rankdir=LR;", " node [shape=box, style=filled, fillcolor=lightgray];"]
256
+
257
+ for node in nodes:
258
+ label = node._op or "leaf"
259
+ if node.name:
260
+ label += f"\n{node.name}"
261
+ shape = "ellipse" if len(node._prev) == 0 else "box"
262
+ lines.append(f" n{ id(node) } [label=\"{label}\", shape={shape}];")
263
+
264
+ for src, dst in edges:
265
+ lines.append(f" n{src} -> n{dst};")
266
+
267
+ lines.append("}")
268
+ return "\n".join(lines)
269
+
270
+ def backward(self, gradient=None):
271
+ if gradient is None:
272
+ gradient = np.ones_like(self.data)
273
+ if self.grad is None:
274
+ self.grad = np.zeros_like(self.data)
275
+ self.grad = self.grad + np.array(gradient, dtype=np.float64)
276
+
277
+ topo = []
278
+ visited = set()
279
+
280
+ def build(v):
281
+ if v not in visited:
282
+ visited.add(v)
283
+ for child in v._prev:
284
+ build(child)
285
+ topo.append(v)
286
+
287
+ build(self)
288
+
289
+ for node in topo:
290
+ if node.requires_grad and node.grad is None:
291
+ node.grad = np.zeros_like(node.data)
292
+
293
+ for node in reversed(topo):
294
+ node._backward()
295
+
296
+ def _graph_nodes(self):
297
+ nodes = []
298
+ edges = []
299
+ visited = set()
300
+
301
+ def build(v):
302
+ if v in visited:
303
+ return
304
+ visited.add(v)
305
+ nodes.append(v)
306
+ for child in v._prev:
307
+ edges.append((id(child), id(v)))
308
+ build(child)
309
+
310
+ build(self)
311
+ return nodes, edges
312
+
313
+ def grad_check(self, eps=1e-6, tol=1e-4):
314
+ """Compare backward gradients against finite differences."""
315
+ if not self.requires_grad:
316
+ raise ValueError("grad_check requires the output tensor to require gradients")
317
+
318
+ nodes, _ = self._graph_nodes()
319
+ numeric = {}
320
+
321
+ for node in nodes:
322
+ if not node.requires_grad:
323
+ continue
324
+ original = node.data.copy()
325
+ numeric_grad = np.zeros_like(node.data)
326
+ for idx in np.ndindex(node.data.shape):
327
+ node.data[idx] = original[idx] + eps
328
+ plus = self._eval_forward()
329
+ node.data[idx] = original[idx] - eps
330
+ minus = self._eval_forward()
331
+ node.data[idx] = original[idx]
332
+ numeric_grad[idx] = (np.sum(plus) - np.sum(minus)) / (2 * eps)
333
+
334
+ self.zero_grad_all()
335
+ self.backward()
336
+ numeric[node] = numeric_grad
337
+ node.data = original
338
+
339
+ errors = []
340
+ for node in nodes:
341
+ if not node.requires_grad:
342
+ continue
343
+ diff = np.max(np.abs(node.grad - numeric[node]))
344
+ if diff > tol:
345
+ errors.append((node, diff, node.grad, numeric[node]))
346
+
347
+ return errors
348
+
349
+ def _eval_forward(self):
350
+ """Evaluate the graph forward using current leaf values without mutating nodes."""
351
+ topo = []
352
+ visited = set()
353
+ values = {}
354
+
355
+ def build(v):
356
+ if v not in visited:
357
+ visited.add(v)
358
+ for child in v._prev:
359
+ build(child)
360
+ topo.append(v)
361
+
362
+ build(self)
363
+
364
+ for node in topo:
365
+ if len(node._prev) == 0:
366
+ values[node] = node.data
367
+ continue
368
+
369
+ if node._op == "+":
370
+ values[node] = values[node._prev[0]] + values[node._prev[1]]
371
+ elif node._op == "-":
372
+ values[node] = values[node._prev[0]] - values[node._prev[1]]
373
+ elif node._op == "*":
374
+ values[node] = values[node._prev[0]] * values[node._prev[1]]
375
+ elif node._op == "/":
376
+ values[node] = values[node._prev[0]] / values[node._prev[1]]
377
+ elif node._op == "**":
378
+ exponent = node._meta.get("exponent", 2.0)
379
+ values[node] = values[node._prev[0]] ** exponent
380
+ elif node._op == "@":
381
+ values[node] = values[node._prev[0]] @ values[node._prev[1]]
382
+ elif node._op == "relu":
383
+ values[node] = np.maximum(0, values[node._prev[0]])
384
+ elif node._op == "exp":
385
+ values[node] = np.exp(values[node._prev[0]])
386
+ elif node._op == "log":
387
+ values[node] = np.log(values[node._prev[0]])
388
+ elif node._op == "sigmoid":
389
+ x = values[node._prev[0]]
390
+ values[node] = 1 / (1 + np.exp(-x))
391
+ elif node._op == "softmax":
392
+ x = values[node._prev[0]]
393
+ shifted = x - x.max(axis=-1, keepdims=True)
394
+ exp_values = np.exp(shifted)
395
+ values[node] = exp_values / exp_values.sum(axis=-1, keepdims=True)
396
+ elif node._op == "sum":
397
+ values[node] = values[node._prev[0]].sum()
398
+ elif node._op == "mean":
399
+ values[node] = values[node._prev[0]].mean()
400
+ elif node._op == "neg":
401
+ values[node] = -values[node._prev[0]]
402
+ else:
403
+ values[node] = node.data
404
+
405
+ return values[self]
406
+
407
+ def zero_grad_all(self):
408
+ """Zero all gradients in the current graph."""
409
+ nodes, _ = self._graph_nodes()
410
+ for node in nodes:
411
+ if node.requires_grad:
412
+ node.grad = np.zeros_like(node.data)
413
+
414
+ def _graph_nodes(self):
415
+ nodes = []
416
+ edges = []
417
+ visited = set()
418
+
419
+ def build(v):
420
+ if v in visited:
421
+ return
422
+ visited.add(v)
423
+ nodes.append(v)
424
+ for child in v._prev:
425
+ edges.append((id(child), id(v)))
426
+ build(child)
427
+
428
+ build(self)
429
+ return nodes, edges
430
+
431
+ def grad_check(self, eps=1e-6, tol=1e-4):
432
+ """Compare analytical gradients to numerical finite differences."""
433
+ if not self.requires_grad:
434
+ raise ValueError("grad_check requires the output tensor to require gradients")
435
+
436
+ nodes, _ = self._graph_nodes()
437
+ numeric = {}
438
+
439
+ def scalar_forward():
440
+ return float(self.data.sum()) if self.data.size == 1 else None
441
+
442
+ for node in nodes:
443
+ if not node.requires_grad:
444
+ continue
445
+ analytic = np.zeros_like(node.data)
446
+ numeric_grad = np.zeros_like(node.data)
447
+ original = node.data.copy()
448
+
449
+ it = np.nditer(node.data, flags=["multi_index"], op_flags=["readwrite"])
450
+ while not it.finished:
451
+ idx = it.multi_index
452
+ node.data[idx] = original[idx] + eps
453
+ plus = self._eval_forward()
454
+ node.data[idx] = original[idx] - eps
455
+ minus = self._eval_forward()
456
+ node.data[idx] = original[idx]
457
+ numeric_grad[idx] = (plus - minus) / (2 * eps)
458
+ it.iternext()
459
+
460
+ node.zero_grad_all()
461
+ self.backward()
462
+ analytic = node.grad.copy()
463
+ numeric[node] = numeric_grad
464
+ node.data = original
465
+
466
+ errors = []
467
+ for node in nodes:
468
+ if not node.requires_grad:
469
+ continue
470
+ diff = np.max(np.abs(node.grad - numeric[node]))
471
+ if diff > tol:
472
+ errors.append((node, diff, node.grad, numeric[node]))
473
+
474
+ return errors
475
+
476
+ def _eval_forward(self):
477
+ """Evaluate current graph data forward to this tensor using NumPy semantics."""
478
+ topo = []
479
+ visited = set()
480
+
481
+ def build(v):
482
+ if v not in visited:
483
+ visited.add(v)
484
+ for child in v._prev:
485
+ build(child)
486
+ topo.append(v)
487
+
488
+ build(self)
489
+
490
+ for node in topo:
491
+ if node._op == "leaf":
492
+ continue
493
+ if node._op == "+":
494
+ node.data = list(node._prev)[0].data + list(node._prev)[1].data
495
+ elif node._op == "-":
496
+ node.data = list(node._prev)[0].data - list(node._prev)[1].data
497
+ elif node._op == "*":
498
+ node.data = list(node._prev)[0].data * list(node._prev)[1].data
499
+ elif node._op == "/":
500
+ node.data = list(node._prev)[0].data / list(node._prev)[1].data
501
+ elif node._op == "**":
502
+ node.data = list(node._prev)[0].data ** float(2)
503
+ elif node._op == "@":
504
+ node.data = list(node._prev)[0].data @ list(node._prev)[1].data
505
+ elif node._op == "relu":
506
+ node.data = np.maximum(0, list(node._prev)[0].data)
507
+ elif node._op == "exp":
508
+ node.data = np.exp(list(node._prev)[0].data)
509
+ elif node._op == "log":
510
+ node.data = np.log(list(node._prev)[0].data)
511
+ elif node._op == "sigmoid":
512
+ x = list(node._prev)[0].data
513
+ node.data = 1 / (1 + np.exp(-x))
514
+ elif node._op == "softmax":
515
+ x = list(node._prev)[0].data
516
+ shifted = x - x.max(axis=-1, keepdims=True)
517
+ exp_values = np.exp(shifted)
518
+ node.data = exp_values / exp_values.sum(axis=-1, keepdims=True)
519
+ elif node._op == "sum":
520
+ node.data = list(node._prev)[0].data.sum()
521
+ elif node._op == "mean":
522
+ node.data = list(node._prev)[0].data.mean()
523
+ return float(self.data) if np.isscalar(self.data) else self.data
524
+
525
+ def zero_grad_all(self):
526
+ """Zero all gradients in the current graph."""
527
+ nodes, _ = self._graph_nodes()
528
+ for node in nodes:
529
+ if node.requires_grad:
530
+ node.grad = np.zeros_like(node.data)
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: leanpass
3
+ Version: 0.1.0
4
+ Summary: A lightweight, transparent alternative to PyTorch/TensorFlow built on NumPy.
5
+ Author: LeanPass
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: numpy
9
+
10
+ # leanpass
11
+
12
+ A minimal, transparent NumPy-based autograd library for small models.
13
+
14
+ ## Features
15
+
16
+ - `Tensor` with reverse-mode autodiff
17
+ - Operators: `+`, `-`, `*`, `/`, `**`, `@`
18
+ - Activations: `relu`, `sigmoid`, `softmax`
19
+ - `Linear` and `MLP` modules
20
+ - `SGD` and `Adam` optimizers
21
+ - Graph visualization via `Tensor.visualize_dot()`
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install .
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ ```python
32
+ from leanpass import Tensor, nn, optim
33
+
34
+ x = Tensor([[1.0, 2.0]], requires_grad=False)
35
+ model = nn.MLP([2, 16, 3])
36
+ logits = model(x)
37
+ ```
38
+
39
+ ## Run demo
40
+
41
+ ```bash
42
+ python demo.py
43
+ ```
44
+
45
+ ## Publish to PyPI
46
+
47
+ 1. Update `version` in `pyproject.toml`.
48
+ 2. Install build tools:
49
+
50
+ ```bash
51
+ python -m pip install --upgrade build twine
52
+ ```
53
+
54
+ 3. Build distribution files:
55
+
56
+ ```bash
57
+ python -m build
58
+ ```
59
+
60
+ 4. Upload to PyPI:
61
+
62
+ ```bash
63
+ python -m twine upload dist/*
64
+ ```
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.cfg
4
+ leanpass/__init__.py
5
+ leanpass/nn.py
6
+ leanpass/optim.py
7
+ leanpass/tensor.py
8
+ leanpass.egg-info/PKG-INFO
9
+ leanpass.egg-info/SOURCES.txt
10
+ leanpass.egg-info/dependency_links.txt
11
+ leanpass.egg-info/entry_points.txt
12
+ leanpass.egg-info/requires.txt
13
+ leanpass.egg-info/top_level.txt
14
+ tests/test_gradcheck.py
15
+ tests/test_leanpass.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ leanpass-demo = demo:main
@@ -0,0 +1 @@
1
+ numpy
@@ -0,0 +1 @@
1
+ leanpass
@@ -0,0 +1,11 @@
1
+ [project]
2
+ name = "leanpass"
3
+ version = "0.1.0"
4
+ description = "A lightweight, transparent alternative to PyTorch/TensorFlow built on NumPy."
5
+ authors = [{ name = "LeanPass" }]
6
+ readme = "README.md"
7
+ requires-python = ">=3.8"
8
+ dependencies = ["numpy"]
9
+
10
+ [project.scripts]
11
+ leanpass-demo = "demo:main"
@@ -0,0 +1,13 @@
1
+ [metadata]
2
+ name = leanpass
3
+ version = 0.1.0
4
+
5
+ [flake8]
6
+ max-line-length = 88
7
+ ignore = E203, W503
8
+ exclude = .git,__pycache__,build,dist,.venv
9
+
10
+ [egg_info]
11
+ tag_build =
12
+ tag_date = 0
13
+
@@ -0,0 +1,81 @@
1
+ import numpy as np
2
+
3
+ from leanpass import Tensor
4
+
5
+
6
+ def finite_difference_grad(f, x, eps=1e-6):
7
+ orig = x.data.copy()
8
+ grad = np.zeros_like(x.data)
9
+ for idx in np.ndindex(x.data.shape):
10
+ x.data[idx] = orig[idx] + eps
11
+ plus = f().data.copy()
12
+ x.data[idx] = orig[idx] - eps
13
+ minus = f().data.copy()
14
+ x.data[idx] = orig[idx]
15
+ grad[idx] = (plus - minus).sum() / (2 * eps)
16
+ return grad
17
+
18
+
19
+ def test_grad_check_add():
20
+ x = Tensor([1.0, 2.0], requires_grad=True)
21
+ y = Tensor([3.0, 4.0], requires_grad=True)
22
+ z = (x + y).sum()
23
+ z.backward()
24
+
25
+ assert np.allclose(x.grad, [1.0, 1.0])
26
+ assert np.allclose(y.grad, [1.0, 1.0])
27
+
28
+
29
+ def test_grad_check_power():
30
+ x = Tensor([2.0, 3.0], requires_grad=True)
31
+ z = (x ** 3).sum()
32
+ z.backward()
33
+
34
+ assert np.allclose(x.grad, [12.0, 27.0])
35
+
36
+
37
+ def test_grad_check_sigmoid():
38
+ x = Tensor([0.5, -0.5], requires_grad=True)
39
+ z = x.sigmoid().sum()
40
+ z.backward()
41
+
42
+ numeric = finite_difference_grad(lambda: x.sigmoid(), x)
43
+ assert np.allclose(x.grad, numeric, atol=1e-4)
44
+
45
+
46
+ def test_grad_check_exp_log():
47
+ x = Tensor([0.5, 1.0], requires_grad=True)
48
+ z = x.exp().log().sum()
49
+ z.backward()
50
+
51
+ numeric = finite_difference_grad(lambda: x.exp().log(), x)
52
+ assert np.allclose(x.grad, numeric, atol=1e-4)
53
+
54
+
55
+ def test_grad_check_softmax():
56
+ x = Tensor([[1.0, 2.0, 3.0]], requires_grad=True)
57
+ z = x.softmax().sum()
58
+ z.backward()
59
+
60
+ numeric = finite_difference_grad(lambda: x.softmax(), x)
61
+ assert np.allclose(x.grad, numeric, atol=1e-4)
62
+
63
+
64
+ def test_grad_check_graph_visualizer():
65
+ x = Tensor([1.0, 2.0], requires_grad=True, name="input")
66
+ y = (x * x + x.sigmoid()).sum()
67
+ graph = y.visualize()
68
+
69
+ assert "softmax" not in graph
70
+ assert "relu" not in graph
71
+ assert "input" in graph
72
+
73
+
74
+ def test_visualize_dot():
75
+ x = Tensor([1.0, 2.0], requires_grad=True, name="input")
76
+ y = (x * x + x.sigmoid()).sum()
77
+ dot = y.visualize_dot()
78
+
79
+ assert dot.startswith("digraph")
80
+ assert "->" in dot
81
+ assert "input" in dot
@@ -0,0 +1,43 @@
1
+ import numpy as np
2
+
3
+ from leanpass import Tensor, nn, optim
4
+
5
+
6
+ def test_tensor_basic_arithmetic():
7
+ x = Tensor([1.0, 2.0], requires_grad=True)
8
+ y = Tensor([3.0, 4.0], requires_grad=True)
9
+
10
+ z = x * y + x
11
+ z_sum = z.sum()
12
+ z_sum.backward()
13
+
14
+ assert np.allclose(x.grad, [4.0, 5.0])
15
+ assert np.allclose(y.grad, [1.0, 2.0])
16
+
17
+
18
+ def test_linear_forward_backward():
19
+ layer = nn.Linear(2, 1)
20
+ x = Tensor([[1.0, 2.0]], requires_grad=False)
21
+ y = layer(x)
22
+ loss = y.sum()
23
+ loss.backward()
24
+
25
+ assert layer.weight.grad.shape == layer.weight.data.shape
26
+ assert layer.bias.grad.shape == layer.bias.data.shape
27
+
28
+
29
+ def test_mlp_training_step():
30
+ model = nn.MLP([2, 4, 1])
31
+ optimizer = optim.SGD(model.parameters(), lr=0.1)
32
+
33
+ x = Tensor([[1.0, 1.0]], requires_grad=False)
34
+ y_true = Tensor([[2.0]], requires_grad=False)
35
+
36
+ pred = model(x)
37
+ loss = nn.mse_loss(pred, y_true)
38
+ model.zero_grad()
39
+ loss.backward()
40
+ optimizer.step()
41
+
42
+ assert all(param.grad is not None for param in model.parameters())
43
+ assert any(np.any(param.data != 0) for param in model.parameters())