epgx 0.1.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- epgx-0.1.dev0/LICENSE +28 -0
- epgx-0.1.dev0/PKG-INFO +64 -0
- epgx-0.1.dev0/README.md +33 -0
- epgx-0.1.dev0/pyproject.toml +65 -0
- epgx-0.1.dev0/setup.cfg +4 -0
- epgx-0.1.dev0/src/epgx/__init__.py +19 -0
- epgx-0.1.dev0/src/epgx/axes.py +285 -0
- epgx-0.1.dev0/src/epgx/common.py +63 -0
- epgx-0.1.dev0/src/epgx/compiler.py +347 -0
- epgx-0.1.dev0/src/epgx/diff.py +242 -0
- epgx-0.1.dev0/src/epgx/examples.py +146 -0
- epgx-0.1.dev0/src/epgx/functions.py +602 -0
- epgx-0.1.dev0/src/epgx/kernels/__init__.py +9 -0
- epgx-0.1.dev0/src/epgx/kernels/affine.py +187 -0
- epgx-0.1.dev0/src/epgx/kernels/evolution.py +40 -0
- epgx-0.1.dev0/src/epgx/kernels/operator.py +32 -0
- epgx-0.1.dev0/src/epgx/kernels/probe.py +107 -0
- epgx-0.1.dev0/src/epgx/kernels/shift.py +106 -0
- epgx-0.1.dev0/src/epgx/kernels/state.py +447 -0
- epgx-0.1.dev0/src/epgx/kernels/transition.py +48 -0
- epgx-0.1.dev0/src/epgx/library/__init__.py +3 -0
- epgx-0.1.dev0/src/epgx/library/sequences.py +210 -0
- epgx-0.1.dev0/src/epgx/lowering.py +95 -0
- epgx-0.1.dev0/src/epgx/merging.py +137 -0
- epgx-0.1.dev0/src/epgx/methods.py +329 -0
- epgx-0.1.dev0/src/epgx/ops/__init__.py +30 -0
- epgx-0.1.dev0/src/epgx/ops/dephasing.py +71 -0
- epgx-0.1.dev0/src/epgx/ops/evolution.py +135 -0
- epgx-0.1.dev0/src/epgx/ops/functions.py +158 -0
- epgx-0.1.dev0/src/epgx/ops/operator.py +224 -0
- epgx-0.1.dev0/src/epgx/ops/probe.py +116 -0
- epgx-0.1.dev0/src/epgx/ops/shift.py +118 -0
- epgx-0.1.dev0/src/epgx/ops/transition.py +58 -0
- epgx-0.1.dev0/src/epgx/plotting.py +226 -0
- epgx-0.1.dev0/src/epgx/progress.py +81 -0
- epgx-0.1.dev0/src/epgx/sample.py +142 -0
- epgx-0.1.dev0/src/epgx/sequences.py +850 -0
- epgx-0.1.dev0/src/epgx/stats.py +117 -0
- epgx-0.1.dev0/src/epgx.egg-info/PKG-INFO +64 -0
- epgx-0.1.dev0/src/epgx.egg-info/SOURCES.txt +67 -0
- epgx-0.1.dev0/src/epgx.egg-info/dependency_links.txt +1 -0
- epgx-0.1.dev0/src/epgx.egg-info/requires.txt +16 -0
- epgx-0.1.dev0/src/epgx.egg-info/top_level.txt +1 -0
- epgx-0.1.dev0/tests/test_axes.py +179 -0
- epgx-0.1.dev0/tests/test_benchmarks.py +75 -0
- epgx-0.1.dev0/tests/test_compiler.py +279 -0
- epgx-0.1.dev0/tests/test_dephasing.py +146 -0
- epgx-0.1.dev0/tests/test_diff.py +117 -0
- epgx-0.1.dev0/tests/test_evolution.py +130 -0
- epgx-0.1.dev0/tests/test_examples.py +107 -0
- epgx-0.1.dev0/tests/test_functions.py +309 -0
- epgx-0.1.dev0/tests/test_isochromats.py +284 -0
- epgx-0.1.dev0/tests/test_jit.py +102 -0
- epgx-0.1.dev0/tests/test_lattice_nd.py +141 -0
- epgx-0.1.dev0/tests/test_library.py +159 -0
- epgx-0.1.dev0/tests/test_merging.py +250 -0
- epgx-0.1.dev0/tests/test_operator.py +33 -0
- epgx-0.1.dev0/tests/test_ops.py +234 -0
- epgx-0.1.dev0/tests/test_package.py +11 -0
- epgx-0.1.dev0/tests/test_plotting.py +127 -0
- epgx-0.1.dev0/tests/test_probe.py +139 -0
- epgx-0.1.dev0/tests/test_progress.py +119 -0
- epgx-0.1.dev0/tests/test_sample.py +202 -0
- epgx-0.1.dev0/tests/test_sequences.py +413 -0
- epgx-0.1.dev0/tests/test_shift.py +131 -0
- epgx-0.1.dev0/tests/test_state.py +277 -0
- epgx-0.1.dev0/tests/test_stats.py +104 -0
- epgx-0.1.dev0/tests/test_trace.py +146 -0
- epgx-0.1.dev0/tests/test_transition.py +133 -0
epgx-0.1.dev0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023-2026, py-baudin
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
epgx-0.1.dev0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: epgx
|
|
3
|
+
Version: 0.1.dev0
|
|
4
|
+
Summary: Extended Phase Graph (EPG) simulations in JAX
|
|
5
|
+
Author: Pierre-Yves Baudin
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Repository, https://github.com/py-baudin/epgx
|
|
8
|
+
Keywords: MRI,extended phase graph,EPG,simulation,JAX,differentiable
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: jax
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest; extra == "test"
|
|
22
|
+
Provides-Extra: plot
|
|
23
|
+
Requires-Dist: matplotlib; extra == "plot"
|
|
24
|
+
Requires-Dist: cmocean; extra == "plot"
|
|
25
|
+
Provides-Extra: xarray
|
|
26
|
+
Requires-Dist: xarray; extra == "xarray"
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: epgx[plot,test,xarray]; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff; extra == "dev"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# epgx
|
|
33
|
+
|
|
34
|
+
A JAX implementation of the Extended Phase Graph (EPG) formalism, successor of
|
|
35
|
+
[epgpy](https://github.com/py-baudin/epgpy).
|
|
36
|
+
|
|
37
|
+
Work in progress: the API may change between versions.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
``` shell
|
|
42
|
+
pip install epgx # core (jax, numpy)
|
|
43
|
+
pip install epgx[plot] # with EPG diagrams (matplotlib, cmocean)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Development:
|
|
47
|
+
|
|
48
|
+
``` shell
|
|
49
|
+
conda create -n epgx python=3.12
|
|
50
|
+
conda activate epgx
|
|
51
|
+
pip install -e .[dev]
|
|
52
|
+
pip install --group parity # epgpy, for the parity tests (tests/parity)
|
|
53
|
+
pytest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Precision
|
|
57
|
+
|
|
58
|
+
`epgx` follows JAX's precision setting (float32 by default) and never changes it.
|
|
59
|
+
For float64, enable it at the start of your program:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import jax
|
|
63
|
+
jax.config.update("jax_enable_x64", True)
|
|
64
|
+
```
|
epgx-0.1.dev0/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# epgx
|
|
2
|
+
|
|
3
|
+
A JAX implementation of the Extended Phase Graph (EPG) formalism, successor of
|
|
4
|
+
[epgpy](https://github.com/py-baudin/epgpy).
|
|
5
|
+
|
|
6
|
+
Work in progress: the API may change between versions.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
``` shell
|
|
11
|
+
pip install epgx # core (jax, numpy)
|
|
12
|
+
pip install epgx[plot] # with EPG diagrams (matplotlib, cmocean)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Development:
|
|
16
|
+
|
|
17
|
+
``` shell
|
|
18
|
+
conda create -n epgx python=3.12
|
|
19
|
+
conda activate epgx
|
|
20
|
+
pip install -e .[dev]
|
|
21
|
+
pip install --group parity # epgpy, for the parity tests (tests/parity)
|
|
22
|
+
pytest
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Precision
|
|
26
|
+
|
|
27
|
+
`epgx` follows JAX's precision setting (float32 by default) and never changes it.
|
|
28
|
+
For float64, enable it at the start of your program:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import jax
|
|
32
|
+
jax.config.update("jax_enable_x64", True)
|
|
33
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "epgx"
|
|
7
|
+
description = "Extended Phase Graph (EPG) simulations in JAX"
|
|
8
|
+
version = "0.1.dev0"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "BSD-3-Clause"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Pierre-Yves Baudin" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = ["jax", "numpy"]
|
|
15
|
+
keywords = ["MRI", "extended phase graph", "EPG", "simulation", "JAX", "differentiable"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Medical Science Apps.",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Repository = "https://github.com/py-baudin/epgx"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = ["pytest"]
|
|
30
|
+
plot = ["matplotlib", "cmocean"]
|
|
31
|
+
xarray = ["xarray"]
|
|
32
|
+
dev = ["epgx[test,plot,xarray]", "ruff"]
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
# reference implementation for parity tests (pinned commit); not published
|
|
36
|
+
# (PyPI rejects direct URL dependencies): `pip install --group parity`
|
|
37
|
+
parity = [
|
|
38
|
+
"epgpy @ git+https://github.com/py-baudin/epgpy@82eebbf0e8a59b100682758c2a04a889fc1ee55c",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
# importlib: allows test modules with the same name in tests/ and tests/parity/
|
|
47
|
+
addopts = "--import-mode=importlib"
|
|
48
|
+
markers = [
|
|
49
|
+
"parity: comparison with epgpy (reference implementation)",
|
|
50
|
+
"slow: long tests (skip with -m \"not slow\")",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 88
|
|
55
|
+
target-version = "py311"
|
|
56
|
+
extend-exclude = ["*.md"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "W", "I"]
|
|
60
|
+
# E741: allow EPG-style short names (e.g. `l`), E501 left to the formatter
|
|
61
|
+
ignore = ["E741", "E501"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.per-file-ignores]
|
|
64
|
+
# re-exports
|
|
65
|
+
"__init__.py" = ["F401"]
|
epgx-0.1.dev0/setup.cfg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Extended Phase Graph (EPG) simulations in JAX
|
|
2
|
+
|
|
3
|
+
Note: numerical precision follows JAX's configuration (float32 by default).
|
|
4
|
+
For float64 precision, enable x64 *before* any computation:
|
|
5
|
+
|
|
6
|
+
import jax
|
|
7
|
+
jax.config.update("jax_enable_x64", True)
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.dev0"
|
|
12
|
+
|
|
13
|
+
from .axes import Axis, resolve
|
|
14
|
+
from .functions import Trace, get_adc_times, getnshift, simulate, trace
|
|
15
|
+
from .lowering import compile_sequence
|
|
16
|
+
from .methods import Isochromats, Lattice
|
|
17
|
+
from .ops import DephasingAxis, kAxis, tAxis
|
|
18
|
+
from .sample import Sample, Scanner
|
|
19
|
+
from .sequences import Sequence, sequence
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Named axes and resolution to a positional layout
|
|
2
|
+
|
|
3
|
+
Batch dimensions are either:
|
|
4
|
+
- named: `Axis(name, values)` (`name` may be a tuple for nd values)
|
|
5
|
+
- unnamed (positional): non-scalar arrays, aligned with the implicit axes
|
|
6
|
+
dim_0, dim_1... (left-aligned by default, cf. `align`)
|
|
7
|
+
|
|
8
|
+
`resolve` collects all axes of a set of pytrees (sequence, sample, scanner...),
|
|
9
|
+
orders them (positional axes first, then named axes by first appearance, or
|
|
10
|
+
explicit `dims`), and converts every leaf into an array broadcastable to the
|
|
11
|
+
common batch shape. This is done once, on static shapes, before simulation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import jax
|
|
15
|
+
import jax.numpy as jnp
|
|
16
|
+
|
|
17
|
+
from .common import pytree, static
|
|
18
|
+
|
|
19
|
+
# alignment of unnamed arrays: "left" (default) or "right" (NumPy)
|
|
20
|
+
ALIGNMENT = "left"
|
|
21
|
+
|
|
22
|
+
POSITIONAL = "dim_{}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytree
|
|
26
|
+
class Axis:
|
|
27
|
+
"""Named array (named axis when 1d)
|
|
28
|
+
|
|
29
|
+
The names cover the leading dimensions of the values; the remaining
|
|
30
|
+
dimensions are core dimensions, declared by `@sequence` variables (e.g.
|
|
31
|
+
`Axis("candidate", alphas)` of shape (10, 400) for `alpha[n]`). At layer 2
|
|
32
|
+
nothing is declared, so every dimension needs a name (checked when the
|
|
33
|
+
axes are collected, cf. `get_layout`).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
name: axis name (str), or tuple of names (leading dimensions of values)
|
|
37
|
+
values: array with at least one dimension per name
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
values: jax.Array = None
|
|
41
|
+
name: object = static()
|
|
42
|
+
|
|
43
|
+
def __init__(self, name, values):
|
|
44
|
+
values = jnp.asarray(values)
|
|
45
|
+
names = (name,) if isinstance(name, str) else tuple(name)
|
|
46
|
+
if not all(isinstance(n, str) for n in names):
|
|
47
|
+
raise TypeError(f"Axis names must be strings: {name}")
|
|
48
|
+
elif len(set(names)) != len(names):
|
|
49
|
+
raise ValueError(f"Duplicate axis names: {names}")
|
|
50
|
+
elif values.ndim < len(names):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"More Axis names than dimensions: {names}, {values.shape}"
|
|
53
|
+
)
|
|
54
|
+
object.__setattr__(self, "values", values)
|
|
55
|
+
object.__setattr__(self, "name", names[0] if len(names) == 1 else names)
|
|
56
|
+
|
|
57
|
+
def __repr__(self):
|
|
58
|
+
return f"Axis({self.name!r}, <{tuple(self.shape)}>)"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def names(self):
|
|
62
|
+
return (self.name,) if isinstance(self.name, str) else tuple(self.name)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def shape(self):
|
|
66
|
+
return jnp.shape(self.values)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def sizes(self):
|
|
70
|
+
"""dict of axis sizes"""
|
|
71
|
+
return dict(zip(self.names, self.shape))
|
|
72
|
+
|
|
73
|
+
def expand(self, names, sizes=None):
|
|
74
|
+
"""return values with axes ordered as `names` (missing axes: size 1 or sizes)"""
|
|
75
|
+
values = place(self.values, self.names, names)
|
|
76
|
+
if sizes is not None:
|
|
77
|
+
values = jnp.broadcast_to(values, tuple(sizes[n] for n in names))
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_axis(obj):
|
|
82
|
+
return isinstance(obj, Axis)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def align(value, ndim, alignment=None):
|
|
86
|
+
"""expand an unnamed array to ndim positional axes
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
value: array with at most ndim dimensions
|
|
90
|
+
ndim: number of positional axes
|
|
91
|
+
alignment: "left" (new axes appended) or "right" (prepended,
|
|
92
|
+
NumPy), default: ALIGNMENT
|
|
93
|
+
"""
|
|
94
|
+
value = jnp.asarray(value)
|
|
95
|
+
return jnp.reshape(value, align_shape(value.shape, ndim, alignment))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def align_shape(shape, ndim, alignment=None):
|
|
99
|
+
"""aligned shape (cf. `align`)"""
|
|
100
|
+
alignment = alignment or ALIGNMENT
|
|
101
|
+
shape = tuple(shape)
|
|
102
|
+
diff = ndim - len(shape)
|
|
103
|
+
if diff < 0:
|
|
104
|
+
raise ValueError(f"Cannot align shape {shape} to {ndim} dimensions")
|
|
105
|
+
elif alignment == "left":
|
|
106
|
+
return shape + (1,) * diff
|
|
107
|
+
elif alignment == "right":
|
|
108
|
+
return (1,) * diff + shape
|
|
109
|
+
raise ValueError(f"Unknown alignment: {alignment}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def place(values, names, dims):
|
|
113
|
+
"""transpose values (with given axis names) to `dims`, insert missing axes"""
|
|
114
|
+
names = tuple(names)
|
|
115
|
+
if not set(names) <= set(dims):
|
|
116
|
+
raise ValueError(f"Unknown axes {set(names) - set(dims)} in {dims}")
|
|
117
|
+
perm = sorted(range(len(names)), key=lambda i: dims.index(names[i]))
|
|
118
|
+
values = jnp.transpose(values, perm)
|
|
119
|
+
ordered = [names[i] for i in perm]
|
|
120
|
+
shape = [values.shape[ordered.index(d)] if d in names else 1 for d in dims]
|
|
121
|
+
return jnp.reshape(values, shape)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytree
|
|
125
|
+
class Layout:
|
|
126
|
+
"""Batch layout: ordered axis names and sizes
|
|
127
|
+
|
|
128
|
+
Attributes:
|
|
129
|
+
dims: (static) ordered axis names
|
|
130
|
+
shape: (static) axis sizes
|
|
131
|
+
npos: (static) number of positional axes
|
|
132
|
+
alignment: (static) alignment of unnamed arrays
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
dims: tuple = static(())
|
|
136
|
+
shape: tuple = static(())
|
|
137
|
+
npos: int = static(0)
|
|
138
|
+
alignment: str = static("left")
|
|
139
|
+
|
|
140
|
+
def __init__(self, dims=(), shape=(), npos=0, alignment=None):
|
|
141
|
+
object.__setattr__(self, "dims", tuple(dims))
|
|
142
|
+
object.__setattr__(self, "shape", tuple(shape))
|
|
143
|
+
object.__setattr__(self, "npos", npos)
|
|
144
|
+
object.__setattr__(self, "alignment", alignment or ALIGNMENT)
|
|
145
|
+
|
|
146
|
+
def __repr__(self):
|
|
147
|
+
axes = ", ".join(f"{d}={s}" for d, s in zip(self.dims, self.shape))
|
|
148
|
+
return f"Layout({axes})"
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def positional(self):
|
|
152
|
+
"""names of the positional axes"""
|
|
153
|
+
return tuple(POSITIONAL.format(i) for i in range(self.npos))
|
|
154
|
+
|
|
155
|
+
def names_of(self, value):
|
|
156
|
+
"""axis names of a leaf (Axis or unnamed array)"""
|
|
157
|
+
if is_axis(value):
|
|
158
|
+
return value.names
|
|
159
|
+
ndim = jnp.ndim(value)
|
|
160
|
+
if ndim == 0:
|
|
161
|
+
return ()
|
|
162
|
+
elif self.alignment == "left":
|
|
163
|
+
return self.positional[:ndim]
|
|
164
|
+
return self.positional[self.npos - ndim :]
|
|
165
|
+
|
|
166
|
+
def index(self, axis):
|
|
167
|
+
"""position of an axis given by name, or by positional index (int)"""
|
|
168
|
+
if isinstance(axis, str):
|
|
169
|
+
if axis not in self.dims:
|
|
170
|
+
raise ValueError(f"Unknown axis: {axis}, available: {self.dims}")
|
|
171
|
+
return self.dims.index(axis)
|
|
172
|
+
positional = self.positional
|
|
173
|
+
if not -len(positional) <= axis < len(positional):
|
|
174
|
+
raise ValueError(f"Invalid positional axis {axis} for {self.npos} axes")
|
|
175
|
+
return self.dims.index(positional[axis])
|
|
176
|
+
|
|
177
|
+
def reduced_dims(self, reduce):
|
|
178
|
+
"""axis names remaining after reduction
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
reduce: None/False (no reduction), True (all axes),
|
|
182
|
+
or tuple of positions (e.g. resolved `Adc.reduce`)
|
|
183
|
+
"""
|
|
184
|
+
if reduce is None or reduce is False:
|
|
185
|
+
return self.dims
|
|
186
|
+
elif reduce is True:
|
|
187
|
+
return ()
|
|
188
|
+
return tuple(d for i, d in enumerate(self.dims) if i not in reduce)
|
|
189
|
+
|
|
190
|
+
def convert(self, value):
|
|
191
|
+
"""convert a leaf to an array broadcastable to the batch shape"""
|
|
192
|
+
if is_axis(value):
|
|
193
|
+
return place(value.values, value.names, self.dims)
|
|
194
|
+
elif jnp.ndim(value) == 0:
|
|
195
|
+
return value
|
|
196
|
+
aligned = align(value, self.npos, self.alignment)
|
|
197
|
+
return place(aligned, self.positional, self.dims)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_layout(*trees, dims=None, alignment=None):
|
|
201
|
+
"""collect the batch axes of pytrees (Axis or unnamed array leaves)
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
trees: pytrees (sequence, sample, scanner...)
|
|
205
|
+
dims: explicit order of all axes (positional axes are named dim_i)
|
|
206
|
+
alignment: alignment of unnamed arrays (default: ALIGNMENT)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Layout
|
|
210
|
+
"""
|
|
211
|
+
alignment = alignment or ALIGNMENT
|
|
212
|
+
leaves = jax.tree.leaves(trees, is_leaf=is_axis)
|
|
213
|
+
|
|
214
|
+
# named axes, by first appearance
|
|
215
|
+
named = {}
|
|
216
|
+
for leaf in filter(is_axis, leaves):
|
|
217
|
+
if len(leaf.names) != len(leaf.shape):
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Axis {leaf.name!r} names {len(leaf.names)} of {len(leaf.shape)} "
|
|
220
|
+
f"dimensions {tuple(leaf.shape)}: at layer 2 every dimension needs "
|
|
221
|
+
"a name (in @sequence, the other dimensions must be the declared "
|
|
222
|
+
"core of the variable)"
|
|
223
|
+
)
|
|
224
|
+
for name, size in leaf.sizes.items():
|
|
225
|
+
if named.setdefault(name, size) != size:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Inconsistent sizes for axis {name}: {named[name]}, {size}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# positional axes
|
|
231
|
+
shapes = [jnp.shape(leaf) for leaf in leaves if not is_axis(leaf)]
|
|
232
|
+
npos = max((len(shape) for shape in shapes), default=0)
|
|
233
|
+
aligned = [align_shape(shape, npos, alignment) for shape in shapes]
|
|
234
|
+
try:
|
|
235
|
+
posshape = jnp.broadcast_shapes(*aligned) if aligned else ()
|
|
236
|
+
except ValueError:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"Incompatible unnamed array shapes ({alignment}-aligned): {shapes}"
|
|
239
|
+
)
|
|
240
|
+
positional = {POSITIONAL.format(i): size for i, size in enumerate(posshape)}
|
|
241
|
+
if set(positional) & set(named):
|
|
242
|
+
raise ValueError(f"Reserved axis names: {set(positional) & set(named)}")
|
|
243
|
+
|
|
244
|
+
sizes = {**positional, **named}
|
|
245
|
+
if dims is None:
|
|
246
|
+
dims = tuple(sizes)
|
|
247
|
+
elif set(dims) != set(sizes) or len(dims) != len(sizes):
|
|
248
|
+
raise ValueError(f"`dims` must contain all axes {tuple(sizes)}, got: {dims}")
|
|
249
|
+
return Layout(dims, tuple(sizes[d] for d in dims), npos, alignment)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def get_coords(*trees):
|
|
253
|
+
"""coordinates of 1d named axes (values of their first occurrence)"""
|
|
254
|
+
coords = {}
|
|
255
|
+
for leaf in filter(is_axis, jax.tree.leaves(trees, is_leaf=is_axis)):
|
|
256
|
+
if len(leaf.names) == 1:
|
|
257
|
+
coords.setdefault(leaf.name, leaf.values)
|
|
258
|
+
return coords
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def resolve(*trees, dims=None, alignment=None):
|
|
262
|
+
"""convert named and unnamed leaves to a common positional layout
|
|
263
|
+
|
|
264
|
+
Objects defining a `resolve_axes(layout)` method (e.g. ADC with named
|
|
265
|
+
reduction axes) are given the layout before conversion.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
trees: pytrees (sequence, sample, scanner...)
|
|
269
|
+
dims, alignment: cf. `get_layout`
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
layout, resolved trees (same structure, Axis leaves replaced by arrays)
|
|
273
|
+
"""
|
|
274
|
+
layout = get_layout(*trees, dims=dims, alignment=alignment)
|
|
275
|
+
|
|
276
|
+
def has_resolve(obj):
|
|
277
|
+
return hasattr(obj, "resolve_axes") and not isinstance(obj, type)
|
|
278
|
+
|
|
279
|
+
trees = jax.tree.map(
|
|
280
|
+
lambda obj: obj.resolve_axes(layout) if has_resolve(obj) else obj,
|
|
281
|
+
trees,
|
|
282
|
+
is_leaf=lambda obj: has_resolve(obj) or is_axis(obj),
|
|
283
|
+
)
|
|
284
|
+
trees = jax.tree.map(layout.convert, trees, is_leaf=is_axis)
|
|
285
|
+
return layout, trees
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Common utilities"""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import dataclasses
|
|
5
|
+
|
|
6
|
+
import jax
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def static(default=None):
|
|
11
|
+
"""dataclass field stored as static metadata (not a pytree leaf)"""
|
|
12
|
+
return dataclasses.field(default=default, metadata={"static": True})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pytree(cls):
|
|
16
|
+
"""class decorator: frozen dataclass registered as a pytree
|
|
17
|
+
|
|
18
|
+
Fields declared with `static()` are static metadata, others are leaves.
|
|
19
|
+
Unflattening does not call `__init__`, so constructors may convert and
|
|
20
|
+
validate their arguments. A `replace(**changes)` method is added (copy
|
|
21
|
+
without calling `__init__`).
|
|
22
|
+
"""
|
|
23
|
+
cls = dataclasses.dataclass(frozen=True, eq=False, init=False, repr=False)(cls)
|
|
24
|
+
fields = dataclasses.fields(cls)
|
|
25
|
+
data = tuple(f.name for f in fields if not f.metadata.get("static"))
|
|
26
|
+
meta = tuple(f.name for f in fields if f.metadata.get("static"))
|
|
27
|
+
|
|
28
|
+
def flatten(obj):
|
|
29
|
+
children = [(jax.tree_util.GetAttrKey(n), getattr(obj, n)) for n in data]
|
|
30
|
+
return children, tuple(getattr(obj, n) for n in meta)
|
|
31
|
+
|
|
32
|
+
def unflatten(aux, children):
|
|
33
|
+
obj = object.__new__(cls)
|
|
34
|
+
for name, value in zip(meta + data, tuple(aux) + tuple(children)):
|
|
35
|
+
object.__setattr__(obj, name, value)
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
jax.tree_util.register_pytree_with_keys(cls, flatten, unflatten)
|
|
39
|
+
if "replace" not in cls.__dict__:
|
|
40
|
+
cls.replace = _replace
|
|
41
|
+
return cls
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def repr_value(value):
|
|
45
|
+
"""short representation of a parameter value (scalar, array, Axis, traced)"""
|
|
46
|
+
if value is None:
|
|
47
|
+
return "None"
|
|
48
|
+
elif isinstance(value, jax.core.Tracer):
|
|
49
|
+
return "<traced>"
|
|
50
|
+
elif hasattr(value, "names"): # Axis
|
|
51
|
+
return repr(value)
|
|
52
|
+
value = np.asarray(value)
|
|
53
|
+
if value.size == 1:
|
|
54
|
+
return f"{value.item():.4g}"
|
|
55
|
+
return f"<{value.shape}>"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _replace(self, **changes):
|
|
59
|
+
"""return a copy with given fields replaced (`__init__` is not called)"""
|
|
60
|
+
new = copy.copy(self)
|
|
61
|
+
for name, value in changes.items():
|
|
62
|
+
object.__setattr__(new, name, value)
|
|
63
|
+
return new
|