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)