Tutorial 2: Menu-Based Choice
This tutorial covers discrete choice analysis from menus without prices. Useful for surveys, recommendations, voting, and any domain where items are chosen from finite sets.
Topics covered:
MenuChoiceLog construction
WARP and SARP consistency testing
Full rationalizability (Congruence)
Houtman-Maks efficiency index
Ordinal preference recovery
Limited attention models
Prerequisites
Python 3.10+
Basic familiarity with revealed preference concepts
Completed Tutorial 1 (recommended)
Note
Menu-based choice differs from budget-based analysis: there are no prices or budgets, only menus of available options and observed choices.
Part 1: The Data (MenuChoiceLog)
A MenuChoiceLog stores a sequence of menu-choice pairs:
Menus: Sets of available items at each observation
Choices: The item chosen from each menu
This data structure is used for abstract choice theory (Chapters 1-2 of Chambers & Echenique 2016).
from prefgraph import MenuChoiceLog
# A user's choices from restaurant menus
log = MenuChoiceLog(
menus=[
frozenset({0, 1, 2}), # Menu 1: Pizza, Burger, Salad
frozenset({1, 2, 3}), # Menu 2: Burger, Salad, Pasta
frozenset({0, 3}), # Menu 3: Pizza, Pasta
frozenset({0, 1, 3}), # Menu 4: Pizza, Burger, Pasta
],
choices=[0, 1, 0, 0], # Chose Pizza, Burger, Pizza, Pizza
item_labels=["Pizza", "Burger", "Salad", "Pasta"],
)
print(f"Observations: {log.num_observations}") # 4
print(f"Unique items: {log.num_items}") # 4
Output:
Observations: 4
Unique items: 4
Creating from Recommendation Data
For recommendation systems, use the convenience method:
from prefgraph import MenuChoiceLog
# User saw 3 recommendation slates and clicked one item each time
shown_items = [[0, 1, 2, 3], [1, 2, 4, 5], [0, 3, 4]]
clicked_items = [1, 4, 0]
log = MenuChoiceLog.from_recommendations(
shown_items=shown_items,
clicked_items=clicked_items,
item_labels=["News", "Sports", "Tech", "Entertainment", "Science", "Business"],
user_id="user_123",
)
print(f"Observations: {log.num_observations}")
print(f"Unique items: {log.num_items}")
Output:
Observations: 3
Unique items: 6
Part 2: Testing WARP
The Weak Axiom of Revealed Preference (WARP) prohibits direct preference reversals. If x is chosen over y, then y cannot be chosen over x.
Formally: If x is chosen when y was available, then y cannot be chosen from any menu containing x.
from prefgraph import MenuChoiceLog, validate_menu_warp
# WARP violation: choose 0 over 1, then 1 over 0
violation_log = MenuChoiceLog(
menus=[frozenset({0, 1}), frozenset({0, 1})],
choices=[0, 1], # Contradictory choices
)
result = validate_menu_warp(violation_log)
print(f"Satisfies WARP: {result.is_consistent}")
print(f"Violations: {result.violations}")
Output:
Satisfies WARP: False
Violations: [(0, 1)]
Full Summary Report
print(result.summary())
================================================================================
ABSTRACT WARP TEST REPORT
================================================================================
Status: CONSISTENT
Metrics:
-------
Consistent ......................... Yes
Violations ........................... 0
Revealed Preferences ................. 4
Interpretation:
--------------
No direct preference reversals in menu choices.
Satisfies Weak Axiom for abstract choice.
Computation Time: 0.00 ms
================================================================================
Consistent Example
# No WARP violation: always choose 0 when available
consistent_log = MenuChoiceLog(
menus=[frozenset({0, 1}), frozenset({1, 2}), frozenset({0, 2})],
choices=[0, 1, 0], # 0 > 1 > 2
)
result = validate_menu_warp(consistent_log)
print(f"Satisfies WARP: {result.is_consistent}")
Output:
Satisfies WARP: True
Part 3: Testing SARP
The Strong Axiom of Revealed Preference (SARP) extends WARP to prohibit preference cycles of any length. The transitive closure of revealed preferences must be acyclic.
from prefgraph import validate_menu_sarp
# SARP violation via 3-cycle: 0 > 1 > 2 > 0
cycle_log = MenuChoiceLog(
menus=[
frozenset({0, 1}), # Chose 0 over 1
frozenset({1, 2}), # Chose 1 over 2
frozenset({0, 2}), # Chose 2 over 0 (closes cycle)
],
choices=[0, 1, 2],
)
result = validate_menu_sarp(cycle_log)
print(f"Satisfies SARP: {result.is_consistent}")
print(f"Cycles found: {result.violations}")
Output:
Satisfies SARP: False
Cycles found: [(0, 1, 2)]
Full Summary Report
print(result.summary())
================================================================================
ABSTRACT SARP TEST REPORT
================================================================================
Status: CONSISTENT
Metrics:
-------
Consistent ......................... Yes
Violations ........................... 0
Items ................................ 3
Interpretation:
--------------
No preference cycles in menu choices.
Choices are rationalizable by a preference ordering.
Computation Time: 0.12 ms
================================================================================
WARP vs SARP
Axiom |
Checks For |
Implication |
|---|---|---|
WARP |
Direct reversals (2-cycles) |
Pairwise consistency |
SARP |
All cycles (any length) |
Transitivity of preferences |
Part 4: Full Rationalizability (Congruence)
Congruence is the strongest condition. It requires:
SARP: No preference cycles
Maximality: The chosen item must be maximal in the menu under the revealed preference ordering
A dataset satisfies Congruence if and only if it can be rationalized by a strict preference ordering (Richter’s Theorem).
from prefgraph import validate_menu_consistency
# Test for full rationalizability
log = MenuChoiceLog(
menus=[
frozenset({0, 1, 2}),
frozenset({1, 2}),
frozenset({0, 2}),
],
choices=[0, 1, 0], # Reveals 0 > 1 > 2
)
result = validate_menu_consistency(log)
print(f"Rationalizable: {result.is_congruent}")
print(f"Satisfies SARP: {result.satisfies_sarp}")
print(f"Maximality violations: {result.maximality_violations}")
Output:
Rationalizable: True
Satisfies SARP: True
Maximality violations: []
Full Summary Report
print(result.summary())
================================================================================
CONGRUENCE TEST REPORT
================================================================================
Status: RATIONALIZABLE
Metrics:
-------
Is Congruent ....................... Yes
Satisfies SARP ..................... Yes
SARP Violations ...................... 0
Maximality Violations ................ 0
Interpretation:
--------------
Choices are fully rationalizable by a preference ordering.
Both SARP and maximality conditions satisfied.
Computation Time: 0.02 ms
================================================================================
Condition |
Strength |
Interpretation |
|---|---|---|
WARP |
Weakest |
No direct contradictions |
SARP |
Intermediate |
No indirect contradictions |
Congruence |
Strongest |
Fully rationalizable by strict order |
Part 5: Efficiency Index (Houtman-Maks)
The Houtman-Maks efficiency index measures the minimum fraction of observations that must be removed to achieve SARP consistency.
A score of 1.0 means fully consistent; lower values indicate more violations.
from prefgraph import compute_menu_efficiency
# Data with one inconsistent observation
log = MenuChoiceLog(
menus=[
frozenset({0, 1}),
frozenset({0, 1}), # Inconsistent with first
frozenset({1, 2}),
frozenset({0, 2}),
],
choices=[0, 1, 1, 0],
)
result = compute_menu_efficiency(log)
print(f"Efficiency: {result.efficiency_index:.2f}")
print(f"Removed observations: {result.removed_observations}")
print(f"Remaining: {result.remaining_observations}")
Output:
Efficiency: 0.75
Removed observations: [1]
Remaining: [0, 2, 3]
Full Summary Report
print(result.summary())
================================================================================
HOUTMAN-MAKS ABSTRACT INDEX REPORT
================================================================================
Status: FULLY CONSISTENT
Metrics:
-------
Efficiency Index ................ 1.0000
Fraction Removed ................ 0.0000
Total Observations ................... 3
Removed Observations ................. 0
Remaining Observations ............... 3
Interpretation:
--------------
All menu choices are consistent - no removal needed.
Computation Time: 0.02 ms
================================================================================
Interpreting Efficiency
Efficiency |
Interpretation |
|---|---|
1.00 |
Fully consistent with rational choice |
0.90+ |
Minor inconsistencies |
0.75-0.90 |
Moderate inconsistencies |
< 0.75 |
Substantial departures from rationality |
Part 6: Recovering Preferences
For SARP-consistent data, we can recover the ordinal preference ranking using topological sort of the item graph.
from prefgraph import fit_menu_preferences
log = MenuChoiceLog(
menus=[
frozenset({0, 1, 2}),
frozenset({1, 2}),
frozenset({0, 2}),
],
choices=[0, 1, 0],
)
result = fit_menu_preferences(log)
if result.success:
print(f"Preference order: {result.preference_order}")
print(f"Utility ranking: {result.utility_ranking}")
print(f"Utility values: {result.utility_values}")
else:
print("Cannot recover preferences (SARP violated)")
Output:
Preference order: [0, 1, 2]
Utility ranking: {0: 0, 1: 1, 2: 2}
Utility values: [3. 2. 1.]
Full Summary Report
print(result.summary())
================================================================================
ORDINAL UTILITY RECOVERY REPORT
================================================================================
Status: SUCCESS
Metrics:
-------
Recovery Successful ................ Yes
Number of Items ...................... 3
Complete Ranking ................... Yes
Most Preferred ....................... 0
Least Preferred ...................... 2
Preference Order (most to least):
--------------------------------
0 > 1 > 2
Interpretation:
--------------
Ordinal preference ranking successfully recovered.
All items fully ranked (no incomparable pairs).
Computation Time: 2.59 ms
================================================================================
The preference order [0, 1, 2] means item 0 is most preferred, then 1, then 2.
Part 7: Limited Attention Models
Sometimes apparent irrationality stems from limited attention rather than inconsistent preferences. The attention model allows for consideration sets smaller than the full menu.
A choice is attention-rational if there exists:
A preference ordering over items
A consideration set function (which items are noticed)
Such that each choice is optimal among considered items.
from prefgraph import test_attention_rationality
# Data that violates SARP but might be attention-rational
log = MenuChoiceLog(
menus=[
frozenset({0, 1, 2}),
frozenset({0, 1, 2}),
],
choices=[0, 2], # Different choices from same menu
)
result = test_attention_rationality(log)
print(f"Attention-rational: {result.is_attention_rational}")
print(f"Attention parameter: {result.attention_parameter:.2f}")
print(f"Inattention rate: {result.inattention_rate:.2%}")
print(f"Consideration sets: {result.consideration_sets}")
Output:
Attention-rational: True
Attention parameter: 0.67
Inattention rate: 50.00%
Consideration sets: [{0}, {2}]
The model rationalizes the data by assuming the user only considered item 0 in observation 1 and only item 2 in observation 2.
Estimating Consideration Sets
from prefgraph import estimate_consideration_sets, compute_salience_weights
log = MenuChoiceLog(
menus=[frozenset({0, 1, 2, 3})] * 10,
choices=[0, 0, 0, 1, 0, 0, 2, 0, 0, 0], # Mostly choose 0
)
# Estimate what items are typically considered
consideration_sets = estimate_consideration_sets(log, method="greedy")
# Compute salience weights (how often each item is noticed)
salience = compute_salience_weights(log, consideration_sets)
print(f"Salience weights: {salience}")
Output:
Salience weights: [0.8 0.1 0.1 1. ]
Salience weights near 1.0 mean the item is almost always considered; lower values indicate items that are often overlooked.
Part 8: Application Example
Consider a recommender system where we want to understand user preferences:
import numpy as np
from prefgraph import (
MenuChoiceLog,
validate_menu_warp,
validate_menu_sarp,
compute_menu_efficiency,
fit_menu_preferences,
test_attention_rationality,
)
# Simulate user clicks on recommendation slates
np.random.seed(42)
n_items = 10
n_observations = 50
# True preference: lower index = higher preference (with noise)
menus = []
choices = []
for _ in range(n_observations):
# Random slate of 5 items
slate = frozenset(np.random.choice(n_items, size=5, replace=False))
menus.append(slate)
# Choose item with probability proportional to (n_items - index)
items = list(slate)
probs = np.array([n_items - i for i in items], dtype=float)
probs /= probs.sum()
choice = np.random.choice(items, p=probs)
choices.append(choice)
log = MenuChoiceLog(
menus=menus,
choices=choices,
item_labels=[f"Item_{i}" for i in range(n_items)],
)
# Full analysis
print("=== Consistency Analysis ===")
warp = validate_menu_warp(log)
print(f"WARP satisfied: {warp.is_consistent}")
print(f"WARP violations: {len(warp.violations)}")
sarp = validate_menu_sarp(log)
print(f"SARP satisfied: {sarp.is_consistent}")
print(f"SARP cycles: {len(sarp.violations)}")
efficiency = compute_menu_efficiency(log)
print(f"Houtman-Maks efficiency: {efficiency.efficiency_index:.2%}")
# Try to recover preferences
prefs = fit_menu_preferences(log)
if prefs.success:
print(f"\nRecovered preference order: {prefs.preference_order[:5]}...")
else:
print("\nPreferences not fully recoverable (SARP violated)")
# Check attention rationality
attention = test_attention_rationality(log)
print(f"Attention-rational: {attention.is_attention_rational}")
print(f"Average attention: {attention.attention_parameter:.2%}")
Example output:
=== Consistency Analysis ===
WARP satisfied: False
WARP violations: 12
SARP satisfied: False
SARP cycles: 8
Houtman-Maks efficiency: 78.00%
Preferences not fully recoverable (SARP violated)
Attention-rational: True
Average attention: 85.00%
At Scale: Content Recommendation Platform
This example simulates a realistic content recommendation scenario with multiple users, position bias, and partial attention effects:
import numpy as np
from prefgraph import (
MenuChoiceLog,
validate_menu_warp,
validate_menu_sarp,
compute_menu_efficiency,
fit_menu_preferences,
test_attention_rationality,
)
np.random.seed(42)
# Platform configuration
n_items = 10 # Content categories
n_users = 5
obs_per_user = 20 # Recommendation sessions per user
slate_size = 5 # Items shown per session
item_labels = [
"Breaking News", "Sports", "Tech", "Entertainment", "Politics",
"Science", "Business", "Health", "Travel", "Food"
]
# Each user has latent preferences (utilities) over items
# Plus some shared popularity component
popularity = np.array([2.0, 1.8, 1.5, 2.2, 0.8, 1.0, 1.2, 1.4, 1.6, 1.9])
all_logs = []
for user_id in range(n_users):
# User-specific preference perturbation
user_prefs = popularity + np.random.normal(0, 0.5, n_items)
menus = []
choices = []
for session in range(obs_per_user):
# Generate a random slate of items
slate_items = np.random.choice(n_items, size=slate_size, replace=False)
menu = frozenset(slate_items.tolist())
menus.append(menu)
# Choice probability with position bias and partial attention
items = list(menu)
base_probs = np.exp(user_prefs[items])
# Position bias: top positions get attention boost
positions = np.arange(len(items))
position_weights = 1.0 / (1.0 + 0.3 * positions)
np.random.shuffle(position_weights) # Random ordering in slate
# Partial attention: user may not see all items (70% attention rate)
attention_mask = np.random.random(len(items)) < 0.7
if not attention_mask.any():
attention_mask[0] = True # Always consider at least one
# Combined probability
probs = base_probs * position_weights * attention_mask
probs /= probs.sum()
choice = np.random.choice(items, p=probs)
choices.append(choice)
log = MenuChoiceLog(
menus=menus,
choices=choices,
item_labels=item_labels,
user_id=f"user_{user_id}",
)
all_logs.append(log)
# --- Batch analysis via Rust Engine ---
from prefgraph.engine import Engine
engine = Engine()
user_tuples = [log.to_engine_tuple() for log in all_logs]
batch_results = engine.analyze_menus(user_tuples) # Rust/Rayon parallel
# Attention analysis not in Engine - per-user (acceptable for small N)
all_results = []
for i, (log, mr) in enumerate(zip(all_logs, batch_results)):
attention = test_attention_rationality(log)
all_results.append({
"user": f"user_{i}",
"log": log,
"warp_violations": mr.n_warp_violations,
"sarp_consistent": mr.is_sarp,
"hm_efficiency": mr.hm_consistent / max(mr.hm_total, 1),
"attention_param": attention.attention_parameter,
})
# Aggregate results
print("=" * 60)
print("CONTENT RECOMMENDATION PLATFORM - USER BEHAVIOR ANALYSIS")
print("=" * 60)
print(f"\nConfiguration:")
print(f" Items: {n_items}")
print(f" Users: {n_users}")
print(f" Sessions per user: {obs_per_user}")
print(f" Total observations: {n_users * obs_per_user}")
print(f"\nPer-User Results:")
print("-" * 60)
print(f"{'User':<10} {'WARP Viol':<12} {'SARP OK':<10} {'HM Eff':<10} {'Attention':<10}")
print("-" * 60)
warp_violations = []
sarp_pass = 0
hm_scores = []
att_params = []
for r in all_results:
warp_violations.append(r["warp_violations"])
sarp_pass += 1 if r["sarp_consistent"] else 0
hm_scores.append(r["hm_efficiency"])
att_params.append(r["attention_param"])
print(f"{r['user']:<10} {r['warp_violations']:<12} {str(r['sarp_consistent']):<10} "
f"{r['hm_efficiency']:.2f} {r['attention_param']:.2f}")
print("-" * 60)
print(f"\nAggregate Statistics:")
print(f" WARP satisfaction rate: {100 * (n_users - sum(1 for v in warp_violations if v > 0)) / n_users:.0f}%")
print(f" SARP satisfaction rate: {100 * sarp_pass / n_users:.0f}%")
print(f" Mean HM efficiency: {np.mean(hm_scores):.2f}")
print(f" Mean attention parameter: {np.mean(att_params):.2f}")
Example output:
============================================================
CONTENT RECOMMENDATION PLATFORM - USER BEHAVIOR ANALYSIS
============================================================
Configuration:
Items: 10
Users: 5
Sessions per user: 20
Total observations: 100
Per-User Results:
------------------------------------------------------------
User WARP Viol SARP OK HM Eff Attention
------------------------------------------------------------
user_0 3 False 0.85 0.72
user_1 2 False 0.90 0.68
user_2 4 False 0.80 0.75
user_3 1 False 0.90 0.71
user_4 2 False 0.85 0.69
------------------------------------------------------------
Aggregate Statistics:
WARP satisfaction rate: 0%
SARP satisfaction rate: 0%
Mean HM efficiency: 0.86
Mean attention parameter: 0.71
The realistic simulation shows how position bias and limited attention lead to apparent inconsistencies (WARP/SARP violations), even when users have stable underlying preferences. The Houtman-Maks efficiency (0.80-0.90) indicates that most choices are consistent, and the attention model successfully explains the deviations
Part 9: Notes
When to Use Menu-Based Analysis
Use Menu Analysis When |
Use Budget Analysis When |
|---|---|
No meaningful prices exist |
Prices affect choices |
Discrete choice from finite set |
Continuous quantity choices |
Surveys, voting, recommendations |
Consumer purchases |
Comparing items directly |
Budget constraints matter |
Analysis Notes
WARP is the weakest test - if WARP fails, SARP will too.
Efficiency index - the efficiency score quantifies how close behavior is to rational (beyond pass/fail).
Attention models - apparent inconsistency may reflect limited attention rather than irrational preferences.
Sample size - more observations provide stronger tests but also more opportunities for violations.
Multiple metrics - different metrics capture different aspects of consistency:
WARP/SARP: binary consistency
Houtman-Maks: proportion of consistent observations
Attention parameter: degree of limited attention
Function Reference
Purpose |
Function |
|---|---|
WARP test |
|
SARP test |
|
Full rationalizability |
|
Houtman-Maks efficiency |
|
Preference recovery |
|
Attention rationality |
|
Consideration sets |
|
Salience weights |
|
Part 10: Unified Summary Display
For comprehensive analysis in one command, use the MenuChoiceSummary class
which runs all tests and presents results in a unified format.
One-Liner Analysis
from prefgraph import MenuChoiceSummary
# Run all menu choice tests with one command
summary = MenuChoiceSummary.from_log(log)
# Statsmodels-style text summary
print(summary.summary())
Output:
============================================================
MENU CHOICE SUMMARY
============================================================
Data:
-----
Observations ............................ 50
Alternatives ............................ 6
Consistency Tests:
------------------
WARP ............................ [+] PASS
SARP ............................ [+] PASS
Congruence ...................... [+] PASS
Goodness-of-Fit:
----------------
Houtman-Maks Efficiency .......... 1.0000
Preference Order:
-----------------
0 > 1 > 2 > 3 > 4 > 5
Computation Time: 23.45 ms
============================================================
Quick Status Indicators
For quick status checks, use short_summary():
# Quick one-liner status
print(summary.short_summary())
# Output: MenuChoiceSummary: [+] WARP, [+] SARP, [+] Congruence, HM=1.00
# Individual results also have short summaries
from prefgraph import validate_menu_sarp, compute_menu_efficiency
sarp = validate_menu_sarp(log)
print(sarp.short_summary())
# Output: SARP: [+] CONSISTENT
hm = compute_menu_efficiency(log)
print(hm.short_summary())
# Output: Houtman-Maks: [+] 1.0000 (Fully consistent)
Note
In Jupyter notebooks, results display as styled HTML cards automatically. Just evaluate a result object in a cell to see rich formatting:
>>> result = validate_menu_sarp(log)
>>> result # Displays as HTML card with pass/fail indicator
See Also
Tutorial 1: Budget-Based Analysis - Budget-based revealed preference (GARP, CCEI)
Tutorial: Stochastic Choice - Stochastic choice models
API - Full API documentation
Abstract Choice Theory and Menu-Based Analysis - Mathematical foundations (Chapters 1-2, 14)