nulldtype 0.1.0__tar.gz
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.
- nulldtype-0.1.0/LICENSE.txt +30 -0
- nulldtype-0.1.0/MANIFEST.in +1 -0
- nulldtype-0.1.0/PKG-INFO +270 -0
- nulldtype-0.1.0/README.md +252 -0
- nulldtype-0.1.0/nulldtype.egg-info/PKG-INFO +270 -0
- nulldtype-0.1.0/nulldtype.egg-info/SOURCES.txt +13 -0
- nulldtype-0.1.0/nulldtype.egg-info/dependency_links.txt +1 -0
- nulldtype-0.1.0/nulldtype.egg-info/requires.txt +4 -0
- nulldtype-0.1.0/nulldtype.egg-info/top_level.txt +2 -0
- nulldtype-0.1.0/nulldtype.py +516 -0
- nulldtype-0.1.0/pyproject.toml +31 -0
- nulldtype-0.1.0/setup.cfg +4 -0
- nulldtype-0.1.0/setup.py +11 -0
- nulldtype-0.1.0/src/nulldtype.c +3541 -0
- nulldtype-0.1.0/test_basic.py +2421 -0
|
@@ -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 @@
|
|
|
1
|
+
include test_basic.py
|
nulldtype-0.1.0/PKG-INFO
ADDED
|
@@ -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,252 @@
|
|
|
1
|
+
# Nullable[T] — prototype
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tuan2k33/NullableDType/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
`Nullable[T]` is a **parametric DType**: it wraps any other dtype and gives it a
|
|
6
|
+
notion of missing data that lives **inside the dtype**, not in a parallel mask.
|
|
7
|
+
NA is a reserved value of the wrapped type itself, so an element costs exactly
|
|
8
|
+
what that type costs:
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
nd.Nullable(np.float64) # itemsize 8 — NA is every bit but the sign
|
|
12
|
+
nd.Nullable("U10") # itemsize 40 — NA is the whole cell of U+FFFF
|
|
13
|
+
nd.Nullable(record) # itemsize T — every field holds its own type's NA
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Nullable treats NA as a datum, not as a number that is hidden, so there is
|
|
17
|
+
nothing like `a.unmask()` or `a[2].value`. Assigning NA overwrites the old value
|
|
18
|
+
the way assigning any other value would, and the old value cannot leak out
|
|
19
|
+
later. To set values aside and bring them back, keep the original array, or put
|
|
20
|
+
a mask on top (`numpy.ma`, [marray](https://github.com/mdhaber/marray)). The
|
|
21
|
+
two layers compose. In the terms of the 2012 NumPy discussion, this is
|
|
22
|
+
**MISSING**, and masks are **IGNORED**.
|
|
23
|
+
|
|
24
|
+
| Document | What is in it |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `LAYOUTS.md` | the NA pattern of every type, and what each gives up |
|
|
27
|
+
| `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 |
|
|
28
|
+
| `DISCUSSION.md` | how it works and why: the borrowed loops, performance, the bugs found on the way, leak checking |
|
|
29
|
+
| `NUMPY-PATCHES.md` | where numpy's array functions fail on Nullable, and patches that leave plain arrays unchanged |
|
|
30
|
+
| `AGENTS.md` | notes for coding agents working on the repo |
|
|
31
|
+
| `archive/flag-layout/` | the earlier two-layout version, frozen |
|
|
32
|
+
|
|
33
|
+
## Running it
|
|
34
|
+
|
|
35
|
+
Needs Python 3.12+ and numpy 2.5+; building from source also needs a C
|
|
36
|
+
compiler. CI tests Linux with numpy 2.5, the latest release and the nightly
|
|
37
|
+
wheels, and the release wheels are tested on Linux (x86-64, ARM), macOS and
|
|
38
|
+
Windows. It will not load on numpy 2.3 or older, which lack
|
|
39
|
+
the DType API slots it uses.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install nulldtype # wheels for Linux, macOS and Windows
|
|
43
|
+
pip install . # or build from a checkout
|
|
44
|
+
pip install pytest && pytest # run the suite
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For working on the C code, `./build.sh` builds in place and `./run_tests.sh`
|
|
48
|
+
builds and runs pytest (extra arguments go to pytest). They use `python3`;
|
|
49
|
+
set `PYTHON`, and `NUMPY_SITE` for a numpy dev tree that is not installed, or
|
|
50
|
+
put both in an uncommitted `local.env`.
|
|
51
|
+
|
|
52
|
+
The suite has 582 tests plus 2 expected failures that pin known gaps. It also
|
|
53
|
+
passes on an ASAN + UBSAN build, which CI runs on every push.
|
|
54
|
+
|
|
55
|
+
## What works
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import numpy as np, nulldtype as nd
|
|
59
|
+
|
|
60
|
+
dt = nd.Nullable(np.float64) # itemsize 8, NA is a bit pattern
|
|
61
|
+
a = np.arange(5.0).astype(dt)
|
|
62
|
+
a[2] = nd.NA
|
|
63
|
+
|
|
64
|
+
a # [0.0 1.0 NA 3.0 4.0]
|
|
65
|
+
a + a # [0.0 2.0 NA 6.0 8.0]
|
|
66
|
+
a + 1.0 # [1.0 2.0 NA 4.0 5.0] a Python scalar works
|
|
67
|
+
a + np.arange(5.0) # so does a plain array, either way round
|
|
68
|
+
np.sqrt(a) * 2 # unary ufuncs too
|
|
69
|
+
a.sum() # NA propagates, R's default
|
|
70
|
+
np.add.accumulate(a) # [0.0 1.0 NA NA NA]
|
|
71
|
+
|
|
72
|
+
np.can_cast(np.float64, dt) # False — a value is given up, so not "safe"
|
|
73
|
+
np.can_cast(np.float64, dt, "same_kind") # True — astype and assignment still automatic
|
|
74
|
+
a.astype(np.float64) # ValueError: cannot convert a missing value to dtype('float64')
|
|
75
|
+
|
|
76
|
+
nd.isna(a) # [False False True False False]
|
|
77
|
+
nd.filled(a, 0.0) # [0. 1. 0. 3. 4.]
|
|
78
|
+
nd.to_numpy(a) # ValueError: pass na_value= to say what they should become
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Strings, raw bytes and records:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
s = np.zeros(3, nd.Nullable("S3")) # itemsize 3
|
|
85
|
+
s[0] = b"ab"; s[1] = nd.NA
|
|
86
|
+
s # [b'ab' NA b'']
|
|
87
|
+
s.tobytes() # b'ab\x00\xff\xff\xff\x00\x00\x00' the gap is all 0xFF
|
|
88
|
+
s[2] = b"\xff\xff\xff" # ValueError: ... is the value reserved to mean NA
|
|
89
|
+
|
|
90
|
+
u = np.array(["b", "a", "c"]).astype(nd.Nullable("U3"))
|
|
91
|
+
u[1] = nd.NA
|
|
92
|
+
np.sort(u) # [b c NA] gaps sort last
|
|
93
|
+
|
|
94
|
+
r = np.zeros(2, nd.Nullable(np.dtype([("a", "i4"), ("b", "f8")]))) # itemsize 12
|
|
95
|
+
r[0] = (1, 2.0); r[1] = nd.NA # the gap: (0x80000000, 0x7FFFFFFFFFFFFFFF)
|
|
96
|
+
r == r # [True NA]
|
|
97
|
+
nd.Nullable(np.longdouble) # Nullable(float64), with a LongDoubleWarning
|
|
98
|
+
nd.Nullable(">i4") # Nullable(int32) — stored in native byte order
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Area | What it does |
|
|
102
|
+
|---|---|
|
|
103
|
+
| 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) |
|
|
104
|
+
| `longdouble`, `clongdouble` | stored as `float64` / `complex128`, with a `LongDoubleWarning` where precision is actually lost |
|
|
105
|
+
| Byte order | always stored native: `Nullable(">i4") == Nullable("<i4")`, and big-endian data still casts in and out correctly |
|
|
106
|
+
| Assignment | `nd.NA` and `None` mark an element missing; numpy scalars (`np.void`, `np.bytes_`, `np.str_`, records) assign as values |
|
|
107
|
+
| 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 |
|
|
108
|
+
| 41 unary ufuncs | all of trig/hyperbolic/log/exp, rounding, `signbit`, `conjugate`, `~`, `logical_not`; `isnan`/`isinf`/`isfinite` of NA answer NA |
|
|
109
|
+
| `clip`, `matmul` | `clip` is a 3-input ufunc; `matmul` is a gufunc, including `v@A`, `A@v` and the batched forms |
|
|
110
|
+
| 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]` |
|
|
111
|
+
| Mixed operands | `Nullable[T] op T` and Python scalars, both ways round, through promoters |
|
|
112
|
+
| Comparisons | answer `Nullable[bool]`; `NA == NA` is `NA`, not `True` |
|
|
113
|
+
| Three-valued logic | `logical_and/or/xor` and `&`, `\|`, `^` on `Nullable[bool]` are Kleene: `NA & False = False`, `NA \| True = True`; on ints `&`, `\|`, `^` propagate |
|
|
114
|
+
| 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 |
|
|
115
|
+
| `argmax`, `argmin` | the position of the first gap, numpy's rule for NaN, so `a[a.argmax()]` is NA exactly when `a.max()` is |
|
|
116
|
+
| `sort`, `argsort` | gaps last, two gaps compare equal — as in R |
|
|
117
|
+
| `nonzero`, `count_nonzero` | work, and **refuse** on a gap rather than guess, at any array size |
|
|
118
|
+
| 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 |
|
|
119
|
+
| Casts in | `T -> Nullable[T]` is automatic (`same_kind`); between wrapped dtypes (`Nullable[i8] -> Nullable[f8]`) gaps are kept |
|
|
120
|
+
| 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 |
|
|
121
|
+
| 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 |
|
|
122
|
+
| Explicit ways out | `nd.isna`, `nd.notna`, `nd.filled`, `nd.to_numpy` (which refuses to guess) |
|
|
123
|
+
| pickle | both the dtype and the array |
|
|
124
|
+
| `np.zeros` | gives real zeros, not gaps |
|
|
125
|
+
|
|
126
|
+
## The `nd` namespace and `skipna`
|
|
127
|
+
|
|
128
|
+
Every name in `nd` that it does not define is numpy's own (`nd.sort is
|
|
129
|
+
np.sort`). It defines only what numpy gets wrong or refuses on a Nullable
|
|
130
|
+
array, and refuses the `nan*` names with the call to use instead, because NA is
|
|
131
|
+
not NaN:
|
|
132
|
+
|
|
133
|
+
- `np.median`, `np.percentile` and `np.quantile` return a wrong number (NA
|
|
134
|
+
sorts last, so they take it for the maximum);
|
|
135
|
+
- `np.mean`, `np.var` and `np.std` truncate on integer and bool arrays;
|
|
136
|
+
- `np.all`, `np.any`, `np.dot`, `np.unique`, `np.isclose` do not work at all.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
nd.median(a) # NA propagates, the default
|
|
140
|
+
nd.median(a, skipna=True) # 3.0
|
|
141
|
+
nd.quantile(a, [.25, .75]) # [NA NA]
|
|
142
|
+
nd.unique(a) # NA is a value of its own, sorted last
|
|
143
|
+
|
|
144
|
+
m = [[1.0, 2.0, 3.0],
|
|
145
|
+
[4.0, NA, 6.0]]
|
|
146
|
+
nd.mean(m, axis=1) # [2.0 NA] a lane with a gap is NA
|
|
147
|
+
nd.mean(m, axis=1, skipna=True) # [2.0 5.0]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- **Reductions:** `nd.sum/prod/min/max/mean/std/var/median/quantile/percentile`
|
|
151
|
+
take `axis=`, `keepdims=` and `skipna=False`.
|
|
152
|
+
- **`skipna` elsewhere:** so do `nd.all`/`nd.any` (Kleene),
|
|
153
|
+
`nd.argmax`/`nd.argmin` and `nd.cumsum`/`nd.cumprod`. `nd.cumsum(a,
|
|
154
|
+
skipna=True)` carries past a gap and leaves NA at it, as pandas does.
|
|
155
|
+
- **Other stand-ins:** `nd.count`, `nd.dropna`, `nd.dot`, `nd.array_equal`,
|
|
156
|
+
`nd.isin`, `nd.isclose` and `nd.allclose` answer NA when the answer is
|
|
157
|
+
genuinely unknown. Against a set `[1, NA]`, `nd.isin` answers NA for 5: the
|
|
158
|
+
gap might be 5.
|
|
159
|
+
- **Skipping without `nd`:** `np.add.reduce(a, where=nd.notna(a))` works too.
|
|
160
|
+
|
|
161
|
+
Why the numpy versions fail, and how numpy could be patched without changing
|
|
162
|
+
plain arrays, is in `NUMPY-PATCHES.md`.
|
|
163
|
+
|
|
164
|
+
## Known limitations
|
|
165
|
+
|
|
166
|
+
- **`np.mean`, `np.var`, `np.std` (and `a.mean()`…) are silently wrong on
|
|
167
|
+
integer and bool arrays**: `np.mean([1, 2])` gives `1`. numpy picks a float
|
|
168
|
+
result only for its own integer types, so the sum is divided and truncated
|
|
169
|
+
back. **`np.median`, `np.quantile`, `np.percentile` are silently wrong** when
|
|
170
|
+
a gap is present. Neither is fixable from the dtype side; use `nd`.
|
|
171
|
+
- `np.all` / `np.any` (and `np.array_equal`, which calls them) cannot be used:
|
|
172
|
+
`ndarray.all` pins its accumulator to plain `bool`, so numpy looks for
|
|
173
|
+
`(BoolDType, NullableDType) -> BoolDType`. Registering that would also hijack
|
|
174
|
+
`np.logical_and(plain_bool_array, nullable_array)`, which today correctly
|
|
175
|
+
answers `Nullable[bool]`.
|
|
176
|
+
- `np.dot` **cannot be implemented**: numpy hands its slot a NULL array (see
|
|
177
|
+
`DISCUSSION.md`). Use `@` or `nd.dot`.
|
|
178
|
+
- **Python scalars and NEP 50.**
|
|
179
|
+
- **Widening:** `Nullable[i4] + 2` gives `Nullable[i8]`, where numpy keeps
|
|
180
|
+
`int32` under the weak-scalar rule. Fixable with
|
|
181
|
+
`NPY_METH_resolve_descriptors_with_scalars`, not done yet; an `xfail` test
|
|
182
|
+
holds the place.
|
|
183
|
+
- **Promotion error:** `np.result_type(dtype, 1.0)` raises
|
|
184
|
+
`DTypePromotionError`, which takes `np.isclose`, `np.allclose` and
|
|
185
|
+
`np.select` down with it. Declaring `common_dtype` for Python scalars fixes
|
|
186
|
+
those three, but breaks the weak-scalar rule for every ufunc (measured:
|
|
187
|
+
`Nullable[f4] + 1.0` came out float64).
|
|
188
|
+
- **`repr` of a `U` array containing an empty string raises `ValueError`.**
|
|
189
|
+
`arrayprint` formats a non-numpy dtype with `str()`, `str('')` is empty, and
|
|
190
|
+
`_extendLine_pretty` then calls `max()` on an empty list of lines. A numpy bug.
|
|
191
|
+
- A gap in `S` and `V` is `0xFF` bytes, which is not valid text, so `astype("U")`
|
|
192
|
+
on a raw view of the values fails to decode. Through the dtype it never comes
|
|
193
|
+
up: a gap always reads as `NA`.
|
|
194
|
+
- `sort`, `argmax` and `nonzero` hold the GIL for the whole loop, because the
|
|
195
|
+
dtype must declare `NPY_NEEDS_PYAPI` (see the segfaults in `DISCUSSION.md`),
|
|
196
|
+
and the ufunc loops declare `NPY_METH_REQUIRES_PYAPI` for their fallback path.
|
|
197
|
+
Large arrays get no threading benefit.
|
|
198
|
+
- `a + 1.0` is about 2.6x slower than `a + a` — the scalar operand has stride 0
|
|
199
|
+
and appears to fall off the borrowed-loop path. Not chased down; the results
|
|
200
|
+
are correct.
|
|
201
|
+
- `np.array([(1, 2.0), nd.NA], dtype=nd.Nullable(rec))` raises a shape error:
|
|
202
|
+
numpy only treats a tuple as one element when the requested dtype is a plain
|
|
203
|
+
record. Build with `np.zeros` and assign, or cast from a plain record array.
|
|
204
|
+
- Records support only `==` and `!=`; `+`, `sum` and `<` fail, as they do for
|
|
205
|
+
plain records.
|
|
206
|
+
- `nd.Nullable("S")` (no length) and a bare subarray dtype have no cell to fill,
|
|
207
|
+
so they raise `TypeError`. A subarray as a record field works.
|
|
208
|
+
- `np.histogram` and `np.einsum` fail cleanly — both need a boolean decision
|
|
209
|
+
about possibly-missing data, and `bool(NA)` refuses to answer. Indexing with a
|
|
210
|
+
mask that contains NA raises `IndexError` for the same reason;
|
|
211
|
+
`nd.filled(mask, False)` says what a gap should count as.
|
|
212
|
+
|
|
213
|
+
## What is next, in order
|
|
214
|
+
|
|
215
|
+
1. **Report the numpy bugs upstream, and propose the hooks in
|
|
216
|
+
`NUMPY-PATCHES.md`.** Bug candidates:
|
|
217
|
+
- the SIMD stride bug — patch and reproducer ready;
|
|
218
|
+
- the `can_cast_pyscalar_scalar_to` assertion — patch and reproducer ready;
|
|
219
|
+
- `sort` not checking for `NULL` after `PyArray_DescrNewByteorder`;
|
|
220
|
+
- `repr` of a non-numpy dtype blowing up when `str()` of an element is empty.
|
|
221
|
+
2. **Give NEP 50's weak-scalar rule back** with
|
|
222
|
+
`NPY_METH_resolve_descriptors_with_scalars`, so `Nullable[i4] + 2` stops
|
|
223
|
+
widening to `int64`.
|
|
224
|
+
3. **A real `longdouble`** through `numpy-quaddtype` instead of substituting
|
|
225
|
+
`float64`.
|
|
226
|
+
4. **Remove the fixed cost of `get_loop`** — two Python calls per operation, most
|
|
227
|
+
visible on small arrays.
|
|
228
|
+
|
|
229
|
+
## Settled design notes
|
|
230
|
+
|
|
231
|
+
- NA is **a datum**: assigning it overwrites the old value, which is **not
|
|
232
|
+
observable** afterwards.
|
|
233
|
+
- NA **has a type**: `Nullable[i2]` is not `Nullable[f8]`, and there is no shared
|
|
234
|
+
singleton.
|
|
235
|
+
- **One layout only**: NA is a bit pattern of `T` itself.
|
|
236
|
+
- A record is **missing when every field is**, each field holding its own type's
|
|
237
|
+
NA.
|
|
238
|
+
- The wrapped dtype is **always stored in native byte order**; `long double` is
|
|
239
|
+
stored as `double`.
|
|
240
|
+
- **Nothing skips a gap unless asked**: reductions propagate, `argmax` points at
|
|
241
|
+
the gap, `isin` against a set with a gap is NA unless it finds a hit;
|
|
242
|
+
`skipna=True` says otherwise.
|
|
243
|
+
- Coercion to a Python scalar **raises**; use `nd.filled(x, ...)` to get out.
|
|
244
|
+
- **A cast never quietly creates an NA**, and never quietly drops one.
|
|
245
|
+
|
|
246
|
+
## AI Disclosure
|
|
247
|
+
|
|
248
|
+
AI was used in writing this project's code, tests and documentation.
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
BSD 3-Clause, the same terms as NumPy; see `LICENSE.txt`.
|