event2vector 0.3.0__tar.gz → 0.3.1__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. event2vector-0.3.1/CHANGELOG.md +39 -0
  2. {event2vector-0.3.0 → event2vector-0.3.1}/MANIFEST.in +1 -0
  3. {event2vector-0.3.0 → event2vector-0.3.1}/PKG-INFO +6 -5
  4. {event2vector-0.3.0 → event2vector-0.3.1}/README.md +1 -1
  5. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/__init__.py +1 -1
  6. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/geometries.py +4 -0
  7. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/models.py +12 -1
  8. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector.egg-info/PKG-INFO +6 -5
  9. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector.egg-info/SOURCES.txt +2 -2
  10. {event2vector-0.3.0 → event2vector-0.3.1}/pyproject.toml +5 -4
  11. event2vector-0.3.1/tests/test_device.py +75 -0
  12. event2vector-0.3.0/tests/test_datasets.py +0 -82
  13. event2vector-0.3.0/tests/test_models.py +0 -360
  14. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/baselines.py +0 -0
  15. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/data.py +0 -0
  16. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/datasets/__init__.py +0 -0
  17. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/datasets/arithmetic.py +0 -0
  18. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/datasets/brown.py +0 -0
  19. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/datasets/decay.py +0 -0
  20. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/datasets/groups.py +0 -0
  21. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/datasets/lifepath.py +0 -0
  22. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/datasets/realdata.py +0 -0
  23. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/eval/__init__.py +0 -0
  24. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/eval/figures.py +0 -0
  25. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/eval/harness.py +0 -0
  26. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector/metrics.py +0 -0
  27. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector.egg-info/dependency_links.txt +0 -0
  28. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector.egg-info/requires.txt +0 -0
  29. {event2vector-0.3.0 → event2vector-0.3.1}/event2vector.egg-info/top_level.txt +0 -0
  30. {event2vector-0.3.0 → event2vector-0.3.1}/setup.cfg +0 -0
@@ -0,0 +1,39 @@
1
+ # Changelog
2
+
3
+ All notable changes to `event2vector` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.3.1] - 2026-10-11
8
+
9
+ ### Fixed
10
+
11
+ - `Event2Vec(geometry="rotation")` no longer crashes on Apple Silicon with
12
+ `NotImplementedError: The operator 'aten::linalg_matrix_exp' is not currently
13
+ implemented for the MPS device`. `RotationModel` now computes the matrix
14
+ exponential on the CPU when its input is on MPS and moves the result back, so
15
+ `device="mps"`, a bare `RotationModel` and a `ProductModel` with a rotation
16
+ factor all work.
17
+ - The project links on PyPI pointed to `github.com/sulcantonin/event2vector`,
18
+ which does not exist; they now point to
19
+ [`github.com/sulcantonin/event2vec_public`](https://github.com/sulcantonin/event2vec_public).
20
+
21
+ ### Changed
22
+
23
+ - When the device is chosen automatically and MPS would be picked, the
24
+ `rotation` geometry runs on the CPU instead and emits a `RuntimeWarning`. On
25
+ MPS every step round-trips through the CPU, which made training roughly 8–14×
26
+ slower than on the CPU alone for this model. Pass `device="mps"` to use MPS
27
+ anyway. CUDA and the other geometries are unaffected.
28
+
29
+ ### Added
30
+
31
+ - Tests for the automatic device selection (`tests/test_device.py`).
32
+ - This changelog, linked from the PyPI project page.
33
+
34
+ ## [0.3.0] - 2026-07-21
35
+
36
+ Earlier releases are listed on [PyPI](https://pypi.org/project/event2vector/#history).
37
+
38
+ [0.3.1]: https://pypi.org/project/event2vector/0.3.1/
39
+ [0.3.0]: https://pypi.org/project/event2vector/0.3.0/
@@ -1,4 +1,5 @@
1
1
  include README.md
2
+ include CHANGELOG.md
2
3
  exclude *.pkl
3
4
  exclude *.model
4
5
  exclude data/*
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: event2vector
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Scikit-learn-style geometric embeddings for event sequences.
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/sulcantonin/event2vector
8
- Project-URL: Repository, https://github.com/sulcantonin/event2vector
9
- Project-URL: Issues, https://github.com/sulcantonin/event2vector/issues
7
+ Project-URL: Homepage, https://github.com/sulcantonin/event2vec_public
8
+ Project-URL: Repository, https://github.com/sulcantonin/event2vec_public
9
+ Project-URL: Issues, https://github.com/sulcantonin/event2vec_public/issues
10
+ Project-URL: Changelog, https://github.com/sulcantonin/event2vec_public/blob/main/CHANGELOG.md
10
11
  Keywords: sequence-embedding,event-sequences,representation-learning,word2vec,scikit-learn,time-series
11
12
  Classifier: Programming Language :: Python :: 3
12
13
  Classifier: Operating System :: OS Independent
@@ -132,7 +133,7 @@ Key methods:
132
133
  - `most_similar`: gensim-style nearest-neighbor lookup over learned event embeddings using tokens or full sequences as queries.
133
134
  - `pad_sequences=True`: enables fully vectorized batches with masking for substantial throughput gains on large corpora.
134
135
 
135
- Device control: set `use_gpu=False` to force CPU even if CUDA/MPS is present, or pass an explicit `device` (e.g., `"cuda:0"` or `"cpu"`).
136
+ Device control: set `use_gpu=False` to force CPU even if CUDA/MPS is present, or pass an explicit `device` (e.g., `"cuda:0"` or `"cpu"`). On Apple Silicon the `rotation` geometry defaults to the CPU, because MPS has no `matrix_exp` kernel and the CPU is faster for it; pass `device="mps"` to override.
136
137
 
137
138
  ## Geometries (v0.2)
138
139
 
@@ -98,7 +98,7 @@ Key methods:
98
98
  - `most_similar`: gensim-style nearest-neighbor lookup over learned event embeddings using tokens or full sequences as queries.
99
99
  - `pad_sequences=True`: enables fully vectorized batches with masking for substantial throughput gains on large corpora.
100
100
 
101
- Device control: set `use_gpu=False` to force CPU even if CUDA/MPS is present, or pass an explicit `device` (e.g., `"cuda:0"` or `"cpu"`).
101
+ Device control: set `use_gpu=False` to force CPU even if CUDA/MPS is present, or pass an explicit `device` (e.g., `"cuda:0"` or `"cpu"`). On Apple Silicon the `rotation` geometry defaults to the CPU, because MPS has no `matrix_exp` kernel and the CPU is faster for it; pass `device="mps"` to override.
102
102
 
103
103
  ## Geometries (v0.2)
104
104
 
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.0"
1
+ __version__ = "0.3.1"
2
2
 
3
3
  from .models import EuclideanModel, HyperbolicModel, HyperbolicUtils, Event2Vec
4
4
  from .geometries import (
@@ -216,6 +216,10 @@ class RotationModel(nn.Module):
216
216
  a = torch.zeros(b, self.n, self.n, device=gen.device, dtype=gen.dtype)
217
217
  a[:, self.triu_i, self.triu_j] = gen
218
218
  a = a - a.transpose(1, 2)
219
+ if a.device.type == "mps":
220
+ # MPS has no matrix_exp kernel (pytorch/pytorch#141287); compute it on
221
+ # the CPU and move it back. Both transfers are differentiable.
222
+ return torch.matrix_exp(a.cpu()).to(a.device)
219
223
  return torch.matrix_exp(a)
220
224
 
221
225
  def zero_state(self, batch, device):
@@ -249,7 +249,18 @@ class Event2Vec:
249
249
  if use_gpu and torch.cuda.is_available():
250
250
  device = torch.device("cuda")
251
251
  elif use_gpu and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
252
- device = torch.device("mps")
252
+ if self.geometry == "rotation":
253
+ # MPS has no matrix_exp, so every rotation step would round-trip
254
+ # through the CPU; for a model this small the CPU is much faster.
255
+ warnings.warn(
256
+ "geometry='rotation' needs torch.matrix_exp, which MPS does not "
257
+ "implement; using device='cpu'. Pass device='mps' to run on MPS "
258
+ "anyway (matrix_exp is then computed on the CPU).",
259
+ RuntimeWarning,
260
+ )
261
+ device = torch.device("cpu")
262
+ else:
263
+ device = torch.device("mps")
253
264
  else:
254
265
  device = torch.device("cpu")
255
266
  else:
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: event2vector
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Scikit-learn-style geometric embeddings for event sequences.
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/sulcantonin/event2vector
8
- Project-URL: Repository, https://github.com/sulcantonin/event2vector
9
- Project-URL: Issues, https://github.com/sulcantonin/event2vector/issues
7
+ Project-URL: Homepage, https://github.com/sulcantonin/event2vec_public
8
+ Project-URL: Repository, https://github.com/sulcantonin/event2vec_public
9
+ Project-URL: Issues, https://github.com/sulcantonin/event2vec_public/issues
10
+ Project-URL: Changelog, https://github.com/sulcantonin/event2vec_public/blob/main/CHANGELOG.md
10
11
  Keywords: sequence-embedding,event-sequences,representation-learning,word2vec,scikit-learn,time-series
11
12
  Classifier: Programming Language :: Python :: 3
12
13
  Classifier: Operating System :: OS Independent
@@ -132,7 +133,7 @@ Key methods:
132
133
  - `most_similar`: gensim-style nearest-neighbor lookup over learned event embeddings using tokens or full sequences as queries.
133
134
  - `pad_sequences=True`: enables fully vectorized batches with masking for substantial throughput gains on large corpora.
134
135
 
135
- Device control: set `use_gpu=False` to force CPU even if CUDA/MPS is present, or pass an explicit `device` (e.g., `"cuda:0"` or `"cpu"`).
136
+ Device control: set `use_gpu=False` to force CPU even if CUDA/MPS is present, or pass an explicit `device` (e.g., `"cuda:0"` or `"cpu"`). On Apple Silicon the `rotation` geometry defaults to the CPU, because MPS has no `matrix_exp` kernel and the CPU is faster for it; pass `device="mps"` to override.
136
137
 
137
138
  ## Geometries (v0.2)
138
139
 
@@ -1,3 +1,4 @@
1
+ CHANGELOG.md
1
2
  MANIFEST.in
2
3
  README.md
3
4
  pyproject.toml
@@ -22,5 +23,4 @@ event2vector/datasets/realdata.py
22
23
  event2vector/eval/__init__.py
23
24
  event2vector/eval/figures.py
24
25
  event2vector/eval/harness.py
25
- tests/test_datasets.py
26
- tests/test_models.py
26
+ tests/test_device.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "event2vector"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Scikit-learn-style geometric embeddings for event sequences."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -63,9 +63,10 @@ dev = [
63
63
  ]
64
64
 
65
65
  [project.urls]
66
- Homepage = "https://github.com/sulcantonin/event2vector"
67
- Repository = "https://github.com/sulcantonin/event2vector"
68
- Issues = "https://github.com/sulcantonin/event2vector/issues"
66
+ Homepage = "https://github.com/sulcantonin/event2vec_public"
67
+ Repository = "https://github.com/sulcantonin/event2vec_public"
68
+ Issues = "https://github.com/sulcantonin/event2vec_public/issues"
69
+ Changelog = "https://github.com/sulcantonin/event2vec_public/blob/main/CHANGELOG.md"
69
70
 
70
71
  [tool.setuptools.packages.find]
71
72
  include = ["event2vector*"]
@@ -0,0 +1,75 @@
1
+ """Device-selection tests: the default constructor must work on every backend.
2
+
3
+ The rest of the suite pins ``use_gpu=False``, so these are the tests that exercise
4
+ the estimator's automatic CUDA / MPS / CPU choice. Run with `pytest tests/ -q`.
5
+ """
6
+ import numpy as np
7
+ import pytest
8
+ import torch
9
+
10
+ from event2vector import Event2Vec, ProductModel, RotationModel, StereographicModel
11
+
12
+ MPS_AVAILABLE = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
13
+
14
+ TINY_SEQS = [[0, 1, 2], [2, 1, 0, 1], [1, 2], [0, 2, 2, 1]]
15
+
16
+
17
+ @pytest.mark.filterwarnings("ignore:geometry='rotation' needs torch.matrix_exp")
18
+ def test_rotation_fits_with_default_device():
19
+ """Event2Vec(geometry="rotation") with no device arguments must fit on any machine
20
+ (it used to raise NotImplementedError for aten::linalg_matrix_exp on Apple Silicon)."""
21
+ est = Event2Vec(num_event_types=3, geometry="rotation", rotation_n=3,
22
+ num_epochs=2, batch_size=2, random_state=0)
23
+ assert est.device.type != "mps"
24
+ est.fit(TINY_SEQS)
25
+
26
+ assert est.is_fitted_
27
+ assert np.isfinite(est.training_loss_).all()
28
+ emb = est.transform(TINY_SEQS)
29
+ assert emb.shape == (len(TINY_SEQS), 9) # flattened 3x3 rotation
30
+ assert np.isfinite(emb).all()
31
+
32
+
33
+ def test_auto_device_skips_mps_for_rotation(monkeypatch):
34
+ """When MPS would be auto-selected, rotation falls back to CPU with a warning.
35
+ Simulated so it runs on any platform; the model never touches MPS."""
36
+ monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
37
+ monkeypatch.setattr(torch.backends.mps, "is_available", lambda: True)
38
+ with pytest.warns(RuntimeWarning, match="matrix_exp"):
39
+ est = Event2Vec(num_event_types=3, geometry="rotation")
40
+ assert est.device.type == "cpu"
41
+
42
+
43
+ @pytest.mark.skipif(not MPS_AVAILABLE, reason="needs an MPS device")
44
+ def test_rotation_runs_on_explicit_mps():
45
+ """device="mps" is honoured: matrix_exp runs on the CPU and the result (and its
46
+ gradient) matches a pure-CPU computation."""
47
+ torch.manual_seed(0)
48
+ gen = torch.randn(5, 3)
49
+ cpu_model = RotationModel(3, n=3)
50
+ mps_model = RotationModel(3, n=3).to("mps")
51
+
52
+ g_cpu = gen.clone().requires_grad_()
53
+ r_cpu = cpu_model._rot(g_cpu)
54
+ (r_cpu ** 3).sum().backward()
55
+ g_mps = gen.clone().to("mps").requires_grad_()
56
+ r_mps = mps_model._rot(g_mps)
57
+ (r_mps ** 3).sum().backward()
58
+
59
+ assert r_mps.device.type == "mps"
60
+ assert torch.allclose(r_cpu, r_mps.cpu(), atol=1e-5)
61
+ assert torch.allclose(g_cpu.grad, g_mps.grad.cpu(), atol=1e-5)
62
+
63
+ # a product manifold with a rotation factor goes through the same path
64
+ product = ProductModel([
65
+ RotationModel(3, n=3),
66
+ StereographicModel(3, embedding_dim=4, kappa=-1.0),
67
+ ]).to("mps")
68
+ logits, _, _ = product(torch.tensor([0, 1, 2], device="mps"))
69
+ assert torch.isfinite(logits).all()
70
+
71
+ est = Event2Vec(num_event_types=3, geometry="rotation", device="mps",
72
+ num_epochs=2, batch_size=2, random_state=0)
73
+ est.fit(TINY_SEQS)
74
+ assert np.isfinite(est.training_loss_).all()
75
+
@@ -1,82 +0,0 @@
1
- """Ground-truth tests for the centerpiece datasets.
2
-
3
- These lock the exact structure the paper's claims rely on: decay operators that
4
- sum to the known transmutation, and group abelianizations that match the known
5
- algebra. No model training required.
6
- """
7
- import numpy as np
8
-
9
- from event2vector.datasets import make_decay, make_group
10
- from event2vector.datasets.decay import GROUND_TRUTH
11
- from event2vector.metrics import _multiset_ceiling
12
-
13
-
14
- # --------------------------------------------------------------------------- #
15
- # Physics: nuclear decay
16
- # --------------------------------------------------------------------------- #
17
- def test_decay_ground_truth_matches_physics():
18
- d = make_decay(num_seq=200, seed=0)
19
- gt = d["ground_truth"]
20
- for mode_id, dzdn in gt.items():
21
- name = d["inv_vocab"][mode_id]
22
- assert tuple(dzdn) == GROUND_TRUTH[name]
23
- # the canonical decays present must include alpha and beta_minus
24
- assert "alpha" in d["vocab"] and "beta_minus" in d["vocab"]
25
-
26
-
27
- def test_canonical_series_sum_to_known_transmutation():
28
- d = make_decay(num_seq=50, seed=0)
29
- gt = d["ground_truth"]
30
- expected = {"thorium_4n": (-8, -16), "uranium_4n+2": (-10, -22)}
31
- for name, c in d["canonical"].items():
32
- net = tuple(np.sum([gt[t] for t in c["tokens"]], axis=0))
33
- assert net == c["net_dZdN"] == expected[name]
34
-
35
-
36
- def test_extended_decay_modes_present_and_exact():
37
- d = make_decay(num_seq=6000, seed=0)
38
- vocab = d["vocab"]
39
- # the richer particle set is exercised by the generator
40
- for m in ["electron_capture", "double_beta", "cluster_c14",
41
- "proton_emission", "neutron_emission"]:
42
- assert m in vocab, f"{m} not generated"
43
- assert GROUND_TRUTH["cluster_c14"] == (-6, -8)
44
- # electron capture and beta+ are physically distinct but share the operator
45
- assert GROUND_TRUTH["electron_capture"] == GROUND_TRUTH["beta_plus"] == (-1, 1)
46
-
47
-
48
- # --------------------------------------------------------------------------- #
49
- # Math: group sequences
50
- # --------------------------------------------------------------------------- #
51
- def test_symmetric_group_abelianization_is_z2():
52
- for name, n_el in [("s3", 6), ("s4", 24)]:
53
- r = make_group(name, num_seq=300, max_len=8, seed=0)
54
- assert r["is_abelian"] is False
55
- assert r["n_elements"] == n_el
56
- assert r["n_abelian"] == 2 # S_n abelianization = Z_2 (sign)
57
-
58
-
59
- def test_abelian_groups_element_equals_abelianization():
60
- for name, n_el in [("z12", 12), ("z6xz4", 24)]:
61
- r = make_group(name, num_seq=300, max_len=8, seed=0)
62
- assert r["is_abelian"] is True
63
- assert r["n_elements"] == n_el
64
- assert r["n_abelian"] == n_el # abelianization is the whole group
65
-
66
-
67
- def test_multiset_ceiling_abelian_vs_nonabelian():
68
- n_gen = 3
69
- # Abelian (Z6xZ4): the multiset determines the element exactly -> ceiling 1.0
70
- z = make_group("z6xz4", num_seq=4000, max_len=10, seed=0)
71
- seqs_z = z["processed_sequences"]
72
- ng_z = len(z["vocab"])
73
- assert _multiset_ceiling(seqs_z, z["sequence_labels_element"], ng_z) > 0.999
74
- # Non-abelian (S4): abelianization is multiset-determined (1.0) but the full
75
- # element is not (well below 1).
76
- s = make_group("s4", num_seq=4000, max_len=10, seed=0)
77
- seqs_s = s["processed_sequences"]
78
- ng_s = len(s["vocab"])
79
- abel_ceil = _multiset_ceiling(seqs_s, s["sequence_labels_abelian"], ng_s)
80
- elem_ceil = _multiset_ceiling(seqs_s, s["sequence_labels_element"], ng_s)
81
- assert abel_ceil > 0.999
82
- assert elem_ceil < 0.8
@@ -1,360 +0,0 @@
1
- """Journal-grade correctness tests for the Event2Vec estimator and models.
2
-
3
- Each test locks one property asserted by the paper or fixed during the TMLR
4
- revision. Run with `pytest tests/ -q`.
5
- """
6
- import math
7
-
8
- import numpy as np
9
- import pytest
10
- import torch
11
-
12
- from event2vector import Event2Vec, EuclideanModel, HyperbolicModel, HyperbolicUtils
13
- from event2vector import (
14
- HeisenbergModel,
15
- ProductModel,
16
- RotationModel,
17
- StereographicModel,
18
- StereographicUtils,
19
- )
20
-
21
-
22
- # --------------------------------------------------------------------------- #
23
- # Fixtures / helpers
24
- # --------------------------------------------------------------------------- #
25
- def structured_sequences():
26
- """A small, strongly-patterned dataset (learnable, order-bearing)."""
27
- base = [
28
- [0, 1, 2, 3],
29
- [0, 1, 2, 4],
30
- [0, 2, 3, 4],
31
- [0, 1, 3, 4],
32
- [0, 2, 1, 4],
33
- ]
34
- return [torch.tensor(s, dtype=torch.long) for s in base for _ in range(8)]
35
-
36
-
37
- def euclid(**kw):
38
- defaults = dict(
39
- num_event_types=5,
40
- embedding_dim=16,
41
- dropout_p=0.0,
42
- num_epochs=4,
43
- batch_size=8,
44
- random_state=0,
45
- use_gpu=False,
46
- )
47
- defaults.update(kw)
48
- return Event2Vec(**defaults)
49
-
50
-
51
- def hyper(**kw):
52
- defaults = dict(
53
- num_event_types=5,
54
- embedding_dim=16,
55
- geometry="hyperbolic",
56
- curvature=1.0,
57
- dropout_p=0.0,
58
- num_epochs=4,
59
- batch_size=8,
60
- random_state=0,
61
- use_gpu=False,
62
- )
63
- defaults.update(kw)
64
- return Event2Vec(**defaults)
65
-
66
-
67
- # --------------------------------------------------------------------------- #
68
- # Shape / dtype / API
69
- # --------------------------------------------------------------------------- #
70
- def test_transform_shape_and_dtype():
71
- m = euclid().fit(structured_sequences())
72
- out = m.transform([[0, 1, 2], [0, 2, 3, 4]])
73
- assert out.shape == (2, 16)
74
- assert out.dtype == np.float32
75
-
76
-
77
- def test_accepts_list_and_tensor_inputs():
78
- seqs_t = structured_sequences()
79
- seqs_l = [s.tolist() for s in seqs_t]
80
- euclid().fit(seqs_t)
81
- euclid().fit(seqs_l) # should not raise
82
-
83
-
84
- # --------------------------------------------------------------------------- #
85
- # Linear additive hypothesis
86
- # --------------------------------------------------------------------------- #
87
- def test_additivity_exact_when_clip_off():
88
- """With clipping off and dropout off, the encoded state is *exactly* the
89
- sum of constituent embeddings (the linear additive hypothesis, proved by
90
- measurement)."""
91
- m = euclid(max_norm=1e12).fit(structured_sequences())
92
- emb = m.model.embedding.weight.detach()
93
- seq = [0, 1, 2, 3]
94
- h = m.transform([seq], as_numpy=False)[0]
95
- ideal = emb[seq].sum(dim=0)
96
- assert torch.allclose(h, ideal, atol=1e-5)
97
- cos = torch.nn.functional.cosine_similarity(h[None], ideal[None]).item()
98
- assert cos > 1 - 1e-5
99
-
100
-
101
- def test_additivity_is_order_invariant_euclidean():
102
- """New Result 1: the ideal Euclidean endpoint is permutation invariant."""
103
- m = euclid(max_norm=1e12).fit(structured_sequences())
104
- a = m.transform([[0, 1, 2, 3]], as_numpy=False)[0]
105
- b = m.transform([[3, 1, 0, 2]], as_numpy=False)[0]
106
- assert torch.allclose(a, b, atol=1e-5)
107
-
108
-
109
- # --------------------------------------------------------------------------- #
110
- # Reconstruction loss reaches zero at the ideal update (both geometries)
111
- # --------------------------------------------------------------------------- #
112
- def _one_step_losses(m, h_old):
113
- m.model.eval() # disable dropout: increment == raw embedding
114
- x = torch.tensor([1])
115
- target = torch.tensor([2])
116
- y1, h1, e1 = m.model(x, h_old)
117
- _, h2, _ = m.model(x, h_old)
118
- return m._compute_losses(y1, h1, e1, h_old, target, h2)
119
-
120
-
121
- def test_reconstruction_zero_at_ideal_euclidean():
122
- m = euclid(max_norm=1e12)
123
- _, recon, consist = _one_step_losses(m, torch.zeros(1, 16))
124
- assert recon.item() < 1e-10
125
- assert consist.item() < 1e-10 # eval mode => identical passes
126
-
127
-
128
- def test_reconstruction_zero_at_ideal_hyperbolic():
129
- """The corrected left-cancellation target makes L_recon reach ~0 at the
130
- ideal Möbius update (the old right-cancellation form could not)."""
131
- m = hyper()
132
- _, recon, _ = _one_step_losses(m, torch.zeros(1, 16))
133
- assert recon.item() < 1e-6
134
-
135
-
136
- # --------------------------------------------------------------------------- #
137
- # Hyperbolic / Möbius algebra
138
- # --------------------------------------------------------------------------- #
139
- def test_mobius_identity():
140
- x = torch.randn(4, 8) * 0.1
141
- z = torch.zeros(4, 8)
142
- assert torch.allclose(HyperbolicUtils.mobius_add(x, z, 1.0), x, atol=1e-5)
143
-
144
-
145
- def test_mobius_left_cancellation():
146
- """(-x) ⊕ (x ⊕ y) == y — the law the corrected recon loss relies on."""
147
- x = torch.randn(4, 8) * 0.1
148
- y = torch.randn(4, 8) * 0.1
149
- lhs = HyperbolicUtils.mobius_add(-x, HyperbolicUtils.mobius_add(x, y, 1.0), 1.0)
150
- assert torch.allclose(lhs, y, atol=1e-4)
151
-
152
-
153
- def test_project_to_ball_respects_radius():
154
- c = 1.0
155
- big = torch.randn(16, 8) * 100.0
156
- proj = HyperbolicUtils.project_to_ball(big, c)
157
- norms = proj.norm(dim=-1)
158
- assert torch.all(norms < 1.0 / math.sqrt(c))
159
-
160
-
161
- # --------------------------------------------------------------------------- #
162
- # Hyperbolic init is NOT clobbered to std=0.02 (P0-1)
163
- # --------------------------------------------------------------------------- #
164
- def test_hyperbolic_init_near_origin():
165
- model = HyperbolicModel(num_event_types=5, embedding_dim=64, dropout_p=0.0, c=1.0)
166
- norms = model.embedding.weight.detach().norm(dim=-1)
167
- # tiny uniform(-1e-3,1e-3) over d=64 => norm ~ 0.008, far below a std=0.02
168
- # normal (~0.16) and well inside the ball.
169
- assert norms.max().item() < 0.05
170
-
171
-
172
- def test_estimator_reset_keeps_hyperbolic_init_tiny():
173
- m = hyper(embedding_dim=64)
174
- m._reset_parameters()
175
- norms = m.model.embedding.weight.detach().norm(dim=-1)
176
- assert norms.max().item() < 0.05
177
-
178
-
179
- # --------------------------------------------------------------------------- #
180
- # Consistency loss is active iff dropout > 0 (P0-3)
181
- # --------------------------------------------------------------------------- #
182
- def test_consistency_loss_active_with_dropout():
183
- m = euclid(dropout_p=0.3)
184
- m.model.train()
185
- x = torch.tensor([1])
186
- h_old = torch.zeros(1, 16)
187
- _, h1, _ = m.model(x, h_old)
188
- _, h2, _ = m.model(x, h_old)
189
- assert m.mse_loss(h1, h2).item() > 0.0
190
-
191
-
192
- def test_consistency_loss_zero_without_dropout():
193
- m = euclid(dropout_p=0.0)
194
- m.model.train()
195
- x = torch.tensor([1])
196
- h_old = torch.zeros(1, 16)
197
- _, h1, _ = m.model(x, h_old)
198
- _, h2, _ = m.model(x, h_old)
199
- assert m.mse_loss(h1, h2).item() == 0.0
200
-
201
-
202
- # --------------------------------------------------------------------------- #
203
- # Training actually reduces the loss
204
- # --------------------------------------------------------------------------- #
205
- @pytest.mark.parametrize("geom", ["euclidean", "hyperbolic"])
206
- def test_fit_improves_loss(geom):
207
- builder = euclid if geom == "euclidean" else hyper
208
- m = builder(num_epochs=40, learning_rate=1e-2).fit(structured_sequences())
209
- assert m.training_loss_[-1] < m.training_loss_[0]
210
-
211
-
212
- # --------------------------------------------------------------------------- #
213
- # Determinism given a seed (CPU)
214
- # --------------------------------------------------------------------------- #
215
- def test_determinism_given_seed_cpu():
216
- seqs = structured_sequences()
217
- a = euclid(dropout_p=0.2, num_epochs=6, random_state=123).fit(seqs)
218
- b = euclid(dropout_p=0.2, num_epochs=6, random_state=123).fit(seqs)
219
- assert np.allclose(a.training_loss_, b.training_loss_)
220
- assert np.allclose(
221
- a.model.embedding.weight.detach().numpy(),
222
- b.model.embedding.weight.detach().numpy(),
223
- )
224
-
225
-
226
- # --------------------------------------------------------------------------- #
227
- # padded vs unpadded must agree at inference (P2-8: only the loss differed)
228
- # --------------------------------------------------------------------------- #
229
- def test_padded_equals_unpadded_embeddings():
230
- m = euclid(num_epochs=3).fit(structured_sequences())
231
- seq = [0, 1, 2, 3, 4]
232
- a = m.transform([seq], pad_sequences=True, as_numpy=False)[0]
233
- b = m.transform([seq], pad_sequences=False, as_numpy=False)[0]
234
- assert torch.allclose(a, b, atol=1e-5)
235
-
236
-
237
- # --------------------------------------------------------------------------- #
238
- # Curvature validation + optional Riemannian optimization
239
- # --------------------------------------------------------------------------- #
240
- def test_curvature_must_be_positive():
241
- with pytest.raises(ValueError):
242
- Event2Vec(num_event_types=5, geometry="hyperbolic", curvature=-1.0)
243
- with pytest.raises(ValueError):
244
- Event2Vec(num_event_types=5, geometry="hyperbolic", curvature=0.0)
245
-
246
-
247
- def test_riemannian_uses_manifold_parameter():
248
- geoopt = pytest.importorskip("geoopt")
249
- m = hyper(riemannian=True).fit(structured_sequences())
250
- assert isinstance(m.model.embedding.weight, geoopt.ManifoldParameter)
251
- norms = m.model.embedding.weight.detach().norm(dim=-1)
252
- assert torch.all(norms < 1.0) # stays inside the ball
253
-
254
-
255
- def test_most_similar_returns_topn():
256
- m = euclid().fit(structured_sequences())
257
- res = m.most_similar(positive=1, topn=3)
258
- assert len(res) == 3
259
- assert all(isinstance(i, int) and isinstance(v, float) for i, v in res)
260
-
261
-
262
- # --------------------------------------------------------------------------- #
263
- # v0.2 geometries: stereographic (kappa), rotation, Heisenberg, product
264
- # --------------------------------------------------------------------------- #
265
- def _geo(geometry, **kw):
266
- defaults = dict(num_event_types=5, embedding_dim=8, dropout_p=0.0, num_epochs=4,
267
- batch_size=8, pad_sequences=True, random_state=0, use_gpu=False)
268
- defaults.update(kw)
269
- return Event2Vec(geometry=geometry, **defaults)
270
-
271
-
272
- ALL_NEW_GEOMS = [
273
- dict(geometry="spherical", kappa=1.0),
274
- dict(geometry="stereographic", kappa=-0.5),
275
- dict(geometry="rotation", rotation_n=3),
276
- dict(geometry="heisenberg", heisenberg_m=1),
277
- dict(geometry="product", product_kappas=(-1.0, 1.0)),
278
- ]
279
-
280
-
281
- @pytest.mark.parametrize("cfg", ALL_NEW_GEOMS)
282
- def test_new_geometry_fits_and_transforms(cfg):
283
- m = _geo(**cfg).fit(structured_sequences())
284
- emb = m.transform(structured_sequences())
285
- assert emb.shape[1] == m.model.state_dim
286
- assert np.isfinite(emb).all()
287
-
288
-
289
- @pytest.mark.parametrize("cfg", ALL_NEW_GEOMS)
290
- def test_new_geometry_fit_improves_loss(cfg):
291
- # Curved-geometry optimization can overshoot at a high LR (the endpoint may
292
- # bounce), so we lock the honest property: training reduces the loss.
293
- m = _geo(num_epochs=40, learning_rate=5e-3, **cfg).fit(structured_sequences())
294
- assert min(m.training_loss_) < m.training_loss_[0] * 0.98
295
-
296
-
297
- def test_stereographic_kappa0_is_euclidean_addition():
298
- x, y = torch.randn(4, 6), torch.randn(4, 6)
299
- assert torch.allclose(StereographicUtils.add(x, y, 0.0), x + y, atol=1e-6)
300
-
301
-
302
- def test_stereographic_negative_kappa_matches_mobius():
303
- """kappa<0 stereographic addition equals Mobius addition with c=-kappa."""
304
- x, y = torch.randn(4, 6) * 0.1, torch.randn(4, 6) * 0.1
305
- kappa = -1.0
306
- assert torch.allclose(
307
- StereographicUtils.add(x, y, kappa),
308
- HyperbolicUtils.mobius_add(x, y, -kappa),
309
- atol=1e-5,
310
- )
311
-
312
-
313
- @pytest.mark.parametrize("kappa", [-1.0, -0.3, 0.5, 2.0])
314
- def test_stereographic_left_cancellation(kappa):
315
- """(-x) (+)_k (x (+)_k y) == y for every curvature (the recon-loss law)."""
316
- x, y = torch.randn(4, 6) * 0.1, torch.randn(4, 6) * 0.1
317
- add = StereographicUtils.add
318
- assert torch.allclose(add(-x, add(x, y, kappa), kappa), y, atol=1e-4)
319
-
320
-
321
- def test_rotation_state_stays_orthogonal():
322
- m = _geo("rotation", rotation_n=3, num_epochs=3).fit(structured_sequences())
323
- h = m.transform([[0, 1, 2, 3, 4]], as_numpy=False)[0].reshape(3, 3)
324
- assert torch.allclose(h @ h.t(), torch.eye(3), atol=1e-4)
325
-
326
-
327
- @pytest.mark.parametrize("cfg", ALL_NEW_GEOMS)
328
- def test_new_geometry_reconstruction_zero_at_ideal(cfg):
329
- """Each geometry's left-cancellation makes L_recon ~ 0 at the ideal update."""
330
- m = _geo(**cfg)
331
- h0 = m.model.zero_state(1, torch.device("cpu"))
332
- _, recon, _ = _one_step_losses(m, h0)
333
- assert recon.item() < 1e-6
334
-
335
-
336
- def test_heisenberg_carries_order_information():
337
- """The Heisenberg central coordinate distinguishes AB from BA; the additive
338
- (Euclidean) endpoint does not."""
339
- hm = HeisenbergModel(num_event_types=5, m=1)
340
- hm.eval()
341
- ab = hm.forward(torch.tensor([1]), hm.forward(torch.tensor([2]),
342
- hm.zero_state(1, torch.device("cpu")))[1])[1]
343
- ba = hm.forward(torch.tensor([2]), hm.forward(torch.tensor([1]),
344
- hm.zero_state(1, torch.device("cpu")))[1])[1]
345
- # same v (abelian part) but different central z (swept area) => order-sensitive
346
- assert torch.allclose(ab[:, :2], ba[:, :2], atol=1e-5)
347
- assert (ab[:, 2] - ba[:, 2]).abs().item() > 1e-4
348
-
349
-
350
- def test_stereographic_requires_kappa():
351
- with pytest.raises(ValueError):
352
- Event2Vec(num_event_types=5, geometry="stereographic") # kappa missing
353
- with pytest.raises(ValueError):
354
- Event2Vec(num_event_types=5, geometry="spherical", kappa=-1.0) # needs kappa>0
355
-
356
-
357
- def test_product_has_no_single_embedding():
358
- m = _geo("product", product_kappas=(-1.0, 1.0)).fit(structured_sequences())
359
- with pytest.raises(NotImplementedError):
360
- m.most_similar(positive=1)
File without changes