nimblend 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.
- nimblend-0.1.0/.gitignore +8 -0
- nimblend-0.1.0/.pre-commit-config.yaml +30 -0
- nimblend-0.1.0/CITATION.cff +11 -0
- nimblend-0.1.0/LICENSE +21 -0
- nimblend-0.1.0/PKG-INFO +476 -0
- nimblend-0.1.0/README.md +443 -0
- nimblend-0.1.0/benchmarks/bench_assembly.py +64 -0
- nimblend-0.1.0/benchmarks/bench_crossover.py +63 -0
- nimblend-0.1.0/benchmarks/bench_presence.py +65 -0
- nimblend-0.1.0/pyproject.toml +90 -0
- nimblend-0.1.0/src/nimblend/__init__.py +85 -0
- nimblend-0.1.0/src/nimblend/buffer.py +54 -0
- nimblend-0.1.0/src/nimblend/coords.py +151 -0
- nimblend-0.1.0/src/nimblend/dense.py +675 -0
- nimblend-0.1.0/src/nimblend/display.py +80 -0
- nimblend-0.1.0/src/nimblend/domain.py +450 -0
- nimblend-0.1.0/src/nimblend/kernel.py +370 -0
- nimblend-0.1.0/src/nimblend/protocol.py +65 -0
- nimblend-0.1.0/src/nimblend/py.typed +0 -0
- nimblend-0.1.0/src/nimblend/sparse.py +842 -0
- nimblend-0.1.0/tests/conformance.py +149 -0
- nimblend-0.1.0/tests/conftest.py +4 -0
- nimblend-0.1.0/tests/reference.py +33 -0
- nimblend-0.1.0/tests/test_alignment_ladder.py +136 -0
- nimblend-0.1.0/tests/test_benchmarks_run.py +39 -0
- nimblend-0.1.0/tests/test_boundary_vocabulary.py +99 -0
- nimblend-0.1.0/tests/test_buffer.py +61 -0
- nimblend-0.1.0/tests/test_coords.py +72 -0
- nimblend-0.1.0/tests/test_dense_absence.py +125 -0
- nimblend-0.1.0/tests/test_dense_against_sparse.py +242 -0
- nimblend-0.1.0/tests/test_dense_arithmetic.py +102 -0
- nimblend-0.1.0/tests/test_dense_conform.py +100 -0
- nimblend-0.1.0/tests/test_dense_construct.py +75 -0
- nimblend-0.1.0/tests/test_dense_refusals.py +86 -0
- nimblend-0.1.0/tests/test_dense_shift.py +115 -0
- nimblend-0.1.0/tests/test_display.py +184 -0
- nimblend-0.1.0/tests/test_domain.py +125 -0
- nimblend-0.1.0/tests/test_domain_algebra.py +276 -0
- nimblend-0.1.0/tests/test_domain_array.py +107 -0
- nimblend-0.1.0/tests/test_domain_from_labels.py +150 -0
- nimblend-0.1.0/tests/test_domain_properties.py +135 -0
- nimblend-0.1.0/tests/test_kernel_align.py +95 -0
- nimblend-0.1.0/tests/test_kernel_canonicalize.py +127 -0
- nimblend-0.1.0/tests/test_kernel_csr.py +37 -0
- nimblend-0.1.0/tests/test_kernel_distinct.py +28 -0
- nimblend-0.1.0/tests/test_kernel_first_repeat.py +35 -0
- nimblend-0.1.0/tests/test_kernel_gather.py +44 -0
- nimblend-0.1.0/tests/test_kernel_is_canonical.py +30 -0
- nimblend-0.1.0/tests/test_kernel_lookup.py +32 -0
- nimblend-0.1.0/tests/test_kernel_ravel.py +47 -0
- nimblend-0.1.0/tests/test_kernel_reduce.py +66 -0
- nimblend-0.1.0/tests/test_kernel_reduce_out.py +61 -0
- nimblend-0.1.0/tests/test_kernel_shift.py +45 -0
- nimblend-0.1.0/tests/test_protocol.py +93 -0
- nimblend-0.1.0/tests/test_public_api.py +196 -0
- nimblend-0.1.0/tests/test_reference.py +27 -0
- nimblend-0.1.0/tests/test_sparse_arithmetic.py +204 -0
- nimblend-0.1.0/tests/test_sparse_broadcast.py +174 -0
- nimblend-0.1.0/tests/test_sparse_construct.py +91 -0
- nimblend-0.1.0/tests/test_sparse_domain.py +76 -0
- nimblend-0.1.0/tests/test_sparse_expand.py +75 -0
- nimblend-0.1.0/tests/test_sparse_from_canonical.py +56 -0
- nimblend-0.1.0/tests/test_sparse_group.py +114 -0
- nimblend-0.1.0/tests/test_sparse_reduce.py +178 -0
- nimblend-0.1.0/tests/test_sparse_restrict.py +72 -0
- nimblend-0.1.0/tests/test_sparse_to_csr.py +44 -0
- nimblend-0.1.0/tests/test_sparse_values.py +62 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
3
|
+
rev: v0.16.3
|
|
4
|
+
hooks:
|
|
5
|
+
- id: ruff-check
|
|
6
|
+
args: [--fix]
|
|
7
|
+
- id: ruff-format
|
|
8
|
+
|
|
9
|
+
- repo: local
|
|
10
|
+
hooks:
|
|
11
|
+
- id: ty
|
|
12
|
+
name: ty
|
|
13
|
+
entry: .venv/bin/ty check
|
|
14
|
+
language: system
|
|
15
|
+
pass_filenames: false
|
|
16
|
+
always_run: true
|
|
17
|
+
|
|
18
|
+
- id: vulture
|
|
19
|
+
name: vulture
|
|
20
|
+
entry: .venv/bin/vulture
|
|
21
|
+
language: system
|
|
22
|
+
pass_filenames: false
|
|
23
|
+
always_run: true
|
|
24
|
+
|
|
25
|
+
- id: pytest
|
|
26
|
+
name: pytest
|
|
27
|
+
entry: .venv/bin/pytest tests/ -q --tb=short
|
|
28
|
+
language: system
|
|
29
|
+
pass_filenames: false
|
|
30
|
+
always_run: true
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
title: nimblend
|
|
3
|
+
message: If you use this software, please cite it as below.
|
|
4
|
+
type: software
|
|
5
|
+
authors:
|
|
6
|
+
- family-names: Gaete-Morales
|
|
7
|
+
given-names: Carlos
|
|
8
|
+
email: cdgaete@gmail.com
|
|
9
|
+
abstract: Labeled sparse N-dimensional arrays
|
|
10
|
+
version: 0.1.0
|
|
11
|
+
license: MIT
|
nimblend-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Gaete-Morales
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
nimblend-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nimblend
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Labeled sparse N-dimensional arrays
|
|
5
|
+
Project-URL: Homepage, https://github.com/cdgaete/nimblend
|
|
6
|
+
Project-URL: Repository, https://github.com/cdgaete/nimblend
|
|
7
|
+
Project-URL: Issues, https://github.com/cdgaete/nimblend/issues
|
|
8
|
+
Author-email: Carlos Gaete-Morales <cdgaete@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: alignment,broadcasting,labeled arrays,ndarray,numpy,sparse
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: <3.15,>=3.13
|
|
22
|
+
Requires-Dist: numpy>=2.5.2
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: coverage>=7.16; extra == 'dev'
|
|
25
|
+
Requires-Dist: hypothesis>=6; extra == 'dev'
|
|
26
|
+
Requires-Dist: pre-commit>=4.6.2; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.16; extra == 'dev'
|
|
29
|
+
Requires-Dist: scipy>=1.16; extra == 'dev'
|
|
30
|
+
Requires-Dist: ty>=0.0.78; extra == 'dev'
|
|
31
|
+
Requires-Dist: vulture>=2.14; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# nimblend
|
|
35
|
+
|
|
36
|
+
Labelled sparse N-dimensional arrays for Python.
|
|
37
|
+
|
|
38
|
+
`nimblend` stores an N-dimensional array as the entries it carries rather than as a grid, and names every position by a label rather than by an offset. Two arrays combine by aligning their labels, never by matching their shapes, so an operand's dimensions may be reordered, may nest inside the other's, or may overlap it partially, and the result carries a frame determined by the dimension names alone. The package declares one dependency, `numpy`, and its vocabulary is dimensions, labels, entries and alignment.
|
|
39
|
+
|
|
40
|
+
The distinguishing property is that absence is a first-class declaration. An array states whether a coordinate it does not carry contributes nothing (`"empty"`) or was not modelled (`"unknown"`), and every operator and reduction follows from that declaration rather than from an implementation's convenience. A stored `0.0` remains distinct from an absent coordinate under both.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
From a checkout:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install . # the package
|
|
48
|
+
pip install -e ".[dev]" # editable, with the test and lint tooling
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.13 or 3.14, and `numpy >= 2.5.2`. A built wheel is in `dist/`.
|
|
52
|
+
|
|
53
|
+
## Quick start
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import numpy as np
|
|
57
|
+
import nimblend as nb
|
|
58
|
+
|
|
59
|
+
years = nb.StoredCoord(np.array([2030, 2040, 2050]))
|
|
60
|
+
regions = nb.StoredCoord(np.array(["DE", "FR"]))
|
|
61
|
+
coords = {"year": years, "region": regions}
|
|
62
|
+
|
|
63
|
+
demand = nb.from_long(
|
|
64
|
+
("year", "region"),
|
|
65
|
+
coords,
|
|
66
|
+
{"year": np.array([2030, 2040, 2040]), "region": np.array(["DE", "DE", "FR"])},
|
|
67
|
+
np.array([5.0, 6.0, 7.0]),
|
|
68
|
+
)
|
|
69
|
+
demand
|
|
70
|
+
# SparseArray(('year', 'region'), shape=(3, 2), nnz=3, absence='empty')
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Three of the six coordinates the frame spans carry an entry. Densifying places the additive identity at the rest, because that is what `absence="empty"` declares them to contribute:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
demand.to_dense()
|
|
77
|
+
# array([[5., 0.],
|
|
78
|
+
# [6., 7.],
|
|
79
|
+
# [0., 0.]])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
An array over one dimension multiplies an array over two by aligning on the dimension they share. The narrower operand supplies a factor at every coordinate of the wider frame, and the result is over the wider frame:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
price = nb.from_long(
|
|
86
|
+
("region",), coords, {"region": np.array(["DE", "FR"])}, np.array([2.0, 3.0])
|
|
87
|
+
)
|
|
88
|
+
cost = demand * price
|
|
89
|
+
cost.dims # ('year', 'region')
|
|
90
|
+
cost.to_dense()
|
|
91
|
+
# array([[10., 0.],
|
|
92
|
+
# [12., 21.],
|
|
93
|
+
# [ 0., 0.]])
|
|
94
|
+
cost.sum("region") # SparseArray(('year',), shape=(3,), nnz=2, absence='empty')
|
|
95
|
+
cost.sum() # 43.0
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The reduction over `region` carries two entries, not three: 2050 holds no entry to sum, so the reduced array holds no entry there either.
|
|
99
|
+
|
|
100
|
+
## Why there are six concepts
|
|
101
|
+
|
|
102
|
+
A labelled array could have been one class. It is six, and the reason is that each one answers a question the one before it raises. Read in order, the table derives the library: every row states a question, and the subsection under it shows what goes wrong when the answer is missing.
|
|
103
|
+
|
|
104
|
+
| | Concept | The question it answers |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| 1 | a coordinate | Where does the label `"FR"` sit, and what sits at position 3? |
|
|
107
|
+
| 2 | `StoredCoord`, `ProductCoord`, `SubsetCoord` | Must a dimension's names be stored in order to be answered? |
|
|
108
|
+
| 3 | `SparseArray`, `DenseArray`, `Array` | Hold every cell of the grid, or only the entries that exist? |
|
|
109
|
+
| 4 | `absence` | Does a coordinate holding nothing mean zero, or mean unknown? |
|
|
110
|
+
| 5 | `Domain` | Which coordinates exist over several dimensions at once? |
|
|
111
|
+
| 6 | `EntryBuffer` | How is one result assembled out of many separate blocks? |
|
|
112
|
+
|
|
113
|
+
### 1. A label is not a position
|
|
114
|
+
|
|
115
|
+
numpy aligns by position. Two arrays holding the same data under different orderings are combined cell against cell, and the answer is wrong without saying so:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import numpy as np
|
|
119
|
+
import nimblend as nb
|
|
120
|
+
|
|
121
|
+
a = nb.from_dense(
|
|
122
|
+
np.array([[1.0, 2.0], [3.0, 4.0]]),
|
|
123
|
+
{"year": np.array([2030, 2040]), "region": np.array(["DE", "FR"])},
|
|
124
|
+
)
|
|
125
|
+
c = nb.from_dense(
|
|
126
|
+
np.array([[2.0, 1.0], [4.0, 3.0]]),
|
|
127
|
+
{"year": np.array([2030, 2040]), "region": np.array(["FR", "DE"])},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
a.to_dense() + c.to_dense() # numpy: adds DE to FR
|
|
131
|
+
# array([[3., 3.],
|
|
132
|
+
# [7., 7.]])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`c` holds exactly the data `a` holds — the same value against the same pair of labels — and differs only in listing its regions the other way round. Adding the two by label therefore has to give `a` doubled, `[[2, 4], [6, 8]]`. Adding the grids gives something else, because numpy pairs cell with cell and cannot see that column 0 means `"DE"` on one side and `"FR"` on the other.
|
|
136
|
+
|
|
137
|
+
An operation that aligns by label cannot make that mistake. A coordinate is what makes it possible: it answers, for one dimension, which position a label occupies and which label sits at a position. Given that, two arrays whose dimensions are merely in a different order combine without the caller doing anything:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
b = nb.from_dense(
|
|
141
|
+
np.array([[10.0, 30.0], [20.0, 40.0]]),
|
|
142
|
+
{"region": np.array(["DE", "FR"]), "year": np.array([2030, 2040])},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
(a + b).dims # ('region', 'year')
|
|
146
|
+
(a + b).to_dense()
|
|
147
|
+
# array([[11., 33.],
|
|
148
|
+
# [22., 44.]])
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
And two arrays whose labels genuinely disagree are refused rather than guessed at:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
a + c
|
|
155
|
+
# ValueError: dimension(s) ['region'] carry different labels in the two arrays;
|
|
156
|
+
# an entry is aligned by its label, so conform one to the other first
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**What it buys.** The frame of a result follows from the dimension names alone, so an operand may be transposed, narrower than the other, or only partly overlapping it, and nothing is lined up by hand. Where the labels cannot be reconciled the operation stops instead of returning a plausible number.
|
|
160
|
+
|
|
161
|
+
### 2. A dimension's names need not be stored
|
|
162
|
+
|
|
163
|
+
A coordinate has to answer the label question; it does not have to hold an array of labels to do so. Where positions run `0, 1, 2, …` over a product of axis sizes, the answer is arithmetic, and storing it would cost a great deal for nothing:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
stored = nb.StoredCoord(np.arange(10_000_000))
|
|
167
|
+
generated = nb.ProductCoord((10_000_000,))
|
|
168
|
+
|
|
169
|
+
stored.labels.nbytes # 80000000
|
|
170
|
+
len(stored), len(generated) # (10000000, 10000000)
|
|
171
|
+
stored.to_index(np.array([3])), generated.to_index(np.array([3])).ravel()
|
|
172
|
+
# (array([3]), array([3]))
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The two answer alike; one costs 80 MB and the other a tuple and an integer. That is why there are three coordinates rather than one, and why they are interchangeable: `StoredCoord` holds arbitrary labels, `ProductCoord` computes the positions of a full product, and `SubsetCoord` computes the positions of a subset of one, numbering its members in code order.
|
|
176
|
+
|
|
177
|
+
| Coordinate | Holds | Suited to |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `StoredCoord(labels)` | An array of labels | A dimension named by arbitrary labels |
|
|
180
|
+
| `ProductCoord(sizes, start=0)` | Axis sizes only | A dimension spanning a full product |
|
|
181
|
+
| `SubsetCoord(codes, sizes, start=0)` | The subset's ravelled codes | A dimension spanning part of a product |
|
|
182
|
+
|
|
183
|
+
`StoredCoord` builds the sorted permutation a lookup needs on the first lookup rather than at construction, so even a stored coordinate carried only to state a dimension's extent never pays for one.
|
|
184
|
+
|
|
185
|
+
**What it buys.** A dimension spanning millions of positions costs nothing to carry, so a subset of a product is a thing in its own right rather than a full grid with holes in it.
|
|
186
|
+
|
|
187
|
+
### 3. Entries, or cells
|
|
188
|
+
|
|
189
|
+
Naming positions says nothing about how many of them carry a value. Both answers are reasonable and neither is right everywhere, so both exist behind one contract.
|
|
190
|
+
|
|
191
|
+
`SparseArray` holds an index matrix of one row per dimension and one column per entry, beside a value buffer, and holds nothing for a coordinate it does not carry. Entries are kept in canonical order — sorted by their C-order ravel key, with no key repeated — which is what makes alignment a merge over sorted keys rather than a hash join.
|
|
192
|
+
|
|
193
|
+
`DenseArray` holds an ndarray over the same labelled dimensions, and is the faster representation once the grid is well populated. The [Performance](#performance) section measures where one overtakes the other: the crossover in time sits near one per cent density, and memory favours the sparse form well before that.
|
|
194
|
+
|
|
195
|
+
`Array` is the contract both satisfy, so a consumer writes one piece of code and chooses the representation on density rather than on capability:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
isinstance(a, nb.Array) # True
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**What it buys.** Density is a storage decision rather than an API decision, and changing it changes no calling code.
|
|
202
|
+
|
|
203
|
+
### 4. Absence has two meanings
|
|
204
|
+
|
|
205
|
+
An array holding entries rather than cells must say what a missing entry means — and there are two answers, which want opposite arithmetic. A coordinate that contributes nothing is the additive identity, so a sum over it is a union of what either operand carries. A coordinate that was never modelled has no value at all, so a sum over it is an intersection: nothing is known where either operand is silent.
|
|
206
|
+
|
|
207
|
+
Nothing in the data distinguishes the two, so the array declares it:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
coords = {"region": nb.StoredCoord(np.array(["DE", "FR"]))}
|
|
211
|
+
de = nb.from_long(("region",), coords, {"region": np.array(["DE"])}, np.array([1.0]))
|
|
212
|
+
fr = nb.from_long(("region",), coords, {"region": np.array(["FR"])}, np.array([2.0]))
|
|
213
|
+
|
|
214
|
+
(de + fr).nnz # 2 — absence is the identity, so the sum is a union
|
|
215
|
+
|
|
216
|
+
unknown = de.as_unknown() + fr.as_unknown()
|
|
217
|
+
unknown.nnz # 0 — nothing is known at either coordinate
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
| | `"empty"` | `"unknown"` |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| An absent coordinate means | it contributes nothing | it was not modelled |
|
|
223
|
+
| Addition aligns by | union | intersection |
|
|
224
|
+
| Reduction | uses the entries held | must state `skip=True` or `fill=<value>` |
|
|
225
|
+
| `to_dense()` | places `0.0` | must state `fill=<value>` where coverage is partial |
|
|
226
|
+
|
|
227
|
+
Under `"unknown"` the operations that would have to invent a value say so instead:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
de.as_unknown().sum()
|
|
231
|
+
# ValueError: this array declares absence 'unknown', so a reduction must state
|
|
232
|
+
# skip=True to use present entries only, or fill=<value> to count absences as
|
|
233
|
+
# that value
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
A stored `0.0` is distinct from an absent coordinate under both declarations: it is a coordinate that is present and worth nothing.
|
|
237
|
+
|
|
238
|
+
**What it buys.** A missing measurement is never quietly counted as a zero, and the caller states which of the two meanings applies once, on the array, rather than at every operation that reads it.
|
|
239
|
+
|
|
240
|
+
### 5. A question about several dimensions at once
|
|
241
|
+
|
|
242
|
+
A coordinate answers for one dimension. Many of the questions that arise are about a tuple of them: which coordinates does this array actually carry, which do two arrays share, which did an operation drop, and what number does each of them get? None of those can be put to a coordinate, because a member is a combination across dimensions rather than a position along one.
|
|
243
|
+
|
|
244
|
+
A `Domain` is that answer — a sorted, unique set of multi-indices over named dimensions, held as ravelled codes:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
years = nb.StoredCoord(np.array([2030, 2040, 2050]))
|
|
248
|
+
regions = nb.StoredCoord(np.array(["DE", "FR"]))
|
|
249
|
+
coords = {"year": years, "region": regions}
|
|
250
|
+
|
|
251
|
+
p = nb.from_long(
|
|
252
|
+
("year", "region"),
|
|
253
|
+
coords,
|
|
254
|
+
{"year": np.array([2030, 2040]), "region": np.array(["DE", "DE"])},
|
|
255
|
+
np.array([1.0, 2.0]),
|
|
256
|
+
)
|
|
257
|
+
q = nb.from_long(
|
|
258
|
+
("year", "region"),
|
|
259
|
+
coords,
|
|
260
|
+
{"year": np.array([2040, 2050]), "region": np.array(["DE", "FR"])},
|
|
261
|
+
np.array([3.0, 4.0]),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
shared = p.domain().intersect(q.domain())
|
|
265
|
+
shared.size # 1
|
|
266
|
+
shared.labels() # {'year': array([2040]), 'region': array(['DE'], dtype='<U2')}
|
|
267
|
+
p.restrict(shared).nnz # 1
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Because a domain is a set with an order, it also states a numbering, which is what lets its members become a dimension of something else. `as_coord(start)` reads it back as a coordinate, and `identity(into, coord)` pairs each member with its own position along a new dimension — the two agree, so a member numbered one way sits where the other puts it.
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
shared.as_coord(start=5) # SubsetCoord(1 of (3, 2), start=5)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**What it buys.** Set arithmetic over coordinates — intersection, union, difference — plus a numbering, so a consumer can ask which members survive an operation and give the survivors positions along a dimension of their own.
|
|
277
|
+
|
|
278
|
+
### 6. One result out of many blocks
|
|
279
|
+
|
|
280
|
+
A result assembled from several blocks would ordinarily exist twice: once as the blocks, and once as the concatenation of them. `EntryBuffer` is one preallocated index and value buffer that hands out successive slices, and the kernel operations write directly into a slice, so a block never exists as a separate object.
|
|
281
|
+
|
|
282
|
+
The [Performance](#performance) section measures it: assembling sixteen blocks peaks at 1.04× the final size, and the excess is one transient sort-merge rather than a second copy of the result.
|
|
283
|
+
|
|
284
|
+
**What it buys.** The peak memory of building a large result is the result plus one block, rather than twice the result.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
Those six are the whole library. Everything below states what they do in detail: how a result's frame is decided, which operations an array answers, how a block is grouped and exported, and what the measurements show.
|
|
289
|
+
|
|
290
|
+
## Alignment
|
|
291
|
+
|
|
292
|
+
The frame a binary result carries is read from the two operands' dimension names alone, by one rule that every operator obeys.
|
|
293
|
+
|
|
294
|
+
| Operands | Result frame |
|
|
295
|
+
|---|---|
|
|
296
|
+
| Equal | The shared order |
|
|
297
|
+
| One nesting inside the other | The wider |
|
|
298
|
+
| Overlapping | The left operand's dimensions, then those only the right carries |
|
|
299
|
+
| Sharing no dimension | Refused |
|
|
300
|
+
|
|
301
|
+
`combined_dims` states that rule without an array to read it from, so a caller may know the answer before materialising either operand:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
nb.combined_dims(("P", "Q"), ("Q", "R")) # ('P', 'Q', 'R')
|
|
305
|
+
nb.combined_dims(("P",), ("Q",))
|
|
306
|
+
# ValueError: frames ('P',) and ('Q',) share no dimension; there is nothing to
|
|
307
|
+
# align them on
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Frames sharing no dimension have nothing to align on, and their combination would be an outer product no caller asked for, so it is refused rather than performed.
|
|
311
|
+
|
|
312
|
+
Alignment is by label throughout, and operands whose shared dimension carries different labels are refused rather than aligned by position, as [step 1](#1-a-label-is-not-a-position) shows. `conform` is what reconciles them: it reads an array at exactly the labels given, in the order given.
|
|
313
|
+
|
|
314
|
+
A quotient refuses an absent denominator, which is a coverage question rather than an arithmetic one: a numerator reaching a coordinate the denominator does not carry has no quotient there that is either zero or one.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
# ValueError: the denominator is absent at 1 coordinate(s) the numerator
|
|
318
|
+
# carries; a quotient there is not zero and not one, so it is refused
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
A stored zero, by contrast, is a value the array carries, so dividing by one answers what the arithmetic answers — infinity, or NaN where the numerator is zero too.
|
|
322
|
+
|
|
323
|
+
Mixing the two implementations is allowed, and every mixed operation answers a `SparseArray`: the dense operand contributes its present coordinates as entries, and the arithmetic is then the sparse arithmetic above. A product intersects presence, so it carries at most the entries the sparse operand holds.
|
|
324
|
+
|
|
325
|
+
## Operations
|
|
326
|
+
|
|
327
|
+
Every operation below is on the `Array` contract and is answered by both implementations.
|
|
328
|
+
|
|
329
|
+
**Arithmetic** — `+`, `-`, `*`, `/`, `**` (by a number), unary `-`, and the reflected forms. An exponent must be a number: raising an array by an array is not an operation the contract offers.
|
|
330
|
+
|
|
331
|
+
**Reductions** — `sum`, `mean`, `min`, `max`, each over one named dimension or over the whole array, and each taking the `skip=` / `fill=` policy an `"unknown"` array requires. Reducing every dimension in turn ends at an array over none, which carries the single entry holding the total.
|
|
332
|
+
|
|
333
|
+
**Selection and reshaping**
|
|
334
|
+
|
|
335
|
+
| Method | Answers |
|
|
336
|
+
|---|---|
|
|
337
|
+
| `sel({dim: label})` | Entries at the given labels, dropping each dimension named |
|
|
338
|
+
| `restrict(domain)` | The entries whose coordinate over the domain's dimensions it carries |
|
|
339
|
+
| `expand(dims, coords)` | Every entry replicated across the full extent of the named dimensions |
|
|
340
|
+
| `conform(dims, labels)` | The array read at exactly `labels`, laid out over `dims` |
|
|
341
|
+
| `transpose(*dims)` | The dimensions in the order given, or reversed when none are named |
|
|
342
|
+
| `rename({old: new})` | The array with dimensions renamed |
|
|
343
|
+
| `shift({dim: n})` | Entries moved along a dimension, those leaving the frame dropped |
|
|
344
|
+
| `roll({dim: n})` | Entries moved along a dimension, wrapping at the ends |
|
|
345
|
+
|
|
346
|
+
`expand` appends its dimensions, which keeps the result canonical; a different order is reached with `transpose`. Adding a dimension of size `k` multiplies the entry count by `k`, so replication is stated by the caller rather than implied by an operator. `conform` names each label once, since a repeat would ask one position to occupy two.
|
|
347
|
+
|
|
348
|
+
**Reading the entries out** — `coordinates(dims)` and `values()` answer the entries as copies without handing out the buffers the array owns; `domain(dims)` answers the distinct coordinates covered; `to_dense(fill)` answers a grid; `nnz`, `dims`, `shape` and `coords` answer the frame.
|
|
349
|
+
|
|
350
|
+
## Grouping and matrix export
|
|
351
|
+
|
|
352
|
+
`group` collapses a tuple of dimensions into one dimension numbered by a domain. A member's position in the domain, plus `offset`, is its index along the new dimension.
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
grouped = demand.group(("year",), into="g")
|
|
356
|
+
grouped # SparseArray(('g', 'region'), shape=(2, 2), nnz=3, absence='empty')
|
|
357
|
+
grouped.coords["g"] # SubsetCoord(2 of (3,), start=0)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The grouped dimensions must be a leading prefix of the canonical order, which is what makes the result canonical as written rather than sorted afterwards. An entry at a coordinate the domain does not carry is not emitted. A non-zero `offset` numbers the result into an extent wider than its own members span, which is what lets several results share one destination buffer and one numbering.
|
|
361
|
+
|
|
362
|
+
A two-dimensional array exports as CSR triplets. Canonical order is sorted by row and then by column, which is CSR's own requirement, so the column indices and values are returned as views and only the row pointer is built:
|
|
363
|
+
|
|
364
|
+
```python
|
|
365
|
+
indices, values, indptr = grouped.to_csr()
|
|
366
|
+
# [0, 0, 1] [5.0, 6.0, 7.0] [0, 1, 3]
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## Assembling blocks into one buffer
|
|
370
|
+
|
|
371
|
+
`EntryBuffer` is a preallocated index and value buffer handing out successive slices. A block computed directly into a reserved slice never exists as a separate object, so assembling several of them holds one copy of the result rather than one copy per block plus the result.
|
|
372
|
+
|
|
373
|
+
```python
|
|
374
|
+
buffer = nb.EntryBuffer(ndim=2, capacity=10)
|
|
375
|
+
index, data = buffer.reserve(3)
|
|
376
|
+
index[:] = np.array([[0, 1, 2], [0, 0, 1]])
|
|
377
|
+
data[:] = [1.0, 2.0, 3.0]
|
|
378
|
+
|
|
379
|
+
buffer.array(
|
|
380
|
+
{"a": nb.StoredCoord(np.arange(4)), "b": nb.StoredCoord(np.arange(2))}, ("a", "b")
|
|
381
|
+
)
|
|
382
|
+
# SparseArray(('a', 'b'), shape=(4, 2), nnz=3, absence='empty')
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`buffer.array(...)` takes no copy, and `group`, `kernel.reduce_axis`, `kernel.gather` and `kernel.shift_axis` all accept such a slice as an `out=` destination.
|
|
386
|
+
|
|
387
|
+
`SparseArray.from_canonical` is the matching door for a caller that has built canonical buffers itself: it takes no copy, and the caller states that the index is sorted with no key repeated. `nb.is_canonical(index, shape)` answers that question where a caller cannot. Verifying it inside `from_canonical` would cost the ravel the path exists to avoid.
|
|
388
|
+
|
|
389
|
+
## Performance
|
|
390
|
+
|
|
391
|
+
The figures below are produced by the scripts in `benchmarks/` and vary with the machine.
|
|
392
|
+
|
|
393
|
+
**Where sparse overtakes dense.** Addition of two 3000×3000 arrays, sweeping the fraction of the coordinate grid that carries a value (`bench_crossover.py`):
|
|
394
|
+
|
|
395
|
+
| Density | Dense | Sparse | Dense memory | Sparse memory |
|
|
396
|
+
|---|---|---|---|---|
|
|
397
|
+
| 50.0% | 10.15 ms | 362.87 ms | 72.0 MB | 56.6 MB |
|
|
398
|
+
| 10.0% | 9.93 ms | 93.15 ms | 72.0 MB | 13.7 MB |
|
|
399
|
+
| 1.0% | 9.91 ms | 7.66 ms | 72.0 MB | 1.4 MB |
|
|
400
|
+
| 0.1% | 9.86 ms | 0.76 ms | 72.0 MB | 0.1 MB |
|
|
401
|
+
|
|
402
|
+
The crossover in time sits near one per cent; memory favours the sparse form well before that. A dense grid is the faster representation for a well-populated frame, which is why both implementations exist and satisfy one contract.
|
|
403
|
+
|
|
404
|
+
**Why a dense array stores what it declares.** Operations on 2000×2000 float64 arrays at 90% density, under each encoding of presence (`bench_presence.py`):
|
|
405
|
+
|
|
406
|
+
| Operation | Mask | NaN tag |
|
|
407
|
+
|---|---|---|
|
|
408
|
+
| `a + b`, absence propagating | 14.04 ms | 2.30 ms |
|
|
409
|
+
| `a + b`, absence as identity | 10.88 ms | 35.91 ms |
|
|
410
|
+
| Sum over present values | 4.66 ms | 8.61 ms |
|
|
411
|
+
| Storage beside 32.0 MB of values | 4.0 MB | none |
|
|
412
|
+
|
|
413
|
+
The two declarations want opposite encodings: an `"unknown"` array wants propagation, which NaN performs in the hardware, and an `"empty"` array wants substitution of the identity, which a mask performs in one pass. `DenseArray` therefore stores what it declares.
|
|
414
|
+
|
|
415
|
+
**Assembling into one buffer.** Peak against final memory while reducing blocks directly into a shared destination (`bench_assembly.py`):
|
|
416
|
+
|
|
417
|
+
| Blocks | Final | Peak | Ratio |
|
|
418
|
+
|---|---|---|---|
|
|
419
|
+
| 1 | 16.00 MB | 25.00 MB | 1.56× |
|
|
420
|
+
| 3 | 48.00 MB | 57.00 MB | 1.19× |
|
|
421
|
+
| 8 | 128.00 MB | 137.00 MB | 1.07× |
|
|
422
|
+
| 16 | 256.00 MB | 265.00 MB | 1.04× |
|
|
423
|
+
|
|
424
|
+
The excess is constant at 9 MB and per-block: it is the transient working set of one sort-merge, not a second copy of the result, so the ratio falls as blocks accumulate.
|
|
425
|
+
|
|
426
|
+
## Architecture
|
|
427
|
+
|
|
428
|
+
The package is two layers, and the seam between them is deliberate.
|
|
429
|
+
|
|
430
|
+
`kernel.py` is module-level functions over plain numpy buffers — `ravel`, `unravel`, `distinct`, `canonicalize`, `align`, `gather`, `reduce_axis`, `shift_axis`, `to_csr`, `lookup`, `first_repeat`, `is_canonical`. They take and return numpy arrays and know nothing of labels or dimensions. Every array operation is carried by them, so a compiled module satisfying the same signatures replaces the layer wholesale.
|
|
431
|
+
|
|
432
|
+
The array layer above — `SparseArray`, `DenseArray`, `Domain`, the coordinates — holds the labels, the frames and the refusals, and emits kernel calls in order.
|
|
433
|
+
|
|
434
|
+
Several design choices are worth naming because they show up in the interface. Sorting is on a single int64 ravel key, so ordering over several dimensions is one `argsort` rather than a lexsort. Alignment is a merge over sorted keys with a take-vector per operand, rather than a hash join. A block whose keys already ascend is copied straight through instead of being permuted, because what a sort costs is applying it. Stored zeros are kept, because a stored zero states that a coordinate is present.
|
|
435
|
+
|
|
436
|
+
`nimblend.kernel` and an array's `.index` and `.data` read like interfaces and are not: a consumer of the package reaches them through the array layer. The public interface is the names `nimblend.__all__` exports, reached through the top-level module.
|
|
437
|
+
|
|
438
|
+
## Failure behaviour
|
|
439
|
+
|
|
440
|
+
The package raises rather than substituting a different behaviour and continuing. Operands whose labels differ, whose absence declarations differ, or whose frames share no dimension are refused; a duplicate coordinate in a constructed array is refused; a quotient at a coordinate the denominator does not carry is refused; a reduction or densification that would have to invent a value for an `"unknown"` array is refused until the caller states the policy. Each message names what was seen and what the contract expects.
|
|
441
|
+
|
|
442
|
+
## Development
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
pip install -e ".[dev]"
|
|
446
|
+
|
|
447
|
+
pytest # 717 passed, 8 skipped
|
|
448
|
+
ruff check . && ruff format --check .
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
The suite states the contract from several directions. `tests/conformance.py` holds one `Array` contract that both implementations are run against; `test_alignment_ladder.py` sweeps every pair of frames across all four operators and asserts that the two implementations answer alike; `test_kernel_*.py` state the buffer layer's contract with the array layer above it; and `test_boundary_vocabulary.py` scans the package's own source for names and words belonging to a consuming layer, so the vocabulary stays that of a labelled array.
|
|
452
|
+
|
|
453
|
+
## Public interface
|
|
454
|
+
|
|
455
|
+
```python
|
|
456
|
+
from nimblend import (
|
|
457
|
+
Array, # the contract; runtime-checkable, never constructed
|
|
458
|
+
SparseArray, # entries in canonical order
|
|
459
|
+
DenseArray, # an ndarray over labelled dimensions
|
|
460
|
+
Domain, # the coordinates carried over a tuple of dimensions
|
|
461
|
+
EntryBuffer, # one preallocated destination for several blocks
|
|
462
|
+
StoredCoord, # labels held as an array
|
|
463
|
+
ProductCoord, # positions of a full product of axis sizes
|
|
464
|
+
SubsetCoord, # positions of a subset of a product
|
|
465
|
+
from_long, # an array from label columns and a value column
|
|
466
|
+
from_dense, # an array from a grid and its labels
|
|
467
|
+
combined_dims, # the frame a binary result carries
|
|
468
|
+
is_canonical, # whether an index is sorted with no key repeated
|
|
469
|
+
)
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
## Licence
|
|
473
|
+
|
|
474
|
+
MIT. See `LICENSE`.
|
|
475
|
+
|
|
476
|
+
Citation metadata is in `CITATION.cff`.
|