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
|
+
[](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,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.
|
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("_")))
|