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.
- tensorgami/__init__.py +22 -0
- tensorgami/api/__init__.py +9 -0
- tensorgami/api/annotations.py +43 -0
- tensorgami/api/draw.py +187 -0
- tensorgami/api/figure.py +119 -0
- tensorgami/importers/__init__.py +13 -0
- tensorgami/importers/base.py +21 -0
- tensorgami/importers/torch/__init__.py +15 -0
- tensorgami/importers/torch/catalog.py +215 -0
- tensorgami/importers/torch/convert.py +193 -0
- tensorgami/importers/torch/importer.py +207 -0
- tensorgami/importers/torch/isolation.py +39 -0
- tensorgami/importers/torch/overrides.py +76 -0
- tensorgami/importers/torch/recorder.py +219 -0
- tensorgami/ir/__init__.py +74 -0
- tensorgami/ir/builder.py +182 -0
- tensorgami/ir/model.py +308 -0
- tensorgami/ir/serialize.py +153 -0
- tensorgami/ir/shapes.py +21 -0
- tensorgami/ir/summary.py +43 -0
- tensorgami/ir/validation.py +228 -0
- tensorgami/layout/__init__.py +27 -0
- tensorgami/layout/base.py +85 -0
- tensorgami/layout/cell.py +93 -0
- tensorgami/layout/labels.py +270 -0
- tensorgami/layout/layered.py +1220 -0
- tensorgami/layout/unrolled.py +221 -0
- tensorgami/layout/ushape.py +196 -0
- tensorgami/layout/volume.py +34 -0
- tensorgami/render/__init__.py +31 -0
- tensorgami/render/canvas.py +56 -0
- tensorgami/render/edges.py +180 -0
- tensorgami/render/groups.py +55 -0
- tensorgami/render/legend.py +85 -0
- tensorgami/render/mpl.py +242 -0
- tensorgami/render/painters.py +315 -0
- tensorgami/render/scene_renderer.py +79 -0
- tensorgami/render/svg.py +145 -0
- tensorgami/render/text.py +97 -0
- tensorgami/scene.py +261 -0
- tensorgami/style/__init__.py +73 -0
- tensorgami/style/color.py +109 -0
- tensorgami/style/glyphs.py +148 -0
- tensorgami/style/loader.py +265 -0
- tensorgami/style/theme.py +65 -0
- tensorgami/style/themes/__init__.py +1 -0
- tensorgami/style/themes/default.toml +237 -0
- tensorgami/style/themes/origami-extended.toml +259 -0
- tensorgami/style/themes/origami.toml +263 -0
- tensorgami/style/validation.py +139 -0
- tensorgami/transforms/__init__.py +37 -0
- tensorgami/transforms/annotate.py +229 -0
- tensorgami/transforms/base.py +185 -0
- tensorgami/transforms/cells.py +340 -0
- tensorgami/transforms/cleanup.py +198 -0
- tensorgami/transforms/collapse.py +139 -0
- tensorgami/transforms/levels.py +74 -0
- tensorgami/transforms/recurrence.py +58 -0
- tensorgami/transforms/repeats.py +174 -0
- tensorgami/transforms/unroll.py +342 -0
- tensorgami/transforms/ushape.py +147 -0
- tensorgami/transforms/volume.py +40 -0
- tensorgami/views/__init__.py +55 -0
- tensorgami/views/base.py +25 -0
- tensorgami/views/block.py +125 -0
- tensorgami/vocabulary.py +43 -0
- tensorgami-0.1.0.dist-info/METADATA +172 -0
- tensorgami-0.1.0.dist-info/RECORD +70 -0
- tensorgami-0.1.0.dist-info/WHEEL +4 -0
- 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}")
|
tensorgami/api/figure.py
ADDED
|
@@ -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
|