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/__init__.py +19 -0
- epgx/axes.py +285 -0
- epgx/common.py +63 -0
- epgx/compiler.py +347 -0
- epgx/diff.py +242 -0
- epgx/examples.py +146 -0
- epgx/functions.py +602 -0
- epgx/kernels/__init__.py +9 -0
- epgx/kernels/affine.py +187 -0
- epgx/kernels/evolution.py +40 -0
- epgx/kernels/operator.py +32 -0
- epgx/kernels/probe.py +107 -0
- epgx/kernels/shift.py +106 -0
- epgx/kernels/state.py +447 -0
- epgx/kernels/transition.py +48 -0
- epgx/library/__init__.py +3 -0
- epgx/library/sequences.py +210 -0
- epgx/lowering.py +95 -0
- epgx/merging.py +137 -0
- epgx/methods.py +329 -0
- epgx/ops/__init__.py +30 -0
- epgx/ops/dephasing.py +71 -0
- epgx/ops/evolution.py +135 -0
- epgx/ops/functions.py +158 -0
- epgx/ops/operator.py +224 -0
- epgx/ops/probe.py +116 -0
- epgx/ops/shift.py +118 -0
- epgx/ops/transition.py +58 -0
- epgx/plotting.py +226 -0
- epgx/progress.py +81 -0
- epgx/sample.py +142 -0
- epgx/sequences.py +850 -0
- epgx/stats.py +117 -0
- epgx-0.1.dev0.dist-info/METADATA +64 -0
- epgx-0.1.dev0.dist-info/RECORD +38 -0
- epgx-0.1.dev0.dist-info/WHEEL +5 -0
- epgx-0.1.dev0.dist-info/licenses/LICENSE +28 -0
- epgx-0.1.dev0.dist-info/top_level.txt +1 -0
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)
|