Example of a C-efficient RUM design with ChoiceDesign

This notebook illustrates how to use ChoiceDesign to generate a C-efficient experimental design for a Random Utility Maximisation (RUM) model. The C-criterion minimises the variance of Willingness-to-Pay (WTP) estimates rather than the overall parameter variance.

For each non-cost attribute \(x\) with parameter \(\beta_x\), the WTP is defined as:

\[\text{WTP}_x = \frac{\beta_x}{\beta_{\text{cost}}}\]

Using the delta method, the variance of \(\text{WTP}_x\) is \(c_x^\top I^{-1} c_x\), where the contrast vector is:

\[\begin{split}c_x[j] = \begin{cases} 1/\beta_{\text{cost}} & j = x \\ -\beta_x/\beta_{\text{cost}}^2 & j = \text{cost} \\ 0 & \text{otherwise} \end{cases}\end{split}\]

The C-error is the sum of these variances over all nominated WTPs. A lower C-error means more precise WTP estimates, which is the quantity of primary interest in many stated choice studies.

Step 1: Load modules, define design parameters and set attributes

The following lines load:

  • EffDesign: the class of efficient designs,

  • Attribute and Parameter: the classes of attributes and parameters, respectively.

[1]:
from choicedesign.design import EffDesign
from choicedesign.expressions import Attribute, Parameter

The following lines define 2 alternatives and 4 attributes (\(A\) to \(D\)). Attribute :math:`B` is treated as the cost attribute — its parameter \(\beta_B\) is the denominator in all WTP ratios.

[2]:
alt1_A = Attribute('alt1_A',[1,2,3])
alt1_B = Attribute('alt1_B',[10,15,15.5])
alt1_C = Attribute('alt1_C',[0,3,5])
alt1_D = Attribute('alt1_D',[0,1,2])

alt2_A = Attribute('alt2_A',[1,2,3])
alt2_B = Attribute('alt2_B',[10,15,15.5])
alt2_C = Attribute('alt2_C',[0,3,5])
alt2_D = Attribute('alt2_D',[0,1,2])

Step 2: Construct efficient design object and generate initial design matrix

[3]:
design = EffDesign(
    X = [alt1_A,alt1_B,alt1_C,alt1_D,
         alt2_A,alt2_B,alt2_C,alt2_D],
    ncs=18)
[4]:
init_design = design.gen_initdesign()
init_design
[4]:
alt1_A alt1_B alt1_C alt1_D alt2_A alt2_B alt2_C alt2_D
0 1.0 15.0 3.0 2.0 2.0 10.0 3.0 1.0
1 1.0 15.5 0.0 1.0 3.0 15.5 3.0 1.0
2 1.0 15.5 5.0 1.0 2.0 15.0 0.0 1.0
3 3.0 10.0 5.0 0.0 1.0 15.5 0.0 0.0
4 2.0 15.0 3.0 1.0 1.0 10.0 3.0 0.0
5 1.0 10.0 5.0 0.0 2.0 15.0 3.0 2.0
6 1.0 10.0 3.0 0.0 2.0 15.0 5.0 1.0
7 2.0 15.0 5.0 2.0 1.0 15.0 3.0 2.0
8 2.0 15.5 3.0 1.0 3.0 15.5 0.0 0.0
9 3.0 15.0 5.0 1.0 1.0 15.5 5.0 0.0
10 3.0 15.5 0.0 2.0 2.0 10.0 5.0 2.0
11 1.0 10.0 3.0 2.0 3.0 15.5 3.0 2.0
12 2.0 15.0 0.0 0.0 2.0 10.0 5.0 2.0
13 3.0 10.0 0.0 2.0 3.0 15.0 0.0 1.0
14 2.0 15.5 0.0 0.0 3.0 15.5 5.0 1.0
15 2.0 10.0 5.0 0.0 1.0 15.0 0.0 0.0
16 3.0 15.5 0.0 2.0 1.0 10.0 5.0 0.0
17 3.0 15.0 3.0 1.0 3.0 10.0 0.0 2.0

Step 3: Set the utility functions and identify the cost parameter

Parameters are defined with the Parameter class. The C-criterion requires knowing which parameter is the cost (denominator). Here beta_B plays that role — it has a negative prior consistent with a monetary cost.

[5]:
beta_A = Parameter('beta_A',-0.1)
beta_B = Parameter('beta_B',-0.02)   # cost parameter
beta_C = Parameter('beta_C',0.1)
beta_D = Parameter('beta_D',0.15)
[6]:
V1 = beta_A * alt1_A + beta_B * alt1_B + beta_C * alt1_C + beta_D * alt1_D
V2 = beta_A * alt2_A + beta_B * alt2_B + beta_C * alt2_C + beta_D * alt2_D

V = {1: V1, 2: V2}

Step 4: Optimise the design using the C-error criterion

Set criterion='c' and supply:

  • cost_param: the Parameter object that acts as the WTP denominator (beta_B here)

  • wtp_params: list of Parameter objects whose WTP variances are minimised (all non-cost parameters)

The C-error is the sum of \(c_x^\top I^{-1} c_x\) over all nominated WTPs, evaluated at the prior values.

[7]:
optimal_design, init_perf, final_perf, final_iter, ubalance_ratio = design.optimise(
    init_design=init_design,
    V=V,
    model='mnl',
    criterion='c',
    cost_param=beta_B,
    wtp_params=[beta_A, beta_C, beta_D],
    time_lim=1,
    verbose=True
)
Evaluating initial design
Optimization complete 0:00:59 / C-error: 3190.561282
Elapsed time: 0:01:00
C-error of initial design:  5800.839106
C-error of last stored design:  3190.561282
Utility Balance ratio:  99.24 %
Algorithm iterations:  181904

Blocking the design

The optimal design can be blocked using the method gen_blocks(). The following line creates 3 blocks:

[8]:
optimal_design_blocked, corr_history = design.gen_blocks(optimal_design, n_blocks=3)
optimal_design_blocked
[8]:
CS alt1_A alt1_B alt1_C alt1_D alt2_A alt2_B alt2_C alt2_D Block
0 1.0 1.0 15.5 0.0 2.0 3.0 10.0 3.0 0.0 3
1 2.0 1.0 10.0 0.0 1.0 3.0 15.5 0.0 2.0 2
2 3.0 1.0 15.0 0.0 1.0 3.0 15.0 5.0 1.0 1
3 4.0 3.0 10.0 3.0 0.0 1.0 15.5 0.0 2.0 2
4 5.0 3.0 15.5 5.0 2.0 1.0 10.0 5.0 0.0 2
5 6.0 2.0 15.0 5.0 0.0 2.0 15.0 3.0 2.0 3
6 7.0 3.0 10.0 5.0 1.0 1.0 15.5 3.0 1.0 1
7 8.0 1.0 15.0 0.0 0.0 2.0 15.0 3.0 1.0 2
8 9.0 2.0 15.0 3.0 1.0 2.0 15.0 0.0 0.0 2
9 10.0 3.0 15.0 3.0 2.0 1.0 15.0 5.0 0.0 3
10 11.0 3.0 15.5 5.0 2.0 1.0 10.0 3.0 1.0 1
11 12.0 2.0 10.0 5.0 0.0 2.0 15.5 3.0 2.0 3
12 13.0 2.0 15.5 5.0 0.0 2.0 10.0 0.0 2.0 1
13 14.0 1.0 10.0 3.0 1.0 3.0 15.5 5.0 1.0 1
14 15.0 2.0 15.5 0.0 1.0 3.0 10.0 0.0 1.0 3
15 16.0 3.0 10.0 3.0 2.0 1.0 15.5 5.0 0.0 3
16 17.0 2.0 15.0 0.0 2.0 2.0 15.0 5.0 0.0 1
17 18.0 1.0 15.5 3.0 0.0 3.0 10.0 0.0 2.0 2

(optional) Evaluate the design

Pass criterion='c' with the same cost_param and wtp_params to evaluate() to compute the C-error of a stored design:

[9]:
perf, ubalance = design.evaluate(
    optimal_design,
    V,
    model='mnl',
    criterion='c',
    cost_param=beta_B,
    wtp_params=[beta_A, beta_C, beta_D]
)

print('C-error:', perf)
print('Utility balance:', ubalance)
C-error: 3190.561282446267
Utility balance: 99.23852054210096

Export the design

Export the C-efficient design to Excel.

[ ]:
attr_names = {
    'alt1_A': 'Attribute A', 'alt2_A': 'Attribute A',
    'alt1_B': 'Attribute B', 'alt2_B': 'Attribute B',
    'alt1_C': 'Attribute C', 'alt2_C': 'Attribute C',
    'alt1_D': 'Attribute D', 'alt2_D': 'Attribute D',
}
design.export_design(optimal_design, attr_names, 'rum_c_efficient_design.xlsx')

Save the optimisation summary

After calling optimise(), the method export_output() writes a plain-text summary of the optimisation run — design configuration, stopping criteria, criterion values, utility balance, elapsed time, and iteration count — to a file.

[ ]:
design.export_output('rum_c_efficient_output.txt')

References

[1] Quan, W., Rose, J. M., Collins, A. T., & Bliemer, M. C. (2011). A comparison of algorithms for generating efficient choice experiments.