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/array.py ADDED
@@ -0,0 +1,474 @@
1
+ """The labelled array.
2
+
3
+ An ``Array`` is a NumPy array with one name per axis, at most one label
4
+ array per axis, attributes, and a name. Its labels are its own: labels from
5
+ outside are copied once into a read-only ``_Labels`` array, which nothing
6
+ else can unlock, so each label array's index (``labelarray.index``) is built
7
+ on first use and kept on the array, released with it. Labels compare under
8
+ one exact rule (``labelarray.labels``). Values are not copied or frozen:
9
+ wrapping an array shares it.
10
+
11
+ NumPy's ufuncs, ``np.where``, and Python's operators broadcast by axis name
12
+ under the exact alignment rule (``labelarray.align``). Selection by
13
+ positions or labels, transposing, renaming axes, reductions (float32 sums
14
+ and means correctly rounded, ``labelarray.sums``), and contraction
15
+ (``dot``) are methods; ``concat``, ``align``, and ``broadcast`` work on
16
+ several arrays.
17
+
18
+ This module needs NumPy only.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import builtins
24
+ import warnings
25
+ from collections.abc import Callable, Hashable, Mapping, Sequence
26
+ from typing import Any
27
+
28
+ import numpy as np
29
+
30
+ from labelarray import sums
31
+ from labelarray.align import order_like
32
+ from labelarray.contract import contract
33
+ from labelarray.errors import AlignError, LabelError, ShapeError
34
+ from labelarray.index import LabelIndex, index_for
35
+
36
+
37
+ class Array:
38
+ """A NumPy array with one name per axis, labels for its labelled axes, attributes, and a name.
39
+
40
+ NumPy's ufuncs, ``np.where``, and Python's operators work on it, broadcasting by axis name under
41
+ the exact alignment rule (``labelarray.align``): the same labels in another order are matched by
42
+ label, and labels that differ are an ``AlignError``. Results have no attributes or name. Python's
43
+ operators do not warn about division by zero or overflow, as on xarray arrays; NumPy's functions do.
44
+ """
45
+
46
+ __slots__ = ("attrs", "coords", "data", "dims", "name")
47
+
48
+ __hash__ = None # type: ignore[assignment] # == compares values, as on NumPy and xarray arrays
49
+
50
+ def __init__(self, data: Any, dims: Sequence[Hashable] = (), coords: Mapping[Hashable, Any] | None = None,
51
+ attrs: Mapping[Hashable, Any] | None = None, name: Hashable | None = None):
52
+ data = data if isinstance(data, np.ndarray) else np.asarray(data)
53
+ dims = (dims,) if isinstance(dims, str) else tuple(dims)
54
+ if len(dims) != data.ndim:
55
+ raise ShapeError(f"{len(dims)} axis names {dims} for an array of rank {data.ndim}")
56
+ if len(set(dims)) != len(dims):
57
+ raise ShapeError(f"axis names must be unique, got {dims}")
58
+ self.data, self.dims, self.name = data, dims, name
59
+ self.coords = _checked_coords(coords or {}, dict(zip(dims, data.shape)))
60
+ self.attrs = dict(attrs or {})
61
+
62
+ @classmethod
63
+ def _make(cls, data: np.ndarray, dims: tuple, coords: dict, attrs: dict | None = None,
64
+ name: Hashable | None = None) -> Array:
65
+ """Build without checks, from parts already validated, with labels this library froze."""
66
+ array = object.__new__(cls)
67
+ array.data, array.dims, array.coords = data, dims, coords
68
+ array.attrs, array.name = attrs if attrs is not None else {}, name
69
+ return array
70
+
71
+ @property
72
+ def sizes(self) -> dict[Hashable, int]:
73
+ return dict(zip(self.dims, self.data.shape))
74
+
75
+ @property
76
+ def shape(self) -> tuple[int, ...]:
77
+ return self.data.shape
78
+
79
+ @property
80
+ def ndim(self) -> int:
81
+ return self.data.ndim
82
+
83
+ @property
84
+ def dtype(self) -> np.dtype:
85
+ return self.data.dtype
86
+
87
+ @property
88
+ def values(self) -> np.ndarray:
89
+ return self.data
90
+
91
+ def axis(self, dim: Hashable) -> int:
92
+ return self.dims.index(dim)
93
+
94
+ def labels(self, dim: Hashable) -> np.ndarray:
95
+ """The labels of *dim* as a plain, read-only NumPy array."""
96
+ return plain(self.coords[dim])
97
+
98
+ def __repr__(self) -> str:
99
+ sizes = ", ".join(f"{d}: {n}" for d, n in self.sizes.items())
100
+ return f"<{type(self).__name__} {self.name or ''}({sizes}) {self.dtype}>"
101
+
102
+ # ── Selection and shape ─────────────────────────────────────────────────
103
+
104
+ def isel(self, indexers: Mapping[Hashable, Any] | None = None, **more: Any) -> Array:
105
+ """Positions, a slice, or a mask per axis; an integer removes the axis."""
106
+ array = self
107
+ for dim, index in {**(indexers or {}), **more}.items():
108
+ array = _take(array, dim, index)
109
+ return array
110
+
111
+ def sel(self, indexers: Mapping[Hashable, Any] | None = None, **more: Any) -> Array:
112
+ """Labels per axis, looked up exactly; one label removes the axis, a list keeps it."""
113
+ wanted = {**(indexers or {}), **more}
114
+ return self.isel({dim: positions_of(self.coords[dim], labels, dim) for dim, labels in wanted.items()})
115
+
116
+ def transpose(self, *dims: Hashable) -> Array:
117
+ order = [self.axis(d) for d in dims]
118
+ return Array._make(np.transpose(self.data, order), tuple(dims), self.coords, self.attrs, self.name)
119
+
120
+ def rename(self, names: Mapping[Hashable, Hashable]) -> Array:
121
+ """Axes renamed, labels with them."""
122
+ dims = tuple(names.get(d, d) for d in self.dims)
123
+ coords = {names.get(d, d): c for d, c in self.coords.items()}
124
+ return Array._make(self.data, dims, coords, self.attrs, self.name)
125
+
126
+ # ── Computation ─────────────────────────────────────────────────────────
127
+
128
+ def reduce(self, op: str, axes: Sequence[Hashable], *, na_rm: bool = False,
129
+ min_count: int | None = None) -> Array:
130
+ """*op* (sum, mean, min, max, median, std, var, any, all, count) over *axes*.
131
+
132
+ Float32 sums and means are correctly rounded (``labelarray.sums``); other
133
+ reductions are NumPy's. ``na_rm`` skips missing values; a sum with
134
+ ``min_count`` is missing where fewer values are present.
135
+ """
136
+ axes = list(axes)
137
+ if not axes:
138
+ return self
139
+ numbers = tuple(self.axis(a) for a in axes)
140
+ if op in ("sum", "mean") and self.data.dtype == np.float32:
141
+ result = sums.reduce(self.data, op, numbers, na_rm=na_rm, min_count=min_count)
142
+ else:
143
+ # One axis goes to NumPy as an integer, as xarray passes it: NumPy sums a
144
+ # tuple of axes in another order, which changes float results in the last bit.
145
+ result = _reduce_data(self.data, op, numbers[0] if len(numbers) == 1 else numbers, na_rm, min_count)
146
+ dims = tuple(d for d in self.dims if d not in axes)
147
+ return Array._make(np.asarray(result), dims, {d: c for d, c in self.coords.items() if d in dims})
148
+
149
+ def dot(self, other: Array, over: Sequence[Hashable]) -> Array:
150
+ """The sum of products over the axes *over*, as ``matmul`` computes it in any memory layout."""
151
+ result, kept = contract(self.data, self.dims, other.data, other.dims, over)
152
+ coords = {d: c for item in (self, other) for d, c in item.coords.items() if d in kept}
153
+ return Array._make(result, kept, coords)
154
+
155
+ # ── NumPy's protocols ───────────────────────────────────────────────────
156
+
157
+ def __array_ufunc__(self, ufunc: np.ufunc, method: str, *inputs: Any, **kwargs: Any) -> Any:
158
+ # Reductions, generalised ufuncs (core dimensions), and out= or where= arrays address
159
+ # positions, not axis names, so they are left to NumPy's error.
160
+ if method != "__call__" or ufunc.signature is not None or "out" in kwargs or "where" in kwargs:
161
+ return NotImplemented
162
+ if not all(isinstance(x, Array) or _is_scalar(x) for x in inputs):
163
+ return NotImplemented # a plain array has no axis names to broadcast by
164
+ return _elementwise(ufunc, inputs, kwargs)
165
+
166
+ def __array_function__(self, func: Callable, types: Sequence[type], args: Sequence[Any],
167
+ kwargs: Mapping[str, Any]) -> Any:
168
+ handler = _FUNCTIONS.get(func)
169
+ if handler is None or not all(issubclass(t, (Array, np.ndarray)) for t in types):
170
+ return NotImplemented
171
+ return handler(*args, **kwargs)
172
+
173
+
174
+ # ── Labels ──────────────────────────────────────────────────────────────────
175
+
176
+
177
+ class _Labels(np.ndarray):
178
+ """Labels this library owns: read-only, never shared writeable, with their index kept on them.
179
+
180
+ Views and slices are labels too, each with its own index. Arithmetic on
181
+ labels gives plain arrays, so labels never leak into values.
182
+ """
183
+
184
+ _index: LabelIndex | None = None
185
+
186
+ def __array_finalize__(self, obj: Any) -> None:
187
+ self._index = None
188
+
189
+ def __array_ufunc__(self, ufunc: Any, method: str, *inputs: Any, **kwargs: Any) -> Any:
190
+ inputs = tuple(x.view(np.ndarray) if isinstance(x, _Labels) else x for x in inputs)
191
+ return getattr(ufunc, method)(*inputs, **kwargs)
192
+
193
+
194
+ def labels_from(labels: Any) -> np.ndarray:
195
+ """Labels for an array, from values the caller made and does not keep: text is stored as objects."""
196
+ values = np.asarray(labels)
197
+ if values.dtype.kind in "UO":
198
+ values = values.astype(object)
199
+ return _freeze(values)
200
+
201
+
202
+ def plain(labels: np.ndarray) -> np.ndarray:
203
+ """Labels as a plain NumPy array, without the index kept on them."""
204
+ return labels.view(np.ndarray)
205
+
206
+
207
+ def label_index(labels: np.ndarray, op: str = "index", dim: Hashable = None) -> LabelIndex:
208
+ """The index for a label array (``labelarray.index.index_for``): built on first use and kept on labels
209
+ this library owns.
210
+
211
+ Other arrays, which could change, get a fresh index each time.
212
+ """
213
+ if not _owned(labels):
214
+ return index_for(labels, op, dim)
215
+ if labels._index is None:
216
+ labels._index = index_for(labels, op, dim)
217
+ return labels._index
218
+
219
+
220
+ def _checked_coords(coords: Mapping[Hashable, Any], sizes: Mapping[Hashable, int]) -> dict:
221
+ out = {}
222
+ for dim, labels in coords.items():
223
+ labels = _frozen_labels(labels)
224
+ if dim not in sizes:
225
+ raise ShapeError(f"labels for {dim}, which is not an axis; axes are {tuple(sizes)}")
226
+ if labels.ndim != 1 or len(labels) != sizes[dim]:
227
+ raise ShapeError(f"{dim} has {sizes[dim]} positions but {labels.size} labels")
228
+ out[dim] = labels
229
+ return out
230
+
231
+
232
+ def _owned(labels: Any) -> bool:
233
+ return isinstance(labels, _Labels) and not labels.flags.writeable
234
+
235
+
236
+ def _frozen_labels(labels: Any) -> _Labels:
237
+ """Labels that can never change: this library's own are kept, others copied once."""
238
+ if _owned(labels):
239
+ return labels
240
+ return _freeze(np.array(labels, copy=True))
241
+
242
+
243
+ def _freeze(array: np.ndarray) -> _Labels:
244
+ """Freeze a label array the caller just created and owns alone."""
245
+ if _owned(array):
246
+ return array
247
+ labels = array.view(_Labels)
248
+ labels.flags.writeable = False
249
+ return labels
250
+
251
+
252
+ # ── Several arrays ──────────────────────────────────────────────────────────
253
+
254
+
255
+ def broadcast(operands: Sequence[Any]) -> tuple[list[Any], tuple[Hashable, ...], dict]:
256
+ """Raw values lined up on the union of named axes (first appearance), and the union's labels.
257
+
258
+ Labels are not compared here: callers align them first (``align``); sizes are checked.
259
+ Operands that are not arrays (scalars) are passed through.
260
+ """
261
+ dims: list[Hashable] = []
262
+ sizes: dict[Hashable, int] = {}
263
+ coords: dict[Hashable, np.ndarray] = {}
264
+ for item in operands:
265
+ if isinstance(item, Array):
266
+ for dim, size in zip(item.dims, item.data.shape):
267
+ if dim not in sizes:
268
+ dims.append(dim)
269
+ sizes[dim] = size
270
+ elif sizes[dim] != size:
271
+ raise ShapeError(f"{dim} has {sizes[dim]} positions on one operand and {size} on another")
272
+ if dim in item.coords and dim not in coords:
273
+ coords[dim] = item.coords[dim]
274
+ raw = []
275
+ for item in operands:
276
+ if not isinstance(item, Array):
277
+ raw.append(item)
278
+ continue
279
+ present = [d for d in dims if d in item.dims]
280
+ data = item.data
281
+ if list(item.dims) != present:
282
+ data = np.transpose(data, [item.dims.index(d) for d in present])
283
+ if len(present) != len(dims):
284
+ data = np.reshape(data, [sizes[d] if d in item.dims else 1 for d in dims])
285
+ raw.append(data)
286
+ return raw, tuple(dims), coords
287
+
288
+
289
+ def align(*operands: Any) -> list[Any]:
290
+ """Each array's axes matched to the first array with that axis, under the exact rule.
291
+
292
+ The same labels in another order are put in the first array's order; an
293
+ axis labelled on one array and not on another, different labels, or
294
+ different sizes without labels are an ``AlignError``. Scalars are passed through.
295
+ """
296
+ first: dict[Hashable, Array] = {}
297
+ aligned = []
298
+ for item in operands:
299
+ if isinstance(item, Array):
300
+ for dim in item.dims:
301
+ if dim in first:
302
+ item = _matched(first[dim], item, dim)
303
+ for dim in item.dims:
304
+ first.setdefault(dim, item)
305
+ aligned.append(item)
306
+ return aligned
307
+
308
+
309
+ def concat(values: Sequence[Any], dim: Hashable, labels: Sequence[Any]) -> Array:
310
+ """Arrays (or scalars) stacked along a new first axis *dim* with *labels*, broadcast by axis name."""
311
+ arrays = [v if isinstance(v, Array) else Array(v) for v in values]
312
+ raw, dims, coords = broadcast(arrays)
313
+ shape = np.broadcast_shapes(*(np.shape(r) for r in raw))
314
+ data = np.stack([np.broadcast_to(r, shape) for r in raw])
315
+ return Array._make(data, (dim, *dims), {dim: labels_from(labels), **coords})
316
+
317
+
318
+ def _matched(reference: Array, value: Array, dim: Hashable) -> Array:
319
+ labelled = dim in reference.coords
320
+ if labelled != (dim in value.coords):
321
+ raise AlignError(f"{dim} is labelled on one operand but not on another; "
322
+ "pairing by length could match the wrong positions")
323
+ if not labelled:
324
+ ours, theirs = reference.sizes[dim], value.sizes[dim]
325
+ if ours != theirs:
326
+ raise AlignError(f"{dim} has {ours:,} positions on one operand and {theirs:,} on another, "
327
+ "and neither has labels to match")
328
+ return value
329
+ order = order_like(reference.coords[dim], value.coords[dim], dim, index=label_index)
330
+ if order is None:
331
+ return value
332
+ taken = _take(value, dim, order)
333
+ taken.coords[dim] = reference.coords[dim]
334
+ return taken
335
+
336
+
337
+ # ── NumPy's protocols ───────────────────────────────────────────────────────
338
+
339
+
340
+ def _is_scalar(value: Any) -> bool:
341
+ return isinstance(value, (int, float, complex, str, bytes, np.generic)) or \
342
+ (isinstance(value, np.ndarray) and value.ndim == 0)
343
+
344
+
345
+ def _elementwise(func: Callable, operands: Sequence[Any], kwargs: Mapping[str, Any]) -> Any:
346
+ """*func* on operands aligned and broadcast by axis name; one array per output."""
347
+ raw, dims, coords = broadcast(align(*operands))
348
+ result = func(*raw, **kwargs)
349
+ if isinstance(result, tuple):
350
+ return tuple(Array._make(np.asarray(r), dims, coords) for r in result)
351
+ return Array._make(np.asarray(result), dims, coords)
352
+
353
+
354
+ def _where(condition: Any, true: Any = None, false: Any = None) -> Any:
355
+ operands = (condition, true, false)
356
+ if true is None or false is None or not all(isinstance(v, Array) or _is_scalar(v) for v in operands):
357
+ return NotImplemented # with one argument, np.where gives positions
358
+ return _elementwise(np.where, operands, {})
359
+
360
+
361
+ _FUNCTIONS: dict[Callable, Callable] = {np.where: _where}
362
+
363
+
364
+ def _operator(ufunc: np.ufunc, reflected: bool = False) -> Callable:
365
+ def method(self: Array, other: Any) -> Any:
366
+ # Objects that set __array_ufunc__ to None (such as expression objects) build their own
367
+ # result through their reflected operators; anything else without axis names is not an operand.
368
+ if getattr(other, "__array_ufunc__", False) is None or not (isinstance(other, Array) or _is_scalar(other)):
369
+ return NotImplemented
370
+ with np.errstate(all="ignore"):
371
+ return _elementwise(ufunc, (other, self) if reflected else (self, other), {})
372
+
373
+ return method
374
+
375
+
376
+ def _unary(ufunc: np.ufunc) -> Callable:
377
+ def method(self: Array) -> Array:
378
+ with np.errstate(all="ignore"):
379
+ return _elementwise(ufunc, (self,), {})
380
+
381
+ return method
382
+
383
+
384
+ for _name, _ufunc in [
385
+ ("add", np.add), ("sub", np.subtract), ("mul", np.multiply), ("truediv", np.true_divide),
386
+ ("floordiv", np.floor_divide), ("mod", np.remainder), ("divmod", np.divmod), ("pow", np.power),
387
+ ("and", np.bitwise_and), ("or", np.bitwise_or), ("xor", np.bitwise_xor),
388
+ ("lshift", np.left_shift), ("rshift", np.right_shift),
389
+ ]:
390
+ setattr(Array, f"__{_name}__", _operator(_ufunc))
391
+ setattr(Array, f"__r{_name}__", _operator(_ufunc, reflected=True))
392
+ for _name, _ufunc in [("lt", np.less), ("le", np.less_equal), ("eq", np.equal), ("ne", np.not_equal),
393
+ ("gt", np.greater), ("ge", np.greater_equal)]:
394
+ setattr(Array, f"__{_name}__", _operator(_ufunc)) # Python swaps the sides of comparisons itself
395
+ for _name, _ufunc in [("neg", np.negative), ("pos", np.positive), ("abs", np.absolute), ("invert", np.invert)]:
396
+ setattr(Array, f"__{_name}__", _unary(_ufunc))
397
+
398
+
399
+ # ── Selection ───────────────────────────────────────────────────────────────
400
+
401
+
402
+ def _take(value: Array, dim: Hashable, index: Any) -> Array:
403
+ """Positions, a slice, or a mask on one axis; an integer removes the axis."""
404
+ axis = value.axis(dim)
405
+ data = value.data[(builtins.slice(None),) * axis + (index,)]
406
+ coords = dict(value.coords)
407
+ if isinstance(index, (int, np.integer)):
408
+ coords.pop(dim, None)
409
+ dims = value.dims[:axis] + value.dims[axis + 1:]
410
+ else:
411
+ dims = value.dims
412
+ if dim in coords:
413
+ coords[dim] = _cut(coords[dim], index)
414
+ return Array._make(data, dims, coords, value.attrs, value.name)
415
+
416
+
417
+ def _cut(labels: np.ndarray, index: Any) -> np.ndarray:
418
+ """Labels at positions, a slice, or a mask: a slice is a view of owned labels, the rest a new array."""
419
+ cut = labels[index]
420
+ return cut if isinstance(index, builtins.slice) else _freeze(cut)
421
+
422
+
423
+ def positions_of(labels: np.ndarray, wanted: Any, dim: Hashable) -> Any:
424
+ """An integer for one label, positions for a list, as xarray's ``sel`` reads them."""
425
+ index = label_index(labels, "sel", dim)
426
+ many = not np.isscalar(wanted) and not (isinstance(wanted, np.ndarray) and wanted.ndim == 0)
427
+ found = index.get_indexer(np.atleast_1d(np.asarray(wanted, dtype=object if _is_text(wanted) else None)))
428
+ if (found < 0).any():
429
+ missing = np.atleast_1d(np.asarray(wanted, dtype=object))[found < 0][0]
430
+ raise LabelError(f"{dim} has no label {missing!r}")
431
+ return found if many else int(found[0])
432
+
433
+
434
+ def _is_text(value: Any) -> bool:
435
+ return isinstance(value, str) or (isinstance(value, (list, tuple)) and any(isinstance(v, str) for v in value))
436
+
437
+
438
+ # ── Reductions ──────────────────────────────────────────────────────────────
439
+
440
+
441
+ _REDUCERS = {
442
+ "sum": (np.sum, np.nansum), "mean": (np.mean, np.nanmean), "min": (np.min, np.nanmin),
443
+ "max": (np.max, np.nanmax), "median": (np.median, np.nanmedian), "std": (np.std, np.nanstd),
444
+ "var": (np.var, np.nanvar),
445
+ }
446
+
447
+
448
+ def _reduce_data(data: np.ndarray, op: str, axes: int | tuple[int, ...], na_rm: bool, min_count: int | None) -> Any:
449
+ """NumPy reductions with missing values handled as xarray handles them."""
450
+ kind = data.dtype.kind
451
+ numbers = (axes,) if isinstance(axes, int) else axes
452
+ # Only some reductions need to know where values are missing; finding out reads the whole array.
453
+ needs_missing = op in ("any", "all", "count") or (op == "sum" and min_count is not None)
454
+ missing = None if not needs_missing else np.isnan(data) if kind in "fc" else np.isnat(data) if kind in "mM" else None
455
+ if op in ("min", "max") and builtins.any(data.shape[axis] == 0 for axis in numbers):
456
+ shape = tuple(n for i, n in enumerate(data.shape) if i not in numbers)
457
+ return np.full(shape, np.nan)
458
+ with warnings.catch_warnings(), np.errstate(all="ignore"):
459
+ warnings.simplefilter("ignore", RuntimeWarning)
460
+ if op in ("any", "all"):
461
+ filled = data if missing is None else np.where(missing, op == "all", data)
462
+ result = getattr(np, op)(filled, axis=axes)
463
+ if na_rm or kind in "biu" or missing is None:
464
+ return result
465
+ return np.where(~missing.any(axis=axes), result, np.nan) # missing wherever a value was
466
+ if op == "count":
467
+ present = ~missing if missing is not None else np.ones(data.shape, dtype=bool)
468
+ return present.sum(axis=axes)
469
+ skip = na_rm and kind in "fcmM"
470
+ result = _REDUCERS[op][1 if skip else 0](data, axis=axes)
471
+ if op == "sum" and min_count is not None:
472
+ present = ~missing if missing is not None else np.ones(data.shape, dtype=bool)
473
+ result = np.where(present.sum(axis=axes) >= min_count, result, np.nan)
474
+ return result