Event2Vec
Vectors for event histories. A customer, an account or a machine becomes the sum of its events, and every score you build on top splits exactly into the events behind it.
Open source, MIT · scikit-learn API · trains on a laptop CPU · version 0.6.0
Churn prediction
Every feature set below goes into the same logistic regression and is scored with five-fold cross-validation on who stopped buying in the next 90 days (57% of customers did).
| Features | columns | AUC |
|---|---|---|
| Which products they bought (tf-idf bag) | 3,114 | 0.627 |
| Event2Vec vector of the same purchases | 32 | 0.683 |
| Recency, frequency, spend, tenure (RFM) | 4 | 0.787 |
| RFM + Event2Vec | 36 | 0.785 |
As a description of what someone bought, Event2Vec's 32 numbers beat the full 3,114-column product list, and the gap is largest for customers with short histories (AUC 0.59 vs 0.54 below 20 purchases). Rarely bought products borrow strength from the products they behave like. Recency and frequency still carry most of the churn signal: adding Event2Vec to RFM does not raise the AUC. What it adds is the right-hand side of Figure 1, a reason for every score, product by product.
Method
Each kind of event gets a vector. A history is the running sum of its events' vectors, kept bounded by a norm clip so very long histories don't blow up. The model learns the vectors by predicting the next event from that sum, so it needs no labels, only events in the order they happened.
Because a history is a sum, any linear model on top of it is a sum as well. A churn, fraud or risk score splits into one term per event, the terms add up to the score exactly, and removing an event removes exactly its term. There is no sampling and no surrogate model in that explanation.
The same split applies to the model's own next-event prediction. attribute returns each earlier
event's share of a predicted event's logit, rank_causes orders a history's events by that share, and
counterfactual re-encodes the history with events removed, replaced or inserted. These explain what
the model learned from the data; they are associations, not causal effects.
Events that lead to similar futures end up with similar vectors. That is what lets 32 numbers describe a customer better than thousands of product columns, and it is why the product map in Figure 1 has structure at all.
Structure in unlabelled sequences
The same estimator, pointed at sequences of letters, sounds and words, separates categories it was never told about, and does so much more cleanly than Word2Vec trained on the same sequences.
Geometries
The sum in Figure 2 is the Euclidean case. Event2Vec ships seven more update rules, each the natural home of a structure plain addition can't carry: hierarchies, cycles, histories where the order of events changes the outcome, and events that act on the history instead of only adding to it. They share one estimator and one argument.
"euclidean"vector addition
Bags of events and exact explanations. The default, and the fastest.
"hyperbolic"Möbius addition, curvature c > 0
Hierarchies: product categories, diagnosis codes, org charts.
"stereographic"κ-addition, signed κ
One dial: κ < 0 hyperbolic, 0 flat, κ > 0 spherical.
"spherical"κ-addition, κ > 0
Cycles: weekdays, seasons, shift patterns.
"rotation"product of SO(n) rotations
Histories where the same steps in another order end somewhere else.
"heisenberg"Heisenberg group product
Same endpoint, opposite signed area: order shows up as a separate number.
"product"one update per factor
Two structures at once, such as a hierarchy × a cycle.
"affine"x ↦ exp(Me) x + ve
Events that shrink, stretch or turn the history, not only add to it.
# one curvature dial
Event2Vec(V,
geometry="stereographic",
kappa=-1.0)# order matters: SO(3)
Event2Vec(V,
geometry="rotation",
rotation_n=3)# hierarchy × cycle
Event2Vec(V,
geometry="product",
product_kappas=(-1, 1))# events act on the history
Event2Vec(V,
geometry="affine")Cuisine attribution in recipes
The same exact split works for any event that follows a history. We gave Event2Vec 9,946 recipes from five cuisines, each recipe as its ingredients followed by a cuisine token, so naming the cuisine is just predicting the next event. Every cuisine score then splits into one term per ingredient, which shows what each cuisine is recognised by and which ingredients two cuisines share.
A second model reads the cuisine first and the ingredients after it. Given the same three ingredients, it shows what each cuisine would add next.
Quickstart
Give it one list of event IDs per customer, account or machine, in time order. It behaves like any scikit-learn transformer.
from event2vector import Event2Vec
# one list of event IDs per customer, in time order
histories = [[0, 3, 3, 7], [1, 4, 2], ...]
model = Event2Vec(num_event_types=V, geometry="euclidean",
embedding_dim=32, pad_sequences=True)
model.fit(histories) # no labels needed
X = model.transform(histories) # one 32-number vector per customer
model.most_similar(3, topn=5) # events that behave like event 3
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression
from event2vector import Event2Vec
pipe = make_pipeline(
Event2Vec(num_event_types=V, embedding_dim=32, pad_sequences=True),
LogisticRegression(max_iter=1000),
)
pipe.fit(histories, churned)
# reasons: the logit is a sum over the events in the history
E = pipe[0].model.embedding.weight.detach().cpu().numpy()
w, b = pipe[-1].coef_[0], pipe[-1].intercept_[0]
reasons = [(event, E[event] @ w) for event in histories[0]]
# sum(r for _, r in reasons) + b == pipe.decision_function(histories[:1])[0]
# (exact while the norm clip is inactive; tools/build_retail.py shows the clipped case)
from event2vector import Event2Vec
# gate="vector": each event may fade the history before adding to it
model = Event2Vec(num_event_types=V, gate="vector", pad_sequences=True)
model.fit(histories)
history, effect = histories[0], 5
model.rank_causes(history, effect) # [(event_id, score), ...], highest first
out = model.attribute(history, target=effect)
# out["baseline"] + out["contributions"].sum() == out["logit"]
model.counterfactual(history, remove=[1])["delta"][effect] # change in P(effect)
model.event_gates().mean(axis=1) # per event: < 0 shrinks the history
from event2vector import Event2Vec, HyperbolicUtils
# for events that form a hierarchy (product categories, diagnosis codes)
hyp = Event2Vec(num_event_types=V, geometry="hyperbolic", curvature=1.0,
embedding_dim=32, pad_sequences=True)
hyp.fit(histories)
H = hyp.transform(histories, as_numpy=False) # points in the Poincaré ball
d = HyperbolicUtils.poincare_dist_sq(H[0], H[1], hyp.curvature).sqrt()
Choosing a geometry: see Figure 4. Start with "euclidean"; switch when your events form a hierarchy or when order should matter.
Practical notes
- License
- MIT: the library can be used, modified and shipped in closed-source software, keeping the copyright notice.
- Input format
- One list of integer event IDs per entity, sorted by time. Map event names to IDs with a dictionary. Timestamps are only used for sorting.
- Scale
- The churn example has 590,454 events and trains in about 40 seconds on a laptop CPU. The model is the number of event types times the vector size, plus a linear decoder; for Figure 1 that is about 200,000 numbers.
- Maturity
- It is a research library with a small, stable surface (
fit,transform,most_similar, and since 0.6.0attribute,rank_causesandcounterfactual) and a test suite. Pin the version (event2vector==0.6.0) and validate on your own data first. - Contact
- Open an issue on GitHub, or reach Antonin through sulcantonin.github.io.
Paper
Sulc, A. Event2Vec: A Geometric Approach to Learning Composable Representations of Event Sequences. Proceedings of Machine Learning Research, vol. 282, pp. 492–512, 2026. PMLR · preprint arXiv:2509.12188 · software DOI 10.5281/zenodo.23399003.
@InProceedings{pmlr-v282-sulc26a,
title = {Event2Vec: A Geometric Approach to Learning
Composable Representations of Event Sequences},
author = {Sulc, Antonin},
booktitle = {Proceedings of the 4th (2025) and 3rd (2024) NeurIPS Workshops
on Symmetry and Geometry in Neural Representations},
pages = {492--512},
year = {2026},
volume = {282},
series = {Proceedings of Machine Learning Research},
publisher = {PMLR},
url = {https://proceedings.mlr.press/v282/sulc26a.html}
}