FastMapy 0.1.1__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.
- {fastmapy-0.1.1 → fastmapy-0.3.0}/FastMapy.egg-info/PKG-INFO +21 -4
- {fastmapy-0.1.1 → fastmapy-0.3.0}/FastMapy.egg-info/SOURCES.txt +1 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/PKG-INFO +21 -4
- {fastmapy-0.1.1 → fastmapy-0.3.0}/README.md +19 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/__init__.py +1 -1
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/_fastmap.py +164 -3
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/distances/_jaccard.py +9 -1
- {fastmapy-0.1.1 → fastmapy-0.3.0}/pyproject.toml +3 -5
- {fastmapy-0.1.1 → fastmapy-0.3.0}/tests/test_distances.py +19 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/tests/test_fastmap.py +19 -0
- fastmapy-0.3.0/tests/test_persistence.py +68 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/FastMapy.egg-info/dependency_links.txt +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/FastMapy.egg-info/requires.txt +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/FastMapy.egg-info/top_level.txt +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/LICENSE +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/_distances.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/_fastmap_funcs.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/distances/__init__.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/distances/_cosine.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/distances/_helpers.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/distances/_l1.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/distances/_l2.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/distances/_lev.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/metrics.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/fastmap/plots.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/setup.cfg +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/setup.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/tests/test_metrics.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/utils/__init__.py +0 -0
- {fastmapy-0.1.1 → fastmapy-0.3.0}/utils/_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastMapy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python implementation of the FastMap multidimensional-scaling technique.
|
|
5
5
|
Author-email: Shawn Davis <shawndavis.lomod@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -9,12 +9,10 @@ Project-URL: Repository, https://github.com/shawn-davis/FastMapy
|
|
|
9
9
|
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Requires-Python: >=3.
|
|
15
|
+
Requires-Python: >=3.11
|
|
18
16
|
Description-Content-Type: text/markdown
|
|
19
17
|
License-File: LICENSE
|
|
20
18
|
Requires-Dist: numpy>=1.22
|
|
@@ -130,6 +128,12 @@ repeatability should control Python's random-number generator before fitting.
|
|
|
130
128
|
`cores` enables threaded fitting and transformation. It defaults to `1`; any speedup depends on the distance metric and
|
|
131
129
|
runtime.
|
|
132
130
|
|
|
131
|
+
For immutable training objects with an expensive distance metric, pass
|
|
132
|
+
`cache_distances=True` to `FastMap` or `FastMap.fit_many`. It memoizes exact
|
|
133
|
+
object-pair metric values by identity, avoiding repeated pivot comparisons while
|
|
134
|
+
keeping the cache linear in the number of comparisons rather than materializing a
|
|
135
|
+
pairwise distance matrix. Do not enable it for objects that may change in place.
|
|
136
|
+
|
|
133
137
|
### Fitting a batch of distinct models
|
|
134
138
|
|
|
135
139
|
Use `FastMap.fit_many` to fit several models with identical settings against one training collection:
|
|
@@ -154,5 +158,18 @@ objects.
|
|
|
154
158
|
|
|
155
159
|
FastMapy is licensed under the [Apache License, Version 2.0](LICENSE).
|
|
156
160
|
|
|
161
|
+
## Model persistence
|
|
162
|
+
|
|
163
|
+
Save a fitted model and restore it later with the versioned persistence API:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
model.save("jaccard.fastmap")
|
|
167
|
+
restored = fastmap.FastMap.load("jaccard.fastmap")
|
|
168
|
+
embedding = restored.transform(new_strings)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Model files use Python pickle and must only be loaded from trusted sources. Custom distance classes and object
|
|
172
|
+
transformers must be importable module-level objects in the environment that loads the model.
|
|
173
|
+
|
|
157
174
|
## References
|
|
158
175
|
<b id="f1">1</b> Proceedings of the 1995 ACM SIGMOD international conference on Management of data - SIGMOD ’95. (1995). doi:10.1145/223784 [↩](#a1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastMapy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python implementation of the FastMap multidimensional-scaling technique.
|
|
5
5
|
Author-email: Shawn Davis <shawndavis.lomod@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -9,12 +9,10 @@ Project-URL: Repository, https://github.com/shawn-davis/FastMapy
|
|
|
9
9
|
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Requires-Python: >=3.
|
|
15
|
+
Requires-Python: >=3.11
|
|
18
16
|
Description-Content-Type: text/markdown
|
|
19
17
|
License-File: LICENSE
|
|
20
18
|
Requires-Dist: numpy>=1.22
|
|
@@ -130,6 +128,12 @@ repeatability should control Python's random-number generator before fitting.
|
|
|
130
128
|
`cores` enables threaded fitting and transformation. It defaults to `1`; any speedup depends on the distance metric and
|
|
131
129
|
runtime.
|
|
132
130
|
|
|
131
|
+
For immutable training objects with an expensive distance metric, pass
|
|
132
|
+
`cache_distances=True` to `FastMap` or `FastMap.fit_many`. It memoizes exact
|
|
133
|
+
object-pair metric values by identity, avoiding repeated pivot comparisons while
|
|
134
|
+
keeping the cache linear in the number of comparisons rather than materializing a
|
|
135
|
+
pairwise distance matrix. Do not enable it for objects that may change in place.
|
|
136
|
+
|
|
133
137
|
### Fitting a batch of distinct models
|
|
134
138
|
|
|
135
139
|
Use `FastMap.fit_many` to fit several models with identical settings against one training collection:
|
|
@@ -154,5 +158,18 @@ objects.
|
|
|
154
158
|
|
|
155
159
|
FastMapy is licensed under the [Apache License, Version 2.0](LICENSE).
|
|
156
160
|
|
|
161
|
+
## Model persistence
|
|
162
|
+
|
|
163
|
+
Save a fitted model and restore it later with the versioned persistence API:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
model.save("jaccard.fastmap")
|
|
167
|
+
restored = fastmap.FastMap.load("jaccard.fastmap")
|
|
168
|
+
embedding = restored.transform(new_strings)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Model files use Python pickle and must only be loaded from trusted sources. Custom distance classes and object
|
|
172
|
+
transformers must be importable module-level objects in the environment that loads the model.
|
|
173
|
+
|
|
157
174
|
## References
|
|
158
175
|
<b id="f1">1</b> Proceedings of the 1995 ACM SIGMOD international conference on Management of data - SIGMOD ’95. (1995). doi:10.1145/223784 [↩](#a1)
|
|
@@ -93,6 +93,12 @@ repeatability should control Python's random-number generator before fitting.
|
|
|
93
93
|
`cores` enables threaded fitting and transformation. It defaults to `1`; any speedup depends on the distance metric and
|
|
94
94
|
runtime.
|
|
95
95
|
|
|
96
|
+
For immutable training objects with an expensive distance metric, pass
|
|
97
|
+
`cache_distances=True` to `FastMap` or `FastMap.fit_many`. It memoizes exact
|
|
98
|
+
object-pair metric values by identity, avoiding repeated pivot comparisons while
|
|
99
|
+
keeping the cache linear in the number of comparisons rather than materializing a
|
|
100
|
+
pairwise distance matrix. Do not enable it for objects that may change in place.
|
|
101
|
+
|
|
96
102
|
### Fitting a batch of distinct models
|
|
97
103
|
|
|
98
104
|
Use `FastMap.fit_many` to fit several models with identical settings against one training collection:
|
|
@@ -117,5 +123,18 @@ objects.
|
|
|
117
123
|
|
|
118
124
|
FastMapy is licensed under the [Apache License, Version 2.0](LICENSE).
|
|
119
125
|
|
|
126
|
+
## Model persistence
|
|
127
|
+
|
|
128
|
+
Save a fitted model and restore it later with the versioned persistence API:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
model.save("jaccard.fastmap")
|
|
132
|
+
restored = fastmap.FastMap.load("jaccard.fastmap")
|
|
133
|
+
embedding = restored.transform(new_strings)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Model files use Python pickle and must only be loaded from trusted sources. Custom distance classes and object
|
|
137
|
+
transformers must be importable module-level objects in the environment that loads the model.
|
|
138
|
+
|
|
120
139
|
## References
|
|
121
140
|
<b id="f1">1</b> Proceedings of the 1995 ACM SIGMOD international conference on Management of data - SIGMOD ’95. (1995). doi:10.1145/223784 [↩](#a1)
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import multiprocessing
|
|
2
|
+
import os
|
|
3
|
+
import pickle
|
|
2
4
|
import random
|
|
3
5
|
from concurrent.futures import ThreadPoolExecutor as Executor
|
|
4
6
|
from dataclasses import dataclass
|
|
5
7
|
from math import sqrt
|
|
8
|
+
from numbers import Real
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tempfile import NamedTemporaryFile
|
|
6
11
|
|
|
7
12
|
import numpy as np
|
|
8
13
|
|
|
@@ -13,6 +18,10 @@ class ModelError(Exception):
|
|
|
13
18
|
"""Raised when a FastMap model cannot be fitted or used."""
|
|
14
19
|
|
|
15
20
|
|
|
21
|
+
_MODEL_FORMAT = "fastmapy.model"
|
|
22
|
+
_MODEL_FORMAT_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
16
25
|
@dataclass
|
|
17
26
|
class Pivots:
|
|
18
27
|
left: object
|
|
@@ -25,7 +34,16 @@ class Pivots:
|
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
class FastMap:
|
|
28
|
-
def __init__(
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
dim,
|
|
40
|
+
distance,
|
|
41
|
+
dist_args=None,
|
|
42
|
+
obj_transformer=None,
|
|
43
|
+
iters=5,
|
|
44
|
+
cores=1,
|
|
45
|
+
cache_distances=False,
|
|
46
|
+
):
|
|
29
47
|
if not isinstance(dim, int) or isinstance(dim, bool) or dim < 1:
|
|
30
48
|
raise ValueError("dim must be a positive integer")
|
|
31
49
|
if not callable(distance):
|
|
@@ -36,6 +54,8 @@ class FastMap:
|
|
|
36
54
|
raise TypeError("obj_transformer must be callable or None")
|
|
37
55
|
if not isinstance(iters, int) or isinstance(iters, bool) or iters < 1:
|
|
38
56
|
raise ValueError("iters must be a positive integer")
|
|
57
|
+
if not isinstance(cache_distances, bool):
|
|
58
|
+
raise TypeError("cache_distances must be a boolean")
|
|
39
59
|
|
|
40
60
|
self._dim = dim
|
|
41
61
|
self._distance = distance(**(dist_args or {}))
|
|
@@ -43,6 +63,8 @@ class FastMap:
|
|
|
43
63
|
self._iters = iters
|
|
44
64
|
self._pivots: list[Pivots] = []
|
|
45
65
|
self._pivot_pair_collisions: list[int] = []
|
|
66
|
+
self._cache_distances = cache_distances
|
|
67
|
+
self._distance_cache = {}
|
|
46
68
|
self.cores = cores
|
|
47
69
|
|
|
48
70
|
@classmethod
|
|
@@ -57,6 +79,7 @@ class FastMap:
|
|
|
57
79
|
iters=5,
|
|
58
80
|
cores=1,
|
|
59
81
|
pair_retries=10,
|
|
82
|
+
cache_distances=False,
|
|
60
83
|
):
|
|
61
84
|
"""Fit several distinct models while avoiding repeated pivot pairs.
|
|
62
85
|
|
|
@@ -76,7 +99,8 @@ class FastMap:
|
|
|
76
99
|
raise ValueError("count cannot exceed the number of training objects")
|
|
77
100
|
|
|
78
101
|
models = [
|
|
79
|
-
cls(dim, distance, dist_args, obj_transformer, iters, cores
|
|
102
|
+
cls(dim, distance, dist_args, obj_transformer, iters, cores, cache_distances)
|
|
103
|
+
for _ in range(count)
|
|
80
104
|
]
|
|
81
105
|
for model in models:
|
|
82
106
|
model._validate_training_data(X)
|
|
@@ -99,6 +123,11 @@ class FastMap:
|
|
|
99
123
|
def iters(self):
|
|
100
124
|
return self._iters
|
|
101
125
|
|
|
126
|
+
@property
|
|
127
|
+
def cache_distances(self):
|
|
128
|
+
"""Whether this model memoizes exact object-pair metric evaluations."""
|
|
129
|
+
return self._cache_distances
|
|
130
|
+
|
|
102
131
|
@property
|
|
103
132
|
def cores(self):
|
|
104
133
|
return self._cores
|
|
@@ -115,6 +144,113 @@ class FastMap:
|
|
|
115
144
|
"""Dimensions where batch fitting exhausted its distinct-pair retries."""
|
|
116
145
|
return tuple(self._pivot_pair_collisions)
|
|
117
146
|
|
|
147
|
+
def save(self, path):
|
|
148
|
+
"""Persist a fitted model to ``path``.
|
|
149
|
+
|
|
150
|
+
The model is stored with Python pickle in a versioned FastMapy envelope. Only
|
|
151
|
+
load files from trusted sources. Custom distance classes and object transformers
|
|
152
|
+
must be importable module-level objects when the model is loaded.
|
|
153
|
+
"""
|
|
154
|
+
if len(self._pivots) != self._dim:
|
|
155
|
+
raise ModelError("Only a fully fitted model can be saved")
|
|
156
|
+
|
|
157
|
+
destination = Path(path)
|
|
158
|
+
if destination.exists() and destination.is_dir():
|
|
159
|
+
raise IsADirectoryError(f"Model path is a directory: {destination}")
|
|
160
|
+
|
|
161
|
+
temporary_path = None
|
|
162
|
+
try:
|
|
163
|
+
with NamedTemporaryFile("wb", dir=destination.parent, delete=False) as temporary:
|
|
164
|
+
temporary_path = Path(temporary.name)
|
|
165
|
+
pickle.dump(
|
|
166
|
+
{
|
|
167
|
+
"format": _MODEL_FORMAT,
|
|
168
|
+
"format_version": _MODEL_FORMAT_VERSION,
|
|
169
|
+
"model": self,
|
|
170
|
+
},
|
|
171
|
+
temporary,
|
|
172
|
+
protocol=pickle.HIGHEST_PROTOCOL,
|
|
173
|
+
)
|
|
174
|
+
temporary.flush()
|
|
175
|
+
os.fsync(temporary.fileno())
|
|
176
|
+
os.replace(temporary_path, destination)
|
|
177
|
+
except (OSError, pickle.PickleError, TypeError, AttributeError) as error:
|
|
178
|
+
raise ModelError(f"Unable to save model to {destination}") from error
|
|
179
|
+
finally:
|
|
180
|
+
if temporary_path is not None and temporary_path.exists():
|
|
181
|
+
temporary_path.unlink()
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def load(cls, path):
|
|
186
|
+
"""Load a model saved with :meth:`save`.
|
|
187
|
+
|
|
188
|
+
Pickle loading can execute code, so ``path`` must refer to a model file from a
|
|
189
|
+
trusted source. The persisted format version is checked before returning it.
|
|
190
|
+
"""
|
|
191
|
+
source = Path(path)
|
|
192
|
+
try:
|
|
193
|
+
with source.open("rb") as model_file:
|
|
194
|
+
payload = pickle.load(model_file)
|
|
195
|
+
except (OSError, EOFError, pickle.UnpicklingError, AttributeError, ImportError) as error:
|
|
196
|
+
raise ModelError(f"Unable to load model from {source}") from error
|
|
197
|
+
|
|
198
|
+
if not isinstance(payload, dict) or payload.get("format") != _MODEL_FORMAT:
|
|
199
|
+
raise ModelError("File is not a FastMapy model")
|
|
200
|
+
if payload.get("format_version") != _MODEL_FORMAT_VERSION:
|
|
201
|
+
raise ModelError("Unsupported FastMapy model format version")
|
|
202
|
+
|
|
203
|
+
model = payload.get("model")
|
|
204
|
+
if not isinstance(model, cls):
|
|
205
|
+
raise ModelError("Persisted model has an invalid type")
|
|
206
|
+
cls._validate_loaded_model(model)
|
|
207
|
+
return model
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _validate_loaded_model(model):
|
|
211
|
+
"""Reject persisted state that cannot safely support transformation."""
|
|
212
|
+
if not isinstance(getattr(model, "_dim", None), int) or isinstance(model._dim, bool):
|
|
213
|
+
raise ModelError("Persisted model has an invalid dimension")
|
|
214
|
+
if model._dim < 1:
|
|
215
|
+
raise ModelError("Persisted model has an invalid dimension")
|
|
216
|
+
if not isinstance(getattr(model, "_iters", None), int) or model._iters < 1:
|
|
217
|
+
raise ModelError("Persisted model has invalid iteration settings")
|
|
218
|
+
if not isinstance(getattr(model, "_cores", None), int) or model._cores < 1:
|
|
219
|
+
raise ModelError("Persisted model has invalid core settings")
|
|
220
|
+
if not callable(getattr(getattr(model, "_distance", None), "calculate", None)):
|
|
221
|
+
raise ModelError("Persisted model has an invalid distance metric")
|
|
222
|
+
transformer = getattr(model, "_obj_transformer", None)
|
|
223
|
+
if transformer is not None and not callable(transformer):
|
|
224
|
+
raise ModelError("Persisted model has an invalid object transformer")
|
|
225
|
+
if not isinstance(getattr(model, "_pivots", None), list):
|
|
226
|
+
raise ModelError("Persisted model is not fully fitted")
|
|
227
|
+
if len(model._pivots) != model._dim:
|
|
228
|
+
raise ModelError("Persisted model is not fully fitted")
|
|
229
|
+
if not isinstance(getattr(model, "_pivot_pair_collisions", None), list):
|
|
230
|
+
raise ModelError("Persisted model has invalid collision data")
|
|
231
|
+
|
|
232
|
+
for pivot in model._pivots:
|
|
233
|
+
if not isinstance(pivot, Pivots):
|
|
234
|
+
raise ModelError("Persisted model has an invalid pivot")
|
|
235
|
+
if not all(
|
|
236
|
+
isinstance(index, int) and not isinstance(index, bool) and index >= 0
|
|
237
|
+
for index in (pivot.left_index, pivot.right_index)
|
|
238
|
+
):
|
|
239
|
+
raise ModelError("Persisted model has invalid pivot indexes")
|
|
240
|
+
if not isinstance(pivot.distance, Real):
|
|
241
|
+
raise ModelError("Persisted model has an invalid pivot distance")
|
|
242
|
+
if not all(
|
|
243
|
+
isinstance(projection, np.ndarray) and projection.shape == (model._dim,)
|
|
244
|
+
for projection in (pivot.left_proj, pivot.right_proj)
|
|
245
|
+
):
|
|
246
|
+
raise ModelError("Persisted model has invalid pivot projections")
|
|
247
|
+
|
|
248
|
+
if not all(
|
|
249
|
+
isinstance(index, int) and not isinstance(index, bool) and 0 <= index < model._dim
|
|
250
|
+
for index in model._pivot_pair_collisions
|
|
251
|
+
):
|
|
252
|
+
raise ModelError("Persisted model has invalid collision data")
|
|
253
|
+
|
|
118
254
|
def _compute_proj_i(self, index, pivots, obj, obj_proj):
|
|
119
255
|
if pivots.distance == 0:
|
|
120
256
|
return 0.0
|
|
@@ -133,10 +269,34 @@ class FastMap:
|
|
|
133
269
|
return x_proj
|
|
134
270
|
|
|
135
271
|
def _dist(self, x, x_proj, y, y_proj, index):
|
|
136
|
-
d_sq = pow(self.
|
|
272
|
+
d_sq = pow(self._metric_distance(x, y), 2)
|
|
137
273
|
diff_sq = sum([pow(x_i - y_i, 2) for (x_i, y_i) in zip(x_proj[0:index], y_proj[0:index])])
|
|
138
274
|
return sqrt(max(d_sq - diff_sq, 0))
|
|
139
275
|
|
|
276
|
+
def _metric_distance(self, x, y):
|
|
277
|
+
"""Return a symmetric metric value, optionally memoized by object identity.
|
|
278
|
+
|
|
279
|
+
FastMap repeatedly compares training objects with the same pivots while
|
|
280
|
+
fitting and transforming them. Identity keys support unhashable objects
|
|
281
|
+
and avoid assuming equality is inexpensive. Cached entries retain both
|
|
282
|
+
objects so their ids cannot be recycled while the cache is live. This is
|
|
283
|
+
opt-in because callers with mutable objects may want fresh measurements.
|
|
284
|
+
"""
|
|
285
|
+
if not self._cache_distances:
|
|
286
|
+
return self._distance.calculate(x, y)
|
|
287
|
+
x_id, y_id = id(x), id(y)
|
|
288
|
+
key = (x_id, y_id) if x_id <= y_id else (y_id, x_id)
|
|
289
|
+
cached = self._distance_cache.get(key)
|
|
290
|
+
if cached is not None:
|
|
291
|
+
return cached[2]
|
|
292
|
+
value = self._distance.calculate(x, y)
|
|
293
|
+
self._distance_cache[key] = (x, y, value)
|
|
294
|
+
return value
|
|
295
|
+
|
|
296
|
+
def clear_distance_cache(self):
|
|
297
|
+
"""Discard memoized metric values to release their retained objects."""
|
|
298
|
+
self._distance_cache.clear()
|
|
299
|
+
|
|
140
300
|
def fit(self, X):
|
|
141
301
|
try:
|
|
142
302
|
X = list(X)
|
|
@@ -183,6 +343,7 @@ class FastMap:
|
|
|
183
343
|
X = [self._obj_transformer(x) for x in X]
|
|
184
344
|
self._pivots = []
|
|
185
345
|
self._pivot_pair_collisions = []
|
|
346
|
+
self.clear_distance_cache()
|
|
186
347
|
return X
|
|
187
348
|
|
|
188
349
|
def _fit_with_reservations(self, X, reserved_pairs, used_starts, pair_retries):
|
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
|
|
1
3
|
from fastmap._distances import Distance, InputError
|
|
2
4
|
from utils import shingler
|
|
3
5
|
|
|
4
6
|
|
|
7
|
+
@lru_cache(maxsize=32_768)
|
|
8
|
+
def _string_shingles(value, shingle_size):
|
|
9
|
+
"""Memoize immutable shingle sets for repeated string comparisons."""
|
|
10
|
+
return frozenset(shingler(value, shingle_size=shingle_size))
|
|
11
|
+
|
|
12
|
+
|
|
5
13
|
def _weight_set(x):
|
|
6
14
|
return {el: 1 for el in x}
|
|
7
15
|
|
|
8
16
|
|
|
9
17
|
def _to_set(value, shingle_size):
|
|
10
18
|
if isinstance(value, str):
|
|
11
|
-
return
|
|
19
|
+
return _string_shingles(value, shingle_size)
|
|
12
20
|
if isinstance(value, set):
|
|
13
21
|
return value
|
|
14
22
|
raise InputError("Jaccard distance needs strings, sets, or dictionaries")
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "FastMapy"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Python implementation of the FastMap multidimensional-scaling technique."
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = "Apache-2.0"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
13
|
authors = [
|
|
@@ -17,8 +17,6 @@ classifiers = [
|
|
|
17
17
|
"Development Status :: 2 - Pre-Alpha",
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
19
|
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
-
"Programming Language :: Python :: 3.9",
|
|
21
|
-
"Programming Language :: Python :: 3.10",
|
|
22
20
|
"Programming Language :: Python :: 3.11",
|
|
23
21
|
"Programming Language :: Python :: 3.12",
|
|
24
22
|
"Programming Language :: Python :: 3.13",
|
|
@@ -60,7 +58,7 @@ testpaths = ["tests"]
|
|
|
60
58
|
addopts = "-ra"
|
|
61
59
|
|
|
62
60
|
[tool.ruff]
|
|
63
|
-
target-version = "
|
|
61
|
+
target-version = "py311"
|
|
64
62
|
line-length = 100
|
|
65
63
|
|
|
66
64
|
[tool.ruff.lint]
|
|
@@ -45,6 +45,25 @@ def test_jaccard_handles_empty_and_mixed_weighted_inputs():
|
|
|
45
45
|
assert metric.calculate("ab", {"a": 1, "b": 1}) == 0
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def test_jaccard_reuses_cached_string_shingles(monkeypatch):
|
|
49
|
+
import fastmap.distances._jaccard as module
|
|
50
|
+
|
|
51
|
+
module._string_shingles.cache_clear()
|
|
52
|
+
calls = []
|
|
53
|
+
original = module.shingler
|
|
54
|
+
|
|
55
|
+
def counting_shingler(value, shingle_size):
|
|
56
|
+
calls.append(value)
|
|
57
|
+
return original(value, shingle_size)
|
|
58
|
+
|
|
59
|
+
monkeypatch.setattr(module, "shingler", counting_shingler)
|
|
60
|
+
metric = Jaccard(shingle_size=2)
|
|
61
|
+
metric.calculate("abcd", "abce")
|
|
62
|
+
metric.calculate("abce", "abcd")
|
|
63
|
+
|
|
64
|
+
assert calls == ["abcd", "abce"]
|
|
65
|
+
|
|
66
|
+
|
|
48
67
|
def test_distance_names_are_public_and_stable():
|
|
49
68
|
assert L1().get_name() == "L1"
|
|
50
69
|
assert L2().get_name() == "L2"
|
|
@@ -65,6 +65,25 @@ def test_fit_handles_identical_objects():
|
|
|
65
65
|
assert np.allclose(embedding, 0)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def test_distance_cache_reuses_symmetric_metric_evaluations():
|
|
69
|
+
calls = []
|
|
70
|
+
|
|
71
|
+
class CountingDistance(L2):
|
|
72
|
+
def calculate(self, left, right):
|
|
73
|
+
calls.append((id(left), id(right)))
|
|
74
|
+
return super().calculate(left, right)
|
|
75
|
+
|
|
76
|
+
vectors = [[0.0], [1.0], [2.0]]
|
|
77
|
+
model = FastMap(dim=1, distance=CountingDistance, iters=1, cache_distances=True)
|
|
78
|
+
model.fit(vectors)
|
|
79
|
+
model._metric_distance(vectors[0], vectors[1])
|
|
80
|
+
before_reverse = len(calls)
|
|
81
|
+
model._metric_distance(vectors[1], vectors[0])
|
|
82
|
+
|
|
83
|
+
assert len(calls) == before_reverse
|
|
84
|
+
assert model.cache_distances
|
|
85
|
+
|
|
86
|
+
|
|
68
87
|
@pytest.mark.parametrize("dim", [0, -1, 1.5, True])
|
|
69
88
|
def test_dimension_must_be_a_positive_integer(dim):
|
|
70
89
|
with pytest.raises(ValueError, match="positive integer"):
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pickle
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from fastmap import FastMap
|
|
7
|
+
from fastmap._fastmap import _MODEL_FORMAT, _MODEL_FORMAT_VERSION, ModelError
|
|
8
|
+
from fastmap.distances import L2, Jaccard
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_save_and_load_preserves_a_fitted_model(monkeypatch, tmp_path):
|
|
12
|
+
vectors = [[0.0, 0.0], [3.0, 4.0], [6.0, 8.0]]
|
|
13
|
+
monkeypatch.setattr("fastmap._fastmap.random.randrange", lambda count: 0)
|
|
14
|
+
model = FastMap(dim=1, distance=L2, iters=2).fit(vectors)
|
|
15
|
+
path = tmp_path / "model.fastmap"
|
|
16
|
+
|
|
17
|
+
assert model.save(path) is model
|
|
18
|
+
loaded = FastMap.load(path)
|
|
19
|
+
|
|
20
|
+
assert loaded.dim == model.dim
|
|
21
|
+
assert loaded.distance == model.distance
|
|
22
|
+
assert np.allclose(loaded.transform(vectors), model.transform(vectors))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_save_and_load_preserves_distance_configuration(monkeypatch, tmp_path):
|
|
26
|
+
strings = ["abcd", "abce", "xyz"]
|
|
27
|
+
monkeypatch.setattr("fastmap._fastmap.random.randrange", lambda count: 0)
|
|
28
|
+
model = FastMap(dim=1, distance=Jaccard, dist_args={"shingle_size": 2}).fit(strings)
|
|
29
|
+
path = tmp_path / "jaccard.fastmap"
|
|
30
|
+
|
|
31
|
+
loaded = model.save(path).load(path)
|
|
32
|
+
|
|
33
|
+
assert loaded._distance.shingle_size == 2
|
|
34
|
+
assert np.allclose(loaded.transform(strings), model.transform(strings))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_save_rejects_an_unfitted_model(tmp_path):
|
|
38
|
+
with pytest.raises(ModelError, match="fully fitted"):
|
|
39
|
+
FastMap(dim=1, distance=L2).save(tmp_path / "model.fastmap")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_load_rejects_non_fastmapy_data(tmp_path):
|
|
43
|
+
path = tmp_path / "not-a-model.fastmap"
|
|
44
|
+
with path.open("wb") as model_file:
|
|
45
|
+
pickle.dump({"format": "something-else"}, model_file)
|
|
46
|
+
|
|
47
|
+
with pytest.raises(ModelError, match="not a FastMapy model"):
|
|
48
|
+
FastMap.load(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_load_rejects_malformed_fitted_model(monkeypatch, tmp_path):
|
|
52
|
+
vectors = [[0.0, 0.0], [3.0, 4.0], [6.0, 8.0]]
|
|
53
|
+
monkeypatch.setattr("fastmap._fastmap.random.randrange", lambda count: 0)
|
|
54
|
+
model = FastMap(dim=1, distance=L2, iters=2).fit(vectors)
|
|
55
|
+
model._pivots[0] = object()
|
|
56
|
+
path = tmp_path / "malformed.fastmap"
|
|
57
|
+
with path.open("wb") as model_file:
|
|
58
|
+
pickle.dump(
|
|
59
|
+
{
|
|
60
|
+
"format": _MODEL_FORMAT,
|
|
61
|
+
"format_version": _MODEL_FORMAT_VERSION,
|
|
62
|
+
"model": model,
|
|
63
|
+
},
|
|
64
|
+
model_file,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
with pytest.raises(ModelError, match="invalid pivot"):
|
|
68
|
+
FastMap.load(path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|