|
| 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