FastMapy 0.1.1__tar.gz → 0.2.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.
Files changed (30) hide show
  1. {fastmapy-0.1.1 → fastmapy-0.2.0}/FastMapy.egg-info/PKG-INFO +14 -1
  2. {fastmapy-0.1.1 → fastmapy-0.2.0}/FastMapy.egg-info/SOURCES.txt +1 -0
  3. {fastmapy-0.1.1 → fastmapy-0.2.0}/PKG-INFO +14 -1
  4. {fastmapy-0.1.1 → fastmapy-0.2.0}/README.md +13 -0
  5. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/__init__.py +1 -1
  6. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/_fastmap.py +116 -0
  7. {fastmapy-0.1.1 → fastmapy-0.2.0}/pyproject.toml +1 -1
  8. fastmapy-0.2.0/tests/test_persistence.py +68 -0
  9. {fastmapy-0.1.1 → fastmapy-0.2.0}/FastMapy.egg-info/dependency_links.txt +0 -0
  10. {fastmapy-0.1.1 → fastmapy-0.2.0}/FastMapy.egg-info/requires.txt +0 -0
  11. {fastmapy-0.1.1 → fastmapy-0.2.0}/FastMapy.egg-info/top_level.txt +0 -0
  12. {fastmapy-0.1.1 → fastmapy-0.2.0}/LICENSE +0 -0
  13. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/_distances.py +0 -0
  14. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/_fastmap_funcs.py +0 -0
  15. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/distances/__init__.py +0 -0
  16. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/distances/_cosine.py +0 -0
  17. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/distances/_helpers.py +0 -0
  18. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/distances/_jaccard.py +0 -0
  19. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/distances/_l1.py +0 -0
  20. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/distances/_l2.py +0 -0
  21. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/distances/_lev.py +0 -0
  22. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/metrics.py +0 -0
  23. {fastmapy-0.1.1 → fastmapy-0.2.0}/fastmap/plots.py +0 -0
  24. {fastmapy-0.1.1 → fastmapy-0.2.0}/setup.cfg +0 -0
  25. {fastmapy-0.1.1 → fastmapy-0.2.0}/setup.py +0 -0
  26. {fastmapy-0.1.1 → fastmapy-0.2.0}/tests/test_distances.py +0 -0
  27. {fastmapy-0.1.1 → fastmapy-0.2.0}/tests/test_fastmap.py +0 -0
  28. {fastmapy-0.1.1 → fastmapy-0.2.0}/tests/test_metrics.py +0 -0
  29. {fastmapy-0.1.1 → fastmapy-0.2.0}/utils/__init__.py +0 -0
  30. {fastmapy-0.1.1 → fastmapy-0.2.0}/utils/_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastMapy
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -154,5 +154,18 @@ objects.
154
154
 
155
155
  FastMapy is licensed under the [Apache License, Version 2.0](LICENSE).
156
156
 
157
+ ## Model persistence
158
+
159
+ Save a fitted model and restore it later with the versioned persistence API:
160
+
161
+ ```python
162
+ model.save("jaccard.fastmap")
163
+ restored = fastmap.FastMap.load("jaccard.fastmap")
164
+ embedding = restored.transform(new_strings)
165
+ ```
166
+
167
+ Model files use Python pickle and must only be loaded from trusted sources. Custom distance classes and object
168
+ transformers must be importable module-level objects in the environment that loads the model.
169
+
157
170
  ## References
158
171
  <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)
@@ -23,5 +23,6 @@ fastmap/distances/_lev.py
23
23
  tests/test_distances.py
24
24
  tests/test_fastmap.py
25
25
  tests/test_metrics.py
26
+ tests/test_persistence.py
26
27
  utils/__init__.py
27
28
  utils/_tools.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastMapy
3
- Version: 0.1.1
3
+ Version: 0.2.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
@@ -154,5 +154,18 @@ objects.
154
154
 
155
155
  FastMapy is licensed under the [Apache License, Version 2.0](LICENSE).
156
156
 
157
+ ## Model persistence
158
+
159
+ Save a fitted model and restore it later with the versioned persistence API:
160
+
161
+ ```python
162
+ model.save("jaccard.fastmap")
163
+ restored = fastmap.FastMap.load("jaccard.fastmap")
164
+ embedding = restored.transform(new_strings)
165
+ ```
166
+
167
+ Model files use Python pickle and must only be loaded from trusted sources. Custom distance classes and object
168
+ transformers must be importable module-level objects in the environment that loads the model.
169
+
157
170
  ## References
158
171
  <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)
@@ -117,5 +117,18 @@ objects.
117
117
 
118
118
  FastMapy is licensed under the [Apache License, Version 2.0](LICENSE).
119
119
 
120
+ ## Model persistence
121
+
122
+ Save a fitted model and restore it later with the versioned persistence API:
123
+
124
+ ```python
125
+ model.save("jaccard.fastmap")
126
+ restored = fastmap.FastMap.load("jaccard.fastmap")
127
+ embedding = restored.transform(new_strings)
128
+ ```
129
+
130
+ Model files use Python pickle and must only be loaded from trusted sources. Custom distance classes and object
131
+ transformers must be importable module-level objects in the environment that loads the model.
132
+
120
133
  ## References
121
134
  <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,3 +1,3 @@
1
- __version__ = "0.1.1"
1
+ __version__ = "0.2.0"
2
2
  from ._distances import Distance, InputError
3
3
  from ._fastmap import FastMap
@@ -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
@@ -115,6 +124,113 @@ class FastMap:
115
124
  """Dimensions where batch fitting exhausted its distinct-pair retries."""
116
125
  return tuple(self._pivot_pair_collisions)
117
126
 
127
+ def save(self, path):
128
+ """Persist a fitted model to ``path``.
129
+
130
+ The model is stored with Python pickle in a versioned FastMapy envelope. Only
131
+ load files from trusted sources. Custom distance classes and object transformers
132
+ must be importable module-level objects when the model is loaded.
133
+ """
134
+ if len(self._pivots) != self._dim:
135
+ raise ModelError("Only a fully fitted model can be saved")
136
+
137
+ destination = Path(path)
138
+ if destination.exists() and destination.is_dir():
139
+ raise IsADirectoryError(f"Model path is a directory: {destination}")
140
+
141
+ temporary_path = None
142
+ try:
143
+ with NamedTemporaryFile("wb", dir=destination.parent, delete=False) as temporary:
144
+ temporary_path = Path(temporary.name)
145
+ pickle.dump(
146
+ {
147
+ "format": _MODEL_FORMAT,
148
+ "format_version": _MODEL_FORMAT_VERSION,
149
+ "model": self,
150
+ },
151
+ temporary,
152
+ protocol=pickle.HIGHEST_PROTOCOL,
153
+ )
154
+ temporary.flush()
155
+ os.fsync(temporary.fileno())
156
+ os.replace(temporary_path, destination)
157
+ except (OSError, pickle.PickleError, TypeError, AttributeError) as error:
158
+ raise ModelError(f"Unable to save model to {destination}") from error
159
+ finally:
160
+ if temporary_path is not None and temporary_path.exists():
161
+ temporary_path.unlink()
162
+ return self
163
+
164
+ @classmethod
165
+ def load(cls, path):
166
+ """Load a model saved with :meth:`save`.
167
+
168
+ Pickle loading can execute code, so ``path`` must refer to a model file from a
169
+ trusted source. The persisted format version is checked before returning it.
170
+ """
171
+ source = Path(path)
172
+ try:
173
+ with source.open("rb") as model_file:
174
+ payload = pickle.load(model_file)
175
+ except (OSError, EOFError, pickle.UnpicklingError, AttributeError, ImportError) as error:
176
+ raise ModelError(f"Unable to load model from {source}") from error
177
+
178
+ if not isinstance(payload, dict) or payload.get("format") != _MODEL_FORMAT:
179
+ raise ModelError("File is not a FastMapy model")
180
+ if payload.get("format_version") != _MODEL_FORMAT_VERSION:
181
+ raise ModelError("Unsupported FastMapy model format version")
182
+
183
+ model = payload.get("model")
184
+ if not isinstance(model, cls):
185
+ raise ModelError("Persisted model has an invalid type")
186
+ cls._validate_loaded_model(model)
187
+ return model
188
+
189
+ @staticmethod
190
+ def _validate_loaded_model(model):
191
+ """Reject persisted state that cannot safely support transformation."""
192
+ if not isinstance(getattr(model, "_dim", None), int) or isinstance(model._dim, bool):
193
+ raise ModelError("Persisted model has an invalid dimension")
194
+ if model._dim < 1:
195
+ raise ModelError("Persisted model has an invalid dimension")
196
+ if not isinstance(getattr(model, "_iters", None), int) or model._iters < 1:
197
+ raise ModelError("Persisted model has invalid iteration settings")
198
+ if not isinstance(getattr(model, "_cores", None), int) or model._cores < 1:
199
+ raise ModelError("Persisted model has invalid core settings")
200
+ if not callable(getattr(getattr(model, "_distance", None), "calculate", None)):
201
+ raise ModelError("Persisted model has an invalid distance metric")
202
+ transformer = getattr(model, "_obj_transformer", None)
203
+ if transformer is not None and not callable(transformer):
204
+ raise ModelError("Persisted model has an invalid object transformer")
205
+ if not isinstance(getattr(model, "_pivots", None), list):
206
+ raise ModelError("Persisted model is not fully fitted")
207
+ if len(model._pivots) != model._dim:
208
+ raise ModelError("Persisted model is not fully fitted")
209
+ if not isinstance(getattr(model, "_pivot_pair_collisions", None), list):
210
+ raise ModelError("Persisted model has invalid collision data")
211
+
212
+ for pivot in model._pivots:
213
+ if not isinstance(pivot, Pivots):
214
+ raise ModelError("Persisted model has an invalid pivot")
215
+ if not all(
216
+ isinstance(index, int) and not isinstance(index, bool) and index >= 0
217
+ for index in (pivot.left_index, pivot.right_index)
218
+ ):
219
+ raise ModelError("Persisted model has invalid pivot indexes")
220
+ if not isinstance(pivot.distance, Real):
221
+ raise ModelError("Persisted model has an invalid pivot distance")
222
+ if not all(
223
+ isinstance(projection, np.ndarray) and projection.shape == (model._dim,)
224
+ for projection in (pivot.left_proj, pivot.right_proj)
225
+ ):
226
+ raise ModelError("Persisted model has invalid pivot projections")
227
+
228
+ if not all(
229
+ isinstance(index, int) and not isinstance(index, bool) and 0 <= index < model._dim
230
+ for index in model._pivot_pair_collisions
231
+ ):
232
+ raise ModelError("Persisted model has invalid collision data")
233
+
118
234
  def _compute_proj_i(self, index, pivots, obj, obj_proj):
119
235
  if pivots.distance == 0:
120
236
  return 0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "FastMapy"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Python implementation of the FastMap multidimensional-scaling technique."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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