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.
Files changed (32) hide show
  1. {event2vector-0.4.1 → event2vector-0.5.0}/CHANGELOG.md +42 -0
  2. {event2vector-0.4.1 → event2vector-0.5.0}/CITATION.cff +2 -2
  3. {event2vector-0.4.1/event2vector.egg-info → event2vector-0.5.0}/PKG-INFO +48 -4
  4. {event2vector-0.4.1 → event2vector-0.5.0}/README.md +46 -3
  5. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/__init__.py +1 -1
  6. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/metrics.py +36 -0
  7. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/models.py +296 -11
  8. {event2vector-0.4.1 → event2vector-0.5.0/event2vector.egg-info}/PKG-INFO +48 -4
  9. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/SOURCES.txt +3 -1
  10. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/requires.txt +1 -0
  11. {event2vector-0.4.1 → event2vector-0.5.0}/pyproject.toml +2 -1
  12. event2vector-0.5.0/tests/test_reconstruction.py +214 -0
  13. event2vector-0.5.0/tests/test_root_cause.py +128 -0
  14. {event2vector-0.4.1 → event2vector-0.5.0}/MANIFEST.in +0 -0
  15. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/baselines.py +0 -0
  16. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/data.py +0 -0
  17. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/__init__.py +0 -0
  18. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/arithmetic.py +0 -0
  19. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/brown.py +0 -0
  20. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/decay.py +0 -0
  21. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/groups.py +0 -0
  22. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/lifepath.py +0 -0
  23. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/datasets/realdata.py +0 -0
  24. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/eval/__init__.py +0 -0
  25. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/eval/figures.py +0 -0
  26. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/eval/harness.py +0 -0
  27. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector/geometries.py +0 -0
  28. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/dependency_links.txt +0 -0
  29. {event2vector-0.4.1 → event2vector-0.5.0}/event2vector.egg-info/top_level.txt +0 -0
  30. {event2vector-0.4.1 → event2vector-0.5.0}/setup.cfg +0 -0
  31. {event2vector-0.4.1 → event2vector-0.5.0}/tests/test_affine.py +0 -0
  32. {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.4.1
8
+ version: 0.5.0
9
9
  doi: 10.5281/zenodo.23399003
10
- date-released: 2026-10-15
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.4.1
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` can be recovered from `h_{t-1}` and `h_t`, so the update stays invertible |
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` can be recovered from `h_{t-1}` and `h_t`, so the update stays invertible |
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
@@ -1,4 +1,4 @@
1
- __version__ = "0.4.1"
1
+ __version__ = "0.5.0"
2
2
 
3
3
  from .models import EuclideanModel, HyperbolicModel, HyperbolicUtils, Event2Vec
4
4
  from .geometries import (
@@ -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
- if clipping:
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), i.e. the quantity the
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 _compute_losses(self, y1, h1, e_curr1, h_old, target, h2):
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 enforces invertibility of the update:
605
- - Euclidean: subtracting the increment recovers the previous state,
606
- ``(h_t - e_t) == h_{t-1}`` (exact when norm clipping does not fire).
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
- h_reconstructed = h1 - e_curr1
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.4.1
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` can be recovered from `h_{t-1}` and `h_t`, so the update stays invertible |
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
@@ -25,4 +25,6 @@ event2vector/eval/__init__.py
25
25
  event2vector/eval/figures.py
26
26
  event2vector/eval/harness.py
27
27
  tests/test_affine.py
28
- tests/test_device.py
28
+ tests/test_device.py
29
+ tests/test_reconstruction.py
30
+ tests/test_root_cause.py
@@ -1,6 +1,7 @@
1
1
  numpy>=1.21
2
2
  torch>=1.13
3
3
  scikit-learn>=1.0
4
+ scipy>=1.5
4
5
  tqdm>=4.0
5
6
 
6
7
  [dev]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "event2vector"
7
- version = "0.4.1"
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