flodr 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.
flodr-0.1.0/.gitignore ADDED
@@ -0,0 +1,231 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ *.lcov
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ # Pipfile.lock
97
+
98
+ # UV
99
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # uv.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ # poetry.lock
110
+ # poetry.toml
111
+
112
+ # pdm
113
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
114
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
115
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
116
+ # pdm.lock
117
+ # pdm.toml
118
+ .pdm-python
119
+ .pdm-build/
120
+
121
+ # pixi
122
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
123
+ # pixi.lock
124
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
125
+ # in the .venv directory. It is recommended not to include this directory in version control.
126
+ .pixi/*
127
+ !.pixi/config.toml
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule*
134
+ celerybeat.pid
135
+
136
+ # Redis
137
+ *.rdb
138
+ *.aof
139
+ *.pid
140
+
141
+ # RabbitMQ
142
+ mnesia/
143
+ rabbitmq/
144
+ rabbitmq-data/
145
+
146
+ # ActiveMQ
147
+ activemq-data/
148
+
149
+ # SageMath parsed files
150
+ *.sage.py
151
+
152
+ # Environments
153
+ .env
154
+ .envrc
155
+ .venv
156
+ env/
157
+ venv/
158
+ ENV/
159
+ env.bak/
160
+ venv.bak/
161
+
162
+ # Spyder project settings
163
+ .spyderproject
164
+ .spyproject
165
+
166
+ # Rope project settings
167
+ .ropeproject
168
+
169
+ # mkdocs documentation
170
+ /site
171
+
172
+ # mypy
173
+ .mypy_cache/
174
+ .dmypy.json
175
+ dmypy.json
176
+
177
+ # Pyre type checker
178
+ .pyre/
179
+
180
+ # pytype static type analyzer
181
+ .pytype/
182
+
183
+ # Cython debug symbols
184
+ cython_debug/
185
+
186
+ # PyCharm
187
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
188
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
189
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
190
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
191
+ # .idea/
192
+
193
+ # Abstra
194
+ # Abstra is an AI-powered process automation framework.
195
+ # Ignore directories containing user credentials, local state, and settings.
196
+ # Learn more at https://abstra.io/docs
197
+ .abstra/
198
+
199
+ # Visual Studio Code
200
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
201
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
202
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
203
+ # you could uncomment the following to ignore the entire vscode folder
204
+ # .vscode/
205
+ # Temporary file for partial code execution
206
+ tempCodeRunnerFile.py
207
+
208
+ # Ruff stuff:
209
+ .ruff_cache/
210
+
211
+ # PyPI configuration file
212
+ .pypirc
213
+
214
+ # Marimo
215
+ marimo/_static/
216
+ marimo/_lsp/
217
+ __marimo__/
218
+
219
+ # Streamlit
220
+ .streamlit/secrets.toml
221
+
222
+ *.pdf
223
+ paper/
224
+ cache/
225
+ tables/
226
+
227
+ data/
228
+
229
+ figures/
230
+
231
+ experiments/
flodr-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Probst
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.
flodr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,240 @@
1
+ Metadata-Version: 2.5
2
+ Name: flodr
3
+ Version: 0.1.0
4
+ Summary: FloDR: exactly invertible dimensionality reduction built on a normalizing flow
5
+ Author-email: Daniel Probst <daenuprobst@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Daniel Probst
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Requires-Python: >=3.11
29
+ Requires-Dist: matplotlib>=3.7
30
+ Requires-Dist: numpy>=1.26
31
+ Requires-Dist: scikit-learn>=1.6
32
+ Requires-Dist: scipy>=1.11
33
+ Requires-Dist: threadpoolctl>=3.1
34
+ Requires-Dist: torch>=2.1
35
+ Requires-Dist: tqdm>=4.66
36
+ Requires-Dist: typing-extensions>=4.15
37
+ Requires-Dist: usearch>=2.25.3
38
+ Provides-Extra: dev
39
+ Requires-Dist: black>=24.0; extra == 'dev'
40
+ Requires-Dist: pandas>=2.0; extra == 'dev'
41
+ Requires-Dist: pytest>=8.0; extra == 'dev'
42
+ Requires-Dist: ruff>=0.5; extra == 'dev'
43
+ Provides-Extra: notebook
44
+ Requires-Dist: ipykernel>=6.29; extra == 'notebook'
45
+ Requires-Dist: nbconvert>=7.0; extra == 'notebook'
46
+ Requires-Dist: nbformat>=5.9; extra == 'notebook'
47
+ Provides-Extra: paper
48
+ Requires-Dist: aiohttp>=3.9; extra == 'paper'
49
+ Requires-Dist: anndata>=0.10; extra == 'paper'
50
+ Requires-Dist: drfp>=0.3; extra == 'paper'
51
+ Requires-Dist: faiss-cpu>=1.8; extra == 'paper'
52
+ Requires-Dist: fsspec>=2024.2; extra == 'paper'
53
+ Requires-Dist: opentsne>=1.0; extra == 'paper'
54
+ Requires-Dist: pacmap>=0.7; extra == 'paper'
55
+ Requires-Dist: pandas>=2.0; extra == 'paper'
56
+ Requires-Dist: phate>=1.0; extra == 'paper'
57
+ Requires-Dist: pillow>=10.0; extra == 'paper'
58
+ Requires-Dist: pymde>=0.1; extra == 'paper'
59
+ Requires-Dist: scanpy>=1.10; extra == 'paper'
60
+ Requires-Dist: trimap>=1.1; extra == 'paper'
61
+ Requires-Dist: umap-learn>=0.5; extra == 'paper'
62
+ Requires-Dist: umato>=1.0; extra == 'paper'
63
+ Description-Content-Type: text/markdown
64
+
65
+ # FloDR
66
+
67
+ Dimensionality reduction built on an invertible normalising flow. The first
68
+ `n_components` output coordinates of the flow are the embedding and the rest are kept as
69
+ a residual, so the trained map is a bijection with an exact inverse. The conditional spread
70
+ uses that inverse to measure what the layout leaves undetermined at each position. The API
71
+ follows the scikit-learn estimator interface. The documentation is at
72
+ https://daenuprobst.github.io/flodr/.
73
+
74
+ ![](assets/flow_morph_bmarrow_white.gif)
75
+
76
+ ## Installation
77
+
78
+ Python 3.11 or newer.
79
+
80
+ ```bash
81
+ pip install flodr
82
+ ```
83
+
84
+ For development, install from a clone with the locked dependencies. The tests run on the CPU.
85
+
86
+ ```bash
87
+ git clone https://github.com/daenuprobst/flodr
88
+ cd flodr
89
+ uv sync --extra dev
90
+ uv run pytest
91
+ ```
92
+
93
+ ## Usage
94
+
95
+ ```python
96
+ import numpy as np
97
+ from flodr import FloDR, viz
98
+
99
+ X = np.random.default_rng(0).normal(size=(2000, 50)).astype("float32")
100
+ labels = (X[:, 0] > 0).astype(int)
101
+
102
+ model = FloDR(n_components=2, random_state=0).fit(X)
103
+ Y = model.embedding_ # (n, 2)
104
+
105
+ Y_new = model.transform(X[:10]) # one forward pass through the flow
106
+ Y_opt = model.transform_opt(X[:10]) # per-point placement, UMAP-style
107
+ X_back = model.inverse_transform(Y[:10]) # embedding back to input space
108
+ Z = model.transform_latent(X[:10]) # layout plus residual
109
+ X_same = model.inverse_latent(Z) # exact to float precision
110
+ logp = model.score_samples(X[:10]) # exact log density
111
+
112
+ sigma = model.conditional_spread() # what the layout leaves undetermined
113
+ field, cert = model.hidden_contrast(labels) # label structure the layout hides
114
+
115
+ ax = viz.plot_embedding(Y, labels)
116
+ viz.save(ax.figure, "embedding.pdf")
117
+ ```
118
+
119
+ Training runs on the GPU whenever CUDA is available; pass `device="cpu"` to force the
120
+ CPU. `hidden_contrast` fits a classifier per permutation replica and is by far the
121
+ slowest call above.
122
+
123
+ `FloDR` is a scikit-learn transformer, so it clones, sits in a `Pipeline`, and returns
124
+ pandas frames with `set_output(transform="pandas")` (columns `flodr0`, `flodr1`, ...). `score`
125
+ is the mean log density per sample, which `GridSearchCV` uses on held-out folds when no
126
+ scorer is given. A fitted model pickles with its tensors on the CPU, so a GPU fit loads on a
127
+ machine without one. `fit_transform` returns the training layout; inputs under 30 features
128
+ carry padding columns that new points do not, so there `transform` on the training data
129
+ places points near, not on, `embedding_`.
130
+
131
+ ## Parameters
132
+
133
+ | Parameter | Default | Description |
134
+ |---|---|---|
135
+ | `n_components` | `2` | Embedding dimension. Must be at least 2 and below the latent dimension. |
136
+ | `w` | `2.0` | Weight of the ordinal term, the local-to-global dial. Higher keeps more global distance. |
137
+ | `density` | `True` | Fit the residual density. Required by `score_samples`, `score` and the diagnostics. |
138
+ | `max_iter` | `None` | Training steps. `None` keeps the recipe's 6400, see Training budget. |
139
+ | `random_state` | `0` | Seed for the fit. `None` draws one. |
140
+ | `device` | GPU if there is one | Resolved by `flodr.default_device()`. Pass `"cpu"` to force it. |
141
+ | `verbose` | `True` | Draw a tqdm bar while training. |
142
+ | `advanced` | `None` | Dict of `TrainConfig` overrides, e.g. `dict(pad=0, fine_eps=0, edge_batch=32768)`. Unknown keys raise. |
143
+
144
+ ## Methods
145
+
146
+ | Method | Returns | Description |
147
+ |---|---|---|
148
+ | `fit(X)` / `fit_transform(X)` | self / `(n, k)` | Fit the flow; the layout also lands in `embedding_`. |
149
+ | `transform(X)` | `(m, k)` | Embed new points in one forward pass. |
150
+ | `transform_opt(X, steps=200, lr=0.05)` | `(m, k)` | Placement transform: per-point optimisation against the frozen train embedding, in the same class as UMAP's `transform`. |
151
+ | `inverse_transform(Y)` | `(m, d)` | Decode screen positions, residual set to zero. |
152
+ | `transform_latent(X)` | `(m, D)` | Full latent coordinates: the layout, then the residual. |
153
+ | `inverse_latent(Z)` | `(m, d)` | Decode full latent coordinates, exactly. |
154
+ | `score_samples(X)` | `(m,)` | Log density under the flow. |
155
+ | `score(X)` | float | Mean of `score_samples`. |
156
+ | `conditional_spread(Y=None, n_samples=64)` | `(m,)` | Spread of the fiber over each position, in input units. |
157
+ | `atypicality(n_samples=128)` | `(n,)` | Per-point tail probability under its own fiber, in `[0, 1]`. |
158
+ | `spread_calibration(n_bins=14, min_pts=12, n_samples=64)` | dict | Certificate for `conditional_spread`, with `passed` and `confidence`. |
159
+ | `hidden_contrast(G, ...)` | `(field, cert)` | Label information present in the input but not in the layout, in nats, against a within-bin permutation null (`n_perm=99` by default). |
160
+ | `diagnostics(G=None, **kw)` | dict | Both fields with their certificates, units and labels. |
161
+
162
+ `fit(X, iters=..., progress=...)` and `inverse_coords(Z)` still work and warn; use
163
+ `max_iter`, `verbose` and `inverse_latent`.
164
+
165
+ ## Attributes
166
+
167
+ | Attribute | Description |
168
+ |---|---|
169
+ | `embedding_` | `(n, k)` layout of the training data. |
170
+ | `n_iter_` | Training steps run. |
171
+ | `n_features_in_`, `feature_names_in_` | Input width, and column names when fit on a DataFrame. |
172
+ | `roundtrip_` | Max absolute error of the latent round trip. |
173
+ | `flow_` | The trained `Flow`. |
174
+ | `knn_idx_`, `knn_dist_` | The 15-NN graph the fit was built on. |
175
+
176
+ ## `flodr.viz`
177
+
178
+ | Function | Description |
179
+ |---|---|
180
+ | `plot_embedding(Y, labels)` | Scatter of the layout. |
181
+ | `plot_field(Y, v)` | One scalar field over the layout. |
182
+ | `bivariate_field(Y, x, y)` | Two fields in one panel, with a corner key. |
183
+ | `occupancy_field(Y)` | Grid axes and a soft mask of where the layout has data. |
184
+ | `depth_fog(Y)` | Draw order, colour weight and depth, for 3D scatters. |
185
+ | `residual(flow, X)`, `residual_fraction(flow, X)` | The residual coordinates and their share of the latent norm. |
186
+ | `save(fig, path)` | Save at 300 dpi with tight bounds. |
187
+ | `RC`, `RC_PAPER` | Matplotlib rc dicts for screen and for print. |
188
+
189
+ ### Training budget
190
+
191
+ `max_iter=None` runs 6400 steps at a learning rate of 0.0025. Setting it rescales the rate
192
+ to keep their product at 16, so `max_iter=1600` runs at 0.01; below 800 steps the rate stays
193
+ at 0.02, since larger steps break the inverse (a round trip of 0.16 at 40 steps). Each step
194
+ trains on the whole kNN graph up to 262,144 edges and on a random batch of that many above
195
+ it, with 48 negatives per edge. Between 50,000 and 500,000 points only the heaviest quarter
196
+ of the graph is kept.
197
+
198
+ Two parts help most on narrow inputs such as a 10-dimensional scVI or scPoli latent. Inputs below
199
+ 30 dimensions get unit-variance noise columns up to 30 (`pad`), which widen the residual the
200
+ layout is routed through; the kNN graph and the ordinal term stay on the input. A shift-only
201
+ branch on fixed random frequencies (`fine_eps`, on for every input) joins the couplings that
202
+ write the layout halfway through training. `advanced=dict(pad=0, fine_eps=0)` turns both off.
203
+
204
+ On 250,000 cells of the HNOCA scPoli latent (10 dimensions) recall@15 is 0.134, against
205
+ 0.102 for UMAP (default parameters, PCA initialisation) and 0.044 for the earlier
206
+ recipe, with both certificates passing.
207
+ On 103,000 bone marrow cells (PCA-50, no padding) it goes from 0.017 to 0.076. The price is
208
+ time: about 6 minutes at 250,000 cells on an RTX 4070 Ti, against under one minute. Every number
209
+ in the paper was produced with the earlier recipe (800 steps at 0.02, 12 negatives, no padding).
210
+
211
+ On CUDA the loss of each step is compiled (`compile_edges`) and its hidden layers run in fp16
212
+ with loss scaling (`half_edges="fp16"`), the output layers and the Fourier phase in fp32.
213
+ Together they are 2.2x faster than the eager fp32 path at 250,000 and 103,000 cells, at the same
214
+ recall and certificates, but no longer bitwise reproducible across runs;
215
+ `advanced=dict(compile_edges=False, half_edges="")` restores the eager path. `half_edges="bf16"`
216
+ is no faster and loses 2.5 to 3.5% recall.
217
+
218
+ For new points prefer `transform_opt`. Fit on 200,000 HNOCA cells, 50,000 held-out cells
219
+ reach recall@15 0.113 with `transform_opt` in about 6 s, against 0.031 with `transform` and
220
+ 0.061 for the earlier recipe's `transform_opt`.
221
+
222
+ `verbose=False` turns the tqdm bar off outright. Left on, it draws only when stderr is a
223
+ terminal, so piping to a log does not fill it with carriage returns.
224
+
225
+ Low-level pieces are exported for custom training: `Flow`, `Coupling`, `TrainConfig`,
226
+ `train_flodr`, `raw_recipe`, plus the data helpers `preprocess`, `raw_coords`,
227
+ `fuzzy_knn_graph`, `knn_search`, `global_pairs`, `find_ab`.
228
+
229
+ ## Example notebook
230
+
231
+ `examples/quickstart.ipynb` runs every call above on Fashion-MNIST, 40000 images reduced to
232
+ a standardised PCA-50, and takes about four minutes on a GPU. It fits on four fifths of the
233
+ images, embeds the rest with both transforms, sweeps `inverse_transform` over a grid of
234
+ screen positions to decode the layout back into pictures, and ends on the two diagnostics
235
+ with their certificates, both of which pass at that sample size.
236
+
237
+ ## Reproducing the paper
238
+
239
+ `scripts/regen` rebuilds every figure, table and number of the paper from the released library,
240
+ starting from the public data sets. See `scripts/regen/README.md`.
flodr-0.1.0/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # FloDR
2
+
3
+ Dimensionality reduction built on an invertible normalising flow. The first
4
+ `n_components` output coordinates of the flow are the embedding and the rest are kept as
5
+ a residual, so the trained map is a bijection with an exact inverse. The conditional spread
6
+ uses that inverse to measure what the layout leaves undetermined at each position. The API
7
+ follows the scikit-learn estimator interface. The documentation is at
8
+ https://daenuprobst.github.io/flodr/.
9
+
10
+ ![](assets/flow_morph_bmarrow_white.gif)
11
+
12
+ ## Installation
13
+
14
+ Python 3.11 or newer.
15
+
16
+ ```bash
17
+ pip install flodr
18
+ ```
19
+
20
+ For development, install from a clone with the locked dependencies. The tests run on the CPU.
21
+
22
+ ```bash
23
+ git clone https://github.com/daenuprobst/flodr
24
+ cd flodr
25
+ uv sync --extra dev
26
+ uv run pytest
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ import numpy as np
33
+ from flodr import FloDR, viz
34
+
35
+ X = np.random.default_rng(0).normal(size=(2000, 50)).astype("float32")
36
+ labels = (X[:, 0] > 0).astype(int)
37
+
38
+ model = FloDR(n_components=2, random_state=0).fit(X)
39
+ Y = model.embedding_ # (n, 2)
40
+
41
+ Y_new = model.transform(X[:10]) # one forward pass through the flow
42
+ Y_opt = model.transform_opt(X[:10]) # per-point placement, UMAP-style
43
+ X_back = model.inverse_transform(Y[:10]) # embedding back to input space
44
+ Z = model.transform_latent(X[:10]) # layout plus residual
45
+ X_same = model.inverse_latent(Z) # exact to float precision
46
+ logp = model.score_samples(X[:10]) # exact log density
47
+
48
+ sigma = model.conditional_spread() # what the layout leaves undetermined
49
+ field, cert = model.hidden_contrast(labels) # label structure the layout hides
50
+
51
+ ax = viz.plot_embedding(Y, labels)
52
+ viz.save(ax.figure, "embedding.pdf")
53
+ ```
54
+
55
+ Training runs on the GPU whenever CUDA is available; pass `device="cpu"` to force the
56
+ CPU. `hidden_contrast` fits a classifier per permutation replica and is by far the
57
+ slowest call above.
58
+
59
+ `FloDR` is a scikit-learn transformer, so it clones, sits in a `Pipeline`, and returns
60
+ pandas frames with `set_output(transform="pandas")` (columns `flodr0`, `flodr1`, ...). `score`
61
+ is the mean log density per sample, which `GridSearchCV` uses on held-out folds when no
62
+ scorer is given. A fitted model pickles with its tensors on the CPU, so a GPU fit loads on a
63
+ machine without one. `fit_transform` returns the training layout; inputs under 30 features
64
+ carry padding columns that new points do not, so there `transform` on the training data
65
+ places points near, not on, `embedding_`.
66
+
67
+ ## Parameters
68
+
69
+ | Parameter | Default | Description |
70
+ |---|---|---|
71
+ | `n_components` | `2` | Embedding dimension. Must be at least 2 and below the latent dimension. |
72
+ | `w` | `2.0` | Weight of the ordinal term, the local-to-global dial. Higher keeps more global distance. |
73
+ | `density` | `True` | Fit the residual density. Required by `score_samples`, `score` and the diagnostics. |
74
+ | `max_iter` | `None` | Training steps. `None` keeps the recipe's 6400, see Training budget. |
75
+ | `random_state` | `0` | Seed for the fit. `None` draws one. |
76
+ | `device` | GPU if there is one | Resolved by `flodr.default_device()`. Pass `"cpu"` to force it. |
77
+ | `verbose` | `True` | Draw a tqdm bar while training. |
78
+ | `advanced` | `None` | Dict of `TrainConfig` overrides, e.g. `dict(pad=0, fine_eps=0, edge_batch=32768)`. Unknown keys raise. |
79
+
80
+ ## Methods
81
+
82
+ | Method | Returns | Description |
83
+ |---|---|---|
84
+ | `fit(X)` / `fit_transform(X)` | self / `(n, k)` | Fit the flow; the layout also lands in `embedding_`. |
85
+ | `transform(X)` | `(m, k)` | Embed new points in one forward pass. |
86
+ | `transform_opt(X, steps=200, lr=0.05)` | `(m, k)` | Placement transform: per-point optimisation against the frozen train embedding, in the same class as UMAP's `transform`. |
87
+ | `inverse_transform(Y)` | `(m, d)` | Decode screen positions, residual set to zero. |
88
+ | `transform_latent(X)` | `(m, D)` | Full latent coordinates: the layout, then the residual. |
89
+ | `inverse_latent(Z)` | `(m, d)` | Decode full latent coordinates, exactly. |
90
+ | `score_samples(X)` | `(m,)` | Log density under the flow. |
91
+ | `score(X)` | float | Mean of `score_samples`. |
92
+ | `conditional_spread(Y=None, n_samples=64)` | `(m,)` | Spread of the fiber over each position, in input units. |
93
+ | `atypicality(n_samples=128)` | `(n,)` | Per-point tail probability under its own fiber, in `[0, 1]`. |
94
+ | `spread_calibration(n_bins=14, min_pts=12, n_samples=64)` | dict | Certificate for `conditional_spread`, with `passed` and `confidence`. |
95
+ | `hidden_contrast(G, ...)` | `(field, cert)` | Label information present in the input but not in the layout, in nats, against a within-bin permutation null (`n_perm=99` by default). |
96
+ | `diagnostics(G=None, **kw)` | dict | Both fields with their certificates, units and labels. |
97
+
98
+ `fit(X, iters=..., progress=...)` and `inverse_coords(Z)` still work and warn; use
99
+ `max_iter`, `verbose` and `inverse_latent`.
100
+
101
+ ## Attributes
102
+
103
+ | Attribute | Description |
104
+ |---|---|
105
+ | `embedding_` | `(n, k)` layout of the training data. |
106
+ | `n_iter_` | Training steps run. |
107
+ | `n_features_in_`, `feature_names_in_` | Input width, and column names when fit on a DataFrame. |
108
+ | `roundtrip_` | Max absolute error of the latent round trip. |
109
+ | `flow_` | The trained `Flow`. |
110
+ | `knn_idx_`, `knn_dist_` | The 15-NN graph the fit was built on. |
111
+
112
+ ## `flodr.viz`
113
+
114
+ | Function | Description |
115
+ |---|---|
116
+ | `plot_embedding(Y, labels)` | Scatter of the layout. |
117
+ | `plot_field(Y, v)` | One scalar field over the layout. |
118
+ | `bivariate_field(Y, x, y)` | Two fields in one panel, with a corner key. |
119
+ | `occupancy_field(Y)` | Grid axes and a soft mask of where the layout has data. |
120
+ | `depth_fog(Y)` | Draw order, colour weight and depth, for 3D scatters. |
121
+ | `residual(flow, X)`, `residual_fraction(flow, X)` | The residual coordinates and their share of the latent norm. |
122
+ | `save(fig, path)` | Save at 300 dpi with tight bounds. |
123
+ | `RC`, `RC_PAPER` | Matplotlib rc dicts for screen and for print. |
124
+
125
+ ### Training budget
126
+
127
+ `max_iter=None` runs 6400 steps at a learning rate of 0.0025. Setting it rescales the rate
128
+ to keep their product at 16, so `max_iter=1600` runs at 0.01; below 800 steps the rate stays
129
+ at 0.02, since larger steps break the inverse (a round trip of 0.16 at 40 steps). Each step
130
+ trains on the whole kNN graph up to 262,144 edges and on a random batch of that many above
131
+ it, with 48 negatives per edge. Between 50,000 and 500,000 points only the heaviest quarter
132
+ of the graph is kept.
133
+
134
+ Two parts help most on narrow inputs such as a 10-dimensional scVI or scPoli latent. Inputs below
135
+ 30 dimensions get unit-variance noise columns up to 30 (`pad`), which widen the residual the
136
+ layout is routed through; the kNN graph and the ordinal term stay on the input. A shift-only
137
+ branch on fixed random frequencies (`fine_eps`, on for every input) joins the couplings that
138
+ write the layout halfway through training. `advanced=dict(pad=0, fine_eps=0)` turns both off.
139
+
140
+ On 250,000 cells of the HNOCA scPoli latent (10 dimensions) recall@15 is 0.134, against
141
+ 0.102 for UMAP (default parameters, PCA initialisation) and 0.044 for the earlier
142
+ recipe, with both certificates passing.
143
+ On 103,000 bone marrow cells (PCA-50, no padding) it goes from 0.017 to 0.076. The price is
144
+ time: about 6 minutes at 250,000 cells on an RTX 4070 Ti, against under one minute. Every number
145
+ in the paper was produced with the earlier recipe (800 steps at 0.02, 12 negatives, no padding).
146
+
147
+ On CUDA the loss of each step is compiled (`compile_edges`) and its hidden layers run in fp16
148
+ with loss scaling (`half_edges="fp16"`), the output layers and the Fourier phase in fp32.
149
+ Together they are 2.2x faster than the eager fp32 path at 250,000 and 103,000 cells, at the same
150
+ recall and certificates, but no longer bitwise reproducible across runs;
151
+ `advanced=dict(compile_edges=False, half_edges="")` restores the eager path. `half_edges="bf16"`
152
+ is no faster and loses 2.5 to 3.5% recall.
153
+
154
+ For new points prefer `transform_opt`. Fit on 200,000 HNOCA cells, 50,000 held-out cells
155
+ reach recall@15 0.113 with `transform_opt` in about 6 s, against 0.031 with `transform` and
156
+ 0.061 for the earlier recipe's `transform_opt`.
157
+
158
+ `verbose=False` turns the tqdm bar off outright. Left on, it draws only when stderr is a
159
+ terminal, so piping to a log does not fill it with carriage returns.
160
+
161
+ Low-level pieces are exported for custom training: `Flow`, `Coupling`, `TrainConfig`,
162
+ `train_flodr`, `raw_recipe`, plus the data helpers `preprocess`, `raw_coords`,
163
+ `fuzzy_knn_graph`, `knn_search`, `global_pairs`, `find_ab`.
164
+
165
+ ## Example notebook
166
+
167
+ `examples/quickstart.ipynb` runs every call above on Fashion-MNIST, 40000 images reduced to
168
+ a standardised PCA-50, and takes about four minutes on a GPU. It fits on four fifths of the
169
+ images, embeds the rest with both transforms, sweeps `inverse_transform` over a grid of
170
+ screen positions to decode the layout back into pictures, and ends on the two diagnostics
171
+ with their certificates, both of which pass at that sample size.
172
+
173
+ ## Reproducing the paper
174
+
175
+ `scripts/regen` rebuilds every figure, table and number of the paper from the released library,
176
+ starting from the public data sets. See `scripts/regen/README.md`.