gridity 0.2.2__tar.gz → 0.3.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.
- {gridity-0.2.2 → gridity-0.3.0}/.idea/modules.xml +1 -1
- {gridity-0.2.2 → gridity-0.3.0}/Cargo.lock +1 -1
- gridity-0.3.0/Cargo.toml +32 -0
- gridity-0.3.0/PKG-INFO +231 -0
- gridity-0.3.0/README.md +204 -0
- gridity-0.3.0/pyproject.toml +54 -0
- gridity-0.3.0/python/benchmark_formats.py +347 -0
- gridity-0.3.0/python/compression.py +26 -0
- gridity-0.3.0/python/compression_ratio.png +0 -0
- gridity-0.3.0/python/experiments/dataset_generator.py +93 -0
- gridity-0.3.0/python/experiments/figures/stage0_tol_sweep.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage3_rate_distortion.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_clustered.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_exact_all.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_generic.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_lattice.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_planted_blob.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage3_rate_distortion_planted_line.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage4_conditioning.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage4_epsilon.png +0 -0
- gridity-0.3.0/python/experiments/figures/stage7_noise.png +0 -0
- gridity-0.3.0/python/experiments/metrics.py +332 -0
- gridity-0.3.0/python/experiments/results/stage0.json +65 -0
- gridity-0.3.0/python/experiments/results/stage4_uniqueness.json +114 -0
- gridity-0.3.0/python/experiments/stage0_tol_sweep.py +36 -0
- gridity-0.3.0/python/experiments/stage1_baselines.py +34 -0
- gridity-0.3.0/python/experiments/stage2_diagnostic.py +252 -0
- gridity-0.3.0/python/experiments/stage2_gnn.py +138 -0
- gridity-0.3.0/python/experiments/stage3_rate_distortion.py +88 -0
- gridity-0.3.0/python/experiments/stage4_conditioning.py +151 -0
- gridity-0.3.0/python/experiments/stage4_epsilon.py +66 -0
- gridity-0.3.0/python/experiments/stage4_uniqueness.py +163 -0
- gridity-0.3.0/python/experiments/stage5_intrinsic_dim.py +58 -0
- gridity-0.3.0/python/experiments/stage6_landmark_scaling.py +81 -0
- gridity-0.3.0/python/experiments/stage7_noise.py +132 -0
- gridity-0.3.0/python/gridity_format.py +659 -0
- gridity-0.3.0/python/real_data_experiments/budget_sweep.py +71 -0
- gridity-0.3.0/python/real_data_experiments/compress_real_proteins.py +74 -0
- gridity-0.3.0/python/real_data_experiments/compression_size.py +69 -0
- gridity-0.3.0/python/real_data_experiments/contact_map_impact.py +85 -0
- gridity-0.3.0/python/real_data_experiments/protein_file_list.json +1 -0
- gridity-0.3.0/python/real_data_experiments/rd_exp.py +53 -0
- gridity-0.3.0/python/real_data_experiments/save_file_list.py +9 -0
- gridity-0.3.0/python/real_data_experiments/save_real_data.py +44 -0
- {gridity-0.2.2 → gridity-0.3.0}/python/test_bridge.py +34 -19
- gridity-0.3.0/python/test_gridity_format.py +214 -0
- gridity-0.3.0/python/test_gridity_rust.py +360 -0
- gridity-0.3.0/src/format/bits.rs +279 -0
- gridity-0.3.0/src/format/build.rs +566 -0
- gridity-0.3.0/src/format/header.rs +214 -0
- gridity-0.3.0/src/format/linalg.rs +887 -0
- gridity-0.3.0/src/format/place.rs +449 -0
- gridity-0.3.0/src/format.rs +986 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/lib.rs +198 -0
- gridity-0.2.2/.idea/GRiD.iml +0 -8
- gridity-0.2.2/Cargo.toml +0 -26
- gridity-0.2.2/PKG-INFO +0 -5
- gridity-0.2.2/README.md +0 -42
- gridity-0.2.2/gnn/algorithms.py +0 -193
- gridity-0.2.2/gnn/best_model.pt +0 -0
- gridity-0.2.2/gnn/compare.py +0 -221
- gridity-0.2.2/gnn/dataset.py +0 -90
- gridity-0.2.2/gnn/model.py +0 -117
- gridity-0.2.2/gnn/train.py +0 -100
- gridity-0.2.2/pyproject.toml +0 -12
- {gridity-0.2.2 → gridity-0.3.0}/.github/workflows/publish.yml +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/.gitignore +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/.idea/.gitignore +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/.idea/inspectionProfiles/profiles_settings.xml +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/.idea/misc.xml +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/.idea/vcs.xml +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/main.rs +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/node.rs +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/pebble.rs +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/rank.rs +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/reconstruct.rs +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/rigidity.rs +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/simulation.rs +0 -0
- {gridity-0.2.2 → gridity-0.3.0}/src/traversal.rs +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<project version="4">
|
|
3
3
|
<component name="ProjectModuleManager">
|
|
4
4
|
<modules>
|
|
5
|
-
<module fileurl="file://$PROJECT_DIR$/.idea/
|
|
5
|
+
<module fileurl="file://$PROJECT_DIR$/.idea/gridity.iml" filepath="$PROJECT_DIR$/.idea/gridity.iml" />
|
|
6
6
|
</modules>
|
|
7
7
|
</component>
|
|
8
8
|
</project>
|
gridity-0.3.0/Cargo.toml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "gridity"
|
|
3
|
+
# keep in step with pyproject.toml: `gridity.__version__` is read from here
|
|
4
|
+
version = "0.3.0"
|
|
5
|
+
edition = "2021"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
|
|
8
|
+
[lib]
|
|
9
|
+
name = "gridity"
|
|
10
|
+
# rlib alongside cdylib so `cargo test` can link the unit tests against the crate
|
|
11
|
+
crate-type = ["cdylib", "rlib"]
|
|
12
|
+
|
|
13
|
+
[dependencies]
|
|
14
|
+
# `extension-module` is NOT enabled here on purpose. With it on, the library
|
|
15
|
+
# leaves Python's symbols to be resolved by the interpreter at load time, which
|
|
16
|
+
# is right for the wheel but means `cargo test` cannot link. maturin turns it on
|
|
17
|
+
# via the `features` list in pyproject.toml when it builds the extension.
|
|
18
|
+
pyo3 = { version = "0.22" }
|
|
19
|
+
numpy = "0.22"
|
|
20
|
+
ndarray = "0.16"
|
|
21
|
+
|
|
22
|
+
[profile.release]
|
|
23
|
+
opt-level = 3
|
|
24
|
+
lto = true
|
|
25
|
+
codegen-units = 1
|
|
26
|
+
|
|
27
|
+
[dev-dependencies]
|
|
28
|
+
approx = "0.5"
|
|
29
|
+
|
|
30
|
+
[[bin]]
|
|
31
|
+
name = "grid_sim_demo"
|
|
32
|
+
path = "src/main.rs"
|
gridity-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gridity
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Classifier: Development Status :: 4 - Beta
|
|
5
|
+
Classifier: Intended Audience :: Science/Research
|
|
6
|
+
Classifier: Intended Audience :: Developers
|
|
7
|
+
Classifier: Programming Language :: Rust
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
15
|
+
Classifier: Topic :: System :: Archiving :: Compression
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Requires-Dist: numpy>=1.21
|
|
18
|
+
Summary: Compress a distance matrix down to the handful of distances that actually pin the geometry, using rigidity theory.
|
|
19
|
+
Keywords: compression,distance-matrix,distance-geometry,rigidity,rigidity-theory,point-cloud,multidimensional-scaling,mds,trilateration,sensor-localization
|
|
20
|
+
Author: Sezer Aptourachman
|
|
21
|
+
Requires-Python: >=3.8, <3.14
|
|
22
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
23
|
+
Project-URL: Homepage, https://github.com/JuL-sezaR/grid
|
|
24
|
+
Project-URL: Issues, https://github.com/JuL-sezaR/grid/issues
|
|
25
|
+
Project-URL: Repository, https://github.com/JuL-sezaR/grid
|
|
26
|
+
|
|
27
|
+
# gridity
|
|
28
|
+
|
|
29
|
+
**Compress a distance matrix down to the handful of distances that actually pin the geometry.**
|
|
30
|
+
|
|
31
|
+
If you have `n` points, there are `n(n-1)/2` distances between them — but the points
|
|
32
|
+
themselves only have `n·d` coordinates. Most of that matrix is redundant. `gridity` works out
|
|
33
|
+
which distances carry the shape and which are implied by the others, then writes only the
|
|
34
|
+
first group to a file.
|
|
35
|
+
|
|
36
|
+
Rust core, Python API, no runtime dependencies beyond NumPy.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install gridity
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## The short version
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import numpy as np
|
|
48
|
+
import gridity
|
|
49
|
+
|
|
50
|
+
# 1000 points in 3D, and the distances between every pair of them
|
|
51
|
+
pts = np.random.rand(1000, 3) * 10
|
|
52
|
+
D = np.linalg.norm(pts[:, None] - pts[None], axis=-1)
|
|
53
|
+
|
|
54
|
+
gridity.compress(D, "cloud.gridity", d=3, bits=0) # 27,952 bytes
|
|
55
|
+
back = gridity.decompress("cloud.gridity") # the full 1000x1000 matrix
|
|
56
|
+
|
|
57
|
+
np.abs(back - D).max() # ~1e-13
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That matrix is 3,996,000 bytes as float64. `lzma` gets it to 3,654,072. `gridity` gets it to
|
|
61
|
+
27,952 — and decompresses back to the same matrix.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Why not just zip it?
|
|
66
|
+
|
|
67
|
+
Because distance data is nearly incompressible. The mantissa bits of a float64 distance are
|
|
68
|
+
effectively random, so a general-purpose compressor has almost nothing to find. Measured on
|
|
69
|
+
uniform 3D clouds, same matrices, all lossless:
|
|
70
|
+
|
|
71
|
+
| n | float64 triangle | `+ lzma -9` | `.gridity` | vs lzma |
|
|
72
|
+
|---|---|---|---|---|
|
|
73
|
+
| 100 | 39,600 B | 36,428 B | **2,721 B** | 13× smaller |
|
|
74
|
+
| 300 | 358,800 B | 328,996 B | **8,240 B** | 40× smaller |
|
|
75
|
+
| 1000 | 3,996,000 B | 3,654,072 B | **27,952 B** | 131× smaller |
|
|
76
|
+
|
|
77
|
+
The gap widens with `n`, because the matrix grows as O(n²) while the geometry only grows
|
|
78
|
+
as O(n). `gridity` stores the geometry.
|
|
79
|
+
|
|
80
|
+
A general-purpose compressor sees a bag of numbers. `gridity` knows they are distances
|
|
81
|
+
between points in `d` dimensions, and that is the entire trick.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Where this is the right tool
|
|
86
|
+
|
|
87
|
+
The target case is **you have distances and never had coordinates**:
|
|
88
|
+
|
|
89
|
+
- wireless sensor localisation — time-of-flight or signal strength between nodes, no GPS
|
|
90
|
+
- molecular structure from NMR — inter-atomic distances, not positions
|
|
91
|
+
- any distance-geometry problem where the O(n²) matrix is the thing you must store or send
|
|
92
|
+
|
|
93
|
+
If you already have coordinates, store those instead; see *Honest limits* below.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Spending fewer bits
|
|
98
|
+
|
|
99
|
+
Rigidity is a threshold. Below a certain number of distances the shape comes apart
|
|
100
|
+
completely, so there is no point asking for "slightly fewer distances". What you can trade
|
|
101
|
+
is **precision per distance**:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
gridity.compress_to_bytes(D, d=3, bits=8) # 6,994 B, error 8.2e-03
|
|
105
|
+
gridity.compress_to_bytes(D, d=3, bits=12) # 8,491 B, error 6.0e-04
|
|
106
|
+
gridity.compress_to_bytes(D, d=3, bits=16) # 9,988 B, error 4.3e-05
|
|
107
|
+
gridity.compress_to_bytes(D, d=3, bits=0) # 27,952 B, exact
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every two extra bits cuts the error roughly fourfold. If you would rather state the error
|
|
111
|
+
than guess the budget, say so and let the encoder find it:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
blob = gridity.compress_to_bytes(D, d=3, target_error=1e-3)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
That bound is **measured, not predicted** — the encoder decodes its own output and checks
|
|
118
|
+
before settling on a budget.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## How it works
|
|
123
|
+
|
|
124
|
+
`max_rank = n·d − d(d+1)/2` distances are enough to make a point cloud *rigid*. That is the
|
|
125
|
+
classical result, and it is where the O(1/n) compression ratio comes from.
|
|
126
|
+
|
|
127
|
+
But rigid is not the same as *uniquely determined*. A minimal rigid edge set is satisfied by
|
|
128
|
+
many different point clouds — about twenty for a ten-point cloud — because each point can
|
|
129
|
+
reflect across the plane through its anchors. Storing only those distances would give you a
|
|
130
|
+
file that decodes to the wrong shape.
|
|
131
|
+
|
|
132
|
+
So the edges are picked as a **trilateration**: a small seed simplex first, then every
|
|
133
|
+
remaining point tied to exactly `d` points already placed. That is exactly `max_rank` edges,
|
|
134
|
+
and it makes the ambiguity explicit — each point has precisely two possible positions, so
|
|
135
|
+
**one bit per point** says which. The result:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
max_rank distances + n bits = unique reconstruction
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`n` bits against an O(n²) matrix costs nothing, so the ratio survives intact.
|
|
142
|
+
|
|
143
|
+
Two modes are available. `minimal` (the default) is the above. `redundant` ties each point
|
|
144
|
+
to `d+1` anchors instead, which needs no sign bits and averages quantisation error down
|
|
145
|
+
rather than letting it land on one coordinate — larger files, better accuracy per bit.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Honest limits
|
|
150
|
+
|
|
151
|
+
Worth knowing before you adopt this.
|
|
152
|
+
|
|
153
|
+
**If your matrix is exactly Euclidean, storing coordinates beats storing distances.** Run
|
|
154
|
+
MDS, keep the `n·d` coordinates, and you are about 1.8× smaller than `.gridity` at the same
|
|
155
|
+
accuracy (at n=300, d=3: 1,374 B against 2,429 B, both at ~3e-04). The reason is structural
|
|
156
|
+
rather than a missing optimisation: naming *which* distances you kept costs about as much as
|
|
157
|
+
the distances themselves, and that is already near the information-theoretic floor for
|
|
158
|
+
choosing a subset. `gridity.explain()` shows the split.
|
|
159
|
+
|
|
160
|
+
So `gridity` earns its place when:
|
|
161
|
+
|
|
162
|
+
- the matrix is **not** exactly embeddable in `d` dimensions — measured data never is — and
|
|
163
|
+
you want the measurements kept rather than projected away. At `bits=0` every retained
|
|
164
|
+
distance is reproduced *exactly*, because each point is placed to satisfy its own anchor
|
|
165
|
+
distances by construction. On a noisy 300-point matrix that is 3e-16 against 1.7e-02 for
|
|
166
|
+
MDS coordinates.
|
|
167
|
+
- you want to know which distances matter, not just a smaller file.
|
|
168
|
+
|
|
169
|
+
The flip side of the same fact: MDS fits all `n²` distances at once and averages noise down,
|
|
170
|
+
so on a noisy matrix it wins on *all-pairs* error even while losing on the retained ones.
|
|
171
|
+
Which of those you care about decides the tool.
|
|
172
|
+
|
|
173
|
+
**Other limits.** `d` must be 2 or 3. The encoder runs an MDS pass, so it is O(n²) in memory
|
|
174
|
+
and wants the full matrix up front; the decoder is O(n·pool). Reconstruction is up to a
|
|
175
|
+
rigid motion, so the recovered coordinates may be rotated, translated or reflected relative
|
|
176
|
+
to the originals — the distances are what is preserved.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## API
|
|
181
|
+
|
|
182
|
+
| Call | What it does |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `compress(D, path, d=3, bits=12, mode="minimal", pool=64, target_error=None)` | matrix → file; returns the same dict as `inspect` |
|
|
185
|
+
| `decompress(path, return_coords=False)` | file → matrix (and coordinates, if asked) |
|
|
186
|
+
| `compress_to_bytes(D, ...)` | same as `compress`, returns `bytes` |
|
|
187
|
+
| `decompress_from_bytes(data, return_coords=False)` | same as `decompress`, takes `bytes` |
|
|
188
|
+
| `inspect(path_or_bytes)` | header only: `n`, `d`, `bits`, `n_edges`, `mode`, `ratio`, … |
|
|
189
|
+
| `explain(path_or_bytes)` | byte budget: header / seed / edge index / sign bits / distances |
|
|
190
|
+
|
|
191
|
+
Arguments: `d` is the dimension the points live in (2 or 3). `bits` is the quantiser width
|
|
192
|
+
per distance, `0` meaning raw float64. `mode` is `"minimal"` or `"redundant"`. `pool` is how
|
|
193
|
+
many nearby points the encoder considers as anchors — raise it for odd geometry, lower it
|
|
194
|
+
for speed. `target_error` overrides `bits`.
|
|
195
|
+
|
|
196
|
+
Bad input raises `ValueError` with a specific message — not square, asymmetric, negative,
|
|
197
|
+
NaN, non-zero diagonal, too few points, bad `d`. Every file carries a CRC-32, so a truncated
|
|
198
|
+
or corrupted file is refused rather than quietly decoded into the wrong shape.
|
|
199
|
+
|
|
200
|
+
### The rigidity core
|
|
201
|
+
|
|
202
|
+
The compression is built on a rigidity engine that is also exposed directly:
|
|
203
|
+
|
|
204
|
+
| Call | What it does |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `max_independent(n, d)` | `n·d − d(d+1)/2`, the number of distances that pin `n` points |
|
|
207
|
+
| `simulate(coords, d, tol=None)` | which edges are independent, by Gaussian elimination on the rigidity matrix |
|
|
208
|
+
| `pebble_game(coords, d)` | the same question combinatorially, O(n²), `d=2` only |
|
|
209
|
+
| `reconstruct(D, d, tol=None)` | classical MDS (see the caveat below) |
|
|
210
|
+
| `gram_from_dist(D)` | Gram matrix by double-centring |
|
|
211
|
+
|
|
212
|
+
Two notes on these. `simulate` and `pebble_game` agree exactly on points in general
|
|
213
|
+
position; on degenerate inputs they will not, and that is correct rather than a bug — the
|
|
214
|
+
pebble game decides *generic* rigidity from the graph alone and never looks at the
|
|
215
|
+
coordinates. And `reconstruct` is accurate only to about n=50; the container does not use
|
|
216
|
+
it, it runs its own MDS.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Building from source
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
pip install maturin
|
|
224
|
+
maturin develop --release
|
|
225
|
+
cargo test # 119 unit tests
|
|
226
|
+
python python/test_gridity_rust.py
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Prebuilt wheels cover CPython 3.11–3.13 on Linux, Windows and macOS. Other versions in range
|
|
230
|
+
build from the sdist and need a Rust toolchain.
|
|
231
|
+
|
gridity-0.3.0/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# gridity
|
|
2
|
+
|
|
3
|
+
**Compress a distance matrix down to the handful of distances that actually pin the geometry.**
|
|
4
|
+
|
|
5
|
+
If you have `n` points, there are `n(n-1)/2` distances between them — but the points
|
|
6
|
+
themselves only have `n·d` coordinates. Most of that matrix is redundant. `gridity` works out
|
|
7
|
+
which distances carry the shape and which are implied by the others, then writes only the
|
|
8
|
+
first group to a file.
|
|
9
|
+
|
|
10
|
+
Rust core, Python API, no runtime dependencies beyond NumPy.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install gridity
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The short version
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import numpy as np
|
|
22
|
+
import gridity
|
|
23
|
+
|
|
24
|
+
# 1000 points in 3D, and the distances between every pair of them
|
|
25
|
+
pts = np.random.rand(1000, 3) * 10
|
|
26
|
+
D = np.linalg.norm(pts[:, None] - pts[None], axis=-1)
|
|
27
|
+
|
|
28
|
+
gridity.compress(D, "cloud.gridity", d=3, bits=0) # 27,952 bytes
|
|
29
|
+
back = gridity.decompress("cloud.gridity") # the full 1000x1000 matrix
|
|
30
|
+
|
|
31
|
+
np.abs(back - D).max() # ~1e-13
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That matrix is 3,996,000 bytes as float64. `lzma` gets it to 3,654,072. `gridity` gets it to
|
|
35
|
+
27,952 — and decompresses back to the same matrix.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Why not just zip it?
|
|
40
|
+
|
|
41
|
+
Because distance data is nearly incompressible. The mantissa bits of a float64 distance are
|
|
42
|
+
effectively random, so a general-purpose compressor has almost nothing to find. Measured on
|
|
43
|
+
uniform 3D clouds, same matrices, all lossless:
|
|
44
|
+
|
|
45
|
+
| n | float64 triangle | `+ lzma -9` | `.gridity` | vs lzma |
|
|
46
|
+
|---|---|---|---|---|
|
|
47
|
+
| 100 | 39,600 B | 36,428 B | **2,721 B** | 13× smaller |
|
|
48
|
+
| 300 | 358,800 B | 328,996 B | **8,240 B** | 40× smaller |
|
|
49
|
+
| 1000 | 3,996,000 B | 3,654,072 B | **27,952 B** | 131× smaller |
|
|
50
|
+
|
|
51
|
+
The gap widens with `n`, because the matrix grows as O(n²) while the geometry only grows
|
|
52
|
+
as O(n). `gridity` stores the geometry.
|
|
53
|
+
|
|
54
|
+
A general-purpose compressor sees a bag of numbers. `gridity` knows they are distances
|
|
55
|
+
between points in `d` dimensions, and that is the entire trick.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Where this is the right tool
|
|
60
|
+
|
|
61
|
+
The target case is **you have distances and never had coordinates**:
|
|
62
|
+
|
|
63
|
+
- wireless sensor localisation — time-of-flight or signal strength between nodes, no GPS
|
|
64
|
+
- molecular structure from NMR — inter-atomic distances, not positions
|
|
65
|
+
- any distance-geometry problem where the O(n²) matrix is the thing you must store or send
|
|
66
|
+
|
|
67
|
+
If you already have coordinates, store those instead; see *Honest limits* below.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Spending fewer bits
|
|
72
|
+
|
|
73
|
+
Rigidity is a threshold. Below a certain number of distances the shape comes apart
|
|
74
|
+
completely, so there is no point asking for "slightly fewer distances". What you can trade
|
|
75
|
+
is **precision per distance**:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
gridity.compress_to_bytes(D, d=3, bits=8) # 6,994 B, error 8.2e-03
|
|
79
|
+
gridity.compress_to_bytes(D, d=3, bits=12) # 8,491 B, error 6.0e-04
|
|
80
|
+
gridity.compress_to_bytes(D, d=3, bits=16) # 9,988 B, error 4.3e-05
|
|
81
|
+
gridity.compress_to_bytes(D, d=3, bits=0) # 27,952 B, exact
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every two extra bits cuts the error roughly fourfold. If you would rather state the error
|
|
85
|
+
than guess the budget, say so and let the encoder find it:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
blob = gridity.compress_to_bytes(D, d=3, target_error=1e-3)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That bound is **measured, not predicted** — the encoder decodes its own output and checks
|
|
92
|
+
before settling on a budget.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## How it works
|
|
97
|
+
|
|
98
|
+
`max_rank = n·d − d(d+1)/2` distances are enough to make a point cloud *rigid*. That is the
|
|
99
|
+
classical result, and it is where the O(1/n) compression ratio comes from.
|
|
100
|
+
|
|
101
|
+
But rigid is not the same as *uniquely determined*. A minimal rigid edge set is satisfied by
|
|
102
|
+
many different point clouds — about twenty for a ten-point cloud — because each point can
|
|
103
|
+
reflect across the plane through its anchors. Storing only those distances would give you a
|
|
104
|
+
file that decodes to the wrong shape.
|
|
105
|
+
|
|
106
|
+
So the edges are picked as a **trilateration**: a small seed simplex first, then every
|
|
107
|
+
remaining point tied to exactly `d` points already placed. That is exactly `max_rank` edges,
|
|
108
|
+
and it makes the ambiguity explicit — each point has precisely two possible positions, so
|
|
109
|
+
**one bit per point** says which. The result:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
max_rank distances + n bits = unique reconstruction
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`n` bits against an O(n²) matrix costs nothing, so the ratio survives intact.
|
|
116
|
+
|
|
117
|
+
Two modes are available. `minimal` (the default) is the above. `redundant` ties each point
|
|
118
|
+
to `d+1` anchors instead, which needs no sign bits and averages quantisation error down
|
|
119
|
+
rather than letting it land on one coordinate — larger files, better accuracy per bit.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Honest limits
|
|
124
|
+
|
|
125
|
+
Worth knowing before you adopt this.
|
|
126
|
+
|
|
127
|
+
**If your matrix is exactly Euclidean, storing coordinates beats storing distances.** Run
|
|
128
|
+
MDS, keep the `n·d` coordinates, and you are about 1.8× smaller than `.gridity` at the same
|
|
129
|
+
accuracy (at n=300, d=3: 1,374 B against 2,429 B, both at ~3e-04). The reason is structural
|
|
130
|
+
rather than a missing optimisation: naming *which* distances you kept costs about as much as
|
|
131
|
+
the distances themselves, and that is already near the information-theoretic floor for
|
|
132
|
+
choosing a subset. `gridity.explain()` shows the split.
|
|
133
|
+
|
|
134
|
+
So `gridity` earns its place when:
|
|
135
|
+
|
|
136
|
+
- the matrix is **not** exactly embeddable in `d` dimensions — measured data never is — and
|
|
137
|
+
you want the measurements kept rather than projected away. At `bits=0` every retained
|
|
138
|
+
distance is reproduced *exactly*, because each point is placed to satisfy its own anchor
|
|
139
|
+
distances by construction. On a noisy 300-point matrix that is 3e-16 against 1.7e-02 for
|
|
140
|
+
MDS coordinates.
|
|
141
|
+
- you want to know which distances matter, not just a smaller file.
|
|
142
|
+
|
|
143
|
+
The flip side of the same fact: MDS fits all `n²` distances at once and averages noise down,
|
|
144
|
+
so on a noisy matrix it wins on *all-pairs* error even while losing on the retained ones.
|
|
145
|
+
Which of those you care about decides the tool.
|
|
146
|
+
|
|
147
|
+
**Other limits.** `d` must be 2 or 3. The encoder runs an MDS pass, so it is O(n²) in memory
|
|
148
|
+
and wants the full matrix up front; the decoder is O(n·pool). Reconstruction is up to a
|
|
149
|
+
rigid motion, so the recovered coordinates may be rotated, translated or reflected relative
|
|
150
|
+
to the originals — the distances are what is preserved.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## API
|
|
155
|
+
|
|
156
|
+
| Call | What it does |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `compress(D, path, d=3, bits=12, mode="minimal", pool=64, target_error=None)` | matrix → file; returns the same dict as `inspect` |
|
|
159
|
+
| `decompress(path, return_coords=False)` | file → matrix (and coordinates, if asked) |
|
|
160
|
+
| `compress_to_bytes(D, ...)` | same as `compress`, returns `bytes` |
|
|
161
|
+
| `decompress_from_bytes(data, return_coords=False)` | same as `decompress`, takes `bytes` |
|
|
162
|
+
| `inspect(path_or_bytes)` | header only: `n`, `d`, `bits`, `n_edges`, `mode`, `ratio`, … |
|
|
163
|
+
| `explain(path_or_bytes)` | byte budget: header / seed / edge index / sign bits / distances |
|
|
164
|
+
|
|
165
|
+
Arguments: `d` is the dimension the points live in (2 or 3). `bits` is the quantiser width
|
|
166
|
+
per distance, `0` meaning raw float64. `mode` is `"minimal"` or `"redundant"`. `pool` is how
|
|
167
|
+
many nearby points the encoder considers as anchors — raise it for odd geometry, lower it
|
|
168
|
+
for speed. `target_error` overrides `bits`.
|
|
169
|
+
|
|
170
|
+
Bad input raises `ValueError` with a specific message — not square, asymmetric, negative,
|
|
171
|
+
NaN, non-zero diagonal, too few points, bad `d`. Every file carries a CRC-32, so a truncated
|
|
172
|
+
or corrupted file is refused rather than quietly decoded into the wrong shape.
|
|
173
|
+
|
|
174
|
+
### The rigidity core
|
|
175
|
+
|
|
176
|
+
The compression is built on a rigidity engine that is also exposed directly:
|
|
177
|
+
|
|
178
|
+
| Call | What it does |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `max_independent(n, d)` | `n·d − d(d+1)/2`, the number of distances that pin `n` points |
|
|
181
|
+
| `simulate(coords, d, tol=None)` | which edges are independent, by Gaussian elimination on the rigidity matrix |
|
|
182
|
+
| `pebble_game(coords, d)` | the same question combinatorially, O(n²), `d=2` only |
|
|
183
|
+
| `reconstruct(D, d, tol=None)` | classical MDS (see the caveat below) |
|
|
184
|
+
| `gram_from_dist(D)` | Gram matrix by double-centring |
|
|
185
|
+
|
|
186
|
+
Two notes on these. `simulate` and `pebble_game` agree exactly on points in general
|
|
187
|
+
position; on degenerate inputs they will not, and that is correct rather than a bug — the
|
|
188
|
+
pebble game decides *generic* rigidity from the graph alone and never looks at the
|
|
189
|
+
coordinates. And `reconstruct` is accurate only to about n=50; the container does not use
|
|
190
|
+
it, it runs its own MDS.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Building from source
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
pip install maturin
|
|
198
|
+
maturin develop --release
|
|
199
|
+
cargo test # 119 unit tests
|
|
200
|
+
python python/test_gridity_rust.py
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Prebuilt wheels cover CPython 3.11–3.13 on Linux, Windows and macOS. Other versions in range
|
|
204
|
+
build from the sdist and need a Rust toolchain.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.5,<2.0"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gridity"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Compress a distance matrix down to the handful of distances that actually pin the geometry, using rigidity theory."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8,<3.14"
|
|
11
|
+
authors = [{ name = "Sezer Aptourachman" }]
|
|
12
|
+
keywords = [
|
|
13
|
+
"compression",
|
|
14
|
+
"distance-matrix",
|
|
15
|
+
"distance-geometry",
|
|
16
|
+
"rigidity",
|
|
17
|
+
"rigidity-theory",
|
|
18
|
+
"point-cloud",
|
|
19
|
+
"multidimensional-scaling",
|
|
20
|
+
"mds",
|
|
21
|
+
"trilateration",
|
|
22
|
+
"sensor-localization",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Science/Research",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Programming Language :: Rust",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
34
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
35
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
36
|
+
"Topic :: System :: Archiving :: Compression",
|
|
37
|
+
"Operating System :: OS Independent",
|
|
38
|
+
]
|
|
39
|
+
dependencies = ["numpy>=1.21"]
|
|
40
|
+
|
|
41
|
+
# The upper Python bound is real: the PyO3 version this builds against supports
|
|
42
|
+
# CPython up to 3.13. Without the bound, installing on 3.14 fails from source
|
|
43
|
+
# with a confusing linker-level message instead of a clean "requires Python".
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/JuL-sezaR/grid"
|
|
47
|
+
Repository = "https://github.com/JuL-sezaR/grid"
|
|
48
|
+
Issues = "https://github.com/JuL-sezaR/grid/issues"
|
|
49
|
+
|
|
50
|
+
[tool.maturin]
|
|
51
|
+
# Enabled only for the wheel. It is deliberately NOT a default feature in
|
|
52
|
+
# Cargo.toml, because with it on the crate leaves Python's symbols to the
|
|
53
|
+
# interpreter and `cargo test` cannot link.
|
|
54
|
+
features = ["pyo3/extension-module"]
|