FastMapy 0.2.0__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.
Files changed (30) hide show
  1. {fastmapy-0.2.0 → fastmapy-0.3.0}/FastMapy.egg-info/PKG-INFO +8 -4
  2. {fastmapy-0.2.0 → fastmapy-0.3.0}/PKG-INFO +8 -4
  3. {fastmapy-0.2.0 → fastmapy-0.3.0}/README.md +6 -0
  4. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/_fastmap.py +48 -3
  5. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/distances/_jaccard.py +9 -1
  6. {fastmapy-0.2.0 → fastmapy-0.3.0}/pyproject.toml +3 -5
  7. {fastmapy-0.2.0 → fastmapy-0.3.0}/tests/test_distances.py +19 -0
  8. {fastmapy-0.2.0 → fastmapy-0.3.0}/tests/test_fastmap.py +19 -0
  9. {fastmapy-0.2.0 → fastmapy-0.3.0}/FastMapy.egg-info/SOURCES.txt +0 -0
  10. {fastmapy-0.2.0 → fastmapy-0.3.0}/FastMapy.egg-info/dependency_links.txt +0 -0
  11. {fastmapy-0.2.0 → fastmapy-0.3.0}/FastMapy.egg-info/requires.txt +0 -0
  12. {fastmapy-0.2.0 → fastmapy-0.3.0}/FastMapy.egg-info/top_level.txt +0 -0
  13. {fastmapy-0.2.0 → fastmapy-0.3.0}/LICENSE +0 -0
  14. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/__init__.py +0 -0
  15. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/_distances.py +0 -0
  16. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/_fastmap_funcs.py +0 -0
  17. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/distances/__init__.py +0 -0
  18. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/distances/_cosine.py +0 -0
  19. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/distances/_helpers.py +0 -0
  20. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/distances/_l1.py +0 -0
  21. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/distances/_l2.py +0 -0
  22. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/distances/_lev.py +0 -0
  23. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/metrics.py +0 -0
  24. {fastmapy-0.2.0 → fastmapy-0.3.0}/fastmap/plots.py +0 -0
  25. {fastmapy-0.2.0 → fastmapy-0.3.0}/setup.cfg +0 -0
  26. {fastmapy-0.2.0 → fastmapy-0.3.0}/setup.py +0 -0
  27. {fastmapy-0.2.0 → fastmapy-0.3.0}/tests/test_metrics.py +0 -0
  28. {fastmapy-0.2.0 → fastmapy-0.3.0}/tests/test_persistence.py +0 -0
  29. {fastmapy-0.2.0 → fastmapy-0.3.0}/utils/__init__.py +0 -0
  30. {fastmapy-0.2.0 → 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.2.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.9
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastMapy
3
- Version: 0.2.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.9
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:
@@ -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:
@@ -34,7 +34,16 @@ class Pivots:
34
34
 
35
35
 
36
36
  class FastMap:
37
- def __init__(self, dim, distance, dist_args=None, obj_transformer=None, iters=5, cores=1):
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
+ ):
38
47
  if not isinstance(dim, int) or isinstance(dim, bool) or dim < 1:
39
48
  raise ValueError("dim must be a positive integer")
40
49
  if not callable(distance):
@@ -45,6 +54,8 @@ class FastMap:
45
54
  raise TypeError("obj_transformer must be callable or None")
46
55
  if not isinstance(iters, int) or isinstance(iters, bool) or iters < 1:
47
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")
48
59
 
49
60
  self._dim = dim
50
61
  self._distance = distance(**(dist_args or {}))
@@ -52,6 +63,8 @@ class FastMap:
52
63
  self._iters = iters
53
64
  self._pivots: list[Pivots] = []
54
65
  self._pivot_pair_collisions: list[int] = []
66
+ self._cache_distances = cache_distances
67
+ self._distance_cache = {}
55
68
  self.cores = cores
56
69
 
57
70
  @classmethod
@@ -66,6 +79,7 @@ class FastMap:
66
79
  iters=5,
67
80
  cores=1,
68
81
  pair_retries=10,
82
+ cache_distances=False,
69
83
  ):
70
84
  """Fit several distinct models while avoiding repeated pivot pairs.
71
85
 
@@ -85,7 +99,8 @@ class FastMap:
85
99
  raise ValueError("count cannot exceed the number of training objects")
86
100
 
87
101
  models = [
88
- cls(dim, distance, dist_args, obj_transformer, iters, cores) for _ in range(count)
102
+ cls(dim, distance, dist_args, obj_transformer, iters, cores, cache_distances)
103
+ for _ in range(count)
89
104
  ]
90
105
  for model in models:
91
106
  model._validate_training_data(X)
@@ -108,6 +123,11 @@ class FastMap:
108
123
  def iters(self):
109
124
  return self._iters
110
125
 
126
+ @property
127
+ def cache_distances(self):
128
+ """Whether this model memoizes exact object-pair metric evaluations."""
129
+ return self._cache_distances
130
+
111
131
  @property
112
132
  def cores(self):
113
133
  return self._cores
@@ -249,10 +269,34 @@ class FastMap:
249
269
  return x_proj
250
270
 
251
271
  def _dist(self, x, x_proj, y, y_proj, index):
252
- d_sq = pow(self._distance.calculate(x, y), 2)
272
+ d_sq = pow(self._metric_distance(x, y), 2)
253
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])])
254
274
  return sqrt(max(d_sq - diff_sq, 0))
255
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
+
256
300
  def fit(self, X):
257
301
  try:
258
302
  X = list(X)
@@ -299,6 +343,7 @@ class FastMap:
299
343
  X = [self._obj_transformer(x) for x in X]
300
344
  self._pivots = []
301
345
  self._pivot_pair_collisions = []
346
+ self.clear_distance_cache()
302
347
  return X
303
348
 
304
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 shingler(value, shingle_size=shingle_size)
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.2.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.9"
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 = "py39"
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"):
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