Executive Summary
What each attribute is worth, and what that does and does not license.
Price is the dominant driver of stated preference, accounting for 32.72% of the total part-worth range, with the gap between $10 and $40 at 1.698 utility points. Speed ranks second at 31.34%, Brand third at 23.34%, and Support weakest at 12.59%. These importances are tied to the tested ranges—narrowing the price band would shrink its apparent importance. The highest-valued single level is Speed: Fast at roughly $14.33 (95% interval $12.56–$16.10) in stated-preference terms—not a price any customer has agreed to pay. In a simulated choice among tested configurations, the best level on every attribute captures 77.46% predicted share versus 0.43% for the worst level. The respondent-level heterogeneity check cannot run on choice data; any part-worth near zero is ambiguous and may mask opposed segments.
Analysis Overview
Part-worth utilities for 4 attributes estimated from 6,000 alternatives.
Conjoint analysis recovers feature values by comparing whole product profiles rather than rating features one at a time. This study analysed 6,000 alternatives grouped into 2,000 choice tasks, where respondents selected one option per task. The data detected choice-based conjoint (binary 'Chosen' column and 'Choice Task' grouping), estimating 11 part-worth utilities across 4 attributes: Brand (3 levels), Speed (2 levels), Price (4 levels: $10, $20, $30, $40), and Support (2 levels). Part-worths are normalised to sum to zero within each attribute—each value is relative to that attribute's average, not absolute. Comparisons across attributes require the importance ranges, not side-by-side part-worth readings. The model fit (McFadden Pseudo R² = 0.271) is typical for choice data. All figures describe stated preferences—hypothetical choices in a survey, not actual purchases with real money or friction.
Data Quality
Attribute screening, incomplete rows, and the tested level sets.
All 6,000 rows were complete and usable; no rows were removed for missing 'Chosen' values or attribute levels. Every mapped attribute column passed screening. The tested levels carried into the model are: Price—$10, $20, $30, $40; Speed—Basic, Fast; Brand—Nimbus, Orbit, Pylon; Support—Included, None. Every result is conditional on exactly these levels. No attribute was dropped as constant or identifier-like. The choice task structure is intact: 2,000 choice sets, each with alternatives shown together and exactly one chosen per set.
Part-Worth Utilities
What every tested level is worth against the average level of its own attribute.
Ten of 11 tested levels have 95% confidence intervals that clear zero, separating them from their attribute's average level. Price: $40 has the widest interval (−0.938 to −0.691) and is pinned down least precisely. Within Brand, Nimbus leads at +0.5936, Orbit sits near zero at +0.0238 (interval −0.0658 to +0.1135), and Pylon trails at −0.6175. Speed shows a clean split: Fast at +0.8131 and Basic at −0.8131. Price descends from $10 (+0.8834) through $20 (+0.2559) and $30 (−0.3247) to $40 (−0.8146). Support: Included is +0.3267 and None is −0.3267. The tight intervals on Speed and Support reflect their smaller tested ranges (two levels each). Orbit's interval overlapping zero signals ambiguity—it may indicate indifference or a split audience.
Attribute Importance
Each attribute's part-worth range as a share of the total — conditional on the levels tested.
The short answer
Price leads at 32.72% of the total part-worth range, followed closely by Speed at 31.34%. Brand contributes 23.34%, and Support is the least influential at 12.59%. Price carries 2.60 times the weight of Support in this design. However, these percentages are entirely conditional on the tested ranges: Price was tested across $10–$40, Speed across Basic and Fast, Brand across three options, and Support as a binary choice.
The detail
Price: part-worth range 1.6979 (best $10, worst $40), importance 32.72%. Speed: range 1.6262 (best Fast, worst Basic), importance 31.34%. Brand: range 1.2111 (best Nimbus, worst Pylon), importance 23.34%. Support: range 0.6533 (best Included, worst None), importance 12.59%. Total range = 5.189 utility points. The ratio of Price to Support ranges is 1.6979 / 0.6533 = 2.60.
What this can't tell you
Importance is a property of the tested ranges, not the market. A price range of $10–$40 makes price look important; a range of $10–$12 would make it look trivial regardless of customer price sensitivity. Comparing these percentages to a study that tested different level ranges is invalid. The narrow Support range (binary choice only) mechanically limits its importance relative to Price's four-level range.
Willingness To Pay
What each level is worth in money — derived from the price slope, and only when that slope supports it.
| Level Label | Wtp | Wtp Low | Wtp High | Utility | Interpretation |
|---|---|---|---|---|---|
| Speed: Fast | 14.33 | 12.56 | 16.1 | 0.8131 | Stated-preference estimate: relative to the average level of its own attribute, this level is worth about $n/a in the units of 'Price'. It is not a price a customer has agreed to pay. |
| Brand: Nimbus | 10.46 | 8.64 | 12.28 | 0.5936 | Stated-preference estimate: relative to the average level of its own attribute, this level is worth about $n/a in the units of 'Price'. It is not a price a customer has agreed to pay. |
| Support: Included | 5.76 | 4.51 | 7 | 0.3267 | Stated-preference estimate: relative to the average level of its own attribute, this level is worth about $n/a in the units of 'Price'. It is not a price a customer has agreed to pay. |
| Brand: Orbit | 0.42 | -1.16 | 2 | 0.0238 | Stated-preference estimate: relative to the average level of its own attribute, this level is worth about $n/a in the units of 'Price'. It is not a price a customer has agreed to pay. |
| Support: None | -5.76 | -7 | -4.51 | -0.3267 | Stated-preference estimate: relative to the average level of its own attribute, this level costs about $n/a of value in the units of 'Price'. It is not a price a customer has agreed to pay. |
| Brand: Pylon | -10.88 | -12.81 | -8.95 | -0.6175 | Stated-preference estimate: relative to the average level of its own attribute, this level costs about $n/a of value in the units of 'Price'. It is not a price a customer has agreed to pay. |
| Speed: Basic | -14.33 | -16.1 | -12.56 | -0.8131 | Stated-preference estimate: relative to the average level of its own attribute, this level costs about $n/a of value in the units of 'Price'. It is not a price a customer has agreed to pay. |
The price slope is −0.057 utility per dollar (95% interval −0.063 to −0.051), making one utility point worth about $17.62. Speed: Fast commands the highest stated-preference value at $14.33 (interval $12.56–$16.10), while Speed: Basic costs $−14.33. Brand: Nimbus is worth $10.46 (interval $8.64–$12.28), and Brand: Pylon costs $−10.88 (interval $−12.81 to $−8.95). Support: Included is valued at $5.76 (interval $4.51–$7.00). These are rankings of relative worth, not prices customers have agreed to pay. Stated-preference estimates routinely overstate actual willingness to pay and understate purchase friction. The price slope is fitted through only 4 tested points (linearity R² = 0.997), so extrapolating beyond $10–$40 is unreliable. Use these figures to rank features, not to set absolute prices.
Market Simulation
Predicted preference for several product configurations built from the tested levels.
In a simulated choice among four configurations built from tested levels, the best combination (Nimbus, Fast, $10, Included) scores 2.62 utility and captures 77.46% predicted share. The worst (Pylon, Basic, $40, None) scores −2.57 utility and takes 0.43%. A premium variant with best features at the highest tested price ($40) scores 0.9188 utility and takes 14.18%. The most common configuration observed in the data (Nimbus, Basic, $10, None) scores 0.3373 utility and takes 7.93%. These shares are model predictions within the tested attribute space—a closed contest between these four profiles. They do not forecast real market share: there is no external competitor, no promotion, no awareness, no switching cost, and no customer who does not buy. Use the ordering and gaps to compare candidate products, not to predict launch share.
Preference Heterogeneity
Do respondents agree with the aggregate favourite, or is the average blending opposed groups?
| Attribute | Agreement PCT | Aggregate Top Level | Most Common Dissent | Respondents Assessed | Interpretation |
|---|---|---|---|---|---|
| Heterogeneity check not run | — | n/a | n/a | — | The respondent-level check is not run on choice data. Each respondent contributes only a handful of binary choices, so their individual part-worths are not estimable without a hierarchical model, which this module deliberately does not fit rather than fit badly. Read every aggregate part-worth near zero as ambiguous: it may mean indifference, or it may mean one group of respondents wants the level and another rejects it. |
The respondent-level heterogeneity check cannot run on choice data. Each respondent contributes only a handful of binary choices, and individual part-worths are not estimable without a hierarchical model, which this analysis deliberately avoids rather than fit poorly. Any aggregate part-worth near zero is ambiguous: it may indicate true indifference or mask opposed segments. For example, Brand: Orbit sits at +0.0238 with a 95% interval of −0.0658 to +0.1135—it could mean the market is indifferent or that one segment strongly prefers it and another rejects it. Read every part-worth near zero as a signal to investigate further, not as a settled verdict of unimportance.
Methods & Disclosure
How the part-worths, importances, willingness-to-pay, and simulation were computed, and what they cannot decide.
| Item | Detail |
|---|---|
| Data shape detected | 'Chosen' takes only the values 0 and 1 and 'Choice Task' groups the rows into choice sets, so the data was read as choice-based conjoint: 2,000 choice tasks, each with the alternatives that were shown together and exactly one chosen. |
| Estimation | Conditional (multinomial) logit fitted by maximising the conditional-logit log-likelihood directly with a quasi-Newton optimiser and an analytic gradient; standard errors come from the observed information matrix. Final log-likelihood -1600.98 across 2,000 choice tasks; McFadden Pseudo R-squared = 0.271. |
| Normalisation | Part-worths are identified only up to a constant within each attribute, so they are reported here re-centred to sum to zero inside every attribute: each number is that level against the average level of its OWN attribute. Comparing a level of one attribute against a level of another is meaningful only through the attribute-importance ranges, never by reading the two part-worths side by side. |
| Attribute importance | For each attribute, the range between its best and worst part-worth, divided by the sum of those ranges. Here the ranges are Price 1.698; Speed 1.626; Brand 1.211; Support 0.653, summing to 5.189. |
| Tested ranges | Importance is conditional on the levels that were tested: Price across $10 | $20 | $30 | $40; Speed across Basic | Fast; Brand across Nimbus | Orbit | Pylon; Support across Included | None. Widen or narrow any of these ranges and the importance ordering can change — a price range of two dollars would make price look unimportant no matter how price-sensitive the market is. |
| Willingness-to-pay | The price part-worths were regressed on the numeric price levels, giving a slope of -0.057 utility per unit of 'Price' (95% interval -0.063 to -0.051, linearity R-squared 0.997 across the 4 tested price points). Willingness-to-pay for a level is its part-worth divided by that slope; the interval uses the delta method with the full covariance between the level's part-worth and the slope. |
| Market simulation | Each configuration's utility is the sum of its levels' part-worths; shares are the logit shares among the 4 configurations listed and nothing else. They are model predictions inside the tested attribute space, not forecasts of real market share — no competitor outside this list, no distribution, no awareness, no availability. |
| Heterogeneity | The respondent-level check is not run on choice data. Each respondent contributes only a handful of binary choices, so their individual part-worths are not estimable without a hierarchical model, which this module deliberately does not fit rather than fit badly. Read every aggregate part-worth near zero as ambiguous: it may mean indifference, or it may mean one group of respondents wants the level and another rejects it. |
| What this is not | Every number here is a stated preference: it describes choices people made between hypothetical profiles in this survey, not purchases they made with their own money. Stated preference routinely overstates what people will actually pay and understates the friction of a real purchase. |
Data shape detected: 'Chosen' is binary (0, 1) and 'Choice Task' groups rows into sets, so the data was read as choice-based conjoint with 2,000 choice tasks, each showing alternatives tested together with exactly one chosen. Estimation: conditional multinomial logit fitted by maximising log-likelihood directly (quasi-Newton optimiser, analytic gradient); final log-likelihood −1600.98, McFadden Pseudo R² = 0.271. Standard errors come from the observed information matrix. Normalisation: part-worths are re-centred to sum to zero within each attribute, so each value is relative to its attribute's average, not absolute. Cross-attribute comparison requires importance ranges, never direct part-worth comparison. Importance: the range between an attribute's best and worst part-worth, divided by the sum of all ranges (here 5.189 total). Tested ranges: Price $10–$40, Speed Basic–Fast, Brand Nimbus–Orbit–Pylon, Support Included–None. Willingness-to-pay: derived from the price slope (−0.057 utility per dollar, 95% interval −0.063 to −0.051), using the delta method for intervals. Simulation: logit choice share applied to configurations built from tested levels only, within this closed set. Stated-preference caveat: all figures describe hypothetical choices in a survey, not purchases with real money or friction.
Methodology
Statistical methodology and diagnostics for Conjoint Analysis
Statistical Method
Standard-library analysis: what is each product feature worth relative to price? Map the preference column and the attribute columns from a conjoint survey — ratings/rankings of whole profiles, or choice-based conjoint with a chosen flag and a choice-task column — and get part-worth utilities for every tested level with confidence intervals, attribute importance reported against the level ranges that were actually tested, willingness-to-pay derived from the price slope when the price coefficient supports it, a market simulation over product configurations built from the tested levels, and a respondent-level check for whether the aggregate is masking opposed segments.
- Each row is one profile evaluation (ratings shape) or one alternative inside a choice task (choice shape)
- The attribute columns hold a small set of designed levels, not free text or identifiers
- Part-worths are additive across attributes — no interaction between attributes is estimated
- Choice data has a task column grouping the alternatives shown together, with exactly one alternative chosen per task
- The profiles shown varied the attributes independently enough for their part-worths to be separated
- Willingness-to-pay is a stated-preference estimate, not a price a customer will actually pay — it routinely overstates real purchase behaviour
- Attribute importance depends entirely on the level ranges tested: a narrow price range makes price look unimportant, and importances are not comparable across studies that tested different ranges
- Part-worths are identified only up to a constant within each attribute, so two levels of different attributes cannot be compared by reading their part-worths side by side
- Aggregate part-worths average over respondents; the heterogeneity check flags gross masking but a minority segment can still hide inside an attribute most respondents agree on
Analysis Code
Complete R source code for this analysis
Conjoint Analysis — Feature and Price Trade-offs
Estimates part-worth utilities for every level of every product attribute from stated preferences, so a product manager can see what each feature is worth relative to price. Two data shapes are supported and the module detects which one arrived:
- Ratings / rankings conjoint — one row per respondent x profile with a
rating or rank score. Part-worths come from an ordinary least-squares fit on dummy-coded attribute levels.
- Choice-based conjoint (CBC) — one row per alternative inside a choice
task, with a 0/1 chosen flag. Part-worths come from a conditional (multinomial) logit fitted directly by maximising the conditional-logit log-likelihood.
Why This Method?
Asking people to rate features one at a time produces a wish list: everything is important. Conjoint forces trade-offs across whole profiles, so the relative weight of each attribute — and the exchange rate between a feature and money — falls out of choices people actually made between bundles.
What This Analysis Covers
- Part-worth utility for every level, with a confidence interval
- Attribute importance (part-worth range as a share of total range), always
reported next to the range of levels that was actually tested
- Willingness-to-pay derived from the price slope, when a price attribute is
detectable AND the price coefficient is genuinely negative
- A market simulation: predicted utility (and, for choice data, predicted
share) for several product configurations built from the tested levels
- A respondent-level heterogeneity check, because an aggregate part-worth
near zero can mean indifference or a split audience
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {preference, attribute_1..attribute_N, task, respondent}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# shape $ "choice" or "ratings" — which data shape was detected
# shape_reason $ computed sentence explaining the detection
# pref_h/task_h/resp_h $ humanized user names for the mapped singles
# attr_h $ named character — semantic attribute -> user name
# used_attrs $ character — semantic attribute columns actually used
# dropped_df $ data.frame(column, reason) — excluded attributes
# levels_list $ named list — the tested levels per used attribute
# pw_df $ part-worth table (attribute, level, utility, CI, ...)
# imp_df $ attribute importance table incl. the tested range
# wtp_df $ willingness-to-pay table (or a single reason row)
# wtp_ok $ TRUE when WTP was computed
# sim_df $ market-simulation table
# het_df $ heterogeneity table (or a single reason row)
# het_ok / het_flag $ whether it ran / whether a split audience is implied
# methods_df $ item/detail disclosure table
# fit_stat/fit_label $ the fit statistic and its name
# n_resp/n_tasks $ respondents and choice tasks (choice shape)
# price_attr/price_slope/price_slope_ci $ price detection results
# stated_note $ the stated-preference caveat, used in several cards
# metrics / json_output
# === /SHARED EXPORTS ===
initial_rows <- nrow(df)Step 1: Resolve the mapped columns and humanize every name
pref_h <- humanize_semantic("preference", col_map)
task_h <- humanize_semantic("task", col_map)
resp_h <- humanize_semantic("respondent", col_map)
attr_cols <- grep("^attribute_[0-9]+$", names(df), value = TRUE)
attr_cols <- attr_cols[order(as.integer(sub("^attribute_", "", attr_cols)))]
attr_h <- setNames(humanize_semantic(attr_cols, col_map), attr_cols)
if (!("preference" %in% names(df))) {
stop(sprintf("Conjoint analysis needs the preference column '%s' mapped — the rating, rank score, or 0/1 chosen flag that each row carries.", pref_h))
}
if (length(attr_cols) < 1) {
stop("Conjoint analysis needs at least one product attribute column mapped(attribute_1, attribute_2, ...) — the columns holding each profile's feature levels.")
}
has_task <- "task" %in% names(df)
has_resp <- "respondent" %in% names(df)Step 2: Coerce the preference column to numeric (95% rule)
pv_raw <- df$preference
if (is.logical(pv_raw)) {
pref <- as.numeric(pv_raw)
} else if (is.numeric(pv_raw)) {
pref <- as.numeric(pv_raw)
} else {
ch <- as.character(pv_raw)
non_blank <- !is.na(ch) & trimws(ch) != ""
conv <- suppressWarnings(as.numeric(ch))
if (sum(non_blank) == 0 ||
sum(!is.na(conv[non_blank])) < 0.95 * sum(non_blank)) {
stop(sprintf("The preference column '%s' does not look numeric — fewer than 95%% of its values parse as numbers. Map a rating, a rank score, or a 0/1 chosen flag.", pref_h))
}
pref <- conv
}Step 3: Screen the attribute columns — constants, identifiers, lumping
drop_col <- character(0); drop_reason <- character(0)
attrs <- list(); levels_list <- list(); lumped <- character(0)
MAX_LEVELS_KEPT <- 12
for (ac in attr_cols) {
lv_raw <- trimws(as.character(df[[ac]]))
lv_raw[is.na(lv_raw) | lv_raw == ""] <- "Missing"
nd <- length(unique(lv_raw))
if (nd < 2) {
drop_col <- c(drop_col, attr_h[[ac]])
drop_reason <- c(drop_reason, sprintf("constant — every row has the same value('%s'), so it carries no trade-off information", unique(lv_raw)[1]))
next
}
if (nd >= 0.5 * initial_rows || nd > 20) {
drop_col <- c(drop_col, attr_h[[ac]])
drop_reason <- c(drop_reason, sprintf("%s distinct values across %s rows — that is an identifier or free text, not a designed attribute with a small set of tested levels", ct(nd), ct(initial_rows)))
next
}
if (nd > MAX_LEVELS_KEPT) {
tab <- sort(table(lv_raw), decreasing = TRUE)
keep <- names(tab)[seq_len(MAX_LEVELS_KEPT - 1)]
lv_raw[!(lv_raw %in% keep)] <- "Other"
lumped <- c(lumped, attr_h[[ac]])
}
attrs[[ac]] <- lv_raw
levels_list[[ac]] <- sort(unique(lv_raw))
}
used_attrs <- names(attrs)
if (length(used_attrs) < 1) {
stop(sprintf("None of the mapped attribute columns(%s) can be used: each is either constant or looks like an identifier rather than a designed attribute with a small set of tested levels.",
paste(unname(attr_h), collapse = ", ")))
}
dropped_df <- if (length(drop_col) > 0) {
data.frame(column = drop_col, reason = drop_reason, stringsAsFactors = FALSE)
} else {
data.frame(column = character(0), reason = character(0), stringsAsFactors = FALSE)
}Step 4: Keep rows that are complete on the preference and every used attribute
A <- as.data.frame(attrs, stringsAsFactors = FALSE)
names(A) <- used_attrs
keep <- !is.na(pref)
for (ac in used_attrs) keep <- keep & !is.na(A[[ac]])
pref <- pref[keep]
A <- A[keep, , drop = FALSE]
task_vec <- if (has_task) as.character(df$task)[keep] else NULL
resp_vec <- if (has_resp) as.character(df$respondent)[keep] else NULL
n_incomplete <- sum(!keep)Levels can disappear once incomplete rows are dropped — re-derive them.
for (ac in used_attrs) levels_list[[ac]] <- sort(unique(A[[ac]]))
still_varying <- vapply(used_attrs, function(ac) length(levels_list[[ac]]) >= 2, TRUE)
if (any(!still_varying)) {
lost <- used_attrs[!still_varying]
dropped_df <- rbind(dropped_df, data.frame(
column = unname(attr_h[lost]),
reason = "became constant once rows with a missing preference value were dropped",
stringsAsFactors = FALSE))
used_attrs <- used_attrs[still_varying]
A <- A[, used_attrs, drop = FALSE]
levels_list <- levels_list[used_attrs]
}
if (length(used_attrs) < 1) {
stop(sprintf("No attribute column still varies once rows with a missing '%s' value are dropped, so no part-worth utilities can be estimated.", pref_h))
}Step 5: Detect the data shape — choice-based or ratings-based
n_kept <- length(pref)
uniq_pref <- sort(unique(pref))
binary_pref <- length(uniq_pref) == 2 && all(uniq_pref %in% c(0, 1))
shape <- "ratings"; shape_reason <- ""; n_tasks <- NA_integer_
bad_tasks <- 0L
if (has_task && binary_pref) {
tsize <- table(task_vec)
tchosen <- tapply(pref, task_vec, sum)
good <- names(tsize)[tsize >= 2 & !is.na(tchosen[names(tsize)]) &
tchosen[names(tsize)] == 1]
bad_tasks <- as.integer(length(tsize) - length(good))
if (length(good) >= 20) {
sel <- task_vec %in% good
pref <- pref[sel]; A <- A[sel, , drop = FALSE]
task_vec <- task_vec[sel]
if (!is.null(resp_vec)) resp_vec <- resp_vec[sel]
for (ac in used_attrs) levels_list[[ac]] <- sort(unique(A[[ac]]))
shape <- "choice"
n_tasks <- length(good)
shape_reason <- sprintf(
"'%s' takes only the values 0 and 1 and '%s' groups the rows into choice sets, so the data was read as choice-based conjoint: %s choice tasks, each with the alternatives that were shown together and exactly one chosen.",
pref_h, task_h, ct(n_tasks))
} else {
shape_reason <- sprintf(
"'%s' is a 0/1 flag and '%s' was mapped, but only %s choice tasks have at least two alternatives and exactly one chosen — too few for a conditional logit, so the data was read as ratings-based instead.",
pref_h, task_h, ct(length(good)))
}
}
if (shape == "ratings" && shape_reason == "") {
shape_reason <- if (binary_pref && !has_task) {
sprintf("'%s' takes only the values 0 and 1 but no choice-task column was mapped, so the alternatives that competed with each other are unknown. The data was fitted as a linear probability model on the 0/1 flag; map the choice-task column to get a proper choice-based conjoint.", pref_h)
} else {
sprintf("'%s' carries %s distinct values, so the data was read as a ratings or rankings conjoint: one row per profile evaluated, fitted by ordinary least squares on dummy-coded attribute levels.",
pref_h, ct(length(uniq_pref)))
}
}Step 6: Build the dummy-coded design matrix (first level is the reference)
cols <- list(); meta_attr <- character(0); meta_level <- character(0)
for (ac in used_attrs) {
lv <- levels_list[[ac]]
for (k in seq_along(lv)[-1]) {
cols[[length(cols) + 1L]] <- as.numeric(A[[ac]] == lv[k])
meta_attr <- c(meta_attr, ac); meta_level <- c(meta_level, lv[k])
}
}
X <- do.call(cbind, cols)
colnames(X) <- paste0("p", seq_len(ncol(X)))
n_params <- ncol(X)
final_rows <- nrow(A)
rows_removed <- initial_rows - final_rows
min_rows <- max(20L, as.integer(3 * n_params))
if (final_rows < min_rows) {
stop(sprintf("Only %s usable rows of '%s' across the mapped attributes (%s) remained — a conjoint fit for %s attribute levels needs at least %s rows.",
ct(final_rows), pref_h,
paste(unname(attr_h[used_attrs]), collapse = ", "),
ct(n_params + length(used_attrs)), ct(min_rows)))
}Step 7: Fit — OLS for ratings, conditional logit by optim for choice
fit_label <- ""; fit_stat <- NA_real_; resid_sd <- NA_real_
base_utility <- NA_real_; converged <- TRUE; ll_final <- NA_real_
if (shape == "ratings") {
fdat <- as.data.frame(X); fdat$y__ <- pref
fit <- stats::lm(y__ ~ ., data = fdat)
b <- stats::coef(fit)
if (any(is.na(b))) {
alias <- names(b)[is.na(b)]
bad <- unique(unname(attr_h[meta_attr[match(alias, colnames(X))]]))
stop(sprintf("Some attribute levels cannot be separated from the others in this design(%s) — the profiles shown never varied them independently, so their part-worths are not identified.",
paste(bad[!is.na(bad)], collapse = ", ")))
}
Vfull <- stats::vcov(fit)
b_slope <- b[-1]; V <- Vfull[-1, -1, drop = FALSE]
fit_stat <- summary(fit)$r.squared
fit_label <- "R-squared"
resid_sd <- stats::sd(stats::residuals(fit))
intercept <- unname(b[1])
} else {
ord <- order(task_vec)
X <- X[ord, , drop = FALSE]
pref <- pref[ord]; y <- pref
tv <- task_vec[ord]
if (!is.null(resp_vec)) resp_vec <- resp_vec[ord]
A <- A[ord, , drop = FALSE]
tid <- as.integer(factor(tv, levels = unique(tv)))
negll <- function(bb) {
eta <- as.vector(X %*% bb)
if (any(!is.finite(eta))) return(1e10)
eta <- eta - mean(eta)
if (max(abs(eta)) > 400) return(1e10)
e <- exp(eta)
d <- as.vector(rowsum(e, tid, reorder = FALSE))[tid]
-sum(y * (eta - log(d)))
}
gradll <- function(bb) {
eta <- as.vector(X %*% bb)
if (any(!is.finite(eta))) return(rep(0, length(bb)))
eta <- eta - mean(eta)
if (max(abs(eta)) > 400) return(rep(0, length(bb)))
e <- exp(eta)
d <- as.vector(rowsum(e, tid, reorder = FALSE))[tid]
-as.vector(crossprod(X, y - e / d))
}
opt <- stats::optim(rep(0, n_params), negll, gradll, method = "BFGS",
control = list(maxit = 500, reltol = 1e-12))
converged <- isTRUE(opt$convergence == 0)
b_slope <- opt$par; names(b_slope) <- colnames(X)
ll_final <- -opt$value
eta <- as.vector(X %*% b_slope); eta <- eta - mean(eta)
e <- exp(eta)
d <- as.vector(rowsum(e, tid, reorder = FALSE))[tid]
p_hat <- e / dObserved information for a conditional logit: sum over tasks of (sum_j p x x') minus (sum_j p x)(sum_j p x)'.
W <- X * p_hat
Info <- crossprod(X, W) - crossprod(rowsum(W, tid, reorder = FALSE))
V <- tryCatch(solve(Info), error = function(e) NULL)
if (is.null(V)) {
stop(sprintf("The choice design does not identify all attribute levels(%s) — some levels never varied independently within a choice task, so their part-worths cannot be separated.",
paste(unname(attr_h[used_attrs]), collapse = ", ")))
}Null log-likelihood: every alternative in a task equally likely.
tsz <- as.vector(rowsum(rep(1, length(y)), tid, reorder = FALSE))
ll_null <- -sum(log(tsz))
fit_stat <- 1 - ll_final / ll_null
fit_label <- "McFadden Pseudo R-squared"
intercept <- NA_real_
final_rows <- nrow(A)
rows_removed <- initial_rows - final_rows
}Step 8: Re-express as sum-to-zero part-worths within each attribute
u = M b is an exact linear transform, so Var(u) = M V M'.
lvl_attr <- character(0); lvl_name <- character(0)
Mrows <- list()
for (ac in used_attrs) {
lv <- levels_list[[ac]]
K <- length(lv)
idx <- which(meta_attr == ac) # parameter positions, levels 2..K
for (k in seq_len(K)) {
row <- rep(0, n_params)
row[idx] <- -1 / K
if (k > 1) row[idx[k - 1]] <- row[idx[k - 1]] + 1
Mrows[[length(Mrows) + 1L]] <- row
lvl_attr <- c(lvl_attr, ac); lvl_name <- c(lvl_name, lv[k])
}
}
M <- do.call(rbind, Mrows)
u <- as.vector(M %*% b_slope)
Vu <- M %*% V %*% t(M)
u_se <- sqrt(pmax(0, diag(Vu)))
zq <- 1.959964
pw_df <- data.frame(
attribute = unname(attr_h[lvl_attr]),
level = lvl_name,
level_label = paste0(unname(attr_h[lvl_attr]), ": ", lvl_name),
utility = round(u, 4),
ci_low = round(u - zq * u_se, 4),
ci_high = round(u + zq * u_se, 4),
std_error = round(u_se, 4),
stringsAsFactors = FALSE
)
if (shape == "ratings") {The centred part-worths shift the intercept by each attribute's mean.
shift <- 0
for (ac in used_attrs) {
idx <- which(meta_attr == ac); K <- length(levels_list[[ac]])
shift <- shift + sum(b_slope[idx]) / K
}
base_utility <- intercept + shift
}Step 9: Attribute importance — range within an attribute, as a share
imp_attr <- character(0); imp_range <- numeric(0)
imp_best <- character(0); imp_worst <- character(0); imp_tested <- character(0)
for (ac in used_attrs) {
sel <- lvl_attr == ac
uu <- u[sel]; nm <- lvl_name[sel]
hi <- safe_which_max(uu); lo <- safe_which_min(uu)
imp_attr <- c(imp_attr, unname(attr_h[[ac]]))
imp_range <- c(imp_range, if (is.na(hi) || is.na(lo)) NA_real_ else uu[hi] - uu[lo])
imp_best <- c(imp_best, if (is.na(hi)) "n/a" else nm[hi])
imp_worst <- c(imp_worst, if (is.na(lo)) "n/a" else nm[lo])
imp_tested <- c(imp_tested, paste(nm, collapse = " | "))
}
tot_range <- sum(imp_range, na.rm = TRUE)
imp_pct <- if (tot_range > 0) 100 * imp_range / tot_range else rep(NA_real_, length(imp_range))
ord_i <- order(-imp_pct, na.last = TRUE)
imp_df <- data.frame(
attribute = imp_attr[ord_i],
importance_pct = round(imp_pct[ord_i], 2),
utility_range = round(imp_range[ord_i], 4),
best_level = imp_best[ord_i],
worst_level = imp_worst[ord_i],
tested_levels = imp_tested[ord_i],
n_levels = as.integer(vapply(used_attrs[ord_i], function(ac) length(levels_list[[ac]]), 1L)),
stringsAsFactors = FALSE
)
top_imp <- imp_df[1, ]Step 10: Detect a price attribute and the price slope
price_attr <- NA_character_; price_sym <- ""
price_levels_num <- NULL; price_idx <- NULL
for (ac in used_attrs) {
lbl <- levels_list[[ac]]
hname <- unname(attr_h[[ac]])
name_hit <- grepl("pric|cost|fee|charg|tariff|msrp|rate|\\$|usd|eur|gbp", hname, ignore.case = TRUE)
sym_hit <- all(grepl("^[\\$€£¥]", lbl))
if (!name_hit && !sym_hit) next
nums <- suppressWarnings(as.numeric(gsub("[^0-9.-]", "", lbl)))
if (any(is.na(nums)) || length(unique(nums)) < 2) next
price_attr <- ac
price_idx <- which(lvl_attr == ac)
price_levels_num <- nums[match(lvl_name[price_idx], lbl)]
syms <- substr(lbl, 1, 1)
price_sym <- if (length(unique(syms)) == 1 && grepl("[\\$€£¥]", syms[1])) syms[1] else ""
break
}
price_slope <- NA_real_; price_slope_se <- NA_real_
price_slope_lo <- NA_real_; price_slope_hi <- NA_real_
price_lin_r2 <- NA_real_; wtp_ok <- FALSE; wtp_reason <- ""
cvec <- NULL
if (!is.na(price_attr)) {
pnum <- price_levels_num
dev <- pnum - mean(pnum); ssd <- sum(dev^2)
cvec <- rep(0, length(u)); cvec[price_idx] <- dev / ssd
price_slope <- sum(cvec * u)
price_slope_se <- sqrt(max(0, as.numeric(t(cvec) %*% Vu %*% cvec)))
price_slope_lo <- price_slope - zq * price_slope_se
price_slope_hi <- price_slope + zq * price_slope_se
up <- u[price_idx]
fitted_up <- mean(up) + price_slope * dev
sst <- sum((up - mean(up))^2)
price_lin_r2 <- if (sst > 0) 1 - sum((up - fitted_up)^2) / sst else NA_real_
if (is.na(price_slope) || price_slope >= 0) {
wtp_reason <- sprintf("The fitted price coefficient is %s per unit of '%s' — not negative, so respondents did not trade utility away as price rose in this data. Dividing by it would produce a nonsense willingness-to-pay, so none is reported.",
r3(price_slope), unname(attr_h[[price_attr]]))
} else if (price_slope_hi >= 0) {
wtp_reason <- sprintf("The price coefficient is %s per unit of '%s', but its 95%% interval (%s to %s) still includes zero, so the exchange rate between utility and money is not established. Willingness-to-pay is a division by that coefficient and would be unbounded here, so none is reported.",
r3(price_slope), unname(attr_h[[price_attr]]),
r3(price_slope_lo), r3(price_slope_hi))
} else {
wtp_ok <- TRUE
}
} else {
wtp_reason <- sprintf("No price attribute could be identified among the mapped attributes(%s): willingness-to-pay needs one attribute whose levels are money amounts. Map the price column as one of the attributes to get it.",
paste(unname(attr_h[used_attrs]), collapse = ", "))
}Step 11: Willingness-to-pay by the delta method (full covariance)
money_per_util <- NA_real_
if (wtp_ok) {
dslope <- -price_slope # positive utility cost per unit
money_per_util <- 1 / dslope
cov_u_s <- as.vector(Vu %*% cvec) # Cov(u_l, slope) for every level
keepw <- setdiff(seq_along(u), price_idx)
wtp <- u[keepw] / dslope
var_wtp <- (1 / dslope^2) * diag(Vu)[keepw] +
(u[keepw]^2 / dslope^4) * price_slope_se^2 +
(2 * u[keepw] / dslope^3) * cov_u_s[keepw]
wtp_se <- sqrt(pmax(0, var_wtp))
o <- order(-wtp)
wtp_df <- data.frame(
level_label = pw_df$level_label[keepw][o],
wtp = round(wtp[o], 2),
wtp_low = round((wtp - zq * wtp_se)[o], 2),
wtp_high = round((wtp + zq * wtp_se)[o], 2),
utility = round(u[keepw][o], 4),
stringsAsFactors = FALSE
)
wtp_df$interpretation <- sprintf(
"Stated-preference estimate: relative to the average level of its own attribute, this level is worth about %s%s in the units of '%s'. It is not a price a customer has agreed to pay.",
price_sym, r2(abs(wtp_df$wtp)), unname(attr_h[[price_attr]]))
wtp_df$interpretation[wtp_df$wtp < 0] <- sprintf(
"Stated-preference estimate: relative to the average level of its own attribute, this level costs about %s%s of value in the units of '%s'. It is not a price a customer has agreed to pay.",
price_sym, r2(abs(wtp_df$wtp[wtp_df$wtp < 0])), unname(attr_h[[price_attr]]))
} else {
wtp_df <- data.frame(
level_label = "Willingness-to-pay not reported",
wtp = NA_real_, wtp_low = NA_real_, wtp_high = NA_real_,
utility = NA_real_, interpretation = wtp_reason,
stringsAsFactors = FALSE
)
}Step 12: Market simulation over configurations built from tested levels
best_lv <- character(0); worst_lv <- character(0); modal_lv <- character(0)
for (ac in used_attrs) {
sel <- lvl_attr == ac
uu <- u[sel]; nm <- lvl_name[sel]
hi <- safe_which_max(uu); lo <- safe_which_min(uu)
best_lv <- c(best_lv, if (is.na(hi)) nm[1] else nm[hi])
worst_lv <- c(worst_lv, if (is.na(lo)) nm[1] else nm[lo])
tb <- table(A[[ac]])
modal_lv <- c(modal_lv, names(tb)[safe_which_max(as.numeric(tb))])
}
names(best_lv) <- names(worst_lv) <- names(modal_lv) <- used_attrs
util_of <- function(cfg) {
s <- 0
for (ac in used_attrs) {
j <- which(lvl_attr == ac & lvl_name == cfg[[ac]])
if (length(j) == 1) s <- s + u[j]
}
s
}
cfg_list <- list(); cfg_names <- character(0)
cfg_list[[1]] <- best_lv; cfg_names[1] <- "Best level on every attribute"
cfg_list[[2]] <- modal_lv; cfg_names[2] <- "Most common configuration in the data"
cfg_list[[3]] <- worst_lv; cfg_names[3] <- "Worst level on every attribute"
if (!is.na(price_attr) && length(price_levels_num) >= 2) {
pnm <- lvl_name[price_idx]
hi_price <- pnm[safe_which_max(price_levels_num)]
lo_price <- pnm[safe_which_min(price_levels_num)]
c4 <- best_lv; c4[[price_attr]] <- hi_price
c5 <- modal_lv; c5[[price_attr]] <- lo_price
cfg_list[[4]] <- c4
cfg_names[4] <- sprintf("Best features at the highest tested %s(%s)",
unname(attr_h[[price_attr]]), hi_price)
cfg_list[[5]] <- c5
cfg_names[5] <- sprintf("Most common features at the lowest tested %s(%s)",
unname(attr_h[[price_attr]]), lo_price)
}
spec_str <- vapply(cfg_list, function(cfg)
paste(sprintf("%s = %s", unname(attr_h[used_attrs]),
unlist(cfg[used_attrs])), collapse = "; "), "")
dup <- duplicated(spec_str)
cfg_list <- cfg_list[!dup]; cfg_names <- cfg_names[!dup]; spec_str <- spec_str[!dup]
cfg_util <- vapply(cfg_list, util_of, 0)
if (shape == "choice") {
ee <- exp(cfg_util - max(cfg_util))
cfg_share <- round(100 * ee / sum(ee), 2)
share_basis <- "Logit choice share among the configurations listed here, on the scale the choice model identified."
} else {
cfg_share <- rep(NA_real_, length(cfg_util))
share_basis <- sprintf("Not identified: turning aggregate '%s' utilities into choice shares needs an arbitrary scale factor that ratings data does not pin down, so the ranking and the utility gaps are reported instead of invented shares.", pref_h)
}
ord_s <- order(-cfg_util)
sim_df <- data.frame(
configuration = cfg_names[ord_s],
total_utility = round(cfg_util[ord_s], 4),
predicted_share_pct = cfg_share[ord_s],
levels_used = spec_str[ord_s],
share_basis = share_basis,
stringsAsFactors = FALSE
)Step 13: Respondent-level heterogeneity — is the aggregate a blend?
Only estimable on the ratings shape: there each respondent's own partial effect for an attribute can be read off directly once the OTHER attributes' aggregate contributions are subtracted. On choice data a respondent contributes a handful of binary choices, and an individual-level part-worth needs a hierarchical model, which this module does not fit.
het_ok <- FALSE; het_flag <- FALSE; het_reason <- ""
het_df <- NULL; het_min <- NA_real_; het_worst_attr <- NA_character_
n_resp <- if (!is.null(resp_vec)) length(unique(resp_vec)) else NA_integer_
if (shape == "choice") {
het_reason <- sprintf("The respondent-level check is not run on choice data. Each respondent contributes only a handful of binary choices, so their individual part-worths are not estimable without a hierarchical model, which this module deliberately does not fit rather than fit badly. Read every aggregate part-worth near zero as ambiguous: it may mean indifference, or it may mean one group of respondents wants the level and another rejects it.")
} else if (is.null(resp_vec)) {
het_reason <- "No respondent column was mapped, so the aggregate part-worths cannot be checked for a split audience. Map the respondent identifier to find out whether a level averaging to neutral is genuinely neutral or is loved by one group and disliked by another."
} else if (n_resp < 8) {
het_reason <- sprintf("Only %s respondents were identified in '%s' — too few to check whether the aggregate part-worths hide opposing groups.", ct(n_resp), resp_h)
} else {Per-row utility contribution of each attribute, so the check on one attribute is not confounded by which levels of the others appeared.
Uc <- matrix(0, nrow(A), length(used_attrs))
for (j in seq_along(used_attrs)) {
ac <- used_attrs[j]; sel <- which(lvl_attr == ac)
Uc[, j] <- u[sel][match(A[[ac]], lvl_name[sel])]
}
u_total <- rowSums(Uc)
rlist <- split(seq_along(pref), resp_vec)
if (length(rlist) > 800) { set.seed(42); rlist <- rlist[sample(length(rlist), 800)] }
ha <- character(0); hagg <- character(0); hn <- integer(0)
hmatch <- integer(0); hpct <- numeric(0); hsecond <- character(0)
for (j in seq_along(used_attrs)) {
ac <- used_attrs[j]
sel <- lvl_attr == ac
uu <- u[sel]; nm <- lvl_name[sel]
hi <- safe_which_max(uu)
top_lv <- if (is.na(hi)) NA_character_ else nm[hi]
partial <- pref - (u_total - Uc[, j])
assessed <- 0L; matched <- 0L; alt_counts <- integer(0)
for (ix in rlist) {
lv <- A[[ac]][ix]
if (length(unique(lv)) < 2) next
mns <- tapply(partial[ix], lv, mean, na.rm = TRUE)
mns <- mns[is.finite(mns)]
if (length(mns) < 2) next
best <- names(mns)[mns == max(mns)]
if (length(best) != 1) next
assessed <- assessed + 1L
if (!is.na(top_lv) && identical(best, top_lv)) {
matched <- matched + 1L
} else {
alt_counts[best] <- (if (is.na(alt_counts[best])) 0L else alt_counts[best]) + 1L
}
}
if (assessed < 8) next
ha <- c(ha, unname(attr_h[[ac]]))
hagg <- c(hagg, top_lv %||% "n/a")
hn <- c(hn, assessed); hmatch <- c(hmatch, matched)
hpct <- c(hpct, 100 * matched / assessed)
hsecond <- c(hsecond, if (length(alt_counts) == 0) "none" else
names(alt_counts)[safe_which_max(as.numeric(alt_counts))])
}
if (length(ha) > 0) {
het_ok <- TRUE
o <- order(hpct)
het_df <- data.frame(
attribute = ha[o],
agreement_pct = round(hpct[o], 1),
aggregate_top_level = hagg[o],
most_common_dissent = hsecond[o],
respondents_assessed = hn[o],
stringsAsFactors = FALSE
)
het_min <- het_df$agreement_pct[1]
het_worst_attr <- het_df$attribute[1]
het_flag <- het_min < 65
het_df$interpretation <- ifelse(
het_df$agreement_pct < 65,
sprintf("Only %s%% of respondents rank '%s' top on this attribute; the most common alternative favourite is '%s'. The aggregate part-worths for this attribute average over groups that want different things.",
vapply(het_df$agreement_pct, r2, ""), het_df$aggregate_top_level,
het_df$most_common_dissent),
sprintf("%s%% of respondents rank '%s' top on this attribute, so the aggregate part-worths describe most of the sample rather than blending opposed groups.",
vapply(het_df$agreement_pct, r2, ""), het_df$aggregate_top_level))
} else {
het_reason <- sprintf("Each respondent in '%s' saw too few distinct levels of any attribute to work out their individual favourite, so the aggregate part-worths could not be checked for a split audience.", resp_h)
}
}
if (is.null(het_df)) {
het_df <- data.frame(
attribute = "Heterogeneity check not run",
agreement_pct = NA_real_, aggregate_top_level = "n/a",
most_common_dissent = "n/a", respondents_assessed = NA_integer_,
interpretation = het_reason, stringsAsFactors = FALSE)
}Step 14: Standing caveats, all computed against this run's numbers
norm_note <- sprintf(
"Part-worths are identified only up to a constant within each attribute, so they are reported here re-centred to sum to zero inside every attribute: each number is that level against the average level of its OWN attribute. Comparing a level of one attribute against a level of another is meaningful only through the attribute-importance ranges, never by reading the two part-worths side by side.")
tested_note <- paste0(
"Importance is conditional on the levels that were tested: ",
paste(sprintf("%s across %s", imp_df$attribute, imp_df$tested_levels),
collapse = "; "),
". Widen or narrow any of these ranges and the importance ordering can change — a price range of two dollars would make price look unimportant no matter how price-sensitive the market is.")
stated_note <- paste0(
"Every number here is a stated preference: it describes choices people made between hypothetical profiles in this survey, not purchases they made with their own money. Stated preference routinely overstates what people will actually pay and understates the friction of a real purchase.")Step 15: Methods and disclosure table
methods_df <- data.frame(
item = c("Data shape detected", "Estimation", "Normalisation",
"Attribute importance", "Tested ranges", "Willingness-to-pay",
"Market simulation", "Heterogeneity", "What this is not"),
detail = c(
shape_reason,
if (shape == "choice")
sprintf("Conditional(multinomial) logit fitted by maximising the conditional-logit log-likelihood directly with a quasi-Newton optimiser and an analytic gradient; standard errors come from the observed information matrix. Final log-likelihood %s across %s choice tasks; %s = %s.",
r2(ll_final), ct(n_tasks), fit_label, r3(fit_stat))
else
sprintf("Ordinary least squares of '%s' on dummy-coded attribute levels. %s = %s; residual standard deviation %s across %s rows.",
pref_h, fit_label, r3(fit_stat), r3(resid_sd), ct(final_rows)),
norm_note,
sprintf("For each attribute, the range between its best and worst part-worth, divided by the sum of those ranges. Here the ranges are %s, summing to %s.",
paste(sprintf("%s %s", imp_df$attribute, vapply(imp_df$utility_range, r3, "")), collapse = "; "),
r3(tot_range)),
tested_note,
if (wtp_ok)
sprintf("The price part-worths were regressed on the numeric price levels, giving a slope of %s utility per unit of '%s' (95%% interval %s to %s, linearity R-squared %s across the %s tested price points). Willingness-to-pay for a level is its part-worth divided by that slope; the interval uses the delta method with the full covariance between the level's part-worth and the slope.",
r3(price_slope), unname(attr_h[[price_attr]]), r3(price_slope_lo),
r3(price_slope_hi), r3(price_lin_r2), ct(length(price_levels_num)))
else wtp_reason,
if (shape == "choice")
sprintf("Each configuration's utility is the sum of its levels' part-worths; shares are the logit shares among the %s configurations listed and nothing else. They are model predictions inside the tested attribute space, not forecasts of real market share — no competitor outside this list, no distribution, no awareness, no availability.",
ct(nrow(sim_df)))
else
sprintf("Each configuration's utility is the sum of its levels' part-worths, so the configurations can be ranked and the utility gaps read. %s",
share_basis),
if (het_ok)
sprintf("For each attribute, the aggregate contribution of every OTHER attribute was subtracted from '%s' row by row, and each respondent's own favourite level was read off the resulting partial scores and compared against the aggregate favourite. Agreement ranged from %s%% (%s) to %s%% across %s respondents assessed. Low agreement can also mean low power when a respondent evaluated few profiles, so read it alongside the number assessed.",
pref_h, r2(min(het_df$agreement_pct, na.rm = TRUE)),
het_df$attribute[1], r2(max(het_df$agreement_pct, na.rm = TRUE)),
ct(max(het_df$respondents_assessed, na.rm = TRUE)))
else het_reason,
stated_note
),
stringsAsFactors = FALSE
)Step 16: Metrics and the one-paragraph answer
metrics <- list(
`Observations` = final_rows,
`Attributes` = length(used_attrs),
`Levels Estimated` = length(u),
`Data Shape` = if (shape == "choice") "choice-based" else "ratings-based",
`Most Important` = sprintf("%s(%s%%)", top_imp$attribute, r2(top_imp$importance_pct)),
`Fit` = sprintf("%s = %s", fit_label, r3(fit_stat))
)
if (shape == "choice") metrics[["Choice Tasks"]] <- as.integer(n_tasks)
if (wtp_ok) metrics[["Value Per Utility Point"]] <- round(money_per_util, 3)
if (het_ok) metrics[["Lowest Agreement"]] <- round(het_min, 1)
best_row <- sim_df[1, ]
wtp_clause <- if (wtp_ok) {
top_w <- wtp_df[1, ]
sprintf(" Against the price slope of %s utility per unit of '%s', the best-valued level is %s at about %s%s (95%% interval %s%s to %s%s) — a stated-preference estimate, not a price anyone has agreed to pay.",
r3(price_slope), unname(attr_h[[price_attr]]), top_w$level_label,
price_sym, r2(top_w$wtp), price_sym, r2(top_w$wtp_low), price_sym, r2(top_w$wtp_high))
} else {
paste0(" Willingness-to-pay is not reported: ", wtp_reason)
}
het_clause <- if (het_ok && het_flag) {
sprintf(" Heterogeneity warning: only %s%% of respondents share the aggregate favourite level of '%s', so the aggregate part-worths for that attribute blend groups that want opposite things.",
r2(het_min), het_worst_attr)
} else if (het_ok) {
sprintf(" Respondent-level agreement with the aggregate favourite runs from %s%% to %s%%, so the aggregate is not obviously masking opposed segments.",
r2(min(het_df$agreement_pct, na.rm = TRUE)),
r2(max(het_df$agreement_pct, na.rm = TRUE)))
} else ""
share_clause <- if (shape == "choice") {
sprintf(" In a simulated choice between the %s configurations built from the tested levels, '%s' takes the largest share at %s%%.",
ct(nrow(sim_df)), best_row$configuration, r2(best_row$predicted_share_pct))
} else {
sprintf(" Of the %s configurations built from the tested levels, '%s' carries the highest total utility at %s.",
ct(nrow(sim_df)), best_row$configuration, r2(best_row$total_utility))
}
json_output <- list(
answer = paste0(
"Conjoint analysis of ", ct(final_rows), " ", if (shape == "choice")
paste0("alternatives across ", ct(n_tasks), " choice tasks") else
paste0("profile evaluations of '", pref_h, "'"),
" across ", ct(length(used_attrs)), " attributes(",
paste(unname(attr_h[used_attrs]), collapse = ", "), "): '",
top_imp$attribute, "' is the most important attribute at ",
r2(top_imp$importance_pct), "% of the total part-worth range, driven by the gap between '",
top_imp$worst_level, "' and '", top_imp$best_level,
"' — an importance that holds only for the levels actually tested (",
top_imp$tested_levels, ").", wtp_clause, share_clause, het_clause,
" ", stated_note
),
cards = lapply(
c("tldr", "overview", "preprocessing", "part_worths",
"attribute_importance", "willingness_to_pay", "market_simulation",
"heterogeneity", "methods"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed, n_incomplete = n_incomplete,
bad_tasks = bad_tasks, lumped = lumped,
shape = shape, shape_reason = shape_reason, converged = converged,
pref_h = pref_h, task_h = task_h, resp_h = resp_h, attr_h = attr_h,
used_attrs = used_attrs, dropped_df = dropped_df, levels_list = levels_list,
pw_df = pw_df, imp_df = imp_df, top_imp = top_imp, tot_range = tot_range,
wtp_df = wtp_df, wtp_ok = wtp_ok, wtp_reason = wtp_reason,
money_per_util = money_per_util, price_sym = price_sym,
price_attr = if (is.na(price_attr)) NA_character_ else unname(attr_h[[price_attr]]),
price_slope = price_slope, price_slope_lo = price_slope_lo,
price_slope_hi = price_slope_hi, price_lin_r2 = price_lin_r2,
price_levels_num = price_levels_num,
sim_df = sim_df, het_df = het_df, het_ok = het_ok, het_flag = het_flag,
het_reason = het_reason,
het_min = het_min, het_worst_attr = het_worst_attr,
methods_df = methods_df, fit_stat = fit_stat, fit_label = fit_label,
resid_sd = resid_sd, base_utility = base_utility, ll_final = ll_final,
n_resp = n_resp, n_tasks = n_tasks,
norm_note = norm_note, tested_note = tested_note, stated_note = stated_note,
metrics = metrics, json_output = json_output
)
}