event2vector 0.1.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {event2vector-0.1.2.1 → event2vector-0.3.0}/PKG-INFO +70 -9
- {event2vector-0.1.2.1 → event2vector-0.3.0}/README.md +62 -3
- event2vector-0.3.0/event2vector/__init__.py +28 -0
- event2vector-0.3.0/event2vector/baselines.py +362 -0
- {event2vector-0.1.2.1 → event2vector-0.3.0}/event2vector/data.py +13 -0
- event2vector-0.3.0/event2vector/datasets/__init__.py +52 -0
- event2vector-0.3.0/event2vector/datasets/arithmetic.py +68 -0
- event2vector-0.3.0/event2vector/datasets/brown.py +91 -0
- event2vector-0.3.0/event2vector/datasets/decay.py +146 -0
- event2vector-0.3.0/event2vector/datasets/groups.py +146 -0
- event2vector-0.3.0/event2vector/datasets/lifepath.py +199 -0
- event2vector-0.3.0/event2vector/datasets/realdata.py +458 -0
- event2vector-0.3.0/event2vector/eval/__init__.py +5 -0
- event2vector-0.3.0/event2vector/eval/figures.py +87 -0
- event2vector-0.3.0/event2vector/eval/harness.py +155 -0
- event2vector-0.3.0/event2vector/geometries.py +412 -0
- event2vector-0.3.0/event2vector/metrics.py +461 -0
- {event2vector-0.1.2.1 → event2vector-0.3.0}/event2vector/models.py +250 -68
- {event2vector-0.1.2.1 → event2vector-0.3.0}/event2vector.egg-info/PKG-INFO +70 -9
- event2vector-0.3.0/event2vector.egg-info/SOURCES.txt +26 -0
- {event2vector-0.1.2.1 → event2vector-0.3.0}/event2vector.egg-info/requires.txt +7 -4
- {event2vector-0.1.2.1 → event2vector-0.3.0}/pyproject.toml +11 -6
- event2vector-0.3.0/tests/test_datasets.py +82 -0
- event2vector-0.3.0/tests/test_models.py +360 -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.0}/MANIFEST.in +0 -0
- {event2vector-0.1.2.1 → event2vector-0.3.0}/event2vector.egg-info/dependency_links.txt +0 -0
- {event2vector-0.1.2.1 → event2vector-0.3.0}/event2vector.egg-info/top_level.txt +0 -0
- {event2vector-0.1.2.1 → event2vector-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: event2vector
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Scikit-learn-style geometric embeddings for event sequences.
|
|
5
5
|
Author: Antonin Sulc
|
|
6
6
|
License-Expression: MIT
|
|
@@ -12,12 +12,12 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Classifier: Operating System :: OS Independent
|
|
13
13
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
14
|
Classifier: Intended Audience :: Science/Research
|
|
15
|
-
Requires-Python: >=3.
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
|
-
Requires-Dist: numpy
|
|
18
|
-
Requires-Dist: torch
|
|
19
|
-
Requires-Dist: scikit-learn
|
|
20
|
-
Requires-Dist: tqdm
|
|
17
|
+
Requires-Dist: numpy>=1.21
|
|
18
|
+
Requires-Dist: torch>=1.13
|
|
19
|
+
Requires-Dist: scikit-learn>=1.0
|
|
20
|
+
Requires-Dist: tqdm>=4.0
|
|
21
21
|
Provides-Extra: viz
|
|
22
22
|
Requires-Dist: matplotlib; extra == "viz"
|
|
23
23
|
Requires-Dist: seaborn; extra == "viz"
|
|
@@ -25,6 +25,8 @@ Provides-Extra: manifold
|
|
|
25
25
|
Requires-Dist: openTSNE; extra == "manifold"
|
|
26
26
|
Provides-Extra: nlp
|
|
27
27
|
Requires-Dist: gensim; extra == "nlp"
|
|
28
|
+
Provides-Extra: hyperbolic
|
|
29
|
+
Requires-Dist: geoopt>=0.5; extra == "hyperbolic"
|
|
28
30
|
Provides-Extra: dev
|
|
29
31
|
Requires-Dist: pytest; extra == "dev"
|
|
30
32
|
Requires-Dist: black; extra == "dev"
|
|
@@ -132,15 +134,46 @@ Key methods:
|
|
|
132
134
|
|
|
133
135
|
Device control: set `use_gpu=False` to force CPU even if CUDA/MPS is present, or pass an explicit `device` (e.g., `"cuda:0"` or `"cpu"`).
|
|
134
136
|
|
|
137
|
+
## Geometries (v0.2)
|
|
135
138
|
|
|
139
|
+
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.
|
|
140
|
+
|
|
141
|
+
| `geometry` | update | best for |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `euclidean` | vector addition | order-invariant bag of events, exact decomposition |
|
|
144
|
+
| `hyperbolic` | Möbius addition (curvature `c>0`) | hierarchical / tree-like structure |
|
|
145
|
+
| `stereographic` | κ-gyroaddition (signed `kappa`) | unified curvature: `kappa<0` hyperbolic, `0` Euclidean, `>0` spherical |
|
|
146
|
+
| `spherical` | κ-gyroaddition (`kappa>0`) | cyclic / modular (periodic) structure |
|
|
147
|
+
| `rotation` | left `SO(n)` product | non-commutative orientation (order matters) |
|
|
148
|
+
| `heisenberg` | Heisenberg group product | order-dependent signed (Lévy) area |
|
|
149
|
+
| `product` | per-factor update | two structure types at once (e.g. hyperbolic × spherical) |
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from event2vector import Event2Vec
|
|
153
|
+
|
|
154
|
+
# one unified curvature dial: kappa<0 hyperbolic, 0 Euclidean, >0 spherical
|
|
155
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="stereographic", kappa=-1.0)
|
|
156
|
+
|
|
157
|
+
# non-commutative orientation (SO(3))
|
|
158
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="rotation", rotation_n=3)
|
|
159
|
+
|
|
160
|
+
# product of a hyperbolic and a spherical factor
|
|
161
|
+
model = Event2Vec(num_event_types=len(vocab), geometry="product", product_kappas=(-1.0, 1.0))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Reproduce the curvature sweep on a real knowledge graph (auto-downloads WN18RR):
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
python3 -m scripts.run_kg_curvature # next-relation accuracy vs kappa
|
|
168
|
+
```
|
|
136
169
|
|
|
137
170
|
## Brown Corpus POS tagging example
|
|
138
171
|
After installation, you can try to run Brown Part-of-Speech tagging example from the paper.
|
|
139
172
|
|
|
140
173
|
```bash
|
|
141
|
-
python3 -m
|
|
142
|
-
python3 -m
|
|
143
|
-
python3 -m
|
|
174
|
+
python3 -m examples.prepare_brown_data
|
|
175
|
+
python3 -m examples.train_brown_data
|
|
176
|
+
python3 -m examples.visualize_brown_corpus
|
|
144
177
|
```
|
|
145
178
|
|
|
146
179
|
|
|
@@ -154,6 +187,34 @@ python3 examples/minimal_example.py
|
|
|
154
187
|
|
|
155
188
|
To try a hyperbolic run, open `examples/minimal_example.py` and set `geometry="hyperbolic"` in the `Event2Vec` constructor, then rerun the script.
|
|
156
189
|
|
|
190
|
+
## Reproducing the paper experiments
|
|
191
|
+
|
|
192
|
+
The journal experiments are built around domains with *exact ground truth* for the
|
|
193
|
+
additive structure, so interpretability is measured rather than visualized:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# Physics (nuclear decay) + Math (group sequences): exact-ground-truth centerpiece
|
|
197
|
+
python3 -m scripts.run_centerpiece --seeds 5 --epochs 20 --out results/centerpiece
|
|
198
|
+
|
|
199
|
+
# Synthetic life-path workbench (order-sensitivity-vs-curvature, additivity decay)
|
|
200
|
+
python3 -m scripts.run_lifepath --seeds 5 --out results/lifepath
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Datasets live in `event2vector/datasets/` and all return a uniform record
|
|
204
|
+
(`sequences`, `processed_sequences`, `vocab`, `labels`, `graph`):
|
|
205
|
+
|
|
206
|
+
- `make_decay()`: radioactive decay chains; each event is an exact `(ΔZ, ΔN)` so
|
|
207
|
+
additivity coincides with conservation laws (metric: `operator_recovery`).
|
|
208
|
+
- `make_group(name)`: Cayley-graph walks on abelian/non-abelian groups; the
|
|
209
|
+
additive endpoint provably equals the abelianization (metric: `abelianization_gap`).
|
|
210
|
+
- `make_lifepath()`, `load_brown()`: controlled synthetic + real (POS) workbench.
|
|
211
|
+
- `load_gdelt()`, `load_sec_8k()`, `load_synthea()`, ...: real-world event streams
|
|
212
|
+
(require a public data download; see each loader's docstring).
|
|
213
|
+
|
|
214
|
+
Metrics (`event2vector.metrics`) and the seeded harness (`event2vector.eval`) are
|
|
215
|
+
reusable across datasets; baselines (GRU/LSTM/Transformer/Word2Vec) are in
|
|
216
|
+
`event2vector.baselines`. Run the test suite with `pytest tests/ -q`.
|
|
217
|
+
|
|
157
218
|
## References
|
|
158
219
|
For citations please use following Bibtex.
|
|
159
220
|
```bibtex
|
|
@@ -100,15 +100,46 @@ Key methods:
|
|
|
100
100
|
|
|
101
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"`).
|
|
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.0"
|
|
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):
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Dataset loaders returning a uniform record.
|
|
2
|
+
|
|
3
|
+
Each loader returns a dict with at least ``sequences`` (raw token sequences),
|
|
4
|
+
``processed_sequences`` (1-D ``LongTensor``s), ``vocab``/``inv_vocab``, and,
|
|
5
|
+
where available, ``labels`` (per-event-id ground-truth label) and ``graph``
|
|
6
|
+
(a transition graph for ground-truth structural evaluation).
|
|
7
|
+
"""
|
|
8
|
+
from .arithmetic import make_arithmetic
|
|
9
|
+
from .brown import load_brown
|
|
10
|
+
from .decay import make_decay
|
|
11
|
+
from .groups import make_group
|
|
12
|
+
from .lifepath import (
|
|
13
|
+
EVENT_TYPES,
|
|
14
|
+
INITIAL,
|
|
15
|
+
LIFEPATH_DOMAINS,
|
|
16
|
+
LIFEPATH_TRANSITIONS,
|
|
17
|
+
TERMINAL,
|
|
18
|
+
make_lifepath,
|
|
19
|
+
)
|
|
20
|
+
from .realdata import (
|
|
21
|
+
load_event_log,
|
|
22
|
+
load_gdelt,
|
|
23
|
+
load_moleculenet,
|
|
24
|
+
load_retailrocket,
|
|
25
|
+
load_sec_8k,
|
|
26
|
+
load_stackoverflow,
|
|
27
|
+
load_synthea,
|
|
28
|
+
load_wn18rr,
|
|
29
|
+
tokenize_smiles,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"make_arithmetic",
|
|
34
|
+
"make_lifepath",
|
|
35
|
+
"LIFEPATH_TRANSITIONS",
|
|
36
|
+
"LIFEPATH_DOMAINS",
|
|
37
|
+
"EVENT_TYPES",
|
|
38
|
+
"INITIAL",
|
|
39
|
+
"TERMINAL",
|
|
40
|
+
"make_decay",
|
|
41
|
+
"make_group",
|
|
42
|
+
"load_brown",
|
|
43
|
+
"load_event_log",
|
|
44
|
+
"load_gdelt",
|
|
45
|
+
"load_moleculenet",
|
|
46
|
+
"tokenize_smiles",
|
|
47
|
+
"load_retailrocket",
|
|
48
|
+
"load_sec_8k",
|
|
49
|
+
"load_synthea",
|
|
50
|
+
"load_stackoverflow",
|
|
51
|
+
"load_wn18rr",
|
|
52
|
+
]
|