Skip to content

Commit c50109f

Browse files
rcap107GaelVaroquauxlisaleemcbjeromedockes
authored
Adding the SessionEncoder (#1930)
Co-authored-by: Gael Varoquaux <gael.varoquaux@normalesup.org> Co-authored-by: Lisa <lisaleemcb@gmail.com> Co-authored-by: Jérôme Dockès <jerome@dockes.org>
1 parent 7f0903f commit c50109f

13 files changed

Lines changed: 2141 additions & 1 deletion

File tree

CHANGES.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ New Features
3131
:meth:`DataOp.skb.eval`, :meth:`SkrubLearner.predict`, etc., or in
3232
:meth:`DataOp.skb.find` or :meth:`SkrubLearner.truncated_after`. :pr:`2062` by
3333
:user:`Jérôme Dockès <jeromedockes>`.
34+
- The :class:`SessionEncoder` is now available. This encoder adds a `session_id`
35+
column, which groups together events that occur within the given session gap.
36+
Additionally, it is possible to provide a ``split_by`` column or list of columns
37+
(e.g., user ID or (user ID, user device)) to compute sessions for each grouping
38+
value.
39+
:pr:`1930` by :user:`Riccardo Cappuzzo <rcap107>`.
40+
- A new synthetic dataset generator for timestamped data and session-based
41+
operations has been added: :meth:`~skrub.datasets.make_retail_events`.
42+
:pr:`1930` by :user:`Riccardo Cappuzzo <rcap107>`.
3443
- The :class:`DropSimilar` transformer has been added, for removing columns in a
3544
dataframe that present high correlation with other columns. :pr:`2023` by
3645
:user:`Eloi Massoulié <emassoulie>`.

doc/api_reference.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"SimilarityEncoder",
9191
"ToCategorical",
9292
"DatetimeEncoder",
93+
"SessionEncoder",
9394
"ToDatetime",
9495
"ToFloat",
9596
],
@@ -341,6 +342,9 @@
341342
"datasets.get_data_dir",
342343
"datasets.make_deduplication_data",
343344
"datasets.toy_orders",
345+
"datasets.toy_products",
346+
"datasets.toy_cities",
347+
"datasets.make_retail_events",
344348
],
345349
}
346350
],
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
.. _sessionization:
2+
3+
.. |SessionEncoder| replace:: :class:`~skrub.SessionEncoder`
4+
.. |BaseEstimator| replace:: :class:`~sklearn.base.BaseEstimator`
5+
.. |TransformerMixin| replace:: :class:`~sklearn.base.TransformerMixin`
6+
7+
8+
Detecting sessions in timestamped data with the SessionEncoder
9+
----------------------------------------------------------------
10+
11+
When dealing with timestamped data (data that includes at least a timestamp column),
12+
it may be beneficial to try and identify groups of events as
13+
`"sessions" <https://en.wikipedia.org/wiki/Session_(web_analytics)>`__,
14+
through **sessionization**.
15+
16+
Sessionization is the process of grouping a sequence of events (like user
17+
interactions) into meaningful sessions.
18+
For example, in an online retail context you might define a new session whenever
19+
more than 30 minutes pass with no activity from the user. On a website, a session may
20+
define a sequence of requests made by a single end-user within a certain time duration.
21+
22+
While definitions may vary depending on the specific use case, being able to detect
23+
such "bursts" of activity by a user can often help with building features that have
24+
greater predictive power than raw individual events, such as number of sessions or
25+
average session duration.
26+
27+
The |SessionEncoder| addresses this problem by detecting sessions based on
28+
a timestamp column, other session-related columns (e.g., user and device) that should be
29+
used to distinguish between sessions, and a ``session_gap``. Session-related columns
30+
-- identified by the ``split_by`` parameter -- allow to split sessions based on
31+
the provided parameters, for example to group user actions only if they were conducted
32+
on the same device.
33+
34+
A session is then defined as a sequence of events that share the same value in the
35+
``split_by`` columns, and whose events are closer to each other than the
36+
``session_gap``.
37+
38+
>>> from skrub import SessionEncoder
39+
>>> from skrub.datasets import make_retail_events
40+
>>> events = make_retail_events(n_events=100, random_state=0)
41+
>>> X, y = events.X, events.y
42+
43+
Once the necessary features are provided, the |SessionEncoder|
44+
returns a dataframe that includes a ``timestamp_session_id`` column, which is
45+
composed of a distinct integer ID for each session:
46+
47+
>>> se = SessionEncoder(timestamp_col="timestamp", split_by="user_id", session_gap=30 * 60)
48+
>>> res = se.fit_transform(X)
49+
>>> res.head(5) # doctest: +SKIP
50+
user_id timestamp device_type page_category event_type time_on_page price_viewed timestamp_session_id
51+
0 user_0164 2024-01-01 03:29:07.708922+00:00 mobile fashion page_view 134.1 309.80 59
52+
1 user_0164 2024-01-01 03:29:42.185048+00:00 tablet books search 103.4 11.00 59
53+
2 user_0164 2024-01-01 03:32:38.352703+00:00 desktop home wishlist 180.3 4.80 59
54+
3 user_0008 2024-01-02 10:49:56.974375+00:00 mobile books page_view 7.0 33.94 2
55+
4 user_0149 2024-01-04 10:00:15.882835+00:00 desktop electronics page_view 108.5 4.44 49
56+
57+
With the session ID, it becomes possible to compute aggregations on
58+
each session, for example to find the duration or number of sessions
59+
by a user.
60+
61+
.. warning::
62+
63+
Caution! Aggregation can introduce data leakage. Records should only be aggregated from
64+
within the training set at training time, and the test set at predict time. To
65+
ensure this is the case, any code that performs aggregation can be wrapped in a
66+
scikit-learn |BaseEstimator| (as shown in the
67+
:ref:`SessionEncoder example <sphx_glr_auto_examples_0110_session_encoder.py>`),
68+
otherwise the pipeline should use the skrub :ref:`Data Ops framework<user_guide_data_ops_plan>`.
69+
70+
The |SessionEncoder| includes the ``suffix`` parameter (by default
71+
``suffix="session_id"``) to specify what the name of the new column should be.
72+
This can help with creating multiple session IDs based on the same timestamp.
73+
For example, we might want to create sessions based on users, and based on users
74+
and their device:
75+
76+
>>> se = SessionEncoder(timestamp_col="timestamp",
77+
... split_by="user_id",
78+
... session_gap=30 * 60,
79+
... suffix="user"
80+
... )
81+
>>> res = se.fit_transform(X)
82+
>>> res.head(5) # doctest: +SKIP
83+
user_id timestamp ... price_viewed timestamp_user
84+
0 user_0164 2024-01-01 03:29:07.708922+00:00 ... 309.80 59
85+
1 user_0164 2024-01-01 03:29:42.185048+00:00 ... 11.00 59
86+
2 user_0164 2024-01-01 03:32:38.352703+00:00 ... 4.80 59
87+
3 user_0008 2024-01-02 10:49:56.974375+00:00 ... 33.94 2
88+
4 user_0149 2024-01-04 10:00:15.882835+00:00 ... 4.44 49
89+
90+
>>> se = SessionEncoder(timestamp_col="timestamp",
91+
... split_by=["user_id", "device_type"],
92+
... session_gap=30 * 60,
93+
... suffix="user_device"
94+
... )
95+
>>> res = se.fit_transform(X)
96+
>>> res.head(5) # doctest: +SKIP
97+
user_id timestamp ... price_viewed timestamp_user_device
98+
0 user_0164 2024-01-01 03:29:07.708922+00:00 ... 309.80 75
99+
1 user_0164 2024-01-01 03:29:42.185048+00:00 ... 11.00 76
100+
2 user_0164 2024-01-01 03:32:38.352703+00:00 ... 4.80 74
101+
3 user_0008 2024-01-02 10:49:56.974375+00:00 ... 33.94 2
102+
4 user_0149 2024-01-04 10:00:15.882835+00:00 ... 4.44 59
103+
104+
The |SessionEncoder| has additional features that are detailed in the relevant docstring.
105+
You can also find a working :ref:`example <sphx_glr_auto_examples_0110_session_encoder.py>`
106+
in the gallery.

doc/multi_column_operations.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ multiple columns.
1515
modules/multi_column_operations/selectors
1616
modules/multi_column_operations/type_of_selectors
1717
modules/multi_column_operations/advanced_selectors
18+
modules/multi_column_operations/sessionization

examples/0110_session_encoder.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""
2+
3+
Sessions in time-based data: Predicting user purchases with the SessionEncoder
4+
===============================================================================
5+
6+
.. |SessionEncoder| replace:: :class:`~skrub.SessionEncoder`
7+
.. |make_retail_events| replace:: :func:`~skrub.datasets.make_retail_events`
8+
.. |tabular_pipeline| replace:: :func:`~skrub.tabular_pipeline`
9+
.. |TableVectorizer| replace:: :class:`~skrub.TableVectorizer`
10+
.. |DummyClassifier| replace:: :class:`~sklearn.dummy.DummyClassifier`
11+
.. |TimeSeriesSplit| replace:: :class:`~sklearn.model_selection.TimeSeriesSplit`
12+
.. |BaseEstimator| replace:: :class:`~sklearn.base.BaseEstimator`
13+
.. |TransformerMixin| replace:: :class:`~sklearn.base.TransformerMixin`
14+
15+
This example shows how to use |SessionEncoder| in a scikit-learn pipeline to
16+
create session-level features (sessionization) for conversion prediction, that is
17+
predicting whether a user session will eventually lead to a purchase.
18+
19+
.. topic:: What is sessionization?
20+
21+
Sessionization is the process of grouping a sequence of events (like user
22+
interactions) into meaningful sessions. A session typically starts fresh or
23+
after a period of inactivity. For example, in an online retail context, you
24+
might define a new session whenever more than 30 minutes pass with no activity
25+
from a user. This allows you to extract session-level features (like the total
26+
number of events in a session or the dominant device type used) which often have
27+
greater predictive power than raw individual events.
28+
29+
We will:
30+
31+
1. Use |make_retail_events| to generate synthetic retail event data
32+
2. Build a baseline classifier on raw event-level features with the |tabular_pipeline|
33+
3. Add session-level and historical features with |SessionEncoder|
34+
4. Train the same model again and compare ROC-AUC
35+
36+
The data includes columns such as event type, device type, viewed price, and
37+
timestamp. The target is binary: whether the session eventually contains a
38+
purchase event or not.
39+
40+
"""
41+
42+
# %%
43+
# Since this is temporal data, we use a time-aware CV strategy with
44+
# |TimeSeriesSplit| to avoid leakage. We reuse the same splitter for all evaluations.
45+
# The dataset is sorted by timestamp, so the training set will always contain only
46+
# past data relative to the test set.
47+
from sklearn.model_selection import TimeSeriesSplit
48+
49+
splitter = TimeSeriesSplit(n_splits=5)
50+
# %%
51+
# We begin by generating the data with |make_retail_events| and defining our
52+
# features and target.
53+
from skrub import TableReport
54+
from skrub.datasets import make_retail_events
55+
56+
events = make_retail_events(n_users=20, n_events=5000, random_state=0)
57+
X, y = events.X, events.y
58+
TableReport(X)
59+
# %%
60+
# The data contains 5000 events from 20 users, where each event is timestamped.
61+
# Other columns include the event type, device used by the user, page category,
62+
# time spent on page and price of the item. The target variable indicates whether
63+
# a user session eventually contains a purchase event: all events in that session
64+
# will have a target value of 1 if a purchase happens, and 0 otherwise.
65+
66+
# %%
67+
# Sanity check: evaluate a DummyClassifier on raw event data
68+
# ---------------------------------------------------------------
69+
# We begin by evaluating a |DummyClassifier| on the original event data
70+
# (without session features). Since it's a |DummyClassifier|, we expect
71+
# chance-level performance (ROC-AUC of 0.5).
72+
from sklearn.dummy import DummyClassifier
73+
from sklearn.model_selection import cross_val_score
74+
75+
dummy = DummyClassifier(strategy="most_frequent")
76+
77+
scores = cross_val_score(dummy, X, y, cv=splitter, scoring="roc_auc")
78+
print(f"ROC-AUC with DummyClassifier: {scores.mean():.3f}")
79+
80+
# %%
81+
# First attempt: training a model without using session-level features
82+
# --------------------------------------------------------------------
83+
# We first use the |tabular_pipeline| on raw event-level data, without any session
84+
# encoding or aggregation. This serves as a baseline to compare against the enriched
85+
# model later.
86+
# Remember that the |tabular_pipeline| will automatically add a |TableVectorizer|
87+
# to perform feature engineering, so the model can still learn from the raw event
88+
# features. However, it won't be able to directly capture session-level patterns.
89+
from skrub import tabular_pipeline
90+
91+
model = tabular_pipeline("classification")
92+
93+
scores = cross_val_score(model, X, y, cv=splitter, scoring="roc_auc")
94+
print(f"ROC-AUC without session encoding: {scores.mean():.3f}")
95+
# %%
96+
# The model is not performing much better than the DummyClassifier, which suggests
97+
# that raw event-level features are not sufficient for good conversion prediction.
98+
# This baseline is limited because it cannot directly use session-level behavior
99+
# (for example, whether "add_to_cart" happened in the same session).
100+
101+
# %%
102+
# A better approach: session encoding and aggregation
103+
# ------------------------------------------------------
104+
# Next, we use the |SessionEncoder| to create session-level features that we can
105+
# aggregate over. We define a session boundary as "a user has been inactive for
106+
# more than 30 minutes". The |SessionEncoder| will create a new column
107+
# ``timestamp_session_id`` that assigns a unique session ID to each session detected.
108+
# The parameter ``session_gap=30 * 60`` specifies the inactivity threshold in
109+
# seconds (30 minutes).
110+
#
111+
# Note that session-based features involve aggregations, which must be performed
112+
# only on the training data within each fold to avoid leakage. In a scikit-learn
113+
# pipeline, we can achieve this by using |SessionEncoder| followed by a custom
114+
# transformer that computes session aggregates, and ensures that the pipeline is
115+
# properly fitted within each fold of cross-validation.
116+
117+
# %%
118+
from skrub import SessionEncoder, tabular_pipeline
119+
120+
se = SessionEncoder("timestamp", split_by="user_id", session_gap=30 * 60)
121+
# Here we fit the SessionEncoder on the entire dataset for demonstration purposes
122+
X_sessions = se.fit_transform(X)
123+
X_sessions.head()
124+
125+
# %%
126+
# Defining a custom transformer for session-level aggregation
127+
# -----------------------------------------------------------
128+
# To avoid data leakage and maintain a clean pipeline, we can create a custom
129+
# transformer that inherits from |BaseEstimator| and |TransformerMixin| and
130+
# computes session-level aggregates within a scikit-learn pipeline.
131+
# This transformer will be fitted and applied separately within each fold of
132+
# cross-validation, ensuring that session features are computed only on the training
133+
# data of each fold.
134+
135+
from sklearn.base import BaseEstimator, TransformerMixin
136+
137+
138+
class SessionAggregator(BaseEstimator, TransformerMixin):
139+
def fit(self, X, y=None):
140+
return self
141+
142+
def transform(self, X):
143+
# Compute session-level aggregates
144+
session_agg = X.groupby("timestamp_session_id").agg(
145+
session_has_add_to_cart=("event_type", lambda x: "add_to_cart" in x.values),
146+
session_n_events=("event_type", "count"),
147+
session_mean_price=("price_viewed", "mean"),
148+
session_dominant_device=("device_type", lambda x: x.mode()[0]),
149+
)
150+
# Join back to the original data
151+
return X.merge(
152+
session_agg,
153+
how="left",
154+
on="timestamp_session_id",
155+
)
156+
157+
158+
# %%
159+
# Then, we create a pipeline that includes the |SessionEncoder|, our custom
160+
# ``SessionAggregator``, and the |tabular_pipeline| for classification. This
161+
# pipeline will be used in cross-validation to evaluate the model
162+
# with session features.
163+
from sklearn.pipeline import make_pipeline
164+
165+
model = make_pipeline(se, SessionAggregator(), tabular_pipeline("classification"))
166+
scores = cross_val_score(model, X, y, cv=splitter, scoring="roc_auc")
167+
print("ROC-AUC with session encoding:", scores.mean())
168+
169+
# %%
170+
# As expected the model with session encoding performs much better than the baseline
171+
# without session features, demonstrating the value of sessionization for conversion
172+
# prediction.
173+
#
174+
# The fact that we are working with aggregation means that it was necessary to
175+
# create a custom transformer to compute session-level features. However, this situation
176+
# can be avoided entirely by using the skrub DataOps workflow, which allows for more
177+
# flexible data transformations without needing to fit everything within a
178+
# scikit-learn pipeline.

0 commit comments

Comments
 (0)