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/sequences.py ADDED
@@ -0,0 +1,850 @@
1
+ """Parametrized sequences: the `@sequence` decorator (layer 3)
2
+
3
+ A Python function building a layer-2 sequence (a list of operators) becomes a
4
+ `Sequence`: a parametrized sequence, called to give the signal. Its variables
5
+ are declared in the decorator by their shape inside the function (one
6
+ simulation):
7
+
8
+ name a scalar
9
+ name[] a vector of any length
10
+ name[n] a vector, dimension n shared with other variables
11
+ name[n, m] a matrix
12
+
13
+ Every other argument is static: it fixes the structure of the sequence and is
14
+ part of the cache key of the compiled program (lists and arrays are made
15
+ hashable; each new value compiles again).
16
+
17
+ Batch dimensions are not declared: they come from the values. The dimensions
18
+ of a value beyond its declared core are its leading batch dimensions. An
19
+ `Axis` names them (`Axis("candidate", alphas)` of shape (10, 400) for
20
+ `alpha[n]`): values with the same name are paired, different names form a
21
+ grid; unnamed batch dimensions are broadcast as NumPy (aligned on the right)
22
+ and named `batch_0`, `batch_1`... The function is mapped over the batch
23
+ dimensions (`jax.vmap`), so it always sees its variables with their core
24
+ shape. Outputs: the ADC axis first, then the batch axes (order of first
25
+ appearance), then the axes of the sample and scanner.
26
+
27
+ `sample=` and `scanner=` are reserved arguments, as in `simulate`; simulation
28
+ options (method, probe, group, output...) are set in the decorator or per
29
+ call (the call wins).
30
+
31
+ Example:
32
+ @sequence(variables=["alpha", "phi"], method=Lattice(nmax=20))
33
+ def pspgr(alpha, phi, TR=5.5, TE=2.25, ndummy=500):
34
+ n = jnp.arange(ndummy + 1)
35
+ phases = phi * n * (n + 1) / 2
36
+ rlx1, rlx2 = E(TE), E(TR - TE)
37
+ dummies = [[T(alpha, ph), rlx1, rlx2, S(1)] for ph in phases[:-1]]
38
+ return dummies + [T(alpha, phases[-1]), rlx1, Adc(phase=phases[-1])]
39
+
40
+ points = dict(alpha=Axis("point", alphas), phi=Axis("point", phis))
41
+ signal = pspgr(**points, sample=Sample(T1=1220.0, T2=t2)) # (1, point, T2)
42
+ """
43
+
44
+ import collections
45
+ import functools
46
+ import inspect
47
+ import re
48
+ import warnings
49
+
50
+ import jax
51
+ import jax.numpy as jnp
52
+ import numpy as np
53
+
54
+ from . import axes, diff, functions, stats
55
+ from .axes import Axis, is_axis
56
+ from .functions import OUTPUTS, OutputLayout, _to_numpy, _to_xarray, simulate
57
+ from .sample import Sample
58
+
59
+ # reserved arguments and simulation options
60
+ RESERVED = ("sample", "scanner", "select")
61
+ OPTIONS = (
62
+ "probe",
63
+ "group",
64
+ "output",
65
+ "method",
66
+ "init",
67
+ "dtype",
68
+ "dims",
69
+ "alignment",
70
+ "max_unroll",
71
+ "checkpoint",
72
+ "merge",
73
+ "jit",
74
+ "disp",
75
+ )
76
+ FIELDS = Sample.FIELDS + ("B1", "B0")
77
+ # options of `trace` (others, e.g. probe or output, do not apply)
78
+ TRACE_OPTIONS = (
79
+ "method",
80
+ "init",
81
+ "dtype",
82
+ "dims",
83
+ "alignment",
84
+ "max_unroll",
85
+ "jit",
86
+ "disp",
87
+ )
88
+
89
+ # warning after this number of values of a static array argument
90
+ RECOMPILATIONS = 3
91
+
92
+ _DECLARATION = re.compile(
93
+ r"\s*([A-Za-z_]\w*)\s*(\[\s*(?:[A-Za-z_]\w*\s*(?:,\s*[A-Za-z_]\w*\s*)*)?\])?\s*"
94
+ )
95
+
96
+
97
+ def sequence(function=None, *, variables=(), **options):
98
+ """decorator: turn a function returning a sequence into a `Sequence`
99
+
100
+ Args:
101
+ function: function returning a (nested) list of operators
102
+ variables: declarations of the traced arguments ("T2", "alpha[n]"...),
103
+ cf. module docstring; every other argument is static
104
+ options: default simulation options (cf. `simulate`)
105
+
106
+ Returns:
107
+ Sequence
108
+ """
109
+ if function is None:
110
+ return functools.partial(sequence, variables=variables, **options)
111
+ return Sequence(function, variables, **options)
112
+
113
+
114
+ def parse_variable(declaration):
115
+ """name and core dimensions of a variable declaration
116
+
117
+ Returns:
118
+ name, dims: tuple of core dimension names ("name[]" for `name[]`)
119
+ """
120
+ match = _DECLARATION.fullmatch(declaration)
121
+ if not match:
122
+ raise ValueError(
123
+ f"Invalid variable declaration: {declaration!r} "
124
+ "(expected name, name[], name[n] or name[n, m])"
125
+ )
126
+ name, brackets = match.groups()
127
+ if brackets is None:
128
+ return name, ()
129
+ dims = tuple(dim.strip() for dim in brackets[1:-1].split(",") if dim.strip())
130
+ return name, dims or (f"{name}[]",)
131
+
132
+
133
+ class Sequence:
134
+ """Parametrized sequence (cf. `sequence`)
135
+
136
+ Attributes:
137
+ function: function returning the layer-2 sequence
138
+ variables: dict name -> core dimensions of the declared variables
139
+ options: default simulation options
140
+ """
141
+
142
+ def __init__(self, function, variables=(), **options):
143
+ functools.update_wrapper(self, function)
144
+ if isinstance(variables, str):
145
+ variables = [variables]
146
+ self.function = function
147
+ self.signature = inspect.signature(function)
148
+ self.variables = dict(parse_variable(v) for v in variables)
149
+ if len(self.variables) != len(variables):
150
+ raise ValueError(f"Variables declared twice: {list(variables)}")
151
+
152
+ params = self.signature.parameters
153
+ for name in params:
154
+ if name in RESERVED + OPTIONS:
155
+ raise ValueError(
156
+ f"Argument {name!r} of {self.__name__} is reserved by @sequence"
157
+ )
158
+ for name in self.variables:
159
+ if name not in params:
160
+ raise ValueError(
161
+ f"Unknown variable {name!r}: not an argument of "
162
+ f"{self.__name__}{self.signature}"
163
+ )
164
+ elif name in FIELDS:
165
+ raise ValueError(
166
+ f"Variable {name!r} reuses the name of a sample/scanner field"
167
+ )
168
+ unknown = set(options) - set(OPTIONS)
169
+ if unknown:
170
+ raise ValueError(f"Unknown option(s): {sorted(unknown)}")
171
+ self.options = options
172
+ self._seen = {} # static array argument -> values seen (recompilations)
173
+ self._compiled = jax.jit(
174
+ self._execute, static_argnames=("statics", "options", "kinds")
175
+ )
176
+
177
+ def __repr__(self):
178
+ variables = [_declaration(name, dims) for name, dims in self.variables.items()]
179
+ return f"Sequence({self.__name__}, variables={variables})"
180
+
181
+ def __call__(self, *args, sample=None, scanner=None, **kwargs):
182
+ """signal (cf. `signal`)"""
183
+ return self.signal(*args, sample=sample, scanner=scanner, **kwargs)
184
+
185
+ def signal(self, *args, sample=None, scanner=None, **kwargs):
186
+ """simulated signal
187
+
188
+ Args:
189
+ args, kwargs: arguments of the function (variables: with optional
190
+ batch dimensions) and simulation options (cf. `simulate`)
191
+ sample, scanner: cf. `simulate`
192
+
193
+ Returns:
194
+ object(s) selected by `output` (cf. `simulate`), with the batch
195
+ axes after the ADC axis
196
+ """
197
+ options = self._options(kwargs)
198
+ variables, statics = self._bind(args, kwargs)
199
+ return self._signal(variables, statics, sample, scanner, options)
200
+
201
+ def build(self, *args, **kwargs):
202
+ """layer-2 sequence for given values (without batch dimensions)"""
203
+ variables, statics = self._bind(args, kwargs)
204
+ for name, value in variables.items():
205
+ array = value.values if is_axis(value) else value
206
+ if jnp.ndim(array) != len(self.variables[name]):
207
+ raise ValueError(f"build: variable {name!r} has batch dimensions")
208
+ values = {name: static.value for name, static in statics}
209
+ values.update({n: v.values if is_axis(v) else v for n, v in variables.items()})
210
+ return self._build(values)
211
+
212
+ def trace(self, *args, sample=None, scanner=None, select=None, **kwargs):
213
+ """states after every operator, for one element of the batch
214
+
215
+ The element of the variables' batch axes is selected, the sequence
216
+ built with core-shaped values, then traced (cf. `functions.trace`).
217
+ Usable with `plotting.plot_epg` (directly, or `plot_epg(seq, ...)`).
218
+
219
+ Args:
220
+ args, kwargs: arguments of the function and trace options (method,
221
+ init, dtype, dims, alignment, max_unroll, jit, disp)
222
+ sample, scanner: cf. `simulate` (optional)
223
+ select: element of the batch axes, as a dict {axis: index}: axes
224
+ of the variables (names, or batch_i for unnamed ones) and of
225
+ the sample/scanner; other axes: index 0
226
+
227
+ Returns:
228
+ Trace
229
+ """
230
+ given = {k: kwargs.pop(k) for k in list(kwargs) if k in OPTIONS}
231
+ invalid = set(given) - set(TRACE_OPTIONS)
232
+ if invalid:
233
+ raise ValueError(f"Option(s) not applicable to trace: {sorted(invalid)}")
234
+ options = {k: v for k, v in self.options.items() if k in TRACE_OPTIONS}
235
+ options.update(given)
236
+ variables, statics = self._bind(args, kwargs)
237
+
238
+ # selected element of the variables' batch axes
239
+ order, sizes, labels, arrays = _batch(variables, self.variables)
240
+ select = dict(select or {})
241
+ index = {}
242
+ for label in order:
243
+ i = select.pop(label, 0)
244
+ if not -sizes[label] <= i < sizes[label]:
245
+ raise IndexError(
246
+ f"Index {i} out of range for axis {label!r} of size {sizes[label]}"
247
+ )
248
+ index[label] = i
249
+ values = {name: static.value for name, static in statics}
250
+ for name, array in arrays.items():
251
+ values[name] = array[tuple(index[label] for label in labels[name])]
252
+ return functions.trace(
253
+ self._build(values), sample, scanner, select=select or None, **options
254
+ )
255
+
256
+ def derivatives(
257
+ self,
258
+ *args,
259
+ jacobian=(),
260
+ hessian=None,
261
+ coupled=(),
262
+ sample=None,
263
+ scanner=None,
264
+ **kwargs,
265
+ ):
266
+ """signal, Jacobian and cross Hessian by name (cf. `diff.derivatives`)
267
+
268
+ Parameters are declared variables (batch dimensions: one column, core
269
+ dimensions: one column per value) or fields of the sample and scanner
270
+ (PD, T1, T2, df, B1, B0).
271
+
272
+ Args:
273
+ args, kwargs: arguments and options, as for `signal`
274
+ jacobian: names of the Jacobian's parameters
275
+ hessian: (rows, cols): names of the Hessian's rows and columns
276
+ coupled: names of sample/scanner axes along which elements
277
+ interact (e.g. compartments summed by the ADC)
278
+ sample, scanner: cf. `simulate`
279
+
280
+ Returns:
281
+ diff.Derivatives (value, jacobian (*shape, ncols), hessian
282
+ (*shape, nrows, ncols), labels)
283
+ """
284
+ options = self._options(kwargs)
285
+ options["output"] = "jax"
286
+ variables, statics = self._bind(args, kwargs)
287
+ values, cores, rebuild = self._parameters(variables, sample, scanner)
288
+
289
+ def signal(params):
290
+ variables_, sample_, scanner_ = rebuild(params)
291
+ return self._signal(variables_, statics, sample_, scanner_, dict(options))
292
+
293
+ return diff.derivatives(
294
+ signal,
295
+ values,
296
+ jacobian=jacobian,
297
+ hessian=hessian,
298
+ coupled=tuple(coupled) + cores,
299
+ )
300
+
301
+ def jacobian(self, names, *args, **kwargs):
302
+ """signal and Jacobian (*shape, ncols) by name (cf. `derivatives`)"""
303
+ d = self.derivatives(*args, jacobian=names, **kwargs)
304
+ return d.value, d.jacobian
305
+
306
+ def hessian(self, rows, cols=None, *args, **kwargs):
307
+ """signal, Jacobian (rows) and Hessian (*shape, nrows, ncols) by name"""
308
+ cols = rows if cols is None else cols
309
+ d = self.derivatives(*args, jacobian=rows, hessian=(rows, cols), **kwargs)
310
+ return d.value, d.jacobian, d.hessian
311
+
312
+ def crlb(
313
+ self,
314
+ names,
315
+ *args,
316
+ W=None,
317
+ sigma2=1,
318
+ log=False,
319
+ split=False,
320
+ axis="adc",
321
+ **kwargs,
322
+ ):
323
+ """Cramer-Rao lower bound of parameters (cf. `stats.crlb`)
324
+
325
+ Args:
326
+ names: parameters (declared variables, sample/scanner fields)
327
+ args, kwargs: arguments and options, as for `derivatives`
328
+ W, sigma2, log: cf. `stats.crlb`
329
+ split: return the bound of each parameter (`stats.crlb_split`)
330
+ axis: name(s) of the observation axes (default: the ADC axis),
331
+ e.g. ("adc", "point") when points are observations too
332
+
333
+ Returns:
334
+ bound for the remaining axes (*batch) (split: (*batch, nparam))
335
+ """
336
+ dims = self._dims(args, kwargs)
337
+ J = self.derivatives(*args, jacobian=names, **kwargs).jacobian
338
+ J = _fold(J, dims, axis, ntrail=1)
339
+ W = None if W is None else jnp.asarray(W)
340
+ bound = stats.crlb_split if split else stats.crlb
341
+ return bound(J, W=W, sigma2=sigma2, log=log)
342
+
343
+ def confint(
344
+ self, obs, names, *args, conflevel=0.95, hessian=False, axis="adc", **kwargs
345
+ ):
346
+ """confidence intervals of parameters (cf. `stats.confint`)
347
+
348
+ Args:
349
+ obs: observed signal (same shape as the simulated signal)
350
+ names: parameters (declared variables, sample/scanner fields)
351
+ args, kwargs: arguments (estimated values) and options
352
+ conflevel: confidence level
353
+ hessian: include the Hessian of the prediction
354
+ axis: name(s) of the observation axes (default: the ADC axis)
355
+
356
+ Returns:
357
+ intervals (*batch, nparam): half-widths, band (shape of `obs`):
358
+ half-widths of the confidence band of the prediction
359
+ """
360
+ dims = self._dims(args, kwargs)
361
+ H = (names, names) if hessian else None
362
+ d = self.derivatives(*args, jacobian=names, hessian=H, **kwargs)
363
+ obs = jnp.asarray(obs)
364
+ if obs.shape != d.value.shape:
365
+ raise ValueError(
366
+ f"Observation shape {obs.shape} differs from the prediction "
367
+ f"shape {d.value.shape}"
368
+ )
369
+ intervals, band = stats.confint(
370
+ _fold(obs, dims, axis),
371
+ _fold(d.value, dims, axis),
372
+ _fold(d.jacobian, dims, axis, ntrail=1),
373
+ None if d.hessian is None else _fold(d.hessian, dims, axis, ntrail=2),
374
+ conflevel=conflevel,
375
+ )
376
+ return intervals, _unfold(band, dims, axis, d.value.shape)
377
+
378
+ #
379
+ # private
380
+
381
+ def _options(self, kwargs):
382
+ """simulation options: decorator defaults, then call (popped from kwargs)"""
383
+ options = {**self.options}
384
+ options.update({k: kwargs.pop(k) for k in list(kwargs) if k in OPTIONS})
385
+ return options
386
+
387
+ def _parameters(self, variables, sample, scanner):
388
+ """differentiable values by name, coupled core axes, rebuilding function"""
389
+ values, cores, forms = {}, (), {}
390
+ for name, value in variables.items():
391
+ names = value.names if is_axis(value) else ()
392
+ array = value.values if is_axis(value) else value
393
+ nbatch = jnp.ndim(array) - len(self.variables[name])
394
+ batch = names or tuple(f"_{name}_batch{i}" for i in range(nbatch))
395
+ core = tuple(f"_{name}_core{i}" for i in range(len(self.variables[name])))
396
+ cores += core
397
+ forms[name] = names
398
+ values[name] = Axis(batch + core, array) if jnp.ndim(array) else array
399
+ objects = {"sample": sample, "scanner": scanner}
400
+ fields = {"sample": Sample.FIELDS, "scanner": ("B1", "B0")}
401
+ for key, obj in objects.items():
402
+ for field in fields[key] if obj is not None else ():
403
+ if getattr(obj, field) is not None:
404
+ values[field] = getattr(obj, field)
405
+
406
+ def rebuild(params):
407
+ variables_ = {}
408
+ for name in variables:
409
+ array = params[name].values if is_axis(params[name]) else params[name]
410
+ variables_[name] = Axis(forms[name], array) if forms[name] else array
411
+ rebuilt = []
412
+ for key, obj in objects.items():
413
+ changes = {f: params[f] for f in fields[key] if f in params}
414
+ rebuilt.append(obj.replace(**changes) if obj is not None else None)
415
+ return (variables_, *rebuilt)
416
+
417
+ return values, cores, rebuild
418
+
419
+ def _dims(self, args, kwargs):
420
+ """axis names of the signal (single stacked output)"""
421
+ kwargs = {k: v for k, v in kwargs.items() if k != "coupled"}
422
+ layout = self.signal(*args, **{**kwargs, "output": "layout"})
423
+ if not isinstance(layout, OutputLayout):
424
+ raise ValueError(
425
+ "Statistics need a single stacked signal (no group=, ADCs of the "
426
+ "same shape)"
427
+ )
428
+ return layout.dims
429
+
430
+ def _bind(self, args, kwargs):
431
+ """variables (checked) and static arguments (hashable)"""
432
+ try:
433
+ bound = self.signature.bind(*args, **kwargs)
434
+ except TypeError as exc:
435
+ raise TypeError(f"{self.__name__}: {exc}") from None
436
+ bound.apply_defaults()
437
+ variables, statics = {}, []
438
+ for name, value in bound.arguments.items():
439
+ if name in self.variables:
440
+ variables[name] = self._check_variable(name, value)
441
+ else:
442
+ statics.append((name, Static(name, value)))
443
+ self._count_recompilations(statics)
444
+ return variables, tuple(statics)
445
+
446
+ def _check_variable(self, name, value):
447
+ """value as an array (or Axis) with its declared core dimensions"""
448
+ dims = self.variables[name]
449
+ names = value.names if is_axis(value) else ()
450
+ array = jnp.asarray(value.values if is_axis(value) else value)
451
+ if not jnp.issubdtype(array.dtype, jnp.inexact):
452
+ array = array.astype(jnp.result_type(float))
453
+ nbatch = array.ndim - len(dims)
454
+ declaration = _declaration(name, dims)
455
+ if nbatch < 0:
456
+ raise ValueError(
457
+ f"Variable {name!r} is declared {declaration} ({len(dims)} core "
458
+ f"dimension(s)), got shape {array.shape}"
459
+ )
460
+ elif names and nbatch != len(names):
461
+ raise ValueError(
462
+ f"Axis {value.name!r} of variable {name!r} names {len(names)} "
463
+ f"dimension(s), but the value {array.shape} has {nbatch} batch "
464
+ f"dimension(s) beyond the declared core ({declaration})"
465
+ )
466
+ return Axis(names, array) if names else array
467
+
468
+ def _count_recompilations(self, statics):
469
+ for name, static in statics:
470
+ if not static.array:
471
+ continue
472
+ seen = self._seen.setdefault(name, set())
473
+ if static in seen:
474
+ continue
475
+ seen.add(static)
476
+ if len(seen) == RECOMPILATIONS:
477
+ warnings.warn(
478
+ f"Argument {name!r} of {self.__name__} changed {RECOMPILATIONS} "
479
+ "times, each causing a recompilation: declare it as a variable "
480
+ f"({name + '[]'!r}) if its values change between calls",
481
+ stacklevel=5,
482
+ )
483
+
484
+ def _build(self, values):
485
+ sequence = self.function(**values)
486
+ if not isinstance(sequence, list):
487
+ raise TypeError(
488
+ f"{self.__name__} must return a list of operators, "
489
+ f"got {type(sequence).__name__}"
490
+ )
491
+ return sequence
492
+
493
+ def _signal(self, variables, statics, sample, scanner, options):
494
+ output = options.pop("output", "jax")
495
+ names = (output,) if isinstance(output, str) else tuple(output)
496
+ if not set(names) <= set(OUTPUTS):
497
+ raise ValueError(f"Unknown output(s): {set(names) - set(OUTPUTS)}")
498
+ jit = options.pop("jit", True)
499
+ dynamic = {"init": options.pop("init", None), "probe": None}
500
+ if not _is_static_probe(options.get("probe")):
501
+ dynamic["probe"] = options.pop("probe")
502
+ options = tuple(sorted((k, _hashable(v)) for k, v in options.items()))
503
+ self._check_names(variables, sample, scanner)
504
+
505
+ # values and times: compiled program
506
+ kinds = tuple(k for k in ("jax", "times") if _needs(k, names))
507
+ results = {}
508
+ if kinds:
509
+ run = self._compiled if jit else self._execute
510
+ out = run(
511
+ variables,
512
+ sample,
513
+ scanner,
514
+ dynamic,
515
+ statics=statics,
516
+ options=options,
517
+ kinds=kinds,
518
+ )
519
+ results.update(zip(kinds, _plain(out)))
520
+ if "layout" in names or "xarray" in names:
521
+ results["layout"] = self._layout(
522
+ variables, statics, sample, scanner, dynamic, options
523
+ )
524
+
525
+ group = dict(options).get("group")
526
+ outputs = []
527
+ for name in names:
528
+ if name == "numpy":
529
+ outputs.append(_to_numpy(results["jax"]))
530
+ elif name == "xarray":
531
+ outputs.append(_xarray(results, group))
532
+ else:
533
+ outputs.append(results[name])
534
+ return outputs[0] if len(outputs) == 1 else tuple(outputs)
535
+
536
+ def _execute(self, variables, sample, scanner, dynamic, *, statics, options, kinds):
537
+ """values (and times) of the batch, in the compiled program"""
538
+ options = dict(options)
539
+ if dynamic["probe"] is not None:
540
+ options["probe"] = dynamic["probe"]
541
+ values = {name: static.value for name, static in statics}
542
+ order, _, labels, arrays = _batch(variables, self.variables)
543
+ masks = []
544
+
545
+ def core(vals):
546
+ sequence = self._build({**values, **vals})
547
+ out = simulate(
548
+ sequence,
549
+ sample,
550
+ scanner,
551
+ init=dynamic["init"],
552
+ output=kinds,
553
+ jit=False,
554
+ **options,
555
+ )
556
+ out = _ordered((out,) if len(kinds) == 1 else out)
557
+ masks.append(_adc_mask(out)) # structure, at trace time
558
+ return out
559
+
560
+ # map over the batch dimensions: outer axes first
561
+ mapped = core
562
+ for k in reversed(range(len(order))):
563
+ in_axes = {}
564
+ for name in arrays:
565
+ remaining = [lab for lab in labels[name] if lab not in order[:k]]
566
+ in_axes[name] = (
567
+ remaining.index(order[k]) if order[k] in remaining else None
568
+ )
569
+ mapped = jax.vmap(mapped, in_axes=(in_axes,))
570
+ out = mapped(arrays)
571
+
572
+ # batch axes after the ADC axis (values acquired by stacked ADCs)
573
+ nbatch = len(order)
574
+ if nbatch:
575
+ out = jax.tree.map(
576
+ lambda leaf, adc: _after_adc(leaf, nbatch) if adc else leaf,
577
+ out,
578
+ masks[0],
579
+ )
580
+ return out
581
+
582
+ def _layout(self, variables, statics, sample, scanner, dynamic, options):
583
+ """layout(s) of the values: one simulation's layout, with the batch axes"""
584
+ order, sizes, labels, arrays = _batch(variables, self.variables)
585
+ values = {name: static.value for name, static in statics}
586
+ values.update({name: arrays[name][(0,) * len(labels[name])] for name in arrays})
587
+ options = dict(options)
588
+ if dynamic["probe"] is not None:
589
+ options["probe"] = dynamic["probe"]
590
+ options.pop("disp", None)
591
+ layout = simulate(
592
+ self._build(values),
593
+ sample,
594
+ scanner,
595
+ init=dynamic["init"],
596
+ output="layout",
597
+ **options,
598
+ )
599
+ return _insert_batch(layout, tuple(order), tuple(sizes[o] for o in order))
600
+
601
+ def _check_names(self, variables, sample, scanner):
602
+ """batch axes of the variables are not axes of the sample/scanner"""
603
+ named = {n for v in variables.values() if is_axis(v) for n in v.names}
604
+ layout = axes.get_layout(sample, scanner)
605
+ shared = named & set(layout.dims)
606
+ if shared:
607
+ raise ValueError(
608
+ f"Axis name(s) {sorted(shared)} used both by a variable and by the "
609
+ "sample/scanner (pairing across them is not supported)"
610
+ )
611
+
612
+
613
+ class Static:
614
+ """Hashable key of a static argument (the function receives the value)
615
+
616
+ Attributes:
617
+ value: the argument
618
+ key: hashable key (arrays: shape, dtype and bytes)
619
+ array: True for array-like values (recompilation warning)
620
+ """
621
+
622
+ def __init__(self, name, value):
623
+ self.value = value
624
+ self.key = _static_key(name, value)
625
+ self.array = _is_array_like(value)
626
+
627
+ def __hash__(self):
628
+ return hash(self.key)
629
+
630
+ def __eq__(self, other):
631
+ return isinstance(other, Static) and self.key == other.key
632
+
633
+ def __repr__(self):
634
+ return f"Static({self.value!r})"
635
+
636
+
637
+ #
638
+ # functions
639
+
640
+
641
+ def _declaration(name, dims):
642
+ if not dims:
643
+ return name
644
+ return f"{name}[{', '.join(d for d in dims if d != f'{name}[]')}]"
645
+
646
+
647
+ def _static_key(name, value):
648
+ if isinstance(value, jax.core.Tracer):
649
+ raise TypeError(
650
+ f"Argument {name!r} is static but traced (e.g. under jax.grad or "
651
+ "jax.jit): declare it as a variable"
652
+ )
653
+ elif _is_array_like(value):
654
+ # numeric arrays, lists and tuples: same key for the same contents
655
+ array = np.asarray(value)
656
+ return (np.ndarray, array.shape, array.dtype.str, array.tobytes())
657
+ elif isinstance(value, (list, tuple)):
658
+ return (type(value), tuple(_static_key(name, v) for v in value))
659
+ elif isinstance(value, dict):
660
+ return (
661
+ dict,
662
+ tuple(sorted((k, _static_key(name, v)) for k, v in value.items())),
663
+ )
664
+ try:
665
+ hash(value)
666
+ except TypeError:
667
+ raise TypeError(
668
+ f"Static argument {name!r} is not hashable ({type(value).__name__}): "
669
+ "pass a tuple or an array, or declare it as a variable"
670
+ ) from None
671
+ return (type(value), value)
672
+
673
+
674
+ def _is_array_like(value):
675
+ if isinstance(value, (np.ndarray, jax.Array)):
676
+ return True
677
+ elif isinstance(value, (list, tuple)) and value:
678
+ try:
679
+ return np.asarray(value).dtype.kind in "iufc"
680
+ except ValueError:
681
+ return False
682
+ return False
683
+
684
+
685
+ def _hashable(value):
686
+ """options as static arguments of jit (lists to tuples)"""
687
+ if isinstance(value, list):
688
+ return tuple(_hashable(v) for v in value)
689
+ return value
690
+
691
+
692
+ def _is_static_probe(probe):
693
+ """attribute names (static) vs Probe operators (pytrees)"""
694
+ if isinstance(probe, tuple):
695
+ return all(isinstance(p, str) for p in probe)
696
+ return probe is None or isinstance(probe, str)
697
+
698
+
699
+ def _needs(kind, names):
700
+ if kind == "jax":
701
+ return bool({"jax", "numpy", "xarray"} & set(names))
702
+ return bool({"times", "xarray"} & set(names))
703
+
704
+
705
+ def _batch(variables, cores):
706
+ """batch structure of the variables
707
+
708
+ Returns:
709
+ order: batch axis labels, in order of first appearance
710
+ sizes: dict label -> size
711
+ labels: dict variable -> labels of its leading batch dimensions
712
+ arrays: dict variable -> array (unnamed batch dimensions broadcast)
713
+ """
714
+ labels, arrays, unnamed = {}, {}, {}
715
+ for name, value in variables.items():
716
+ array = value.values if is_axis(value) else value
717
+ nbatch = jnp.ndim(array) - len(cores[name])
718
+ arrays[name] = array
719
+ if is_axis(value):
720
+ labels[name] = value.names
721
+ elif nbatch:
722
+ unnamed[name] = jnp.shape(array)[:nbatch]
723
+ else:
724
+ labels[name] = ()
725
+ if unnamed:
726
+ try:
727
+ common = tuple(np.broadcast_shapes(*unnamed.values()))
728
+ except ValueError:
729
+ raise ValueError(f"Incompatible unnamed batch shapes: {unnamed}") from None
730
+ for name, shape in unnamed.items():
731
+ core = jnp.shape(arrays[name])[len(shape) :]
732
+ arrays[name] = jnp.broadcast_to(arrays[name], common + core)
733
+ labels[name] = tuple(f"batch_{i}" for i in range(len(common)))
734
+
735
+ order, sizes, core_sizes = [], {}, {}
736
+ for name in variables:
737
+ shape = jnp.shape(arrays[name])
738
+ for label, size in zip(labels[name], shape):
739
+ if sizes.setdefault(label, size) != size:
740
+ raise ValueError(
741
+ f"Inconsistent sizes for batch axis {label!r}: "
742
+ f"{sizes[label]}, {size} ({name!r})"
743
+ )
744
+ if label not in order:
745
+ order.append(label)
746
+ for dim, size in zip(cores[name], shape[len(labels[name]) :]):
747
+ if core_sizes.setdefault(dim, size) != size:
748
+ raise ValueError(
749
+ f"Inconsistent sizes for core dimension {dim!r}: "
750
+ f"{core_sizes[dim]}, {size} ({name!r})"
751
+ )
752
+ return order, sizes, labels, arrays
753
+
754
+
755
+ def _fold(array, dims, axis, ntrail=0):
756
+ """observation axes (names) first, folded into one (statistics layout)
757
+
758
+ Args:
759
+ array: (*shape, *trail) with axis names `dims` for `shape`
760
+ axis: name or tuple of names of the observation axes
761
+ ntrail: number of trailing axes (parameters)
762
+ """
763
+ index = _axis_index(dims, axis)
764
+ moved = jnp.moveaxis(array, index, tuple(range(len(index))))
765
+ nobs = int(np.prod(moved.shape[: len(index)]))
766
+ return moved.reshape((nobs,) + moved.shape[len(index) :])
767
+
768
+
769
+ def _unfold(array, dims, axis, shape):
770
+ """inverse of `_fold` (no trailing axes), back to `shape`"""
771
+ index = _axis_index(dims, axis)
772
+ array = array.reshape(tuple(shape[i] for i in index) + array.shape[1:])
773
+ return jnp.moveaxis(array, tuple(range(len(index))), index)
774
+
775
+
776
+ def _axis_index(dims, axis):
777
+ axis = (axis,) if isinstance(axis, str) else tuple(axis)
778
+ for name in axis:
779
+ if name not in dims:
780
+ raise ValueError(f"Unknown observation axis {name!r}, available: {dims}")
781
+ return tuple(dims.index(name) for name in axis)
782
+
783
+
784
+ def _adc_mask(tree):
785
+ """True for leaves with a leading ADC axis (stacked values)"""
786
+ if isinstance(tree, list):
787
+ return jax.tree.map(lambda _: False, tree)
788
+ elif isinstance(tree, tuple):
789
+ return tuple(_adc_mask(item) for item in tree)
790
+ elif isinstance(tree, dict):
791
+ return type(tree)((key, _adc_mask(item)) for key, item in tree.items())
792
+ return True
793
+
794
+
795
+ def _ordered(tree):
796
+ """dicts as OrderedDict (JAX sorts the keys of plain dicts, e.g. groups)"""
797
+ if isinstance(tree, dict):
798
+ return collections.OrderedDict((k, _ordered(v)) for k, v in tree.items())
799
+ elif isinstance(tree, (list, tuple)):
800
+ return type(tree)(_ordered(item) for item in tree)
801
+ return tree
802
+
803
+
804
+ def _plain(tree):
805
+ """OrderedDict back to plain dicts (order kept)"""
806
+ if isinstance(tree, dict):
807
+ return {k: _plain(v) for k, v in tree.items()}
808
+ elif isinstance(tree, (list, tuple)):
809
+ return type(tree)(_plain(item) for item in tree)
810
+ return tree
811
+
812
+
813
+ def _after_adc(leaf, nbatch):
814
+ """move the leading batch axes after the ADC axis"""
815
+ return jnp.moveaxis(leaf, tuple(range(nbatch)), tuple(range(1, nbatch + 1)))
816
+
817
+
818
+ def _insert_batch(tree, dims, shape):
819
+ """layout(s) with the batch axes (after the ADC axis, if any)"""
820
+ if isinstance(tree, OutputLayout):
821
+ n = 1 if tree.dims[:1] == ("adc",) else 0
822
+ return OutputLayout(
823
+ tree.dims[:n] + dims + tree.dims[n:],
824
+ tree.shape[:n] + shape + tree.shape[n:],
825
+ tree.coords,
826
+ tree.names,
827
+ )
828
+ elif isinstance(tree, dict):
829
+ return {key: _insert_batch(item, dims, shape) for key, item in tree.items()}
830
+ elif isinstance(tree, (list, tuple)):
831
+ return type(tree)(_insert_batch(item, dims, shape) for item in tree)
832
+ return tree
833
+
834
+
835
+ def _xarray(results, group):
836
+ """xarray output (ADC times as coordinates if the same for the whole batch)"""
837
+ values, layout, times = results["jax"], results["layout"], results["times"]
838
+ if group is None:
839
+ return _to_xarray(values, layout, _common_times(times))
840
+ return {
841
+ key: _to_xarray(values[key], layout[key], _common_times(times[key]))
842
+ for key in values
843
+ }
844
+
845
+
846
+ def _common_times(times):
847
+ """ADC times (nadc,) if the same for every batch element, else None"""
848
+ times = np.asarray(_to_numpy(times))
849
+ flat = times.reshape(len(times), -1)
850
+ return flat[:, 0] if np.allclose(flat, flat[:, :1]) else None