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.
- {event2vector-0.3.1 → event2vector-0.4.0}/CHANGELOG.md +25 -0
- event2vector-0.4.0/CITATION.cff +26 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/MANIFEST.in +1 -0
- {event2vector-0.3.1/event2vector.egg-info → event2vector-0.4.0}/PKG-INFO +36 -2
- {event2vector-0.3.1 → event2vector-0.4.0}/README.md +35 -1
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/__init__.py +3 -1
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/geometries.py +167 -5
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/models.py +18 -5
- {event2vector-0.3.1 → event2vector-0.4.0/event2vector.egg-info}/PKG-INFO +36 -2
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/SOURCES.txt +2 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/pyproject.toml +1 -1
- event2vector-0.4.0/tests/test_affine.py +97 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/baselines.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/data.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/__init__.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/arithmetic.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/brown.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/decay.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/groups.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/lifepath.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/datasets/realdata.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/eval/__init__.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/eval/figures.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/eval/harness.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector/metrics.py +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/dependency_links.txt +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/requires.txt +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/event2vector.egg-info/top_level.txt +0 -0
- {event2vector-0.3.1 → event2vector-0.4.0}/setup.cfg +0 -0
- {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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: event2vector
|
|
3
|
-
Version: 0.
|
|
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`
|
|
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`
|
|
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.
|
|
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
|
-
|
|
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
|
|
253
|
-
# MPS has no matrix_exp, so every
|
|
254
|
-
#
|
|
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='
|
|
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
|
+
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`
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|