fastumap 0.1.6__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.
- fastumap-0.1.6/.gitignore +38 -0
- fastumap-0.1.6/CHANGELOG.md +87 -0
- fastumap-0.1.6/LICENSE +21 -0
- fastumap-0.1.6/PKG-INFO +197 -0
- fastumap-0.1.6/README.md +167 -0
- fastumap-0.1.6/docs/issues/README.md +83 -0
- fastumap-0.1.6/pyproject.toml +110 -0
- fastumap-0.1.6/src/fastumap/__init__.py +43 -0
- fastumap-0.1.6/src/fastumap/_scipy.py +119 -0
- fastumap-0.1.6/src/fastumap/_types.py +13 -0
- fastumap-0.1.6/src/fastumap/grade.py +103 -0
- fastumap-0.1.6/src/fastumap/graph.py +158 -0
- fastumap-0.1.6/src/fastumap/layout.py +277 -0
- fastumap-0.1.6/src/fastumap/metrics.py +64 -0
- fastumap-0.1.6/src/fastumap/neighbors.py +143 -0
- fastumap-0.1.6/src/fastumap/projection.py +227 -0
- fastumap-0.1.6/src/fastumap/py.typed +0 -0
- fastumap-0.1.6/tests/fixtures.py +60 -0
- fastumap-0.1.6/tests/test_cosine.py +77 -0
- fastumap-0.1.6/tests/test_determinism.py +29 -0
- fastumap-0.1.6/tests/test_find_ab_params.py +17 -0
- fastumap-0.1.6/tests/test_import_cost.py +41 -0
- fastumap-0.1.6/tests/test_memory.py +44 -0
- fastumap-0.1.6/tests/test_metrics.py +32 -0
- fastumap-0.1.6/tests/test_no_heavy_deps.py +28 -0
- fastumap-0.1.6/tests/test_projection.py +100 -0
- fastumap-0.1.6/tests/test_thread_safety.py +43 -0
- fastumap-0.1.6/tests/test_time_budget.py +54 -0
- fastumap-0.1.6/tests/test_transform.py +59 -0
- fastumap-0.1.6/tests/test_umap_compare.py +30 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
|
|
7
|
+
# Build artifacts
|
|
8
|
+
/dist/
|
|
9
|
+
/build/
|
|
10
|
+
/site/
|
|
11
|
+
/public/
|
|
12
|
+
|
|
13
|
+
# uv / venv
|
|
14
|
+
.venv/
|
|
15
|
+
uv.lock.bak
|
|
16
|
+
|
|
17
|
+
# Tool caches
|
|
18
|
+
.pytest_cache/
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.pyright/
|
|
21
|
+
.mypy_cache/
|
|
22
|
+
.moon/cache/
|
|
23
|
+
|
|
24
|
+
# Editor
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
*.swp
|
|
28
|
+
|
|
29
|
+
# Local session logs
|
|
30
|
+
logs/
|
|
31
|
+
|
|
32
|
+
# Cached benchmark datasets (MNIST etc.)
|
|
33
|
+
/bench/data/
|
|
34
|
+
|
|
35
|
+
# Local agent wiring: names the cabildo issues MCP, not a package dependency.
|
|
36
|
+
# docs/issues/ IS tracked — the register is plain markdown.
|
|
37
|
+
.mcp.json
|
|
38
|
+
.claude/
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to fastumap are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/), and versions follow SemVer.
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.1.6] - 2026-08-23
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Sparse input decision (#16): **not supported**. `umap_project`/`fit`/`transform` take a
|
|
12
|
+
dense numpy array; a `scipy.sparse` matrix now raises a clear `TypeError` pointing to
|
|
13
|
+
`.toarray()` instead of a cryptic failure. Rationale documented in README ("Sparse
|
|
14
|
+
input"): the blocked kNN uses dense BLAS matmul and the target workload is dense
|
|
15
|
+
encoder/CLS embeddings. Revisit if a sparse workload actually appears.
|
|
16
|
+
|
|
17
|
+
## [0.1.5] - 2026-08-23
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `fit()` → `UMAPModel` and `transform(model, new)` (roadmap #13): place new points into an
|
|
21
|
+
existing layout without refitting — what makes a drift view incremental instead of a full
|
|
22
|
+
recompute per request. New points get their neighbours in the training set (`knn_between`),
|
|
23
|
+
a membership-weighted-average seed, then a fixed-tail optimiser (`optimize_transform`) that
|
|
24
|
+
moves only the new points while the training layout stays frozen. Deterministic, does not
|
|
25
|
+
mutate the base layout, and lands new points near same-cluster training points (>0.8 in
|
|
26
|
+
the test). `umap_project` is now `fit(...).embedding`. `compute_membership` gains
|
|
27
|
+
`exclude_self` (off for transform, where neighbours come from a different set).
|
|
28
|
+
|
|
29
|
+
## [0.1.4] - 2026-08-23
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- `metric="cosine"` on `umap_project` / `spectral_project` (roadmap #14) — the metric that
|
|
33
|
+
matters for text/CLS embeddings. Implemented as a blocked-brute-force cosine kNN
|
|
34
|
+
(`knn_cosine`): L2-normalise rows once, then the block dot product is cosine similarity,
|
|
35
|
+
distance = 1 − sim. Deterministic. A test shows it beats euclidean on directional
|
|
36
|
+
clusters (same direction, magnitudes spanning 0.1–10). `knn`/`knn_cosine` are the new
|
|
37
|
+
neighbour entry points; an unknown metric raises `ValueError`.
|
|
38
|
+
|
|
39
|
+
## [0.1.3] - 2026-08-23
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- `optimize_layout` scatter: replace `np.add.at` with one per-dimension `np.bincount`
|
|
43
|
+
reduction (#19). ~31% faster at n=1000×1024 (7.7s → 5.3s single-thread), ~19% at
|
|
44
|
+
n=5000 (39.5s → 32.1s). overlap@15 unchanged within noise (n=1000 0.2338 → 0.2331,
|
|
45
|
+
n=2000 0.1387 → 0.1381); determinism preserved. Bytes differ from 0.1.2 (bincount sums
|
|
46
|
+
in a different float order), which is expected and does not affect quality.
|
|
47
|
+
|
|
48
|
+
## [0.1.2] - 2026-08-23
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- `compute_membership`: clamp the exponent argument at 0 before `np.exp` so the discarded
|
|
52
|
+
`np.where` branch no longer raises `overflow encountered in exp` at scale. Output is
|
|
53
|
+
value-identical (only the masked-out branch changed); determinism preserved.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
- Peak-RSS ceiling test (roadmap #9): a subprocess measures peak RSS for a 5000 × 1024 fit
|
|
57
|
+
and asserts it stays under 200 MB. Measured ~102 MB added over the import+input baseline
|
|
58
|
+
(175 MB total peak), consistent with the 512-row blocked kNN never materialising n-by-n.
|
|
59
|
+
|
|
60
|
+
## [0.1.1] - 2026-08-23
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
- `chunk_count` knob on `umap_project` / `optimize_layout` (roadmap #6). Default `1` keeps
|
|
64
|
+
the fast per-epoch snapshot update unchanged; higher values process each epoch's edges in
|
|
65
|
+
that many shuffled chunks so later chunks see earlier moves (partial in-epoch feedback),
|
|
66
|
+
trading speed for local overlap. Measured: overlap@15 at n=1000 rises 0.234 → 0.244
|
|
67
|
+
(chunk_count=10) → 0.248 (chunk_count=40 ≈ umap-learn), at ~5×/~10× cost; n=2000 0.139 →
|
|
68
|
+
0.147 (chunk_count=10). Stays deterministic.
|
|
69
|
+
|
|
70
|
+
### Notes
|
|
71
|
+
- #5 (per-edge negative-sample schedule) was measured neutral vs the flat rate and reverted.
|
|
72
|
+
|
|
73
|
+
## [0.1.0] - 2026-08-22
|
|
74
|
+
|
|
75
|
+
Initial port of a working single-file numpy+scipy UMAP into a typed, tested library.
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
- `umap_project(matrix, dimensions, ...)` — full fuzzy-simplicial-set UMAP in pure
|
|
79
|
+
numpy + scipy: blocked brute-force kNN, `smooth_knn_dist` (vectorised binary search),
|
|
80
|
+
fuzzy-union symmetrisation, spectral initialisation via `scipy.sparse.linalg.eigsh`,
|
|
81
|
+
`find_ab_params` via `scipy.optimize.curve_fit`, and a vectorised attract/repel SGD.
|
|
82
|
+
- `spectral_project(matrix, dimensions, ...)` — the spectral initialisation alone.
|
|
83
|
+
- Metrics: `neighbor_overlap`, `global_distance_correlation`, `random_layout`.
|
|
84
|
+
- 2D and 3D output, both first-class.
|
|
85
|
+
- Deterministic given a seed (pinned ARPACK start vector + seeded negative sampler).
|
|
86
|
+
- Full type annotations, pyright strict.
|
|
87
|
+
- musil-style CI: moon + uv + proto, OIDC Trusted Publishing to PyPI.
|
fastumap-0.1.6/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jorge Cardona
|
|
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.
|
fastumap-0.1.6/PKG-INFO
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastumap
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: A numba-free UMAP in pure numpy + scipy: imports in milliseconds, no LLVM/JIT at runtime.
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/jorgeecardona/fastumap
|
|
6
|
+
Project-URL: Repository, https://gitlab.com/jorgeecardona/fastumap
|
|
7
|
+
Project-URL: Changelog, https://gitlab.com/jorgeecardona/fastumap/-/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: Jorge Cardona <jorgeecardona@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dimensionality-reduction,embedding,manifold-learning,numpy,scipy,umap,visualization
|
|
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.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: scipy>=1.10
|
|
25
|
+
Provides-Extra: docs
|
|
26
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
27
|
+
Requires-Dist: mkdocs>=1.6; extra == 'docs'
|
|
28
|
+
Requires-Dist: mkdocstrings[python]>=0.27; extra == 'docs'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# fastumap
|
|
32
|
+
|
|
33
|
+
A numba-free UMAP in pure **numpy + scipy**. It exists for one reason: import cost.
|
|
34
|
+
|
|
35
|
+
| | import (cold) | wheels added to image |
|
|
36
|
+
|---|---:|---:|
|
|
37
|
+
| `import umap` (umap-learn) | **~21 s** on a laptop; **148 s** at 0.5 vCPU ×4 | ~172 MB (llvmlite alone 113 MB) |
|
|
38
|
+
| `import fastumap` | **~12 ms** | 0 MB (numpy + scipy already present) |
|
|
39
|
+
|
|
40
|
+
umap-learn is excellent, but its kernels are numba, and numba compiles with LLVM at
|
|
41
|
+
*import* time. numpy and scipy ship precompiled kernels, so they import in milliseconds.
|
|
42
|
+
That is the whole thesis: **the problem is not numba, it is compilation at import time**,
|
|
43
|
+
and a vectorised numpy implementation avoids it without giving up much quality.
|
|
44
|
+
|
|
45
|
+
## Why this exists — the incident
|
|
46
|
+
|
|
47
|
+
The first consumer is an internal ML-platform API on AWS Fargate: **0.5 vCPU, 4 granian
|
|
48
|
+
workers, 2 GB**, shared with a Datadog sidecar. Every worker pays every import, and the
|
|
49
|
+
cgroup meters CPU, so four numba compilations run through a half-vCPU straw at once:
|
|
50
|
+
|
|
51
|
+
- `from umap import UMAP` cost ~4.1 CPU-seconds; `pynndescent` alone declares 46
|
|
52
|
+
eagerly-compiled `@njit` functions.
|
|
53
|
+
- In that container the import took **148 s wall** (126 s and 206 s on two Fargate tasks).
|
|
54
|
+
The health check kills the container at ~120 s, so the service crash-looped and **never
|
|
55
|
+
served a request**. At 1 CPU it was 37.8 s, at 2 CPUs 16.1 s — compilation is serial, so
|
|
56
|
+
more cores barely help.
|
|
57
|
+
- Baking a numba cache into the image did not fix it (11.9 s vs 12.0 s): the eager-signature
|
|
58
|
+
functions that dominate import have no `cache=True`.
|
|
59
|
+
- `NUMBA_DISABLE_JIT=1` works but the pure-Python kernels are ~100× slower.
|
|
60
|
+
|
|
61
|
+
fastumap imports in ~12 ms, four times over, and leaves the health-check budget intact.
|
|
62
|
+
|
|
63
|
+
## Quality vs umap-learn
|
|
64
|
+
|
|
65
|
+
Graded on separated Gaussian clusters (dimensions noted per table), against umap-learn on
|
|
66
|
+
the same data. We assert we are *within a tolerance* of the reference, never that layouts
|
|
67
|
+
match: different initialisation and update order rotate and reflect equally-good embeddings.
|
|
68
|
+
|
|
69
|
+
<!-- QUALITY_TABLE -->
|
|
70
|
+
**n=1000, 64-dim, 8 clusters, 3 seeds** (`make grade`):
|
|
71
|
+
|
|
72
|
+
| layout | overlap@15 ↑ | global dist corr ↑ |
|
|
73
|
+
|---|---:|---:|
|
|
74
|
+
| fastumap | 0.236 ± 0.003 | **0.400 ± 0.086** |
|
|
75
|
+
| umap-learn | **0.250 ± 0.003** | 0.206 ± 0.033 |
|
|
76
|
+
| pca | 0.165 ± 0.001 | 0.843 ± 0.012 |
|
|
77
|
+
| random | 0.015 ± 0.001 | −0.003 ± 0.003 |
|
|
78
|
+
|
|
79
|
+
fastumap (default) trails umap-learn by ~0.014 on local neighbourhood overlap and is well
|
|
80
|
+
ahead on global structure. Both beat PCA on local overlap and crush the random control.
|
|
81
|
+
|
|
82
|
+
**Quality knob — `chunk_count`.** The optimiser defaults to a per-epoch snapshot update
|
|
83
|
+
(`chunk_count=1`), the fast path the 0.5-vCPU service needs. Raising `chunk_count` processes
|
|
84
|
+
each epoch's edges in that many shuffled chunks so later chunks see earlier moves (partial
|
|
85
|
+
in-epoch feedback, toward umap-learn's in-place walk). It trades speed for local overlap:
|
|
86
|
+
|
|
87
|
+
| chunk_count | overlap@15 (n=1000) | vs default | rel. cost |
|
|
88
|
+
|---:|---:|---:|---:|
|
|
89
|
+
| 1 (default) | 0.234 | — | 1× |
|
|
90
|
+
| 10 | 0.244 | +0.010 | ~5× |
|
|
91
|
+
| 40 | 0.248 | +0.014 (≈ umap-learn) | ~10× |
|
|
92
|
+
|
|
93
|
+
`umap_project(x, 2, chunk_count=10)` closes most of the gap; it stays deterministic.
|
|
94
|
+
|
|
95
|
+
### Real data — MNIST (`make bench`)
|
|
96
|
+
|
|
97
|
+
Single-thread, 784-dim MNIST via `bench/mnist.py` (defaults, `chunk_count=1`):
|
|
98
|
+
|
|
99
|
+
| n | method | wall-clock | overlap@15 ↑ | global dist corr ↑ |
|
|
100
|
+
|---|---|---:|---:|---:|
|
|
101
|
+
| 5000 | **fastumap** | 26 s | 0.333 | 0.310 |
|
|
102
|
+
| 5000 | umap-learn | 73 s | 0.344 | 0.329 |
|
|
103
|
+
| 5000 | pca | 2 s | 0.058 | 0.523 |
|
|
104
|
+
| 10000 | **fastumap** | 72 s | 0.267 | 0.279 |
|
|
105
|
+
| 10000 | umap-learn | 83 s | 0.274 | 0.286 |
|
|
106
|
+
| 10000 | pca | 4 s | 0.039 | 0.471 |
|
|
107
|
+
| 20000 | fastumap | 110 s | 0.188 | 0.323 |
|
|
108
|
+
| 20000 | umap-learn | 31 s | 0.205 | 0.316 |
|
|
109
|
+
| 20000 | pca | 10 s | 0.024 | 0.497 |
|
|
110
|
+
|
|
111
|
+
fastumap stays within **0.01–0.02** overlap of umap-learn at every size, and beats PCA on
|
|
112
|
+
local structure by a wide margin (PCA keeps global distances best, as a linear method does).
|
|
113
|
+
On speed it wins at 5k and 10k. Two honest caveats: (1) wall-clock is measured in one
|
|
114
|
+
process, so umap-learn's later fits reuse the numba compile paid on the first — its 20k time
|
|
115
|
+
excludes the ~20 s compile a *fresh* process pays every time, which is the cost fastumap
|
|
116
|
+
exists to avoid; (2) at 20k fastumap's brute-force O(n²) kNN and Python-loop SGD lose to
|
|
117
|
+
pynndescent — the case the approximate-kNN backend (roadmap #15) addresses.
|
|
118
|
+
|
|
119
|
+
Metrics:
|
|
120
|
+
- **overlap@k** — share of each point's k input-space neighbours still among its k nearest
|
|
121
|
+
after projection. Chance is ~`k/(n-1)`; always read against the random control.
|
|
122
|
+
- **global dist corr** — Spearman correlation of all pairwise distances, before vs after.
|
|
123
|
+
|
|
124
|
+
## Install
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
pip install fastumap # numpy + scipy, nothing else
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Use
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from fastumap import umap_project, spectral_project
|
|
134
|
+
|
|
135
|
+
xy = umap_project(embeddings, dimensions=2) # (n, 2)
|
|
136
|
+
xyz = umap_project(embeddings, dimensions=3) # (n, 3)
|
|
137
|
+
cos = umap_project(embeddings, dimensions=2, metric="cosine") # for text/CLS embeddings
|
|
138
|
+
init = spectral_project(embeddings, dimensions=2) # just the spectral init
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`metric` is `"euclidean"` (default) or `"cosine"`. Cosine is usually what you want for
|
|
142
|
+
encoder/CLS embeddings — euclidean on unnormalised output is dominated by vector length,
|
|
143
|
+
not the direction that carries the meaning.
|
|
144
|
+
|
|
145
|
+
**Incremental placement.** Fit once, then place new points into the frozen layout without
|
|
146
|
+
refitting — so a drift view stays stable instead of reshuffling every request:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from fastumap import fit, transform
|
|
150
|
+
|
|
151
|
+
model = fit(window, dimensions=2) # UMAPModel; model.embedding is the layout
|
|
152
|
+
new_xy = transform(model, new_points) # (n_new, 2), placed against the fixed layout
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Same input and seed give **bit-identical** output across processes (the layout must be
|
|
156
|
+
stable across reloads so people can compare the picture over time). `umap_project` takes
|
|
157
|
+
`n_neighbors`, `min_dist`, `spread`, `n_epochs`, `negative_sample_rate`, `random_state`,
|
|
158
|
+
`metric`, and `chunk_count`.
|
|
159
|
+
|
|
160
|
+
## Sparse input
|
|
161
|
+
|
|
162
|
+
Not supported — `umap_project`/`fit` take a **dense** numpy array. The blocked kNN relies
|
|
163
|
+
on dense BLAS matmul, and the target workload is dense encoder/CLS embeddings, so a native
|
|
164
|
+
sparse distance path is out of scope. Passing a `scipy.sparse` matrix raises a `TypeError`
|
|
165
|
+
telling you to densify first (`matrix.toarray()`). Revisit if a sparse workload actually
|
|
166
|
+
shows up.
|
|
167
|
+
|
|
168
|
+
## Guarantees (enforced by tests)
|
|
169
|
+
|
|
170
|
+
- **numpy + scipy only** at runtime — no numba, llvmlite, scikit-learn, or compiled
|
|
171
|
+
extension of our own. A test asserts the JIT stack is never imported.
|
|
172
|
+
- **Import under 200 ms** — a subprocess test measures it.
|
|
173
|
+
- **Deterministic** — pinned ARPACK start vector, seeded negative sampler; identical bytes
|
|
174
|
+
across processes.
|
|
175
|
+
- **Memory bounded** — the n-by-n distance matrix is never materialised (512-row blocked
|
|
176
|
+
kNN). At the worst case (5000 × 1024) the fit adds ~102 MB over the import+input baseline
|
|
177
|
+
(175 MB total peak); the test enforces a 200 MB ceiling.
|
|
178
|
+
- **Time budgeted** — single-thread wall-clock ~5.3 s at 1000 × 1024, ~32 s at 5000 × 1024;
|
|
179
|
+
a CI test measures under a single-core cap and fails on a regression past a generous
|
|
180
|
+
ceiling (25 s / 120 s). The SGD dominates — the number is honest, not yet fast; speeding
|
|
181
|
+
it up further is tracked.
|
|
182
|
+
- **2-D and 3-D**, both first-class. **Typed**, pyright strict.
|
|
183
|
+
|
|
184
|
+
## Releasing
|
|
185
|
+
|
|
186
|
+
CI (GitLab, moon + uv + proto) runs lint, format, typecheck, tests, build on every push.
|
|
187
|
+
On `main` it runs `:release`: the same checks, then publishes to PyPI via **OIDC Trusted
|
|
188
|
+
Publishing** (no stored token, pending publisher configured) whenever the version in
|
|
189
|
+
`pyproject.toml` is not yet on PyPI — idempotent, so it is a no-op on every other pipeline.
|
|
190
|
+
|
|
191
|
+
## Not affiliated with UMAP
|
|
192
|
+
|
|
193
|
+
fastumap is an **independent reimplementation** of the UMAP algorithm
|
|
194
|
+
([McInnes, Healy, Melville, arXiv:1802.03426](https://arxiv.org/abs/1802.03426);
|
|
195
|
+
reference implementation [lmcinnes/umap](https://github.com/lmcinnes/umap)). It is not
|
|
196
|
+
affiliated with or endorsed by the UMAP authors, and it is **not a drop-in replacement** —
|
|
197
|
+
the public surface is deliberately small. MIT licensed.
|
fastumap-0.1.6/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# fastumap
|
|
2
|
+
|
|
3
|
+
A numba-free UMAP in pure **numpy + scipy**. It exists for one reason: import cost.
|
|
4
|
+
|
|
5
|
+
| | import (cold) | wheels added to image |
|
|
6
|
+
|---|---:|---:|
|
|
7
|
+
| `import umap` (umap-learn) | **~21 s** on a laptop; **148 s** at 0.5 vCPU ×4 | ~172 MB (llvmlite alone 113 MB) |
|
|
8
|
+
| `import fastumap` | **~12 ms** | 0 MB (numpy + scipy already present) |
|
|
9
|
+
|
|
10
|
+
umap-learn is excellent, but its kernels are numba, and numba compiles with LLVM at
|
|
11
|
+
*import* time. numpy and scipy ship precompiled kernels, so they import in milliseconds.
|
|
12
|
+
That is the whole thesis: **the problem is not numba, it is compilation at import time**,
|
|
13
|
+
and a vectorised numpy implementation avoids it without giving up much quality.
|
|
14
|
+
|
|
15
|
+
## Why this exists — the incident
|
|
16
|
+
|
|
17
|
+
The first consumer is an internal ML-platform API on AWS Fargate: **0.5 vCPU, 4 granian
|
|
18
|
+
workers, 2 GB**, shared with a Datadog sidecar. Every worker pays every import, and the
|
|
19
|
+
cgroup meters CPU, so four numba compilations run through a half-vCPU straw at once:
|
|
20
|
+
|
|
21
|
+
- `from umap import UMAP` cost ~4.1 CPU-seconds; `pynndescent` alone declares 46
|
|
22
|
+
eagerly-compiled `@njit` functions.
|
|
23
|
+
- In that container the import took **148 s wall** (126 s and 206 s on two Fargate tasks).
|
|
24
|
+
The health check kills the container at ~120 s, so the service crash-looped and **never
|
|
25
|
+
served a request**. At 1 CPU it was 37.8 s, at 2 CPUs 16.1 s — compilation is serial, so
|
|
26
|
+
more cores barely help.
|
|
27
|
+
- Baking a numba cache into the image did not fix it (11.9 s vs 12.0 s): the eager-signature
|
|
28
|
+
functions that dominate import have no `cache=True`.
|
|
29
|
+
- `NUMBA_DISABLE_JIT=1` works but the pure-Python kernels are ~100× slower.
|
|
30
|
+
|
|
31
|
+
fastumap imports in ~12 ms, four times over, and leaves the health-check budget intact.
|
|
32
|
+
|
|
33
|
+
## Quality vs umap-learn
|
|
34
|
+
|
|
35
|
+
Graded on separated Gaussian clusters (dimensions noted per table), against umap-learn on
|
|
36
|
+
the same data. We assert we are *within a tolerance* of the reference, never that layouts
|
|
37
|
+
match: different initialisation and update order rotate and reflect equally-good embeddings.
|
|
38
|
+
|
|
39
|
+
<!-- QUALITY_TABLE -->
|
|
40
|
+
**n=1000, 64-dim, 8 clusters, 3 seeds** (`make grade`):
|
|
41
|
+
|
|
42
|
+
| layout | overlap@15 ↑ | global dist corr ↑ |
|
|
43
|
+
|---|---:|---:|
|
|
44
|
+
| fastumap | 0.236 ± 0.003 | **0.400 ± 0.086** |
|
|
45
|
+
| umap-learn | **0.250 ± 0.003** | 0.206 ± 0.033 |
|
|
46
|
+
| pca | 0.165 ± 0.001 | 0.843 ± 0.012 |
|
|
47
|
+
| random | 0.015 ± 0.001 | −0.003 ± 0.003 |
|
|
48
|
+
|
|
49
|
+
fastumap (default) trails umap-learn by ~0.014 on local neighbourhood overlap and is well
|
|
50
|
+
ahead on global structure. Both beat PCA on local overlap and crush the random control.
|
|
51
|
+
|
|
52
|
+
**Quality knob — `chunk_count`.** The optimiser defaults to a per-epoch snapshot update
|
|
53
|
+
(`chunk_count=1`), the fast path the 0.5-vCPU service needs. Raising `chunk_count` processes
|
|
54
|
+
each epoch's edges in that many shuffled chunks so later chunks see earlier moves (partial
|
|
55
|
+
in-epoch feedback, toward umap-learn's in-place walk). It trades speed for local overlap:
|
|
56
|
+
|
|
57
|
+
| chunk_count | overlap@15 (n=1000) | vs default | rel. cost |
|
|
58
|
+
|---:|---:|---:|---:|
|
|
59
|
+
| 1 (default) | 0.234 | — | 1× |
|
|
60
|
+
| 10 | 0.244 | +0.010 | ~5× |
|
|
61
|
+
| 40 | 0.248 | +0.014 (≈ umap-learn) | ~10× |
|
|
62
|
+
|
|
63
|
+
`umap_project(x, 2, chunk_count=10)` closes most of the gap; it stays deterministic.
|
|
64
|
+
|
|
65
|
+
### Real data — MNIST (`make bench`)
|
|
66
|
+
|
|
67
|
+
Single-thread, 784-dim MNIST via `bench/mnist.py` (defaults, `chunk_count=1`):
|
|
68
|
+
|
|
69
|
+
| n | method | wall-clock | overlap@15 ↑ | global dist corr ↑ |
|
|
70
|
+
|---|---|---:|---:|---:|
|
|
71
|
+
| 5000 | **fastumap** | 26 s | 0.333 | 0.310 |
|
|
72
|
+
| 5000 | umap-learn | 73 s | 0.344 | 0.329 |
|
|
73
|
+
| 5000 | pca | 2 s | 0.058 | 0.523 |
|
|
74
|
+
| 10000 | **fastumap** | 72 s | 0.267 | 0.279 |
|
|
75
|
+
| 10000 | umap-learn | 83 s | 0.274 | 0.286 |
|
|
76
|
+
| 10000 | pca | 4 s | 0.039 | 0.471 |
|
|
77
|
+
| 20000 | fastumap | 110 s | 0.188 | 0.323 |
|
|
78
|
+
| 20000 | umap-learn | 31 s | 0.205 | 0.316 |
|
|
79
|
+
| 20000 | pca | 10 s | 0.024 | 0.497 |
|
|
80
|
+
|
|
81
|
+
fastumap stays within **0.01–0.02** overlap of umap-learn at every size, and beats PCA on
|
|
82
|
+
local structure by a wide margin (PCA keeps global distances best, as a linear method does).
|
|
83
|
+
On speed it wins at 5k and 10k. Two honest caveats: (1) wall-clock is measured in one
|
|
84
|
+
process, so umap-learn's later fits reuse the numba compile paid on the first — its 20k time
|
|
85
|
+
excludes the ~20 s compile a *fresh* process pays every time, which is the cost fastumap
|
|
86
|
+
exists to avoid; (2) at 20k fastumap's brute-force O(n²) kNN and Python-loop SGD lose to
|
|
87
|
+
pynndescent — the case the approximate-kNN backend (roadmap #15) addresses.
|
|
88
|
+
|
|
89
|
+
Metrics:
|
|
90
|
+
- **overlap@k** — share of each point's k input-space neighbours still among its k nearest
|
|
91
|
+
after projection. Chance is ~`k/(n-1)`; always read against the random control.
|
|
92
|
+
- **global dist corr** — Spearman correlation of all pairwise distances, before vs after.
|
|
93
|
+
|
|
94
|
+
## Install
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install fastumap # numpy + scipy, nothing else
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Use
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from fastumap import umap_project, spectral_project
|
|
104
|
+
|
|
105
|
+
xy = umap_project(embeddings, dimensions=2) # (n, 2)
|
|
106
|
+
xyz = umap_project(embeddings, dimensions=3) # (n, 3)
|
|
107
|
+
cos = umap_project(embeddings, dimensions=2, metric="cosine") # for text/CLS embeddings
|
|
108
|
+
init = spectral_project(embeddings, dimensions=2) # just the spectral init
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`metric` is `"euclidean"` (default) or `"cosine"`. Cosine is usually what you want for
|
|
112
|
+
encoder/CLS embeddings — euclidean on unnormalised output is dominated by vector length,
|
|
113
|
+
not the direction that carries the meaning.
|
|
114
|
+
|
|
115
|
+
**Incremental placement.** Fit once, then place new points into the frozen layout without
|
|
116
|
+
refitting — so a drift view stays stable instead of reshuffling every request:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from fastumap import fit, transform
|
|
120
|
+
|
|
121
|
+
model = fit(window, dimensions=2) # UMAPModel; model.embedding is the layout
|
|
122
|
+
new_xy = transform(model, new_points) # (n_new, 2), placed against the fixed layout
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Same input and seed give **bit-identical** output across processes (the layout must be
|
|
126
|
+
stable across reloads so people can compare the picture over time). `umap_project` takes
|
|
127
|
+
`n_neighbors`, `min_dist`, `spread`, `n_epochs`, `negative_sample_rate`, `random_state`,
|
|
128
|
+
`metric`, and `chunk_count`.
|
|
129
|
+
|
|
130
|
+
## Sparse input
|
|
131
|
+
|
|
132
|
+
Not supported — `umap_project`/`fit` take a **dense** numpy array. The blocked kNN relies
|
|
133
|
+
on dense BLAS matmul, and the target workload is dense encoder/CLS embeddings, so a native
|
|
134
|
+
sparse distance path is out of scope. Passing a `scipy.sparse` matrix raises a `TypeError`
|
|
135
|
+
telling you to densify first (`matrix.toarray()`). Revisit if a sparse workload actually
|
|
136
|
+
shows up.
|
|
137
|
+
|
|
138
|
+
## Guarantees (enforced by tests)
|
|
139
|
+
|
|
140
|
+
- **numpy + scipy only** at runtime — no numba, llvmlite, scikit-learn, or compiled
|
|
141
|
+
extension of our own. A test asserts the JIT stack is never imported.
|
|
142
|
+
- **Import under 200 ms** — a subprocess test measures it.
|
|
143
|
+
- **Deterministic** — pinned ARPACK start vector, seeded negative sampler; identical bytes
|
|
144
|
+
across processes.
|
|
145
|
+
- **Memory bounded** — the n-by-n distance matrix is never materialised (512-row blocked
|
|
146
|
+
kNN). At the worst case (5000 × 1024) the fit adds ~102 MB over the import+input baseline
|
|
147
|
+
(175 MB total peak); the test enforces a 200 MB ceiling.
|
|
148
|
+
- **Time budgeted** — single-thread wall-clock ~5.3 s at 1000 × 1024, ~32 s at 5000 × 1024;
|
|
149
|
+
a CI test measures under a single-core cap and fails on a regression past a generous
|
|
150
|
+
ceiling (25 s / 120 s). The SGD dominates — the number is honest, not yet fast; speeding
|
|
151
|
+
it up further is tracked.
|
|
152
|
+
- **2-D and 3-D**, both first-class. **Typed**, pyright strict.
|
|
153
|
+
|
|
154
|
+
## Releasing
|
|
155
|
+
|
|
156
|
+
CI (GitLab, moon + uv + proto) runs lint, format, typecheck, tests, build on every push.
|
|
157
|
+
On `main` it runs `:release`: the same checks, then publishes to PyPI via **OIDC Trusted
|
|
158
|
+
Publishing** (no stored token, pending publisher configured) whenever the version in
|
|
159
|
+
`pyproject.toml` is not yet on PyPI — idempotent, so it is a no-op on every other pipeline.
|
|
160
|
+
|
|
161
|
+
## Not affiliated with UMAP
|
|
162
|
+
|
|
163
|
+
fastumap is an **independent reimplementation** of the UMAP algorithm
|
|
164
|
+
([McInnes, Healy, Melville, arXiv:1802.03426](https://arxiv.org/abs/1802.03426);
|
|
165
|
+
reference implementation [lmcinnes/umap](https://github.com/lmcinnes/umap)). It is not
|
|
166
|
+
affiliated with or endorsed by the UMAP authors, and it is **not a drop-in replacement** —
|
|
167
|
+
the public surface is deliberately small. MIT licensed.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# docs/issues — follow-ups with an owner-shaped edge
|
|
2
|
+
|
|
3
|
+
One file per follow-up, self-contained, with YAML front matter. This folder is for work that is
|
|
4
|
+
**known, scoped, and not being done right now** — the residue a session leaves behind when it lands
|
|
5
|
+
its main change. Without a home, that residue lives in a chat log nobody re-reads, and "we should
|
|
6
|
+
fix X" decays into nobody having ever owned X.
|
|
7
|
+
|
|
8
|
+
**The files are the store.** No database, no second copy — an issue is markdown in git, readable and
|
|
9
|
+
editable by hand. Everything derived (the index below above all) is *generated from* the files and
|
|
10
|
+
is never authoritative. A duplicate store is a fault dimension; the way to avoid one is not to
|
|
11
|
+
create it.
|
|
12
|
+
|
|
13
|
+
## The rules that make one worth writing
|
|
14
|
+
|
|
15
|
+
1. **Answerable cold.** Assume the reader has none of the author's context and no access to the
|
|
16
|
+
session that found it. Concrete instances, file:line, the actual commands.
|
|
17
|
+
2. **Say what is MEASURED and what is INFERRED.** Separately, and in those words. An issue that
|
|
18
|
+
states an inference as a finding sends the next person to fix the wrong thing, and they will
|
|
19
|
+
believe you because you wrote it down.
|
|
20
|
+
3. **Define done.** Not "improve X" — the specific observation that will be true afterwards, and
|
|
21
|
+
how to make it. `verify:` says which kind of evidence closes it, because they are different
|
|
22
|
+
claims: `test` (a red-then-green test is enough), `probe` (only sensing the real thing counts),
|
|
23
|
+
`inspect` (the artifact itself is the evidence).
|
|
24
|
+
4. **Name what you deliberately did NOT do,** with the reason. Silence reads as an oversight and
|
|
25
|
+
gets re-litigated.
|
|
26
|
+
5. **Close by marking it done,** with `fixed_in:` naming the sha that landed the fix. A register
|
|
27
|
+
that cannot say what fixed something has stopped being evidence.
|
|
28
|
+
|
|
29
|
+
## The format
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
---
|
|
33
|
+
title: One line — the finding, not the fix
|
|
34
|
+
status: open # open | in-progress (name an owner) | blocked | done (needs fixed_in)
|
|
35
|
+
scope: M # XS | S | M | L | ?
|
|
36
|
+
owner: null # a handle, or null when unowned
|
|
37
|
+
filed: 2026-08-22 # when it was filed; it never changes
|
|
38
|
+
area: <one of the areas above>
|
|
39
|
+
verify: probe # test | probe | inspect — see rule 3
|
|
40
|
+
blocked_by: null # slug(s) of issues that must close first
|
|
41
|
+
fixed_in: null # the sha on the mainline; required once status is done
|
|
42
|
+
---
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**`blocked_by` is what makes the board honest.** `status: blocked` names a *person* who can unblock
|
|
46
|
+
something; `blocked_by` names the *work* it waits on. Only the second can be computed, which is why
|
|
47
|
+
"what can I pick up cold right now" is answerable at all.
|
|
48
|
+
|
|
49
|
+
**Naming:** `DD-MM-YYYY-short-kebab-summary.md` — the date it was filed, then a slug.
|
|
50
|
+
|
|
51
|
+
## Tooling — the folder checks itself
|
|
52
|
+
|
|
53
|
+
Managed by `cabildo issues`. The CLI, the `issues` MCP server, and any pre-commit gate are one
|
|
54
|
+
module, so the tool an agent calls and the gate that grades it cannot disagree.
|
|
55
|
+
`docs/issues/config.toml` holds this folder's specifics.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cabildo issues # what's ready, what's yours, what everything else waits on
|
|
59
|
+
cabildo issues check # the invariants; non-zero on problems
|
|
60
|
+
cabildo issues reindex # regenerate the index below
|
|
61
|
+
cabildo issues show <slug>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Open now
|
|
65
|
+
|
|
66
|
+
<!-- BEGIN GENERATED INDEX — `cabildo issues reindex` -->
|
|
67
|
+
| Issue | Area | Scope | Verify | Status | Waiting on |
|
|
68
|
+
|---|---|---|---|---|---|
|
|
69
|
+
| [#13 transform(): place new points into an existing layout without refit](22-08-2026-13-transform-place-new-points-into-an-existing-layout-without-refit.md) | capability | L | test | open | — |
|
|
70
|
+
| [#14 Cosine metric (the one that matters for text embeddings)](22-08-2026-14-cosine-metric-the-one-that-matters-for-text-embeddings.md) | capability | M | test | open | — |
|
|
71
|
+
| [#15 Approximate kNN behind an extra, for above ~50k points](22-08-2026-15-approximate-knn-behind-an-extra-for-above-50k-points.md) | capability | L | test | open | — |
|
|
72
|
+
| [#16 Sparse input: decide whether to support it at all](22-08-2026-16-sparse-input-decide-whether-to-support-it-at-all.md) | capability | S | inspect | open | — |
|
|
73
|
+
| [#11 Thread safety: concurrent projections from asyncio.to_thread](22-08-2026-11-thread-safety-concurrent-projections-from-asyncio-to-thread.md) | production | S | test | open | — |
|
|
74
|
+
| [#12 Time-budget test at 1000 and 5000 points under a single-core cap](22-08-2026-12-time-budget-test-at-1000-and-5000-points-under-a-single.md) | production | M | test | open | — |
|
|
75
|
+
| [#9 Memory ceiling test: peak RSS at 5000x1024 under a stated bound](22-08-2026-9-memory-ceiling-test-peak-rss-at-5000x1024-under-a-stated-bound.md) | production | S | test | open | — |
|
|
76
|
+
| [#5 Match umap-learn's negative-sample schedule](22-08-2026-5-match-umap-learn-s-negative-sample-schedule.md) | quality | S | test | open | — |
|
|
77
|
+
| [#6 Partial in-epoch feedback (shuffled chunks) in the optimiser](22-08-2026-6-partial-in-epoch-feedback-shuffled-chunks-in-the-optimiser.md) | quality | M | test | open | — |
|
|
78
|
+
| [#7 Real-data benchmark: MNIST + Fashion-MNIST via cached downloader](22-08-2026-7-real-data-benchmark-mnist-fashion-mnist-via-cached-downloader.md) | quality | M | test | open | — |
|
|
79
|
+
| [#17 README leads with the two numbers that justify the library](22-08-2026-17-readme-leads-with-the-two-numbers-that-justify-the-library.md) | release | S | inspect | open | — |
|
|
80
|
+
| [#18 Publish to PyPI (OIDC Trusted Publishing), independent-reimplementation notice](22-08-2026-18-publish-to-pypi-oidc-trusted-publishing-independent-reimplementation-notice.md) | release | M | probe | open | — |
|
|
81
|
+
|
|
82
|
+
_12 open — 12 open; 6 done. Generated; edit the files, then `cabildo issues reindex`._
|
|
83
|
+
<!-- END GENERATED INDEX -->
|