epgx 0.1.dev0__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.
epgx/compiler.py ADDED
@@ -0,0 +1,347 @@
1
+ """Sequence compiler: repeated blocks -> lax.scan
2
+
3
+ Generic: the compiler knows nothing about EPG. The caller provides the lowering
4
+ (item -> Step) and the run context (passed through to the steps). The EPG
5
+ front-end is `lowering.compile_sequence`.
6
+
7
+ Stages:
8
+ 1. grouping: a (nested) list of items is split into segments:
9
+ - Unrolled: steps applied one after the other
10
+ - Scan: consecutive sub-lists with the same pytree structure (repeated
11
+ blocks), applied with `lax.scan`; leaves that differ between blocks are
12
+ stacked, leaves shared by all blocks (same object) are constants.
13
+ Blocks are compiled recursively (nested repeats: nested scans).
14
+ 2. lowering: items (anything but lists) -> steps, by the caller's `lower`
15
+ 3. passes: functions Program -> Program, applied to the program of each
16
+ level (top level and scan bodies), e.g. merging (cf. `merging`)
17
+ 4. execution: `Program.run(state, context=None, monitor=None)`; `context` is
18
+ passed to the steps (e.g. sample and scanner); the optional monitor is
19
+ called (when tracing) with the state and a number of items done: once per
20
+ top-level unrolled segment and once per iteration of top-level scans
21
+ (e.g. progress from the compiled program, cf. `progress`)
22
+
23
+ Grouping only depends on the structure (static), so a program can be compiled
24
+ and run with traced values (under jit, grad...). Acquired values may be
25
+ pytrees (e.g. tuples of arrays, for multiple probes).
26
+ """
27
+
28
+ import dataclasses
29
+ import typing
30
+
31
+ import jax
32
+ import jax.numpy as jnp
33
+
34
+ MAX_UNROLL = 1000
35
+
36
+
37
+ @dataclasses.dataclass(frozen=True)
38
+ class Step:
39
+ """Lowered item
40
+
41
+ Attributes:
42
+ kernel: function applied as kernel(state, **params(context))
43
+ params: function context -> dict of kernel parameters
44
+ acquire: function (state, context) -> value, or None
45
+ label: description (e.g. operator repr)
46
+ count: number of items represented by the step (e.g. merged steps: several)
47
+ source: object the step was lowered from (e.g. operator), for passes
48
+ """
49
+
50
+ kernel: typing.Callable
51
+ params: typing.Callable
52
+ acquire: typing.Callable = None
53
+ label: str = ""
54
+ count: int = 1
55
+ source: typing.Any = None
56
+
57
+
58
+ @dataclasses.dataclass
59
+ class Unrolled:
60
+ """Segment of steps applied one after the other"""
61
+
62
+ steps: list
63
+
64
+ def __repr__(self):
65
+ return f"Unrolled({len(self.steps)})"
66
+
67
+ @property
68
+ def nunroll(self):
69
+ return len(self.steps)
70
+
71
+ @property
72
+ def count(self):
73
+ """number of items"""
74
+ return sum(step.count for step in self.steps)
75
+
76
+ def run(self, state, context=None, monitor=None):
77
+ chunks = []
78
+ for step in self.steps:
79
+ state = step.kernel(state, **step.params(context))
80
+ if step.acquire is not None:
81
+ value = step.acquire(state, context)
82
+ chunks.append(jax.tree.map(lambda leaf: leaf[jnp.newaxis], value))
83
+ if monitor is not None:
84
+ monitor(state, self.count)
85
+ return state, chunks
86
+
87
+
88
+ @dataclasses.dataclass
89
+ class Scan:
90
+ """Segment of repeated blocks applied with lax.scan
91
+
92
+ Attributes:
93
+ treedef: pytree structure of one block
94
+ leaves: leaves of the first block (constants where `varying` is False)
95
+ varying: for each leaf, True if it differs between blocks (stacked)
96
+ stacked: stacked varying leaves (leading axis: block index)
97
+ length: number of blocks
98
+ body: program of one block (compiled from the first block)
99
+ options: compilation options (to compile the body of each block)
100
+ """
101
+
102
+ treedef: typing.Any
103
+ leaves: list
104
+ varying: list
105
+ stacked: list
106
+ length: int
107
+ body: "Program"
108
+ options: dict
109
+
110
+ def __repr__(self):
111
+ return f"Scan({self.length} x {self.body!r})"
112
+
113
+ @property
114
+ def nunroll(self):
115
+ return self.body.nunroll
116
+
117
+ @property
118
+ def count(self):
119
+ """number of items"""
120
+ return self.length * self.body.count
121
+
122
+ @property
123
+ def xs(self):
124
+ """scanned values (stacked varying leaves), None if all blocks are the same"""
125
+ return tuple(self.stacked) if self.stacked else None
126
+
127
+ def block(self, xs):
128
+ """block rebuilt from one slice of the stacked leaves"""
129
+ xs = iter(xs or ())
130
+ leaves = [next(xs) if v else leaf for v, leaf in zip(self.varying, self.leaves)]
131
+ return jax.tree.unflatten(self.treedef, leaves)
132
+
133
+ def body_at(self, xs):
134
+ """program of the block with the scanned values `xs` (one slice)"""
135
+ return _compile(self.block(xs), **self.options)
136
+
137
+ def run(self, state, context=None, monitor=None):
138
+ def body(state, xs):
139
+ state, chunks = self.body_at(xs)._run(state, context)
140
+ if monitor is not None:
141
+ monitor(state, self.body.count)
142
+ return state, tuple(chunks)
143
+
144
+ if self.options.get("checkpoint"):
145
+ body = jax.checkpoint(body)
146
+ state, ys = jax.lax.scan(body, state, self.xs, length=self.length)
147
+ return state, _interleave(ys, self.length)
148
+
149
+
150
+ @dataclasses.dataclass
151
+ class Program:
152
+ """Compiled sequence
153
+
154
+ Attributes:
155
+ segments: list of Unrolled and Scan segments
156
+ """
157
+
158
+ segments: list
159
+
160
+ def __repr__(self):
161
+ return f"Program({', '.join(map(repr, self.segments))})"
162
+
163
+ @property
164
+ def nunroll(self):
165
+ """number of unrolled steps (traced items), including scan bodies"""
166
+ return sum(segment.nunroll for segment in self.segments)
167
+
168
+ @property
169
+ def count(self):
170
+ """number of items (steps standing for several items count them all)"""
171
+ return sum(segment.count for segment in self.segments)
172
+
173
+ @property
174
+ def nscan(self):
175
+ """number of scan segments (including nested scans)"""
176
+ return sum(
177
+ 1 + segment.body.nscan
178
+ for segment in self.segments
179
+ if isinstance(segment, Scan)
180
+ )
181
+
182
+ def run(self, state, context=None, monitor=None):
183
+ """apply the sequence to the state
184
+
185
+ Args:
186
+ state: initial state (pytree)
187
+ context: passed to the steps' `params` and `acquire` (e.g. sample
188
+ and scanner)
189
+ monitor: function (state, count) called after each top-level segment
190
+ and each iteration of top-level scans (cf. module docstring)
191
+
192
+ Returns:
193
+ final state, acquired values: if all values have the same structure
194
+ and shapes, a single value (array or pytree of arrays) with leading
195
+ axis (nadc, ...), else a list of values
196
+ """
197
+ state, chunks = self._run(state, context, monitor)
198
+ return state, _assemble(chunks)
199
+
200
+ def _run(self, state, context=None, monitor=None):
201
+ chunks = []
202
+ for segment in self.segments:
203
+ state, new = segment.run(state, context, monitor)
204
+ chunks.extend(new)
205
+ return state, chunks
206
+
207
+
208
+ def build(sequence, *, lower, passes=(), max_unroll=MAX_UNROLL, checkpoint=False):
209
+ """compile a (nested) list of items into a Program
210
+
211
+ Args:
212
+ sequence: (nested) list of items; repeated blocks are consecutive
213
+ sub-lists with the same structure (e.g. `[exc] + [block] * N`)
214
+ lower: function item -> Step (raises for invalid items)
215
+ passes: functions Program -> Program, applied to each level (top level
216
+ and scan bodies), in order
217
+ max_unroll: maximum number of unrolled steps (error if exceeded)
218
+ checkpoint: wrap scan bodies with `jax.checkpoint` (memory vs. compute
219
+ trade-off for reverse-mode differentiation)
220
+
221
+ Returns:
222
+ Program
223
+ """
224
+ program = _compile(
225
+ sequence, lower=lower, passes=tuple(passes), checkpoint=checkpoint
226
+ )
227
+ if program.nunroll > max_unroll:
228
+ raise ValueError(
229
+ f"Too many unrolled operators ({program.nunroll} > max_unroll={max_unroll}). "
230
+ "Group repeated blocks into sub-lists with the same structure, "
231
+ "e.g. `[exc] + [[rf, shift, adc]] * N`, or increase `max_unroll`."
232
+ )
233
+ return program
234
+
235
+
236
+ def join_unrolled(segments):
237
+ """merge consecutive Unrolled segments (e.g. for passes)"""
238
+ joined = []
239
+ for segment in segments:
240
+ if (
241
+ isinstance(segment, Unrolled)
242
+ and joined
243
+ and isinstance(joined[-1], Unrolled)
244
+ ):
245
+ joined[-1] = Unrolled(joined[-1].steps + segment.steps)
246
+ else:
247
+ joined.append(segment)
248
+ return joined
249
+
250
+
251
+ #
252
+ # private
253
+
254
+
255
+ def _compile(sequence, **options):
256
+ """group a (nested) list of items into segments, then apply the passes"""
257
+ items = list(sequence) if isinstance(sequence, (list, tuple)) else [sequence]
258
+ segments = []
259
+ i = 0
260
+ while i < len(items):
261
+ item = items[i]
262
+ if not isinstance(item, (list, tuple)):
263
+ segments.append(Unrolled([options["lower"](item)]))
264
+ i += 1
265
+ continue
266
+
267
+ # run of consecutive blocks with the same structure
268
+ structure = jax.tree.structure(item)
269
+ j = i + 1
270
+ while (
271
+ j < len(items)
272
+ and isinstance(items[j], (list, tuple))
273
+ and jax.tree.structure(items[j]) == structure
274
+ ):
275
+ j += 1
276
+ if j - i == 1:
277
+ # single block: inline
278
+ segments.extend(_compile(item, **options).segments)
279
+ else:
280
+ segments.append(_scan(items[i:j], **options))
281
+ i = j
282
+
283
+ program = Program(join_unrolled(segments))
284
+ for transform in options.get("passes", ()):
285
+ program = transform(program)
286
+ return program
287
+
288
+
289
+ def _scan(blocks, **options):
290
+ """Scan segment from a list of blocks with the same structure"""
291
+ flat = [jax.tree.flatten(block) for block in blocks]
292
+ treedef = flat[0][1]
293
+ leaves = [leaves for leaves, _ in flat]
294
+ first = leaves[0]
295
+ varying = [
296
+ any(other[j] is not first[j] for other in leaves[1:]) for j in range(len(first))
297
+ ]
298
+ stacked = []
299
+ for j, vary in enumerate(varying):
300
+ if not vary:
301
+ continue
302
+ values = [jnp.asarray(other[j]) for other in leaves]
303
+ shape = jnp.broadcast_shapes(*(value.shape for value in values))
304
+ stacked.append(jnp.stack([jnp.broadcast_to(value, shape) for value in values]))
305
+ body = _compile(blocks[0], **options) # structure of the body (same for all blocks)
306
+ return Scan(treedef, first, varying, stacked, len(blocks), body, options)
307
+
308
+
309
+ def _signature(chunk, skip):
310
+ """structure and trailing leaf shapes of a chunk (skipping `skip` leading axes)"""
311
+ leaves, treedef = jax.tree.flatten(chunk)
312
+ return treedef, tuple(leaf.shape[skip:] for leaf in leaves)
313
+
314
+
315
+ def _count(chunk, axis=0):
316
+ """number of values along a leading axis of a chunk"""
317
+ return jax.tree.leaves(chunk)[0].shape[axis]
318
+
319
+
320
+ def _interleave(ys, length):
321
+ """scan outputs (one per chunk of the body, leading axes (length, k)) -> chunks"""
322
+ if not ys:
323
+ return []
324
+ if len({_signature(y, 2) for y in ys}) == 1:
325
+ # same structures and shapes: one chunk of length * k values, in sequence order
326
+ merged = jax.tree.map(lambda *leaves: jnp.concatenate(leaves, axis=1), *ys)
327
+ return [jax.tree.map(lambda leaf: leaf.reshape((-1,) + leaf.shape[2:]), merged)]
328
+ # otherwise: one chunk per value
329
+ return [
330
+ jax.tree.map(lambda leaf: leaf[i, j][jnp.newaxis], y)
331
+ for i in range(length)
332
+ for y in ys
333
+ for j in range(_count(y, axis=1))
334
+ ]
335
+
336
+
337
+ def _assemble(chunks):
338
+ """acquired values: single value if all have the same structure and shapes, else list"""
339
+ if not chunks:
340
+ return jnp.zeros((0,))
341
+ if len({_signature(chunk, 1) for chunk in chunks}) == 1:
342
+ return jax.tree.map(lambda *leaves: jnp.concatenate(leaves), *chunks)
343
+ return [
344
+ jax.tree.map(lambda leaf: leaf[j], chunk)
345
+ for chunk in chunks
346
+ for j in range(_count(chunk))
347
+ ]
epgx/diff.py ADDED
@@ -0,0 +1,242 @@
1
+ """Derivatives of functions of named values (Jacobian, cross Hessian)
2
+
3
+ JAX differentiates `simulate` directly (`jax.jvp`, `jax.jacfwd`, `jax.grad`...).
4
+ This module only handles what plain JAX makes error-prone:
5
+
6
+ - batch axes: `jax.jacfwd` w.r.t. a parameter with one value per batch
7
+ element (e.g. 1000 T2 values) builds a (..., 1000, 1000) matrix, zero off
8
+ the diagonal, at the cost of 1000 passes. Since each element depends only
9
+ on its own value, one pass with a tangent of ones gives all the
10
+ derivatives;
11
+ - Jacobian and (cross) Hessian in one computation (nested JVPs);
12
+ - coupled axes: elements interacting along an axis (e.g. compartments
13
+ mixed by exchange) need one derivative per value along that axis.
14
+
15
+ Kinds of values (one entry of `values`):
16
+ Axis: one value per element of its batch axes -> one column (tangent of
17
+ ones); along axes listed in `coupled`: one column per value
18
+ scalar: one parameter -> one column
19
+ array (not an Axis): a vector of parameters (e.g. flip angles) -> one
20
+ column per value
21
+
22
+ Example:
23
+ def signal(p):
24
+ seq = [T(90, 90)] + [[S(1), E(5), T(a, 0), S(1), E(5), ADC] for a in p["alpha"]]
25
+ return simulate(seq, Sample(T1=p["T1"], T2=p["T2"]))
26
+
27
+ values = {"T1": 1000.0, "T2": Axis("T2", [30.0, 40.0]), "alpha": alphas}
28
+ d = derivatives(signal, values, jacobian=["T1", "T2"], hessian=(["T2"], ["alpha"]))
29
+ d.jacobian # (nadc, 2, 2): last axis: T1, T2
30
+ d.hessian # (nadc, 2, 1, nalpha)
31
+ """
32
+
33
+ import itertools
34
+
35
+ import jax
36
+ import jax.numpy as jnp
37
+ import numpy as np
38
+
39
+ from .axes import is_axis
40
+ from .common import pytree, static
41
+
42
+
43
+ @pytree
44
+ class Derivatives:
45
+ """Value and derivatives of a function (cf. `derivatives`)
46
+
47
+ Attributes:
48
+ value: function value
49
+ jacobian: (*shape, ncols) or None, derivatives along the last axis
50
+ hessian: (*shape, nrows, ncols) or None
51
+ columns: (static) labels of the Jacobian's columns, e.g. ("T2",) or
52
+ ("alpha", 3) (parameter name, then index for vectors/coupled axes)
53
+ rows, cols: (static) labels of the Hessian's rows and columns
54
+ """
55
+
56
+ value: jax.Array = None
57
+ jacobian: jax.Array = None
58
+ hessian: jax.Array = None
59
+ columns: tuple = static(())
60
+ rows: tuple = static(())
61
+ cols: tuple = static(())
62
+
63
+ def __init__(
64
+ self, value, jacobian=None, hessian=None, *, columns=(), rows=(), cols=()
65
+ ):
66
+ for name, item in [
67
+ ("value", value),
68
+ ("jacobian", jacobian),
69
+ ("hessian", hessian),
70
+ ("columns", tuple(columns)),
71
+ ("rows", tuple(rows)),
72
+ ("cols", tuple(cols)),
73
+ ]:
74
+ object.__setattr__(self, name, item)
75
+
76
+
77
+ def derivatives(fn, values, *, jacobian=(), hessian=None, coupled=()):
78
+ """value, Jacobian and cross Hessian of fn(values), in one computation
79
+
80
+ Args:
81
+ fn: function dict -> array (or pytree of arrays)
82
+ values: dict name -> value (Axis, scalar or array, cf. module docstring)
83
+ jacobian: names of the Jacobian's parameters
84
+ hessian: (rows, cols): names of the Hessian's rows and columns
85
+ (rectangular, not assumed symmetric), or None
86
+ coupled: names of batch axes along which elements interact
87
+
88
+ Returns:
89
+ Derivatives (value, jacobian, hessian and their labels)
90
+ """
91
+ jacobian = list(jacobian)
92
+ rows, cols = (None, None) if hessian is None else map(list, hessian)
93
+ names = list(dict.fromkeys(jacobian + (rows or []) + (cols or [])))
94
+ for name in names:
95
+ if name not in values:
96
+ raise ValueError(
97
+ f"Unknown parameter: {name!r}, expected one of {list(values)}"
98
+ )
99
+ params = {name: _as_float(values[name]) for name in names}
100
+
101
+ def f(params):
102
+ return fn({**values, **params})
103
+
104
+ if hessian is None:
105
+ basis, columns = _basis(params, jacobian, coupled)
106
+ value, jac = _jacobian(f, params, basis)
107
+ return Derivatives(value, _last(jac), columns=columns)
108
+
109
+ # nested JVPs: rows inside, columns outside -> value, J_rows, J_cols, H
110
+ U, row_labels = _basis(params, rows, coupled)
111
+ V, col_labels = _basis(params, cols, coupled)
112
+
113
+ def per_col(v):
114
+ def per_row(u):
115
+ def inner(x):
116
+ return jax.jvp(f, (x,), (u,))
117
+
118
+ (value, ju), (jv, huv) = jax.jvp(inner, (params,), (v,))
119
+ return value, ju, jv, huv
120
+
121
+ return jax.vmap(per_row, out_axes=(None, 0, None, 0))(U)
122
+
123
+ value, ju, jv, huv = jax.vmap(per_col, out_axes=(None, None, 0, 0))(V)
124
+ # huv: (ncols, nrows, *shape) -> (*shape, nrows, ncols)
125
+ hess = jax.tree.map(lambda h: jnp.moveaxis(h, (0, 1), (-1, -2)), huv)
126
+
127
+ # Jacobian: columns from J_rows / J_cols, others computed separately
128
+ jac, columns = None, ()
129
+ if jacobian:
130
+ known = {}
131
+ for labels, jacs in [(row_labels, ju), (col_labels, jv)]:
132
+ for i, label in enumerate(labels):
133
+ known.setdefault(label, jax.tree.map(lambda j, i=i: j[i], jacs))
134
+ missing = [name for name in jacobian if not _has(known, name)]
135
+ if missing:
136
+ basis, labels = _basis(params, missing, coupled)
137
+ _, extra = _jacobian(f, params, basis)
138
+ for i, label in enumerate(labels):
139
+ known[label] = jax.tree.map(lambda j, i=i: j[i], extra)
140
+ _, columns = _basis(params, jacobian, coupled)
141
+ jac = jax.tree.map(
142
+ lambda *js: jnp.stack(js, axis=-1), *[known[c] for c in columns]
143
+ )
144
+ return Derivatives(
145
+ value, jac, hess, columns=columns, rows=row_labels, cols=col_labels
146
+ )
147
+
148
+
149
+ def jacobian(fn, values, wrt, *, coupled=()):
150
+ """value and Jacobian (*shape, ncols) of fn(values) (cf. `derivatives`)"""
151
+ d = derivatives(fn, values, jacobian=wrt, coupled=coupled)
152
+ return d.value, d.jacobian
153
+
154
+
155
+ def hessian(fn, values, rows, cols=None, *, coupled=()):
156
+ """value, Jacobian (rows) and Hessian (*shape, nrows, ncols) (cf. `derivatives`)"""
157
+ cols = rows if cols is None else cols
158
+ d = derivatives(fn, values, jacobian=rows, hessian=(rows, cols), coupled=coupled)
159
+ return d.value, d.jacobian, d.hessian
160
+
161
+
162
+ #
163
+ # private
164
+
165
+
166
+ def _as_float(value):
167
+ """differentiable copy of a value (float dtype)"""
168
+
169
+ def convert(leaf):
170
+ leaf = jnp.asarray(leaf)
171
+ if jnp.issubdtype(leaf.dtype, jnp.inexact):
172
+ return leaf
173
+ return leaf.astype(jnp.result_type(float))
174
+
175
+ return jax.tree.map(convert, value)
176
+
177
+
178
+ def _tangents(name, value, coupled):
179
+ """tangents of one parameter: (ntangent, *shape) array, labels"""
180
+ if is_axis(value):
181
+ shape, dims = value.shape, value.names
182
+ cdims = [i for i, dim in enumerate(dims) if dim in coupled]
183
+ else:
184
+ shape = jnp.shape(value)
185
+ cdims = list(range(len(shape))) # vector of parameters
186
+ csizes = [shape[i] for i in cdims]
187
+ tangents, labels = [], []
188
+ for index in itertools.product(*(range(n) for n in csizes)):
189
+ tangent = np.zeros(shape) if cdims else np.ones(shape)
190
+ if cdims:
191
+ where = [slice(None)] * len(shape)
192
+ for i, k in zip(cdims, index):
193
+ where[i] = k
194
+ tangent[tuple(where)] = 1
195
+ tangents.append(tangent)
196
+ if not index:
197
+ labels.append((name,))
198
+ elif len(index) == 1:
199
+ labels.append((name, index[0]))
200
+ else:
201
+ labels.append((name, index))
202
+ return np.stack(tangents), labels
203
+
204
+
205
+ def _basis(params, names, coupled):
206
+ """stacked tangents (leading axis) for the parameters `names`, labels"""
207
+ # tangents in the requested order
208
+ offsets, labels, start = {}, [], 0
209
+ for name in names:
210
+ tangents, name_labels = _tangents(name, params[name], coupled)
211
+ offsets[name] = (start, tangents)
212
+ labels += name_labels
213
+ start += len(tangents)
214
+ # one stacked array per parameter (zeros outside its own tangents)
215
+ stacked = {}
216
+ for name, value in params.items():
217
+ leaf = value.values if is_axis(value) else jnp.asarray(value)
218
+ array = np.zeros((start,) + tuple(jnp.shape(leaf)))
219
+ if name in offsets:
220
+ offset, tangents = offsets[name]
221
+ array[offset : offset + len(tangents)] = tangents
222
+ array = jnp.asarray(array, dtype=leaf.dtype)
223
+ stacked[name] = value.replace(values=array) if is_axis(value) else array
224
+ return stacked, tuple(labels)
225
+
226
+
227
+ def _jacobian(f, params, basis):
228
+ """value and Jacobian (ntangent, *shape) for stacked tangents"""
229
+
230
+ def column(t):
231
+ return jax.jvp(f, (params,), (t,))
232
+
233
+ return jax.vmap(column, out_axes=(None, 0))(basis)
234
+
235
+
236
+ def _last(jac):
237
+ """leading tangent axis -> last axis"""
238
+ return jax.tree.map(lambda j: jnp.moveaxis(j, 0, -1), jac)
239
+
240
+
241
+ def _has(known, name):
242
+ return any(label[0] == name for label in known)