event2vector 0.3.1__tar.gz → 0.4.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. {event2vector-0.3.1 → event2vector-0.4.0}/CHANGELOG.md +25 -0
  2. event2vector-0.4.0/CITATION.cff +26 -0
  3. {event2vector-0.3.1 → event2vector-0.4.0}/MANIFEST.in +1 -0
  4. {event2vector-0.3.1/event2vector.egg-info → event2vector-0.4.0}/PKG-INFO +36 -2
  5. {event2vector-0.3.1 → event2vector-0.4.0}/README.md +35 -1
  6. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/__init__.py +3 -1
  7. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/geometries.py +167 -5
  8. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/models.py +18 -5
  9. {event2vector-0.3.1 → event2vector-0.4.0/event2vector.egg-info}/PKG-INFO +36 -2
  10. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/SOURCES.txt +2 -0
  11. {event2vector-0.3.1 → event2vector-0.4.0}/pyproject.toml +1 -1
  12. event2vector-0.4.0/tests/test_affine.py +97 -0
  13. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/baselines.py +0 -0
  14. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/data.py +0 -0
  15. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/__init__.py +0 -0
  16. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/arithmetic.py +0 -0
  17. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/brown.py +0 -0
  18. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/decay.py +0 -0
  19. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/groups.py +0 -0
  20. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/lifepath.py +0 -0
  21. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/realdata.py +0 -0
  22. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/eval/__init__.py +0 -0
  23. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/eval/figures.py +0 -0
  24. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/eval/harness.py +0 -0
  25. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/metrics.py +0 -0
  26. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/dependency_links.txt +0 -0
  27. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/requires.txt +0 -0
  28. {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/top_level.txt +0 -0
  29. {event2vector-0.3.1 → event2vector-0.4.0}/setup.cfg +0 -0
  30. {event2vector-0.3.1 → event2vector-0.4.0}/tests/test_device.py +0 -0
@@ -4,6 +4,30 @@ All notable changes to `event2vector` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.0] - 2026-10-15
8
+
9
+ ### Added
10
+
11
+ - `geometry="affine"` (`AffineModel`): each event is an invertible affine map
12
+ `x -> exp(M_e) x + v_e` applied on the left of an affine state, so an event can
13
+ act on the history (shrink, stretch or rotate it) instead of only adding to it.
14
+ With `M_e = 0`, the initialisation, the point is exactly the Euclidean sum. The
15
+ step is recovered exactly by the reconstruction loss, and subsequences compose
16
+ into one affine map:
17
+ - `AffineModel.point(state)` returns the plotted point `b` from a state;
18
+ - `AffineModel.compose(event_ids)` returns one `(A, v)` for a subsequence;
19
+ - `AffineModel.override_strength(event_ids)` returns the singular values of its
20
+ linear part.
21
+ - Estimator options `affine_readout` (`"point"` or `"full"`) and
22
+ `affine_max_log_scale` (bounds how much one event can stretch or shrink the state).
23
+ - Tests for the affine geometry (`tests/test_affine.py`).
24
+
25
+ ### Changed
26
+
27
+ - The MPS-safe `matrix_exp` is now a shared helper used by both the `rotation` and
28
+ `affine` geometries, and automatic device selection falls back to the CPU for
29
+ `affine` as it already did for `rotation`.
30
+
7
31
  ## [0.3.1] - 2026-10-11
8
32
 
9
33
  ### Fixed
@@ -35,5 +59,6 @@ All notable changes to `event2vector` are documented here. The format follows
35
59
 
36
60
  Earlier releases are listed on [PyPI](https://pypi.org/project/event2vector/#history).
37
61
 
62
+ [0.4.0]: https://pypi.org/project/event2vector/0.4.0/
38
63
  [0.3.1]: https://pypi.org/project/event2vector/0.3.1/
39
64
  [0.3.0]: https://pypi.org/project/event2vector/0.3.0/
@@ -0,0 +1,26 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use event2vector, please cite the paper below."
3
+ title: "event2vector: geometric embeddings for event sequences"
4
+ type: software
5
+ authors:
6
+ - family-names: Sulc
7
+ given-names: Antonin
8
+ version: 0.4.0
9
+ date-released: 2026-10-15
10
+ license: MIT
11
+ repository-code: "https://github.com/sulcantonin/event2vec_public"
12
+ url: "https://event2vec.com"
13
+ keywords:
14
+ - sequence-embedding
15
+ - event-sequences
16
+ - representation-learning
17
+ - geometric deep learning
18
+ preferred-citation:
19
+ type: article
20
+ title: "Event2Vec: A Geometric Approach to Learning Composable Representations of Event Sequences"
21
+ authors:
22
+ - family-names: Sulc
23
+ given-names: Antonin
24
+ journal: "arXiv preprint arXiv:2509.12188"
25
+ year: 2025
26
+ url: "https://arxiv.org/abs/2509.12188"
@@ -3,3 +3,4 @@ include CHANGELOG.md
3
3
  exclude *.pkl
4
4
  exclude *.model
5
5
  exclude data/*
6
+ include CITATION.cff
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: event2vector
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Scikit-learn-style geometric embeddings for event sequences.
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
@@ -133,7 +133,7 @@ Key methods:
133
133
  - `most_similar`: gensim-style nearest-neighbor lookup over learned event embeddings using tokens or full sequences as queries.
134
134
  - `pad_sequences=True`: enables fully vectorized batches with masking for substantial throughput gains on large corpora.
135
135
 
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
+ 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` and `affine` geometries default to the CPU, because MPS has no `matrix_exp` kernel and the CPU is faster for them; pass `device="mps"` to override.
137
137
 
138
138
  ## Geometries (v0.2)
139
139
 
@@ -147,6 +147,7 @@ Beyond `euclidean` and `hyperbolic`, the estimator exposes the paper's pilot geo
147
147
  | `spherical` | κ-gyroaddition (`kappa>0`) | cyclic / modular (periodic) structure |
148
148
  | `rotation` | left `SO(n)` product | non-commutative orientation (order matters) |
149
149
  | `heisenberg` | Heisenberg group product | order-dependent signed (Lévy) area |
150
+ | `affine` | `x -> exp(M_e) x + v_e` on the left | events that act on the history (shrink, stretch, rotate it), not only add to it |
150
151
  | `product` | per-factor update | two structure types at once (e.g. hyperbolic × spherical) |
151
152
 
152
153
  ```python
@@ -162,6 +163,39 @@ model = Event2Vec(num_event_types=len(vocab), geometry="rotation", rotation_n=3)
162
163
  model = Event2Vec(num_event_types=len(vocab), geometry="product", product_kappas=(-1.0, 1.0))
163
164
  ```
164
165
 
166
+ ### Affine geometry (v0.4)
167
+
168
+ In the additive geometries an event always pushes the same way. With
169
+ `geometry="affine"` each event is an invertible affine map
170
+ `x -> exp(M_e) x + v_e`, so the same event contributes differently depending on
171
+ what came before, while the model stays a group:
172
+
173
+ - with every `M_e = 0` (the initialisation) the point is exactly the Euclidean sum,
174
+ so training starts from the additive model;
175
+ - every step is invertible and the reconstruction loss recovers it exactly;
176
+ - any subsequence composes into a single affine map, so its effect on *any*
177
+ history is one `A x + v`.
178
+
179
+ The state is the whole map `[vec(A), b]`; the point to plot is `b`, the image of
180
+ the origin (`model.model.point(...)`). The singular values of a subsequence's `A`
181
+ say how much it overrides what came before: `1` passes the history through,
182
+ `< 1` erases it along that direction.
183
+
184
+ ```python
185
+ model = Event2Vec(num_event_types=len(vocab), geometry="affine", embedding_dim=2)
186
+ model.fit(sequences)
187
+
188
+ states = model.transform(sequences, as_numpy=False) # [vec(A), b] per sequence
189
+ points = model.model.point(states) # (n, 2) points to plot
190
+
191
+ A, v = model.model.compose([3, 7, 1]) # one map for a subsequence
192
+ print(model.model.override_strength([3, 7, 1])) # its singular values
193
+ ```
194
+
195
+ Options: `affine_readout="point"` (default; the decoder reads `b`) or `"full"`
196
+ (it also reads `A`), and `affine_max_log_scale` to bound how strongly a single
197
+ event can stretch or shrink the state.
198
+
165
199
  Reproduce the curvature sweep on a real knowledge graph (auto-downloads WN18RR):
166
200
 
167
201
  ```bash
@@ -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"`). 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.
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` and `affine` geometries default to the CPU, because MPS has no `matrix_exp` kernel and the CPU is faster for them; pass `device="mps"` to override.
102
102
 
103
103
  ## Geometries (v0.2)
104
104
 
@@ -112,6 +112,7 @@ Beyond `euclidean` and `hyperbolic`, the estimator exposes the paper's pilot geo
112
112
  | `spherical` | κ-gyroaddition (`kappa>0`) | cyclic / modular (periodic) structure |
113
113
  | `rotation` | left `SO(n)` product | non-commutative orientation (order matters) |
114
114
  | `heisenberg` | Heisenberg group product | order-dependent signed (Lévy) area |
115
+ | `affine` | `x -> exp(M_e) x + v_e` on the left | events that act on the history (shrink, stretch, rotate it), not only add to it |
115
116
  | `product` | per-factor update | two structure types at once (e.g. hyperbolic × spherical) |
116
117
 
117
118
  ```python
@@ -127,6 +128,39 @@ model = Event2Vec(num_event_types=len(vocab), geometry="rotation", rotation_n=3)
127
128
  model = Event2Vec(num_event_types=len(vocab), geometry="product", product_kappas=(-1.0, 1.0))
128
129
  ```
129
130
 
131
+ ### Affine geometry (v0.4)
132
+
133
+ In the additive geometries an event always pushes the same way. With
134
+ `geometry="affine"` each event is an invertible affine map
135
+ `x -> exp(M_e) x + v_e`, so the same event contributes differently depending on
136
+ what came before, while the model stays a group:
137
+
138
+ - with every `M_e = 0` (the initialisation) the point is exactly the Euclidean sum,
139
+ so training starts from the additive model;
140
+ - every step is invertible and the reconstruction loss recovers it exactly;
141
+ - any subsequence composes into a single affine map, so its effect on *any*
142
+ history is one `A x + v`.
143
+
144
+ The state is the whole map `[vec(A), b]`; the point to plot is `b`, the image of
145
+ the origin (`model.model.point(...)`). The singular values of a subsequence's `A`
146
+ say how much it overrides what came before: `1` passes the history through,
147
+ `< 1` erases it along that direction.
148
+
149
+ ```python
150
+ model = Event2Vec(num_event_types=len(vocab), geometry="affine", embedding_dim=2)
151
+ model.fit(sequences)
152
+
153
+ states = model.transform(sequences, as_numpy=False) # [vec(A), b] per sequence
154
+ points = model.model.point(states) # (n, 2) points to plot
155
+
156
+ A, v = model.model.compose([3, 7, 1]) # one map for a subsequence
157
+ print(model.model.override_strength([3, 7, 1])) # its singular values
158
+ ```
159
+
160
+ Options: `affine_readout="point"` (default; the decoder reads `b`) or `"full"`
161
+ (it also reads `A`), and `affine_max_log_scale` to bound how strongly a single
162
+ event can stretch or shrink the state.
163
+
130
164
  Reproduce the curvature sweep on a real knowledge graph (auto-downloads WN18RR):
131
165
 
132
166
  ```bash
@@ -1,4 +1,4 @@
1
- __version__ = "0.3.1"
1
+ __version__ = "0.4.0"
2
2
 
3
3
  from .models import EuclideanModel, HyperbolicModel, HyperbolicUtils, Event2Vec
4
4
  from .geometries import (
@@ -6,6 +6,7 @@ from .geometries import (
6
6
  StereographicModel,
7
7
  RotationModel,
8
8
  HeisenbergModel,
9
+ AffineModel,
9
10
  ProductModel,
10
11
  )
11
12
  from .data import generate_sequences, get_sequences
@@ -20,6 +21,7 @@ __all__ = [
20
21
  "StereographicModel",
21
22
  "RotationModel",
22
23
  "HeisenbergModel",
24
+ "AffineModel",
23
25
  "ProductModel",
24
26
  "generate_sequences",
25
27
  "get_sequences",
@@ -17,6 +17,10 @@ rotation (SO(n)) left matrix product non-commutative orientation;
17
17
  abelianization
18
18
  heisenberg nilpotent group product the signed (Levy) area / commutator,
19
19
  a purely order-dependent quantity
20
+ affine (Aff+(d)) x -> exp(M_e) x + v_e events that act on the history
21
+ on the left (shrink / stretch / rotate it) as
22
+ well as add to it; M_e = 0 is
23
+ exactly the Euclidean sum
20
24
  product per-factor update two structure types at once
21
25
  (e.g. hierarchy H x cycle S)
22
26
  ================ =========================== ===================================
@@ -35,6 +39,22 @@ import torch.nn as nn
35
39
  EPS = 1e-5
36
40
 
37
41
 
42
+ def _matrix_exp(a):
43
+ """torch.matrix_exp that also works for MPS tensors: MPS has no matrix_exp
44
+ kernel (pytorch/pytorch#141287), so it is computed on the CPU and moved back.
45
+ Both transfers are differentiable."""
46
+ if a.device.type == "mps":
47
+ return torch.matrix_exp(a.cpu()).to(a.device)
48
+ return torch.matrix_exp(a)
49
+
50
+
51
+ def _solve(a, b):
52
+ """torch.linalg.solve, computed on the CPU for MPS tensors (as above)."""
53
+ if a.device.type == "mps":
54
+ return torch.linalg.solve(a.cpu(), b.cpu()).to(a.device)
55
+ return torch.linalg.solve(a, b)
56
+
57
+
38
58
  # --------------------------------------------------------------------------- #
39
59
  # kappa-stereographic model: one signed curvature for E / S / H
40
60
  # --------------------------------------------------------------------------- #
@@ -216,11 +236,7 @@ class RotationModel(nn.Module):
216
236
  a = torch.zeros(b, self.n, self.n, device=gen.device, dtype=gen.dtype)
217
237
  a[:, self.triu_i, self.triu_j] = gen
218
238
  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)
223
- return torch.matrix_exp(a)
239
+ return _matrix_exp(a)
224
240
 
225
241
  def zero_state(self, batch, device):
226
242
  return torch.eye(self.n, device=device).reshape(1, -1).repeat(batch, 1)
@@ -333,6 +349,152 @@ class HeisenbergModel(nn.Module):
333
349
  return ((a - b) ** 2).sum(-1, keepdim=True)
334
350
 
335
351
 
352
+ # --------------------------------------------------------------------------- #
353
+ # Affine group model: events that act on the history, not only add to it
354
+ # --------------------------------------------------------------------------- #
355
+ class AffineModel(nn.Module):
356
+ """State is an orientation-preserving affine map ``x -> A x + b`` of R^d,
357
+ stored flat as ``[vec(A), b]`` and starting at the identity. Each event is the
358
+ affine map ``x -> exp(M_e) x + v_e`` applied on the left::
359
+
360
+ A_t = exp(M_e) A_{t-1} b_t = exp(M_e) b_{t-1} + v_e
361
+
362
+ ``b`` is the sequence's *point* (the image of the origin). With every
363
+ ``M_e = 0`` it is exactly the Euclidean sum of the ``v_e``, so this geometry
364
+ contains event2vec's additive model and starts training from it.
365
+
366
+ The linear part lets an event act on what came before -- shrink, stretch or
367
+ rotate it -- so the same event contributes differently after different
368
+ histories, while the model stays a group: every step is invertible (``exp``
369
+ of any matrix is), the reconstruction loss recovers it exactly, and any
370
+ subsequence composes into one affine map (:meth:`compose`). Its singular
371
+ values (:meth:`override_strength`) say how strongly that subsequence
372
+ overrides the history: values below 1 erase the history along a direction.
373
+
374
+ Args:
375
+ embedding_dim: dimension ``d`` of the point ``b`` (2 for a planar map).
376
+ Each event has ``d + d*d`` parameters (``v_e`` and ``M_e``).
377
+ readout: ``"point"`` decodes the next event from ``b`` only (the plotted
378
+ point); ``"full"`` also reads ``A``.
379
+ max_log_scale: if set, ``M_e`` entries are squashed to
380
+ ``(-max_log_scale, max_log_scale)``, bounding how much one event can
381
+ stretch or shrink the state (each step's condition number is at most
382
+ ``exp(2 * d * max_log_scale)``). ``None`` leaves ``M_e`` free.
383
+ """
384
+
385
+ geometry = "affine"
386
+
387
+ def __init__(self, num_event_types, embedding_dim=2, dropout_p=0.0,
388
+ readout="point", max_log_scale=None):
389
+ super().__init__()
390
+ if readout not in ("point", "full"):
391
+ raise ValueError("readout must be 'point' or 'full'.")
392
+ d = embedding_dim
393
+ self.d = d
394
+ self.point_dim = d
395
+ self.embedding_dim = d + d * d # generator: [v_e, vec(M_e)]
396
+ self.state_dim = d * d + d # state: [vec(A), b]
397
+ self.readout = readout
398
+ self.max_log_scale = max_log_scale
399
+ self.embedding = nn.Embedding(num_event_types, self.embedding_dim)
400
+ self.dropout = nn.Dropout(p=dropout_p)
401
+ self.linear_dec = nn.Linear(d if readout == "point" else self.state_dim,
402
+ num_event_types)
403
+ self.reset_parameters()
404
+
405
+ def reset_parameters(self):
406
+ if self.linear_dec is not None: # None when used as a ProductModel factor
407
+ nn.init.xavier_uniform_(self.linear_dec.weight)
408
+ if self.linear_dec.bias is not None:
409
+ nn.init.zeros_(self.linear_dec.bias)
410
+ d = self.d
411
+ with torch.no_grad():
412
+ nn.init.normal_(self.embedding.weight[:, :d], 0.0, 0.02) # v_e as Euclidean
413
+ self.embedding.weight[:, d:].zero_() # M_e = 0: A_e = I
414
+
415
+ # -- group elements ----------------------------------------------------- #
416
+ def _maps(self, gen):
417
+ """(B, d + d*d) generators -> (A_e (B, d, d), v_e (B, d))."""
418
+ d = self.d
419
+ v, m = gen[:, :d], gen[:, d:].reshape(-1, d, d)
420
+ if self.max_log_scale is not None:
421
+ c = self.max_log_scale
422
+ m = c * torch.tanh(m / c)
423
+ return _matrix_exp(m), v
424
+
425
+ def _split(self, h):
426
+ d = self.d
427
+ return h[:, : d * d].reshape(-1, d, d), h[:, d * d:]
428
+
429
+ @staticmethod
430
+ def _join(a, b):
431
+ return torch.cat([a.reshape(a.shape[0], -1), b], dim=-1)
432
+
433
+ def zero_state(self, batch, device):
434
+ a = torch.eye(self.d, device=device).expand(batch, -1, -1)
435
+ return self._join(a, torch.zeros(batch, self.d, device=device))
436
+
437
+ def point(self, h):
438
+ """The plotted point ``b`` of a state (or of ``transform``'s output)."""
439
+ return h[..., self.d * self.d:]
440
+
441
+ def to_tangent(self, h):
442
+ return self.point(h) if self.readout == "point" else h
443
+
444
+ # -- recurrence ---------------------------------------------------------- #
445
+ def encoder(self, x, h):
446
+ a_e, v_e = self._maps(self.dropout(self.embedding(x)))
447
+ a_old, b_old = self._split(h)
448
+ a_new = torch.bmm(a_e, a_old)
449
+ b_new = torch.bmm(a_e, b_old.unsqueeze(-1)).squeeze(-1) + v_e
450
+ return self._join(a_new, b_new), self._join(a_e, v_e)
451
+
452
+ def decoder(self, h):
453
+ if self.linear_dec is None:
454
+ raise RuntimeError(
455
+ "this model is a ProductModel factor; decode through the "
456
+ "product's shared decoder, not the factor's")
457
+ return self.linear_dec(self.to_tangent(h))
458
+
459
+ def forward(self, x, h=None):
460
+ if h is None:
461
+ h = self.zero_state(x.shape[0], x.device)
462
+ h_next, e = self.encoder(x, h)
463
+ return self.decoder(h_next), h_next, e
464
+
465
+ def recover_increment(self, h_old, h_new):
466
+ """g_e = g_new . g_old^-1: A_e = A_new A_old^-1, v_e = b_new - A_e b_old."""
467
+ a_old, b_old = self._split(h_old)
468
+ a_new, b_new = self._split(h_new)
469
+ # A_e A_old = A_new <=> A_old^T A_e^T = A_new^T (solve, don't invert)
470
+ a_e = _solve(a_old.transpose(1, 2), a_new.transpose(1, 2)).transpose(1, 2)
471
+ v_e = b_new - torch.bmm(a_e, b_old.unsqueeze(-1)).squeeze(-1)
472
+ return self._join(a_e, v_e)
473
+
474
+ def dist2(self, a, b):
475
+ return ((a - b) ** 2).sum(-1, keepdim=True) # Frobenius^2 on [A, b]
476
+
477
+ # -- interpretation -------------------------------------------------------- #
478
+ @torch.no_grad()
479
+ def compose(self, event_ids):
480
+ """One affine map (A, v) for a whole subsequence: applying it to any state
481
+ equals feeding the events one by one (exact; it is a group product)."""
482
+ dev = self.embedding.weight.device
483
+ a, v = torch.eye(self.d, device=dev), torch.zeros(self.d, device=dev)
484
+ for i in event_ids:
485
+ a_e, v_e = self._maps(self.embedding.weight[int(i)].unsqueeze(0))
486
+ a, v = a_e[0] @ a, a_e[0] @ v + v_e[0]
487
+ return a, v
488
+
489
+ @torch.no_grad()
490
+ def override_strength(self, event_ids):
491
+ """Singular values of the subsequence's linear part, largest first:
492
+ 1 = the history passes through unchanged, < 1 = erased along that
493
+ direction, > 1 = amplified."""
494
+ a, _ = self.compose(event_ids)
495
+ return torch.linalg.svdvals(a)
496
+
497
+
336
498
  # --------------------------------------------------------------------------- #
337
499
  # Product manifold: two structure types at once (e.g. hierarchy H x cycle S)
338
500
  # --------------------------------------------------------------------------- #
@@ -8,6 +8,7 @@ import torch.nn as nn
8
8
  from torch.nn.utils.rnn import pad_sequence
9
9
 
10
10
  from .geometries import (
11
+ AffineModel,
11
12
  HeisenbergModel,
12
13
  ProductModel,
13
14
  RotationModel,
@@ -196,6 +197,8 @@ class Event2Vec:
196
197
  kappa: Optional[float] = None,
197
198
  rotation_n: int = 3,
198
199
  heisenberg_m: int = 1,
200
+ affine_readout: str = "point",
201
+ affine_max_log_scale: Optional[float] = None,
199
202
  product_kappas: Sequence[float] = (-1.0, 1.0),
200
203
  lambda_reconstruction: float = 0.2,
201
204
  lambda_consistency: float = 0.2,
@@ -232,6 +235,8 @@ class Event2Vec:
232
235
  self.kappa = kappa
233
236
  self.rotation_n = rotation_n
234
237
  self.heisenberg_m = heisenberg_m
238
+ self.affine_readout = affine_readout
239
+ self.affine_max_log_scale = affine_max_log_scale
235
240
  self.product_kappas = tuple(product_kappas)
236
241
  self.lambda_reconstruction = lambda_reconstruction
237
242
  self.lambda_consistency = lambda_consistency
@@ -249,11 +254,11 @@ class Event2Vec:
249
254
  if use_gpu and torch.cuda.is_available():
250
255
  device = torch.device("cuda")
251
256
  elif use_gpu and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
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.
257
+ if self.geometry in ("rotation", "affine"):
258
+ # MPS has no matrix_exp, so every step would round-trip through
259
+ # the CPU; for a model this small the CPU is much faster.
255
260
  warnings.warn(
256
- "geometry='rotation' needs torch.matrix_exp, which MPS does not "
261
+ f"geometry='{self.geometry}' needs torch.matrix_exp, which MPS does not "
257
262
  "implement; using device='cpu'. Pass device='mps' to run on MPS "
258
263
  "anyway (matrix_exp is then computed on the CPU).",
259
264
  RuntimeWarning,
@@ -318,6 +323,14 @@ class Event2Vec:
318
323
  m=self.heisenberg_m,
319
324
  dropout_p=self.dropout_p,
320
325
  )
326
+ elif self.geometry == "affine":
327
+ model = AffineModel(
328
+ num_event_types=self.num_event_types,
329
+ embedding_dim=self.embedding_dim,
330
+ dropout_p=self.dropout_p,
331
+ readout=self.affine_readout,
332
+ max_log_scale=self.affine_max_log_scale,
333
+ )
321
334
  elif self.geometry == "product":
322
335
  factors = [
323
336
  StereographicModel(
@@ -332,7 +345,7 @@ class Event2Vec:
332
345
  else:
333
346
  raise ValueError(
334
347
  "geometry must be one of: 'euclidean', 'hyperbolic', 'spherical', "
335
- "'stereographic', 'rotation', 'heisenberg', 'product'."
348
+ "'stereographic', 'rotation', 'heisenberg', 'affine', 'product'."
336
349
  )
337
350
  return model.to(self.device)
338
351
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: event2vector
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Scikit-learn-style geometric embeddings for event sequences.
5
5
  Author: Antonin Sulc
6
6
  License-Expression: MIT
@@ -133,7 +133,7 @@ Key methods:
133
133
  - `most_similar`: gensim-style nearest-neighbor lookup over learned event embeddings using tokens or full sequences as queries.
134
134
  - `pad_sequences=True`: enables fully vectorized batches with masking for substantial throughput gains on large corpora.
135
135
 
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
+ 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` and `affine` geometries default to the CPU, because MPS has no `matrix_exp` kernel and the CPU is faster for them; pass `device="mps"` to override.
137
137
 
138
138
  ## Geometries (v0.2)
139
139
 
@@ -147,6 +147,7 @@ Beyond `euclidean` and `hyperbolic`, the estimator exposes the paper's pilot geo
147
147
  | `spherical` | κ-gyroaddition (`kappa>0`) | cyclic / modular (periodic) structure |
148
148
  | `rotation` | left `SO(n)` product | non-commutative orientation (order matters) |
149
149
  | `heisenberg` | Heisenberg group product | order-dependent signed (Lévy) area |
150
+ | `affine` | `x -> exp(M_e) x + v_e` on the left | events that act on the history (shrink, stretch, rotate it), not only add to it |
150
151
  | `product` | per-factor update | two structure types at once (e.g. hyperbolic × spherical) |
151
152
 
152
153
  ```python
@@ -162,6 +163,39 @@ model = Event2Vec(num_event_types=len(vocab), geometry="rotation", rotation_n=3)
162
163
  model = Event2Vec(num_event_types=len(vocab), geometry="product", product_kappas=(-1.0, 1.0))
163
164
  ```
164
165
 
166
+ ### Affine geometry (v0.4)
167
+
168
+ In the additive geometries an event always pushes the same way. With
169
+ `geometry="affine"` each event is an invertible affine map
170
+ `x -> exp(M_e) x + v_e`, so the same event contributes differently depending on
171
+ what came before, while the model stays a group:
172
+
173
+ - with every `M_e = 0` (the initialisation) the point is exactly the Euclidean sum,
174
+ so training starts from the additive model;
175
+ - every step is invertible and the reconstruction loss recovers it exactly;
176
+ - any subsequence composes into a single affine map, so its effect on *any*
177
+ history is one `A x + v`.
178
+
179
+ The state is the whole map `[vec(A), b]`; the point to plot is `b`, the image of
180
+ the origin (`model.model.point(...)`). The singular values of a subsequence's `A`
181
+ say how much it overrides what came before: `1` passes the history through,
182
+ `< 1` erases it along that direction.
183
+
184
+ ```python
185
+ model = Event2Vec(num_event_types=len(vocab), geometry="affine", embedding_dim=2)
186
+ model.fit(sequences)
187
+
188
+ states = model.transform(sequences, as_numpy=False) # [vec(A), b] per sequence
189
+ points = model.model.point(states) # (n, 2) points to plot
190
+
191
+ A, v = model.model.compose([3, 7, 1]) # one map for a subsequence
192
+ print(model.model.override_strength([3, 7, 1])) # its singular values
193
+ ```
194
+
195
+ Options: `affine_readout="point"` (default; the decoder reads `b`) or `"full"`
196
+ (it also reads `A`), and `affine_max_log_scale` to bound how strongly a single
197
+ event can stretch or shrink the state.
198
+
165
199
  Reproduce the curvature sweep on a real knowledge graph (auto-downloads WN18RR):
166
200
 
167
201
  ```bash
@@ -1,4 +1,5 @@
1
1
  CHANGELOG.md
2
+ CITATION.cff
2
3
  MANIFEST.in
3
4
  README.md
4
5
  pyproject.toml
@@ -23,4 +24,5 @@ event2vector/datasets/realdata.py
23
24
  event2vector/eval/__init__.py
24
25
  event2vector/eval/figures.py
25
26
  event2vector/eval/harness.py
27
+ tests/test_affine.py
26
28
  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.1"
7
+ version = "0.4.0"
8
8
  description = "Scikit-learn-style geometric embeddings for event sequences."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,97 @@
1
+ """Tests for the affine geometry (``geometry="affine"``, :class:`AffineModel`).
2
+
3
+ The affine state is a map ``x -> A x + b``; each event applies
4
+ ``x -> exp(M_e) x + v_e`` on the left. 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 AffineModel, Event2Vec
13
+
14
+ SEQS = [[0, 1, 2, 3], [0, 1, 2, 4], [0, 2, 3, 4], [0, 1, 3, 4], [0, 2, 1, 4]] * 8
15
+
16
+
17
+ def _affine(dim=2, **kw):
18
+ am = AffineModel(num_event_types=5, embedding_dim=dim, **kw)
19
+ am.eval()
20
+ with torch.no_grad(): # a non-trivial linear part for every event
21
+ am.embedding.weight.normal_(0.0, 0.3)
22
+ return am
23
+
24
+
25
+ def _feed(model, ids):
26
+ h = model.zero_state(1, torch.device("cpu"))
27
+ for i in ids:
28
+ h = model.forward(torch.tensor([i]), h)[1]
29
+ return h
30
+
31
+
32
+ @pytest.mark.parametrize("kw", [dict(), dict(affine_readout="full",
33
+ affine_max_log_scale=1.0)])
34
+ def test_affine_fits_transforms_and_learns(kw):
35
+ est = Event2Vec(num_event_types=5, geometry="affine", embedding_dim=2,
36
+ dropout_p=0.0, num_epochs=40, learning_rate=5e-3, batch_size=8,
37
+ pad_sequences=True, random_state=0, use_gpu=False, **kw)
38
+ est.fit(SEQS)
39
+ emb = est.transform(SEQS)
40
+ assert emb.shape == (len(SEQS), est.model.state_dim) # [vec(A), b] = 4 + 2
41
+ assert np.isfinite(emb).all()
42
+ assert min(est.training_loss_) < est.training_loss_[0] * 0.98
43
+ points = est.model.point(torch.as_tensor(emb))
44
+ assert points.shape == (len(SEQS), 2)
45
+
46
+
47
+ def test_affine_with_identity_maps_is_the_euclidean_sum():
48
+ """M_e = 0 (the initialisation) makes the point b exactly the sum of v_e."""
49
+ am = AffineModel(num_event_types=5, embedding_dim=3)
50
+ am.eval()
51
+ ids = [0, 3, 1, 3]
52
+ v = am.embedding.weight[:, :3].detach()
53
+ assert torch.allclose(am.point(_feed(am, ids))[0], v[ids].sum(0), atol=1e-6)
54
+
55
+
56
+ def test_affine_compose_equals_stepwise_and_is_order_sensitive():
57
+ am = _affine()
58
+ ids = [1, 2, 3, 4]
59
+ a, v = am.compose(ids)
60
+ assert torch.allclose(am.point(_feed(am, ids))[0], v, atol=1e-5)
61
+ # applied to any state, the composed map equals feeding the events one by one
62
+ _, b_stem = am._split(_feed(am, [0, 2]))
63
+ after = _feed(am, [0, 2] + ids)
64
+ assert torch.allclose(am.point(after)[0], a @ b_stem[0] + v, atol=1e-5)
65
+ assert not torch.allclose(am.compose([1, 2])[1], am.compose([2, 1])[1], atol=1e-4)
66
+
67
+
68
+ def test_affine_recover_increment_is_exact():
69
+ """The reconstruction loss's left-cancellation recovers each step exactly."""
70
+ am = _affine()
71
+ h0 = _feed(am, [0, 1, 2])
72
+ h1, step = am.encoder(torch.tensor([3]), h0)
73
+ assert torch.allclose(am.recover_increment(h0, h1), step, atol=1e-5)
74
+ assert am.dist2(am.recover_increment(h0, h1), step).item() < 1e-8
75
+
76
+
77
+ def test_affine_max_log_scale_bounds_each_step():
78
+ am = _affine(max_log_scale=0.5)
79
+ with torch.no_grad():
80
+ am.embedding.weight.mul_(100.0)
81
+ s = am.override_strength([2])
82
+ bound = math.exp(2 * 2 * 0.5)
83
+ assert s.max().item() <= bound + 1e-4 and s.min().item() >= 1 / bound - 1e-4
84
+
85
+
86
+ def test_affine_rejects_unknown_readout():
87
+ with pytest.raises(ValueError):
88
+ AffineModel(num_event_types=5, readout="nope")
89
+
90
+
91
+ def test_auto_device_skips_mps_for_affine(monkeypatch):
92
+ """Like rotation, affine needs matrix_exp: auto-selection prefers the CPU."""
93
+ monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
94
+ monkeypatch.setattr(torch.backends.mps, "is_available", lambda: True)
95
+ with pytest.warns(RuntimeWarning, match="geometry='affine' needs torch.matrix_exp"):
96
+ est = Event2Vec(num_event_types=3, geometry="affine")
97
+ assert est.device.type == "cpu"
File without changes