DEV Community

Koji M
Koji M

Posted on

partworth: hierarchical Bayes for choice-based conjoint in Python

A choice-based conjoint (CBC) study shows people a few products and asks them to pick one. Then it does this again and again. Each product is a mix of features, such as brand, size and price. From the choices, you estimate how much each feature matters to each person. Then you answer business questions. Which product wins? What happens if we raise the price? How much more will people pay for a bigger size?

A common method is hierarchical Bayes (HB). Many teams use Sawtooth CBC/HB for it. It is commercial and runs outside Python. So the work often looks like this: you estimate in one tool, export a file, and build a simulator in a spreadsheet. Each hand-off is a place for mistakes. The model's uncertainty is also often lost on the way, and the spreadsheet shows one share per product, with no range.

I wanted to do the whole thing in Python. Python already has general choice models. For example, pymc-marketing has a MixedLogit model. As of October 2026, it takes a wide table and has no correlations between coefficients. It also has no Sawtooth-style files and no WTP. For CBC work, I needed a few specific things:

  • a "none" option, for people who would buy nothing
  • correlated tastes, because people who care about brand may also care about price
  • a price coefficient that is negative for every respondent, so willingness to pay makes sense
  • outputs in the format CBC teams already use, like a Sawtooth-style utilities file
  • a market simulator that shows uncertainty

I could not find one Python package that did all of this, so I wrote partworth. It is open source (Apache-2.0) and built on PyMC.

What partworth does

The README splits the workflow into four steps.

  1. Your survey. You bring CBC answers in long format, one row per respondent, task and alternative. You can also import a wide design, for example from ChoiceDesign.
  2. Fit one model. ChoiceData checks the data. HierarchicalMNL runs HB estimation on PyMC. Then you get diagnostics: R-hat, effective sample size (ESS) and divergences.
  3. Read the results. You get part-worths for each respondent, attribute importances and RLH (a fit score for each person). You can write a Sawtooth-style CSV file.
  4. Answer business questions. You get market shares with credible intervals, price curves, and willingness to pay (WTP) for a feature.

Steps 1 to 3 replace the "estimate, export, spreadsheet" routine. Step 4 uses the posterior draws, so every share, price curve and WTP comes with a credible interval.

A few design choices are there because of how CBC data look. Each person usually answers only about 8 to 20 tasks, so the data for one person is thin. partworth uses a non-centred model, which usually samples better in this case. It puts an LKJ prior on the correlations between tastes. For price, you can add a sign constraint. Then the price coefficient is negative for every respondent, through a lognormal. Without this, a respondent whose price coefficient is near zero or positive gives a WTP that makes no sense.

A quick look

partworth needs Python 3.12 or later. It is not on PyPI yet, so you install it from GitHub:

pip install "partworth[nutpie,datasets] @ git+https://github.com/kojikojikojikoji/partworth"
Enter fullscreen mode Exit fullscreen mode

On macOS 26 or later, PyTensor 3.3.3 or earlier can fail with ld: library 'd64' not found. Set PYTENSOR_FLAGS=cxx= to avoid it.

This is the Quick look example from the README. It uses simulated data, so you can paste it as it is.

import pandas as pd
import partworth as pw
from partworth.datasets import simulate_cbc

# 300 respondents, 12 tasks, 3 products + "none" per task.
# mu: true population means of the coded coefficients (price on the log scale).
data, truth = simulate_cbc(
    300, 12, 3,
    {"brand": ["A", "B", "C"], "size": ["S", "M", "L"], "price": [2.0, 2.5, 3.0, 3.5]},
    price="price", mu=[0.4, -0.1, -0.3, 0.1, -0.5, -1.0], sd=0.6, seed=1,
)
print(data.describe())                       # how often each level was shown and chosen

# more tuning and draws than the defaults: the SD of "none" mixes slowly (see the guide)
result = pw.HierarchicalMNL(data).fit(draws=2000, tune=3000, chains=4, random_seed=1)
print(result.diagnostics().report())         # R-hat, ESS, divergences, what to try

result.part_worths()                         # raw utilities, one column per level
result.part_worths("zc_diffs")               # zero-centred diffs
result.importances()
result.to_sawtooth_csv("utilities.csv")

scenario = pd.DataFrame({"product": ["Ours", "Theirs"], "brand": ["A", "B"],
                         "size": ["M", "M"], "price": [3.0, 2.5]})
sim = pw.MarketSimulator(result)
print(sim.shares(scenario))                  # shares with 90% credible intervals
print(sim.price_curve(scenario, product="Ours", grid=[2.0, 2.5, 3.0, 3.5]))
print(pw.wtp(result, attribute="size", from_level="S", to_level="L"))
Enter fullscreen mode Exit fullscreen mode

simulate_cbc makes 300 fake respondents with known true values. The fit uses more tuning and more draws than the defaults. The SD of "none" mixes slowly in this example, and the user guide explains why. The last three lines answer the business questions. Each one prints an estimate together with its credible interval.

For real data, examples/camera.py runs the same steps on the public camera data from the R package bayesm. partworth downloads that data from CRAN on first use.

How I checked it

An HB package can give numbers that look fine and still be wrong. So the repository has validation scripts. Each script writes a report to validation/results/. These are the main results.

Comparison with R bayesm. I ran bayesm 3.1.7 (rhierMnlRwMixture) and partworth on the camera data. Tasks 1 to 14 were for estimation. Tasks 15 and 16 were kept as hold-out tasks.

  • For every coefficient, the respondent-level posterior means of the two packages correlate at 0.997 or more.
  • The hold-out hit rate is 0.729 for partworth and 0.730 for bayesm.

The two packages use different samplers and priors. bayesm uses random-walk Metropolis and an inverse-Wishart prior. partworth uses NUTS and an LKJ prior. Even so, the respondent-level posterior means move together very closely, and the two packages predict the hold-out tasks equally well.

Parameter recovery. I simulated 300 respondents with 12 tasks each, using known true values. Then I checked if partworth could find them.

  • Respondent-level 90% intervals covered 88.7% of the true values.
  • 5 of 6 population means and 6 of 6 SDs were inside their 90% intervals.
  • For the coefficients, the convergence targets were met: R-hat max 1.006, bulk ESS min 1094, no divergences. The correlations are a different story (see below).

Simulation-based calibration. I fitted 200 simulated data sets and looked at 8 population parameters. The smallest uniformity p-value was 0.017. None was below 0.006, the threshold after correcting for 8 tests. So the test found no problem. That is not a proof that the model is calibrated, but it is a useful check.

Speed. On one laptop, 300 respondents took 74 seconds. 1,000 respondents took 290 seconds.

What is not good enough yet

Some targets were not met. I want to list them clearly.

  • On the camera data, population means differ from bayesm by up to 0.21 of bayesm's posterior SD. My target was 0.1, so this target is not met. The two packages use different priors. When I made partworth's priors much wider, the difference grew to 0.92 SDs. The respondent-level means still correlated with bayesm at 0.998. So, at least on this data set, the population means are sensitive to the priors.
  • In the recovery test, the price mean (on the log scale) was just outside its 90% interval. The true value was 0.30 and the interval was [0.13, 0.29].
  • In the recovery test, correlations between tastes mixed slowly. They reached R-hat 1.013 and ESS 399. The targets are R-hat below 1.01 and ESS above 400. Before you report correlations, check their diagnostics. You may need more draws.
  • In the calibration run, 15 of the 200 fits had a population ESS below 99. That makes the check weaker.
  • The "Sawtooth-style" files follow Sawtooth's public manuals. They have not been compared with files made by Sawtooth software.
  • The package is pre-alpha. The API may change. It is not on PyPI yet.

All examples use public or simulated data. The camera data comes from bayesm, which is GPL. partworth never includes that data and uses no bayesm code. The comparison runs R in a separate process.

What is next

The next step is a first release on PyPI. Some things are out of scope: experimental design (use ChoiceDesign for that), latent class, MaxDiff and ACBC.

If you work with CBC data, please try it. Run the Quick look, then examples/camera.py. If you have Sawtooth, I would like to see how its utilities file compares with partworth's. If a fit does not converge, or a number looks wrong to you, please open an issue. Simulated data that shows the problem is the most helpful.

The code, the user guide and all validation reports are here: https://github.com/kojikojikojikoji/partworth

I am Koji Miyazawa, a data scientist living in London. Thanks for reading.

Top comments (0)