event2vector 0.4.1__tar.gz → 0.5.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.4.1 → event2vector-0.5.0}/CHANGELOG.md +42 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/CITATION.cff +2 -2
- {event2vector-0.4.1/event2vector.egg-info → event2vector-0.5.0}/PKG-INFO +48 -4
- {event2vector-0.4.1 → event2vector-0.5.0}/README.md +46 -3
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/__init__.py +1 -1
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/metrics.py +36 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/models.py +296 -11
- {event2vector-0.4.1 → event2vector-0.5.0/event2vector.egg-info}/PKG-INFO +48 -4
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/SOURCES.txt +3 -1
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/requires.txt +1 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/pyproject.toml +2 -1
- event2vector-0.5.0/tests/test_reconstruction.py +214 -0
- event2vector-0.5.0/tests/test_root_cause.py +128 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/MANIFEST.in +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/baselines.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/data.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/__init__.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/arithmetic.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/brown.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/decay.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/groups.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/lifepath.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/realdata.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/eval/__init__.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/eval/figures.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/eval/harness.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/geometries.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/dependency_links.txt +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/top_level.txt +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/setup.cfg +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/tests/test_affine.py +0 -0
- {event2vector-0.4.1 → event2vector-0.5.0}/tests/test_device.py +0 -0
|
@@ -4,6 +4,48 @@ 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.5.0] - 2026-10-21
|
|
8
|
+
|
|
9
|
+
The Euclidean model gets a reconstruction objective that acts on whole histories
|
|
10
|
+
and a set of root-cause analysis methods built on the exact decomposition of its
|
|
11
|
+
state. Other geometries are unchanged; the API is backward compatible.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Root-cause analysis methods on `Event2Vec`: `attribute` (exact per-event
|
|
16
|
+
logit contributions for Euclidean, leave-one-out otherwise), `counterfactual`,
|
|
17
|
+
`influence_matrix`, `event_impact`, and for Euclidean `decompose` and
|
|
18
|
+
`explain_difference` (event counts recovered from states by non-negative least
|
|
19
|
+
squares).
|
|
20
|
+
- `metrics.transition_graph_auc`, which scores the influence matrix against a
|
|
21
|
+
known transition graph.
|
|
22
|
+
- `lambda_difference` (experimental, off by default): a difference-reconstruction
|
|
23
|
+
term for the Euclidean model. Random pairs of histories in a padded batch must
|
|
24
|
+
have a state difference that decodes (with the same scale-invariant form) to
|
|
25
|
+
the events that differ. In our experiments it lowers the reconstruction error
|
|
26
|
+
and improves the decoding of sums, but does not improve the decoding of
|
|
27
|
+
differences; it is kept as an option for further work.
|
|
28
|
+
- `max_norm=None` disables the Euclidean norm clipping, so the state is the exact
|
|
29
|
+
sum of its events.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- The Euclidean reconstruction loss is upgraded to a history-level objective.
|
|
34
|
+
It reconstructs the normalized event histogram `c/n` of each history from its
|
|
35
|
+
additive embedding `Eᵀ c/n`, with the event-embedding matrix as a tied decoder:
|
|
36
|
+
`‖α* E Eᵀ c/n − c/n‖²`, where `α* = ⟨E Eᵀ c, c⟩ / ‖E Eᵀ c‖²` is the
|
|
37
|
+
closed-form best scale (no trainable parameter), so the loss does not compete
|
|
38
|
+
with prediction for the embedding norm. The embedding is rebuilt without dropout
|
|
39
|
+
or clipping so the gradient reaches `E`. The previous step-level term
|
|
40
|
+
`‖(h_t − e_t) − h_{t-1}‖²` is satisfied by construction of the additive update
|
|
41
|
+
(it acted only when `max_norm` clipping fired). The other geometries are
|
|
42
|
+
unchanged.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- An infinite `max_norm` previously produced NaN gradients (`0 · inf` in the
|
|
47
|
+
backward pass of the clip).
|
|
48
|
+
|
|
7
49
|
## [0.4.1] - 2026-10-15
|
|
8
50
|
|
|
9
51
|
Documentation and metadata only; the code is unchanged from 0.4.0.
|
|
@@ -5,9 +5,9 @@ type: software
|
|
|
5
5
|
authors:
|
|
6
6
|
- family-names: Sulc
|
|
7
7
|
given-names: Antonin
|
|
8
|
-
version: 0.
|
|
8
|
+
version: 0.5.0
|
|
9
9
|
doi: 10.5281/zenodo.23399003
|
|
10
|
-
date-released: 2026-10-
|
|
10
|
+
date-released: 2026-10-20
|
|
11
11
|
license: MIT
|
|
12
12
|
repository-code: "https://github.com/sulcantonin/event2vec_public"
|
|
13
13
|
url: "https://event2vec.com"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: event2vector
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Scikit-learn-style geometric embeddings for event sequences.
|
|
5
5
|
Author: Antonin Sulc
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
Requires-Dist: numpy>=1.21
|
|
19
19
|
Requires-Dist: torch>=1.13
|
|
20
20
|
Requires-Dist: scikit-learn>=1.0
|
|
21
|
+
Requires-Dist: scipy>=1.5
|
|
21
22
|
Requires-Dist: tqdm>=4.0
|
|
22
23
|
Provides-Extra: viz
|
|
23
24
|
Requires-Dist: matplotlib; extra == "viz"
|
|
@@ -131,7 +132,7 @@ step:
|
|
|
131
132
|
| term | weight | what it enforces |
|
|
132
133
|
|---|---|---|
|
|
133
134
|
| next-event prediction | 1 | a linear decoder predicts event `t+1` from `h_t` (cross-entropy) |
|
|
134
|
-
| reconstruction | `lambda_reconstruction` | the step `e_t`
|
|
135
|
+
| reconstruction | `lambda_reconstruction` | Euclidean: the history's event counts can be read back from its embedding with the tied decoder `E`, `‖α* E Eᵀ c/n − c/n‖²` with the closed-form best scale `α*`, so it does not fix the embedding norm; other geometries: the step `e_t` is recovered from `h_{t-1}` and `h_t` |
|
|
135
136
|
| consistency | `lambda_consistency` | two dropout passes over the same step agree |
|
|
136
137
|
|
|
137
138
|
There is no attention and no hidden nonlinearity in the state update, which is
|
|
@@ -141,7 +142,7 @@ what keeps every embedding a readable composition of its events. See the
|
|
|
141
142
|
## Geometries
|
|
142
143
|
|
|
143
144
|
Choose the composition with `geometry=`. Every geometry uses the same estimator
|
|
144
|
-
and a generic left-cancellation reconstruction loss.
|
|
145
|
+
and, apart from `euclidean`, a generic left-cancellation reconstruction loss.
|
|
145
146
|
|
|
146
147
|
| `geometry` | update | best for |
|
|
147
148
|
|---|---|---|
|
|
@@ -230,9 +231,10 @@ the state.
|
|
|
230
231
|
| `affine_max_log_scale` | `None` | bound on how much one event can stretch the `affine` state |
|
|
231
232
|
| `product_kappas` | `(-1.0, 1.0)` | one curvature per factor for `product` |
|
|
232
233
|
| `dropout_p` | `0.2` | dropout on each step's increment |
|
|
233
|
-
| `max_norm` | `10.0` | norm clipping of the Euclidean state |
|
|
234
|
+
| `max_norm` | `10.0` | norm clipping of the Euclidean state; `None` disables it, so the state is the exact sum of its events |
|
|
234
235
|
| `lambda_reconstruction` | `0.2` | weight of the reconstruction loss |
|
|
235
236
|
| `lambda_consistency` | `0.2` | weight of the consistency loss |
|
|
237
|
+
| `lambda_difference` | `0.0` | weight of the difference-reconstruction loss (Euclidean, needs `pad_sequences=True`): the difference of two histories' states must decode to the events that differ |
|
|
236
238
|
| `learning_rate` | `1e-3` | AdamW learning rate |
|
|
237
239
|
| `batch_size` | `32` | sequences per batch |
|
|
238
240
|
| `num_epochs` | `50` | training epochs |
|
|
@@ -250,10 +252,52 @@ the state.
|
|
|
250
252
|
| `transform(sequences, as_numpy=True)` | one state per sequence (NumPy array or tensor) |
|
|
251
253
|
| `fit_transform(sequences, ...)` | `fit`, then `transform` on the same sequences |
|
|
252
254
|
| `most_similar(positive, negative=None, topn=10)` | nearest event ids to events or whole sequences, with similarities |
|
|
255
|
+
| `attribute(sequence, target=None)` | each event's contribution to the logit of the next event `target` |
|
|
256
|
+
| `counterfactual(sequence, remove)` | next-event probabilities with and without the events at positions `remove` |
|
|
257
|
+
| `influence_matrix()` | `A[j, i]`: how much event `i` alone raises the logit of next event `j` |
|
|
258
|
+
| `event_impact()` | how strongly one occurrence of each event moves the next-event distribution |
|
|
259
|
+
| `decompose(sequence_or_state)` | event counts recovered from a state vector (Euclidean) |
|
|
260
|
+
| `explain_difference(sequence_a, sequence_b)` | signed counts of the events that differ, read from the two states (Euclidean) |
|
|
253
261
|
|
|
254
262
|
The underlying PyTorch module is `model.model` (event vectors in
|
|
255
263
|
`model.model.embedding.weight`).
|
|
256
264
|
|
|
265
|
+
### Root-cause analysis
|
|
266
|
+
|
|
267
|
+
In the Euclidean geometry the state is an exact weighted sum of the event
|
|
268
|
+
embeddings, `h_T = Σ_i w_i e_{s_i}`, and the decoder is linear. The weights `w_i`
|
|
269
|
+
are the products of the later norm-clipping factors (all `1` with
|
|
270
|
+
`max_norm=None`), so recent events weigh more once clipping fires. Each prediction
|
|
271
|
+
therefore splits exactly into per-event terms, which the methods above expose:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
est = Event2Vec(num_event_types=len(vocab), pad_sequences=True).fit(sequences)
|
|
275
|
+
|
|
276
|
+
out = est.attribute(history, target=vocab["job_loss"])
|
|
277
|
+
out["bias"] + out["contributions"].sum() # equals out["logit"]: one term per event
|
|
278
|
+
|
|
279
|
+
est.counterfactual(history, remove=[2])["delta"] # change in next-event probabilities
|
|
280
|
+
A = est.influence_matrix() # (V, V) event -> next-event influence
|
|
281
|
+
est.event_impact() # one score per event
|
|
282
|
+
est.decompose(history, round_counts=True) # which events a state contains
|
|
283
|
+
est.explain_difference(failing_run, healthy_run) # +: more in the failing run
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
| method | geometries | exact when |
|
|
287
|
+
|---|---|---|
|
|
288
|
+
| `attribute` | all | Euclidean: always (the terms sum to the logit). Others: leave-one-out differences, which do not sum to the logit |
|
|
289
|
+
| `counterfactual` | all | always (the edited sequence is re-encoded) |
|
|
290
|
+
| `influence_matrix`, `event_impact` | all | Euclidean with `max_norm=None`: `A = W_dec Eᵀ` and every sequence's logits are `bias + A @ counts` |
|
|
291
|
+
| `decompose`, `explain_difference` | Euclidean | `max_norm=None` and few enough distinct events for the embeddings to separate them; with clipping they estimate the clip-weighted counts |
|
|
292
|
+
|
|
293
|
+
`event2vector.metrics.transition_graph_auc(est, graph, vocab)` scores
|
|
294
|
+
`influence_matrix()` against a known transition graph (ROC AUC over all ordered
|
|
295
|
+
event pairs), e.g. `make_lifepath()["graph"]`.
|
|
296
|
+
|
|
297
|
+
These methods explain the model's predictions. They reflect associations learned
|
|
298
|
+
from the data, not causal effects, and the sum-based ones cannot tell event
|
|
299
|
+
orders apart when clipping does not fire.
|
|
300
|
+
|
|
257
301
|
### Devices
|
|
258
302
|
|
|
259
303
|
By default the estimator uses CUDA, then Apple MPS, then the CPU. On Apple
|
|
@@ -96,7 +96,7 @@ step:
|
|
|
96
96
|
| term | weight | what it enforces |
|
|
97
97
|
|---|---|---|
|
|
98
98
|
| next-event prediction | 1 | a linear decoder predicts event `t+1` from `h_t` (cross-entropy) |
|
|
99
|
-
| reconstruction | `lambda_reconstruction` | the step `e_t`
|
|
99
|
+
| reconstruction | `lambda_reconstruction` | Euclidean: the history's event counts can be read back from its embedding with the tied decoder `E`, `‖α* E Eᵀ c/n − c/n‖²` with the closed-form best scale `α*`, so it does not fix the embedding norm; other geometries: the step `e_t` is recovered from `h_{t-1}` and `h_t` |
|
|
100
100
|
| consistency | `lambda_consistency` | two dropout passes over the same step agree |
|
|
101
101
|
|
|
102
102
|
There is no attention and no hidden nonlinearity in the state update, which is
|
|
@@ -106,7 +106,7 @@ what keeps every embedding a readable composition of its events. See the
|
|
|
106
106
|
## Geometries
|
|
107
107
|
|
|
108
108
|
Choose the composition with `geometry=`. Every geometry uses the same estimator
|
|
109
|
-
and a generic left-cancellation reconstruction loss.
|
|
109
|
+
and, apart from `euclidean`, a generic left-cancellation reconstruction loss.
|
|
110
110
|
|
|
111
111
|
| `geometry` | update | best for |
|
|
112
112
|
|---|---|---|
|
|
@@ -195,9 +195,10 @@ the state.
|
|
|
195
195
|
| `affine_max_log_scale` | `None` | bound on how much one event can stretch the `affine` state |
|
|
196
196
|
| `product_kappas` | `(-1.0, 1.0)` | one curvature per factor for `product` |
|
|
197
197
|
| `dropout_p` | `0.2` | dropout on each step's increment |
|
|
198
|
-
| `max_norm` | `10.0` | norm clipping of the Euclidean state |
|
|
198
|
+
| `max_norm` | `10.0` | norm clipping of the Euclidean state; `None` disables it, so the state is the exact sum of its events |
|
|
199
199
|
| `lambda_reconstruction` | `0.2` | weight of the reconstruction loss |
|
|
200
200
|
| `lambda_consistency` | `0.2` | weight of the consistency loss |
|
|
201
|
+
| `lambda_difference` | `0.0` | weight of the difference-reconstruction loss (Euclidean, needs `pad_sequences=True`): the difference of two histories' states must decode to the events that differ |
|
|
201
202
|
| `learning_rate` | `1e-3` | AdamW learning rate |
|
|
202
203
|
| `batch_size` | `32` | sequences per batch |
|
|
203
204
|
| `num_epochs` | `50` | training epochs |
|
|
@@ -215,10 +216,52 @@ the state.
|
|
|
215
216
|
| `transform(sequences, as_numpy=True)` | one state per sequence (NumPy array or tensor) |
|
|
216
217
|
| `fit_transform(sequences, ...)` | `fit`, then `transform` on the same sequences |
|
|
217
218
|
| `most_similar(positive, negative=None, topn=10)` | nearest event ids to events or whole sequences, with similarities |
|
|
219
|
+
| `attribute(sequence, target=None)` | each event's contribution to the logit of the next event `target` |
|
|
220
|
+
| `counterfactual(sequence, remove)` | next-event probabilities with and without the events at positions `remove` |
|
|
221
|
+
| `influence_matrix()` | `A[j, i]`: how much event `i` alone raises the logit of next event `j` |
|
|
222
|
+
| `event_impact()` | how strongly one occurrence of each event moves the next-event distribution |
|
|
223
|
+
| `decompose(sequence_or_state)` | event counts recovered from a state vector (Euclidean) |
|
|
224
|
+
| `explain_difference(sequence_a, sequence_b)` | signed counts of the events that differ, read from the two states (Euclidean) |
|
|
218
225
|
|
|
219
226
|
The underlying PyTorch module is `model.model` (event vectors in
|
|
220
227
|
`model.model.embedding.weight`).
|
|
221
228
|
|
|
229
|
+
### Root-cause analysis
|
|
230
|
+
|
|
231
|
+
In the Euclidean geometry the state is an exact weighted sum of the event
|
|
232
|
+
embeddings, `h_T = Σ_i w_i e_{s_i}`, and the decoder is linear. The weights `w_i`
|
|
233
|
+
are the products of the later norm-clipping factors (all `1` with
|
|
234
|
+
`max_norm=None`), so recent events weigh more once clipping fires. Each prediction
|
|
235
|
+
therefore splits exactly into per-event terms, which the methods above expose:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
est = Event2Vec(num_event_types=len(vocab), pad_sequences=True).fit(sequences)
|
|
239
|
+
|
|
240
|
+
out = est.attribute(history, target=vocab["job_loss"])
|
|
241
|
+
out["bias"] + out["contributions"].sum() # equals out["logit"]: one term per event
|
|
242
|
+
|
|
243
|
+
est.counterfactual(history, remove=[2])["delta"] # change in next-event probabilities
|
|
244
|
+
A = est.influence_matrix() # (V, V) event -> next-event influence
|
|
245
|
+
est.event_impact() # one score per event
|
|
246
|
+
est.decompose(history, round_counts=True) # which events a state contains
|
|
247
|
+
est.explain_difference(failing_run, healthy_run) # +: more in the failing run
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
| method | geometries | exact when |
|
|
251
|
+
|---|---|---|
|
|
252
|
+
| `attribute` | all | Euclidean: always (the terms sum to the logit). Others: leave-one-out differences, which do not sum to the logit |
|
|
253
|
+
| `counterfactual` | all | always (the edited sequence is re-encoded) |
|
|
254
|
+
| `influence_matrix`, `event_impact` | all | Euclidean with `max_norm=None`: `A = W_dec Eᵀ` and every sequence's logits are `bias + A @ counts` |
|
|
255
|
+
| `decompose`, `explain_difference` | Euclidean | `max_norm=None` and few enough distinct events for the embeddings to separate them; with clipping they estimate the clip-weighted counts |
|
|
256
|
+
|
|
257
|
+
`event2vector.metrics.transition_graph_auc(est, graph, vocab)` scores
|
|
258
|
+
`influence_matrix()` against a known transition graph (ROC AUC over all ordered
|
|
259
|
+
event pairs), e.g. `make_lifepath()["graph"]`.
|
|
260
|
+
|
|
261
|
+
These methods explain the model's predictions. They reflect associations learned
|
|
262
|
+
from the data, not causal effects, and the sum-based ones cannot tell event
|
|
263
|
+
orders apart when clipping does not fire.
|
|
264
|
+
|
|
222
265
|
### Devices
|
|
223
266
|
|
|
224
267
|
By default the estimator uses CUDA, then Apple MPS, then the CPU. On Apple
|
|
@@ -459,3 +459,39 @@ def abelianization_gap(estimator, record, seed: int = 0, probe: str = "mlp") ->
|
|
|
459
459
|
"n_abelian": record["n_abelian"],
|
|
460
460
|
"is_abelian": record["is_abelian"],
|
|
461
461
|
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# --------------------------------------------------------------------------- #
|
|
465
|
+
# Root-cause analysis — does the influence matrix expose the generating graph?
|
|
466
|
+
# --------------------------------------------------------------------------- #
|
|
467
|
+
def transition_graph_auc(estimator, graph: dict, vocab: dict, influence=None) -> dict:
|
|
468
|
+
"""Score the estimator's influence matrix against a known transition graph.
|
|
469
|
+
|
|
470
|
+
``graph`` maps an event name to its successors, ``{name: [(next, prob), ...]}``
|
|
471
|
+
(as in ``make_lifepath()["graph"]``) and ``vocab`` maps names to event ids.
|
|
472
|
+
Every ordered pair ``i != j`` is a candidate edge ``i -> j`` scored by
|
|
473
|
+
``A[j, i]`` from ``estimator.influence_matrix()`` (or the given ``influence``);
|
|
474
|
+
the true edges are the positives.
|
|
475
|
+
|
|
476
|
+
Returns ``auc`` (ROC AUC), ``average_precision``, ``n_edges`` and
|
|
477
|
+
``n_pairs``. 0.5 AUC is chance.
|
|
478
|
+
"""
|
|
479
|
+
from sklearn.metrics import average_precision_score, roc_auc_score
|
|
480
|
+
|
|
481
|
+
A = estimator.influence_matrix() if influence is None else np.asarray(influence)
|
|
482
|
+
V = A.shape[0]
|
|
483
|
+
truth = np.zeros((V, V), dtype=bool) # truth[j, i]: edge i -> j
|
|
484
|
+
for src, succ in graph.items():
|
|
485
|
+
if src not in vocab:
|
|
486
|
+
continue
|
|
487
|
+
for dst, _ in succ:
|
|
488
|
+
if dst in vocab:
|
|
489
|
+
truth[vocab[dst], vocab[src]] = True
|
|
490
|
+
off = ~np.eye(V, dtype=bool)
|
|
491
|
+
y, s = truth[off], A[off]
|
|
492
|
+
return {
|
|
493
|
+
"auc": float(roc_auc_score(y, s)),
|
|
494
|
+
"average_precision": float(average_precision_score(y, s)),
|
|
495
|
+
"n_edges": int(y.sum()),
|
|
496
|
+
"n_pairs": int(y.size),
|
|
497
|
+
}
|
|
@@ -56,7 +56,9 @@ class EuclideanModel(nn.Module):
|
|
|
56
56
|
e = self.dropout(self.embedding(x))
|
|
57
57
|
h_next = e + h
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
# max_norm=None (or inf) disables clipping, keeping h_t an exact sum; an
|
|
60
|
+
# infinite coefficient would otherwise turn the gradient into 0 * inf = NaN.
|
|
61
|
+
if clipping and self.max_norm is not None and self.max_norm != float("inf"):
|
|
60
62
|
norm = torch.norm(h_next, p=2, dim=1, keepdim=True)
|
|
61
63
|
clip_coef = self.max_norm / (norm + 1e-6)
|
|
62
64
|
clip_coef = torch.min(clip_coef, torch.ones_like(clip_coef))
|
|
@@ -69,8 +71,7 @@ class EuclideanModel(nn.Module):
|
|
|
69
71
|
|
|
70
72
|
def forward(self, x, h=None):
|
|
71
73
|
# Returns (logits, next_state, increment). `increment` is the vector
|
|
72
|
-
# actually added at this step (post-dropout)
|
|
73
|
-
# reconstruction loss must be able to invert.
|
|
74
|
+
# actually added at this step (post-dropout).
|
|
74
75
|
h, e = self.encoder(x, h)
|
|
75
76
|
return self.decoder(h), h, e
|
|
76
77
|
|
|
@@ -179,6 +180,22 @@ class HyperbolicModel(nn.Module):
|
|
|
179
180
|
SequenceType = Union[torch.Tensor, Sequence[int]]
|
|
180
181
|
|
|
181
182
|
|
|
183
|
+
def _nonnegative_lstsq(A, b):
|
|
184
|
+
"""``argmin_{x >= 0} ||A x - b||``.
|
|
185
|
+
|
|
186
|
+
The active-set NNLS returns a sparse minimizer, which is what decoding event
|
|
187
|
+
counts needs when ``A`` has more columns than rows. Its default iteration cap
|
|
188
|
+
(3x the columns) is too small for some embeddings, so it gets a larger one and,
|
|
189
|
+
if that still fails, falls back to bounded-variable least squares.
|
|
190
|
+
"""
|
|
191
|
+
from scipy.optimize import lsq_linear, nnls
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
return nnls(A, b, maxiter=50 * A.shape[1])[0]
|
|
195
|
+
except RuntimeError:
|
|
196
|
+
return lsq_linear(A, b, bounds=(0.0, np.inf), method="bvls").x
|
|
197
|
+
|
|
198
|
+
|
|
182
199
|
class Event2Vec:
|
|
183
200
|
"""
|
|
184
201
|
High-level estimator that follows the fit/transform conventions of scikit-learn
|
|
@@ -202,6 +219,7 @@ class Event2Vec:
|
|
|
202
219
|
product_kappas: Sequence[float] = (-1.0, 1.0),
|
|
203
220
|
lambda_reconstruction: float = 0.2,
|
|
204
221
|
lambda_consistency: float = 0.2,
|
|
222
|
+
lambda_difference: float = 0.0,
|
|
205
223
|
learning_rate: float = 1e-3,
|
|
206
224
|
batch_size: int = 32,
|
|
207
225
|
num_epochs: int = 50,
|
|
@@ -240,6 +258,7 @@ class Event2Vec:
|
|
|
240
258
|
self.product_kappas = tuple(product_kappas)
|
|
241
259
|
self.lambda_reconstruction = lambda_reconstruction
|
|
242
260
|
self.lambda_consistency = lambda_consistency
|
|
261
|
+
self.lambda_difference = lambda_difference
|
|
243
262
|
self.learning_rate = learning_rate
|
|
244
263
|
self.batch_size = batch_size
|
|
245
264
|
self.num_epochs = num_epochs
|
|
@@ -582,6 +601,186 @@ class Event2Vec:
|
|
|
582
601
|
values, indices = torch.topk(sims, k=k)
|
|
583
602
|
return [(idx.item(), float(val)) for idx, val in zip(indices, values)]
|
|
584
603
|
|
|
604
|
+
# ------------------------------------------------------------------ #
|
|
605
|
+
# Root-cause analysis
|
|
606
|
+
#
|
|
607
|
+
# For the Euclidean geometry the state is a (clip-weighted) sum of event
|
|
608
|
+
# embeddings and the decoder is linear, so every quantity below is an exact
|
|
609
|
+
# decomposition of the model's own logits. For the other geometries the
|
|
610
|
+
# generic methods fall back to re-encoding (leave-one-out / forward passes),
|
|
611
|
+
# which is exact for the model but not additive. All of them explain the
|
|
612
|
+
# model's predictions; they are associations learned from data, not causal
|
|
613
|
+
# effects.
|
|
614
|
+
# ------------------------------------------------------------------ #
|
|
615
|
+
def _as_ids(self, sequence: SequenceType) -> List[int]:
|
|
616
|
+
if isinstance(sequence, torch.Tensor):
|
|
617
|
+
sequence = sequence.tolist()
|
|
618
|
+
return [int(i) for i in sequence]
|
|
619
|
+
|
|
620
|
+
def _next_logits(self, sequence: SequenceType) -> torch.Tensor:
|
|
621
|
+
"""Decoder logits for the event following ``sequence`` (dropout off)."""
|
|
622
|
+
ids = self._as_ids(sequence)
|
|
623
|
+
was_training = self.model.training
|
|
624
|
+
self.model.eval()
|
|
625
|
+
with torch.no_grad():
|
|
626
|
+
h = self.model.zero_state(1, self.device)
|
|
627
|
+
logits = self.model.decoder(h)
|
|
628
|
+
for i in ids:
|
|
629
|
+
logits, h, _ = self.model(torch.tensor([i], device=self.device), h)
|
|
630
|
+
if was_training:
|
|
631
|
+
self.model.train()
|
|
632
|
+
return logits[0]
|
|
633
|
+
|
|
634
|
+
def _clip_weights(self, ids: List[int]) -> torch.Tensor:
|
|
635
|
+
"""Euclidean weights ``w_i`` with ``h_T = sum_i w_i e_{s_i}`` exactly.
|
|
636
|
+
|
|
637
|
+
Step ``k`` scales ``h_{k-1} + e_k`` by ``gamma_k = min(1, max_norm / ||.||)``,
|
|
638
|
+
so ``w_i = prod_{k >= i} gamma_k``; all ones when clipping never fires.
|
|
639
|
+
"""
|
|
640
|
+
E = self.model.embedding.weight.detach()
|
|
641
|
+
max_norm = self.model.max_norm
|
|
642
|
+
h = torch.zeros(E.shape[1], device=E.device)
|
|
643
|
+
gammas = []
|
|
644
|
+
for i in ids:
|
|
645
|
+
v = h + E[i]
|
|
646
|
+
gamma = 1.0
|
|
647
|
+
if max_norm is not None and max_norm != float("inf"):
|
|
648
|
+
gamma = min(1.0, max_norm / (v.norm().item() + 1e-6))
|
|
649
|
+
gammas.append(gamma)
|
|
650
|
+
h = v * gamma
|
|
651
|
+
g = torch.tensor(gammas, device=E.device)
|
|
652
|
+
return torch.flip(torch.cumprod(torch.flip(g, [0]), 0), [0])
|
|
653
|
+
|
|
654
|
+
def _require_euclidean(self, method: str):
|
|
655
|
+
if self.geometry != "euclidean":
|
|
656
|
+
raise NotImplementedError(
|
|
657
|
+
f"{method} needs the state to be a sum of event embeddings, which only "
|
|
658
|
+
"holds for geometry='euclidean'."
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def attribute(self, sequence: SequenceType, target: Optional[int] = None) -> dict:
|
|
662
|
+
"""Contribution of each event in ``sequence`` to the logit of the next event ``target``.
|
|
663
|
+
|
|
664
|
+
``target`` defaults to the most likely next event. Euclidean: the logit is
|
|
665
|
+
decomposed exactly, ``logit = bias + sum_i contributions[i]`` with
|
|
666
|
+
``contributions[i] = weights[i] * (W_dec e_{s_i})[target]``, where ``weights``
|
|
667
|
+
are the norm-clipping weights (all ones with ``max_norm=None``), so recent
|
|
668
|
+
events weigh more when clipping fires. Other geometries: leave-one-out,
|
|
669
|
+
``contributions[i] = logit(sequence) - logit(sequence without position i)``,
|
|
670
|
+
which does not sum to the logit.
|
|
671
|
+
|
|
672
|
+
Returns a dict with ``target``, ``logit``, ``contributions`` (one per
|
|
673
|
+
position), ``weights`` (Euclidean only, else ``None``), ``bias`` and
|
|
674
|
+
``exact`` (whether the contributions sum to ``logit - bias``).
|
|
675
|
+
"""
|
|
676
|
+
self._check_is_fitted()
|
|
677
|
+
ids = self._as_ids(sequence)
|
|
678
|
+
if not ids:
|
|
679
|
+
raise ValueError("sequence must be non-empty.")
|
|
680
|
+
logits = self._next_logits(ids)
|
|
681
|
+
if target is None:
|
|
682
|
+
target = int(logits.argmax())
|
|
683
|
+
logit = float(logits[target])
|
|
684
|
+
if self.geometry == "euclidean":
|
|
685
|
+
E = self.model.embedding.weight.detach()
|
|
686
|
+
W = self.model.linear_dec.weight.detach()
|
|
687
|
+
b = self.model.linear_dec.bias.detach()
|
|
688
|
+
weights = self._clip_weights(ids)
|
|
689
|
+
contributions = weights * (E[ids] @ W[target])
|
|
690
|
+
return {
|
|
691
|
+
"target": target,
|
|
692
|
+
"logit": logit,
|
|
693
|
+
"contributions": contributions.cpu().numpy(),
|
|
694
|
+
"weights": weights.cpu().numpy(),
|
|
695
|
+
"bias": float(b[target]) if b is not None else 0.0,
|
|
696
|
+
"exact": True,
|
|
697
|
+
}
|
|
698
|
+
contributions = np.array([
|
|
699
|
+
logit - float(self._next_logits(ids[:i] + ids[i + 1:])[target])
|
|
700
|
+
for i in range(len(ids))
|
|
701
|
+
])
|
|
702
|
+
return {"target": target, "logit": logit, "contributions": contributions,
|
|
703
|
+
"weights": None, "bias": None, "exact": False}
|
|
704
|
+
|
|
705
|
+
def counterfactual(self, sequence: SequenceType, remove: Union[int, Sequence[int]]) -> dict:
|
|
706
|
+
"""Next-event distribution with and without the events at positions ``remove``.
|
|
707
|
+
|
|
708
|
+
Re-encodes the edited sequence, so it is exact for every geometry. Returns
|
|
709
|
+
``probs`` (factual), ``probs_counterfactual`` and ``delta`` (their
|
|
710
|
+
difference), each of length ``num_event_types``.
|
|
711
|
+
"""
|
|
712
|
+
self._check_is_fitted()
|
|
713
|
+
ids = self._as_ids(sequence)
|
|
714
|
+
drop = {remove} if isinstance(remove, int) else set(remove)
|
|
715
|
+
kept = [i for p, i in enumerate(ids) if p not in drop]
|
|
716
|
+
p = torch.softmax(self._next_logits(ids), dim=0).cpu().numpy()
|
|
717
|
+
q = torch.softmax(self._next_logits(kept), dim=0).cpu().numpy()
|
|
718
|
+
return {"probs": p, "probs_counterfactual": q, "delta": q - p}
|
|
719
|
+
|
|
720
|
+
def influence_matrix(self) -> np.ndarray:
|
|
721
|
+
"""``A[j, i]``: change in the logit of next event ``j`` caused by event ``i``
|
|
722
|
+
alone, ``A[j, i] = logit_j([i]) - logit_j([])``.
|
|
723
|
+
|
|
724
|
+
Euclidean without clipping this is exactly ``W_dec E^T``, and every
|
|
725
|
+
sequence's logits are ``bias + sum_i count_i A[:, i]``; with clipping it is
|
|
726
|
+
the single-event effect. Shape ``(num_event_types, num_event_types)``.
|
|
727
|
+
"""
|
|
728
|
+
self._check_is_fitted()
|
|
729
|
+
base = self._next_logits([])
|
|
730
|
+
cols = [self._next_logits([i]) - base for i in range(self.num_event_types)]
|
|
731
|
+
return torch.stack(cols, dim=1).cpu().numpy()
|
|
732
|
+
|
|
733
|
+
def event_impact(self) -> np.ndarray:
|
|
734
|
+
"""How strongly one occurrence of each event moves the next-event
|
|
735
|
+
distribution: the norm of its centered influence column
|
|
736
|
+
``||A[:, i] - mean(A[:, i])||`` (centering removes the softmax-invariant
|
|
737
|
+
shift). Shape ``(num_event_types,)``.
|
|
738
|
+
"""
|
|
739
|
+
A = self.influence_matrix()
|
|
740
|
+
return np.linalg.norm(A - A.mean(axis=0, keepdims=True), axis=0)
|
|
741
|
+
|
|
742
|
+
def decompose(self, item: Union[SequenceType, torch.Tensor], round_counts: bool = False) -> np.ndarray:
|
|
743
|
+
"""Recover event counts from a Euclidean state by non-negative least squares,
|
|
744
|
+
``min_{x >= 0} ||E^T x - h||``.
|
|
745
|
+
|
|
746
|
+
``item`` is a sequence (encoded first) or a state vector. Without clipping
|
|
747
|
+
the true counts are the unique solution whenever the history is short
|
|
748
|
+
enough relative to the coherence of the embeddings; with clipping the
|
|
749
|
+
result estimates the clip-weighted counts. ``round_counts`` rounds to
|
|
750
|
+
integers. Shape ``(num_event_types,)``.
|
|
751
|
+
"""
|
|
752
|
+
self._check_is_fitted()
|
|
753
|
+
self._require_euclidean("decompose")
|
|
754
|
+
h = self._state_of(item)
|
|
755
|
+
E = self.model.embedding.weight.detach().cpu().double().numpy()
|
|
756
|
+
x = _nonnegative_lstsq(E.T, h)
|
|
757
|
+
return np.round(x) if round_counts else x
|
|
758
|
+
|
|
759
|
+
def explain_difference(self, sequence_a: SequenceType, sequence_b: SequenceType,
|
|
760
|
+
round_counts: bool = False) -> np.ndarray:
|
|
761
|
+
"""Which events differ between two histories, read from their states alone.
|
|
762
|
+
|
|
763
|
+
Decodes ``h_a - h_b`` into signed counts (positive: more in ``a``) by
|
|
764
|
+
non-negative least squares on ``[E^T, -E^T]``. Without clipping
|
|
765
|
+
``h_a - h_b = E^T (c_a - c_b)`` exactly, so a failing run can be compared
|
|
766
|
+
with a healthy one. Shape ``(num_event_types,)``.
|
|
767
|
+
"""
|
|
768
|
+
self._check_is_fitted()
|
|
769
|
+
self._require_euclidean("explain_difference")
|
|
770
|
+
d = self._state_of(sequence_a) - self._state_of(sequence_b)
|
|
771
|
+
E = self.model.embedding.weight.detach().cpu().double().numpy()
|
|
772
|
+
x = _nonnegative_lstsq(np.concatenate([E.T, -E.T], axis=1), d)
|
|
773
|
+
diff = x[: self.num_event_types] - x[self.num_event_types:]
|
|
774
|
+
return np.round(diff) if round_counts else diff
|
|
775
|
+
|
|
776
|
+
def _state_of(self, item) -> np.ndarray:
|
|
777
|
+
if isinstance(item, torch.Tensor) and item.dtype.is_floating_point:
|
|
778
|
+
return item.detach().cpu().double().numpy()
|
|
779
|
+
if isinstance(item, np.ndarray) and np.issubdtype(item.dtype, np.floating):
|
|
780
|
+
return item.astype(np.float64)
|
|
781
|
+
ids = self._as_ids(item)
|
|
782
|
+
return self.transform([ids], as_numpy=True)[0].astype(np.float64)
|
|
783
|
+
|
|
585
784
|
def _standardize_sequences(
|
|
586
785
|
self, sequences: Iterable[SequenceType]
|
|
587
786
|
) -> List[torch.Tensor]:
|
|
@@ -598,12 +797,74 @@ class Event2Vec:
|
|
|
598
797
|
raise ValueError("At least one sequence is required.")
|
|
599
798
|
return processed
|
|
600
799
|
|
|
601
|
-
def
|
|
800
|
+
def _history_reconstruction_loss(self, counts):
|
|
801
|
+
"""Euclidean event-history reconstruction ``||alpha* E E^T c / n - c / n||^2``.
|
|
802
|
+
|
|
803
|
+
``counts`` is a ``(B, V)`` histogram of the events in each row's history
|
|
804
|
+
and ``n`` its length. The history embedding ``h = E^T c / n`` is rebuilt
|
|
805
|
+
from the counts (no dropout, no clipping, not detached) and decoded with
|
|
806
|
+
the same embedding matrix, so the gradient reaches ``E`` through both the
|
|
807
|
+
encoder and the decoder. Rows with an empty history are excluded; the
|
|
808
|
+
result is the mean over the remaining rows.
|
|
809
|
+
|
|
810
|
+
``alpha* = <E E^T c, c> / ||E E^T c||^2`` is the closed-form best scale
|
|
811
|
+
per row (no trainable parameter). It makes the loss invariant to the scale
|
|
812
|
+
of ``E``, so it scores only how well the embedding preserves the histogram
|
|
813
|
+
and does not pin the embedding norm (``||c||^2 sin^2`` of the angle between
|
|
814
|
+
``E E^T c`` and ``c``).
|
|
815
|
+
"""
|
|
816
|
+
n = counts.sum(dim=1, keepdim=True)
|
|
817
|
+
valid = n.squeeze(1) > 0
|
|
818
|
+
if not torch.any(valid):
|
|
819
|
+
return counts.new_zeros(())
|
|
820
|
+
c = counts[valid] / n[valid]
|
|
821
|
+
E = self.model.embedding.weight
|
|
822
|
+
c_hat = (c @ E) @ E.t()
|
|
823
|
+
alpha = (c_hat * c).sum(dim=1, keepdim=True) / (
|
|
824
|
+
c_hat.pow(2).sum(dim=1, keepdim=True).clamp_min(1e-12)
|
|
825
|
+
)
|
|
826
|
+
return (alpha * c_hat - c).pow(2).sum(dim=1).mean()
|
|
827
|
+
|
|
828
|
+
def _difference_reconstruction_loss(self, counts):
|
|
829
|
+
"""Euclidean difference reconstruction, the same scale-invariant form as
|
|
830
|
+
``_history_reconstruction_loss`` applied to differences of histories.
|
|
831
|
+
|
|
832
|
+
``counts`` is a ``(B, V)`` batch of histograms. Each row is paired with
|
|
833
|
+
another row of the batch (a random cyclic shift), both normalized by
|
|
834
|
+
their length, and the unit-normalized difference ``d`` must decode to
|
|
835
|
+
itself: ``min_alpha ||alpha E E^T d - d||^2``, which equals the squared
|
|
836
|
+
sine of the angle between ``E E^T d`` and ``d``. Pairs with identical
|
|
837
|
+
histograms are skipped. This trains ``h_a - h_b`` to be decodable into
|
|
838
|
+
the events that differ, which the history term alone does not.
|
|
839
|
+
"""
|
|
840
|
+
n = counts.sum(dim=1, keepdim=True)
|
|
841
|
+
valid = n.squeeze(1) > 0
|
|
842
|
+
if valid.sum() < 2:
|
|
843
|
+
return counts.new_zeros(())
|
|
844
|
+
c = counts[valid] / n[valid]
|
|
845
|
+
shift = int(torch.randint(1, c.shape[0], (1,)).item())
|
|
846
|
+
d = c - torch.roll(c, shifts=shift, dims=0)
|
|
847
|
+
norm = d.norm(dim=1, keepdim=True)
|
|
848
|
+
keep = norm.squeeze(1) > 1e-8
|
|
849
|
+
if not torch.any(keep):
|
|
850
|
+
return counts.new_zeros(())
|
|
851
|
+
d = d[keep] / norm[keep]
|
|
852
|
+
E = self.model.embedding.weight
|
|
853
|
+
d_hat = (d @ E) @ E.t()
|
|
854
|
+
alpha = (d_hat * d).sum(dim=1, keepdim=True) / (
|
|
855
|
+
d_hat.pow(2).sum(dim=1, keepdim=True).clamp_min(1e-12)
|
|
856
|
+
)
|
|
857
|
+
return (alpha * d_hat - d).pow(2).sum(dim=1).mean()
|
|
858
|
+
|
|
859
|
+
def _compute_losses(self, y1, h1, e_curr1, h_old, target, h2, counts=None):
|
|
602
860
|
"""Per-step prediction / reconstruction / consistency losses.
|
|
603
861
|
|
|
604
|
-
Reconstruction
|
|
605
|
-
- Euclidean:
|
|
606
|
-
|
|
862
|
+
Reconstruction:
|
|
863
|
+
- Euclidean: reconstructs the normalized event histogram ``counts`` of
|
|
864
|
+
the history from its additive embedding with the tied decoder ``E``
|
|
865
|
+
(see ``_history_reconstruction_loss``). The previous
|
|
866
|
+
``(h_t - e_t) == h_{t-1}`` form was identically zero by construction
|
|
867
|
+
of ``h_t = h_{t-1} + e_t`` (non-zero only when norm clipping fired).
|
|
607
868
|
- Hyperbolic: the gyrogroup *left-cancellation* law recovers the
|
|
608
869
|
increment exactly, ``(-h_{t-1}) ⊕_c h_t == e_t``. (The previous
|
|
609
870
|
``(h_t ⊕_c -e_t)`` form is a right-cancellation and carries a
|
|
@@ -612,8 +873,7 @@ class Event2Vec:
|
|
|
612
873
|
"""
|
|
613
874
|
prediction_loss = self.ce_loss(y1, target)
|
|
614
875
|
if self.geometry == "euclidean":
|
|
615
|
-
|
|
616
|
-
reconstruction_loss = self.mse_loss(h_reconstructed, h_old)
|
|
876
|
+
reconstruction_loss = self._history_reconstruction_loss(counts)
|
|
617
877
|
consistency_loss = self.mse_loss(h1, h2)
|
|
618
878
|
elif self.geometry == "hyperbolic":
|
|
619
879
|
e_recovered = HyperbolicUtils.mobius_add(-h_old, h1, self.curvature)
|
|
@@ -633,6 +893,12 @@ class Event2Vec:
|
|
|
633
893
|
consistency_loss = self.model.dist2(h1, h2).mean()
|
|
634
894
|
return prediction_loss, reconstruction_loss, consistency_loss
|
|
635
895
|
|
|
896
|
+
def _zero_counts(self, batch):
|
|
897
|
+
"""Empty event histograms for the Euclidean reconstruction loss (else None)."""
|
|
898
|
+
if self.geometry != "euclidean":
|
|
899
|
+
return None
|
|
900
|
+
return torch.zeros((batch, self.num_event_types), device=self.device)
|
|
901
|
+
|
|
636
902
|
def _train_epoch(self, sequences, optimizer, use_padding: bool) -> float:
|
|
637
903
|
shuffled = sequences.copy()
|
|
638
904
|
self._rng.shuffle(shuffled)
|
|
@@ -660,15 +926,19 @@ class Event2Vec:
|
|
|
660
926
|
continue
|
|
661
927
|
seq = seq.to(self.device)
|
|
662
928
|
h = self.model.zero_state(1, self.device)
|
|
929
|
+
counts = self._zero_counts(1)
|
|
663
930
|
for t in range(seq.numel() - 1):
|
|
664
931
|
x = seq[t].unsqueeze(0)
|
|
665
932
|
target = seq[t + 1].unsqueeze(0)
|
|
666
933
|
h_old = h.detach()
|
|
667
934
|
y1, h1, e_curr1 = self.model(x, h_old)
|
|
668
935
|
_, h2, _ = self.model(x, h_old)
|
|
936
|
+
if counts is not None:
|
|
937
|
+
# Histogram of seq[: t + 1], the events summed into h1.
|
|
938
|
+
counts = counts + nn.functional.one_hot(x, self.num_event_types)
|
|
669
939
|
|
|
670
940
|
prediction_loss, reconstruction_loss, consistency_loss = (
|
|
671
|
-
self._compute_losses(y1, h1, e_curr1, h_old, target, h2)
|
|
941
|
+
self._compute_losses(y1, h1, e_curr1, h_old, target, h2, counts)
|
|
672
942
|
)
|
|
673
943
|
combined_loss = (
|
|
674
944
|
prediction_loss
|
|
@@ -700,6 +970,7 @@ class Event2Vec:
|
|
|
700
970
|
)
|
|
701
971
|
|
|
702
972
|
h = self.model.zero_state(len(sequences), self.device)
|
|
973
|
+
counts = self._zero_counts(len(sequences))
|
|
703
974
|
total_loss = torch.tensor(0.0, device=self.device)
|
|
704
975
|
total_transitions = 0
|
|
705
976
|
|
|
@@ -714,15 +985,29 @@ class Event2Vec:
|
|
|
714
985
|
|
|
715
986
|
y1, h1, e_curr1 = self.model(x, h_old)
|
|
716
987
|
_, h2, _ = self.model(x, h_old)
|
|
988
|
+
step_counts = None
|
|
989
|
+
if counts is not None:
|
|
990
|
+
# Only rows still inside their sequence are updated, so padding
|
|
991
|
+
# never enters a histogram.
|
|
992
|
+
counts = counts.index_add(
|
|
993
|
+
0, idx, nn.functional.one_hot(x, self.num_event_types).to(counts.dtype)
|
|
994
|
+
)
|
|
995
|
+
step_counts = counts[idx]
|
|
717
996
|
|
|
718
997
|
prediction_loss, reconstruction_loss, consistency_loss = (
|
|
719
|
-
self._compute_losses(y1, h1, e_curr1, h_old, target, h2)
|
|
998
|
+
self._compute_losses(y1, h1, e_curr1, h_old, target, h2, step_counts)
|
|
720
999
|
)
|
|
721
1000
|
combined_loss = (
|
|
722
1001
|
prediction_loss
|
|
723
1002
|
+ self.lambda_reconstruction * reconstruction_loss
|
|
724
1003
|
+ self.lambda_consistency * consistency_loss
|
|
725
1004
|
)
|
|
1005
|
+
if self.lambda_difference > 0 and step_counts is not None:
|
|
1006
|
+
# Differences need at least two histories, which only the padded
|
|
1007
|
+
# loop (several sequences per step) provides.
|
|
1008
|
+
combined_loss = combined_loss + self.lambda_difference * (
|
|
1009
|
+
self._difference_reconstruction_loss(step_counts)
|
|
1010
|
+
)
|
|
726
1011
|
step_transitions = idx.numel()
|
|
727
1012
|
total_loss = total_loss + combined_loss * step_transitions
|
|
728
1013
|
total_transitions += step_transitions
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: event2vector
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Scikit-learn-style geometric embeddings for event sequences.
|
|
5
5
|
Author: Antonin Sulc
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
Requires-Dist: numpy>=1.21
|
|
19
19
|
Requires-Dist: torch>=1.13
|
|
20
20
|
Requires-Dist: scikit-learn>=1.0
|
|
21
|
+
Requires-Dist: scipy>=1.5
|
|
21
22
|
Requires-Dist: tqdm>=4.0
|
|
22
23
|
Provides-Extra: viz
|
|
23
24
|
Requires-Dist: matplotlib; extra == "viz"
|
|
@@ -131,7 +132,7 @@ step:
|
|
|
131
132
|
| term | weight | what it enforces |
|
|
132
133
|
|---|---|---|
|
|
133
134
|
| next-event prediction | 1 | a linear decoder predicts event `t+1` from `h_t` (cross-entropy) |
|
|
134
|
-
| reconstruction | `lambda_reconstruction` | the step `e_t`
|
|
135
|
+
| reconstruction | `lambda_reconstruction` | Euclidean: the history's event counts can be read back from its embedding with the tied decoder `E`, `‖α* E Eᵀ c/n − c/n‖²` with the closed-form best scale `α*`, so it does not fix the embedding norm; other geometries: the step `e_t` is recovered from `h_{t-1}` and `h_t` |
|
|
135
136
|
| consistency | `lambda_consistency` | two dropout passes over the same step agree |
|
|
136
137
|
|
|
137
138
|
There is no attention and no hidden nonlinearity in the state update, which is
|
|
@@ -141,7 +142,7 @@ what keeps every embedding a readable composition of its events. See the
|
|
|
141
142
|
## Geometries
|
|
142
143
|
|
|
143
144
|
Choose the composition with `geometry=`. Every geometry uses the same estimator
|
|
144
|
-
and a generic left-cancellation reconstruction loss.
|
|
145
|
+
and, apart from `euclidean`, a generic left-cancellation reconstruction loss.
|
|
145
146
|
|
|
146
147
|
| `geometry` | update | best for |
|
|
147
148
|
|---|---|---|
|
|
@@ -230,9 +231,10 @@ the state.
|
|
|
230
231
|
| `affine_max_log_scale` | `None` | bound on how much one event can stretch the `affine` state |
|
|
231
232
|
| `product_kappas` | `(-1.0, 1.0)` | one curvature per factor for `product` |
|
|
232
233
|
| `dropout_p` | `0.2` | dropout on each step's increment |
|
|
233
|
-
| `max_norm` | `10.0` | norm clipping of the Euclidean state |
|
|
234
|
+
| `max_norm` | `10.0` | norm clipping of the Euclidean state; `None` disables it, so the state is the exact sum of its events |
|
|
234
235
|
| `lambda_reconstruction` | `0.2` | weight of the reconstruction loss |
|
|
235
236
|
| `lambda_consistency` | `0.2` | weight of the consistency loss |
|
|
237
|
+
| `lambda_difference` | `0.0` | weight of the difference-reconstruction loss (Euclidean, needs `pad_sequences=True`): the difference of two histories' states must decode to the events that differ |
|
|
236
238
|
| `learning_rate` | `1e-3` | AdamW learning rate |
|
|
237
239
|
| `batch_size` | `32` | sequences per batch |
|
|
238
240
|
| `num_epochs` | `50` | training epochs |
|
|
@@ -250,10 +252,52 @@ the state.
|
|
|
250
252
|
| `transform(sequences, as_numpy=True)` | one state per sequence (NumPy array or tensor) |
|
|
251
253
|
| `fit_transform(sequences, ...)` | `fit`, then `transform` on the same sequences |
|
|
252
254
|
| `most_similar(positive, negative=None, topn=10)` | nearest event ids to events or whole sequences, with similarities |
|
|
255
|
+
| `attribute(sequence, target=None)` | each event's contribution to the logit of the next event `target` |
|
|
256
|
+
| `counterfactual(sequence, remove)` | next-event probabilities with and without the events at positions `remove` |
|
|
257
|
+
| `influence_matrix()` | `A[j, i]`: how much event `i` alone raises the logit of next event `j` |
|
|
258
|
+
| `event_impact()` | how strongly one occurrence of each event moves the next-event distribution |
|
|
259
|
+
| `decompose(sequence_or_state)` | event counts recovered from a state vector (Euclidean) |
|
|
260
|
+
| `explain_difference(sequence_a, sequence_b)` | signed counts of the events that differ, read from the two states (Euclidean) |
|
|
253
261
|
|
|
254
262
|
The underlying PyTorch module is `model.model` (event vectors in
|
|
255
263
|
`model.model.embedding.weight`).
|
|
256
264
|
|
|
265
|
+
### Root-cause analysis
|
|
266
|
+
|
|
267
|
+
In the Euclidean geometry the state is an exact weighted sum of the event
|
|
268
|
+
embeddings, `h_T = Σ_i w_i e_{s_i}`, and the decoder is linear. The weights `w_i`
|
|
269
|
+
are the products of the later norm-clipping factors (all `1` with
|
|
270
|
+
`max_norm=None`), so recent events weigh more once clipping fires. Each prediction
|
|
271
|
+
therefore splits exactly into per-event terms, which the methods above expose:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
est = Event2Vec(num_event_types=len(vocab), pad_sequences=True).fit(sequences)
|
|
275
|
+
|
|
276
|
+
out = est.attribute(history, target=vocab["job_loss"])
|
|
277
|
+
out["bias"] + out["contributions"].sum() # equals out["logit"]: one term per event
|
|
278
|
+
|
|
279
|
+
est.counterfactual(history, remove=[2])["delta"] # change in next-event probabilities
|
|
280
|
+
A = est.influence_matrix() # (V, V) event -> next-event influence
|
|
281
|
+
est.event_impact() # one score per event
|
|
282
|
+
est.decompose(history, round_counts=True) # which events a state contains
|
|
283
|
+
est.explain_difference(failing_run, healthy_run) # +: more in the failing run
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
| method | geometries | exact when |
|
|
287
|
+
|---|---|---|
|
|
288
|
+
| `attribute` | all | Euclidean: always (the terms sum to the logit). Others: leave-one-out differences, which do not sum to the logit |
|
|
289
|
+
| `counterfactual` | all | always (the edited sequence is re-encoded) |
|
|
290
|
+
| `influence_matrix`, `event_impact` | all | Euclidean with `max_norm=None`: `A = W_dec Eᵀ` and every sequence's logits are `bias + A @ counts` |
|
|
291
|
+
| `decompose`, `explain_difference` | Euclidean | `max_norm=None` and few enough distinct events for the embeddings to separate them; with clipping they estimate the clip-weighted counts |
|
|
292
|
+
|
|
293
|
+
`event2vector.metrics.transition_graph_auc(est, graph, vocab)` scores
|
|
294
|
+
`influence_matrix()` against a known transition graph (ROC AUC over all ordered
|
|
295
|
+
event pairs), e.g. `make_lifepath()["graph"]`.
|
|
296
|
+
|
|
297
|
+
These methods explain the model's predictions. They reflect associations learned
|
|
298
|
+
from the data, not causal effects, and the sum-based ones cannot tell event
|
|
299
|
+
orders apart when clipping does not fire.
|
|
300
|
+
|
|
257
301
|
### Devices
|
|
258
302
|
|
|
259
303
|
By default the estimator uses CUDA, then Apple MPS, then the CPU. On Apple
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "event2vector"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Scikit-learn-style geometric embeddings for event sequences."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -34,6 +34,7 @@ dependencies = [
|
|
|
34
34
|
"numpy>=1.21",
|
|
35
35
|
"torch>=1.13",
|
|
36
36
|
"scikit-learn>=1.0",
|
|
37
|
+
"scipy>=1.5",
|
|
37
38
|
"tqdm>=4.0"
|
|
38
39
|
]
|
|
39
40
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Tests for the Euclidean event-history reconstruction loss.
|
|
2
|
+
|
|
3
|
+
``L_recon = sum_t ||alpha* E (h_t - e_{s_t}) / (t-1) - c_{t-1} / (t-1)||^2`` with
|
|
4
|
+
``h_{t-1} = E^T c_{t-1}`` and the closed-form scale
|
|
5
|
+
``alpha* = <E E^T c, c> / ||E E^T c||^2``. Run with `pytest tests/ -q`.
|
|
6
|
+
"""
|
|
7
|
+
import pytest
|
|
8
|
+
import torch
|
|
9
|
+
|
|
10
|
+
from event2vector import Event2Vec
|
|
11
|
+
|
|
12
|
+
V, D = 6, 4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _est(**kw):
|
|
16
|
+
kw = dict(dict(num_event_types=V, embedding_dim=D, dropout_p=0.0,
|
|
17
|
+
use_gpu=False, random_state=0), **kw)
|
|
18
|
+
est = Event2Vec(**kw)
|
|
19
|
+
torch.manual_seed(0)
|
|
20
|
+
with torch.no_grad():
|
|
21
|
+
est.model.embedding.weight.normal_(0.0, 0.5)
|
|
22
|
+
return est
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _record_counts(est, monkeypatch):
|
|
26
|
+
"""Capture every histogram handed to the reconstruction loss."""
|
|
27
|
+
seen = []
|
|
28
|
+
original = est._history_reconstruction_loss
|
|
29
|
+
|
|
30
|
+
def wrapper(counts):
|
|
31
|
+
seen.append(counts.detach().clone())
|
|
32
|
+
return original(counts)
|
|
33
|
+
|
|
34
|
+
monkeypatch.setattr(est, "_history_reconstruction_loss", wrapper)
|
|
35
|
+
return seen
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _batch_loss(est, seqs, padded):
|
|
39
|
+
opt = torch.optim.SGD(est.model.parameters(), lr=0.0)
|
|
40
|
+
seqs = [torch.tensor(s) for s in seqs]
|
|
41
|
+
if padded:
|
|
42
|
+
return est._train_batch_padded(seqs, opt)
|
|
43
|
+
return est._train_batch_unpadded(seqs, opt)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_loss_is_nonzero_for_random_embeddings():
|
|
47
|
+
est = _est()
|
|
48
|
+
counts = torch.randint(0, 3, (8, V)).float()
|
|
49
|
+
counts[:, 0] += 1 # no empty histories
|
|
50
|
+
assert est._history_reconstruction_loss(counts).item() > 1e-3
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_loss_has_nonzero_embedding_gradient():
|
|
54
|
+
est = _est()
|
|
55
|
+
counts = torch.tensor([[2.0, 1.0, 0.0, 0.0, 1.0, 0.0]])
|
|
56
|
+
est._history_reconstruction_loss(counts).backward()
|
|
57
|
+
assert est.model.embedding.weight.grad.norm().item() > 1e-4
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize("padded", [False, True])
|
|
61
|
+
def test_histogram_counts_repeated_events(monkeypatch, padded):
|
|
62
|
+
est = _est()
|
|
63
|
+
seen = _record_counts(est, monkeypatch)
|
|
64
|
+
_batch_loss(est, [[3, 3, 1, 3, 0]], padded)
|
|
65
|
+
expected = [{3: 1}, {3: 2}, {3: 2, 1: 1}, {3: 3, 1: 1}] # last event is only a target
|
|
66
|
+
assert len(seen) == len(expected)
|
|
67
|
+
for counts, exp in zip(seen, expected):
|
|
68
|
+
target = torch.zeros(1, V)
|
|
69
|
+
for k, v in exp.items():
|
|
70
|
+
target[0, k] = v
|
|
71
|
+
assert torch.equal(counts, target)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_loss_is_invariant_to_embedding_scale():
|
|
75
|
+
est = _est()
|
|
76
|
+
counts = torch.tensor([[1.0, 0.0, 2.0, 0.0, 3.0, 1.0], [0.0, 4.0, 0.0, 1.0, 0.0, 0.0]])
|
|
77
|
+
before = est._history_reconstruction_loss(counts)
|
|
78
|
+
with torch.no_grad():
|
|
79
|
+
est.model.embedding.weight.mul_(7.0)
|
|
80
|
+
assert torch.allclose(est._history_reconstruction_loss(counts), before, rtol=1e-4)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_empty_histories_are_excluded():
|
|
84
|
+
est = _est()
|
|
85
|
+
counts = torch.tensor([[1.0, 0.0, 2.0, 0.0, 0.0, 1.0]])
|
|
86
|
+
with_empty = torch.cat([counts, torch.zeros(2, V)])
|
|
87
|
+
assert torch.allclose(est._history_reconstruction_loss(with_empty),
|
|
88
|
+
est._history_reconstruction_loss(counts))
|
|
89
|
+
assert est._history_reconstruction_loss(torch.zeros(3, V)).item() == 0.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_padding_is_excluded(monkeypatch):
|
|
93
|
+
# pad_value=0 is also a real event id, so a leaked pad would change the counts.
|
|
94
|
+
seqs = [[0, 1, 2, 3, 4], [2, 2], [5, 0, 5]]
|
|
95
|
+
padded_est, unpadded_est = _est(pad_value=0), _est(pad_value=0)
|
|
96
|
+
seen = _record_counts(padded_est, monkeypatch)
|
|
97
|
+
loss_padded = _batch_loss(padded_est, seqs, padded=True)
|
|
98
|
+
loss_unpadded = _batch_loss(unpadded_est, seqs, padded=False)
|
|
99
|
+
assert loss_padded == pytest.approx(loss_unpadded, rel=1e-5)
|
|
100
|
+
# every row's histogram length equals its step index + 1, never more
|
|
101
|
+
for t, counts in enumerate(seen):
|
|
102
|
+
assert torch.all(counts.sum(dim=1) == t + 1)
|
|
103
|
+
assert seen[-1].shape[0] == 1 # only the longest sequence is left at the end
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_batched_loss_matches_definition():
|
|
107
|
+
seqs = [[0, 1, 1, 4, 2], [3, 3, 5], [2, 0, 2, 2]]
|
|
108
|
+
est = _est(lambda_consistency=0.0)
|
|
109
|
+
E = est.model.embedding.weight.detach()
|
|
110
|
+
|
|
111
|
+
# Reference: sum_{t=2}^{T} ||alpha* E (h_t - e_{s_t}) - c_{t-1}||^2 / (t-1)^2,
|
|
112
|
+
# with the additive state built step by step (no clipping, no dropout).
|
|
113
|
+
terms = []
|
|
114
|
+
for seq in seqs:
|
|
115
|
+
h = torch.zeros(D)
|
|
116
|
+
c = torch.zeros(V)
|
|
117
|
+
for t in range(1, len(seq)): # s_t = seq[t] (0-based), history seq[:t]
|
|
118
|
+
h = h + E[seq[t - 1]]
|
|
119
|
+
c[seq[t - 1]] += 1
|
|
120
|
+
h_t = h + E[seq[t]]
|
|
121
|
+
c_hat = E @ (h_t - E[seq[t]])
|
|
122
|
+
alpha = (c_hat @ c) / (c_hat @ c_hat)
|
|
123
|
+
terms.append(((alpha * c_hat - c) / t).pow(2).sum())
|
|
124
|
+
reference = torch.stack(terms).mean().item()
|
|
125
|
+
|
|
126
|
+
for padded in (False, True):
|
|
127
|
+
est.lambda_reconstruction = 0.0
|
|
128
|
+
base = _batch_loss(est, seqs, padded)
|
|
129
|
+
est.lambda_reconstruction = 1.0
|
|
130
|
+
full = _batch_loss(est, seqs, padded)
|
|
131
|
+
assert full - base == pytest.approx(reference, rel=1e-4)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_reconstruction_gradient_reaches_embedding_in_training():
|
|
135
|
+
est = _est(lambda_consistency=0.0)
|
|
136
|
+
seqs = [torch.tensor([0, 1, 2, 3])]
|
|
137
|
+
grads = {}
|
|
138
|
+
for lam in (0.0, 1.0):
|
|
139
|
+
est.lambda_reconstruction = lam
|
|
140
|
+
est.model.zero_grad()
|
|
141
|
+
opt = torch.optim.SGD(est.model.parameters(), lr=0.0)
|
|
142
|
+
opt.zero_grad = lambda *a, **k: None # keep the grads for inspection
|
|
143
|
+
est._train_batch_unpadded(seqs, opt)
|
|
144
|
+
grads[lam] = est.model.embedding.weight.grad.clone()
|
|
145
|
+
assert (grads[1.0] - grads[0.0]).norm().item() > 1e-4
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize("lam", [0.0, 0.2])
|
|
149
|
+
def test_next_event_prediction_still_learns(lam):
|
|
150
|
+
seqs = [[0, 1, 2, 3, 4]] * 16
|
|
151
|
+
est = Event2Vec(num_event_types=5, embedding_dim=8, dropout_p=0.0,
|
|
152
|
+
lambda_reconstruction=lam, num_epochs=60, learning_rate=1e-2,
|
|
153
|
+
batch_size=8, use_gpu=False, random_state=0)
|
|
154
|
+
est.fit(seqs)
|
|
155
|
+
est.model.eval()
|
|
156
|
+
h = est.model.zero_state(1, est.device)
|
|
157
|
+
correct = 0
|
|
158
|
+
with torch.no_grad():
|
|
159
|
+
for t in range(4):
|
|
160
|
+
logits, h, _ = est.model(torch.tensor([t]), h)
|
|
161
|
+
correct += int(logits.argmax(dim=1).item() == t + 1)
|
|
162
|
+
assert correct == 4
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize("max_norm", [None, float("inf")])
|
|
166
|
+
def test_unclipped_state_is_exact_sum_and_trains(max_norm):
|
|
167
|
+
est = _est(max_norm=max_norm, num_epochs=2, batch_size=2)
|
|
168
|
+
E = est.model.embedding.weight.detach().clone()
|
|
169
|
+
h = est.model.zero_state(1, est.device)
|
|
170
|
+
for i in [3, 1, 3, 5]:
|
|
171
|
+
h = est.model(torch.tensor([i]), h)[1]
|
|
172
|
+
assert torch.allclose(h[0], E[[3, 1, 3, 5]].sum(0), atol=1e-5)
|
|
173
|
+
est.fit([[0, 1, 2, 3], [3, 3, 1], [5, 0, 5, 2]])
|
|
174
|
+
assert all(torch.isfinite(p).all() for p in est.model.parameters())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_difference_loss_is_nonzero_scale_invariant_and_trains():
|
|
178
|
+
est = _est(lambda_difference=0.5)
|
|
179
|
+
counts = torch.tensor([[2.0, 1.0, 0.0, 0.0, 1.0, 0.0], [0.0, 1.0, 3.0, 0.0, 0.0, 1.0],
|
|
180
|
+
[1.0, 0.0, 0.0, 2.0, 0.0, 0.0]])
|
|
181
|
+
torch.manual_seed(0)
|
|
182
|
+
loss = est._difference_reconstruction_loss(counts)
|
|
183
|
+
assert loss.item() > 1e-3
|
|
184
|
+
loss.backward()
|
|
185
|
+
assert est.model.embedding.weight.grad.norm().item() > 1e-4
|
|
186
|
+
with torch.no_grad():
|
|
187
|
+
est.model.embedding.weight.mul_(5.0)
|
|
188
|
+
torch.manual_seed(0)
|
|
189
|
+
assert torch.allclose(est._difference_reconstruction_loss(counts), loss, rtol=1e-4)
|
|
190
|
+
# identical histograms give no pair; fewer than two rows give no pair
|
|
191
|
+
same = counts[:1].repeat(3, 1)
|
|
192
|
+
assert est._difference_reconstruction_loss(same).item() == 0.0
|
|
193
|
+
assert est._difference_reconstruction_loss(counts[:1]).item() == 0.0
|
|
194
|
+
est = _est(lambda_difference=0.5, num_epochs=2, batch_size=3, pad_sequences=True)
|
|
195
|
+
est.fit([[0, 1, 2, 3], [3, 3, 1, 5], [5, 0, 5, 2, 4]])
|
|
196
|
+
assert all(torch.isfinite(p).all() for p in est.model.parameters())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_difference_loss_only_in_padded_training():
|
|
200
|
+
seqs = [torch.tensor([0, 1, 2, 3]), torch.tensor([3, 3, 1, 5])]
|
|
201
|
+
est = _est(lambda_difference=1.0, lambda_consistency=0.0)
|
|
202
|
+
opt = torch.optim.SGD(est.model.parameters(), lr=0.0)
|
|
203
|
+
torch.manual_seed(0)
|
|
204
|
+
with_term = est._train_batch_padded(seqs, opt)
|
|
205
|
+
est.lambda_difference = 0.0
|
|
206
|
+
torch.manual_seed(0)
|
|
207
|
+
without = est._train_batch_padded(seqs, opt)
|
|
208
|
+
assert with_term > without
|
|
209
|
+
est.lambda_difference = 1.0
|
|
210
|
+
torch.manual_seed(0)
|
|
211
|
+
unpadded_with = est._train_batch_unpadded(seqs, opt)
|
|
212
|
+
est.lambda_difference = 0.0
|
|
213
|
+
torch.manual_seed(0)
|
|
214
|
+
assert unpadded_with == pytest.approx(est._train_batch_unpadded(seqs, opt))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tests for the root-cause analysis API (``attribute``, ``counterfactual``,
|
|
2
|
+
``influence_matrix``, ``event_impact``, ``decompose``, ``explain_difference``)
|
|
3
|
+
and ``metrics.transition_graph_auc``. Run with `pytest tests/ -q`.
|
|
4
|
+
"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from event2vector import Event2Vec
|
|
10
|
+
from event2vector.metrics import transition_graph_auc
|
|
11
|
+
|
|
12
|
+
V = 8
|
|
13
|
+
SEQS = [[0, 1, 2, 3], [0, 1, 4, 5], [6, 2, 3, 7], [0, 6, 1, 2]] * 4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fit(**kw):
|
|
17
|
+
kw = dict(dict(num_event_types=V, embedding_dim=6, dropout_p=0.0, num_epochs=3,
|
|
18
|
+
batch_size=4, use_gpu=False, random_state=0), **kw)
|
|
19
|
+
return Event2Vec(**kw).fit(SEQS)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize("max_norm", [None, 10.0, 0.01])
|
|
23
|
+
def test_euclidean_attribution_is_exact(max_norm):
|
|
24
|
+
est = _fit(max_norm=max_norm)
|
|
25
|
+
seq = [0, 6, 1, 2, 1]
|
|
26
|
+
out = est.attribute(seq, target=3)
|
|
27
|
+
assert out["exact"] and len(out["contributions"]) == len(seq)
|
|
28
|
+
assert out["bias"] + out["contributions"].sum() == pytest.approx(out["logit"], abs=1e-4)
|
|
29
|
+
if max_norm is None:
|
|
30
|
+
assert np.allclose(out["weights"], 1.0)
|
|
31
|
+
if max_norm == 0.01: # clipping fires: earlier events are down-weighted
|
|
32
|
+
assert np.all(np.diff(out["weights"]) >= -1e-6) and out["weights"][0] < 1.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_attribution_defaults_to_predicted_event():
|
|
36
|
+
est = _fit()
|
|
37
|
+
out = est.attribute([0, 1])
|
|
38
|
+
logits = est._next_logits([0, 1])
|
|
39
|
+
assert out["target"] == int(logits.argmax())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("geometry,kw", [("hyperbolic", {}), ("rotation", {"rotation_n": 3})])
|
|
43
|
+
def test_attribution_falls_back_to_leave_one_out(geometry, kw):
|
|
44
|
+
est = _fit(geometry=geometry, **kw)
|
|
45
|
+
seq = [0, 1, 2]
|
|
46
|
+
out = est.attribute(seq, target=3)
|
|
47
|
+
assert not out["exact"] and out["weights"] is None
|
|
48
|
+
expected = out["logit"] - float(est._next_logits([0, 2])[3])
|
|
49
|
+
assert out["contributions"][1] == pytest.approx(expected, abs=1e-5)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("geometry", ["euclidean", "hyperbolic"])
|
|
53
|
+
def test_counterfactual_matches_reencoding(geometry):
|
|
54
|
+
est = _fit(geometry=geometry)
|
|
55
|
+
out = est.counterfactual([0, 1, 2, 3], remove=[1, 3])
|
|
56
|
+
q = torch.softmax(est._next_logits([0, 2]), 0).numpy()
|
|
57
|
+
assert np.allclose(out["probs_counterfactual"], q, atol=1e-6)
|
|
58
|
+
assert np.allclose(out["delta"], out["probs_counterfactual"] - out["probs"])
|
|
59
|
+
assert out["probs"].sum() == pytest.approx(1.0, abs=1e-5)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_influence_matrix_is_decoder_times_embeddings_without_clipping():
|
|
63
|
+
est = _fit(max_norm=None)
|
|
64
|
+
A = est.influence_matrix()
|
|
65
|
+
W = est.model.linear_dec.weight.detach().numpy()
|
|
66
|
+
E = est.model.embedding.weight.detach().numpy()
|
|
67
|
+
assert A.shape == (V, V) and np.allclose(A, W @ E.T, atol=1e-5)
|
|
68
|
+
# every sequence's logits are bias + A @ counts
|
|
69
|
+
counts = np.bincount([0, 1, 1, 5], minlength=V)
|
|
70
|
+
b = est.model.linear_dec.bias.detach().numpy()
|
|
71
|
+
assert np.allclose(est._next_logits([0, 1, 1, 5]).numpy(), b + A @ counts, atol=1e-4)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_event_impact_is_centered_column_norm():
|
|
75
|
+
est = _fit(geometry="hyperbolic")
|
|
76
|
+
A = est.influence_matrix()
|
|
77
|
+
impact = est.event_impact()
|
|
78
|
+
assert impact.shape == (V,)
|
|
79
|
+
assert impact[2] == pytest.approx(np.linalg.norm(A[:, 2] - A[:, 2].mean()), rel=1e-5)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _orthogonal_estimator():
|
|
83
|
+
est = _fit(max_norm=None, embedding_dim=V)
|
|
84
|
+
with torch.no_grad(): # orthonormal embeddings: every history is identifiable
|
|
85
|
+
est.model.embedding.weight.copy_(torch.eye(V) * 2.0)
|
|
86
|
+
return est
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_decompose_recovers_counts_from_state():
|
|
90
|
+
est = _orthogonal_estimator()
|
|
91
|
+
seq = [3, 1, 3, 7, 3]
|
|
92
|
+
expected = np.bincount(seq, minlength=V)
|
|
93
|
+
assert np.array_equal(est.decompose(seq, round_counts=True), expected)
|
|
94
|
+
state = est.transform([seq], as_numpy=False)[0]
|
|
95
|
+
assert np.allclose(est.decompose(state), expected, atol=1e-6)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_explain_difference_returns_signed_counts():
|
|
99
|
+
est = _orthogonal_estimator()
|
|
100
|
+
diff = est.explain_difference([0, 2, 2, 5], [0, 2, 6], round_counts=True)
|
|
101
|
+
expected = np.zeros(V)
|
|
102
|
+
expected[[2, 5, 6]] = [1, 1, -1]
|
|
103
|
+
assert np.array_equal(diff, expected)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_sum_based_methods_reject_other_geometries():
|
|
107
|
+
est = _fit(geometry="hyperbolic")
|
|
108
|
+
with pytest.raises(NotImplementedError):
|
|
109
|
+
est.decompose([0, 1])
|
|
110
|
+
with pytest.raises(NotImplementedError):
|
|
111
|
+
est.explain_difference([0, 1], [1])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_transition_graph_auc():
|
|
115
|
+
est = _fit()
|
|
116
|
+
vocab = {f"e{i}": i for i in range(V)}
|
|
117
|
+
graph = {"e0": [("e1", 0.5), ("e6", 0.5)], "e1": [("e2", 1.0)], "e2": [("e3", 1.0)]}
|
|
118
|
+
A = np.zeros((V, V))
|
|
119
|
+
A[1, 0] = A[6, 0] = A[2, 1] = A[3, 2] = 1.0 # a perfect score on the true edges
|
|
120
|
+
out = transition_graph_auc(est, graph, vocab, influence=A)
|
|
121
|
+
assert out["auc"] == 1.0 and out["n_edges"] == 4 and out["n_pairs"] == V * (V - 1)
|
|
122
|
+
assert 0.0 <= transition_graph_auc(est, graph, vocab)["auc"] <= 1.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_root_cause_methods_require_fit():
|
|
126
|
+
est = Event2Vec(num_event_types=V, use_gpu=False)
|
|
127
|
+
with pytest.raises(RuntimeError):
|
|
128
|
+
est.attribute([0, 1])
|
|
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
|
|
File without changes
|