event2vector 0.1.2.1__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.
- event2vector-0.3.1/CHANGELOG.md +39 -0
- {event2vector-0.1.2.1 → event2vector-0.3.1}/MANIFEST.in +1 -0
- {event2vector-0.1.2.1 → event2vector-0.3.1}/PKG-INFO +75 -13
- {event2vector-0.1.2.1 → event2vector-0.3.1}/README.md +63 -4
- event2vector-0.3.1/event2vector/__init__.py +28 -0
- event2vector-0.3.1/event2vector/baselines.py +362 -0
- {event2vector-0.1.2.1 → event2vector-0.3.1}/event2vector/data.py +13 -0
- event2vector-0.3.1/event2vector/datasets/__init__.py +52 -0
- event2vector-0.3.1/event2vector/datasets/arithmetic.py +68 -0
- event2vector-0.3.1/event2vector/datasets/brown.py +91 -0
- event2vector-0.3.1/event2vector/datasets/decay.py +146 -0
- event2vector-0.3.1/event2vector/datasets/groups.py +146 -0
- event2vector-0.3.1/event2vector/datasets/lifepath.py +199 -0
- event2vector-0.3.1/event2vector/datasets/realdata.py +458 -0
- event2vector-0.3.1/event2vector/eval/__init__.py +5 -0
- event2vector-0.3.1/event2vector/eval/figures.py +87 -0
- event2vector-0.3.1/event2vector/eval/harness.py +155 -0
- event2vector-0.3.1/event2vector/geometries.py +416 -0
- event2vector-0.3.1/event2vector/metrics.py +461 -0
- {event2vector-0.1.2.1 → event2vector-0.3.1}/event2vector/models.py +262 -69
- {event2vector-0.1.2.1 → event2vector-0.3.1}/event2vector.egg-info/PKG-INFO +75 -13
- event2vector-0.3.1/event2vector.egg-info/SOURCES.txt +26 -0
- {event2vector-0.1.2.1 → event2vector-0.3.1}/event2vector.egg-info/requires.txt +7 -4
- {event2vector-0.1.2.1 → event2vector-0.3.1}/pyproject.toml +15 -9
- event2vector-0.3.1/tests/test_device.py +75 -0
- event2vector-0.1.2.1/event2vector/__init__.py +0 -4
- event2vector-0.1.2.1/event2vector.egg-info/SOURCES.txt +0 -11
- {event2vector-0.1.2.1 → event2vector-0.3.1}/event2vector.egg-info/dependency_links.txt +0 -0
- {event2vector-0.1.2.1 → event2vector-0.3.1}/event2vector.egg-info/top_level.txt +0 -0
- {event2vector-0.1.2.1 → 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,23 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: event2vector
|
|
3
|
-
Version: 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/
|
|
8
|
-
Project-URL: Repository, https://github.com/sulcantonin/
|
|
9
|
-
Project-URL: Issues, https://github.com/sulcantonin/
|
|
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
|
|
13
14
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
15
|
Classifier: Intended Audience :: Science/Research
|
|
15
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.9
|
|
16
17
|
Description-Content-Type: text/markdown
|
|
17
|
-
Requires-Dist: numpy
|
|
18
|
-
Requires-Dist: torch
|
|
19
|
-
Requires-Dist: scikit-learn
|
|
20
|
-
Requires-Dist: tqdm
|
|
18
|
+
Requires-Dist: numpy>=1.21
|
|
19
|
+
Requires-Dist: torch>=1.13
|
|
20
|
+
Requires-Dist: scikit-learn>=1.0
|
|
21
|
+
Requires-Dist: tqdm>=4.0
|
|
21
22
|
Provides-Extra: viz
|
|
22
23
|
Requires-Dist: matplotlib; extra == "viz"
|
|
23
24
|
Requires-Dist: seaborn; extra == "viz"
|
|
@@ -25,6 +26,8 @@ Provides-Extra: manifold
|
|
|
25
26
|
Requires-Dist: openTSNE; extra == "manifold"
|
|
26
27
|
Provides-Extra: nlp
|
|
27
28
|
Requires-Dist: gensim; extra == "nlp"
|
|
29
|
+
Provides-Extra: hyperbolic
|
|
30
|
+
Requires-Dist: geoopt>=0.5; extra == "hyperbolic"
|
|
28
31
|
Provides-Extra: dev
|
|
29
32
|
Requires-Dist: pytest; extra == "dev"
|
|
30
33
|
Requires-Dist: black; extra == "dev"
|
|
@@ -130,17 +133,48 @@ Key methods:
|
|
|
130
133
|
- `most_similar`: gensim-style nearest-neighbor lookup over learned event embeddings using tokens or full sequences as queries.
|
|
131
134
|
- `pad_sequences=True`: enables fully vectorized batches with masking for substantial throughput gains on large corpora.
|
|
132
135
|
|
|
133
|
-
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.
|
|
134
137
|
|
|
138
|
+
## Geometries (v0.2)
|
|
135
139
|
|
|
140
|
+
Beyond `euclidean` and `hyperbolic`, the estimator exposes the paper's pilot geometries as first-class `geometry=` backbones, each the natural home of a structure the additive endpoint cannot carry. They share the same estimator and a generic gyrogroup/Lie-group left-cancellation reconstruction loss.
|
|
141
|
+
|
|
142
|
+
| `geometry` | update | best for |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `euclidean` | vector addition | order-invariant bag of events, exact decomposition |
|
|
145
|
+
| `hyperbolic` | Möbius addition (curvature `c>0`) | hierarchical / tree-like structure |
|
|
146
|
+
| `stereographic` | κ-gyroaddition (signed `kappa`) | unified curvature: `kappa<0` hyperbolic, `0` Euclidean, `>0` spherical |
|
|
147
|
+
| `spherical` | κ-gyroaddition (`kappa>0`) | cyclic / modular (periodic) structure |
|
|
148
|
+
| `rotation` | left `SO(n)` product | non-commutative orientation (order matters) |
|
|
149
|
+
| `heisenberg` | Heisenberg group product | order-dependent signed (Lévy) area |
|
|
150
|
+
| `product` | per-factor update | two structure types at once (e.g. hyperbolic × spherical) |
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from event2vector import Event2Vec
|
|
154
|
+
|
|
155
|
+
# one unified curvature dial: kappa<0 hyperbolic, 0 Euclidean, >0 spherical
|
|
156
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="stereographic", kappa=-1.0)
|
|
157
|
+
|
|
158
|
+
# non-commutative orientation (SO(3))
|
|
159
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="rotation", rotation_n=3)
|
|
160
|
+
|
|
161
|
+
# product of a hyperbolic and a spherical factor
|
|
162
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="product", product_kappas=(-1.0, 1.0))
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Reproduce the curvature sweep on a real knowledge graph (auto-downloads WN18RR):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
python3 -m scripts.run_kg_curvature # next-relation accuracy vs kappa
|
|
169
|
+
```
|
|
136
170
|
|
|
137
171
|
## Brown Corpus POS tagging example
|
|
138
172
|
After installation, you can try to run Brown Part-of-Speech tagging example from the paper.
|
|
139
173
|
|
|
140
174
|
```bash
|
|
141
|
-
python3 -m
|
|
142
|
-
python3 -m
|
|
143
|
-
python3 -m
|
|
175
|
+
python3 -m examples.prepare_brown_data
|
|
176
|
+
python3 -m examples.train_brown_data
|
|
177
|
+
python3 -m examples.visualize_brown_corpus
|
|
144
178
|
```
|
|
145
179
|
|
|
146
180
|
|
|
@@ -154,6 +188,34 @@ python3 examples/minimal_example.py
|
|
|
154
188
|
|
|
155
189
|
To try a hyperbolic run, open `examples/minimal_example.py` and set `geometry="hyperbolic"` in the `Event2Vec` constructor, then rerun the script.
|
|
156
190
|
|
|
191
|
+
## Reproducing the paper experiments
|
|
192
|
+
|
|
193
|
+
The journal experiments are built around domains with *exact ground truth* for the
|
|
194
|
+
additive structure, so interpretability is measured rather than visualized:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Physics (nuclear decay) + Math (group sequences): exact-ground-truth centerpiece
|
|
198
|
+
python3 -m scripts.run_centerpiece --seeds 5 --epochs 20 --out results/centerpiece
|
|
199
|
+
|
|
200
|
+
# Synthetic life-path workbench (order-sensitivity-vs-curvature, additivity decay)
|
|
201
|
+
python3 -m scripts.run_lifepath --seeds 5 --out results/lifepath
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Datasets live in `event2vector/datasets/` and all return a uniform record
|
|
205
|
+
(`sequences`, `processed_sequences`, `vocab`, `labels`, `graph`):
|
|
206
|
+
|
|
207
|
+
- `make_decay()`: radioactive decay chains; each event is an exact `(ΔZ, ΔN)` so
|
|
208
|
+
additivity coincides with conservation laws (metric: `operator_recovery`).
|
|
209
|
+
- `make_group(name)`: Cayley-graph walks on abelian/non-abelian groups; the
|
|
210
|
+
additive endpoint provably equals the abelianization (metric: `abelianization_gap`).
|
|
211
|
+
- `make_lifepath()`, `load_brown()`: controlled synthetic + real (POS) workbench.
|
|
212
|
+
- `load_gdelt()`, `load_sec_8k()`, `load_synthea()`, ...: real-world event streams
|
|
213
|
+
(require a public data download; see each loader's docstring).
|
|
214
|
+
|
|
215
|
+
Metrics (`event2vector.metrics`) and the seeded harness (`event2vector.eval`) are
|
|
216
|
+
reusable across datasets; baselines (GRU/LSTM/Transformer/Word2Vec) are in
|
|
217
|
+
`event2vector.baselines`. Run the test suite with `pytest tests/ -q`.
|
|
218
|
+
|
|
157
219
|
## References
|
|
158
220
|
For citations please use following Bibtex.
|
|
159
221
|
```bibtex
|
|
@@ -98,17 +98,48 @@ 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
|
+
## Geometries (v0.2)
|
|
103
104
|
|
|
105
|
+
Beyond `euclidean` and `hyperbolic`, the estimator exposes the paper's pilot geometries as first-class `geometry=` backbones, each the natural home of a structure the additive endpoint cannot carry. They share the same estimator and a generic gyrogroup/Lie-group left-cancellation reconstruction loss.
|
|
106
|
+
|
|
107
|
+
| `geometry` | update | best for |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `euclidean` | vector addition | order-invariant bag of events, exact decomposition |
|
|
110
|
+
| `hyperbolic` | Möbius addition (curvature `c>0`) | hierarchical / tree-like structure |
|
|
111
|
+
| `stereographic` | κ-gyroaddition (signed `kappa`) | unified curvature: `kappa<0` hyperbolic, `0` Euclidean, `>0` spherical |
|
|
112
|
+
| `spherical` | κ-gyroaddition (`kappa>0`) | cyclic / modular (periodic) structure |
|
|
113
|
+
| `rotation` | left `SO(n)` product | non-commutative orientation (order matters) |
|
|
114
|
+
| `heisenberg` | Heisenberg group product | order-dependent signed (Lévy) area |
|
|
115
|
+
| `product` | per-factor update | two structure types at once (e.g. hyperbolic × spherical) |
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from event2vector import Event2Vec
|
|
119
|
+
|
|
120
|
+
# one unified curvature dial: kappa<0 hyperbolic, 0 Euclidean, >0 spherical
|
|
121
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="stereographic", kappa=-1.0)
|
|
122
|
+
|
|
123
|
+
# non-commutative orientation (SO(3))
|
|
124
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="rotation", rotation_n=3)
|
|
125
|
+
|
|
126
|
+
# product of a hyperbolic and a spherical factor
|
|
127
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="product", product_kappas=(-1.0, 1.0))
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Reproduce the curvature sweep on a real knowledge graph (auto-downloads WN18RR):
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
python3 -m scripts.run_kg_curvature # next-relation accuracy vs kappa
|
|
134
|
+
```
|
|
104
135
|
|
|
105
136
|
## Brown Corpus POS tagging example
|
|
106
137
|
After installation, you can try to run Brown Part-of-Speech tagging example from the paper.
|
|
107
138
|
|
|
108
139
|
```bash
|
|
109
|
-
python3 -m
|
|
110
|
-
python3 -m
|
|
111
|
-
python3 -m
|
|
140
|
+
python3 -m examples.prepare_brown_data
|
|
141
|
+
python3 -m examples.train_brown_data
|
|
142
|
+
python3 -m examples.visualize_brown_corpus
|
|
112
143
|
```
|
|
113
144
|
|
|
114
145
|
|
|
@@ -122,6 +153,34 @@ python3 examples/minimal_example.py
|
|
|
122
153
|
|
|
123
154
|
To try a hyperbolic run, open `examples/minimal_example.py` and set `geometry="hyperbolic"` in the `Event2Vec` constructor, then rerun the script.
|
|
124
155
|
|
|
156
|
+
## Reproducing the paper experiments
|
|
157
|
+
|
|
158
|
+
The journal experiments are built around domains with *exact ground truth* for the
|
|
159
|
+
additive structure, so interpretability is measured rather than visualized:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Physics (nuclear decay) + Math (group sequences): exact-ground-truth centerpiece
|
|
163
|
+
python3 -m scripts.run_centerpiece --seeds 5 --epochs 20 --out results/centerpiece
|
|
164
|
+
|
|
165
|
+
# Synthetic life-path workbench (order-sensitivity-vs-curvature, additivity decay)
|
|
166
|
+
python3 -m scripts.run_lifepath --seeds 5 --out results/lifepath
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Datasets live in `event2vector/datasets/` and all return a uniform record
|
|
170
|
+
(`sequences`, `processed_sequences`, `vocab`, `labels`, `graph`):
|
|
171
|
+
|
|
172
|
+
- `make_decay()`: radioactive decay chains; each event is an exact `(ΔZ, ΔN)` so
|
|
173
|
+
additivity coincides with conservation laws (metric: `operator_recovery`).
|
|
174
|
+
- `make_group(name)`: Cayley-graph walks on abelian/non-abelian groups; the
|
|
175
|
+
additive endpoint provably equals the abelianization (metric: `abelianization_gap`).
|
|
176
|
+
- `make_lifepath()`, `load_brown()`: controlled synthetic + real (POS) workbench.
|
|
177
|
+
- `load_gdelt()`, `load_sec_8k()`, `load_synthea()`, ...: real-world event streams
|
|
178
|
+
(require a public data download; see each loader's docstring).
|
|
179
|
+
|
|
180
|
+
Metrics (`event2vector.metrics`) and the seeded harness (`event2vector.eval`) are
|
|
181
|
+
reusable across datasets; baselines (GRU/LSTM/Transformer/Word2Vec) are in
|
|
182
|
+
`event2vector.baselines`. Run the test suite with `pytest tests/ -q`.
|
|
183
|
+
|
|
125
184
|
## References
|
|
126
185
|
For citations please use following Bibtex.
|
|
127
186
|
```bibtex
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
2
|
+
|
|
3
|
+
from .models import EuclideanModel, HyperbolicModel, HyperbolicUtils, Event2Vec
|
|
4
|
+
from .geometries import (
|
|
5
|
+
StereographicUtils,
|
|
6
|
+
StereographicModel,
|
|
7
|
+
RotationModel,
|
|
8
|
+
HeisenbergModel,
|
|
9
|
+
ProductModel,
|
|
10
|
+
)
|
|
11
|
+
from .data import generate_sequences, get_sequences
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"Event2Vec",
|
|
16
|
+
"EuclideanModel",
|
|
17
|
+
"HyperbolicModel",
|
|
18
|
+
"HyperbolicUtils",
|
|
19
|
+
"StereographicUtils",
|
|
20
|
+
"StereographicModel",
|
|
21
|
+
"RotationModel",
|
|
22
|
+
"HeisenbergModel",
|
|
23
|
+
"ProductModel",
|
|
24
|
+
"generate_sequences",
|
|
25
|
+
"get_sequences",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Baseline sequence encoders with the same fit/transform interface as the
|
|
2
|
+
``Event2Vec`` estimator, so the experiment metrics can treat every model
|
|
3
|
+
uniformly.
|
|
4
|
+
|
|
5
|
+
- ``RNNEncoder`` (GRU/LSTM) and ``TransformerEncoder``: order-sensitive encoders
|
|
6
|
+
trained as next-event predictors; ``transform`` returns the pooled hidden
|
|
7
|
+
state. These are the order-sensitive reference points for A2 / E1.
|
|
8
|
+
- ``Word2VecBagEncoder``: an order-blind bag-of-tokens baseline (mean of learned
|
|
9
|
+
token vectors), the Word2Vec comparison used throughout the paper.
|
|
10
|
+
|
|
11
|
+
All encoders expose ``n_params`` so comparisons can be framed per-parameter
|
|
12
|
+
rather than as a leaderboard.
|
|
13
|
+
"""
|
|
14
|
+
import random
|
|
15
|
+
from typing import List, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import torch
|
|
19
|
+
import torch.nn as nn
|
|
20
|
+
from torch.nn.utils.rnn import pad_sequence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _seed(seed):
|
|
24
|
+
if seed is not None:
|
|
25
|
+
random.seed(seed)
|
|
26
|
+
np.random.seed(seed)
|
|
27
|
+
torch.manual_seed(seed)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _device(use_gpu):
|
|
31
|
+
if use_gpu and torch.cuda.is_available():
|
|
32
|
+
return torch.device("cuda")
|
|
33
|
+
if use_gpu and hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
34
|
+
return torch.device("mps")
|
|
35
|
+
return torch.device("cpu")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _standardize(sequences) -> List[torch.Tensor]:
|
|
39
|
+
out = []
|
|
40
|
+
for s in sequences:
|
|
41
|
+
t = s.detach().long().cpu() if isinstance(s, torch.Tensor) else torch.tensor(s, dtype=torch.long)
|
|
42
|
+
out.append(t)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _NextEventEncoder(nn.Module):
|
|
47
|
+
"""Shared next-event-prediction training loop for neural baselines."""
|
|
48
|
+
|
|
49
|
+
def fit(self, sequences, epochs=None, verbose=False):
|
|
50
|
+
seqs = [s for s in _standardize(sequences) if s.numel() >= 2]
|
|
51
|
+
opt = torch.optim.AdamW(self.parameters(), lr=self.learning_rate)
|
|
52
|
+
ce = nn.CrossEntropyLoss(ignore_index=self.pad_value)
|
|
53
|
+
self.train()
|
|
54
|
+
total_epochs = epochs or self.num_epochs
|
|
55
|
+
rng = random.Random(self.random_state)
|
|
56
|
+
for ep in range(total_epochs):
|
|
57
|
+
rng.shuffle(seqs)
|
|
58
|
+
running = 0.0
|
|
59
|
+
nb = 0
|
|
60
|
+
for i in range(0, len(seqs), self.batch_size):
|
|
61
|
+
batch = seqs[i : i + self.batch_size]
|
|
62
|
+
tens = [b.to(self.device) for b in batch]
|
|
63
|
+
padded = pad_sequence(tens, batch_first=True, padding_value=self.pad_value)
|
|
64
|
+
inp, tgt = padded[:, :-1], padded[:, 1:]
|
|
65
|
+
logits = self._logits(inp)
|
|
66
|
+
loss = ce(logits.reshape(-1, self.num_event_types), tgt.reshape(-1))
|
|
67
|
+
opt.zero_grad()
|
|
68
|
+
loss.backward()
|
|
69
|
+
opt.step()
|
|
70
|
+
running += float(loss.item())
|
|
71
|
+
nb += 1
|
|
72
|
+
if verbose:
|
|
73
|
+
print(f"epoch {ep + 1}/{total_epochs} loss {running / max(1, nb):.4f}")
|
|
74
|
+
self.eval()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
@torch.no_grad()
|
|
78
|
+
def transform(self, sequences, as_numpy=True, pad_sequences=None):
|
|
79
|
+
self.eval()
|
|
80
|
+
seqs = _standardize(sequences)
|
|
81
|
+
outs = []
|
|
82
|
+
for i in range(0, len(seqs), self.batch_size):
|
|
83
|
+
batch = seqs[i : i + self.batch_size]
|
|
84
|
+
tens = [b.to(self.device) for b in batch]
|
|
85
|
+
lengths = torch.tensor([t.size(0) for t in tens], device=self.device)
|
|
86
|
+
padded = pad_sequence(tens, batch_first=True, padding_value=self.pad_value)
|
|
87
|
+
pooled = self._pool(padded, lengths)
|
|
88
|
+
outs.append(pooled.cpu())
|
|
89
|
+
emb = torch.cat(outs, dim=0)
|
|
90
|
+
return emb.numpy() if as_numpy else emb
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def n_params(self):
|
|
94
|
+
return sum(p.numel() for p in self.parameters())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RNNEncoder(_NextEventEncoder):
|
|
98
|
+
def __init__(self, num_event_types, embedding_dim=32, kind="gru", num_layers=1,
|
|
99
|
+
learning_rate=1e-3, batch_size=64, num_epochs=12, pad_value=None,
|
|
100
|
+
random_state=None, use_gpu=True):
|
|
101
|
+
super().__init__()
|
|
102
|
+
self.num_event_types = num_event_types
|
|
103
|
+
self.embedding_dim = embedding_dim
|
|
104
|
+
self.learning_rate = learning_rate
|
|
105
|
+
self.batch_size = batch_size
|
|
106
|
+
self.num_epochs = num_epochs
|
|
107
|
+
# A dedicated pad id OUTSIDE the real vocabulary. Using 0 collides with a genuine
|
|
108
|
+
# token (e.g. alpha-decay is 18% of the decay corpus, the first group generator is
|
|
109
|
+
# 33% of S4 words); that token would then be frozen at zero, excluded from the loss,
|
|
110
|
+
# and -- for the Transformer -- dropped from attention and pooling.
|
|
111
|
+
self.pad_value = num_event_types if pad_value is None else pad_value
|
|
112
|
+
self.random_state = random_state
|
|
113
|
+
self.geometry = "euclidean" # for distance_for() in metrics
|
|
114
|
+
self.curvature = 1.0
|
|
115
|
+
_seed(random_state)
|
|
116
|
+
self.device = _device(use_gpu)
|
|
117
|
+
self.embedding = nn.Embedding(num_event_types + 1, embedding_dim,
|
|
118
|
+
padding_idx=self.pad_value)
|
|
119
|
+
rnn_cls = nn.LSTM if kind.lower() == "lstm" else nn.GRU
|
|
120
|
+
self.rnn = rnn_cls(embedding_dim, embedding_dim, num_layers=num_layers, batch_first=True)
|
|
121
|
+
self.dec = nn.Linear(embedding_dim, num_event_types)
|
|
122
|
+
self.kind = kind.lower()
|
|
123
|
+
self.to(self.device)
|
|
124
|
+
|
|
125
|
+
def _logits(self, inp):
|
|
126
|
+
out, _ = self.rnn(self.embedding(inp))
|
|
127
|
+
return self.dec(out)
|
|
128
|
+
|
|
129
|
+
def _pool(self, padded, lengths):
|
|
130
|
+
out, _ = self.rnn(self.embedding(padded))
|
|
131
|
+
last = (lengths - 1).clamp_min(0)
|
|
132
|
+
return out[torch.arange(out.size(0), device=out.device), last]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TransformerEncoder(_NextEventEncoder):
|
|
136
|
+
def __init__(self, num_event_types, embedding_dim=32, nhead=4, num_layers=2,
|
|
137
|
+
dim_feedforward=64, learning_rate=1e-3, batch_size=64, num_epochs=12,
|
|
138
|
+
pad_value=None, max_len=512, random_state=None, use_gpu=True):
|
|
139
|
+
super().__init__()
|
|
140
|
+
self.num_event_types = num_event_types
|
|
141
|
+
self.embedding_dim = embedding_dim
|
|
142
|
+
self.learning_rate = learning_rate
|
|
143
|
+
self.batch_size = batch_size
|
|
144
|
+
self.num_epochs = num_epochs
|
|
145
|
+
self.pad_value = num_event_types if pad_value is None else pad_value
|
|
146
|
+
self.random_state = random_state
|
|
147
|
+
self.max_len = max_len
|
|
148
|
+
self.geometry = "euclidean"
|
|
149
|
+
self.curvature = 1.0
|
|
150
|
+
_seed(random_state)
|
|
151
|
+
self.device = _device(use_gpu)
|
|
152
|
+
self.embedding = nn.Embedding(num_event_types + 1, embedding_dim,
|
|
153
|
+
padding_idx=self.pad_value)
|
|
154
|
+
self.pos = nn.Embedding(max_len, embedding_dim)
|
|
155
|
+
layer = nn.TransformerEncoderLayer(
|
|
156
|
+
embedding_dim, nhead, dim_feedforward, batch_first=True, dropout=0.1
|
|
157
|
+
)
|
|
158
|
+
self.encoder = nn.TransformerEncoder(layer, num_layers)
|
|
159
|
+
self.dec = nn.Linear(embedding_dim, num_event_types)
|
|
160
|
+
self.to(self.device)
|
|
161
|
+
|
|
162
|
+
def _embed(self, x):
|
|
163
|
+
L = x.size(1)
|
|
164
|
+
pos = torch.arange(L, device=x.device).clamp_max(self.max_len - 1)
|
|
165
|
+
return self.embedding(x) + self.pos(pos)[None]
|
|
166
|
+
|
|
167
|
+
def _logits(self, inp):
|
|
168
|
+
L = inp.size(1)
|
|
169
|
+
mask = torch.triu(torch.ones(L, L, device=inp.device), diagonal=1).bool()
|
|
170
|
+
key_pad = inp == self.pad_value
|
|
171
|
+
h = self.encoder(self._embed(inp), mask=mask, src_key_padding_mask=key_pad)
|
|
172
|
+
# fully-masked (all-padding) query rows can emit NaN; targets there are
|
|
173
|
+
# ignored in the loss, but NaN would still poison the backward pass.
|
|
174
|
+
return torch.nan_to_num(self.dec(h))
|
|
175
|
+
|
|
176
|
+
def _pool(self, padded, lengths):
|
|
177
|
+
key_pad = padded == self.pad_value
|
|
178
|
+
h = self.encoder(self._embed(padded), src_key_padding_mask=key_pad)
|
|
179
|
+
h = torch.nan_to_num(h) # padded rows may be NaN; they are zeroed by the mask below
|
|
180
|
+
mask = (~key_pad).float()[..., None]
|
|
181
|
+
return (h * mask).sum(1) / mask.sum(1).clamp_min(1.0)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Word2VecBagEncoder:
|
|
185
|
+
"""Order-blind baseline: mean of gensim Word2Vec token vectors."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, num_event_types, embedding_dim=32, window=5, min_count=1,
|
|
188
|
+
num_epochs=12, random_state=0, pooling="mean", **_):
|
|
189
|
+
self.num_event_types = num_event_types
|
|
190
|
+
self.embedding_dim = embedding_dim
|
|
191
|
+
self.window = window
|
|
192
|
+
self.min_count = min_count
|
|
193
|
+
self.num_epochs = num_epochs
|
|
194
|
+
self.random_state = random_state
|
|
195
|
+
self.pooling = pooling
|
|
196
|
+
self.geometry = "euclidean"
|
|
197
|
+
self.curvature = 1.0
|
|
198
|
+
self._vectors = None
|
|
199
|
+
|
|
200
|
+
def fit(self, sequences, **_):
|
|
201
|
+
from gensim.models import Word2Vec
|
|
202
|
+
|
|
203
|
+
toks = [[str(int(t)) for t in (s.tolist() if isinstance(s, torch.Tensor) else s)]
|
|
204
|
+
for s in sequences]
|
|
205
|
+
model = Word2Vec(
|
|
206
|
+
sentences=toks, vector_size=self.embedding_dim, window=self.window,
|
|
207
|
+
min_count=self.min_count, workers=1, seed=self.random_state, epochs=self.num_epochs,
|
|
208
|
+
)
|
|
209
|
+
vecs = np.zeros((self.num_event_types, self.embedding_dim), dtype=np.float32)
|
|
210
|
+
for i in range(self.num_event_types):
|
|
211
|
+
key = str(i)
|
|
212
|
+
if key in model.wv:
|
|
213
|
+
vecs[i] = model.wv[key]
|
|
214
|
+
self._vectors = torch.tensor(vecs)
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def transform(self, sequences, as_numpy=True, **_):
|
|
218
|
+
out = []
|
|
219
|
+
for s in sequences:
|
|
220
|
+
toks = [int(t) for t in (s.tolist() if isinstance(s, torch.Tensor) else s)]
|
|
221
|
+
if not toks:
|
|
222
|
+
out.append(torch.zeros(self.embedding_dim))
|
|
223
|
+
continue
|
|
224
|
+
v = self._vectors[toks]
|
|
225
|
+
out.append(v.sum(0) if self.pooling == "sum" else v.mean(0))
|
|
226
|
+
emb = torch.stack(out)
|
|
227
|
+
return emb.numpy() if as_numpy else emb
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def n_params(self):
|
|
231
|
+
# Skip-Gram trains TWO V x d matrices (input `wv` and output `syn1neg`); gensim
|
|
232
|
+
# discards the output one after fitting. Reporting only the retained matrix would
|
|
233
|
+
# make this look half the size of Event2Vec at equal capacity, so count both and
|
|
234
|
+
# note that only V x d is kept at inference time.
|
|
235
|
+
return 2 * self.num_event_types * self.embedding_dim
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class GraphWalkEncoder:
|
|
239
|
+
"""Order-blind graph baselines: DeepWalk and Node2Vec.
|
|
240
|
+
|
|
241
|
+
These methods do not embed sequences directly; they embed the *nodes* of a
|
|
242
|
+
graph learned from random walks, exactly the ``walks as unordered context''
|
|
243
|
+
setting discussed in the Related Work. We build the observed event-transition
|
|
244
|
+
graph (nodes = event types, edge weight = count of ``i \\to j`` transitions in
|
|
245
|
+
the training sequences), learn node vectors by DeepWalk (uniform walks +
|
|
246
|
+
Skip-Gram) or Node2Vec (biased $p,q$ walks), and pool the node vectors of a
|
|
247
|
+
sequence's tokens into a sequence embedding. Pooling is a *sum* by default so
|
|
248
|
+
the comparison to Event2Vec's additive endpoint isolates the learned
|
|
249
|
+
representation rather than the pooling rule (``mean`` is also available).
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self, num_event_types, embedding_dim=32, method="deepwalk",
|
|
253
|
+
walk_length=20, num_walks=10, window=5, p=1.0, q=1.0,
|
|
254
|
+
pooling="sum", num_epochs=5, random_state=0, **_):
|
|
255
|
+
self.num_event_types = num_event_types
|
|
256
|
+
self.embedding_dim = embedding_dim
|
|
257
|
+
self.method = method.lower()
|
|
258
|
+
self.walk_length = walk_length
|
|
259
|
+
self.num_walks = num_walks
|
|
260
|
+
self.window = window
|
|
261
|
+
self.p = p
|
|
262
|
+
self.q = q
|
|
263
|
+
self.pooling = pooling
|
|
264
|
+
self.num_epochs = num_epochs
|
|
265
|
+
self.random_state = random_state
|
|
266
|
+
self.geometry = "euclidean"
|
|
267
|
+
self.curvature = 1.0
|
|
268
|
+
self._vectors = None
|
|
269
|
+
|
|
270
|
+
def _graph(self, sequences):
|
|
271
|
+
import networkx as nx
|
|
272
|
+
|
|
273
|
+
g = nx.DiGraph()
|
|
274
|
+
g.add_nodes_from(range(self.num_event_types))
|
|
275
|
+
for s in sequences:
|
|
276
|
+
toks = [int(t) for t in (s.tolist() if isinstance(s, torch.Tensor) else s)]
|
|
277
|
+
for a, b in zip(toks[:-1], toks[1:]):
|
|
278
|
+
w = g[a][b]["weight"] + 1.0 if g.has_edge(a, b) else 1.0
|
|
279
|
+
g.add_edge(a, b, weight=w)
|
|
280
|
+
return g
|
|
281
|
+
|
|
282
|
+
def _deepwalk_walks(self, g):
|
|
283
|
+
rng = random.Random(self.random_state)
|
|
284
|
+
nodes = [n for n in g.nodes() if g.out_degree(n) > 0]
|
|
285
|
+
walks = []
|
|
286
|
+
for _ in range(self.num_walks):
|
|
287
|
+
rng.shuffle(nodes)
|
|
288
|
+
for start in nodes:
|
|
289
|
+
walk = [start]
|
|
290
|
+
while len(walk) < self.walk_length:
|
|
291
|
+
cur = walk[-1]
|
|
292
|
+
nbrs = list(g.successors(cur))
|
|
293
|
+
if not nbrs:
|
|
294
|
+
break
|
|
295
|
+
wts = np.array([g[cur][n]["weight"] for n in nbrs], dtype=np.float64)
|
|
296
|
+
walk.append(nbrs[int(rng.choices(range(len(nbrs)), weights=wts)[0])])
|
|
297
|
+
walks.append([str(n) for n in walk])
|
|
298
|
+
return walks
|
|
299
|
+
|
|
300
|
+
def fit(self, sequences, **_):
|
|
301
|
+
g = self._graph(sequences)
|
|
302
|
+
if self.method == "node2vec":
|
|
303
|
+
from node2vec import Node2Vec
|
|
304
|
+
|
|
305
|
+
n2v = Node2Vec(g, dimensions=self.embedding_dim, walk_length=self.walk_length,
|
|
306
|
+
num_walks=self.num_walks, p=self.p, q=self.q, weight_key="weight",
|
|
307
|
+
workers=1, quiet=True, seed=self.random_state)
|
|
308
|
+
wv = n2v.fit(window=self.window, min_count=0, sg=1, seed=self.random_state,
|
|
309
|
+
epochs=self.num_epochs).wv
|
|
310
|
+
else: # deepwalk = uniform random walks + Skip-Gram
|
|
311
|
+
from gensim.models import Word2Vec
|
|
312
|
+
|
|
313
|
+
walks = self._deepwalk_walks(g)
|
|
314
|
+
wv = Word2Vec(walks, vector_size=self.embedding_dim, window=self.window,
|
|
315
|
+
min_count=0, sg=1, workers=1, seed=self.random_state,
|
|
316
|
+
epochs=self.num_epochs).wv
|
|
317
|
+
vecs = np.zeros((self.num_event_types, self.embedding_dim), dtype=np.float32)
|
|
318
|
+
for i in range(self.num_event_types):
|
|
319
|
+
if str(i) in wv:
|
|
320
|
+
vecs[i] = wv[str(i)]
|
|
321
|
+
self._vectors = torch.tensor(vecs)
|
|
322
|
+
return self
|
|
323
|
+
|
|
324
|
+
def transform(self, sequences, as_numpy=True, **_):
|
|
325
|
+
out = []
|
|
326
|
+
for s in sequences:
|
|
327
|
+
toks = [int(t) for t in (s.tolist() if isinstance(s, torch.Tensor) else s)]
|
|
328
|
+
if not toks:
|
|
329
|
+
out.append(torch.zeros(self.embedding_dim))
|
|
330
|
+
continue
|
|
331
|
+
v = self._vectors[toks]
|
|
332
|
+
out.append(v.sum(0) if self.pooling == "sum" else v.mean(0))
|
|
333
|
+
emb = torch.stack(out)
|
|
334
|
+
return emb.numpy() if as_numpy else emb
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def n_params(self):
|
|
338
|
+
return 2 * self.num_event_types * self.embedding_dim # Skip-Gram: input + output matrices
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def build_baseline(name: str, num_event_types: int, **kw):
|
|
342
|
+
"""Construct a baseline by name.
|
|
343
|
+
|
|
344
|
+
Note on pooling: ``Word2VecBagEncoder``/``GraphWalkEncoder`` default to *sum*
|
|
345
|
+
pooling here, matching \methodtitle{}'s endpoint (which is a sum, not a mean).
|
|
346
|
+
Mean pooling rescales every sequence by 1/L and so measures something different;
|
|
347
|
+
it stays available via ``pooling="mean"`` but is no longer what a caller gets by
|
|
348
|
+
forgetting to pass the argument -- the paper's tables are all sum-pooled.
|
|
349
|
+
"""
|
|
350
|
+
name = name.lower()
|
|
351
|
+
if name in ("word2vec", "word2vec_bag", "bag", "deepwalk", "node2vec"):
|
|
352
|
+
kw.setdefault("pooling", "sum")
|
|
353
|
+
if name in ("gru", "lstm"):
|
|
354
|
+
return RNNEncoder(num_event_types, kind=name, **kw)
|
|
355
|
+
if name == "transformer":
|
|
356
|
+
return TransformerEncoder(num_event_types, **kw)
|
|
357
|
+
if name in ("word2vec", "word2vec_bag", "bag"):
|
|
358
|
+
return Word2VecBagEncoder(num_event_types, **kw)
|
|
359
|
+
if name in ("deepwalk", "node2vec"):
|
|
360
|
+
method = "node2vec" if name == "node2vec" else "deepwalk"
|
|
361
|
+
return GraphWalkEncoder(num_event_types, method=method, **kw)
|
|
362
|
+
raise ValueError(f"unknown baseline: {name}")
|
|
@@ -7,6 +7,19 @@ def generate_sequences(event_types, event_transitions, initial, terminal, num_se
|
|
|
7
7
|
Generates synthetic sequences of events based on a state transition graph.
|
|
8
8
|
This function simulates realistic life trajectories by performing a guided
|
|
9
9
|
random walk on the graph.
|
|
10
|
+
|
|
11
|
+
Note the walk is only 90% faithful to ``event_transitions``: with probability
|
|
12
|
+
0.1 (and always from a node with no outgoing edges) it teleports to a uniformly
|
|
13
|
+
chosen event type. That noise is deliberate, but it has two consequences worth
|
|
14
|
+
stating, because the record returned by ``make_lifepath`` advertises the graph
|
|
15
|
+
as ground truth:
|
|
16
|
+
|
|
17
|
+
* roughly 10% of observed transitions are NOT edges of the graph, so the
|
|
18
|
+
graph explains ~90% of the data, not all of it;
|
|
19
|
+
* event types unreachable from ``initial`` still occur in the data. In the
|
|
20
|
+
lifepath graph 5 of the 64 types (adoption, inheritance, job_loss,
|
|
21
|
+
military_service, widowhood) are unreachable from ``birth`` and appear
|
|
22
|
+
only via this teleport.
|
|
10
23
|
"""
|
|
11
24
|
sequences = []
|
|
12
25
|
for _ in range(num_seq):
|