nulldtype 0.1.0__cp313-cp313-win_amd64.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.
Binary file
@@ -0,0 +1,270 @@
1
+ Metadata-Version: 2.4
2
+ Name: nulldtype
3
+ Version: 0.1.0
4
+ Summary: Missing values as a NumPy DType: NA stored as a reserved bit pattern of the wrapped type
5
+ License-Expression: BSD-3-Clause
6
+ Project-URL: Source, https://github.com/tuan2k33/NullableDType
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Programming Language :: C
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE.txt
14
+ Requires-Dist: numpy>=2.5
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # Nullable[T] — prototype
20
+
21
+ [![CI](https://github.com/tuan2k33/NullableDType/actions/workflows/ci.yml/badge.svg)](https://github.com/tuan2k33/NullableDType/actions/workflows/ci.yml)
22
+
23
+ `Nullable[T]` is a **parametric DType**: it wraps any other dtype and gives it a
24
+ notion of missing data that lives **inside the dtype**, not in a parallel mask.
25
+ NA is a reserved value of the wrapped type itself, so an element costs exactly
26
+ what that type costs:
27
+
28
+ ```python
29
+ nd.Nullable(np.float64) # itemsize 8 — NA is every bit but the sign
30
+ nd.Nullable("U10") # itemsize 40 — NA is the whole cell of U+FFFF
31
+ nd.Nullable(record) # itemsize T — every field holds its own type's NA
32
+ ```
33
+
34
+ Nullable treats NA as a datum, not as a number that is hidden, so there is
35
+ nothing like `a.unmask()` or `a[2].value`. Assigning NA overwrites the old value
36
+ the way assigning any other value would, and the old value cannot leak out
37
+ later. To set values aside and bring them back, keep the original array, or put
38
+ a mask on top (`numpy.ma`, [marray](https://github.com/mdhaber/marray)). The
39
+ two layers compose. In the terms of the 2012 NumPy discussion, this is
40
+ **MISSING**, and masks are **IGNORED**.
41
+
42
+ | Document | What is in it |
43
+ |---|---|
44
+ | `LAYOUTS.md` | the NA pattern of every type, and what each gives up |
45
+ | `VS-NUMPY-MA.md` | all 218 names in `numpy.ma.__all__` that numpy 2 still spells that way, side by side with `nd`, with real calls and results |
46
+ | `DISCUSSION.md` | how it works and why: the borrowed loops, performance, the bugs found on the way, leak checking |
47
+ | `NUMPY-PATCHES.md` | where numpy's array functions fail on Nullable, and patches that leave plain arrays unchanged |
48
+ | `AGENTS.md` | notes for coding agents working on the repo |
49
+ | `archive/flag-layout/` | the earlier two-layout version, frozen |
50
+
51
+ ## Running it
52
+
53
+ Needs Python 3.12+ and numpy 2.5+; building from source also needs a C
54
+ compiler. CI tests Linux with numpy 2.5, the latest release and the nightly
55
+ wheels, and the release wheels are tested on Linux (x86-64, ARM), macOS and
56
+ Windows. It will not load on numpy 2.3 or older, which lack
57
+ the DType API slots it uses.
58
+
59
+ ```bash
60
+ pip install nulldtype # wheels for Linux, macOS and Windows
61
+ pip install . # or build from a checkout
62
+ pip install pytest && pytest # run the suite
63
+ ```
64
+
65
+ For working on the C code, `./build.sh` builds in place and `./run_tests.sh`
66
+ builds and runs pytest (extra arguments go to pytest). They use `python3`;
67
+ set `PYTHON`, and `NUMPY_SITE` for a numpy dev tree that is not installed, or
68
+ put both in an uncommitted `local.env`.
69
+
70
+ The suite has 582 tests plus 2 expected failures that pin known gaps. It also
71
+ passes on an ASAN + UBSAN build, which CI runs on every push.
72
+
73
+ ## What works
74
+
75
+ ```python
76
+ import numpy as np, nulldtype as nd
77
+
78
+ dt = nd.Nullable(np.float64) # itemsize 8, NA is a bit pattern
79
+ a = np.arange(5.0).astype(dt)
80
+ a[2] = nd.NA
81
+
82
+ a # [0.0 1.0 NA 3.0 4.0]
83
+ a + a # [0.0 2.0 NA 6.0 8.0]
84
+ a + 1.0 # [1.0 2.0 NA 4.0 5.0] a Python scalar works
85
+ a + np.arange(5.0) # so does a plain array, either way round
86
+ np.sqrt(a) * 2 # unary ufuncs too
87
+ a.sum() # NA propagates, R's default
88
+ np.add.accumulate(a) # [0.0 1.0 NA NA NA]
89
+
90
+ np.can_cast(np.float64, dt) # False — a value is given up, so not "safe"
91
+ np.can_cast(np.float64, dt, "same_kind") # True — astype and assignment still automatic
92
+ a.astype(np.float64) # ValueError: cannot convert a missing value to dtype('float64')
93
+
94
+ nd.isna(a) # [False False True False False]
95
+ nd.filled(a, 0.0) # [0. 1. 0. 3. 4.]
96
+ nd.to_numpy(a) # ValueError: pass na_value= to say what they should become
97
+ ```
98
+
99
+ Strings, raw bytes and records:
100
+
101
+ ```python
102
+ s = np.zeros(3, nd.Nullable("S3")) # itemsize 3
103
+ s[0] = b"ab"; s[1] = nd.NA
104
+ s # [b'ab' NA b'']
105
+ s.tobytes() # b'ab\x00\xff\xff\xff\x00\x00\x00' the gap is all 0xFF
106
+ s[2] = b"\xff\xff\xff" # ValueError: ... is the value reserved to mean NA
107
+
108
+ u = np.array(["b", "a", "c"]).astype(nd.Nullable("U3"))
109
+ u[1] = nd.NA
110
+ np.sort(u) # [b c NA] gaps sort last
111
+
112
+ r = np.zeros(2, nd.Nullable(np.dtype([("a", "i4"), ("b", "f8")]))) # itemsize 12
113
+ r[0] = (1, 2.0); r[1] = nd.NA # the gap: (0x80000000, 0x7FFFFFFFFFFFFFFF)
114
+ r == r # [True NA]
115
+ nd.Nullable(np.longdouble) # Nullable(float64), with a LongDoubleWarning
116
+ nd.Nullable(">i4") # Nullable(int32) — stored in native byte order
117
+ ```
118
+
119
+ | Area | What it does |
120
+ |---|---|
121
+ | Types | every int, uint, float, complex, bool, `datetime64`, `timedelta64`; `S`, `V` (NA is the whole cell of `0xFF`); `U` (every character `U+FFFF`, a noncharacter, so a gap is still a well-formed string); records (missing when every field holds its own NA; nested records, subarray fields) |
122
+ | `longdouble`, `clongdouble` | stored as `float64` / `complex128`, with a `LongDoubleWarning` where precision is actually lost |
123
+ | Byte order | always stored native: `Nullable(">i4") == Nullable("<i4")`, and big-endian data still casts in and out correctly |
124
+ | Assignment | `nd.NA` and `None` mark an element missing; numpy scalars (`np.void`, `np.bytes_`, `np.str_`, records) assign as values |
125
+ | 29 binary ufuncs | arithmetic, `maximum`/`minimum`, the 6 comparisons, `remainder`, `fmod`, `arctan2`, `hypot`, `logaddexp`, `copysign`, `nextafter`, `fmax`/`fmin`, `heaviside`, `float_power`, `&`/`\|`/`^` — **borrowing T's own loop**; merging validity is all this code does |
126
+ | 41 unary ufuncs | all of trig/hyperbolic/log/exp, rounding, `signbit`, `conjugate`, `~`, `logical_not`; `isnan`/`isinf`/`isfinite` of NA answer NA |
127
+ | `clip`, `matmul` | `clip` is a 3-input ufunc; `matmul` is a gufunc, including `v@A`, `A@v` and the batched forms |
128
+ | Results follow numpy's rules | `resolve` asks the wrapped ufunc, so `Nullable[i8] / Nullable[i8]` is `Nullable[f8]` and `Nullable[i8] + Nullable[f8]` is `Nullable[f8]` |
129
+ | Mixed operands | `Nullable[T] op T` and Python scalars, both ways round, through promoters |
130
+ | Comparisons | answer `Nullable[bool]`; `NA == NA` is `NA`, not `True` |
131
+ | Three-valued logic | `logical_and/or/xor` and `&`, `\|`, `^` on `Nullable[bool]` are Kleene: `NA & False = False`, `NA \| True = True`; on ints `&`, `\|`, `^` propagate |
132
+ | Reductions and `accumulate` | propagate by default, like R's `na.rm = FALSE`, over several axes at once too; `cumsum` is NA from the first gap on |
133
+ | `argmax`, `argmin` | the position of the first gap, numpy's rule for NaN, so `a[a.argmax()]` is NA exactly when `a.max()` is |
134
+ | `sort`, `argsort` | gaps last, two gaps compare equal — as in R |
135
+ | `nonzero`, `count_nonzero` | work, and **refuse** on a gap rather than guess, at any array size |
136
+ | Nothing is computed on a gap | `where=` is handed to the wrapped ufunc when needed, so a gap raises no spurious warning while real errors still do |
137
+ | Casts in | `T -> Nullable[T]` is automatic (`same_kind`); between wrapped dtypes (`Nullable[i8] -> Nullable[f8]`) gaps are kept |
138
+ | Casts out | `Nullable[T] -> T` or any plain dtype (`int16`, big-endian…) **raises on a gap**; the guard is in the loop, which is why `np.isin` works while NA still cannot slip out |
139
+ | Nothing invents an NA | casting `int64 2**31 -> Nullable[i4]`, truncating `S5 -> S3` down to all `0xFF`, arithmetic landing exactly on `INT_MIN` or `UINT_MAX`, and an `np.arange` reaching the reserved value all raise |
140
+ | Explicit ways out | `nd.isna`, `nd.notna`, `nd.filled`, `nd.to_numpy` (which refuses to guess) |
141
+ | pickle | both the dtype and the array |
142
+ | `np.zeros` | gives real zeros, not gaps |
143
+
144
+ ## The `nd` namespace and `skipna`
145
+
146
+ Every name in `nd` that it does not define is numpy's own (`nd.sort is
147
+ np.sort`). It defines only what numpy gets wrong or refuses on a Nullable
148
+ array, and refuses the `nan*` names with the call to use instead, because NA is
149
+ not NaN:
150
+
151
+ - `np.median`, `np.percentile` and `np.quantile` return a wrong number (NA
152
+ sorts last, so they take it for the maximum);
153
+ - `np.mean`, `np.var` and `np.std` truncate on integer and bool arrays;
154
+ - `np.all`, `np.any`, `np.dot`, `np.unique`, `np.isclose` do not work at all.
155
+
156
+ ```python
157
+ nd.median(a) # NA propagates, the default
158
+ nd.median(a, skipna=True) # 3.0
159
+ nd.quantile(a, [.25, .75]) # [NA NA]
160
+ nd.unique(a) # NA is a value of its own, sorted last
161
+
162
+ m = [[1.0, 2.0, 3.0],
163
+ [4.0, NA, 6.0]]
164
+ nd.mean(m, axis=1) # [2.0 NA] a lane with a gap is NA
165
+ nd.mean(m, axis=1, skipna=True) # [2.0 5.0]
166
+ ```
167
+
168
+ - **Reductions:** `nd.sum/prod/min/max/mean/std/var/median/quantile/percentile`
169
+ take `axis=`, `keepdims=` and `skipna=False`.
170
+ - **`skipna` elsewhere:** so do `nd.all`/`nd.any` (Kleene),
171
+ `nd.argmax`/`nd.argmin` and `nd.cumsum`/`nd.cumprod`. `nd.cumsum(a,
172
+ skipna=True)` carries past a gap and leaves NA at it, as pandas does.
173
+ - **Other stand-ins:** `nd.count`, `nd.dropna`, `nd.dot`, `nd.array_equal`,
174
+ `nd.isin`, `nd.isclose` and `nd.allclose` answer NA when the answer is
175
+ genuinely unknown. Against a set `[1, NA]`, `nd.isin` answers NA for 5: the
176
+ gap might be 5.
177
+ - **Skipping without `nd`:** `np.add.reduce(a, where=nd.notna(a))` works too.
178
+
179
+ Why the numpy versions fail, and how numpy could be patched without changing
180
+ plain arrays, is in `NUMPY-PATCHES.md`.
181
+
182
+ ## Known limitations
183
+
184
+ - **`np.mean`, `np.var`, `np.std` (and `a.mean()`…) are silently wrong on
185
+ integer and bool arrays**: `np.mean([1, 2])` gives `1`. numpy picks a float
186
+ result only for its own integer types, so the sum is divided and truncated
187
+ back. **`np.median`, `np.quantile`, `np.percentile` are silently wrong** when
188
+ a gap is present. Neither is fixable from the dtype side; use `nd`.
189
+ - `np.all` / `np.any` (and `np.array_equal`, which calls them) cannot be used:
190
+ `ndarray.all` pins its accumulator to plain `bool`, so numpy looks for
191
+ `(BoolDType, NullableDType) -> BoolDType`. Registering that would also hijack
192
+ `np.logical_and(plain_bool_array, nullable_array)`, which today correctly
193
+ answers `Nullable[bool]`.
194
+ - `np.dot` **cannot be implemented**: numpy hands its slot a NULL array (see
195
+ `DISCUSSION.md`). Use `@` or `nd.dot`.
196
+ - **Python scalars and NEP 50.**
197
+ - **Widening:** `Nullable[i4] + 2` gives `Nullable[i8]`, where numpy keeps
198
+ `int32` under the weak-scalar rule. Fixable with
199
+ `NPY_METH_resolve_descriptors_with_scalars`, not done yet; an `xfail` test
200
+ holds the place.
201
+ - **Promotion error:** `np.result_type(dtype, 1.0)` raises
202
+ `DTypePromotionError`, which takes `np.isclose`, `np.allclose` and
203
+ `np.select` down with it. Declaring `common_dtype` for Python scalars fixes
204
+ those three, but breaks the weak-scalar rule for every ufunc (measured:
205
+ `Nullable[f4] + 1.0` came out float64).
206
+ - **`repr` of a `U` array containing an empty string raises `ValueError`.**
207
+ `arrayprint` formats a non-numpy dtype with `str()`, `str('')` is empty, and
208
+ `_extendLine_pretty` then calls `max()` on an empty list of lines. A numpy bug.
209
+ - A gap in `S` and `V` is `0xFF` bytes, which is not valid text, so `astype("U")`
210
+ on a raw view of the values fails to decode. Through the dtype it never comes
211
+ up: a gap always reads as `NA`.
212
+ - `sort`, `argmax` and `nonzero` hold the GIL for the whole loop, because the
213
+ dtype must declare `NPY_NEEDS_PYAPI` (see the segfaults in `DISCUSSION.md`),
214
+ and the ufunc loops declare `NPY_METH_REQUIRES_PYAPI` for their fallback path.
215
+ Large arrays get no threading benefit.
216
+ - `a + 1.0` is about 2.6x slower than `a + a` — the scalar operand has stride 0
217
+ and appears to fall off the borrowed-loop path. Not chased down; the results
218
+ are correct.
219
+ - `np.array([(1, 2.0), nd.NA], dtype=nd.Nullable(rec))` raises a shape error:
220
+ numpy only treats a tuple as one element when the requested dtype is a plain
221
+ record. Build with `np.zeros` and assign, or cast from a plain record array.
222
+ - Records support only `==` and `!=`; `+`, `sum` and `<` fail, as they do for
223
+ plain records.
224
+ - `nd.Nullable("S")` (no length) and a bare subarray dtype have no cell to fill,
225
+ so they raise `TypeError`. A subarray as a record field works.
226
+ - `np.histogram` and `np.einsum` fail cleanly — both need a boolean decision
227
+ about possibly-missing data, and `bool(NA)` refuses to answer. Indexing with a
228
+ mask that contains NA raises `IndexError` for the same reason;
229
+ `nd.filled(mask, False)` says what a gap should count as.
230
+
231
+ ## What is next, in order
232
+
233
+ 1. **Report the numpy bugs upstream, and propose the hooks in
234
+ `NUMPY-PATCHES.md`.** Bug candidates:
235
+ - the SIMD stride bug — patch and reproducer ready;
236
+ - the `can_cast_pyscalar_scalar_to` assertion — patch and reproducer ready;
237
+ - `sort` not checking for `NULL` after `PyArray_DescrNewByteorder`;
238
+ - `repr` of a non-numpy dtype blowing up when `str()` of an element is empty.
239
+ 2. **Give NEP 50's weak-scalar rule back** with
240
+ `NPY_METH_resolve_descriptors_with_scalars`, so `Nullable[i4] + 2` stops
241
+ widening to `int64`.
242
+ 3. **A real `longdouble`** through `numpy-quaddtype` instead of substituting
243
+ `float64`.
244
+ 4. **Remove the fixed cost of `get_loop`** — two Python calls per operation, most
245
+ visible on small arrays.
246
+
247
+ ## Settled design notes
248
+
249
+ - NA is **a datum**: assigning it overwrites the old value, which is **not
250
+ observable** afterwards.
251
+ - NA **has a type**: `Nullable[i2]` is not `Nullable[f8]`, and there is no shared
252
+ singleton.
253
+ - **One layout only**: NA is a bit pattern of `T` itself.
254
+ - A record is **missing when every field is**, each field holding its own type's
255
+ NA.
256
+ - The wrapped dtype is **always stored in native byte order**; `long double` is
257
+ stored as `double`.
258
+ - **Nothing skips a gap unless asked**: reductions propagate, `argmax` points at
259
+ the gap, `isin` against a set with a gap is NA unless it finds a hit;
260
+ `skipna=True` says otherwise.
261
+ - Coercion to a Python scalar **raises**; use `nd.filled(x, ...)` to get out.
262
+ - **A cast never quietly creates an NA**, and never quietly drops one.
263
+
264
+ ## AI Disclosure
265
+
266
+ AI was used in writing this project's code, tests and documentation.
267
+
268
+ ## License
269
+
270
+ BSD 3-Clause, the same terms as NumPy; see `LICENSE.txt`.
@@ -0,0 +1,7 @@
1
+ _nulldtype.cp313-win_amd64.pyd,sha256=Yq5fznaGGBywDwK2cs1kySQrIlh66vIzI9iVhdWtkCw,91136
2
+ nulldtype.py,sha256=x0ANjop7fWbvQtaV47KrJK3UUFjhdgpGZ-SGg0UiCGs,19612
3
+ nulldtype-0.1.0.dist-info/licenses/LICENSE.txt,sha256=F9FvJXlkhauPL_Ni5Pc0GRxYlnPoOCWkcIbK8Vc8F14,1582
4
+ nulldtype-0.1.0.dist-info/METADATA,sha256=cDsPlOfBSnGbQwXFIHAnOO7Omrdq-pEKuvhE3tsF_44,15096
5
+ nulldtype-0.1.0.dist-info/WHEEL,sha256=0LUNoHxLvcpw9db_x0XozE5jW73-_GOAO7fDDJ4Xt4Q,101
6
+ nulldtype-0.1.0.dist-info/top_level.txt,sha256=zpWg0wxM7gHDwrTmY7w1BeguxQodz8rC2Vgrt58HQuM,21
7
+ nulldtype-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: false
4
+ Tag: cp313-cp313-win_amd64
5
+
@@ -0,0 +1,30 @@
1
+ Copyright (c) 2026, the NullableDType authors.
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are
6
+ met:
7
+
8
+ * Redistributions of source code must retain the above copyright
9
+ notice, this list of conditions and the following disclaimer.
10
+
11
+ * Redistributions in binary form must reproduce the above
12
+ copyright notice, this list of conditions and the following
13
+ disclaimer in the documentation and/or other materials provided
14
+ with the distribution.
15
+
16
+ * Neither the name of the NullableDType authors nor the names of any
17
+ contributors may be used to endorse or promote products derived
18
+ from this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,2 @@
1
+ _nulldtype
2
+ nulldtype
nulldtype.py ADDED
@@ -0,0 +1,516 @@
1
+ """Missing values as a NumPy dtype.
2
+
3
+ NA is a reserved value of the wrapped type itself, so an element costs exactly
4
+ what the wrapped type costs:
5
+
6
+ Nullable(np.float64) itemsize 8 — every bit but the sign
7
+ Nullable("U10") itemsize 40 — the whole cell filled with 0xFF
8
+ Nullable(record) itemsize T — every field its own type's NA
9
+
10
+ LAYOUTS.md has the pattern for every type.
11
+ """
12
+ import math
13
+
14
+ import numpy as np
15
+ from numpy.lib.array_utils import normalize_axis_tuple
16
+
17
+ from _nulldtype import NA, LongDoubleWarning, NullableDType
18
+
19
+ __all__ = ["NA", "LongDoubleWarning", "Nullable", "is_nullable",
20
+ "NullableDType",
21
+ "isna", "notna", "filled", "to_numpy", "count", "dropna",
22
+ "all", "any", "array_equal", "isin", "isclose", "allclose", "dot",
23
+ "argmax", "argmin", "cumsum", "cumprod",
24
+ "sum", "prod", "min", "max", "mean", "std", "var",
25
+ "median", "quantile", "percentile", "unique"]
26
+
27
+
28
+ def Nullable(t):
29
+ """`T` with missing values.
30
+
31
+ Numbers -- ints, unsigned ints, floats, complex, bool, datetime64 --
32
+ fixed-width `S`, `U` and `V`, and records of any of these reserve one of
33
+ their own bit patterns for NA, so an element costs exactly what `T` costs.
34
+ A record is missing when every field holds its own type's NA. See
35
+ LAYOUTS.md for every pattern. `longdouble` and `clongdouble` are stored as
36
+ `float64` and `complex128`, with a `LongDoubleWarning` wherever that loses
37
+ precision.
38
+ """
39
+ return NullableDType(t)
40
+
41
+
42
+ def is_nullable(dtype):
43
+ """True for a dtype produced by `Nullable`."""
44
+ return isinstance(dtype, NullableDType)
45
+
46
+
47
+ def _check(a):
48
+ a = np.asarray(a)
49
+ if not is_nullable(a.dtype):
50
+ raise TypeError(f"expected a Nullable array, got {a.dtype!r}")
51
+ # `ascontiguousarray` hands back a 1-d array for a 0-d one, and a scalar
52
+ # must stay a scalar all the way through `isna` and `filled`
53
+ return np.ascontiguousarray(a).reshape(a.shape)
54
+
55
+
56
+ def _parts(a):
57
+ """Plain arrays of the values and of the validity flags."""
58
+ a = _check(a)
59
+ values = a.view(a.dtype.wrapped)
60
+ return values, ~_na_mask(values)
61
+
62
+
63
+ def _na_mask(values):
64
+ """True where a plain array of a stored dtype holds that dtype's NA."""
65
+ if values.ndim == 0:
66
+ return _na_mask(values.reshape(1)).reshape(())
67
+ dt = values.dtype
68
+ if dt.names is not None:
69
+ # a record is missing when every field is: one field with a value makes
70
+ # it a value, and padding belongs to no field
71
+ na = np.ones(values.shape, dtype=bool)
72
+ for name in dt.names:
73
+ per_record = int(np.prod(dt.fields[name][0].shape, dtype=np.intp))
74
+ field_na = _na_mask(values[name])
75
+ na &= field_na.reshape(values.shape + (per_record,)).all(axis=-1)
76
+ return na
77
+
78
+ kind, size = dt.kind, dt.itemsize
79
+ if kind in "cSUV":
80
+ # the views below change the item size, which needs contiguous data,
81
+ # and a record's fields are strided views
82
+ values = np.ascontiguousarray(values)
83
+ if kind == "f" and size == 8:
84
+ return _na_float(values.view(np.uint64), 0x7FFFFFFFFFFFFFFF)
85
+ if kind == "f" and size == 4:
86
+ return _na_float(values.view(np.uint32), 0x7FFFFFFF)
87
+ if kind == "f" and size == 2:
88
+ return _na_float(values.view(np.uint16), 0x7FFF)
89
+ if kind == "c":
90
+ # the pattern lives in the real half, which is the first field
91
+ half = np.float64 if size == 16 else np.float32
92
+ return _parts_na_real(values.view(half).reshape(values.shape + (2,))[..., 0])
93
+ if kind == "b":
94
+ return values.view(np.uint8) == 2
95
+ if kind in "Mm":
96
+ # numpy already spells this NaT, so ask numpy
97
+ return np.isnat(values)
98
+ if kind == "U":
99
+ # every character U+FFFF, the noncharacter kept for internal use
100
+ chars = values.view(np.uint32).reshape(values.shape + (size // 4,))
101
+ return (chars == 0xFFFF).all(axis=-1)
102
+ if kind in "SV":
103
+ # the whole cell filled with 0xFF; b"\xffab" is an ordinary value
104
+ raw = values.view(np.uint8).reshape(values.shape + (size,))
105
+ return (raw == 0xFF).all(axis=-1)
106
+ if kind == "u":
107
+ # UINT_MAX, the unsigned mirror of INT_MIN; `min` would be 0 here
108
+ return values == np.iinfo(dt).max
109
+ return values == np.iinfo(dt).min
110
+
111
+
112
+ def _na_float(bits, pattern):
113
+ """NA is every bit but the sign; the sign itself is ignored."""
114
+ p = bits.dtype.type(pattern)
115
+ return (bits & p) == p
116
+
117
+
118
+ def _parts_na_real(real):
119
+ if real.dtype == np.float64:
120
+ return _na_float(real.view(np.uint64), 0x7FFFFFFFFFFFFFFF)
121
+ return _na_float(real.view(np.uint32), 0x7FFFFFFF)
122
+
123
+
124
+ def _as_fill(values, value):
125
+ """A tuple standing in for a record becomes a record `np.where` can use."""
126
+ if values.dtype.names is not None and not isinstance(value, np.ndarray):
127
+ return np.array(value, dtype=values.dtype)
128
+ return value
129
+
130
+
131
+ def notna(a):
132
+ """A plain bool array, True where the element has a value."""
133
+ return _parts(a)[1]
134
+
135
+
136
+ def isna(a):
137
+ """A plain bool array, True where the element is missing."""
138
+ return ~notna(a)
139
+
140
+
141
+ def filled(a, value):
142
+ """A plain array of the wrapped dtype, with `value` in place of NA."""
143
+ values, valid = _parts(a)
144
+ return np.where(valid, values, _as_fill(values, value))
145
+
146
+
147
+ def to_numpy(a, na_value=None):
148
+ """Leave the missing-data world. Refuses to guess if anything is missing."""
149
+ values, valid = _parts(a)
150
+ if na_value is None:
151
+ if not valid.all():
152
+ raise ValueError(
153
+ "array contains missing values; pass na_value= to say what "
154
+ "they should become")
155
+ return values.copy()
156
+ return np.where(valid, values, _as_fill(values, na_value))
157
+
158
+
159
+ def count(a):
160
+ """How many elements have a value. `numpy.ma` spells this `a.count()`."""
161
+ return int(notna(a).sum())
162
+
163
+
164
+ def dropna(a):
165
+ """The same array with the missing elements taken out."""
166
+ a = _check(a)
167
+ return a[notna(a)]
168
+
169
+
170
+ def dot(a, b):
171
+ """`np.dot` cannot be implemented -- its legacy slot is handed a NULL array
172
+ -- but for 1-D and 2-D operands `@` is the same operation."""
173
+ x, y = np.asarray(a), np.asarray(b)
174
+ if x.ndim > 2 or y.ndim > 2:
175
+ raise ValueError("nd.dot covers 1-D and 2-D; for more, use `@` directly")
176
+ return x @ y
177
+
178
+
179
+ def array_equal(a1, a2):
180
+ """True, False, or NA when a missing element leaves the answer undecided.
181
+
182
+ `np.array_equal` calls `np.all`, which pins its accumulator to a plain bool.
183
+ """
184
+ x, y = np.asarray(a1), np.asarray(a2)
185
+ if x.shape != y.shape:
186
+ return False
187
+ if x.size == 0:
188
+ return True
189
+ return all(x == y)
190
+
191
+
192
+ def isin(a, test_elements):
193
+ """Like `np.isin`, but an element that is missing answers NA instead of
194
+ raising: whether an unknown value is in a set is itself unknown."""
195
+ values, valid = _parts(a)
196
+ test = np.asarray(test_elements)
197
+ gap_in_test = False
198
+ if is_nullable(test.dtype):
199
+ gap_in_test = bool(isna(test).any())
200
+ test = to_numpy(dropna(test))
201
+ found = np.isin(values, test)
202
+ out = np.zeros(values.shape, dtype=Nullable(np.bool_))
203
+ out[...] = found
204
+ if gap_in_test:
205
+ # a gap in the set could be any value, so not finding x among the known
206
+ # ones does not rule x out -- SQL's `3 IN (1, NULL)` is NULL too
207
+ out[~found] = NA
208
+ out[~valid] = NA
209
+ return out
210
+
211
+
212
+ def _arg_extreme(a, skipna, find):
213
+ a = _check(a)
214
+ if not skipna:
215
+ return find(a)
216
+ values, valid = _parts(a)
217
+ valid = valid.ravel()
218
+ if not valid.any():
219
+ return NA
220
+ positions = np.flatnonzero(valid)
221
+ return positions[find(values.ravel()[valid])]
222
+
223
+
224
+ def argmax(a, skipna=False):
225
+ """Flat position of the largest value.
226
+
227
+ A gap could be anything, so by default the answer is the position of the
228
+ first gap -- numpy's rule for NaN, and what keeps `a[nd.argmax(a)]` equal to
229
+ `nd.max(a)`. `skipna=True` skips the gaps, like `np.nanargmax`, and is NA
230
+ when nothing is left.
231
+ """
232
+ return _arg_extreme(a, skipna, np.argmax)
233
+
234
+
235
+ def argmin(a, skipna=False):
236
+ """Flat position of the smallest value; see `argmax`."""
237
+ return _arg_extreme(a, skipna, np.argmin)
238
+
239
+
240
+ def isclose(a, b, rtol=1e-05, atol=1e-08):
241
+ """`np.isclose` asks `np.result_type(dtype, 1.0)` before anything else and
242
+ gives up there; this one is the same formula over nullable arithmetic."""
243
+ x, y = np.asarray(a), np.asarray(b)
244
+ return abs(x - y) <= atol + rtol * abs(y)
245
+
246
+
247
+ def allclose(a, b, rtol=1e-05, atol=1e-08):
248
+ """Kleene `allclose`: NA when a missing element leaves it undecided."""
249
+ return all(isclose(a, b, rtol, atol))
250
+
251
+
252
+ def all(a, axis=None, skipna=False, keepdims=False):
253
+ """Kleene `all`: False wins, otherwise NA if anything is missing.
254
+ `skipna=True` asks about the known elements only, and is a plain bool.
255
+
256
+ `np.all` cannot be used: it forces a plain bool result.
257
+ """
258
+ if skipna:
259
+ return np.all(filled(a, True), axis=axis, keepdims=keepdims)
260
+ return np.logical_and.reduce(_check(a), axis=axis, keepdims=keepdims)
261
+
262
+
263
+ def any(a, axis=None, skipna=False, keepdims=False):
264
+ """Kleene `any`: True wins, otherwise NA if anything is missing. See `all`."""
265
+ if skipna:
266
+ return np.any(filled(a, False), axis=axis, keepdims=keepdims)
267
+ return np.logical_or.reduce(_check(a), axis=axis, keepdims=keepdims)
268
+
269
+
270
+ def _accumulate(name, a, axis, skipna):
271
+ arr = _check(a)
272
+ if axis is None:
273
+ arr, axis = arr.ravel(), 0
274
+ # numpy widens bool and small ints to the platform int before summing
275
+ widened = getattr(np, name)(np.zeros(0, arr.dtype.wrapped)).dtype
276
+ if widened != arr.dtype.wrapped:
277
+ arr = arr.astype(Nullable(widened))
278
+ if not skipna:
279
+ return getattr(np, name)(arr, axis=axis)
280
+ gaps = isna(arr)
281
+ out = np.empty(arr.shape, dtype=arr.dtype)
282
+ out[...] = getattr(np, name)(filled(arr, _IDENTITY[name[3:]]), axis=axis)
283
+ out[gaps] = NA
284
+ return out
285
+
286
+
287
+ def cumsum(a, axis=None, skipna=False):
288
+ """Running sum. By default everything from the first gap on is NA.
289
+ `skipna=True` carries the total past a gap and leaves NA at the gap
290
+ itself -- pandas' `cumsum`. `axis=None` flattens, like `np.cumsum`."""
291
+ return _accumulate("cumsum", a, axis, skipna)
292
+
293
+
294
+ def cumprod(a, axis=None, skipna=False):
295
+ """Running product; see `cumsum`."""
296
+ return _accumulate("cumprod", a, axis, skipna)
297
+
298
+
299
+ # --------------------------------------------------------------- statistics
300
+ #
301
+ # `np.median`, `np.percentile` and `np.quantile` are written in Python on top
302
+ # of `sort`/`partition`. NA sorts last, so they take it for the largest value
303
+ # and hand back a plausible-looking number:
304
+ #
305
+ # >>> np.median(np.array([3.0, 1.0, NA, 5.0], dtype=Nullable(np.float64)))
306
+ # 4.0 # the median of 1, 3, 5 is 3.0
307
+ #
308
+ # Nothing in a dtype can intercept that -- removing `compare` would only break
309
+ # `sort` as well. These take its place, and follow R: propagate by default,
310
+ # `skipna=True` to drop. A reduction over nothing left is NA, not zero.
311
+ #
312
+ # Reductions that go through ufuncs (`a.sum()`, `a.mean()`, `a.std()`) already
313
+ # propagate correctly; the versions here exist so that `skipna=` reads the same
314
+ # way across the whole surface instead of turning into `where=nd.notna(a)`.
315
+
316
+
317
+ # Reducing over nothing. `sum` and `prod` have identity elements, so 0 and 1
318
+ # are the answers, not inventions -- R agrees, and so does numpy. The rest
319
+ # have no identity: the mean, median or maximum of no numbers is not a number,
320
+ # it is unknown, so it is NA. Returning NaN there would be worse than useless
321
+ # here, because in this dtype NaN is an ordinary value that some column might
322
+ # legitimately hold.
323
+ #
324
+ # Nothing distinguishes an input that was empty to begin with from one that
325
+ # `skipna` emptied, so both take this path.
326
+ _IDENTITY = {"sum": 0, "prod": 1}
327
+
328
+
329
+ def _stat_values(a, skipna):
330
+ """The plain values to compute on, or None when the answer is NA."""
331
+ values, valid = _parts(a)
332
+ if not skipna:
333
+ return None if not valid.all() else values
334
+ return values[valid]
335
+
336
+
337
+ def _all_missing_like(a, shape):
338
+ """An all-NA result in `a`'s own dtype, for a call that asked for an array."""
339
+ out = np.empty(shape, dtype=np.asarray(a).dtype)
340
+ out[...] = NA
341
+ return out
342
+
343
+
344
+ def _reduce_empty(name, dtype):
345
+ if name not in _IDENTITY:
346
+ return NA
347
+ return dtype.type(_IDENTITY[name])
348
+
349
+
350
+ def _whole(name, a, skipna, kwargs):
351
+ """The whole array down to one plain scalar, or NA."""
352
+ values = _stat_values(a, skipna)
353
+ if values is None:
354
+ return NA
355
+ if values.size == 0:
356
+ return _reduce_empty(name, values.dtype)
357
+ return getattr(np, name)(values, **kwargs)
358
+
359
+
360
+ def _result_dtype(name, wrapped, kwargs):
361
+ """What `np.<name>` returns for `wrapped`, asked of a throwaway array."""
362
+ with np.errstate(all="ignore"):
363
+ return np.asarray(getattr(np, name)(np.ones(3, wrapped), **kwargs)).dtype
364
+
365
+
366
+ def _lanes(name, a, axis, keepdims, one, kwargs):
367
+ """Apply `one` -- a whole-array reduction -- to every lane along `axis`.
368
+
369
+ A Python loop over the lanes, each one handed to numpy's own function, so
370
+ the numbers are numpy's. `numpy.ma` would be faster and is wrong for this:
371
+ it masks every non-finite result, so the mean of `[inf, 1]` comes back
372
+ masked and its std as 0.
373
+ """
374
+ arr = _check(a)
375
+ axes = normalize_axis_tuple(range(arr.ndim) if axis is None else axis, arr.ndim)
376
+ rest = [i for i in range(arr.ndim) if i not in axes]
377
+ moved = arr.transpose(rest + list(axes))
378
+ outer = moved.shape[:len(rest)]
379
+ lanes = moved.reshape(math.prod(outer), math.prod(moved.shape[len(rest):]))
380
+
381
+ results = [one(lane) for lane in lanes]
382
+ q_shape = ()
383
+ for r in results:
384
+ if r is not NA:
385
+ q_shape = np.shape(r)
386
+ break
387
+ else:
388
+ q_shape = np.shape(kwargs["q"]) if "q" in kwargs else ()
389
+ out = np.empty((len(results),) + q_shape,
390
+ dtype=Nullable(_result_dtype(name, arr.dtype.wrapped, kwargs)))
391
+ for i, r in enumerate(results):
392
+ if r is NA or (isinstance(r, np.ndarray) and is_nullable(r.dtype)):
393
+ out[i] = NA # a Nullable array here is `_all_missing_like`
394
+ else:
395
+ out[i] = r
396
+ out = out.reshape(outer + q_shape)
397
+ # numpy puts the dimensions of an array `q` in front
398
+ out = np.moveaxis(out, range(len(outer), out.ndim), range(len(q_shape)))
399
+ if keepdims:
400
+ out = np.expand_dims(out, tuple(len(q_shape) + i for i in axes))
401
+ return out
402
+
403
+
404
+ def _simple(name):
405
+ def stat(a, axis=None, skipna=False, keepdims=False, **kwargs):
406
+ if axis is None and not keepdims:
407
+ return _whole(name, a, skipna, kwargs)
408
+ if name in _IDENTITY:
409
+ # vectorised: fill the gaps with the identity, then put NA back on
410
+ # every lane that had one
411
+ values, valid = _parts(a)
412
+ plain = np.where(valid, values, values.dtype.type(_IDENTITY[name]))
413
+ got = np.asarray(getattr(np, name)(plain, axis=axis, keepdims=keepdims,
414
+ **kwargs))
415
+ out = np.empty(got.shape, dtype=Nullable(got.dtype))
416
+ out[...] = got
417
+ if not skipna:
418
+ out[~np.logical_and.reduce(valid, axis=axis, keepdims=keepdims)] = NA
419
+ return out
420
+ return _lanes(name, a, axis, keepdims,
421
+ lambda lane: _whole(name, lane, skipna, kwargs), kwargs)
422
+ stat.__name__ = name
423
+ stat.__doc__ = (f"`np.{name}`, propagating NA unless `skipna=True`. With "
424
+ f"no `axis`, a plain scalar or NA; with one, a Nullable "
425
+ f"array with NA on every lane that is missing something.")
426
+ return stat
427
+
428
+
429
+ sum = _simple("sum")
430
+ prod = _simple("prod")
431
+ min = _simple("min")
432
+ max = _simple("max")
433
+ mean = _simple("mean")
434
+ std = _simple("std")
435
+ var = _simple("var")
436
+ median = _simple("median")
437
+
438
+
439
+ def _quantile_like(name, a, q, axis, skipna, keepdims, kwargs):
440
+ def one(lane):
441
+ values = _stat_values(lane, skipna)
442
+ if values is None or values.size == 0:
443
+ return NA if np.ndim(q) == 0 else _all_missing_like(lane, np.shape(q))
444
+ return getattr(np, name)(values, q, **kwargs)
445
+ if axis is None and not keepdims:
446
+ return one(a)
447
+ return _lanes(name, a, axis, keepdims, one, dict(kwargs, q=q))
448
+
449
+
450
+ def quantile(a, q, axis=None, skipna=False, keepdims=False, **kwargs):
451
+ """`np.quantile`, propagating NA unless `skipna=True`.
452
+
453
+ A scalar `q` gives a scalar or NA; an array `q` gives an array, all NA if
454
+ the input was missing anything. With `axis`, NA per lane.
455
+ """
456
+ return _quantile_like("quantile", a, q, axis, skipna, keepdims, kwargs)
457
+
458
+
459
+ def percentile(a, q, axis=None, skipna=False, keepdims=False, **kwargs):
460
+ """`np.percentile`, propagating NA unless `skipna=True`. See `quantile`."""
461
+ return _quantile_like("percentile", a, q, axis, skipna, keepdims, kwargs)
462
+
463
+
464
+ def unique(a):
465
+ """R's `unique`: NA is one distinct value of its own, and it sorts last.
466
+
467
+ `np.unique` cannot be used -- it compares neighbours in the sorted array
468
+ and assigns the result into a plain bool mask, which NA refuses to become.
469
+ """
470
+ arr = _check(a)
471
+ values, valid = _parts(arr)
472
+ found = np.unique(values[valid]).astype(arr.dtype)
473
+ if valid.all():
474
+ return found
475
+ out = np.empty(found.size + 1, dtype=arr.dtype)
476
+ out[:found.size] = found
477
+ out[found.size] = NA
478
+ return out
479
+
480
+
481
+ # --------------------------------------------------------------- the rest
482
+ #
483
+ # Everything numpy already gets right on a Nullable array -- `sort`, `clip`,
484
+ # `concatenate`, every ufunc -- is forwarded untouched, so `nd.sort` *is*
485
+ # `np.sort`. Only two kinds of name are not: the ones defined above, which
486
+ # numpy gets wrong or refuses, and the ones below, which would look like they
487
+ # work. `VS-NUMPY-MA.md` has the measured list.
488
+ _REFUSED = {
489
+ name: "NA is not NaN: these skip NaN, which a gap only happens to be for "
490
+ "floats. Use nd.{}(a, skipna=True)".format(name[3:])
491
+ for name in ("nansum", "nanprod", "nanmean", "nanstd", "nanvar",
492
+ "nanmedian", "nanmax", "nanmin", "nanquantile", "nanpercentile")
493
+ }
494
+ _REFUSED.update({
495
+ "nanargmax": "use nd.argmax(a, skipna=True); NA is not NaN",
496
+ "nanargmin": "use nd.argmin(a, skipna=True); NA is not NaN",
497
+ "nancumsum": "use nd.cumsum(a, skipna=True); NA is not NaN",
498
+ "nancumprod": "use nd.cumprod(a, skipna=True); NA is not NaN",
499
+ })
500
+
501
+
502
+ def __getattr__(name):
503
+ message = _REFUSED.get(name)
504
+ if message is not None:
505
+ raise AttributeError(f"nd.{name}: {message}")
506
+ try:
507
+ forwarded = getattr(np, name)
508
+ except AttributeError:
509
+ raise AttributeError(
510
+ f"module 'nulldtype' has no attribute {name!r}") from None
511
+ globals()[name] = forwarded # forward once, then it is just numpy
512
+ return forwarded
513
+
514
+
515
+ def __dir__():
516
+ return sorted(set(__all__) | set(n for n in dir(np) if not n.startswith("_")))