labelarray 0.1.0__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.
- labelarray/__init__.py +82 -0
- labelarray/__version__.py +1 -0
- labelarray/_compiled.py +101 -0
- labelarray/align.py +40 -0
- labelarray/array.py +474 -0
- labelarray/collection.py +267 -0
- labelarray/contract.py +47 -0
- labelarray/errors.py +21 -0
- labelarray/grouped.py +259 -0
- labelarray/index.py +470 -0
- labelarray/labels.py +244 -0
- labelarray/py.typed +0 -0
- labelarray/sums.py +317 -0
- labelarray/transforms.py +610 -0
- labelarray-0.1.0.dist-info/METADATA +187 -0
- labelarray-0.1.0.dist-info/RECORD +19 -0
- labelarray-0.1.0.dist-info/WHEEL +5 -0
- labelarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- labelarray-0.1.0.dist-info/top_level.txt +1 -0
labelarray/collection.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""A collection of labelled arrays sharing axes.
|
|
2
|
+
|
|
3
|
+
A ``Collection`` holds variables with different axes; each axis has one size
|
|
4
|
+
and at most one label array, shared by every variable on it. Selection,
|
|
5
|
+
assignment, renaming, joins under the exact label rule, and nearest-label
|
|
6
|
+
matching are methods. Conversion to and from xarray needs xarray, imported
|
|
7
|
+
only when asked for.
|
|
8
|
+
|
|
9
|
+
This module needs NumPy only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Hashable, Mapping, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from labelarray import labels as exact
|
|
20
|
+
from labelarray.array import (
|
|
21
|
+
Array,
|
|
22
|
+
_checked_coords,
|
|
23
|
+
_cut,
|
|
24
|
+
_freeze,
|
|
25
|
+
_take,
|
|
26
|
+
label_index,
|
|
27
|
+
plain,
|
|
28
|
+
positions_of,
|
|
29
|
+
)
|
|
30
|
+
from labelarray.errors import ShapeError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Collection:
|
|
34
|
+
"""Variables with different axes; each axis has one size and at most one label array."""
|
|
35
|
+
|
|
36
|
+
__slots__ = ("attrs", "coord_attrs", "coords", "sizes", "variables")
|
|
37
|
+
|
|
38
|
+
def __init__(self, variables: Mapping[Hashable, Array], coords: Mapping[Hashable, Any] | None = None,
|
|
39
|
+
attrs: Mapping[Hashable, Any] | None = None, coord_attrs: Mapping[Hashable, Any] | None = None):
|
|
40
|
+
sizes: dict[Hashable, int] = {}
|
|
41
|
+
for name, value in variables.items():
|
|
42
|
+
for dim, size in zip(value.dims, value.data.shape):
|
|
43
|
+
if sizes.setdefault(dim, size) != size:
|
|
44
|
+
raise ShapeError(f"{name}: axis {dim} has {size} positions, other variables have {sizes[dim]}")
|
|
45
|
+
coords = dict(coords or {})
|
|
46
|
+
for dim, labels in coords.items():
|
|
47
|
+
sizes.setdefault(dim, len(labels))
|
|
48
|
+
self.variables = {name: Array._make(v.data, v.dims, {}, v.attrs, name) for name, v in variables.items()}
|
|
49
|
+
self.coords = _checked_coords(coords, sizes)
|
|
50
|
+
self.coord_attrs = {d: dict(a) for d, a in (coord_attrs or {}).items() if d in self.coords}
|
|
51
|
+
self.attrs = dict(attrs or {})
|
|
52
|
+
self.sizes = sizes
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def dims(self) -> tuple[Hashable, ...]:
|
|
56
|
+
return tuple(self.sizes)
|
|
57
|
+
|
|
58
|
+
def array(self, name: Hashable) -> Array:
|
|
59
|
+
"""One variable, with the labels of its axes."""
|
|
60
|
+
value = self.variables[name]
|
|
61
|
+
return Array._make(value.data, value.dims, {d: self.coords[d] for d in value.dims if d in self.coords},
|
|
62
|
+
value.attrs, name)
|
|
63
|
+
|
|
64
|
+
def evolve(self, variables: Mapping | None = None, coords: Mapping | None = None, **changes: Any) -> Collection:
|
|
65
|
+
"""A copy with parts replaced (``variables``, ``coords``, ``attrs``, ``coord_attrs``)."""
|
|
66
|
+
return Collection(
|
|
67
|
+
self.variables if variables is None else variables,
|
|
68
|
+
self.coords if coords is None else coords,
|
|
69
|
+
changes.get("attrs", self.attrs), changes.get("coord_attrs", self.coord_attrs),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# ── Selection ───────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def isel(self, indexers: Mapping[Hashable, Any] | None = None, **more: Any) -> Collection:
|
|
75
|
+
"""Positions, a slice, or a mask per axis, on every variable with that axis; an integer removes it."""
|
|
76
|
+
variables, coords = dict(self.variables), dict(self.coords)
|
|
77
|
+
for dim, index in {**(indexers or {}), **more}.items():
|
|
78
|
+
if getattr(index, "dtype", None) == bool:
|
|
79
|
+
index = np.flatnonzero(index) # scan the mask once; every variable reuses the positions
|
|
80
|
+
for name, value in variables.items():
|
|
81
|
+
if dim in value.dims:
|
|
82
|
+
variables[name] = _take(value, dim, index)
|
|
83
|
+
if dim in coords:
|
|
84
|
+
if isinstance(index, (int, np.integer)):
|
|
85
|
+
del coords[dim]
|
|
86
|
+
else:
|
|
87
|
+
coords[dim] = _cut(coords[dim], index)
|
|
88
|
+
return self.evolve(variables, coords)
|
|
89
|
+
|
|
90
|
+
def sel(self, indexers: Mapping[Hashable, Any] | None = None, **more: Any) -> Collection:
|
|
91
|
+
"""Labels per axis, looked up exactly; one label removes the axis, a list keeps it."""
|
|
92
|
+
wanted = {**(indexers or {}), **more}
|
|
93
|
+
return self.isel({dim: positions_of(self.coords[dim], labels, dim) for dim, labels in wanted.items()})
|
|
94
|
+
|
|
95
|
+
def select(self, names: Sequence[Hashable]) -> Collection:
|
|
96
|
+
"""Only the variables *names*, with the labels of the axes they use."""
|
|
97
|
+
if not names:
|
|
98
|
+
return self.evolve(variables={})
|
|
99
|
+
variables = {name: self.variables[name] for name in names}
|
|
100
|
+
used = {d for v in variables.values() for d in v.dims}
|
|
101
|
+
return self.evolve(variables, {d: c for d, c in self.coords.items() if d in used})
|
|
102
|
+
|
|
103
|
+
# ── Building ────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def assign(self, name: Hashable, value: Any) -> Collection:
|
|
106
|
+
"""*value* as the variable *name*; its labels are added for axes that have none yet."""
|
|
107
|
+
if not isinstance(value, Array):
|
|
108
|
+
value = Array(value)
|
|
109
|
+
variables = dict(self.variables)
|
|
110
|
+
variables[name] = value
|
|
111
|
+
coords = dict(self.coords)
|
|
112
|
+
for dim, labels in value.coords.items():
|
|
113
|
+
coords.setdefault(dim, labels)
|
|
114
|
+
return self.evolve(variables, coords)
|
|
115
|
+
|
|
116
|
+
def rename(self, names: Mapping[Hashable, Hashable]) -> Collection:
|
|
117
|
+
"""Variables renamed."""
|
|
118
|
+
return self.evolve({names.get(n, n): v for n, v in self.variables.items()})
|
|
119
|
+
|
|
120
|
+
# ── Joins ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def join(self, other: Collection, how: str) -> Collection:
|
|
123
|
+
"""Both collections' variables, on shared axes' labels joined *how* (left, inner, or outer).
|
|
124
|
+
|
|
125
|
+
Labels are paired under the exact rule; positions a side lacks are
|
|
126
|
+
missing, with integers and booleans promoted as xarray promotes them.
|
|
127
|
+
Attributes that conflict are dropped, as xarray's ``drop_conflicts``.
|
|
128
|
+
"""
|
|
129
|
+
left, right = self, other
|
|
130
|
+
for dim in [d for d in left.coords if d in right.coords]:
|
|
131
|
+
ours, theirs = left.coords[dim], right.coords[dim]
|
|
132
|
+
if exact.equal(ours, theirs):
|
|
133
|
+
continue
|
|
134
|
+
target = _joined_labels(ours, theirs, how, dim)
|
|
135
|
+
left = left._reindexed(dim, label_index(ours, "join", dim).get_indexer(target), target)
|
|
136
|
+
right = right._reindexed(dim, label_index(theirs, "join", dim).get_indexer(target), target)
|
|
137
|
+
variables = {**{n: left.array(n) for n in left.variables}, **{n: right.array(n) for n in right.variables}}
|
|
138
|
+
coords = {**right.coords, **left.coords}
|
|
139
|
+
coord_attrs = {d: _combine_attrs(left.coord_attrs.get(d, {}), right.coord_attrs.get(d, {})) for d in coords}
|
|
140
|
+
return Collection(variables, coords, _combine_attrs(left.attrs, right.attrs), coord_attrs)
|
|
141
|
+
|
|
142
|
+
def nearest(self, dim: Hashable, labels: np.ndarray, tolerance: Any) -> Collection:
|
|
143
|
+
"""This collection on the labels *labels* for *dim*, each matched to its nearest label within *tolerance*.
|
|
144
|
+
|
|
145
|
+
A label with none near enough gives missing values; a tie goes to the lower label.
|
|
146
|
+
"""
|
|
147
|
+
theirs = self.coords[dim]
|
|
148
|
+
order = np.argsort(theirs, kind="stable")
|
|
149
|
+
ordered = theirs[order]
|
|
150
|
+
upper = np.clip(np.searchsorted(ordered, labels), 0, len(ordered) - 1)
|
|
151
|
+
lower = np.clip(upper - 1, 0, len(ordered) - 1)
|
|
152
|
+
pick_lower = np.abs(labels - ordered[lower]) <= np.abs(ordered[upper] - labels)
|
|
153
|
+
nearest = np.where(pick_lower, lower, upper)
|
|
154
|
+
positions = np.where(np.abs(ordered[nearest] - labels) <= tolerance, order[nearest], -1)
|
|
155
|
+
return self._reindexed(dim, positions, labels)
|
|
156
|
+
|
|
157
|
+
def _reindexed(self, dim: Hashable, positions: np.ndarray, labels: np.ndarray) -> Collection:
|
|
158
|
+
variables = {}
|
|
159
|
+
for name in self.variables:
|
|
160
|
+
value = self.array(name)
|
|
161
|
+
variables[name] = _reindex(value, dim, positions, labels) if dim in value.dims else value
|
|
162
|
+
coords = dict(self.coords)
|
|
163
|
+
coords[dim] = labels
|
|
164
|
+
return self.evolve(variables, coords)
|
|
165
|
+
|
|
166
|
+
# ── Conversion ──────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def to_dict(self) -> dict:
|
|
169
|
+
"""As xarray's ``Dataset.to_dict(data="array")`` gives it, without xarray."""
|
|
170
|
+
return {
|
|
171
|
+
"coords": {d: {"dims": (d,), "attrs": dict(self.coord_attrs.get(d, {})), "data": plain(labels)}
|
|
172
|
+
for d, labels in self.coords.items()},
|
|
173
|
+
"attrs": dict(self.attrs),
|
|
174
|
+
"dims": dict(self.sizes),
|
|
175
|
+
"data_vars": {n: {"dims": v.dims, "attrs": dict(v.attrs), "data": v.data} for n, v in self.variables.items()},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
def to_xarray(self) -> Any:
|
|
179
|
+
"""An xarray ``Dataset`` sharing the values; needs xarray."""
|
|
180
|
+
import xarray as xr
|
|
181
|
+
|
|
182
|
+
coords = {d: (d, plain(labels), dict(self.coord_attrs.get(d, {}))) for d, labels in self.coords.items()}
|
|
183
|
+
return xr.Dataset({n: (v.dims, v.data, dict(v.attrs)) for n, v in self.variables.items()},
|
|
184
|
+
coords=coords, attrs=dict(self.attrs))
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_xarray(cls, dataset: Any) -> Collection:
|
|
188
|
+
"""A collection from an in-memory xarray ``Dataset`` whose coordinates are its axes' labels."""
|
|
189
|
+
variables = {name: Array._make(np.asarray(value.data), tuple(value.dims), {}, dict(value.attrs), name)
|
|
190
|
+
for name, value in dataset.data_vars.items()}
|
|
191
|
+
coords = {name: dataset[name].values for name in dataset.coords}
|
|
192
|
+
coord_attrs = {name: dict(dataset[name].attrs) for name in dataset.coords}
|
|
193
|
+
return cls(variables, coords, dict(dataset.attrs), coord_attrs)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _joined_labels(ours: np.ndarray, theirs: np.ndarray, how: str, dim: Hashable) -> np.ndarray:
|
|
197
|
+
"""The labels a join keeps, in the order xarray's joins give them."""
|
|
198
|
+
if how == "left":
|
|
199
|
+
return ours
|
|
200
|
+
found = label_index(theirs, "join", dim).get_indexer(ours) >= 0
|
|
201
|
+
if how == "inner":
|
|
202
|
+
return _freeze(ours[found])
|
|
203
|
+
extra = theirs[label_index(ours, "join", dim).get_indexer(theirs) < 0]
|
|
204
|
+
union = np.concatenate([ours, extra])
|
|
205
|
+
try:
|
|
206
|
+
return _freeze(np.sort(union, kind="stable"))
|
|
207
|
+
except TypeError: # labels that cannot be ordered keep their first-seen order
|
|
208
|
+
return _freeze(union)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _fill_for(dtype: np.dtype) -> tuple[np.dtype, Any]:
|
|
212
|
+
"""The type and missing value a reindexed variable needs, as xarray promotes them."""
|
|
213
|
+
if dtype.kind in "iu":
|
|
214
|
+
# As xarray promotes: integers of up to 16 bits to float32, wider ones to float64.
|
|
215
|
+
return np.dtype(np.float32 if dtype.itemsize <= 2 else np.float64), np.nan
|
|
216
|
+
if dtype.kind == "b":
|
|
217
|
+
return np.dtype(np.float64), np.nan
|
|
218
|
+
if dtype.kind == "f" or dtype.kind == "c":
|
|
219
|
+
return dtype, np.nan
|
|
220
|
+
if dtype.kind in "mM":
|
|
221
|
+
return dtype, np.datetime64("NaT") if dtype.kind == "M" else np.timedelta64("NaT")
|
|
222
|
+
return np.dtype(object), np.nan
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _reindex(value: Array, dim: Hashable, positions: np.ndarray, labels: np.ndarray) -> Array:
|
|
226
|
+
"""*value* on new labels for *dim*: position -1 becomes missing."""
|
|
227
|
+
axis = value.axis(dim)
|
|
228
|
+
missing = positions < 0
|
|
229
|
+
if not value.data.shape[axis]: # nothing to take from: every position is missing
|
|
230
|
+
dtype, fill = _fill_for(value.data.dtype) if len(positions) else (value.data.dtype, None)
|
|
231
|
+
shape = value.data.shape[:axis] + (len(positions),) + value.data.shape[axis + 1:]
|
|
232
|
+
taken = np.full(shape, fill, dtype=dtype) if len(positions) else np.empty(shape, dtype=dtype)
|
|
233
|
+
coords = dict(value.coords)
|
|
234
|
+
coords[dim] = labels
|
|
235
|
+
return Array._make(taken, value.dims, coords, value.attrs, value.name)
|
|
236
|
+
taken = np.take(value.data, np.where(missing, 0, positions), axis=axis)
|
|
237
|
+
if missing.any():
|
|
238
|
+
dtype, fill = _fill_for(value.data.dtype)
|
|
239
|
+
taken = taken.astype(dtype, copy=True) if taken.dtype != dtype else taken.copy()
|
|
240
|
+
index = (slice(None),) * axis + (missing,)
|
|
241
|
+
taken[index] = fill
|
|
242
|
+
coords = dict(value.coords)
|
|
243
|
+
coords[dim] = labels
|
|
244
|
+
return Array._make(taken, value.dims, coords, value.attrs, value.name)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _combine_attrs(*mappings: Mapping) -> dict:
|
|
248
|
+
"""xarray's ``drop_conflicts``: keep keys whose values agree wherever they appear."""
|
|
249
|
+
result: dict = {}
|
|
250
|
+
dropped: set = set()
|
|
251
|
+
for mapping in mappings:
|
|
252
|
+
for key, value in mapping.items():
|
|
253
|
+
if key in dropped:
|
|
254
|
+
continue
|
|
255
|
+
if key in result and not _same(result[key], value):
|
|
256
|
+
dropped.add(key)
|
|
257
|
+
del result[key]
|
|
258
|
+
elif key not in result:
|
|
259
|
+
result[key] = value
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _same(a: Any, b: Any) -> bool:
|
|
264
|
+
try:
|
|
265
|
+
return bool(np.array_equal(a, b)) if isinstance(a, np.ndarray) or isinstance(b, np.ndarray) else bool(a == b)
|
|
266
|
+
except Exception: # noqa: BLE001 - values that cannot be compared count as different
|
|
267
|
+
return False
|
labelarray/contract.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Contraction over named axes with ``np.matmul``: the sum of products over some axes, keeping the others.
|
|
2
|
+
|
|
3
|
+
Operands are arranged as ``(batch, rows, contracted)`` and ``(batch,
|
|
4
|
+
contracted, columns)`` C-ordered arrays, whatever their memory layout, and
|
|
5
|
+
multiplied with ``np.matmul``; batch axes are those both operands keep. The
|
|
6
|
+
result has the kept axes in order of first appearance, left then right (as
|
|
7
|
+
``np.einsum`` and ``xarray.dot`` order them), and is C-ordered. Arranging
|
|
8
|
+
the operands the same way for every layout makes the result independent of
|
|
9
|
+
layout. On 10M points rotated by a 3 x 3 matrix this takes 6.5 ms where
|
|
10
|
+
``np.einsum`` took 13.3 (68.7 on row-major input). Floating-point warnings
|
|
11
|
+
are off for the multiplication: BLAS libraries do not set the flags NumPy
|
|
12
|
+
reads reliably (Accelerate under NumPy 2.0 sets them on ordinary finite
|
|
13
|
+
data), so an overflow shows as infinity in the result, without a warning.
|
|
14
|
+
This module needs NumPy only.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from collections.abc import Hashable, Sequence
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def contract(left: np.ndarray, left_dims: Sequence[Hashable], right: np.ndarray, right_dims: Sequence[Hashable],
|
|
26
|
+
over: Sequence[Hashable]) -> tuple[np.ndarray, tuple[Hashable, ...]]:
|
|
27
|
+
"""The sum over *over* of products of *left* and *right*, and its axis names."""
|
|
28
|
+
left_dims, right_dims, over = tuple(left_dims), tuple(right_dims), tuple(over)
|
|
29
|
+
sizes = {**dict(zip(right_dims, right.shape)), **dict(zip(left_dims, left.shape))}
|
|
30
|
+
batch = tuple(d for d in left_dims if d in right_dims and d not in over)
|
|
31
|
+
rows = tuple(d for d in left_dims if d not in right_dims and d not in over)
|
|
32
|
+
columns = tuple(d for d in right_dims if d not in left_dims and d not in over)
|
|
33
|
+
kept = tuple(dict.fromkeys(d for d in (*left_dims, *right_dims) if d not in over))
|
|
34
|
+
|
|
35
|
+
def arranged(values, dims, first, second):
|
|
36
|
+
order = [dims.index(d) for d in (*batch, *first, *second)]
|
|
37
|
+
shape = (math.prod(sizes[d] for d in batch), math.prod(sizes[d] for d in first),
|
|
38
|
+
math.prod(sizes[d] for d in second))
|
|
39
|
+
return np.ascontiguousarray(np.transpose(values, order)).reshape(shape)
|
|
40
|
+
|
|
41
|
+
with np.errstate(all="ignore"):
|
|
42
|
+
product = np.matmul(arranged(left, left_dims, rows, over), arranged(right, right_dims, over, columns))
|
|
43
|
+
result = product.reshape(tuple(sizes[d] for d in (*batch, *rows, *columns)))
|
|
44
|
+
produced = (*batch, *rows, *columns)
|
|
45
|
+
result = np.transpose(result, [produced.index(d) for d in kept])
|
|
46
|
+
# np.ascontiguousarray would make a 0-d result 1-d; copy only what is not C-ordered already.
|
|
47
|
+
return (result if result.flags.c_contiguous else result.copy(order="C")), kept
|
labelarray/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""labelarray's errors: one family of ``ValueError``, so callers catching bad input keep working."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LabelArrayError(ValueError):
|
|
5
|
+
"""Base class for labelarray's errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LabelError(LabelArrayError):
|
|
9
|
+
"""Labels that cannot be ordered, compared, or looked up, or a label that is absent."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AlignError(LabelArrayError):
|
|
13
|
+
"""Labels on a shared axis do not match."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GroupError(LabelArrayError):
|
|
17
|
+
"""Key values or a key transform's settings that cannot give groups."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ShapeError(LabelArrayError):
|
|
21
|
+
"""Axis names, sizes, or ranks that do not fit together."""
|
labelarray/grouped.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Grouped reductions in one pass over positions sorted by group.
|
|
2
|
+
|
|
3
|
+
Reducing groups one by one costs a Python step per group, which dominates
|
|
4
|
+
when groups are many and small (points binned into voxels). Here every group
|
|
5
|
+
is reduced at once wherever the result is bit-identical to reducing each
|
|
6
|
+
group on its own, as both backends do otherwise:
|
|
7
|
+
|
|
8
|
+
- min, max, count, any, and all do not depend on the order values are
|
|
9
|
+
combined in, nor do integer and boolean sums; ``reduceat`` gives them for
|
|
10
|
+
every group.
|
|
11
|
+
- Float sums and means depend on the order. NumPy adds fewer than 8 values
|
|
12
|
+
one after another, starting from 0.0, in every memory layout; from 8 on it
|
|
13
|
+
adds in blocks. Groups with fewer than 8 positions are therefore added
|
|
14
|
+
position by position for all groups at once; larger groups are left to the
|
|
15
|
+
caller, which reduces them one by one.
|
|
16
|
+
|
|
17
|
+
Other reductions (std, var, median), other types, groups with no positions,
|
|
18
|
+
and reductions over further axes are left to the caller.
|
|
19
|
+
``ONE_PASS = False`` turns it off, for comparison in tests.
|
|
20
|
+
|
|
21
|
+
``Grouping`` is the object the kernels work from: one integer code per
|
|
22
|
+
position and the group labels in code order, with the group sizes and the
|
|
23
|
+
positions sorted by group computed once and kept. ``reduce``, ``counts``,
|
|
24
|
+
and ``broadcast`` apply a grouping to an ``Array``: every group's result
|
|
25
|
+
along a new group axis, and a result per group back on the positions.
|
|
26
|
+
This module needs NumPy only.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import builtins
|
|
32
|
+
from collections.abc import Hashable, Sequence
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from labelarray import sums
|
|
38
|
+
from labelarray.array import Array, _freeze, _take, labels_from
|
|
39
|
+
|
|
40
|
+
ONE_PASS = True
|
|
41
|
+
|
|
42
|
+
# Up to this many groups, scanning the axis once per group is cheaper than sorting it.
|
|
43
|
+
FEW_GROUPS = 8
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Grouping:
|
|
47
|
+
"""Positions assigned to groups: one code per position, and each group's label.
|
|
48
|
+
|
|
49
|
+
Codes are integers from 0 to ``len(labels) - 1``; ``labels[code]`` is the
|
|
50
|
+
group's label. ``counts()`` and ``blocks()`` are computed on first use and
|
|
51
|
+
kept, so a sort by group serves every reduction of the same grouping.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, codes: Any, labels: Sequence[Any]):
|
|
55
|
+
self.codes = np.asarray(codes)
|
|
56
|
+
self.labels = list(labels)
|
|
57
|
+
self._counts: np.ndarray | None = None
|
|
58
|
+
self._blocks: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None
|
|
59
|
+
|
|
60
|
+
def __len__(self) -> int:
|
|
61
|
+
return len(self.labels)
|
|
62
|
+
|
|
63
|
+
def counts(self) -> np.ndarray:
|
|
64
|
+
"""Positions in each group, in code order."""
|
|
65
|
+
if self._counts is None:
|
|
66
|
+
self._counts = np.bincount(self.codes, minlength=len(self.labels))
|
|
67
|
+
return self._counts
|
|
68
|
+
|
|
69
|
+
def blocks(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
70
|
+
"""Positions sorted by group, keeping their order within a group, and each group's start and end."""
|
|
71
|
+
if self._blocks is None:
|
|
72
|
+
# NumPy's stable sort is a radix sort for 8- and 16-bit integers, far faster than for int64.
|
|
73
|
+
narrow = np.min_scalar_type(max(len(self.labels) - 1, 0))
|
|
74
|
+
order = np.argsort(self.codes.astype(narrow, copy=False), kind="stable")
|
|
75
|
+
ends = np.cumsum(self.counts())
|
|
76
|
+
self._blocks = (order, np.concatenate(([0], ends[:-1])), ends)
|
|
77
|
+
return self._blocks
|
|
78
|
+
|
|
79
|
+
# NumPy adds fewer than this many values one after another; more, in blocks (pairwise summation).
|
|
80
|
+
SMALL = 8
|
|
81
|
+
|
|
82
|
+
_ORDER_FREE = {"min", "max", "count", "any", "all"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def one_pass(data: np.ndarray, axis: int, op: str, na_rm: bool, min_count: int | None,
|
|
86
|
+
order: np.ndarray, starts: np.ndarray, ends: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
|
|
87
|
+
"""*op* per group along *axis*, with the group axis first, and which groups it gives.
|
|
88
|
+
|
|
89
|
+
Groups are ``order[starts[g]:ends[g]]``. Rows of groups not given are
|
|
90
|
+
left unset for the caller. None when no group can be reduced here.
|
|
91
|
+
"""
|
|
92
|
+
if not ONE_PASS or not isinstance(data, np.ndarray):
|
|
93
|
+
return None
|
|
94
|
+
kind = data.dtype.kind
|
|
95
|
+
if kind not in "biuf" or (kind == "f" and data.dtype.itemsize < 4) or op not in (*_ORDER_FREE, "sum", "mean"):
|
|
96
|
+
return None
|
|
97
|
+
sizes = ends - starts
|
|
98
|
+
if not len(sizes) or not sizes.all():
|
|
99
|
+
return None # groups with no positions (empty bins) are reduced one by one, as before
|
|
100
|
+
any_size = op in _ORDER_FREE or (op == "sum" and kind in "biu")
|
|
101
|
+
done = np.ones(len(sizes), dtype=bool) if any_size else sizes < SMALL
|
|
102
|
+
if not done.any():
|
|
103
|
+
return None
|
|
104
|
+
ordered = np.moveaxis(np.take(data, order, axis=axis), axis, 0)
|
|
105
|
+
missing = np.isnan(ordered) if kind == "f" else None
|
|
106
|
+
with np.errstate(all="ignore"):
|
|
107
|
+
values = _reduce(ordered, missing, kind, op, na_rm, min_count, starts, sizes, done)
|
|
108
|
+
if done.all():
|
|
109
|
+
return values, done
|
|
110
|
+
result = np.empty((len(sizes), *values.shape[1:]), dtype=values.dtype)
|
|
111
|
+
result[done] = values
|
|
112
|
+
return result, done
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _reduce(ordered, missing, kind, op, na_rm, min_count, starts, sizes, done):
|
|
116
|
+
"""The reduction for the groups in *done*, matching ``Array.reduce`` group by group."""
|
|
117
|
+
if op in ("min", "max"):
|
|
118
|
+
skip = na_rm and kind == "f" # nanmin and nanmax ignore NaN; fmin and fmax do too
|
|
119
|
+
ufunc = {("min", False): np.minimum, ("min", True): np.fmin,
|
|
120
|
+
("max", False): np.maximum, ("max", True): np.fmax}[op, skip]
|
|
121
|
+
return ufunc.reduceat(ordered, starts, axis=0)
|
|
122
|
+
present = ~missing if missing is not None else None
|
|
123
|
+
if op == "count":
|
|
124
|
+
present = present if present is not None else np.ones(ordered.shape, dtype=bool)
|
|
125
|
+
return np.add.reduceat(present, starts, axis=0, dtype=_sum_type(present))
|
|
126
|
+
if op in ("any", "all"):
|
|
127
|
+
filled = ordered if missing is None else np.where(missing, op == "all", ordered)
|
|
128
|
+
ufunc = np.logical_or if op == "any" else np.logical_and
|
|
129
|
+
result = ufunc.reduceat(filled.astype(bool, copy=False), starts, axis=0)
|
|
130
|
+
if na_rm or kind in "biu" or missing is None:
|
|
131
|
+
return result
|
|
132
|
+
return np.where(~np.logical_or.reduceat(missing, starts, axis=0), result, np.nan)
|
|
133
|
+
skip = na_rm and kind == "f"
|
|
134
|
+
source = np.where(missing, 0.0, ordered) if skip else ordered # nansum and nanmean count NaN as 0
|
|
135
|
+
if op == "sum":
|
|
136
|
+
if kind in "biu":
|
|
137
|
+
total = np.add.reduceat(source, starts, axis=0, dtype=_sum_type(source))
|
|
138
|
+
else:
|
|
139
|
+
total = _left_to_right(source, starts[done], sizes[done])
|
|
140
|
+
if min_count is None:
|
|
141
|
+
return total
|
|
142
|
+
counts = _counts(present, ordered.shape, starts, sizes)[done]
|
|
143
|
+
return np.where(counts >= min_count, total, np.nan)
|
|
144
|
+
# mean: integers and booleans are added as float64, as np.mean adds them
|
|
145
|
+
total = _left_to_right(source if kind == "f" else source.astype(np.float64), starts[done], sizes[done])
|
|
146
|
+
counts = _counts(present if skip else None, ordered.shape, starts, sizes)[done]
|
|
147
|
+
# np.mean and np.nanmean divide in float64 and store in the sum's type.
|
|
148
|
+
return (total.astype(np.float64) / counts).astype(total.dtype, copy=False)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _left_to_right(values: np.ndarray, first: np.ndarray, sizes: np.ndarray) -> np.ndarray:
|
|
152
|
+
"""Each group's values added one after another, for groups of fewer than ``SMALL`` positions."""
|
|
153
|
+
total = values[first] + 0.0 # NumPy's sums start from 0.0, so a group of -0.0 sums to 0.0
|
|
154
|
+
for rank in range(1, int(sizes.max())):
|
|
155
|
+
active = np.flatnonzero(sizes > rank)
|
|
156
|
+
total[active] += values[first[active] + rank]
|
|
157
|
+
return total
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _counts(present, shape, starts, sizes) -> np.ndarray:
|
|
161
|
+
"""Present values per group (every position when *present* is None), shaped like one group's result."""
|
|
162
|
+
if present is None:
|
|
163
|
+
return sizes.reshape(-1, *([1] * (len(shape) - 1)))
|
|
164
|
+
return np.add.reduceat(present, starts, axis=0, dtype=np.intp)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _sum_type(values: np.ndarray) -> np.dtype:
|
|
168
|
+
"""The type ``np.sum`` gives: small integers and booleans add as the platform integer."""
|
|
169
|
+
return np.sum(values[:0], axis=0).dtype
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Groupings applied to arrays ─────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def reduce(array: Array, op: str, grouping: Grouping, axis: Hashable, name: Hashable,
|
|
176
|
+
others: Sequence[Hashable] = (), *, na_rm: bool = False, min_count: int | None = None) -> Array:
|
|
177
|
+
"""*op* per group of positions on *axis*, and over *others*: the groups along a new first axis *name*.
|
|
178
|
+
|
|
179
|
+
Each group's result is bit-identical to reducing that group on its own
|
|
180
|
+
(``Array.reduce`` over a mask), whichever path computes it.
|
|
181
|
+
"""
|
|
182
|
+
if op in ("sum", "mean") and array.data.dtype == np.float32:
|
|
183
|
+
axes = (array.axis(axis), *(array.axis(o) for o in others))
|
|
184
|
+
values = sums.reduce(array.data, op, axes, na_rm=na_rm, min_count=min_count, codes=grouping.codes,
|
|
185
|
+
groups=len(grouping), sizes=grouping.counts())
|
|
186
|
+
dims = tuple(d for d in array.dims if d != axis and d not in others)
|
|
187
|
+
coords = {d: c for d, c in array.coords.items() if d in dims}
|
|
188
|
+
return Array._make(values, (name, *dims), {name: labels_from(grouping.labels), **coords})
|
|
189
|
+
fast = _one_pass_array(array, op, grouping, axis, name, others, na_rm, min_count)
|
|
190
|
+
if fast is not None:
|
|
191
|
+
return fast
|
|
192
|
+
stats = [part.reduce(op, [axis, *others], na_rm=na_rm, min_count=min_count)
|
|
193
|
+
for part in _group_parts(array, grouping, axis)]
|
|
194
|
+
labels = labels_from(grouping.labels)
|
|
195
|
+
if stats:
|
|
196
|
+
data = np.stack([s.data for s in stats])
|
|
197
|
+
return Array._make(data, (name, *stats[0].dims), {name: labels, **stats[0].coords})
|
|
198
|
+
empty = _take(array, axis, builtins.slice(0, 0)).reduce(op, [axis, *others], na_rm=na_rm, min_count=min_count)
|
|
199
|
+
return Array._make(empty.data[np.newaxis][:0], (name, *empty.dims), {name: labels, **empty.coords})
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def counts(grouping: Grouping, name: Hashable, per_position: int = 1) -> Array:
|
|
203
|
+
"""Values per group: positions times *per_position*, along the axis *name*."""
|
|
204
|
+
# Plain NumPy labels, not objects: xarray labels counts this way.
|
|
205
|
+
return Array._make(grouping.counts() * per_position, (name,), {name: _freeze(np.asarray(grouping.labels))})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def broadcast(stats: Array, grouping: Grouping, axis: Hashable, name: Hashable,
|
|
209
|
+
labels: np.ndarray | None = None) -> Array:
|
|
210
|
+
"""A result per group (along *name*) back on each position of *axis*, labelled *labels* if given."""
|
|
211
|
+
number = stats.axis(name)
|
|
212
|
+
# Group results come with the group axis first (reduce). Indexing gathers them
|
|
213
|
+
# several times faster than np.take, into the same values and the same C-ordered layout.
|
|
214
|
+
data = stats.data[grouping.codes] if number == 0 else np.take(stats.data, grouping.codes, axis=number)
|
|
215
|
+
dims = stats.dims[:number] + (axis,) + stats.dims[number + 1:]
|
|
216
|
+
coords = {d: c for d, c in stats.coords.items() if d != name}
|
|
217
|
+
if labels is not None:
|
|
218
|
+
coords[axis] = labels
|
|
219
|
+
return Array._make(data, dims, coords)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _one_pass_array(array: Array, op: str, grouping: Grouping, axis: Hashable, name: Hashable,
|
|
223
|
+
others: Sequence[Hashable], na_rm: bool, min_count: int | None) -> Array | None:
|
|
224
|
+
"""Every group at once where that is exact (``one_pass``); the rest one by one."""
|
|
225
|
+
if others or len(grouping) <= FEW_GROUPS:
|
|
226
|
+
return None
|
|
227
|
+
order, starts, ends = grouping.blocks()
|
|
228
|
+
fast = one_pass(array.data, array.axis(axis), op, na_rm, min_count, order, starts, ends)
|
|
229
|
+
if fast is None:
|
|
230
|
+
return None
|
|
231
|
+
values, done = fast
|
|
232
|
+
rest = np.flatnonzero(~done)
|
|
233
|
+
if len(rest):
|
|
234
|
+
stats = np.stack([_take(array, axis, order[int(starts[g]):int(ends[g])])
|
|
235
|
+
.reduce(op, [axis], na_rm=na_rm, min_count=min_count).data for g in rest])
|
|
236
|
+
if stats.dtype != values.dtype:
|
|
237
|
+
return None
|
|
238
|
+
values[rest] = stats
|
|
239
|
+
dims = tuple(d for d in array.dims if d != axis)
|
|
240
|
+
coords = {d: c for d, c in array.coords.items() if d in dims}
|
|
241
|
+
return Array._make(values, (name, *dims), {name: labels_from(grouping.labels), **coords})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _group_parts(array: Array, grouping: Grouping, axis: Hashable) -> list[Array]:
|
|
245
|
+
"""Each group's positions, in their original order and in a mask's memory layout.
|
|
246
|
+
|
|
247
|
+
Indexing an axis other than the first with positions or a mask lays
|
|
248
|
+
the result out with that axis first in memory; reductions over it then
|
|
249
|
+
add in another order than over a contiguous copy, which changes float
|
|
250
|
+
results in the last bit. Each group is therefore indexed by its
|
|
251
|
+
positions, as a mask would be, so results are bit-identical to xarray
|
|
252
|
+
and to per-group masks. With many groups, the positions come from one
|
|
253
|
+
sort instead of one scan of the axis per group.
|
|
254
|
+
"""
|
|
255
|
+
count = len(grouping)
|
|
256
|
+
if count <= FEW_GROUPS:
|
|
257
|
+
return [_take(array, axis, np.flatnonzero(grouping.codes == code)) for code in range(count)]
|
|
258
|
+
order, starts, ends = grouping.blocks()
|
|
259
|
+
return [_take(array, axis, order[int(start):int(end)]) for start, end in zip(starts, ends)]
|