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.3.0
What the 32 numbers are worth
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.
How it works
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.
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.
It finds structure nobody labelled
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.
Seven ways to add events
The sum in Figure 2 is the Euclidean case. Event2Vec ships six more update rules, each the natural home of a structure plain addition can't carry: hierarchies, cycles, and histories where the order of events changes the outcome. 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.
# 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))Where it doesn't help
We tried Event2Vec on other real tasks while building this page. These are the ones where a simpler method did as well or better, measured the same way.
| Task | Data | Event2Vec | Better or equal | Why |
|---|---|---|---|---|
| Flag failing sessions in server logs, no labels | HDFS, 575,061 sessions (Loghub) | AP 0.27 | rare-line rule 0.75 | One line repeats hundreds of times and order matters; a sum blurs both. |
| Recommend the next basket | Online Retail II | hit@10 0.43 | rebuy rule 0.75 | People mostly reorder what they bought before. |
| Find look-alike customers | Online Retail II | overlap 0.039 | tf-idf 0.041 | Exact product overlap already works well here. |
| Group WordNet concepts by type | WordNet hypernym paths | 0.13 | Word2Vec 0.23 | On a pure tree, averaging shared ancestors is enough. |
| Group recipes by cuisine | 3,723 recipes | 0.16 | Word2Vec 0.23 | A recipe is a set; order carries nothing. |
AP: average precision. hit@10: share of customers whose next order contains one of ten suggestions.
Overlap: how much ten look-alikes' later purchases overlap with the customer's own (Jaccard). WordNet and recipes: silhouette.
Scripts for these runs are in tools/experiments/.
The pattern: Event2Vec earns its place when there are many kinds of events and each history is short or sparse, and when a score has to come with reasons. When a few event types repeat many times, when exact order is the signal, or when a set is all that matters, use a count, a rule or a sequence model.
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, 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.
Questions
- Can I use it in a commercial product?
- Yes. event2vector is MIT licensed: use it, change it and ship it in closed-source software, keeping the copyright notice.
- What does my data need to look like?
- 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.
- How big can it get?
- The churn example has 590,454 events and trains in about half a minute 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.
- Is it production-ready?
- It is a research library with a small, stable surface (
fit,transform,most_similar) and a test suite. Pin the version (event2vector==0.3.0) and validate on your own data first. - Where do I ask questions?
- 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. arXiv:2509.12188.
@article{sulc2025event2vec,
title = {Event2Vec: A Geometric Approach to Learning
Composable Representations of Event Sequences},
author = {Sulc, Antonin},
journal = {arXiv preprint arXiv:2509.12188},
year = {2025}
}