tensorgami 0.1.0__py3-none-any.whl

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.
Files changed (70) hide show
  1. tensorgami/__init__.py +22 -0
  2. tensorgami/api/__init__.py +9 -0
  3. tensorgami/api/annotations.py +43 -0
  4. tensorgami/api/draw.py +187 -0
  5. tensorgami/api/figure.py +119 -0
  6. tensorgami/importers/__init__.py +13 -0
  7. tensorgami/importers/base.py +21 -0
  8. tensorgami/importers/torch/__init__.py +15 -0
  9. tensorgami/importers/torch/catalog.py +215 -0
  10. tensorgami/importers/torch/convert.py +193 -0
  11. tensorgami/importers/torch/importer.py +207 -0
  12. tensorgami/importers/torch/isolation.py +39 -0
  13. tensorgami/importers/torch/overrides.py +76 -0
  14. tensorgami/importers/torch/recorder.py +219 -0
  15. tensorgami/ir/__init__.py +74 -0
  16. tensorgami/ir/builder.py +182 -0
  17. tensorgami/ir/model.py +308 -0
  18. tensorgami/ir/serialize.py +153 -0
  19. tensorgami/ir/shapes.py +21 -0
  20. tensorgami/ir/summary.py +43 -0
  21. tensorgami/ir/validation.py +228 -0
  22. tensorgami/layout/__init__.py +27 -0
  23. tensorgami/layout/base.py +85 -0
  24. tensorgami/layout/cell.py +93 -0
  25. tensorgami/layout/labels.py +270 -0
  26. tensorgami/layout/layered.py +1220 -0
  27. tensorgami/layout/unrolled.py +221 -0
  28. tensorgami/layout/ushape.py +196 -0
  29. tensorgami/layout/volume.py +34 -0
  30. tensorgami/render/__init__.py +31 -0
  31. tensorgami/render/canvas.py +56 -0
  32. tensorgami/render/edges.py +180 -0
  33. tensorgami/render/groups.py +55 -0
  34. tensorgami/render/legend.py +85 -0
  35. tensorgami/render/mpl.py +242 -0
  36. tensorgami/render/painters.py +315 -0
  37. tensorgami/render/scene_renderer.py +79 -0
  38. tensorgami/render/svg.py +145 -0
  39. tensorgami/render/text.py +97 -0
  40. tensorgami/scene.py +261 -0
  41. tensorgami/style/__init__.py +73 -0
  42. tensorgami/style/color.py +109 -0
  43. tensorgami/style/glyphs.py +148 -0
  44. tensorgami/style/loader.py +265 -0
  45. tensorgami/style/theme.py +65 -0
  46. tensorgami/style/themes/__init__.py +1 -0
  47. tensorgami/style/themes/default.toml +237 -0
  48. tensorgami/style/themes/origami-extended.toml +259 -0
  49. tensorgami/style/themes/origami.toml +263 -0
  50. tensorgami/style/validation.py +139 -0
  51. tensorgami/transforms/__init__.py +37 -0
  52. tensorgami/transforms/annotate.py +229 -0
  53. tensorgami/transforms/base.py +185 -0
  54. tensorgami/transforms/cells.py +340 -0
  55. tensorgami/transforms/cleanup.py +198 -0
  56. tensorgami/transforms/collapse.py +139 -0
  57. tensorgami/transforms/levels.py +74 -0
  58. tensorgami/transforms/recurrence.py +58 -0
  59. tensorgami/transforms/repeats.py +174 -0
  60. tensorgami/transforms/unroll.py +342 -0
  61. tensorgami/transforms/ushape.py +147 -0
  62. tensorgami/transforms/volume.py +40 -0
  63. tensorgami/views/__init__.py +55 -0
  64. tensorgami/views/base.py +25 -0
  65. tensorgami/views/block.py +125 -0
  66. tensorgami/vocabulary.py +43 -0
  67. tensorgami-0.1.0.dist-info/METADATA +172 -0
  68. tensorgami-0.1.0.dist-info/RECORD +70 -0
  69. tensorgami-0.1.0.dist-info/WHEEL +4 -0
  70. tensorgami-0.1.0.dist-info/licenses/LICENSE +21 -0
tensorgami/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Tensorgami: draw the architecture and information flow of ML models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+
8
+ def __getattr__(name: str):
9
+ """Lazy top-level API, so ``import tensorgami`` stays light: matplotlib loads when a
10
+ figure is made, torch when a model is drawn."""
11
+ if name in ("Figure", "Input", "Loss", "Note", "draw"):
12
+ from tensorgami import api
13
+
14
+ return getattr(api, name)
15
+ raise AttributeError(f"module 'tensorgami' has no attribute {name!r}")
16
+
17
+
18
+ def __dir__() -> list[str]: # tab completion lists the lazy names too
19
+ return sorted({*globals(), *__all__})
20
+
21
+
22
+ __all__ = ["Figure", "Input", "Loss", "Note", "__version__", "draw"]
@@ -0,0 +1,9 @@
1
+ """Public API: the objects users work with (ADR-0007)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tensorgami.api.annotations import Input, Loss, Note
6
+ from tensorgami.api.draw import draw
7
+ from tensorgami.api.figure import Figure
8
+
9
+ __all__ = ["Figure", "Input", "Loss", "Note", "draw"]
@@ -0,0 +1,43 @@
1
+ """Annotations: what a figure shows beyond the traced modules (ADR-0007 ``annotations=``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, init=False)
9
+ class Loss:
10
+ """A loss term: a box fed by the tensors it compares, e.g.
11
+ ``tg.Loss("reconstruction", "output 0", "input")``. ``trains`` names the models or modules
12
+ its gradient updates; dashed arrows go back to them (the adversarial loop of a GAN)."""
13
+
14
+ name: str
15
+ inputs: tuple[str, ...]
16
+ trains: tuple[str, ...]
17
+
18
+ def __init__(self, name: str, *inputs: str, trains: tuple[str, ...] | list[str] = ()) -> None:
19
+ if not inputs:
20
+ raise ValueError(f"Loss({name!r}) needs the tensors it compares, e.g. 'output'")
21
+ object.__setattr__(self, "name", name)
22
+ object.__setattr__(self, "inputs", tuple(inputs))
23
+ object.__setattr__(self, "trains", tuple(trains))
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Input:
28
+ """Another input for a tensor of the figure: it feeds what that tensor feeds, e.g. the real
29
+ images a discriminator also judges, ``tg.Input("real image", to="discriminator.image")``."""
30
+
31
+ name: str
32
+ to: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Note:
37
+ """A line of text under a node, e.g. ``tg.Note("ε ~ N(0, I)", at="...")``."""
38
+
39
+ text: str
40
+ at: str
41
+
42
+
43
+ Annotation = Loss | Input | Note
tensorgami/api/draw.py ADDED
@@ -0,0 +1,187 @@
1
+ """``tg.draw``: from a loaded model to a figure in one call (ADR-0007, ADR-0008)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import fields
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from tensorgami.api.annotations import Annotation, Input, Loss, Note
12
+ from tensorgami.api.figure import Figure
13
+ from tensorgami.ir import Graph
14
+ from tensorgami.layout import LayoutOptions
15
+ from tensorgami.style import DEFAULT_STYLE, Theme, load_style
16
+ from tensorgami.transforms import Level
17
+ from tensorgami.transforms.annotate import add_input, add_loss, add_note, compose, link
18
+ from tensorgami.views import get_view
19
+
20
+ if TYPE_CHECKING:
21
+ from torch import nn
22
+
23
+ LAYOUT_OPTIONS = tuple(f.name for f in fields(LayoutOptions))
24
+ _PARAMETERS = (
25
+ "level",
26
+ "view",
27
+ "style",
28
+ "overrides",
29
+ "links",
30
+ "annotations",
31
+ "direction",
32
+ *LAYOUT_OPTIONS,
33
+ )
34
+ _RENAMED = {"theme": "style"} # the name sketched in ADR-0007, before ADR-0008
35
+
36
+
37
+ def draw(
38
+ model: nn.Module | Mapping[str, nn.Module],
39
+ example_input: Any,
40
+ *,
41
+ level: int | str = Level.LAYERS,
42
+ view: str = "auto",
43
+ style: str | Path | Theme = DEFAULT_STYLE,
44
+ overrides: Mapping[str, Mapping[str, Any]] | None = None,
45
+ links: Sequence[tuple[str, ...]] = (),
46
+ annotations: Sequence[Annotation] = (),
47
+ direction: str | None = None,
48
+ **layout_options: Any,
49
+ ) -> Figure:
50
+ """Draw ``model`` as it runs on ``example_input``.
51
+
52
+ The model runs once, without gradients and in eval mode; its weights, training flags and
53
+ the caller's random state are left as they were. Nothing is written to disk: the figure
54
+ shows inline in Jupyter and is saved only on request, ``fig.save("model.png", dpi=300)``.
55
+
56
+ Args:
57
+ model: a loaded ``torch.nn.Module``, on any device (the input is moved to it).
58
+ example_input: what the model is called with: a tensor, a tuple of positional
59
+ arguments, or a dict of keyword arguments. Include the batch dimension.
60
+ level: the detail, by number or name: 0 ``"system"`` (one box), 1 ``"stages"`` (the
61
+ model's direct sub-modules), 2 ``"layers"`` (default), 3 ``"operators"``
62
+ (everything traced), 4 ``"cells"``.
63
+ view: ``"block"`` (boxes and wires), ``"unrolled"`` (recurrence as a time × layer
64
+ grid, with per-step inputs x_t), ``"volume"`` (CNN layers as tensor volumes),
65
+ ``"cell"`` (the recurrent cells drawn as their gates), or ``"auto"`` (default:
66
+ unrolled when a module is called step after step with state passed along, block
67
+ otherwise).
68
+ style: a built-in style (``"default"``, ``"origami"``), the path of a ``.toml``
69
+ style, or a loaded :class:`~tensorgami.style.Theme`.
70
+ overrides: category, label or leaf-ness per module path or class name, e.g.
71
+ ``{"ConvLSTMCell": {"category": "recurrent", "label": "ConvLSTM cell"}}``.
72
+ links: with several models (``model`` a dict of name → module, ``example_input`` a
73
+ dict of name → input), which tensor feeds which model input, as ``(source,
74
+ target)`` or ``(source, target, label)``, e.g. ``("generator",
75
+ "discriminator.image")``: the generator's output is the image its discriminator
76
+ judges.
77
+ annotations: what no module contains: ``tg.Loss`` terms (with ``trains=`` for the
78
+ arrows back to the models they train), extra ``tg.Input`` and ``tg.Note``.
79
+ Tensors are named by input/output names (``"input"``, ``"output 0"``, keyword
80
+ names) or module paths (``"mu"``); with several models, after the model's name
81
+ (``"discriminator.image"``; the name alone is the model's output).
82
+ direction: ``"LR"`` (left → right) or ``"TB"`` (top → bottom); by default the
83
+ style's choice, else ``"LR"``.
84
+ **layout_options: further :class:`~tensorgami.layout.LayoutOptions`, e.g.
85
+ ``legend=False``, ``wrap_width=None`` (one row) or ``rank_gap=64``.
86
+
87
+ Example::
88
+
89
+ import tensorgami as tg
90
+
91
+ fig = tg.draw(model, torch.randn(1, 3, 224, 224), style="origami")
92
+ fig # shows inline in Jupyter
93
+ fig.save("model.png", dpi=300)
94
+ """
95
+ # every argument is checked before the model runs, so a typo costs no forward pass
96
+ _check_names(layout_options)
97
+ _check_extras(model, example_input, links, annotations)
98
+ theme = style if isinstance(style, Theme) else load_style(style)
99
+ if direction is not None:
100
+ layout_options["direction"] = direction
101
+ options = LayoutOptions.for_style(theme, **layout_options)
102
+ level = Level.parse(level)
103
+ chosen = get_view(view)
104
+
105
+ graph = _annotate(_capture(model, example_input, overrides), links, annotations)
106
+ shown, scene = chosen.draw(graph, theme, level=level, options=options)
107
+ return Figure(scene, theme, shown)
108
+
109
+
110
+ def _capture(model, example_input: Any, overrides: Mapping | None) -> Graph:
111
+ from tensorgami.importers.torch import trace # torch is imported only to draw a model
112
+
113
+ if not isinstance(model, Mapping):
114
+ return trace(model, example_input, overrides=overrides)
115
+ graphs = {
116
+ name: trace(module, example_input[name], overrides=_own(name, model, overrides))
117
+ for name, module in model.items()
118
+ }
119
+ return compose(graphs)
120
+
121
+
122
+ def _own(name: str, models: Mapping, overrides: Mapping | None) -> dict | None:
123
+ """The overrides for one of several models: its own (``"generator.fc"`` → ``"fc"``) and
124
+ the shared ones (class names and paths without a model's name)."""
125
+ if not overrides:
126
+ return None
127
+ own = {}
128
+ for key, value in overrides.items():
129
+ head, _, rest = key.partition(".")
130
+ if head == name and rest:
131
+ own[rest] = value
132
+ elif head not in models:
133
+ own[key] = value
134
+ return own
135
+
136
+
137
+ def _annotate(graph: Graph, links: Sequence[tuple[str, ...]], annotations) -> Graph:
138
+ for a in annotations: # extra inputs first: a link may then replace the input they join
139
+ if isinstance(a, Input):
140
+ graph = add_input(graph, a.name, a.to)
141
+ for source, target, *label in links:
142
+ graph = link(graph, source, target, *label)
143
+ for a in annotations:
144
+ if isinstance(a, Loss):
145
+ graph = add_loss(graph, a.name, a.inputs, a.trains)
146
+ elif isinstance(a, Note):
147
+ graph = add_note(graph, a.text, a.at)
148
+ return graph
149
+
150
+
151
+ def _check_extras(model, example_input, links, annotations) -> None:
152
+ if isinstance(model, Mapping):
153
+ if not model or not all(isinstance(k, str) and "." not in k for k in model):
154
+ raise ValueError("several models: a dict of name → model, names without dots")
155
+ missing = (
156
+ sorted(set(model) - set(example_input)) if isinstance(example_input, Mapping) else []
157
+ )
158
+ if not isinstance(example_input, Mapping) or missing:
159
+ raise ValueError(
160
+ "several models need a dict of example inputs with the same names "
161
+ f"({', '.join(model)}); missing: {', '.join(missing) or 'all'}"
162
+ )
163
+ for entry in links:
164
+ if not (isinstance(entry, tuple) and len(entry) in (2, 3)):
165
+ raise TypeError(
166
+ f"links: each entry is (source, target) or (source, target, label); got {entry!r}"
167
+ )
168
+ for a in annotations:
169
+ if not isinstance(a, Loss | Input | Note):
170
+ raise TypeError(f"annotations: tg.Loss, tg.Input or tg.Note; got {a!r}")
171
+
172
+
173
+ def _check_names(layout_options: Mapping[str, Any]) -> None:
174
+ for name in layout_options:
175
+ if name in LAYOUT_OPTIONS:
176
+ continue
177
+ close = (
178
+ [_RENAMED[name]]
179
+ if name in _RENAMED
180
+ else difflib.get_close_matches(name, _PARAMETERS, n=1)
181
+ )
182
+ hint = (
183
+ f"; did you mean {close[0]!r}?"
184
+ if close
185
+ else f"; layout options are: {', '.join(LAYOUT_OPTIONS)}"
186
+ )
187
+ raise TypeError(f"draw() got an unexpected keyword argument {name!r}{hint}")
@@ -0,0 +1,119 @@
1
+ """``tg.Figure``: what the user gets back — shown inline in Jupyter, saved only on request
2
+ (ADR-0005, ADR-0007)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ from functools import cached_property
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from tensorgami.ir import Graph
12
+ from tensorgami.render.mpl import PX_PER_INCH, draw_scene, quiet_fonts, render_figure
13
+ from tensorgami.render.scene_renderer import render_svg
14
+ from tensorgami.scene import Scene
15
+ from tensorgami.style import Theme
16
+
17
+ if TYPE_CHECKING:
18
+ from matplotlib.axes import Axes
19
+ from matplotlib.figure import Figure as MplFigure
20
+
21
+ #: formats written through matplotlib (SVG is written by Tensorgami's own SVG canvas)
22
+ RASTER_AND_PDF = {".png", ".pdf", ".jpg", ".jpeg", ".tif", ".tiff"}
23
+ SCREEN_DPI = 2 * PX_PER_INCH # notebook display: rendered at 2× for sharp HiDPI screens
24
+
25
+
26
+ class Figure:
27
+ """A drawn model. Nothing is written to disk until :meth:`save` is called.
28
+
29
+ * in Jupyter, the figure renders inline (``_repr_png_``);
30
+ * ``fig.save("model.png", dpi=300)`` — also ``.svg`` and ``.pdf``;
31
+ * ``fig.show()`` opens a window from a plain script;
32
+ * ``fig.figure`` / ``fig.ax`` are the underlying matplotlib objects (titles, annotations);
33
+ * ``fig.draw_on(ax)`` draws into a panel of your own ``plt.subplots`` layout.
34
+ """
35
+
36
+ def __init__(self, scene: Scene, style: Theme, graph: Graph | None = None) -> None:
37
+ self._scene = scene
38
+ self._style = style
39
+ self._graph = graph
40
+
41
+ # ------------------------------------------------------------------ inspection
42
+
43
+ @property
44
+ def scene(self) -> Scene:
45
+ return self._scene
46
+
47
+ @property
48
+ def style(self) -> Theme:
49
+ return self._style
50
+
51
+ @property
52
+ def graph(self) -> Graph | None:
53
+ return self._graph
54
+
55
+ @property
56
+ def size(self) -> tuple[float, float]:
57
+ """Natural size in px (1 px = 1/96 inch)."""
58
+ return self._scene.width, self._scene.height
59
+
60
+ @cached_property
61
+ def figure(self) -> MplFigure:
62
+ return render_figure(self._scene, self._style)
63
+
64
+ @property
65
+ def ax(self) -> Axes:
66
+ return self.figure.axes[0]
67
+
68
+ def __repr__(self) -> str:
69
+ w, h = self.size
70
+ title, style = self._scene.title, self._style.name
71
+ return f"<tensorgami.Figure {title!r} {w:.0f}×{h:.0f} px, style {style!r}>"
72
+
73
+ # ------------------------------------------------------------------ output
74
+
75
+ def save(self, path: str | Path, *, dpi: float = 300) -> Path:
76
+ """Write the figure; the format follows the suffix: ``.png`` (default choice for
77
+ reports), ``.svg`` (vector, editable), ``.pdf``, ``.jpg``, ``.tif``."""
78
+ path = Path(path)
79
+ suffix = path.suffix.lower()
80
+ if suffix == ".svg":
81
+ path.write_text(self.to_svg(), encoding="utf-8")
82
+ elif suffix in RASTER_AND_PDF:
83
+ with quiet_fonts():
84
+ self.figure.savefig(path, dpi=dpi)
85
+ else:
86
+ raise ValueError(
87
+ f"cannot save as {suffix or 'a file without suffix'}: use .png, .svg, .pdf, "
88
+ ".jpg or .tif"
89
+ )
90
+ return path
91
+
92
+ def to_png(self, *, dpi: float = SCREEN_DPI) -> bytes:
93
+ buffer = io.BytesIO()
94
+ with quiet_fonts():
95
+ self.figure.savefig(buffer, format="png", dpi=dpi)
96
+ return buffer.getvalue()
97
+
98
+ def to_svg(self) -> str:
99
+ return render_svg(self._scene, self._style)
100
+
101
+ def _repr_png_(self) -> tuple[bytes, dict[str, int]]:
102
+ """Jupyter display: rendered at 2× but shown at the natural size."""
103
+ w, h = self.size
104
+ return self.to_png(), {"width": round(w), "height": round(h)}
105
+
106
+ def draw_on(self, ax: Axes) -> Axes:
107
+ """Draw into an existing Axes (e.g. a panel of ``plt.subplots``); returns it."""
108
+ draw_scene(ax, self._scene, self._style)
109
+ return ax
110
+
111
+ def show(self) -> None:
112
+ """Open the figure in a matplotlib window (from scripts)."""
113
+ import matplotlib.pyplot as plt
114
+
115
+ w, h = self.size
116
+ window = plt.figure(figsize=(w / PX_PER_INCH, h / PX_PER_INCH))
117
+ self.draw_on(window.add_axes((0.0, 0.0, 1.0, 1.0)))
118
+ with quiet_fonts():
119
+ plt.show()
@@ -0,0 +1,13 @@
1
+ """Model importers: framework → IR (ADR-0001).
2
+
3
+ Each framework lives in its own subpackage and is imported on demand, so ``tensorgami`` itself
4
+ works without any deep-learning framework installed::
5
+
6
+ from tensorgami.importers.torch import trace
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from tensorgami.importers.base import ImporterError, ModelImporter, UnclassifiedOpWarning
12
+
13
+ __all__ = ["ImporterError", "ModelImporter", "UnclassifiedOpWarning"]
@@ -0,0 +1,21 @@
1
+ """Framework-independent importer contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ from tensorgami.ir import Graph
8
+
9
+
10
+ class ImporterError(RuntimeError):
11
+ """The model could not be captured (e.g. it failed on the example input)."""
12
+
13
+
14
+ class UnclassifiedOpWarning(UserWarning):
15
+ """Some operations are not in the catalogue and were drawn with the 'other' category."""
16
+
17
+
18
+ class ModelImporter(Protocol):
19
+ """Turns a framework model plus an example input into an :class:`~tensorgami.ir.Graph`."""
20
+
21
+ def import_model(self, model: Any, example_input: Any) -> Graph: ...
@@ -0,0 +1,15 @@
1
+ """PyTorch importer (ADR-0003): ``trace(model, example_input)`` → :class:`tensorgami.ir.Graph`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ import torch # noqa: F401
7
+ except ImportError as exc: # pragma: no cover - exercised only without torch
8
+ raise ImportError(
9
+ "tensorgami.importers.torch needs PyTorch. Install it the way your project does "
10
+ "(e.g. `pip install torch`), or use the extra: `pip install tensorgami[torch]`."
11
+ ) from exc
12
+
13
+ from tensorgami.importers.torch.importer import TorchImporter, trace
14
+
15
+ __all__ = ["TorchImporter", "trace"]
@@ -0,0 +1,215 @@
1
+ """What Tensorgami knows about PyTorch operations: names, categories, leaves, parameters.
2
+
3
+ Pure data plus small lookup functions. Classes are matched by *name* along the MRO, so
4
+ subclasses (e.g. ``NonDynamicallyQuantizableLinear``) inherit their parent's category and the
5
+ catalogue never needs the class objects themselves. Name lists are whitespace-separated
6
+ strings to keep the tables compact.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ from tensorgami.vocabulary import OpCategory as C
14
+
15
+
16
+ def _names(text: str) -> tuple[str, ...]:
17
+ return tuple(text.split())
18
+
19
+
20
+ # --------------------------------------------------------------------------- modules
21
+
22
+ _MODULES: dict[C, tuple[str, ...]] = {
23
+ C.CONV: _names("""Conv1d Conv2d Conv3d ConvTranspose1d ConvTranspose2d ConvTranspose3d
24
+ LazyConv1d LazyConv2d LazyConv3d"""),
25
+ C.LINEAR: _names("Linear LazyLinear Bilinear"),
26
+ C.RECURRENT: _names("RNN LSTM GRU RNNCell LSTMCell GRUCell"),
27
+ C.ATTENTION: _names("MultiheadAttention"),
28
+ C.NORM: _names("""BatchNorm1d BatchNorm2d BatchNorm3d SyncBatchNorm LayerNorm GroupNorm
29
+ InstanceNorm1d InstanceNorm2d InstanceNorm3d LocalResponseNorm RMSNorm"""),
30
+ C.ACTIVATION: _names("""ReLU ReLU6 LeakyReLU PReLU RReLU ELU SELU CELU GELU SiLU Mish
31
+ Sigmoid Tanh Softmax LogSoftmax Softplus Softsign Hardtanh
32
+ Hardswish Hardsigmoid GLU Tanhshrink Threshold"""),
33
+ C.RESAMPLE: _names("""MaxPool1d MaxPool2d MaxPool3d AvgPool1d AvgPool2d AvgPool3d
34
+ AdaptiveAvgPool1d AdaptiveAvgPool2d AdaptiveAvgPool3d
35
+ AdaptiveMaxPool1d AdaptiveMaxPool2d AdaptiveMaxPool3d Upsample
36
+ UpsamplingNearest2d UpsamplingBilinear2d PixelShuffle
37
+ PixelUnshuffle"""),
38
+ C.EMBEDDING: _names("Embedding EmbeddingBag"),
39
+ C.REGULARIZATION: _names(
40
+ "Dropout Dropout1d Dropout2d Dropout3d AlphaDropout FeatureAlphaDropout"
41
+ ),
42
+ C.RESHAPE: _names("""Flatten Unflatten ZeroPad1d ZeroPad2d ZeroPad3d ConstantPad1d
43
+ ConstantPad2d ConstantPad3d ReflectionPad1d ReflectionPad2d
44
+ ReflectionPad3d ReplicationPad1d ReplicationPad2d ReplicationPad3d"""),
45
+ C.OTHER: _names("Identity"),
46
+ }
47
+ MODULE_CATEGORY: dict[str, C] = {name: cat for cat, names in _MODULES.items() for name in names}
48
+
49
+ #: Modules drawn as one operation; their internals are not traced (ADR-0003).
50
+ LEAF_MODULES: frozenset[str] = frozenset(MODULE_CATEGORY)
51
+
52
+ #: Hyper-parameters worth keeping per module type (first match along the MRO).
53
+ MODULE_PARAMS: dict[str, tuple[str, ...]] = {
54
+ "_ConvNd": _names("in_channels out_channels kernel_size stride padding dilation groups"),
55
+ "Linear": _names("in_features out_features"),
56
+ "Bilinear": _names("in1_features in2_features out_features"),
57
+ "RNNBase": _names("input_size hidden_size num_layers bidirectional batch_first"),
58
+ "RNNCellBase": _names("input_size hidden_size"),
59
+ "MultiheadAttention": _names("embed_dim num_heads batch_first"),
60
+ "_NormBase": _names("num_features"),
61
+ "LayerNorm": _names("normalized_shape"),
62
+ "GroupNorm": _names("num_groups num_channels"),
63
+ "_MaxPoolNd": _names("kernel_size stride"),
64
+ "_AvgPoolNd": _names("kernel_size stride"),
65
+ "_AdaptiveAvgPoolNd": _names("output_size"),
66
+ "_AdaptiveMaxPoolNd": _names("output_size"),
67
+ "Upsample": _names("scale_factor size mode"),
68
+ "Embedding": _names("num_embeddings embedding_dim"),
69
+ "EmbeddingBag": _names("num_embeddings embedding_dim"),
70
+ "_DropoutNd": _names("p"),
71
+ "LeakyReLU": _names("negative_slope"),
72
+ "Softmax": _names("dim"),
73
+ "LogSoftmax": _names("dim"),
74
+ "Flatten": _names("start_dim end_dim"),
75
+ }
76
+
77
+ # --------------------------------------------------------------------------- functions
78
+
79
+ _FUNCTIONS: dict[C, tuple[str, ...]] = {
80
+ C.CONV: _names("""conv1d conv2d conv3d conv_transpose1d conv_transpose2d conv_transpose3d
81
+ convolution"""),
82
+ C.LINEAR: _names("linear bilinear matmul mm bmm addmm baddbmm einsum mv addmv"),
83
+ C.ACTIVATION: _names("""relu relu6 leaky_relu elu selu celu gelu silu mish sigmoid tanh
84
+ softmax log_softmax softplus softsign hardtanh hardswish hardsigmoid
85
+ glu logsigmoid prelu rrelu threshold tanhshrink exp log"""),
86
+ C.NORM: _names("""batch_norm layer_norm group_norm instance_norm local_response_norm
87
+ normalize rms_norm"""),
88
+ C.RESAMPLE: _names("""max_pool1d max_pool2d max_pool3d avg_pool1d avg_pool2d avg_pool3d
89
+ adaptive_avg_pool1d adaptive_avg_pool2d adaptive_avg_pool3d
90
+ adaptive_max_pool1d adaptive_max_pool2d adaptive_max_pool3d
91
+ interpolate upsample upsample_nearest upsample_bilinear pixel_shuffle
92
+ pixel_unshuffle grid_sample"""),
93
+ C.ATTENTION: _names("scaled_dot_product_attention multi_head_attention_forward"),
94
+ C.EMBEDDING: _names("embedding embedding_bag one_hot"),
95
+ C.REGULARIZATION: _names("""dropout dropout1d dropout2d dropout3d alpha_dropout
96
+ feature_alpha_dropout"""),
97
+ C.RESHAPE: _names("""view reshape flatten unflatten permute transpose t squeeze unsqueeze
98
+ contiguous getitem split chunk unbind expand expand_as repeat tile
99
+ narrow select movedim swapaxes swapdims view_as reshape_as flip roll
100
+ pad to float double half bfloat16 int long bool type clone detach T mT
101
+ H mH index_select gather masked_select data real imag"""),
102
+ C.MERGE: _names("cat concat concatenate stack hstack vstack dstack"),
103
+ C.STOCHASTIC: _names("""randn rand randint randn_like rand_like randint_like normal
104
+ bernoulli multinomial randperm poisson"""),
105
+ C.TENSOR: _names("""zeros ones full empty zeros_like ones_like full_like empty_like arange
106
+ linspace logspace eye tensor as_tensor new_zeros new_ones new_full
107
+ new_empty new_tensor tril triu"""),
108
+ C.LOSS: _names("""mse_loss l1_loss cross_entropy nll_loss binary_cross_entropy
109
+ binary_cross_entropy_with_logits kl_div smooth_l1_loss huber_loss
110
+ cosine_embedding_loss margin_ranking_loss triplet_margin_loss"""),
111
+ }
112
+ FUNCTION_CATEGORY: dict[str, C] = {n: cat for cat, names in _FUNCTIONS.items() for n in names}
113
+
114
+ #: Element-wise binary operations: a *merge* when they combine two tensors, plain arithmetic
115
+ #: (category 'other') when one operand is a Python number, e.g. ``0.5 * logvar``.
116
+ ARITHMETIC: frozenset[str] = frozenset(
117
+ _names("""add sub mul div true_divide floor_divide maximum minimum where remainder fmod
118
+ atan2 hypot logaddexp""")
119
+ )
120
+
121
+ #: Known operations without a dedicated category: drawn as 'other', without a warning.
122
+ KNOWN_OTHER: frozenset[str] = frozenset(
123
+ _names("""neg abs sqrt rsqrt pow square reciprocal mean sum prod max min amax amin argmax
124
+ argmin std var norm clamp clip sign round floor ceil trunc cumsum cumprod
125
+ logsumexp sort topk eq ne lt le gt ge logical_not logical_and logical_or
126
+ logical_xor masked_fill fill copy zero index_put scatter scatter_add nan_to_num
127
+ isnan isinf sin cos erf all any count_nonzero diff outer cross dot trace diag
128
+ diagonal setitem""")
129
+ )
130
+
131
+ #: Operations that read their tensor argument only for its *shape*: they become sources
132
+ #: without data edges (ADR-0002), e.g. ``randn_like(mu)`` is noise, not a function of μ.
133
+ SHAPE_ONLY: frozenset[str] = frozenset(
134
+ _names("""zeros_like ones_like full_like empty_like randn_like rand_like randint_like
135
+ new_zeros new_ones new_full new_empty new_tensor""")
136
+ )
137
+
138
+ #: Fused operations that hide sub-module structure (ADR-0003 consequence).
139
+ FUSED: frozenset[str] = frozenset(
140
+ _names("_transformer_encoder_layer_fwd _native_multi_head_attention")
141
+ )
142
+
143
+ _REFLECTED = frozenset(
144
+ _names("add sub mul truediv floordiv mod pow matmul and or xor lshift rshift")
145
+ )
146
+ _RENAMES = {
147
+ "truediv": "div",
148
+ "floordiv": "floor_divide",
149
+ "mod": "remainder",
150
+ "and": "logical_and",
151
+ "or": "logical_or",
152
+ "xor": "logical_xor",
153
+ }
154
+
155
+ _GROUP_KEYWORDS: tuple[tuple[tuple[str, ...], C], ...] = (
156
+ (("lstm", "gru", "rnn", "recurrent"), C.RECURRENT),
157
+ (("attention", "attn"), C.ATTENTION),
158
+ (("embed",), C.EMBEDDING),
159
+ (("conv",), C.CONV),
160
+ (("norm",), C.NORM),
161
+ )
162
+
163
+ # --------------------------------------------------------------------------- lookups
164
+
165
+
166
+ def normalize_op_name(name: str) -> str:
167
+ """``__add__``/``__radd__`` → ``add``; ``__iadd__`` → ``add_``; ``__getitem__`` →
168
+ ``getitem``; ``__truediv__`` → ``div``."""
169
+ if name.startswith("__") and name.endswith("__"):
170
+ core = name.strip("_")
171
+ if core.startswith("r") and core[1:] in _REFLECTED:
172
+ core = core[1:]
173
+ elif core.startswith("i") and core[1:] in _REFLECTED:
174
+ return _RENAMES.get(core[1:], core[1:]) + "_"
175
+ return _RENAMES.get(core, core)
176
+ return name
177
+
178
+
179
+ def base_op(name: str) -> str:
180
+ """In-place variants share the category of their base op: ``add_`` → ``add``."""
181
+ return name[:-1] if name.endswith("_") and not name.startswith("_") else name
182
+
183
+
184
+ def module_category(mro_names: Iterable[str]) -> C | None:
185
+ for name in mro_names:
186
+ if name in MODULE_CATEGORY:
187
+ return MODULE_CATEGORY[name]
188
+ return None
189
+
190
+
191
+ def module_param_names(mro_names: Iterable[str]) -> tuple[str, ...]:
192
+ for name in mro_names:
193
+ if name in MODULE_PARAMS:
194
+ return MODULE_PARAMS[name]
195
+ return ()
196
+
197
+
198
+ def function_category(name: str, tensor_inputs: int) -> tuple[C, bool]:
199
+ """Category of a functional op and whether it is known to the catalogue."""
200
+ op = base_op(name)
201
+ if op in ARITHMETIC:
202
+ return (C.MERGE if tensor_inputs >= 2 else C.OTHER), True
203
+ if op in FUNCTION_CATEGORY:
204
+ return FUNCTION_CATEGORY[op], True
205
+ return C.OTHER, op in KNOWN_OTHER
206
+
207
+
208
+ def guess_group_category(type_name: str) -> C | None:
209
+ """Category hint for a user-defined module from its class name (``ConvLSTMCell`` →
210
+ recurrent). Only used when the module is drawn collapsed."""
211
+ lower = type_name.lower()
212
+ for keywords, category in _GROUP_KEYWORDS:
213
+ if any(k in lower for k in keywords):
214
+ return category
215
+ return None