Recommender Systems =================== Test whether users' click patterns from recommendation menus reveal consistent preferences, and use consistency scores for segmentation and churn detection. .. raw:: html
.. image:: ../_static/eco_auc_comparison.png :width: 70% :align: center :alt: E-commerce benchmarking pipeline results map .. raw:: html
Introduction ------------ Every recommendation platform generates the same data: users see a *menu* of items (search results, playlist tracks, product carousel) and *click* one. Under the assumption that a click reflects a deliberate preference, these sessions can be modeled as menu-choice observations. If a user clicks item A over B in one session but B over A in another, no fixed ranking can explain the choices --- a SARP violation. Kallus & Udell (2016) formalized scalable preference learning from assortment choice data. Cazzola & Daly (2024) argued that rank-preference consistency --- conceptually SARP satisfaction --- is a better evaluation metric for recommender systems than RMSE or MAE. **What you'll learn:** - How to map click-stream data into menu-choice observations - The SARP test and Houtman-Maks efficiency for discrete choices - Reconstructing menus from RetailRocket e-commerce sessions - User segmentation by preference consistency - Temporal analysis for churn detection via sliding-window SARP **Companion script:** ``examples/applications/03_recommendation_clicks.py`` Formal Setup ------------ Menu choice and the item graph ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A user faces :math:`T` sessions. In session :math:`t`, they see menu :math:`S_t \subseteq \{1, \ldots, N\}` (a subset of :math:`N` items in the catalog) and choose item :math:`c_t \in S_t`. Choosing :math:`c_t` from :math:`S_t` **reveals** :math:`c_t` is preferred to every other item in the menu: .. math:: c_t \succ a \quad \forall \, a \in S_t \setminus \{c_t\} This builds an **item graph** :math:`G = (V, E)` where vertices are items and edges are revealed preferences: .. math:: (a, b) \in E \iff \exists \, t : c_t = a \text{ and } b \in S_t SARP ~~~~ The **Strong Axiom of Revealed Preference** (Richter, 1966) requires that the transitive closure :math:`G^*` of the item graph is acyclic: .. math:: \text{SARP: } \quad \nexists \text{ cycle } a_1 \succ^* a_2 \succ^* \cdots \succ^* a_k \succ^* a_1 SARP holds if and only if the user's choices can be explained by a strict linear ordering over items. If it fails, no fixed preference ranking rationalizes the data. Houtman-Maks efficiency ~~~~~~~~~~~~~~~~~~~~~~~ When SARP fails, the **Houtman-Maks index** measures the minimum fraction of observations to discard to restore consistency: .. math:: \text{HM} = 1 - \frac{\min |S| : \text{removing observations } S \text{ makes SARP hold}}{T} HM = 1.0 means perfectly consistent. HM = 0.5 means half the observations must be discarded. This is equivalent to finding the maximum acyclic subgraph of the item graph (NP-hard in general, solved by greedy feedback vertex set heuristic). Data ---- RetailRocket click-stream ~~~~~~~~~~~~~~~~~~~~~~~~~ The `RetailRocket `_ dataset contains 2.75M events from an e-commerce platform: views, add-to-cart, and transactions with timestamps. **Download:** .. code-block:: bash # Requires Kaggle CLI (pip install kaggle) kaggle datasets download -d retailrocket/ecommerce-dataset unzip ecommerce-dataset.zip -d datasets/retailrocket/data/ **Menu reconstruction:** Items viewed in a session form the menu; the purchased item is the choice. Sessions are split by 30-minute gaps. .. warning:: **Identification assumption.** Revealed preference theory requires exogenous menus --- the decision-maker faces a given set and chooses. In click-stream data, menus are **endogenous**: the recommendation algorithm curates which items each user sees. SARP violations may reflect the recommender showing different assortments over time, not the user having inconsistent preferences. Additionally, viewing an item is not the same as consciously evaluating it; users may click for comparison or curiosity without deliberate preference revelation. Results should be interpreted as descriptive patterns, not causal evidence of preference inconsistency. .. code-block:: python from prefgraph.datasets import load_retailrocket user_logs = load_retailrocket(min_sessions=5, max_users=200) print(f"Users: {len(user_logs)}") # Inspect one user uid = list(user_logs.keys())[0] log = user_logs[uid] print(f"User {uid}: {len(log.choices)} sessions, " f"{len(set().union(*log.menus))} unique items") .. note:: The RetailRocket dataset must be downloaded from Kaggle. See the download instructions above. EDA: RetailRocket ~~~~~~~~~~~~~~~~~ After loading and session reconstruction: .. list-table:: :header-rows: 1 :widths: 30 20 * - Statistic - Value * - Raw events - 2,756,101 * - Unique visitors - 1,407,580 * - Unique items - 417,053 * - Valid sessions (1 purchase, menu >= 2) - ~45,000 * - Users with >= 5 sessions - ~1,200 * - Mean menu size - 4.3 items * - Median menu size - 3 items Most sessions have small menus (3--5 items viewed before purchase), which is realistic for e-commerce browse-to-buy funnels. Larger menus (10+ items) represent research-heavy purchases. Algorithm --------- The SARP test operates on the **item graph** (not the observation graph used in GARP). This is a key difference from budget-based analysis: .. code-block:: text SARP-TEST(menus M[1..T], choices c[1..T]): ─────────────────────────────────────────── 1. BUILD ITEM GRAPH O(T × |S|) Initialize N×N matrix G ← 0 For each session t = 1, ..., T: For each item a ∈ Mₜ, a ≠ cₜ: G[cₜ, a] ← 1 // cₜ revealed preferred to a 2. TRANSITIVE CLOSURE O(N³) G* ← Floyd-Warshall(G) // G*[a,b] = 1 iff a ≻* b (transitive preference) 3. CHECK FOR CYCLES O(N²) For each pair (a, b): if G*[a, b] AND G*[b, a]: Record cycle: a ≻* b ≻* a Return: is_consistent, violation_cycles HOUTMAN-MAKS(menus, choices): ───────────────────────────── 4. FIND MIN FEEDBACK VERTEX SET NP-hard Find smallest set S of observations to remove such that SARP-TEST(M \ S, c \ S) is consistent Greedy heuristic: repeatedly remove observation contributing most edges to violation cycles Total: O(N³) where N = catalog size .. note:: Complexity depends on :math:`N` (catalog items), not :math:`T` (sessions). With 20 items, Floyd-Warshall runs on a 20x20 matrix --- nearly instant. With 1,000+ items (large catalogs), consider filtering to frequently-interacted items first. Pipeline Walkthrough -------------------- Single user ~~~~~~~~~~~ .. code-block:: python from prefgraph import MenuChoiceLog from prefgraph.algorithms.abstract_choice import validate_menu_sarp from prefgraph.algorithms.abstract_choice import compute_menu_efficiency # Using a loaded user sarp = validate_menu_sarp(log) print(f"SARP consistent: {sarp.is_consistent}") print(f"Violations: {sarp.num_violations}") .. code-block:: text SARP consistent: False Violations: 42 .. code-block:: python hm = compute_menu_efficiency(log) print(f"HM efficiency: {hm.efficiency_index:.3f}") print(f"Observations to remove: {len(hm.removed_observations)}/{len(log.choices)}") .. code-block:: text HM efficiency: 0.620 Observations to remove: 19/50 This user's choices require removing 38% of sessions to become SARP-consistent, suggesting moderately noisy preferences. Batch Analysis -------------- User segmentation ~~~~~~~~~~~~~~~~~ Scoring all users via the Rust Engine (batch SARP/HM): .. code-block:: python from prefgraph.engine import Engine engine = Engine() # Convert all users to Engine format in one pass uids = list(user_logs.keys()) users = [log.to_engine_tuple() for log in user_logs.values()] results = engine.analyze_menus(users) # Rust/Rayon parallel scoring # Each MenuResult has: .is_sarp, .n_sarp_violations, .hm_consistent, .hm_total for uid, mr in zip(uids, results): hm_eff = mr.hm_consistent / max(mr.hm_total, 1) print(f" {uid}: SARP={mr.is_sarp} HM={hm_eff:.3f}") Segmentation by HM efficiency: .. list-table:: :header-rows: 1 :widths: 20 15 15 30 * - Segment - HM Range - Users (%) - Action * - Stable preferences - 0.90 -- 1.00 - ~30% - Invest in personalization * - Moderate noise - 0.60 -- 0.90 - ~40% - Balance exploration and exploitation * - Noisy / drifting - < 0.60 - ~30% - Invest in curation and defaults .. raw:: html
.. image:: ../images/app_recsys_panel.png :width: 100% :align: center :alt: Recommender analysis - HM distribution, drift detection, sliding-window trajectories, lifecycle classification .. raw:: html
Beyond SARP: What Else Can You Measure? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ After SARP and HM, the same ``MenuChoiceLog`` supports further analysis: .. code-block:: python from prefgraph.algorithms.abstract_choice import recover_ordinal_utility from prefgraph.algorithms.attention import test_warp_la # Recover a preference ranking (requires SARP-consistent subset) u = recover_ordinal_utility(log) # Can violations be explained by limited attention? wla = test_warp_la(log) .. list-table:: :header-rows: 1 :widths: 25 22 28 * - Method - Returns - Interpretation * - ``recover_ordinal_utility`` - Ranking + utility values - Best-fit item ordering from choices * - ``test_warp_la`` - bool + attention filters - Are violations due to inattention, not irrationality? * - ``check_congruence`` - bool - Full rationalizability (SARP + maximality) * - ``fit_random_attention_model`` - Attention probabilities - P(item considered) per item * - ``fit_rum`` - Choice probabilities - Random utility model (Luce/logit) When SARP fails but WARP-LA passes, the user has consistent underlying preferences --- they just didn't notice some items. This distinction matters: inattentive users benefit from better UI placement, not different recommendations. Temporal Analysis: Churn Detection ---------------------------------- A proposed application: tracking SARP consistency over time to detect preference drift --- a potential leading indicator of churn. Split-half analysis ~~~~~~~~~~~~~~~~~~~ For each user, split sessions into first-half and second-half, then batch-score each half via the Engine: .. code-block:: python # Build three batches: full, first-half, second-half full_tuples = [log.to_engine_tuple() for log in user_logs.values()] fh_tuples, sh_tuples = [], [] for log in user_logs.values(): mid = len(log.choices) // 2 fh = MenuChoiceLog(menus=log.menus[:mid], choices=log.choices[:mid]) sh = MenuChoiceLog(menus=log.menus[mid:], choices=log.choices[mid:]) fh_tuples.append(fh.to_engine_tuple()) sh_tuples.append(sh.to_engine_tuple()) # Three batch Engine calls - no per-user Python loops full_results = engine.analyze_menus(full_tuples) fh_results = engine.analyze_menus(fh_tuples) sh_results = engine.analyze_menus(sh_tuples) # Drift signal per user for mr_full, mr_fh, mr_sh in zip(full_results, fh_results, sh_results): hm_full = mr_full.hm_consistent / max(mr_full.hm_total, 1) hm_1 = mr_fh.hm_consistent / max(mr_fh.hm_total, 1) hm_2 = mr_sh.hm_consistent / max(mr_sh.hm_total, 1) drift_signal = (hm_1 + hm_2) / 2 - hm_full The key insight: .. code-block:: text User Type Full HM 1st Half 2nd Half Drift Signal ───────────── ─────── ──────── ──────── ──────────── Consistent 1.000 1.000 1.000 0.000 Noisy 0.488 0.706 0.757 +0.244 Drifting 0.616 1.000 1.000 +0.384 Random 0.342 0.717 0.717 +0.375 **Drifting users** show high consistency within each half but low full-sequence consistency, suggesting their preference ranking changed. This pattern --- high per-window consistency but low full-window consistency --- is a **candidate churn signal** worth validating against actual engagement data. Lifecycle classification ~~~~~~~~~~~~~~~~~~~~~~~~ Classify each user by the shape of their rolling-window HM trajectory: .. list-table:: :header-rows: 1 :widths: 18 12 40 * - Lifecycle - Criteria - Action * - Stable - std < 0.05 - Reliable user; invest in deep personalization * - Improving - slope > +0.01 - Preferences crystallizing; increase recommendation specificity * - Deteriorating - slope < -0.01 - Preference erosion; early churn signal * - Volatile - std > 0.05, \|slope\| < 0.01 - Context-dependent; emphasize exploration over exploitation .. note:: **Validation gap.** The lifecycle classification is a descriptive segmentation, not a validated predictor. To test whether "deteriorating" users actually churn, join HM trajectories with engagement outcomes (e.g., days since last session) and evaluate predictive power in a time-forward train/test split. Without such validation, the churn detection claim remains a hypothesis. Sliding window extension ~~~~~~~~~~~~~~~~~~~~~~~~~ For production use, compute HM efficiency over a sliding window (e.g., last 20 sessions) and track the trend: .. code-block:: python window_size = 20 for start in range(0, len(log.choices) - window_size + 1, 5): end = start + window_size window_log = MenuChoiceLog( menus=log.menus[start:end], choices=log.choices[start:end], ) hm = compute_menu_efficiency(window_log).efficiency_index # Track hm over time; declining trend = preference drift Interpretation -------------- Three use cases from one score ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 1. **Recommender evaluation**: In an A/B test with **randomized menus**, the algorithm yielding higher average SARP consistency elicits more coherent click patterns. (Menu randomization is essential to control for the endogeneity of algorithmic recommendations.) 2. **User segmentation**: High-HM users exhibit more predictable click patterns. Low-HM users may benefit more from curated defaults and exploration. 3. **Churn detection (hypothesis)**: Monitor sliding-window HM efficiency. A declining trend may signal preference drift. This requires validation against actual churn labels before deployment. Comparison with standard metrics ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 20 25 25 * - Metric - Measures - Limitation * - CTR - Click rate - Doesn't distinguish coherent from random clicks * - RMSE/MAE - Rating prediction accuracy - Requires explicit ratings * - NDCG - Ranking quality vs. ground truth - Requires ground-truth relevance labels * - **SARP/HM** - **Preference consistency** - **Only needs clicks from varying menus** SARP consistency requires no ground truth, no explicit ratings, and no user feedback surveys. It's computed directly from the data every platform already logs. Limitations ~~~~~~~~~~~ - Menu reconstruction from click-stream is approximate. The "true" menu (what the user actually saw) may differ from items they viewed. - SARP tests for a *strict linear order*. Real preferences may be incomplete (indifference) or context-dependent. - With large catalogs, sparsity limits the test's power: if two items never co-occur in a menu, SARP says nothing about their relative ranking. - Session-level analysis assumes within-session independence; fatigue or position bias may create spurious violations.