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.
@@ -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.
@@ -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.
@@ -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 -->