MaxDiff (Best-Worst Scaling)#
MaxDiff is a survey methodology for ranking a large pool of items by preference. Respondents see rotating subsets of the items and, for each subset, pick the best and worst. The method has become a workhorse in conjoint / pricing / product research because it:
Avoids the well-known biases of rating scales (scale use heterogeneity, acquiescence).
Forces discrimination — respondents cannot rate everything “important”.
Recovers item utilities on a common scale that supports share-of-preference calculations.
This notebook walks through hierarchical Bayesian MaxDiff with MaxDiffMixedLogit: synthetic data → model fit → recovery diagnostics → respondent heterogeneity → share-of-preference output.
import warnings
import arviz as az
import arviz_plots as azp
import matplotlib.pyplot as plt
import numpy as np
import pymc as pm
from pymc_marketing.customer_choice import (
MaxDiffMixedLogit,
generate_maxdiff_data,
)
warnings.filterwarnings("ignore", category=FutureWarning)
%config InlineBackend.figure_format = "retina"
az.style.use("arviz-darkgrid")
plt.rcParams["figure.figsize"] = [10, 6]
plt.rcParams["figure.dpi"] = 100
SEED = 42
rng = np.random.default_rng(SEED)
Model#
For each task \(t\) a respondent \(r\) sees a subset \(S_t\) of the item pool and picks a best item \(b_t\) and worst item \(w_t\). The Louviere sequential best-worst likelihood decomposes the joint pick as
Item utilities \(U_{rj}\) decompose into a population-level item intercept plus a per-respondent deviation:
with the reference item pinned to \(\beta_{\text{ref}} = 0\) for identification. Only utility contrasts against the reference item are identified — absolute utility levels are arbitrary.
Synthetic data#
generate_maxdiff_data draws subsets uniformly at random (a real field design would use a balanced incomplete block design) and simulates best / worst picks under the Louviere model. It returns a long-format DataFrame and a dictionary of the true utilities we want to recover.
corr = np.array(
[
[1.0, 0.7, 0.6, -0.4, -0.3],
[0.7, 1.0, 0.5, -0.3, -0.3],
[0.6, 0.5, 1.0, -0.3, -0.2],
[-0.4, -0.3, -0.3, 1.0, 0.6],
[-0.3, -0.3, -0.2, 0.6, 1.0],
]
)
task_df, ground_truth = generate_maxdiff_data(
n_respondents=200,
n_items=5,
n_tasks_per_resp=12,
subset_size=4,
sigma_respondent=0.4,
random_seed=SEED,
item_correlation=corr,
)
items = ground_truth["items"]
true_utilities = ground_truth["utilities"]
task_df.head()
| respondent_id | task_id | item_id | is_best | is_worst | |
|---|---|---|---|---|---|
| 0 | r0 | 0 | item_4 | 1 | 0 |
| 1 | r0 | 0 | item_3 | 0 | 0 |
| 2 | r0 | 0 | item_0 | 0 | 1 |
| 3 | r0 | 0 | item_1 | 0 | 0 |
| 4 | r0 | 1 | item_1 | 0 | 0 |
Each row is one shown item in one task. Exactly one row per (respondent_id, task_id) group
carries is_best=1 and one carries is_worst=1.
grouped = task_df.groupby(["respondent_id", "task_id"])
print(f"respondents: {task_df['respondent_id'].nunique()}")
print(f"tasks: {grouped.ngroups}")
print(f"rows: {len(task_df)} (= tasks x subset_size)")
print(f"items: {len(items)}")
respondents: 200
tasks: 2400
rows: 9600 (= tasks x subset_size)
items: 5
Data format and prepare_maxdiff_data#
The long-format task_df above is the natural export from Sawtooth / Qualtrics / Conjointly. Internally, MaxDiffMixedLogit reshapes it into a padded-plus-mask representation via prepare_maxdiff_data. You can call the helper directly to inspect what the model consumes. This is useful when debugging imports from survey platforms.
from pymc_marketing.customer_choice import prepare_maxdiff_data
arrays = prepare_maxdiff_data(task_df, items=items)
print(f"n_tasks = {arrays['n_tasks']}")
print(f"n_respondents= {arrays['n_respondents']}")
print(f"n_items = {arrays['n_items']}")
print(f"k_max = {arrays['k_max']} (largest subset size)\n")
print("item_idx[:5] (items shown per task, as ints into `items`):")
print(arrays["item_idx"][:5])
print("\nmask[:5] (True = real shown item, False = padding):")
print(arrays["mask"][:5])
print("\nbest_pos[:5] (position 0..k_max-1 of the best pick):")
print(arrays["best_pos"][:5])
print("\nworst_pos[:5] (position of the worst pick):")
print(arrays["worst_pos"][:5])
print("\nresp_idx[:5] (respondent index per task):")
print(arrays["resp_idx"][:5])
n_tasks = 2400
n_respondents= 200
n_items = 5
k_max = 4 (largest subset size)
item_idx[:5] (items shown per task, as ints into `items`):
[[4 3 0 1]
[1 2 0 4]
[2 3 0 1]
[0 4 2 3]
[3 0 4 1]]
mask[:5] (True = real shown item, False = padding):
[[ True True True True]
[ True True True True]
[ True True True True]
[ True True True True]
[ True True True True]]
best_pos[:5] (position 0..k_max-1 of the best pick):
[0 1 2 3 0]
worst_pos[:5] (position of the worst pick):
[2 3 3 1 2]
resp_idx[:5] (respondent index per task):
[0 0 0 0 0]
Ragged subsets and input validation#
Real MaxDiff designs occasionally mix subset sizes within a single study. When a task_df has ragged subsets, prepare_maxdiff_data pads the shorter tasks with the reference item; the mask array records which positions are real so the model ignores padding in the softmax.
The helper also validates the data up front and raises a descriptive ValueError on malformed inputs i.e. missing best/worst picks, duplicate best picks, best == worst in the same task, duplicate items within a task, or items outside the declared pool:
bad_df = task_df.copy()
# Corrupt the first task: remove the best pick
first_task = (bad_df["respondent_id"] == "r0") & (bad_df["task_id"] == 0)
bad_df.loc[first_task, "is_best"] = 0
try:
prepare_maxdiff_data(bad_df, items=items)
except ValueError as e:
print(f"Caught expected error:\n {e}")
Caught expected error:
Task ('r0', np.int64(0)) has 0 best picks; exactly one is required.
Fit#
model = MaxDiffMixedLogit(task_df=task_df, items=items, full_covariance=False)
idata = model.fit(
draws=1000,
tune=1000,
chains=4,
target_accept=0.9,
random_seed=SEED,
nuts_sampler="pymc",
)
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [beta_item_, sigma_item, z_item]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 49 seconds.
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
pm.model_to_graphviz(model.model)
Parameter recovery#
Plot posterior item utilities against the ground truth. The dashed 45° line is perfect recovery.
idata
<xarray.DataTree>
Group: /
│ Attributes: (12/20)
│ id: 078d067ff5c38fbf
│ model_type: MaxDiff Mixed Logit
│ version: 0.3.0
│ sampler_config: {"nuts_sampler": "numpyro", "idata_kwargs": {"log_lik...
│ model_config: {"beta_item_": {"dist": "Normal", "kwargs": {"mu": 0,...
│ task_df: "Placeholder for DataFrame"
│ ... ...
│ non_centered: true
│ full_covariance: false
│ lkj_eta: 2.0
│ utility_formula: null
│ random_attributes: []
│ item_attributes: null
├── Group: /posterior
│ Dimensions: (chain: 4, draw: 1000, items: 5, respondents: 200,
│ tasks: 2400, positions: 4)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 6 ... 993 994 995 996 997 998 999
│ * items (items) <U6 120B 'item_0' 'item_1' 'item_2' 'item_3' 'item_4'
│ * respondents (respondents) <U4 3kB 'r0' 'r1' 'r2' ... 'r197' 'r198' 'r199'
│ * tasks (tasks) int64 19kB 0 1 2 3 4 5 ... 2395 2396 2397 2398 2399
│ * positions (positions) int64 32B 0 1 2 3
│ Data variables:
│ beta_item_ (chain, draw, items) float64 160kB 2.307 0.9915 ... 2.875 1.846
│ z_item (chain, draw, respondents, items) float64 32MB -0.1699 ... -...
│ sigma_item (chain, draw, items) float64 160kB 0.3337 0.3876 ... 0.6222
│ beta_item (chain, draw, items) float64 160kB 2.307 0.9915 ... 2.875 0.0
│ beta_item_r (chain, draw, respondents, items) float64 32MB 2.25 ... -0.1164
│ U (chain, draw, tasks, positions) float64 307MB 0.4174 ... -0....
│ p_best (chain, draw, tasks, positions) float64 307MB 0.04865 ... 0....
│ p_worst (chain, draw, tasks, positions) float64 307MB 0.0 ... 0.577
│ Attributes:
│ created_at: 2026-07-10T13:10:19.888995+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.0.1
│ sample_dims: ['chain', 'draw']
│ sampling_time: 48.86851501464844
│ tuning_steps: 1000
│ pymc_marketing_version: 1.0.0.dev0
├── Group: /sample_stats
│ Dimensions: (chain: 4, draw: 1000)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 ... 995 996 997 998 999
│ Data variables: (12/18)
│ n_steps (chain, draw) float64 32kB 31.0 31.0 ... 31.0 31.0
│ perf_counter_diff (chain, draw) float64 32kB 0.01915 ... 0.01871
│ energy_error (chain, draw) float64 32kB -0.6998 0.2852 ... -0.2269
│ process_time_diff (chain, draw) float64 32kB 0.01912 ... 0.01871
│ step_size_bar (chain, draw) float64 32kB 0.1618 0.1618 ... 0.1489
│ tree_depth (chain, draw) int64 32kB 5 5 5 5 5 5 ... 5 5 5 5 5 5
│ ... ...
│ perf_counter_start (chain, draw) float64 32kB 2.227e+06 ... 2.227e+06
│ index_in_trajectory (chain, draw) int64 32kB -14 -6 16 6 ... 20 11 -15 16
│ acceptance_rate (chain, draw) float64 32kB 1.0 0.7996 ... 0.8877
│ largest_eigval (chain, draw) float64 32kB nan nan nan ... nan nan
│ step_size (chain, draw) float64 32kB 0.14 0.14 ... 0.1887
│ lp (chain, draw) float64 32kB -5.557e+03 ... -5.583e+03
│ Attributes:
│ created_at: 2026-07-10T13:10:19.903189+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.0.1
│ sample_dims: ['chain', 'draw']
│ sampling_time: 48.86851501464844
│ tuning_steps: 1000
├── Group: /observed_data
│ Dimensions: (tasks: 2400)
│ Coordinates:
│ * tasks (tasks) int64 19kB 0 1 2 3 4 5 ... 2394 2395 2396 2397 2398 2399
│ Data variables:
│ best_pick (tasks) int64 19kB 0 1 2 3 0 3 2 3 1 1 1 ... 2 3 2 1 1 3 3 1 2 2
│ worst_pick (tasks) int64 19kB 2 3 3 1 2 1 1 0 2 2 0 ... 0 1 3 2 2 0 2 2 0 0
│ Attributes:
│ created_at: 2026-07-10T13:10:19.905423+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.0.1
│ sample_dims: []
├── Group: /constant_data
│ Dimensions: (tasks: 2400, positions: 4)
│ Coordinates:
│ * tasks (tasks) int64 19kB 0 1 2 3 4 5 ... 2394 2395 2396 2397 2398 2399
│ * positions (positions) int64 32B 0 1 2 3
│ Data variables:
│ item_idx (tasks, positions) int32 38kB 4 3 0 1 1 2 0 4 ... 1 4 0 3 1 3 2 4
│ mask (tasks, positions) bool 10kB True True True ... True True True
│ best_pos (tasks) int32 10kB 0 1 2 3 0 3 2 3 1 1 1 ... 2 3 2 1 1 3 3 1 2 2
│ worst_pos (tasks) int32 10kB 2 3 3 1 2 1 1 0 2 2 0 ... 0 1 3 2 2 0 2 2 0 0
│ resp_idx (tasks) int32 10kB 0 0 0 0 0 0 0 ... 199 199 199 199 199 199 199
│ Attributes:
│ created_at: 2026-07-10T13:10:19.906767+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.0.1
│ sample_dims: []
├── Group: /log_likelihood
│ Dimensions: (chain: 4, draw: 1000, tasks: 2400)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 6 7 ... 993 994 995 996 997 998 999
│ * tasks (tasks) int64 19kB 0 1 2 3 4 5 ... 2394 2395 2396 2397 2398 2399
│ Data variables:
│ worst_pick (chain, draw, tasks) float64 77MB -1.148 -0.3315 ... -0.9642
│ best_pick (chain, draw, tasks) float64 77MB -3.023 -0.6238 ... -0.5239
│ Attributes:
│ created_at: 2026-07-10T13:10:22.265092+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.0.1
│ sample_dims: ['chain', 'draw']
└── Group: /fit_data
Dimensions: (row: 9600)
Coordinates:
* row (row) int64 77kB 0 1 2 3 4 5 ... 9595 9596 9597 9598 9599
Data variables:
respondent_id (row) object 77kB 'r0' 'r0' 'r0' ... 'r199' 'r199' 'r199'
task_id (row) int64 77kB 0 0 0 0 1 1 1 1 ... 10 10 10 10 11 11 11 11
item_id (row) object 77kB 'item_4' 'item_3' ... 'item_2' 'item_4'
is_best (row) int64 77kB 1 0 0 0 0 1 0 0 0 0 ... 0 0 0 0 1 0 0 0 1 0
is_worst (row) int64 77kB 0 0 1 0 0 0 0 1 0 0 ... 1 0 1 0 0 0 1 0 0 0az.summary(idata, var_names=["beta_item"], round_to=2)
/Users/nathanielforde/Documents/Github/pymc-marketing/.venv/lib/python3.14/site-packages/arviz_stats/base/diagnostics.py:90: RuntimeWarning: invalid value encountered in scalar divide
(between_chain_variance / within_chain_variance + num_samples - 1) / (num_samples)
/Users/nathanielforde/Documents/Github/pymc-marketing/.venv/lib/python3.14/site-packages/arviz_stats/base/diagnostics.py:313: RuntimeWarning: invalid value encountered in scalar divide
varsd = varvar / evar / 4
| mean | sd | eti89_lb | eti89_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| beta_item[item_0] | 2.27 | 0.08 | 2.15 | 2.40 | 3074.51 | 3085.12 | 1.0 | 0.0 | 0.0 |
| beta_item[item_1] | 0.94 | 0.07 | 0.83 | 1.05 | 3500.91 | 3461.53 | 1.0 | 0.0 | 0.0 |
| beta_item[item_2] | 2.83 | 0.08 | 2.70 | 2.96 | 3111.82 | 3042.88 | 1.0 | 0.0 | 0.0 |
| beta_item[item_3] | 2.93 | 0.09 | 2.80 | 3.07 | 3172.27 | 3222.32 | 1.0 | 0.0 | 0.0 |
| beta_item[item_4] | 0.00 | 0.00 | 0.00 | 0.00 | 4000.00 | 4000.00 | NaN | 0.0 | NaN |
Respondent heterogeneity#
beta_item_r holds per-respondent item utilities. A forest of the first few respondents shows
how strongly preferences vary — the motivation for the hierarchical layer.
Caveats and follow-ups#
Design: real MaxDiff studies use balanced incomplete block designs (Sawtooth/Lighthouse). This model does not care which design produced the data — but efficiency is much higher under balanced rotations.
Item-attribute part-worths: when items share structure (price, brand, feature levels), a linear utility over item covariates \(U_j = x_j^\top \beta\) gains efficiency — see Part II below.
sample_posterior_predictive vs predict_choices / apply_intervention#
The Louviere model is sequential: the worst pick is drawn from the remaining items after the best has been removed. In the PyMC graph this is achieved by masking the chosen best position out of the worst-pick softmax, using best_pos as a pm.Data node.
sample_posterior_predictive therefore produces a partially conditioned joint:
best_pickis sampled correctly from \(\operatorname{softmax}(U)\).worst_pickis conditioned on the observedbest_pos(the value in the training data), not on the freshly sampledbest_pick.
This means the two draws can designate the same position i.e. the joint is incoherent for generative use. Instead for generative usage predict_choices and apply_intervention sample best first, then condition worst on the sampled best, so the joint is always coherent. All counterfactual work in this notebook uses apply_intervention for exactly this reason. The table below summarises when each method is appropriate:
Task |
Correct method |
|---|---|
In-sample PPC — does the model reproduce training worst picks given observed bests? |
|
Counterfactual / out-of-sample — coherent joint \((b, w)\) draws |
|
Part II — MaxDiff with attribute part-worths#
So far each item carried its own free utility. In conjoint-style MaxDiff we instead decompose utility into attribute part-worths:
where \(X_i\) is a row of attribute features (brand dummies, price, quality score, …) and \(\beta_{\text{feat}}\) is a vector of population-level part-worths. This buys three things the item-intercept model cannot give:
Extrapolation to new items. A hypothetical SKU outside the training pool gets a utility from its attributes alone — no need to re-survey.
Attribute-level inference. The posterior on \(\beta_{\text{feat}}\) tells us what drives preference, not just which item is preferred.
Heterogeneity on a chosen subspace. Pass
random_attributes=[...]to let respondents vary on a strict subset of features (e.g. price sensitivity) while sharing population beliefs on the rest.
Identification comes from ~ 0 + ... in the patsy formula (drop the global intercept) plus a sum-to-zero constraint on the implied per-item utilities. No reference item is needed.
from pymc_marketing.customer_choice import generate_maxdiff_conjoint_data
task_df_pw, attrs, gt = generate_maxdiff_conjoint_data(
n_respondents=500,
n_items=12,
n_tasks_per_resp=12,
subset_size=4,
sigma_respondent=0.4,
random_attributes=["price"],
random_seed=SEED,
)
print("Items + attributes:")
print(attrs)
print("\nFeature names (after patsy expansion):", gt["feature_names"])
print("Ground-truth betas:", dict(zip(gt["feature_names"], gt["betas"], strict=True)))
Items + attributes:
brand price quality
item_id
item_0 A 0.761140 0.878450
item_1 C 0.786064 -0.049926
item_2 B 0.128114 -0.184862
item_3 B 0.450386 -0.680930
item_4 B 0.370798 1.222541
item_5 C 0.926765 -0.154529
item_6 A 0.643865 -0.428328
item_7 C 0.822762 -0.352134
item_8 A 0.443414 0.532309
item_9 A 0.227239 0.365444
item_10 B 0.554585 0.412733
item_11 C 0.063817 0.430821
Feature names (after patsy expansion): ['C(brand)[A]', 'C(brand)[B]', 'C(brand)[C]', 'price', 'quality']
Ground-truth betas: {'C(brand)[A]': np.float64(2.1416476008704612), 'C(brand)[B]': np.float64(-0.4064150163846156), 'C(brand)[C]': np.float64(-0.5122427290715373), 'price': np.float64(-0.8137727282478777), 'quality': np.float64(0.6159794225754956)}
model_pw = MaxDiffMixedLogit(
task_df=task_df_pw,
items=gt["items"],
item_attributes=attrs,
utility_formula="~ 0 + C(brand) + price + quality",
random_attributes=["price"],
)
idata_pw = model_pw.fit(
draws=1000,
tune=1000,
chains=4,
target_accept=0.9,
random_seed=SEED,
)
NUTS[numpyro]: [beta_feat, sigma_feat, z_feat]
Recovery — population part-worths#
Compare the posterior mean of beta_feat against the simulated ground truth. Brand dummies share an unidentified global location (only contrasts are identified via ~ 0 + ...), so we compare centred values; price and quality recover on absolute scale.
feature_names = model_pw.feature_names
beta_post = idata_pw["posterior"]["beta_feat"] # (chain, draw, features)
beta_mean = beta_post.mean(dim=("chain", "draw")).values
beta_hdi = az.hdi(beta_post, prob=0.94).values # (features, 2)
truth = np.asarray(gt["betas"])
# Centre brand dummies (location is unidentified within the brand group).
is_brand = np.array([f.startswith("C(brand)") for f in feature_names])
truth_plot = truth.copy()
mean_plot = beta_mean.copy()
hdi_plot = beta_hdi.copy()
truth_plot[is_brand] -= truth_plot[is_brand].mean()
mean_plot[is_brand] -= mean_plot[is_brand].mean()
hdi_plot[is_brand] -= mean_plot[is_brand].mean() # shift HDI by same amount
fig, ax = plt.subplots(figsize=(8, 6))
yerr = np.abs(hdi_plot.T - mean_plot)
ax.errorbar(
truth_plot,
mean_plot,
yerr=yerr,
fmt="o",
capsize=3,
label="posterior mean +/- 94% HDI",
)
lo = min(truth_plot.min(), mean_plot.min()) - 0.3
hi = max(truth_plot.max(), mean_plot.max()) + 0.3
ax.plot([lo, hi], [lo, hi], "k--", alpha=0.5, label="perfect recovery")
# C(brand)[B], C(brand)[C] and price sit close together; spread their labels.
label_placements = {
"C(brand)[C]": {"xytext": (-6, 5), "ha": "right"},
"C(brand)[B]": {"xytext": (6, -14), "ha": "left"},
"price": {"xytext": (6, 8), "ha": "left"},
}
for i, name in enumerate(feature_names):
placement = label_placements.get(name, {"xytext": (5, 5), "ha": "left"})
ax.annotate(
name,
(truth_plot[i], mean_plot[i]),
fontsize=8,
textcoords="offset points",
**placement,
)
ax.set_xlabel("true part-worth (brand contrasts centred)")
ax.set_ylabel("posterior mean part-worth")
ax.set_title("Part-worth recovery")
ax.legend()
plt.show()
az.summary(
idata_pw,
var_names=["beta_feat", "U_item_pop", "sigma_feat"],
round_to=2,
)
| mean | sd | eti89_lb | eti89_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| beta_feat[C(brand)[A]] | 1.75 | 0.58 | 0.83 | 2.69 | 2475.38 | 2757.16 | 1.0 | 0.01 | 0.01 |
| beta_feat[C(brand)[B]] | -0.77 | 0.58 | -1.67 | 0.17 | 2494.93 | 2732.56 | 1.0 | 0.01 | 0.01 |
| beta_feat[C(brand)[C]] | -0.94 | 0.58 | -1.85 | 0.00 | 2484.10 | 2742.20 | 1.0 | 0.01 | 0.01 |
| beta_feat[price] | -0.76 | 0.05 | -0.85 | -0.68 | 6843.37 | 3273.06 | 1.0 | 0.00 | 0.00 |
| beta_feat[quality] | 0.58 | 0.02 | 0.54 | 0.62 | 8674.79 | 3004.38 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_0] | 1.96 | 0.03 | 1.91 | 2.01 | 4876.15 | 3400.88 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_1] | -1.28 | 0.02 | -1.32 | -1.25 | 4144.76 | 3580.19 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_2] | -0.69 | 0.03 | -0.73 | -0.65 | 5314.02 | 3673.99 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_3] | -1.23 | 0.03 | -1.27 | -1.18 | 6142.94 | 3657.47 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_4] | -0.06 | 0.03 | -0.11 | -0.01 | 6167.21 | 3514.09 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_5] | -1.45 | 0.03 | -1.49 | -1.41 | 4677.37 | 3546.47 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_6] | 1.29 | 0.03 | 1.24 | 1.34 | 4477.65 | 3196.60 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_7] | -1.49 | 0.02 | -1.53 | -1.45 | 4741.38 | 3449.30 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_8] | 2.00 | 0.03 | 1.96 | 2.05 | 4105.52 | 2898.85 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_9] | 2.07 | 0.03 | 2.02 | 2.12 | 4468.14 | 3171.13 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_10] | -0.67 | 0.02 | -0.71 | -0.64 | 4699.60 | 3620.34 | 1.0 | 0.00 | 0.00 |
| U_item_pop[item_11] | -0.45 | 0.03 | -0.51 | -0.39 | 6632.83 | 3684.64 | 1.0 | 0.00 | 0.00 |
| sigma_feat[price] | 0.23 | 0.12 | 0.03 | 0.41 | 552.66 | 751.88 | 1.0 | 0.01 | 0.00 |
Attribute importance#
A standard conjoint deliverable. Within each attribute, take the range of part-worths (max − min across its levels for categoricals, or the part-worth times the observed range for continuous attributes), then normalise so the importances sum to 1.
# Posterior draws of beta_feat, shape (samples, features)
beta_draws_pw = (
idata_pw["posterior"]["beta_feat"]
.stack(sample=("chain", "draw"))
.transpose("sample", "features")
.values
)
attr_groups = {
"brand": [i for i, f in enumerate(feature_names) if f.startswith("C(brand)")],
"price": [feature_names.index("price")],
"quality": [feature_names.index("quality")],
}
# Continuous attributes: importance = |beta| * (observed range across items)
ranges = {
"price": float(attrs["price"].max() - attrs["price"].min()),
"quality": float(attrs["quality"].max() - attrs["quality"].min()),
}
importance_draws = {}
for name, idxs in attr_groups.items():
if name == "brand":
# Range of brand part-worths per draw
importance_draws[name] = beta_draws_pw[:, idxs].max(axis=1) - beta_draws_pw[
:, idxs
].min(axis=1)
else:
importance_draws[name] = np.abs(beta_draws_pw[:, idxs[0]]) * ranges[name]
stack = np.stack([importance_draws[k] for k in attr_groups]) # (3, samples)
shares = stack / stack.sum(axis=0, keepdims=True) # normalise per draw
share_mean_pw = shares.mean(axis=1)
share_hdi_pw = np.quantile(shares, [0.03, 0.97], axis=1)
fig, ax = plt.subplots(figsize=(7, 4))
ax.barh(
list(attr_groups),
share_mean_pw,
xerr=np.abs(share_hdi_pw - share_mean_pw),
color="steelblue",
alpha=0.85,
)
ax.set_xlabel("Attribute importance (share of total range)")
ax.set_title("Attribute importance (94% HDI)")
ax.invert_yaxis()
plt.tight_layout()
plt.show()
Extrapolation to a brand-new item#
A unique selling point of part-worths: we can score a hypothetical SKU that was never shown in the survey. We just need its attribute row. Apply the same patsy formula to the new row and use the posterior of beta_feat to get a full posterior over the new item’s utility. This can, in turn, be plugged directly into a share-of-preference calculation against the existing items.
# Score a brand-new item: brand "B", aggressive price 0.10, average quality 0.0.
new_item = pd.DataFrame(
[{"brand": "B", "price": 0.10, "quality": 0.0}],
index=pd.Index(["item_NEW"], name="item_id"),
)
# score_new_items encodes the new row via the fitted patsy formula, computes posterior
# utilities from beta_feat, and returns share-of-preference over the extended pool.
scored = model_pw.score_new_items(new_item)
# scored.coords["items"] = training items + ["item_NEW"]
all_items = list(scored.coords["items"].values)
share_mean_new = scored["share_of_preference"].mean(dim=("chain", "draw")).values
order_new = np.argsort(share_mean_new)[::-1]
fig, ax = plt.subplots(figsize=(8, 5))
y_pos = np.arange(len(all_items))
colors = [
"darkorange" if all_items[i] == "item_NEW" else "steelblue" for i in order_new
]
ax.barh(
y_pos,
share_mean_new[order_new],
color=colors,
alpha=0.85,
)
ax.set_yticks(y_pos)
ax.set_yticklabels([all_items[i] for i in order_new])
ax.invert_yaxis()
ax.set_xlabel("Share of preference")
ax.set_title("Share of preference incl. brand-new SKU 'item_NEW' (orange)")
plt.tight_layout()
plt.show()
new_idx = all_items.index("item_NEW")
print(f"New item posterior share: mean={share_mean_new[new_idx]:.3f}")
pm.model_to_graphviz(model_pw.model)
%load_ext watermark
%watermark -n -u -v -iv -w -p pymc_marketing
Last updated: Fri, 10 Jul 2026
Python implementation: CPython
Python version : 3.14.6
IPython version : 9.15.0
pymc_marketing: 1.0.0.dev0
arviz : 1.2.0
arviz_plots : 1.2.0
matplotlib : 3.10.9
numpy : 2.4.6
pandas : 2.3.3
pymc : 6.0.1
pymc_marketing: 1.0.0.dev0
Watermark: 2.6.0