cogforge-engine 2.1.1__tar.gz → 2.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cogforge-engine
3
- Version: 2.1.1
3
+ Version: 2.1.2
4
4
  Summary: A custom autograd engine and Transformer block built from scratch.
5
5
  Project-URL: Homepage, https://github.com/avikmjd2/cogforge
6
6
  Author-email: Avik Majumder <avikmjd2@gmail.com>
@@ -19,12 +19,15 @@ Description-Content-Type: text/markdown
19
19
 
20
20
  `cogforge` is a small, readable, educational deep learning framework. At its core is a `Tensor` that records every operation into a computation graph and backpropagates through it (micrograd-style), but unlike a toy autograd it scales up to real architectures: MLPs, RNNs, batch/layer normalization, multi-head self- and cross-attention, rotary position embeddings (RoPE), decoder-only GPTs, and a full encoder–decoder Seq2Seq transformer you can actually train and sample from.
21
21
 
22
- There is no C++, no PyTorch — just NumPy and explicit, hand-derived gradients. Optionally, the entire backend can be swapped to **CuPy** for GPU execution, or accelerated with **numexpr** on CPU, without changing any model code. The goal is still to *understand* every gradient that flows — speed is a bonus, not the point.
22
+ There is no C++, no PyTorch — just NumPy and explicit, hand-derived gradients. Optionally, the entire backend can be swapped to **CuPy** for GPU execution, or accelerated with **numexpr** on CPU, without changing any model code. Every tensor also records the operation that produced it, and the built-in **Graphviz visualizer** can render the full computation graph — data, gradients, and ops — so you can literally *see* backpropagation. The goal is still to *understand* every gradient that flows — speed is a bonus, not the point.
23
+
24
+ > **This release is a correctness pass.** Every gradient in the engine has now been verified against finite-difference numerical checks, and several subtle autograd bugs were found and fixed — see [What's new](#whats-new).
23
25
 
24
26
  ---
25
27
 
26
28
  ## Table of contents
27
29
 
30
+ - [What's new](#whats-new)
28
31
  - [Installation](#installation)
29
32
  - [Quick start](#quick-start)
30
33
  - [Backend: CPU, GPU, numexpr, and no-grad mode](#backend-cpu-gpu-numexpr-and-no-grad-mode)
@@ -42,12 +45,36 @@ There is no C++, no PyTorch — just NumPy and explicit, hand-derived gradients.
42
45
  - [Models](#models)
43
46
  - [Worked example: train a char-level GPT](#worked-example-train-a-char-level-gpt)
44
47
  - [Worked example: encoder–decoder Seq2Seq](#worked-example-encoderdecoder-seq2seq)
48
+ - [Visualizing the computation graph](#visualizing-the-computation-graph)
45
49
  - [Gotchas](#gotchas)
46
50
  - [Roadmap](#roadmap)
47
51
  - [License](#license)
48
52
 
49
53
  ---
50
54
 
55
+ ## What's new
56
+
57
+ This version focuses on **autograd correctness and debuggability**. All fixes below were validated with finite-difference gradient checks and end-to-end training tests.
58
+
59
+ **Gradient correctness fixes**
60
+
61
+ - **Broadcast gradients fully generalized.** `+`, `-`, and `*` previously mishandled un-broadcasting when shapes broadcast along an *interior* size-1 dimension (e.g. `(B, 1, C) + (B, T, C)` — exactly the shape of a padding mask or any `keepdims` tensor): depending on shapes this either crashed or silently accumulated wrong gradients. All elementwise ops now route through a single, verified `Tensor.unbroadcast`, matching `@`.
62
+ - **Indexing with repeated indices now accumulates.** `tensor[key]`'s backward used direct fancy-index assignment, which drops gradient contributions for duplicate indices due to NumPy write buffering. It now uses the backend-aware `scatter_add` (`np.add.at` on CPU, `cupyx.scatter_add` on GPU), so gathers with repeated indices — RoPE slicing, token lookups — backprop correctly.
63
+ - **`sparse_softmax_cross_entropy` backward rewritten.** The gradient is now written straight into `scores.grad` as `(softmax − onehot) / N` without copying or mutating the cached softmax output — faster, less memory, and verified against numerical gradients (max error ~1e-4).
64
+ - **`BatchNorm1D` is now safe to call multiple times before `backwards()`.** Backward state (`x_hat`, `std_inv`, the training flag) was previously stored on the module, so a second forward pass clobbered what the first pass's backward needed. Each call's backward closure now captures its own state — interleaved forwards produce bit-identical gradients to isolated ones.
65
+ - **`NO_GRAD` fast paths are consistent everywhere:** no op builds a graph node it's about to discard.
66
+
67
+ **Generation fixes**
68
+
69
+ - **`Seq2Seq.generate` handles per-sequence EOS.** Previously the loop only stopped if *every* row in the batch emitted `eos_id` on the *same step*, and finished rows kept sampling. Each row is now frozen to `pad_id` the moment it emits EOS, and the loop exits once all rows are done.
70
+
71
+ **New: graph visualization (`cogforge.utils`)**
72
+
73
+ - Every `Tensor` now carries a human-readable `.op` label (`"+"`, `"MATMUL"`, `"softmax"`, `"layernorm"`, …) recording the operation that produced it.
74
+ - `draw_graph(tensor)` renders the full computation graph via Graphviz — one record node per tensor showing its op, shape, data, and gradient. See [Visualizing the computation graph](#visualizing-the-computation-graph).
75
+
76
+ ---
77
+
51
78
  ## Installation
52
79
 
53
80
  ```bash
@@ -59,15 +86,17 @@ Requires Python 3.8+ and NumPy — that's the only hard dependency. Two optional
59
86
  ```bash
60
87
  pip install cupy-cuda12x # GPU backend (pick the build matching your CUDA version)
61
88
  pip install numexpr # multi-threaded CPU element-wise ops
89
+ pip install graphviz # computation-graph visualization (also needs the Graphviz system binaries)
62
90
  ```
63
91
 
64
- The package is organized into three modules:
92
+ The package is organized into four modules:
65
93
 
66
94
  | Module | Contains |
67
95
  | --- | --- |
68
96
  | `cogforge.backend` | The swappable array backend: NumPy ↔ CuPy switching, numexpr flag, global no-grad flag. |
69
97
  | `cogforge.app` | The autograd engine (`Tensor`) and every building block — layers, optimizers, losses, normalization, attention, positional encodings. |
70
98
  | `cogforge.models` | Ready-to-use models: `GPTV1`, `GPT2`, `Seq2Seq`. |
99
+ | `cogforge.utils` | Computation-graph tracing and Graphviz rendering (`trace_graph`, `draw_graph`). |
71
100
 
72
101
  ```python
73
102
  from cogforge.app import Tensor, Linear, Adam, MultiHeadAttention # building blocks
@@ -158,9 +187,12 @@ Tensor(array, children=(), requires_grad=True, typed="compressed")
158
187
  | `children` | Parent tensors in the graph (set internally by ops; you rarely pass this). |
159
188
  | `requires_grad` | Reserved flag (currently informational). |
160
189
  | `typed` | `"compressed"` → `float32` (default), anything else → `float64`. |
190
+ | `op` | Human-readable label of the operation that produced this tensor (`"+"`, `"MATMUL"`, `"softmax"`, …). Set internally by every op; used by the graph visualizer and handy when debugging. |
161
191
 
162
192
  Gradients **accumulate** into `.grad`. Always zero them between optimization steps (the optimizers do this for you via `zero_grad()`). When global no-grad mode is on, `.grad` is `None` and no graph is recorded.
163
193
 
194
+ > **The graph is single-use.** `backwards()` frees the graph as it goes (clearing each node's children and backward closure) to release memory eagerly. Run one forward → one backward per step, and if you want to [visualize the graph](#visualizing-the-computation-graph), draw it **before** calling `backwards()`.
195
+
164
196
  ---
165
197
 
166
198
  ## API reference
@@ -171,9 +203,9 @@ Gradients **accumulate** into `.grad`. Always zero them between optimization ste
171
203
 
172
204
  | Operation | Notes |
173
205
  | --- | --- |
174
- | `a + b`, `a - b`, `a * b` | Elementwise, with broadcasting support. |
206
+ | `a + b`, `a - b`, `a * b` | Elementwise, with full broadcasting support — gradients are correctly un-broadcast for leading *and* interior size-1 dimensions. |
175
207
  | `a @ b` | Batched matmul; gradients are correctly un-broadcast. |
176
- | `a[key]` | Indexing/slicing. |
208
+ | `a[key]` | Indexing/slicing. Backward uses backend-aware scatter-add, so **repeated indices accumulate gradients correctly** on CPU and GPU. |
177
209
  | `Tensor.cat(tensors, axis=-1)` | Classmethod. Concatenates a tuple of tensors along `axis`; backward splits the gradient back to each parent. (Used internally by RoPE.) |
178
210
  | `.relu()` | |
179
211
  | `.sigmoid()` | |
@@ -191,7 +223,7 @@ Gradients **accumulate** into `.grad`. Always zero them between optimization ste
191
223
 
192
224
  | Method | Notes |
193
225
  | --- | --- |
194
- | `.backwards()` | **Primary.** Iterative topological sort — safe for deep/long graphs. |
226
+ | `.backwards()` | **Primary.** Iterative topological sort — safe for deep/long graphs. Frees the graph as it runs (single-use; see the note under [Core concept](#core-concept-the-tensor)). |
195
227
  | `.backwards_recursive()` | Legacy recursive version; can hit Python's recursion limit on long sequences. Prefer `.backwards()`. |
196
228
 
197
229
  **Static helper**
@@ -231,7 +263,7 @@ Lookup table. Call with an integer index array; backward scatters gradients corr
231
263
  Normalizes over the last dimension. Learnable `gamma`/`beta`, full hand-derived backward. `.parameters()` → `[gamma, beta]`.
232
264
 
233
265
  #### `BatchNorm1D(dim, eps=1e-5, momentum=0.1)`
234
- Normalizes over the batch (and time, for 3-D input). Tracks `running_mean`/`running_var` for inference. Toggle `.training = True/False`. Learnable `gamma`/`beta`.
266
+ Normalizes over the batch (and time, for 3-D input). Tracks `running_mean`/`running_var` for inference. Toggle `.training = True/False`. Learnable `gamma`/`beta`. Each forward call captures its own backward state, so calling the layer multiple times before `backwards()` (e.g. gradient accumulation, shared modules) yields correct gradients for every call.
235
267
 
236
268
  #### `FeedForward(dmodel, dff=None)`
237
269
  Position-wise MLP: `Linear → ReLU → Dropout(0.15) → Linear`. `dff` defaults to `4 * dmodel`. Dropout is active only when called with `is_training=True` (transformer blocks handle this for you via their train/infer state).
@@ -380,7 +412,7 @@ A full **encoder–decoder transformer** (the original *Attention Is All You Nee
380
412
  | `model(enc_idx, dec_idx)` | Teacher-forced forward. `enc_idx`: source `(B, T_enc)`; `dec_idx`: shifted target starting with `<SOS>`, `(B, T_dec)`. Returns logits `(B, T_dec, dec_vocab)`. |
381
413
  | `model.encode(enc_idx)` | Run the encoder once; returns `(x_enc, enc_pad_mask)` for reuse across decode steps. |
382
414
  | `model.decode_step(dec_idx, x_enc, enc_pad)` | Decoder forward against a fixed encoder output. |
383
- | `model.generate(enc_idx, sos_id, eos_id=None, max_new=50, temperature=1.0, top_k=None)` | Encodes once, then autoregressively decodes from `<SOS>`; stops early when every sequence in the batch emits `eos_id`. |
415
+ | `model.generate(enc_idx, sos_id, eos_id=None, max_new=50, temperature=1.0, top_k=None)` | Encodes once, then autoregressively decodes from `<SOS>`. Tracks completion **per sequence**: the moment a row emits `eos_id` it is frozen and padded with `pad_id` for the remaining steps, and decoding stops early once every row has finished. |
384
416
  | `model.train()` / `model.infer()` | Toggle dropout across all encoder and decoder blocks. |
385
417
  | `Seq2Seq.make_pad_mask(idx, pad_id)` | Static helper: `(B, T)` ints → `(B, 1, 1, T)` boolean mask, `True` at padding. |
386
418
 
@@ -480,13 +512,41 @@ for step in range(num_steps):
480
512
  opt.clip_grads(1.0)
481
513
  opt.step()
482
514
 
483
- # inference: encode once, decode token by token, stop on EOS
515
+ # inference: encode once, decode token by token, stop on EOS (per sequence)
484
516
  out = model.generate(src, sos_id=SOS, eos_id=EOS, max_new=32,
485
517
  temperature=1.0, top_k=5)
486
518
  ```
487
519
 
488
520
  ---
489
521
 
522
+ ## Visualizing the computation graph
523
+
524
+ `cogforge.utils` renders the autograd graph with Graphviz — every node shows the op that produced it, its shape, its data, and its gradient. It's the fastest way to *see* what your model is doing and to debug shape or gradient-flow issues.
525
+
526
+ ```python
527
+ import numpy as np
528
+ from cogforge.app import Tensor, Linear
529
+ from cogforge.utils import draw_graph
530
+
531
+ x = Tensor(np.random.randn(2, 4))
532
+ lin = Linear(4, 3)
533
+ out = lin(x).relu().softmax()
534
+
535
+ dot = draw_graph(out, rankdir='LR') # draw BEFORE backwards() — the graph is freed by it
536
+ dot.render('graph', view=True) # writes graph.svg and opens it
537
+ ```
538
+
539
+ | Function | Purpose |
540
+ | --- | --- |
541
+ | `trace_graph(root_tensor)` | Walks the graph from a root tensor; returns `(nodes, edges)`. |
542
+ | `draw_graph(root_tensor, max_char=50, format='svg', rankdir='LR')` | Builds a Graphviz `Digraph`. `max_char` truncates long data/grad strings; `format` is any Graphviz output format (`svg`, `png`, `pdf`); `rankdir='TB'` for top-to-bottom layout. |
543
+
544
+ Nodes produced by a named op render white with the op label; raw leaf tensors (parameters, inputs) render light blue. Requires the `graphviz` Python package **and** the Graphviz system binaries (`apt install graphviz` / `brew install graphviz`).
545
+
546
+ Two practical notes: call `draw_graph` **before** `backwards()`, since the backward pass frees the graph as it runs (afterwards you'll see a single orphan node); and keep the visualized graph small — a full transformer forward produces hundreds of nodes, so draw a single block or a toy input rather than a whole training step.
547
+
548
+ ---
549
+
490
550
  ## Gotchas
491
551
 
492
552
  - **It's `backwards()`, not `backward()`.** The backward pass method has a trailing `s`.
@@ -498,12 +558,21 @@ out = model.generate(src, sos_id=SOS, eos_id=EOS, max_new=32,
498
558
  - **`Sequential.parameters()` is shallow** — see the note under [Containers](#containers). For attention/feed-forward/transformer stacks, gather parameters through each module's own `.parameters()` (as the models' `parameters()` methods do).
499
559
  - **RNNs operate on lists**, not a single `(B, T, C)` tensor — pass a list of per-timestep tensors.
500
560
  - **Don't dedupe tied parameters yourself.** `Seq2Seq.parameters()` already deduplicates shared tensors by identity, so the tied embedding is only updated once per step.
561
+ - **The graph is single-use.** `backwards()` frees children and backward closures as it runs. One forward → one backward. Calling `backwards()` twice on the same graph is a silent no-op the second time, and `draw_graph` must be called before, not after.
562
+ - **Visualize small graphs.** `draw_graph` on a full model forward will produce an unreadable diagram with hundreds of nodes; visualize a single layer or block instead.
501
563
 
502
564
  ---
503
565
 
504
566
  ## Roadmap
505
567
 
506
- Shipped since the last release:
568
+ Shipped in this release:
569
+
570
+ - ✅ **Autograd correctness pass** — all gradients verified against finite-difference checks; broadcast, indexing, sparse cross-entropy, and BatchNorm backward bugs fixed (see [What's new](#whats-new))
571
+ - ✅ Computation-graph visualization via Graphviz (`cogforge.utils`), with per-tensor `op` labels
572
+ - ✅ Per-sequence EOS handling in `Seq2Seq.generate` (finished rows freeze to `pad_id`)
573
+ - ✅ Eager graph freeing in `backwards()` for lower peak memory
574
+
575
+ Shipped previously:
507
576
 
508
577
  - ✅ RoPE (rotary position embeddings), usable in GPT and at every attention site of the Seq2Seq model
509
578
  - ✅ Full encoder–decoder transformer (`Seq2Seq`) with cross-attention and padding masks
@@ -514,14 +583,18 @@ Shipped since the last release:
514
583
 
515
584
  Planned / under consideration:
516
585
 
586
+ - Lazy Gradient Allocation (LGA) for lower peak memory on long sequences
587
+ - Cosine LR function as part of UTILS module
588
+ - Weight decay / AdamW optimizer
589
+ - Checkpoint save/load (`state_dict` / `load_state_dict`) for the transformer models, including optimizer state
590
+ - `no_grad()` context manager and a shared `Module` base class
517
591
  - KV cache for faster generation
518
592
  - SwiGLU feed-forward and RMSNorm
519
593
  - RoPE length interpolation
520
594
  - Linear-attention block (as a study in the recall-vs-cost tradeoff)
521
- - Checkpoint save/load for the transformer models
522
595
 
523
596
  ---
524
597
 
525
598
  ## License
526
599
 
527
- MIT License. See [LICENSE](LICENSE) for details.
600
+ APACHE License. See [LICENSE](LICENSE) for details.
@@ -4,12 +4,15 @@
4
4
 
5
5
  `cogforge` is a small, readable, educational deep learning framework. At its core is a `Tensor` that records every operation into a computation graph and backpropagates through it (micrograd-style), but unlike a toy autograd it scales up to real architectures: MLPs, RNNs, batch/layer normalization, multi-head self- and cross-attention, rotary position embeddings (RoPE), decoder-only GPTs, and a full encoder–decoder Seq2Seq transformer you can actually train and sample from.
6
6
 
7
- There is no C++, no PyTorch — just NumPy and explicit, hand-derived gradients. Optionally, the entire backend can be swapped to **CuPy** for GPU execution, or accelerated with **numexpr** on CPU, without changing any model code. The goal is still to *understand* every gradient that flows — speed is a bonus, not the point.
7
+ There is no C++, no PyTorch — just NumPy and explicit, hand-derived gradients. Optionally, the entire backend can be swapped to **CuPy** for GPU execution, or accelerated with **numexpr** on CPU, without changing any model code. Every tensor also records the operation that produced it, and the built-in **Graphviz visualizer** can render the full computation graph — data, gradients, and ops — so you can literally *see* backpropagation. The goal is still to *understand* every gradient that flows — speed is a bonus, not the point.
8
+
9
+ > **This release is a correctness pass.** Every gradient in the engine has now been verified against finite-difference numerical checks, and several subtle autograd bugs were found and fixed — see [What's new](#whats-new).
8
10
 
9
11
  ---
10
12
 
11
13
  ## Table of contents
12
14
 
15
+ - [What's new](#whats-new)
13
16
  - [Installation](#installation)
14
17
  - [Quick start](#quick-start)
15
18
  - [Backend: CPU, GPU, numexpr, and no-grad mode](#backend-cpu-gpu-numexpr-and-no-grad-mode)
@@ -27,12 +30,36 @@ There is no C++, no PyTorch — just NumPy and explicit, hand-derived gradients.
27
30
  - [Models](#models)
28
31
  - [Worked example: train a char-level GPT](#worked-example-train-a-char-level-gpt)
29
32
  - [Worked example: encoder–decoder Seq2Seq](#worked-example-encoderdecoder-seq2seq)
33
+ - [Visualizing the computation graph](#visualizing-the-computation-graph)
30
34
  - [Gotchas](#gotchas)
31
35
  - [Roadmap](#roadmap)
32
36
  - [License](#license)
33
37
 
34
38
  ---
35
39
 
40
+ ## What's new
41
+
42
+ This version focuses on **autograd correctness and debuggability**. All fixes below were validated with finite-difference gradient checks and end-to-end training tests.
43
+
44
+ **Gradient correctness fixes**
45
+
46
+ - **Broadcast gradients fully generalized.** `+`, `-`, and `*` previously mishandled un-broadcasting when shapes broadcast along an *interior* size-1 dimension (e.g. `(B, 1, C) + (B, T, C)` — exactly the shape of a padding mask or any `keepdims` tensor): depending on shapes this either crashed or silently accumulated wrong gradients. All elementwise ops now route through a single, verified `Tensor.unbroadcast`, matching `@`.
47
+ - **Indexing with repeated indices now accumulates.** `tensor[key]`'s backward used direct fancy-index assignment, which drops gradient contributions for duplicate indices due to NumPy write buffering. It now uses the backend-aware `scatter_add` (`np.add.at` on CPU, `cupyx.scatter_add` on GPU), so gathers with repeated indices — RoPE slicing, token lookups — backprop correctly.
48
+ - **`sparse_softmax_cross_entropy` backward rewritten.** The gradient is now written straight into `scores.grad` as `(softmax − onehot) / N` without copying or mutating the cached softmax output — faster, less memory, and verified against numerical gradients (max error ~1e-4).
49
+ - **`BatchNorm1D` is now safe to call multiple times before `backwards()`.** Backward state (`x_hat`, `std_inv`, the training flag) was previously stored on the module, so a second forward pass clobbered what the first pass's backward needed. Each call's backward closure now captures its own state — interleaved forwards produce bit-identical gradients to isolated ones.
50
+ - **`NO_GRAD` fast paths are consistent everywhere:** no op builds a graph node it's about to discard.
51
+
52
+ **Generation fixes**
53
+
54
+ - **`Seq2Seq.generate` handles per-sequence EOS.** Previously the loop only stopped if *every* row in the batch emitted `eos_id` on the *same step*, and finished rows kept sampling. Each row is now frozen to `pad_id` the moment it emits EOS, and the loop exits once all rows are done.
55
+
56
+ **New: graph visualization (`cogforge.utils`)**
57
+
58
+ - Every `Tensor` now carries a human-readable `.op` label (`"+"`, `"MATMUL"`, `"softmax"`, `"layernorm"`, …) recording the operation that produced it.
59
+ - `draw_graph(tensor)` renders the full computation graph via Graphviz — one record node per tensor showing its op, shape, data, and gradient. See [Visualizing the computation graph](#visualizing-the-computation-graph).
60
+
61
+ ---
62
+
36
63
  ## Installation
37
64
 
38
65
  ```bash
@@ -44,15 +71,17 @@ Requires Python 3.8+ and NumPy — that's the only hard dependency. Two optional
44
71
  ```bash
45
72
  pip install cupy-cuda12x # GPU backend (pick the build matching your CUDA version)
46
73
  pip install numexpr # multi-threaded CPU element-wise ops
74
+ pip install graphviz # computation-graph visualization (also needs the Graphviz system binaries)
47
75
  ```
48
76
 
49
- The package is organized into three modules:
77
+ The package is organized into four modules:
50
78
 
51
79
  | Module | Contains |
52
80
  | --- | --- |
53
81
  | `cogforge.backend` | The swappable array backend: NumPy ↔ CuPy switching, numexpr flag, global no-grad flag. |
54
82
  | `cogforge.app` | The autograd engine (`Tensor`) and every building block — layers, optimizers, losses, normalization, attention, positional encodings. |
55
83
  | `cogforge.models` | Ready-to-use models: `GPTV1`, `GPT2`, `Seq2Seq`. |
84
+ | `cogforge.utils` | Computation-graph tracing and Graphviz rendering (`trace_graph`, `draw_graph`). |
56
85
 
57
86
  ```python
58
87
  from cogforge.app import Tensor, Linear, Adam, MultiHeadAttention # building blocks
@@ -143,9 +172,12 @@ Tensor(array, children=(), requires_grad=True, typed="compressed")
143
172
  | `children` | Parent tensors in the graph (set internally by ops; you rarely pass this). |
144
173
  | `requires_grad` | Reserved flag (currently informational). |
145
174
  | `typed` | `"compressed"` → `float32` (default), anything else → `float64`. |
175
+ | `op` | Human-readable label of the operation that produced this tensor (`"+"`, `"MATMUL"`, `"softmax"`, …). Set internally by every op; used by the graph visualizer and handy when debugging. |
146
176
 
147
177
  Gradients **accumulate** into `.grad`. Always zero them between optimization steps (the optimizers do this for you via `zero_grad()`). When global no-grad mode is on, `.grad` is `None` and no graph is recorded.
148
178
 
179
+ > **The graph is single-use.** `backwards()` frees the graph as it goes (clearing each node's children and backward closure) to release memory eagerly. Run one forward → one backward per step, and if you want to [visualize the graph](#visualizing-the-computation-graph), draw it **before** calling `backwards()`.
180
+
149
181
  ---
150
182
 
151
183
  ## API reference
@@ -156,9 +188,9 @@ Gradients **accumulate** into `.grad`. Always zero them between optimization ste
156
188
 
157
189
  | Operation | Notes |
158
190
  | --- | --- |
159
- | `a + b`, `a - b`, `a * b` | Elementwise, with broadcasting support. |
191
+ | `a + b`, `a - b`, `a * b` | Elementwise, with full broadcasting support — gradients are correctly un-broadcast for leading *and* interior size-1 dimensions. |
160
192
  | `a @ b` | Batched matmul; gradients are correctly un-broadcast. |
161
- | `a[key]` | Indexing/slicing. |
193
+ | `a[key]` | Indexing/slicing. Backward uses backend-aware scatter-add, so **repeated indices accumulate gradients correctly** on CPU and GPU. |
162
194
  | `Tensor.cat(tensors, axis=-1)` | Classmethod. Concatenates a tuple of tensors along `axis`; backward splits the gradient back to each parent. (Used internally by RoPE.) |
163
195
  | `.relu()` | |
164
196
  | `.sigmoid()` | |
@@ -176,7 +208,7 @@ Gradients **accumulate** into `.grad`. Always zero them between optimization ste
176
208
 
177
209
  | Method | Notes |
178
210
  | --- | --- |
179
- | `.backwards()` | **Primary.** Iterative topological sort — safe for deep/long graphs. |
211
+ | `.backwards()` | **Primary.** Iterative topological sort — safe for deep/long graphs. Frees the graph as it runs (single-use; see the note under [Core concept](#core-concept-the-tensor)). |
180
212
  | `.backwards_recursive()` | Legacy recursive version; can hit Python's recursion limit on long sequences. Prefer `.backwards()`. |
181
213
 
182
214
  **Static helper**
@@ -216,7 +248,7 @@ Lookup table. Call with an integer index array; backward scatters gradients corr
216
248
  Normalizes over the last dimension. Learnable `gamma`/`beta`, full hand-derived backward. `.parameters()` → `[gamma, beta]`.
217
249
 
218
250
  #### `BatchNorm1D(dim, eps=1e-5, momentum=0.1)`
219
- Normalizes over the batch (and time, for 3-D input). Tracks `running_mean`/`running_var` for inference. Toggle `.training = True/False`. Learnable `gamma`/`beta`.
251
+ Normalizes over the batch (and time, for 3-D input). Tracks `running_mean`/`running_var` for inference. Toggle `.training = True/False`. Learnable `gamma`/`beta`. Each forward call captures its own backward state, so calling the layer multiple times before `backwards()` (e.g. gradient accumulation, shared modules) yields correct gradients for every call.
220
252
 
221
253
  #### `FeedForward(dmodel, dff=None)`
222
254
  Position-wise MLP: `Linear → ReLU → Dropout(0.15) → Linear`. `dff` defaults to `4 * dmodel`. Dropout is active only when called with `is_training=True` (transformer blocks handle this for you via their train/infer state).
@@ -365,7 +397,7 @@ A full **encoder–decoder transformer** (the original *Attention Is All You Nee
365
397
  | `model(enc_idx, dec_idx)` | Teacher-forced forward. `enc_idx`: source `(B, T_enc)`; `dec_idx`: shifted target starting with `<SOS>`, `(B, T_dec)`. Returns logits `(B, T_dec, dec_vocab)`. |
366
398
  | `model.encode(enc_idx)` | Run the encoder once; returns `(x_enc, enc_pad_mask)` for reuse across decode steps. |
367
399
  | `model.decode_step(dec_idx, x_enc, enc_pad)` | Decoder forward against a fixed encoder output. |
368
- | `model.generate(enc_idx, sos_id, eos_id=None, max_new=50, temperature=1.0, top_k=None)` | Encodes once, then autoregressively decodes from `<SOS>`; stops early when every sequence in the batch emits `eos_id`. |
400
+ | `model.generate(enc_idx, sos_id, eos_id=None, max_new=50, temperature=1.0, top_k=None)` | Encodes once, then autoregressively decodes from `<SOS>`. Tracks completion **per sequence**: the moment a row emits `eos_id` it is frozen and padded with `pad_id` for the remaining steps, and decoding stops early once every row has finished. |
369
401
  | `model.train()` / `model.infer()` | Toggle dropout across all encoder and decoder blocks. |
370
402
  | `Seq2Seq.make_pad_mask(idx, pad_id)` | Static helper: `(B, T)` ints → `(B, 1, 1, T)` boolean mask, `True` at padding. |
371
403
 
@@ -465,13 +497,41 @@ for step in range(num_steps):
465
497
  opt.clip_grads(1.0)
466
498
  opt.step()
467
499
 
468
- # inference: encode once, decode token by token, stop on EOS
500
+ # inference: encode once, decode token by token, stop on EOS (per sequence)
469
501
  out = model.generate(src, sos_id=SOS, eos_id=EOS, max_new=32,
470
502
  temperature=1.0, top_k=5)
471
503
  ```
472
504
 
473
505
  ---
474
506
 
507
+ ## Visualizing the computation graph
508
+
509
+ `cogforge.utils` renders the autograd graph with Graphviz — every node shows the op that produced it, its shape, its data, and its gradient. It's the fastest way to *see* what your model is doing and to debug shape or gradient-flow issues.
510
+
511
+ ```python
512
+ import numpy as np
513
+ from cogforge.app import Tensor, Linear
514
+ from cogforge.utils import draw_graph
515
+
516
+ x = Tensor(np.random.randn(2, 4))
517
+ lin = Linear(4, 3)
518
+ out = lin(x).relu().softmax()
519
+
520
+ dot = draw_graph(out, rankdir='LR') # draw BEFORE backwards() — the graph is freed by it
521
+ dot.render('graph', view=True) # writes graph.svg and opens it
522
+ ```
523
+
524
+ | Function | Purpose |
525
+ | --- | --- |
526
+ | `trace_graph(root_tensor)` | Walks the graph from a root tensor; returns `(nodes, edges)`. |
527
+ | `draw_graph(root_tensor, max_char=50, format='svg', rankdir='LR')` | Builds a Graphviz `Digraph`. `max_char` truncates long data/grad strings; `format` is any Graphviz output format (`svg`, `png`, `pdf`); `rankdir='TB'` for top-to-bottom layout. |
528
+
529
+ Nodes produced by a named op render white with the op label; raw leaf tensors (parameters, inputs) render light blue. Requires the `graphviz` Python package **and** the Graphviz system binaries (`apt install graphviz` / `brew install graphviz`).
530
+
531
+ Two practical notes: call `draw_graph` **before** `backwards()`, since the backward pass frees the graph as it runs (afterwards you'll see a single orphan node); and keep the visualized graph small — a full transformer forward produces hundreds of nodes, so draw a single block or a toy input rather than a whole training step.
532
+
533
+ ---
534
+
475
535
  ## Gotchas
476
536
 
477
537
  - **It's `backwards()`, not `backward()`.** The backward pass method has a trailing `s`.
@@ -483,12 +543,21 @@ out = model.generate(src, sos_id=SOS, eos_id=EOS, max_new=32,
483
543
  - **`Sequential.parameters()` is shallow** — see the note under [Containers](#containers). For attention/feed-forward/transformer stacks, gather parameters through each module's own `.parameters()` (as the models' `parameters()` methods do).
484
544
  - **RNNs operate on lists**, not a single `(B, T, C)` tensor — pass a list of per-timestep tensors.
485
545
  - **Don't dedupe tied parameters yourself.** `Seq2Seq.parameters()` already deduplicates shared tensors by identity, so the tied embedding is only updated once per step.
546
+ - **The graph is single-use.** `backwards()` frees children and backward closures as it runs. One forward → one backward. Calling `backwards()` twice on the same graph is a silent no-op the second time, and `draw_graph` must be called before, not after.
547
+ - **Visualize small graphs.** `draw_graph` on a full model forward will produce an unreadable diagram with hundreds of nodes; visualize a single layer or block instead.
486
548
 
487
549
  ---
488
550
 
489
551
  ## Roadmap
490
552
 
491
- Shipped since the last release:
553
+ Shipped in this release:
554
+
555
+ - ✅ **Autograd correctness pass** — all gradients verified against finite-difference checks; broadcast, indexing, sparse cross-entropy, and BatchNorm backward bugs fixed (see [What's new](#whats-new))
556
+ - ✅ Computation-graph visualization via Graphviz (`cogforge.utils`), with per-tensor `op` labels
557
+ - ✅ Per-sequence EOS handling in `Seq2Seq.generate` (finished rows freeze to `pad_id`)
558
+ - ✅ Eager graph freeing in `backwards()` for lower peak memory
559
+
560
+ Shipped previously:
492
561
 
493
562
  - ✅ RoPE (rotary position embeddings), usable in GPT and at every attention site of the Seq2Seq model
494
563
  - ✅ Full encoder–decoder transformer (`Seq2Seq`) with cross-attention and padding masks
@@ -499,14 +568,18 @@ Shipped since the last release:
499
568
 
500
569
  Planned / under consideration:
501
570
 
571
+ - Lazy Gradient Allocation (LGA) for lower peak memory on long sequences
572
+ - Cosine LR function as part of UTILS module
573
+ - Weight decay / AdamW optimizer
574
+ - Checkpoint save/load (`state_dict` / `load_state_dict`) for the transformer models, including optimizer state
575
+ - `no_grad()` context manager and a shared `Module` base class
502
576
  - KV cache for faster generation
503
577
  - SwiGLU feed-forward and RMSNorm
504
578
  - RoPE length interpolation
505
579
  - Linear-attention block (as a study in the recall-vs-cost tradeoff)
506
- - Checkpoint save/load for the transformer models
507
580
 
508
581
  ---
509
582
 
510
583
  ## License
511
584
 
512
- MIT License. See [LICENSE](LICENSE) for details.
585
+ APACHE License. See [LICENSE](LICENSE) for details.
@@ -857,49 +857,46 @@ class BatchNorm1D:
857
857
 
858
858
  def __call__(self, x: Tensor):
859
859
  reduce_dims = (0,) if x.data.ndim == 2 else (0, 1)
860
-
860
+
861
861
  if self.training:
862
862
  mean = x.data.mean(axis=reduce_dims, keepdims=True)
863
- var = x.data.var(axis=reduce_dims, keepdims=True)
864
- x_centered = x.data - mean
863
+ var = x.data.var(axis=reduce_dims, keepdims=True)
864
+
865
+ x_centered = x.data - mean # local, not self.
865
866
  std_inv = 1.0 / backend.np.sqrt(var + self.eps)
866
867
  x_hat = x_centered * std_inv
867
-
868
+
868
869
  N = numpy.prod([x.data.shape[d] for d in reduce_dims])
869
870
  unbiased_var = var.squeeze() * (N / (N - 1)) if N > 1 else var.squeeze()
870
-
871
871
  self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * mean.squeeze()
872
- self.running_var = (1 - self.momentum) * self.running_var + self.momentum * unbiased_var
873
-
872
+ self.running_var = (1 - self.momentum) * self.running_var + self.momentum * unbiased_var
874
873
  else:
875
- std_inv = 1.0 / backend.np.sqrt(self.running_var + self.eps)
876
- x_hat = (x.data - self.running_mean) * std_inv
877
-
874
+ std_inv = None
875
+ x_hat = (x.data - self.running_mean) / backend.np.sqrt(self.running_var + self.eps)
876
+
878
877
  out_data = self.gamma.data * x_hat + self.beta.data
879
878
  if backend.NO_GRAD: return Tensor(out_data, op="BatchNorm")
880
879
  out = Tensor(out_data, children=(x, self.gamma, self.beta), op="BatchNorm")
881
-
880
+
881
+ was_training = self.training
882
+
882
883
  def _backward():
883
- if not self.training:
884
+ if not was_training:
884
885
  return
885
-
886
886
  dout = out.grad
887
887
  self.gamma.grad += backend.np.sum(dout * x_hat, axis=reduce_dims)
888
- self.beta.grad += backend.np.sum(dout, axis=reduce_dims)
889
-
888
+ self.beta.grad += backend.np.sum(dout, axis=reduce_dims)
890
889
  N = numpy.prod([x.data.shape[d] for d in reduce_dims])
891
-
892
890
  dx_hat = dout * self.gamma.data
893
891
  dx = (1.0 / N) * std_inv * (
894
- N * dx_hat
895
- - backend.np.sum(dx_hat, axis=reduce_dims, keepdims=True)
892
+ N * dx_hat
893
+ - backend.np.sum(dx_hat, axis=reduce_dims, keepdims=True)
896
894
  - x_hat * backend.np.sum(dx_hat * x_hat, axis=reduce_dims, keepdims=True)
895
+ # - x_hat * backend.np.sum(dx_hat * self.x_hat if False else dx_hat * x_hat, axis=reduce_dims, keepdims=True)
897
896
  )
898
-
899
897
  x.grad += dx
900
-
898
+
901
899
  out._backwards = _backward
902
-
903
900
  return out
904
901
 
905
902
 
@@ -233,8 +233,10 @@ class Seq2Seq:
233
233
  original_grad_state = not backend.NO_GRAD
234
234
  needGradientHence(False)
235
235
  try:
236
- x_enc, enc_pad = self.encode(enc_idx) # encode once
236
+ x_enc, enc_pad = self.encode(enc_idx)
237
237
  dec_idx = numpy.full((B, 1), sos_id, dtype=numpy.int64)
238
+ finished = numpy.zeros(B, dtype=bool) # NEW
239
+
238
240
  for _ in range(max_new):
239
241
  logits = to_cpu(self.decode_step(dec_idx, x_enc, enc_pad).data)[:, -1, :] / temperature
240
242
  if top_k is not None:
@@ -243,9 +245,14 @@ class Seq2Seq:
243
245
  z = logits - logits.max(-1, keepdims=True)
244
246
  p = numpy.exp(z).astype(numpy.float64); p /= p.sum(-1, keepdims=True)
245
247
  nxt = numpy.array([[numpy.random.choice(len(pr), p=pr)] for pr in p])
248
+
249
+ nxt[finished, 0] = self.pad_id # NEW: freeze finished rows
246
250
  dec_idx = numpy.concatenate([dec_idx, nxt], axis=1)
247
- if eos_id is not None and bool((nxt[:, 0] == eos_id).all()):
248
- break
251
+
252
+ if eos_id is not None:
253
+ finished |= (nxt[:, 0] == eos_id) # NEW: mark newly finished
254
+ if finished.all(): # NEW: stop when everyone's done
255
+ break
249
256
  return dec_idx
250
257
  finally:
251
258
  if was_training:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cogforge-engine"
7
- version = "2.1.1"
7
+ version = "2.1.2"
8
8
  authors = [
9
9
  { name="Avik Majumder", email="avikmjd2@gmail.com" },
10
10
  ]
File without changes
File without changes