shapelens 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,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ .ipynb_checkpoints/
4
+ .ruff_cache/
5
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nhanncv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: shapelens
3
+ Version: 0.1.0
4
+ Summary: See tensor shapes as boxes: trace PyTorch ops and draw them in Jupyter notebooks.
5
+ Project-URL: Homepage, https://github.com/hiimnhan/shapelens
6
+ Project-URL: Repository, https://github.com/hiimnhan/shapelens
7
+ Author: nhanncv
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: debugging,pytorch,shape,tensor,visualization
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Topic :: Software Development :: Debuggers
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: torch>=2.0.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # shapelens
21
+
22
+ See tensor shapes as boxes. Wrap a block of PyTorch code and get, per source line, the input
23
+ tensors, the op, and the output tensor drawn as rectangles, with your variable names and
24
+ dimension labels (`B`, `T`, `D`, ...).
25
+
26
+ ![ViT patchify traced with shapelens: each source line, its input tensors, the op, and the output tensor drawn as boxes](https://raw.githubusercontent.com/hiimnhan/shapelens/main/docs/vit-patchify.png)
27
+
28
+ *A ViT patch embedding, traced: image `(B, C, H, W)` becomes tokens `(B, N+1, D)`.*
29
+
30
+ Built for Jupyter notebooks. In a cell:
31
+
32
+ ```python
33
+ import shapelens as sl
34
+
35
+ with sl.Trace() as t:
36
+ out = attention(x, Wq, Wk, Wv, H=4)
37
+ t # last expression in the cell renders the boxes inline
38
+ ```
39
+
40
+ - **Shape errors:** if an op fails inside the block, the trace is displayed immediately with the
41
+ failing line highlighted in red, then the original error still propagates.
42
+ - `print(t)` gives a plain-text version of the same trace.
43
+
44
+ ![A failing matmul highlighted in red: A has last dim 32 but B has first dim 64](https://raw.githubusercontent.com/hiimnhan/shapelens/main/docs/shape-error.png)
45
+
46
+ ## Drawing rules
47
+
48
+ - Last two dims are the rectangle (rows x cols); the third-from-last is the cuboid depth;
49
+ any leading dims are a label plus ghost copies behind.
50
+ - Sides are log-scaled, so relative sizes are not to scale.
51
+ - Dims of equal size share a color.
52
+
53
+ ## Dimension labels
54
+
55
+ Any UPPERCASE `int` variable in your code (`B = 2`, `D = 32`) labels every dim of that size.
56
+ Add more by hand: `sl.Trace(S=197)`. Labels go by size, so equal sizes show every name (`H/W`).
57
+
58
+ ## Tip
59
+
60
+ Use `device="meta"` tensors to see shapes without any compute or memory.
61
+
62
+ ## Limits
63
+
64
+ PyTorch only. Smoke-tested on torch 2.0.1 to 2.8.0 and 2.14.1 (Python 3.10, macOS arm64); older
65
+ versions are untested. The tracer keeps references to op outputs while tracing, so prefer `meta`
66
+ tensors for big models. Only errors raised inside a torch op are highlighted.
67
+
68
+ ## Development
69
+
70
+ ```
71
+ uv sync # installs the project plus jupyter/nbconvert (dev group)
72
+ uv run python test_shapelens.py # smoke test (no pytest needed)
73
+ uv run jupyter lab # open demo.ipynb: attention, ViT patchify, and a shape-bug cell
74
+ uv build # wheel + sdist into dist/
75
+ ```
@@ -0,0 +1,56 @@
1
+ # shapelens
2
+
3
+ See tensor shapes as boxes. Wrap a block of PyTorch code and get, per source line, the input
4
+ tensors, the op, and the output tensor drawn as rectangles, with your variable names and
5
+ dimension labels (`B`, `T`, `D`, ...).
6
+
7
+ ![ViT patchify traced with shapelens: each source line, its input tensors, the op, and the output tensor drawn as boxes](https://raw.githubusercontent.com/hiimnhan/shapelens/main/docs/vit-patchify.png)
8
+
9
+ *A ViT patch embedding, traced: image `(B, C, H, W)` becomes tokens `(B, N+1, D)`.*
10
+
11
+ Built for Jupyter notebooks. In a cell:
12
+
13
+ ```python
14
+ import shapelens as sl
15
+
16
+ with sl.Trace() as t:
17
+ out = attention(x, Wq, Wk, Wv, H=4)
18
+ t # last expression in the cell renders the boxes inline
19
+ ```
20
+
21
+ - **Shape errors:** if an op fails inside the block, the trace is displayed immediately with the
22
+ failing line highlighted in red, then the original error still propagates.
23
+ - `print(t)` gives a plain-text version of the same trace.
24
+
25
+ ![A failing matmul highlighted in red: A has last dim 32 but B has first dim 64](https://raw.githubusercontent.com/hiimnhan/shapelens/main/docs/shape-error.png)
26
+
27
+ ## Drawing rules
28
+
29
+ - Last two dims are the rectangle (rows x cols); the third-from-last is the cuboid depth;
30
+ any leading dims are a label plus ghost copies behind.
31
+ - Sides are log-scaled, so relative sizes are not to scale.
32
+ - Dims of equal size share a color.
33
+
34
+ ## Dimension labels
35
+
36
+ Any UPPERCASE `int` variable in your code (`B = 2`, `D = 32`) labels every dim of that size.
37
+ Add more by hand: `sl.Trace(S=197)`. Labels go by size, so equal sizes show every name (`H/W`).
38
+
39
+ ## Tip
40
+
41
+ Use `device="meta"` tensors to see shapes without any compute or memory.
42
+
43
+ ## Limits
44
+
45
+ PyTorch only. Smoke-tested on torch 2.0.1 to 2.8.0 and 2.14.1 (Python 3.10, macOS arm64); older
46
+ versions are untested. The tracer keeps references to op outputs while tracing, so prefer `meta`
47
+ tensors for big models. Only errors raised inside a torch op are highlighted.
48
+
49
+ ## Development
50
+
51
+ ```
52
+ uv sync # installs the project plus jupyter/nbconvert (dev group)
53
+ uv run python test_shapelens.py # smoke test (no pytest needed)
54
+ uv run jupyter lab # open demo.ipynb: attention, ViT patchify, and a shape-bug cell
55
+ uv build # wheel + sdist into dist/
56
+ ```
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "shapelens"
3
+ version = "0.1.0"
4
+ description = "See tensor shapes as boxes: trace PyTorch ops and draw them in Jupyter notebooks."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "nhanncv" }]
9
+ requires-python = ">=3.10"
10
+ keywords = ["pytorch", "tensor", "shape", "visualization", "debugging"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: Science/Research",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ "Topic :: Software Development :: Debuggers",
17
+ ]
18
+ dependencies = [
19
+ "torch>=2.0.1",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/hiimnhan/shapelens"
24
+ Repository = "https://github.com/hiimnhan/shapelens"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "jupyter>=1.1.1",
29
+ "nbconvert>=7.17.2",
30
+ "numpy>=2.2.6",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ # single-file module: shapelens.py at the project root
38
+ [tool.hatch.build.targets.wheel]
39
+ include = ["shapelens.py"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["shapelens.py", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,192 @@
1
+ """shapelens: trace tensor shapes in a code block, render in Jupyter.
2
+
3
+ import shapelens as sl
4
+ with sl.Trace() as t:
5
+ out = attention(x)
6
+ t # last expression in a cell -> HTML table
7
+ """
8
+ import html
9
+ import linecache
10
+ import math
11
+ import sys
12
+
13
+ import torch
14
+ from torch.utils._python_dispatch import TorchDispatchMode
15
+
16
+ _SKIP = ("torch/", "contextlib.py", "/IPython/", "/ipykernel/")
17
+ _NOISE = {"view", "_unsafe_view", "expand", "clone", "reshape", "_reshape_alias", "detach", "alias"}
18
+ _PALETTE =["#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f", "#edc948", "#b07aa1", "#ff9da7", "#9c755f"]
19
+
20
+
21
+ def _side(d):
22
+ return int(8 + 12 * math.log2(max(d, 1))) # log scale so 224 vs 3 stays drawable
23
+
24
+
25
+ def _user_frame(f):
26
+ while f and (f.f_code.co_filename == __file__ or any(s in f.f_code.co_filename for s in _SKIP)):
27
+ f = f.f_back
28
+ return f
29
+
30
+
31
+ class Trace(TorchDispatchMode):
32
+ def __init__(self, **dims):
33
+ """dims: extra labels, e.g. Trace(N=196). UPPERCASE int variables in your code are picked up automatically."""
34
+ super().__init__()
35
+ self.dims = {} # size -> [labels]; ponytail: label by value, so equal sizes share names (H/W)
36
+ for k, v in dims.items():
37
+ self._label(v, k)
38
+ self.rows = [] # (lineno, code, op, [(id, shape)], [(id, shape)])
39
+ self.names = {} # id(tensor) -> variable name, last seen
40
+ self._keep = [] # keep outputs alive so ids stay unique
41
+
42
+ def _label(self, size, name):
43
+ labels = self.dims.setdefault(size, [])
44
+ if name not in labels:
45
+ labels.append(name)
46
+
47
+ def _scan(self, frame):
48
+ for k, v in frame.f_locals.items():
49
+ if isinstance(v, torch.Tensor) and not k.startswith("_"):
50
+ self.names[id(v)] = k
51
+ elif type(v) is int and v > 1 and k.isupper(): # B, T, D, H2 ... but not loop vars like i
52
+ self._label(v, k)
53
+
54
+ def __torch_dispatch__(self, func, types, args=(), kwargs=None):
55
+ try:
56
+ out = func(*args, **(kwargs or {}))
57
+ except Exception as e: # record the failing op, then let the error propagate
58
+ self._rec(func, args, [], (str(e).strip().splitlines() or [type(e).__name__])[0][:300])
59
+ raise
60
+ self._rec(func, args, out, None)
61
+ return out
62
+
63
+ def _rec(self, func, args, out, err):
64
+ fr = _user_frame(sys._getframe(1))
65
+ if fr is not None:
66
+ self._scan(fr)
67
+ code = linecache.getline(fr.f_code.co_filename, fr.f_lineno).strip()
68
+ line = fr.f_lineno
69
+ else:
70
+ code, line = "", 0
71
+ outs = [o for o in (out if isinstance(out, (tuple, list)) else [out]) if isinstance(o, torch.Tensor)]
72
+ flat = [x for a in args for x in (a if isinstance(a, (list, tuple)) else [a])] # cat/stack take lists
73
+ ins = [(id(a), tuple(a.shape)) for a in flat if isinstance(a, torch.Tensor)]
74
+ self._keep += outs
75
+ self.rows.append((line, code, func._schema.name.split("::")[-1], ins,
76
+ [(id(o), tuple(o.shape)) for o in outs], err))
77
+
78
+ def __exit__(self, *a):
79
+ fr = _user_frame(sys._getframe(1)) # block's frame holds the final variable names
80
+ if fr is not None:
81
+ self._scan(fr)
82
+ r = super().__exit__(*a)
83
+ if a[0] is not None: # block raised: the cell's trailing `t` never runs, so show the trace now
84
+ try:
85
+ from IPython.display import display
86
+ display(self)
87
+ except ImportError: # never mask the user's real error
88
+ pass
89
+ return r
90
+
91
+ def _lab(self, d):
92
+ return f'{"/".join(self.dims.get(d, []))} {d}'.strip()
93
+
94
+ def text(self):
95
+ fmt = lambda i, s: f'{self.names.get(i, "?")}[{", ".join(self._lab(d) for d in s)}]'
96
+ return "\n".join(
97
+ f"{'✗ ' if err else ''}{code}\n {op}: {', '.join(fmt(i, s) for i, s in ins)} -> "
98
+ + (f"ERROR: {err}" if err else ", ".join(fmt(i, s) for i, s in outs))
99
+ for code, op, ins, outs, err in self.lines()
100
+ )
101
+
102
+ __repr__ = text
103
+
104
+ # --- rendering ---
105
+ def _box(self, tid, shape, colors):
106
+ """Draw a tensor: last 2 dims = rectangle, 3rd-from-last = cuboid depth, leading dims = ghost stack + label."""
107
+ col = lambda d: colors.setdefault(d, _PALETTE[len(colors) % len(_PALETTE)])
108
+ lab = lambda d: html.escape(self._lab(d))
109
+ n = len(shape)
110
+ W = _side(shape[-1]) if n else 12
111
+ Hh = _side(shape[-2]) if n >= 2 else (16 if n else 12)
112
+ dep = shape[-3] if n >= 3 else None
113
+ D = min(40, _side(dep) // 2) if dep else 0
114
+ lead = shape[:-3]
115
+ g = min(math.prod(lead), 3) - 1 if lead else 0 # ghost copies behind
116
+ rl, dl = (lab(shape[-2]) if n >= 2 else ""), (lab(dep) if dep else "")
117
+ lt = " × ".join(lab(d) for d in lead)
118
+ x0, y0 = 7 * len(rl) + 8, D + (16 if lead else 4)
119
+
120
+ def cube(ox, oy, op):
121
+ x, y = x0 + ox, y0 + oy
122
+ s = (f'<g opacity="{op}"><rect x="{x}" y="{y}" width="{W}" height="{Hh}" '
123
+ f'fill="#4e79a7" fill-opacity=".18" stroke="#4e79a7"/>')
124
+ if D:
125
+ c = col(dep)
126
+ s += (f'<polygon points="{x},{y} {x+D},{y-D} {x+W+D},{y-D} {x+W},{y}" fill="{c}" fill-opacity=".45" stroke="{c}"/>'
127
+ f'<polygon points="{x+W},{y} {x+W+D},{y-D} {x+W+D},{y+Hh-D} {x+W},{y+Hh}" fill="{c}" fill-opacity=".3" stroke="{c}"/>')
128
+ return s + "</g>"
129
+
130
+ def text(x, y, d, anchor, txt):
131
+ return (f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-size="11" font-family="monospace" '
132
+ f'fill="{col(d)}">{txt}</text>')
133
+
134
+ body = "".join(cube(8 * i, 8 * i, 0.5) for i in range(g, 0, -1)) + cube(0, 0, 1)
135
+ if n:
136
+ body += text(x0 + W / 2, y0 + Hh + 8 * g + 13, shape[-1], "middle", lab(shape[-1]))
137
+ if n >= 2:
138
+ body += text(x0 - 4, y0 + Hh / 2 + 4, shape[-2], "end", rl)
139
+ if dep:
140
+ body += text(x0 + W + D + 5, y0 - D / 2 + 4, dep, "start", dl)
141
+ if lead:
142
+ body += f'<text x="{x0}" y="11" font-size="11" font-family="monospace" fill="currentColor">{lt}</text>'
143
+ wd = max(x0 + W + D + 8 * g + (7 * len(dl) + 8 if dl else 6), x0 + 7 * len(lt) if lead else 0)
144
+ ht = y0 + Hh + 8 * g + 18
145
+ name = html.escape(self.names.get(tid, ""))
146
+ return (f'<div style="text-align:center;margin:0 6px"><svg width="{int(wd)}" height="{int(ht)}">{body}</svg>'
147
+ f'<div style="font:bold 12px monospace">{name}</div></div>')
148
+
149
+ def lines(self):
150
+ """Collapse raw aten ops to one row per source line: named inputs -> named outputs."""
151
+ groups = [] # [code, ops, ins, outs, last_out, all_ops, err, failing_op_ins]
152
+ for line, code, op, ins, outs, err in self.rows:
153
+ if not groups or groups[-1][0] != (line, code):
154
+ groups.append([(line, code), [], {}, {}, None, [], None, []])
155
+ g = groups[-1]
156
+ if err:
157
+ g[6], g[7] = err, ins
158
+ if op not in g[5]:
159
+ g[5].append(op)
160
+ if op not in _NOISE and op not in g[1]:
161
+ g[1].append(op)
162
+ g[2].update({i: s for i, s in ins if i in self.names})
163
+ g[3].update({i: s for i, s in outs if i in self.names})
164
+ g[4] = outs
165
+ for g in groups:
166
+ if g[6]: # failing op: your named inputs, else the raw shapes that clash
167
+ g[2], g[3] = g[2] or dict(g[7]), {}
168
+ continue
169
+ if not g[3] and g[4]: # statement result never bound to a name
170
+ g[3] = dict(g[4])
171
+ g[2] = {i: s for i, s in g[2].items() if i not in g[3]} # drop in-place/aliased echoes
172
+ return [(g[0][1], ", ".join(g[1] or g[5]), list(g[2].items()), list(g[3].items()), g[6]) for g in groups]
173
+
174
+ def _repr_html_(self):
175
+ colors = {} # same dim size -> same color, so equal dims are visible at a glance
176
+
177
+ def row(code, op, ins, outs, err):
178
+ bad = "background:rgba(225,87,89,.12);border-left:4px solid #e15759;padding-left:8px;" if err else ""
179
+ res = (f"<div style='color:#e15759;font:12px monospace;max-width:340px;white-space:pre-wrap'>"
180
+ f"<b>✗ {html.escape(op)} failed</b><br>{html.escape(err)}</div>"
181
+ if err else "".join(self._box(i, s, colors) for i, s in outs))
182
+ return (
183
+ f"<div style='display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid #8884;{bad}'>"
184
+ f"<div style='width:240px;flex:none;font:12px monospace;white-space:pre-wrap'>{html.escape(code)}"
185
+ f"<div style='opacity:.6'>{html.escape(op)}</div></div>"
186
+ + "".join(self._box(i, s, colors) for i, s in ins)
187
+ + f"<div style='font-size:24px;padding:0 6px;{'color:#e15759' if err else ''}'>{'✗' if err else '→'}</div>"
188
+ + res
189
+ + "</div>"
190
+ )
191
+
192
+ return "".join(row(*r) for r in self.lines())