Skip to content

Commit bc72c45

Browse files
Update to pytensor>=3.2.3 (#40)
* Decouple graph serialization from FrozenFunctionGraph constructor * Base layer ops on SymbolicOp so numba optimizes their inner graphs * Bump pytensor lower pin to 3.2.3
1 parent 114de47 commit bc72c45

10 files changed

Lines changed: 97 additions & 84 deletions

File tree

‎conda_envs/pytensor_ml-gpu_jax.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ channels:
66

77
dependencies:
88
- python>=3.12
9-
- pytensor>=3.0.0,<4.0.0
9+
- pytensor>=3.2.3,<4.0.0
1010
- numpy
1111
- scikit-learn
1212

‎conda_envs/pytensor_ml.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ channels:
55

66
dependencies:
77
- python>=3.12
8-
- pytensor>=3.0.0,<4.0.0
8+
- pytensor>=3.2.3,<4.0.0
99
- numpy
1010
- safetensors
1111
- scikit-learn

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ keywords = [
3434
]
3535

3636
dependencies = [
37-
"pytensor>=3.0.0,<4.0.0",
37+
"pytensor>=3.2.3,<4.0.0",
3838
"numpy",
3939
"safetensors"
4040
]

‎pytensor_ml/attention.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
class AttentionLayer(UnaryLayerOp):
1212
__props__ = ("is_causal", "scale")
1313

14+
def build_inner_graph(self, q, k, v, mask=None):
15+
return [_sdpa_graph(q, k, v, mask, self.is_causal, self.scale)]
16+
1417

1518
def _repeat_kv(x: TensorVariable, n_head: int | None, n_kv_head: int | None) -> TensorVariable:
1619
"""Broadcast ``n_kv_head`` key/value heads up to ``n_head`` query heads (grouped-query attention).
@@ -117,11 +120,7 @@ def scaled_dot_product_attention(
117120
mask = pt.as_tensor(mask)
118121
inputs.append(mask)
119122

120-
out = _sdpa_graph(q, k, v, mask, is_causal, scale)
121-
122123
op = AttentionLayer(
123-
inputs=inputs,
124-
outputs=[out],
125124
name="ScaledDotProductAttention",
126125
is_causal=is_causal,
127126
scale=scale,
@@ -184,8 +183,14 @@ def __init__(
184183
self.out_proj = Linear(f"{self.name}_out_proj", n_head * self.head_dim, n_embd, bias)
185184

186185
def _split_heads(self, x: pt.TensorVariable, n_head: int) -> pt.TensorVariable:
187-
# (..., seq, n_head * head_dim) -> (..., n_head, seq, head_dim)
188-
x = x.reshape((*tuple(x.shape[:-1]), n_head, self.head_dim))
186+
# (..., seq, n_head * head_dim) -> (..., n_head, seq, head_dim). Keep statically known leading
187+
# dims as literals so the reshaped tensor retains its full static shape -- a symbolic
188+
# ``x.shape[i]`` would erase it. A static core shape is what lets the numba backend vectorize
189+
# attention (e.g. under ``vectorize_graph``) instead of falling back to object mode.
190+
lead = tuple(
191+
size if size is not None else x.shape[i] for i, size in enumerate(x.type.shape[:-1])
192+
)
193+
x = x.reshape((*lead, n_head, self.head_dim))
189194
return x.swapaxes(-3, -2)
190195

191196
def __call__(self, x: pt.TensorLike, mask: pt.TensorLike | None = None) -> pt.TensorVariable:

‎pytensor_ml/json_serialize.py‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import pytensor.tensor as pt
88

99
from pytensor.graph.basic import Constant, Variable
10-
from pytensor.graph.fg import FrozenFunctionGraph
1110
from pytensor.graph.op import Op
1211
from pytensor.graph.traversal import io_toposort
1312
from pytensor.graph.type import Type
@@ -136,12 +135,10 @@ def _resolve_class(path: str):
136135
return getattr(importlib.import_module(module), name)
137136

138137

139-
def graph_to_json(frozen: FrozenFunctionGraph) -> dict:
140-
"""Serialize a frozen graph to a dict of input types, op nodes, and output references."""
141-
nodes = io_toposort(frozen.inputs, frozen.outputs)
142-
var_ref: dict[int, dict] = {
143-
id(inp): {"input": index} for index, inp in enumerate(frozen.inputs)
144-
}
138+
def graph_to_json(inputs: Sequence[Variable], outputs: Sequence[Variable]) -> dict:
139+
"""Serialize a graph to a dict of input types, op nodes, and output references."""
140+
nodes = io_toposort(inputs, outputs)
141+
var_ref: dict[int, dict] = {id(inp): {"input": index} for index, inp in enumerate(inputs)}
145142
for node_index, node in enumerate(nodes):
146143
for output_index, out in enumerate(node.outputs):
147144
var_ref[id(out)] = {"node": node_index, "out": output_index}
@@ -155,7 +152,7 @@ def make_ref(variable: Variable) -> dict:
155152
raise ValueError(f"Unresolved variable while serializing graph: {variable!r}")
156153

157154
return {
158-
"inputs": [type_to_json(inp.type) for inp in frozen.inputs],
155+
"inputs": [type_to_json(inp.type) for inp in inputs],
159156
"nodes": [
160157
{
161158
"op": op_to_json(node.op),
@@ -164,7 +161,7 @@ def make_ref(variable: Variable) -> dict:
164161
}
165162
for node in nodes
166163
],
167-
"outputs": [make_ref(out) for out in frozen.outputs],
164+
"outputs": [make_ref(out) for out in outputs],
168165
}
169166

170167

@@ -221,8 +218,7 @@ def serialize_graph(inputs: Sequence[Variable], outputs: Sequence[Variable]) ->
221218
dict
222219
A JSON-native description of the graph's structure (no parameter values).
223220
"""
224-
frozen = FrozenFunctionGraph(list(inputs), list(outputs))
225-
return graph_to_json(frozen)
221+
return graph_to_json(list(inputs), list(outputs))
226222

227223

228224
def deserialize_graph(

‎pytensor_ml/layers.py‎

Lines changed: 59 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ def __call__(self, x: pt.TensorLike) -> pt.TensorVariable: ...
3131
class LinearLayer(UnaryLayerOp):
3232
__props__ = ("n_in", "n_out", "bias")
3333

34+
def build_inner_graph(self, X, W, b=None):
35+
res = X @ W
36+
if self.bias:
37+
res = res + b
38+
return [res]
39+
3440

3541
class Linear(Layer):
3642
def __init__(self, name: str | None, n_in: int, n_out: int, bias: bool = True):
@@ -49,20 +55,15 @@ def __init__(self, name: str | None, n_in: int, n_out: int, bias: bool = True):
4955
def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
5056
X = pt.as_tensor(X)
5157

52-
init_st_shape = shape_to_str(X.type.shape)
53-
54-
res = X @ self.W
5558
inputs = [X, self.W]
5659
if self.bias:
57-
res += self.b
5860
inputs.append(self.b)
5961

60-
final_st_shape = shape_to_str(res.type.shape)
62+
input_shape = shape_to_str(X.type.shape)
63+
output_shape = shape_to_str((X @ self.W).type.shape)
6164

6265
ofg = LinearLayer(
63-
inputs=inputs,
64-
outputs=[res],
65-
name=f"{self.name}[{init_st_shape} -> {final_st_shape}]",
66+
name=f"{self.name}[{input_shape} -> {output_shape}]",
6667
n_in=self.n_in,
6768
n_out=self.n_out,
6869
bias=self.bias,
@@ -76,6 +77,9 @@ def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
7677
class EmbeddingLayer(UnaryLayerOp):
7778
__props__ = ("n_embeddings", "n_features")
7879

80+
def build_inner_graph(self, ids, W):
81+
return [W[ids]]
82+
7983

8084
class Embedding(Layer):
8185
r"""
@@ -107,8 +111,6 @@ def __call__(self, ids: pt.TensorLike) -> pt.TensorVariable:
107111
ids = pt.as_tensor(ids)
108112

109113
out = EmbeddingLayer(
110-
inputs=[ids, self.W],
111-
outputs=[self.W[ids]],
112114
name=self.name,
113115
n_embeddings=self.n_embeddings,
114116
n_features=self.n_features,
@@ -121,6 +123,9 @@ def __call__(self, ids: pt.TensorLike) -> pt.TensorVariable:
121123
class DropoutLayer(UnaryLayerOp):
122124
__props__ = ("p",)
123125

126+
def build_inner_graph(self, X, mask):
127+
return [pt.where(mask, ift=X / (1 - self.p), iff=0)]
128+
124129

125130
class Dropout(Layer):
126131
def __init__(self, name: str | None = None, p: float = 0.5, random_state: Any | None = None):
@@ -137,8 +142,6 @@ def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
137142
mask = mask.astype(config.floatX)
138143

139144
X_masked = DropoutLayer(
140-
inputs=[X, mask],
141-
outputs=[pt.where(mask, ift=X / (1 - p), iff=0)],
142145
name=f"{self.name}[p = {self.p}]",
143146
p=self.p,
144147
)(X, mask)
@@ -147,20 +150,52 @@ def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
147150
return X_masked
148151

149152

153+
def _batch_normalize(X, epsilon):
154+
mu = X.mean(axis=0)
155+
sigma_sq = X.var(axis=0)
156+
return (X - mu) / pt.sqrt(sigma_sq + epsilon), mu, sigma_sq
157+
158+
150159
class BatchNormLayer(LayerOp):
151160
__props__ = ("n_in", "epsilon", "momentum", "affine")
152161

153162
def update_map(self):
163+
# Outputs 1 and 2 (the new running mean and variance) update inputs 3 and 4 (the old ones).
154164
return {1: 3, 2: 4}
155165

166+
def build_inner_graph(self, X, *rest):
167+
if self.affine:
168+
loc, scale, running_mean, running_var = rest
169+
else:
170+
running_mean, running_var = rest
171+
172+
X_normalized, mu, sigma_sq = _batch_normalize(X, self.epsilon)
173+
X_rescaled = X_normalized * scale + loc if self.affine else X_normalized
174+
175+
new_running_mean = self.momentum * mu + (1 - self.momentum) * running_mean
176+
new_running_var = self.momentum * sigma_sq + (1 - self.momentum) * running_var
177+
178+
return [X_rescaled, new_running_mean, new_running_var]
179+
156180

157181
class NoRunningStatsBatchNormLayer(UnaryLayerOp):
158182
__props__ = ("n_in", "epsilon", "momentum", "affine")
159183

184+
def build_inner_graph(self, X, *rest):
185+
X_normalized, _, _ = _batch_normalize(X, self.epsilon)
186+
if self.affine:
187+
loc, scale = rest
188+
return [X_normalized * scale + loc]
189+
return [X_normalized]
190+
160191

161192
class PredictionBatchNormLayer(UnaryLayerOp):
162193
__props__ = ("n_in", "epsilon", "momentum", "affine")
163194

195+
def build_inner_graph(self, X, loc, scale, running_mean, running_var):
196+
res = (X - running_mean) / pt.sqrt(running_var + self.epsilon)
197+
return [loc + res * scale]
198+
164199

165200
class BatchNorm2D(Layer):
166201
r"""
@@ -261,26 +296,14 @@ def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
261296

262297
self._initialize_params(X)
263298

264-
mu = X.mean(axis=0)
265-
sigma_sq = X.var(axis=0)
266-
267-
X_normalized = (X - mu) / pt.sqrt(sigma_sq + self.epsilon)
268-
269299
if self.affine:
270300
assert self.scale is not None and self.loc is not None
271-
X_rescaled = X_normalized * self.scale + self.loc
272301
inputs.extend([self.loc, self.scale])
273-
else:
274-
X_rescaled = X_normalized
275302

276303
if self.track_running_stats:
277304
assert self.running_mean is not None and self.running_var is not None
278-
new_running_mean = self.momentum * mu + (1 - self.momentum) * self.running_mean
279-
new_running_var = self.momentum * sigma_sq + (1 - self.momentum) * self.running_var
280305

281306
batch_norm_op: LayerOp = BatchNormLayer(
282-
inputs=[*inputs, self.running_mean, self.running_var],
283-
outputs=[X_rescaled, new_running_mean, new_running_var],
284307
name=self.name,
285308
n_in=self.n_in,
286309
epsilon=self.epsilon,
@@ -294,8 +317,6 @@ def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
294317

295318
else:
296319
batch_norm_op = NoRunningStatsBatchNormLayer(
297-
inputs=inputs,
298-
outputs=[X_rescaled],
299320
name=self.name,
300321
n_in=self.n_in,
301322
epsilon=self.epsilon,
@@ -315,6 +336,18 @@ def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
315336
class LayerNormLayer(UnaryLayerOp):
316337
__props__ = ("n_in", "epsilon", "affine")
317338

339+
def build_inner_graph(self, X, *rest):
340+
mu = X.mean(axis=-1, keepdims=True)
341+
# Biased variance (ddof=0), matching torch.nn.LayerNorm; pretrained weights assume it. Do
342+
# not "correct" to the unbiased estimator -- it would diverge from every pretrained model.
343+
sigma_sq = X.var(axis=-1, keepdims=True)
344+
X_normalized = (X - mu) / pt.sqrt(sigma_sq + self.epsilon)
345+
346+
if self.affine:
347+
scale, loc = rest
348+
return [X_normalized * scale + loc]
349+
return [X_normalized]
350+
318351

319352
class LayerNorm(Layer):
320353
r"""
@@ -380,23 +413,12 @@ def __call__(self, X: pt.TensorLike) -> pt.TensorVariable:
380413
X = pt.as_tensor(X)
381414
self._initialize_params(X)
382415

383-
mu = X.mean(axis=-1, keepdims=True)
384-
# Biased variance (ddof=0), matching torch.nn.LayerNorm; pretrained weights assume it. Do
385-
# not "correct" to the unbiased estimator -- it would diverge from every pretrained model.
386-
sigma_sq = X.var(axis=-1, keepdims=True)
387-
X_normalized = (X - mu) / pt.sqrt(sigma_sq + self.epsilon)
388-
389416
inputs = [X]
390417
if self.affine:
391418
assert self.scale is not None and self.loc is not None
392-
X_rescaled = X_normalized * self.scale + self.loc
393419
inputs.extend([self.scale, self.loc])
394-
else:
395-
X_rescaled = X_normalized
396420

397421
X_transformed = LayerNormLayer(
398-
inputs=inputs,
399-
outputs=[X_rescaled],
400422
name=self.name,
401423
n_in=self.n_in,
402424
epsilon=self.epsilon,

‎pytensor_ml/pytensorf.py‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from pytensor import Mode
1010
from pytensor.compile import Function, SharedVariable, get_mode
11-
from pytensor.compile.builders import OpFromGraph
11+
from pytensor.compile.builders import OpFromGraph, SymbolicOp
1212
from pytensor.graph import FunctionGraph, RewriteDatabaseQuery, graph_inputs, rewrite_graph
1313
from pytensor.graph.basic import Apply, Constant, equal_computations
1414
from pytensor.graph.fg import Output
@@ -22,19 +22,18 @@
2222
RandomSeed = None | int | Sequence[int] | np.ndarray
2323

2424

25-
class LayerOp(OpFromGraph):
26-
# This can be removed once https://github.com/pymc-devs/pytensor/issues/1114 is fixed
25+
class LayerOp(SymbolicOp):
26+
"""Base class for the library's neural-network ops.
2727
28-
__props__: tuple[str, ...] = ()
29-
30-
def __init__(self, *args, **kwargs):
31-
prop_kwargs = {key: value for key, value in kwargs.items() if key in self.__props__}
32-
kwargs = {key: value for key, value in kwargs.items() if key not in self.__props__}
33-
34-
for key, value in prop_kwargs.items():
35-
setattr(self, key, value)
28+
A ``SymbolicOp`` is an ``OpFromGraph`` whose inner graph is rebuilt from its ``__props__`` and input
29+
types by :meth:`build_inner_graph`, so equal props with equal inputs yield an identical op. Basing the
30+
layers on it (rather than plain ``OpFromGraph``) is what lets the numba backend optimize each inner
31+
graph: ``SymbolicOp`` restores the fgraph-aware ``__eq__``/``__hash__`` that a props-carrying
32+
``OpFromGraph`` would otherwise lose, so the ``ofg_inner_graph`` rewrite keeps its optimized inner
33+
graph instead of discarding it as unchanged.
34+
"""
3635

37-
super().__init__(*args, **kwargs)
36+
__props__: tuple[str, ...] = ()
3837

3938
def update_map(self) -> dict[int, int]:
4039
"""Return a mapping of output indexes to input indexes"""
@@ -44,7 +43,7 @@ def update_map(self) -> dict[int, int]:
4443
class UnaryLayerOp(LayerOp):
4544
"""A ``LayerOp`` with exactly one output, typed as such.
4645
47-
``OpFromGraph.__call__`` is annotated ``Variable | list[Variable]`` because an op may produce many
46+
``SymbolicOp.__call__`` is annotated ``Variable | list[Variable]`` because an op may produce many
4847
outputs; a unary layer op produces one, so narrow the result to ``TensorVariable``. The ``isinstance``
4948
guard narrows for the type checker without a cast and asserts the invariant at runtime.
5049
"""

‎pytensor_ml/rewriting/layers.py‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import pytensor.tensor as pt
2-
31
from pytensor.graph.basic import Apply
42
from pytensor.graph.fg import FunctionGraph
53
from pytensor.graph.rewriting.basic import node_rewriter
@@ -60,12 +58,7 @@ def rewrite_batch_stats_to_running_average_stats(
6058
"""
6159
X, loc, scale, running_mean, running_var = node.inputs
6260

63-
res = (X - running_mean) / pt.sqrt(running_var + node.op.epsilon) # type: ignore[operator]
64-
res = loc + res * scale
65-
6661
batch_norm_op = PredictionBatchNormLayer(
67-
inputs=[X, loc, scale, running_mean, running_var],
68-
outputs=[res],
6962
name=f"{node.op.name}",
7063
n_in=node.op.n_in,
7164
momentum=node.op.momentum,

0 commit comments

Comments
 (0)