Skip to content

Commit 96defca

Browse files
Add save_network/save_pretrained and load counterparts
1 parent a7f0b7f commit 96defca

4 files changed

Lines changed: 398 additions & 3 deletions

File tree

‎pytensor_ml/__init__.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from pytensor_ml._version import __version__
55
from pytensor_ml.checkpoint import load_state, save_state
6+
from pytensor_ml.pretrained import from_pretrained, load_network, save_network, save_pretrained
67
from pytensor_ml.pytensorf import function
78

89
_log = logging.getLogger(__name__)
@@ -14,4 +15,13 @@
1415
_log.addHandler(handler)
1516

1617

17-
__all__ = ["__version__", "function", "load_state", "save_state"]
18+
__all__ = [
19+
"__version__",
20+
"from_pretrained",
21+
"function",
22+
"load_network",
23+
"load_state",
24+
"save_network",
25+
"save_pretrained",
26+
"save_state",
27+
]

‎pytensor_ml/params.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def collect_graph_inputs(
104104

105105

106106
def collect_shared_variables(
107-
outputs: TensorVariable | Sequence[TensorVariable],
107+
outputs: Variable | Sequence[Variable],
108108
) -> list[SharedVariable]:
109109
"""
110110
Collect all SharedVariables from a computation graph.
@@ -208,7 +208,7 @@ def collect_non_trainable_updates(
208208

209209

210210
def collect_data_inputs(
211-
outputs: TensorVariable | Sequence[TensorVariable],
211+
outputs: Variable | Sequence[Variable],
212212
) -> list[TensorVariable]:
213213
"""
214214
Extract data inputs from a graph (inputs that are not SharedVariables).

‎pytensor_ml/pretrained.py‎

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import json
2+
3+
from collections.abc import Sequence
4+
from enum import StrEnum
5+
from pathlib import Path
6+
from typing import Literal
7+
8+
import numpy as np
9+
import pytensor
10+
11+
from pytensor.compile.sharedvalue import SharedVariable
12+
from pytensor.graph.basic import Variable
13+
from pytensor.tensor.random.type import RandomGeneratorType
14+
15+
from pytensor_ml.checkpoint import load_state, save_state
16+
from pytensor_ml.json_serialize import deserialize_graph, serialize_graph, type_from_json
17+
from pytensor_ml.params import (
18+
NonTrainableParameter,
19+
TrainableParameter,
20+
collect_data_inputs,
21+
collect_shared_variables,
22+
non_trainable,
23+
trainable,
24+
)
25+
from pytensor_ml.pytensorf import find_rng_nodes
26+
27+
CONFIG_FILENAME = "config.json"
28+
WEIGHTS_FILENAME = "model.safetensors"
29+
30+
# Marks a config as a pytensor_ml graph (vs a HuggingFace config, which shares the config.json filename but
31+
# is a hyperparameter sheet, not a serialized graph). The version guards future schema changes.
32+
GRAPH_FORMAT = "pytensor_ml.graph"
33+
GRAPH_FORMAT_VERSION = 1
34+
35+
Format = Literal["auto", "pytensor", "huggingface"]
36+
37+
38+
class InputKind(StrEnum):
39+
"""How a serialized graph input is rebuilt: as a data placeholder or a kind of shared variable."""
40+
41+
DATA = "data"
42+
TRAINABLE = "trainable"
43+
NON_TRAINABLE = "non_trainable"
44+
SHARED = "shared"
45+
RNG = "rng"
46+
47+
48+
def _looks_like_huggingface(config: dict) -> bool:
49+
return "model_type" in config or "architectures" in config
50+
51+
52+
def _detect_format(config: dict) -> Format:
53+
if config.get("format") == GRAPH_FORMAT:
54+
return "pytensor"
55+
if _looks_like_huggingface(config):
56+
return "huggingface"
57+
raise ValueError("Unrecognized config: not a pytensor_ml graph or a HuggingFace model.")
58+
59+
60+
def _as_output_list(outputs: Variable | Sequence[Variable]) -> list[Variable]:
61+
return [outputs] if isinstance(outputs, Variable) else list(outputs)
62+
63+
64+
def _weight_variables(outputs: Variable | Sequence[Variable]) -> list[SharedVariable]:
65+
# The parameter shared variables that go to safetensors -- everything except random generators, whose
66+
# state is JSON, not a tensor, and is carried in the config.
67+
output_list = _as_output_list(outputs)
68+
random_generators = set(find_rng_nodes(output_list))
69+
return [
70+
variable
71+
for variable in collect_shared_variables(output_list)
72+
if variable not in random_generators
73+
]
74+
75+
76+
def _input_kind(variable: Variable) -> InputKind:
77+
if isinstance(variable.type, RandomGeneratorType):
78+
return InputKind.RNG
79+
if isinstance(variable, TrainableParameter):
80+
return InputKind.TRAINABLE
81+
if isinstance(variable, NonTrainableParameter):
82+
return InputKind.NON_TRAINABLE
83+
if isinstance(variable, SharedVariable):
84+
return InputKind.SHARED
85+
return InputKind.DATA
86+
87+
88+
def _input_meta(variable: Variable) -> dict:
89+
meta = {"name": variable.name, "kind": _input_kind(variable)}
90+
if isinstance(variable, SharedVariable) and meta["kind"] == InputKind.RNG:
91+
# Capture the generator state so a checkpoint can be reproduced exactly, even though it is not
92+
# restored by default.
93+
meta["rng_state"] = variable.get_value(borrow=True).bit_generator.state
94+
return meta
95+
96+
97+
def _rebuild_input(type_json: dict, meta: dict, restore_rng: bool):
98+
kind, name = meta["kind"], meta["name"]
99+
if kind == InputKind.DATA:
100+
return type_from_json(type_json)(name=name)
101+
if kind == InputKind.RNG:
102+
generator = np.random.default_rng()
103+
if restore_rng:
104+
generator.bit_generator.state = meta["rng_state"]
105+
return pytensor.shared(generator, name=name)
106+
107+
graph_type = type_from_json(type_json)
108+
placeholder = np.zeros(graph_type.shape, dtype=graph_type.dtype)
109+
if kind == InputKind.TRAINABLE:
110+
return trainable(placeholder, name)
111+
if kind == InputKind.NON_TRAINABLE:
112+
return non_trainable(placeholder, name)
113+
return pytensor.shared(placeholder, name=name)
114+
115+
116+
def save_network(
117+
outputs: Variable | Sequence[Variable],
118+
path: str | Path,
119+
*,
120+
inputs: Sequence[Variable] | None = None,
121+
) -> None:
122+
"""
123+
Serialize a network's architecture (its graph) to a JSON config file, without parameter values.
124+
125+
Records each input's name and kind (data, trainable, non-trainable, or plain shared) so
126+
:func:`load_network` can rebuild the graph with the right variable identities. The data inputs are
127+
collected from ``outputs`` unless given explicitly.
128+
129+
Parameters
130+
----------
131+
outputs : Variable or sequence of Variable
132+
The network's output(s).
133+
path : str or pathlib.Path
134+
Destination JSON file.
135+
inputs : sequence of Variable, optional
136+
The network's data inputs, in call order. Collected from ``outputs`` when omitted; pass explicitly
137+
when call order matters.
138+
"""
139+
output_list = _as_output_list(outputs)
140+
data_inputs = list(inputs) if inputs is not None else collect_data_inputs(output_list)
141+
leaves = [*data_inputs, *collect_shared_variables(output_list)]
142+
143+
config = {"format": GRAPH_FORMAT, "format_version": GRAPH_FORMAT_VERSION}
144+
config.update(serialize_graph(leaves, output_list))
145+
config["input_meta"] = [_input_meta(leaf) for leaf in leaves]
146+
config["n_outputs"] = len(output_list)
147+
Path(path).write_text(json.dumps(config))
148+
149+
150+
def load_network(
151+
path: str | Path, *, restore_rng: bool = False
152+
) -> tuple[list[Variable], Variable | list[Variable]]:
153+
"""
154+
Rebuild a network's graph from a :func:`save_network` config file.
155+
156+
Shared variables (parameters) come back zero-initialized and keep their original names and kinds, so a
157+
subsequent :func:`load_state` (or :func:`from_pretrained`) can fill them by name. This restores the
158+
architecture only.
159+
160+
Parameters
161+
----------
162+
path : str or pathlib.Path
163+
A config file written by :func:`save_network`.
164+
restore_rng : bool
165+
If True, restore each random generator to its saved state for exact reproducibility. By default a
166+
fresh generator is created, so stochastic layers draw new randomness. Default False.
167+
168+
Returns
169+
-------
170+
inputs : list of Variable
171+
The network's data inputs, in call order.
172+
outputs : Variable or list of Variable
173+
The rebuilt output(s) -- a single variable when the network has one output, otherwise a list.
174+
"""
175+
config = json.loads(Path(path).read_text())
176+
if config.get("format") != GRAPH_FORMAT:
177+
hint = " (this looks like a HuggingFace config)" if _looks_like_huggingface(config) else ""
178+
raise ValueError(f"{path} is not a pytensor_ml network config{hint}.")
179+
leaves = [
180+
_rebuild_input(type_json, meta, restore_rng)
181+
for type_json, meta in zip(config["inputs"], config["input_meta"])
182+
]
183+
_, outputs = deserialize_graph(config, inputs=leaves)
184+
185+
data_inputs = [
186+
leaf for leaf, meta in zip(leaves, config["input_meta"]) if meta["kind"] == InputKind.DATA
187+
]
188+
return data_inputs, (outputs[0] if config["n_outputs"] == 1 else outputs)
189+
190+
191+
def save_pretrained(
192+
outputs: Variable | Sequence[Variable],
193+
directory: str | Path,
194+
*,
195+
inputs: Sequence[Variable] | None = None,
196+
) -> None:
197+
"""
198+
Save a complete network -- architecture and weights -- to a directory.
199+
200+
Writes ``config.json`` (the graph) and ``model.safetensors`` (the parameter values), the layout
201+
:func:`from_pretrained` expects.
202+
203+
Parameters
204+
----------
205+
outputs : Variable or sequence of Variable
206+
The network's output(s).
207+
directory : str or pathlib.Path
208+
Destination directory, created if needed.
209+
inputs : sequence of Variable, optional
210+
The network's data inputs, in call order. Collected from ``outputs`` when omitted.
211+
"""
212+
directory = Path(directory)
213+
directory.mkdir(parents=True, exist_ok=True)
214+
save_network(outputs, directory / CONFIG_FILENAME, inputs=inputs)
215+
save_state(_weight_variables(outputs), directory / WEIGHTS_FILENAME)
216+
217+
218+
def from_pretrained(
219+
directory: str | Path, source_format: Format = "auto", *, restore_rng: bool = False
220+
) -> tuple[list[Variable], Variable | list[Variable]]:
221+
"""
222+
Load a complete network -- architecture and weights -- from a directory.
223+
224+
For a pytensor_ml directory, rebuilds the graph from ``config.json`` and fills its parameters from
225+
``model.safetensors``. The format is detected from the config by default, since a pytensor_ml graph and
226+
a HuggingFace model share the same filenames but not the same schema.
227+
228+
Parameters
229+
----------
230+
directory : str or pathlib.Path
231+
A directory holding ``config.json`` and ``model.safetensors``.
232+
source_format : {'auto', 'pytensor', 'huggingface'}
233+
Which loader to use. ``'auto'`` detects the format from the config's marker. Default 'auto'.
234+
restore_rng : bool
235+
If True, restore each random generator to its saved state for exact reproducibility. Default False.
236+
237+
Returns
238+
-------
239+
inputs : list of Variable
240+
The network's data inputs, in call order.
241+
outputs : Variable or list of Variable
242+
The rebuilt, weight-filled output(s).
243+
"""
244+
directory = Path(directory)
245+
if source_format == "auto":
246+
source_format = _detect_format(json.loads((directory / CONFIG_FILENAME).read_text()))
247+
if source_format == "huggingface":
248+
raise NotImplementedError(
249+
"Loading HuggingFace models is not yet supported; pass a pytensor_ml directory."
250+
)
251+
252+
data_inputs, outputs = load_network(directory / CONFIG_FILENAME, restore_rng=restore_rng)
253+
load_state(_weight_variables(outputs), directory / WEIGHTS_FILENAME)
254+
return data_inputs, outputs

0 commit comments

Comments
 (0)