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/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
|