library(dplyr)
library(ggplot2)
library(knitr)
theme_conjoint_dark <- function(base_size = 13) {
theme_minimal(base_size = base_size) +
theme(
plot.background = element_rect(fill = "#111827", color = NA),
panel.background = element_rect(fill = "#111827", color = NA),
panel.grid.major = element_line(color = "#374151"),
panel.grid.minor = element_blank(),
text = element_text(color = "#f9fafb"),
plot.title = element_text(color = "#f9fafb", face = "bold"),
plot.subtitle = element_text(color = "#d1d5db"),
axis.title = element_text(color = "#e5e7eb"),
axis.text = element_text(color = "#d1d5db"),
strip.text = element_text(color = "#f9fafb", face = "bold"),
strip.background = element_rect(fill = "#1f2937", color = NA),
legend.background = element_rect(fill = "#111827", color = NA),
legend.key = element_rect(fill = "#111827", color = NA),
legend.text = element_text(color = "#d1d5db"),
legend.title = element_text(color = "#f9fafb")
)
}
num_fmt <- function(x, digits = 2) {
format(round(as.numeric(x), digits), nsmall = digits)
}
By the end of this lab, I should be able to:
Conjoint analysis is a survey-based method for estimating how people trade off product features when forming an overall preference. Instead of asking respondents to directly state which features matter most, we ask them to rate realistic product profiles that bundle several attributes together.
The pattern of ratings across profiles allows the model to estimate how much each attribute level contributes to the overall rating.
Interpretation checkpoint: In this lab, a higher rating means the respondent found the auto insurance policy more attractive. The regression model estimates which policy features tend to raise or lower that expected rating.
This lab studies preferences for auto insurance policies using three attributes.
| Attribute | Levels |
|---|---|
| Deductible | 500, 1000, 2000 |
| Brand | State Farm, AAA, Local |
| Pay-as-you-drive | Yes, No |
A full factorial design would create:
3 * 3 * 2
## [1] 18
That means there are 18 possible policies. Showing all 18 to each respondent would create more burden than necessary for a short classroom lab.
For a main-effects conjoint model, the required number of estimated terms is:
1 + (number of Deductible levels - 1) +
(number of Brand levels - 1) +
(number of Pay-as-you-drive levels - 1)
For this design:
1 + (3 - 1) + (3 - 1) + (2 - 1) = 6
Key clarification: The number 6 refers to the number of product profiles shown to each respondent, not the number of survey respondents. The intercept counts as 1, and each attribute contributes one fewer estimated parameter than its number of levels.
This is a full-profile, within-respondent conjoint exercise. Each respondent rates multiple policy profiles, not just one. This matters because the model needs to observe how ratings change as the policy attributes change.
Good data collection practice includes:
if (!file.exists("insurance_conjoint.csv")) {
stop("The file insurance_conjoint.csv was not found. Put it in the same folder as this RMD file.")
}
df_raw <- read.csv("insurance_conjoint.csv", stringsAsFactors = FALSE)
head(df_raw)
cat("Rows:", nrow(df_raw), "\n")
## Rows: 120
cat("Columns:", ncol(df_raw), "\n")
## Columns: 6
cat("Column names:", paste(names(df_raw), collapse = ", "), "\n")
## Column names: id, order_shown, Deductible, Brand, Pay_as_you_drive, rating
cat("Respondents:", length(unique(df_raw$id)), "\n")
## Respondents: 20
cat("Ratings per respondent:\n")
## Ratings per respondent:
print(table(df_raw$id))
##
## 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
## 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6
Lab step interpretation: The dataset is in long
format. That means each row is one respondent-profile rating. This is
the correct shape for lm() because the model needs one
outcome value, rating, for each evaluated policy
profile.
The model uses dummy variables for attribute levels. One level from each attribute becomes the baseline. The remaining coefficients are interpreted relative to that baseline.
# Professor-aligned preparation step:
# Treat the design attributes as categorical factors.
# Deductible looks numeric, but in conjoint analysis we want a separate
# part-worth utility for each level rather than assuming a straight-line slope.
df <- df_raw %>%
mutate(
Deductible = as.numeric(as.character(Deductible)),
rating = as.numeric(rating)
)
df$Deductible <- factor(
df$Deductible,
levels = c(500, 1000, 2000)
)
df$Brand <- factor(
df$Brand,
levels = c("State Farm", "AAA", "Local")
)
df$Pay_as_you_drive <- factor(
df$Pay_as_you_drive,
levels = c("No", "Yes")
)
str(df)
## 'data.frame': 120 obs. of 6 variables:
## $ id : int 1 1 1 1 1 1 2 2 2 2 ...
## $ order_shown : int 1 2 3 4 5 6 1 2 3 4 ...
## $ Deductible : Factor w/ 3 levels "500","1000","2000": 3 2 3 2 1 1 3 3 1 2 ...
## $ Brand : Factor w/ 3 levels "State Farm","AAA",..: 1 1 2 3 1 2 2 1 2 1 ...
## $ Pay_as_you_drive: Factor w/ 2 levels "No","Yes": 1 1 2 2 2 1 2 1 1 1 ...
## $ rating : num 3 2 6 5 5 6 4 4 5 4 ...
# Professor naming convention: this is the part-worth main-effects model.
conjoint_model <- lm(rating ~ Deductible + Brand + Pay_as_you_drive, data = df)
# Keep m01 as an alias so the rest of the existing report still knits.
m01 <- conjoint_model
summary(conjoint_model)
##
## Call:
## lm(formula = rating ~ Deductible + Brand + Pay_as_you_drive,
## data = df)
##
## Residuals:
## Min 1Q Median 3Q Max
## -2.70 -0.70 0.05 0.55 2.55
##
## Coefficients:
## Estimate Std. Error t value
## (Intercept) 4.4500000000000001776 0.2696651594952785280 16.502
## Deductible1000 -0.9999999999999997780 0.3813641258577268878 -2.622
## Deductible2000 -0.7500000000000000000 0.2696651594952785835 -2.781
## BrandAAA 0.2500000000000001110 0.2696651594952785835 0.927
## BrandLocal -0.6000000000000005329 0.4670737572769874668 -1.285
## Pay_as_you_driveYes 0.0000000000000002777 0.2696651594952785835 0.000
## Pr(>|t|)
## (Intercept) < 0.0000000000000002 ***
## Deductible1000 0.00993 **
## Deductible2000 0.00634 **
## BrandAAA 0.35585
## BrandLocal 0.20154
## Pay_as_you_driveYes 1.00000
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 1.206 on 114 degrees of freedom
## Multiple R-squared: 0.2153, Adjusted R-squared: 0.1809
## F-statistic: 6.257 on 5 and 114 DF, p-value: 0.00003641
coef_table <- as.data.frame(summary(m01)$coefficients)
coef_table$term <- rownames(coef_table)
rownames(coef_table) <- NULL
names(coef_table) <- c("Estimate", "Std_Error", "t_value", "p_value", "term")
coef_table <- coef_table %>%
select(term, Estimate, Std_Error, t_value, p_value) %>%
mutate(
abs_estimate = abs(Estimate),
significance = case_when(
p_value < 0.001 ~ "p < .001",
p_value < 0.01 ~ "p < .01",
p_value < 0.05 ~ "p < .05",
TRUE ~ "not significant at .05"
)
)
kable(
coef_table,
digits = 3,
caption = "Regression coefficients for auto insurance conjoint model"
)
| term | Estimate | Std_Error | t_value | p_value | abs_estimate | significance |
|---|---|---|---|---|---|---|
| (Intercept) | 4.45 | 0.270 | 16.502 | 0.000 | 4.45 | p < .001 |
| Deductible1000 | -1.00 | 0.381 | -2.622 | 0.010 | 1.00 | p < .01 |
| Deductible2000 | -0.75 | 0.270 | -2.781 | 0.006 | 0.75 | p < .01 |
| BrandAAA | 0.25 | 0.270 | 0.927 | 0.356 | 0.25 | not significant at .05 |
| BrandLocal | -0.60 | 0.467 | -1.285 | 0.202 | 0.60 | not significant at .05 |
| Pay_as_you_driveYes | 0.00 | 0.270 | 0.000 | 1.000 | 0.00 | not significant at .05 |
Coefficient table read: This table is the source of truth for the written interpretation. The intercept represents the baseline policy rating. Each non-intercept coefficient estimates the change in predicted rating from switching to that level while holding the other features constant. In the tutorial output, Deductible1000 and Deductible2000 are statistically significant at the .01 level, while BrandAAA, BrandLocal, and Pay_as_you_driveYes are not significant at the .05 level.
The professor’s continuation adds four important outputs after the main regression model:
The regression coefficients are relative to baseline levels. Centering them within each attribute makes them easier to compare as part-worth utilities because each attribute’s levels average to zero.
coefs <- coef(conjoint_model)
get_utilities <- function(attr, levels_vec, coefs) {
u <- setNames(numeric(length(levels_vec)), levels_vec)
for (lv in levels_vec[-1]) {
term_candidates <- c(
paste0(attr, lv),
paste0(attr, make.names(lv))
)
matched_term <- term_candidates[term_candidates %in% names(coefs)]
if (length(matched_term) > 0) {
u[lv] <- coefs[matched_term[1]]
}
}
# Center so each attribute's levels average to zero.
u - mean(u)
}
util_deductible <- get_utilities("Deductible", levels(df$Deductible), coefs)
util_brand <- get_utilities("Brand", levels(df$Brand), coefs)
util_payd <- get_utilities("Pay_as_you_drive", levels(df$Pay_as_you_drive), coefs)
cat("Deductible:\n")
## Deductible:
print(round(util_deductible, 3))
## 500 1000 2000
## 0.583 -0.417 -0.167
cat("Brand:\n")
## Brand:
print(round(util_brand, 3))
## State Farm AAA Local
## 0.117 0.367 -0.483
cat("Pay_as_you_drive:\n")
## Pay_as_you_drive:
print(round(util_payd, 3))
## No Yes
## 0 0
utility_table <- data.frame(
Attribute = c(
rep("Deductible", length(util_deductible)),
rep("Brand", length(util_brand)),
rep("Pay_as_you_drive", length(util_payd))
),
Level = c(
names(util_deductible),
names(util_brand),
names(util_payd)
),
Utility = round(c(util_deductible, util_brand, util_payd), 3)
)
kable(
utility_table,
caption = "Part-worth utilities by attribute and level"
)
| Attribute | Level | Utility | |
|---|---|---|---|
| 500 | Deductible | 500 | 0.583 |
| 1000 | Deductible | 1000 | -0.417 |
| 2000 | Deductible | 2000 | -0.167 |
| State Farm | Brand | State Farm | 0.117 |
| AAA | Brand | AAA | 0.367 |
| Local | Brand | Local | -0.483 |
| No | Pay_as_you_drive | No | 0.000 |
| Yes | Pay_as_you_drive | Yes | 0.000 |
How to read this table: Positive part-worth utilities add preference relative to the average level within that attribute. Negative utilities subtract preference relative to that attribute’s average. The values are most meaningful when compared within the same attribute.
The professor’s method calculates each attribute’s importance as the spread between its highest and lowest part-worth utilities, then normalizes those spreads to sum to 100%.
importance <- c(
Deductible = diff(range(util_deductible)),
Brand = diff(range(util_brand)),
Pay_as_you_drive = diff(range(util_payd))
)
importance_pct <- round(100 * importance / sum(importance), 1)
importance_table <- data.frame(
Attribute = names(importance_pct),
Importance_Pct = importance_pct
)
kable(
importance_table,
caption = "Relative importance of each attribute (%)"
)
| Attribute | Importance_Pct | |
|---|---|---|
| Deductible | Deductible | 54.1 |
| Brand | Brand | 45.9 |
| Pay_as_you_drive | Pay_as_you_drive | 0.0 |
Current importance read: The largest overall
attribute driver is Deductible, accounting for
approximately 54.1% of the total modeled utility swing.
This builds the model-predicted ideal policy by selecting the highest part-worth level within each attribute.
best_deductible <- names(which.max(util_deductible))
best_brand <- names(which.max(util_brand))
best_payd <- names(which.max(util_payd))
best_profile_partworth <- data.frame(
Deductible = factor(best_deductible, levels = levels(df$Deductible)),
Brand = factor(best_brand, levels = levels(df$Brand)),
Pay_as_you_drive = factor(best_payd, levels = levels(df$Pay_as_you_drive))
)
predicted_rating_partworth <- predict(conjoint_model, newdata = best_profile_partworth)
kable(
best_profile_partworth,
caption = "Highest-utility predicted profile from part-worth utilities"
)
| Deductible | Brand | Pay_as_you_drive |
|---|---|---|
| 500 | AAA | Yes |
cat("Predicted rating:", round(predicted_rating_partworth, 2), "\n")
## Predicted rating: 4.7
Current ideal-profile read: The highest-utility
predicted policy combines a 500 deductible,
AAA brand, and Pay-as-you-drive = Yes. The
model-predicted rating is 4.70.
This step compares the model’s ideal profile with the actual average ratings for the profiles respondents saw.
observed_avg <- aggregate(
rating ~ Deductible + Brand + Pay_as_you_drive,
data = df,
FUN = mean
)
observed_avg <- observed_avg[order(-observed_avg$rating), ]
kable(
observed_avg,
digits = 2,
caption = "Observed mean rating by profile"
)
| Deductible | Brand | Pay_as_you_drive | rating | |
|---|---|---|---|---|
| 3 | 500 | AAA | No | 4.70 |
| 4 | 500 | State Farm | Yes | 4.45 |
| 5 | 2000 | AAA | Yes | 3.95 |
| 2 | 2000 | State Farm | No | 3.70 |
| 1 | 1000 | State Farm | No | 3.45 |
| 6 | 1000 | Local | Yes | 2.85 |
Observed-profile check: The highest observed average
profile is 500 deductible, AAA,
Pay-as-you-drive = No, with a mean rating of
4.70. If this is close to the model-predicted ideal profile
above, the main-effects model is behaving consistently with the raw
ratings. If it differs, that is a useful limitation to flag.
The baseline policy is the omitted combination: a 500 deductible, State Farm as the brand, and no pay-as-you-drive feature. The model estimates this baseline rating as 4.45.
Each coefficient shows the expected rating change compared with that baseline, holding the other attributes constant. Positive coefficients increase the expected rating relative to the baseline. Negative coefficients lower the expected rating relative to the baseline.
Current model read: The largest absolute coefficient
is Deductible1000, with an estimated effect of
-1.00. This is the single strongest modeled attribute-level
movement in the current lab output.
Predicted ratings let us rank policy profiles using the fitted regression model. This is useful because the model can estimate expected ratings for combinations that were not directly shown to respondents.
design_df <- df %>%
distinct(Deductible, Brand, Pay_as_you_drive) %>%
arrange(Deductible, Brand, Pay_as_you_drive)
kable(
design_df,
caption = "Unique tested profiles found in the dataset"
)
| Deductible | Brand | Pay_as_you_drive |
|---|---|---|
| 500 | State Farm | Yes |
| 500 | AAA | No |
| 1000 | State Farm | No |
| 1000 | Local | Yes |
| 2000 | State Farm | No |
| 2000 | AAA | Yes |
fit_tested <- predict(m01, newdata = design_df, interval = "confidence")
design_out <- cbind(design_df, fit_tested) %>%
arrange(desc(fit))
kable(
design_out,
digits = 2,
caption = "Predicted rating for each tested profile"
)
| Deductible | Brand | Pay_as_you_drive | fit | lwr | upr | |
|---|---|---|---|---|---|---|
| 2 | 500 | AAA | No | 4.70 | 4.17 | 5.23 |
| 1 | 500 | State Farm | Yes | 4.45 | 3.92 | 4.98 |
| 6 | 2000 | AAA | Yes | 3.95 | 3.42 | 4.48 |
| 5 | 2000 | State Farm | No | 3.70 | 3.17 | 4.23 |
| 3 | 1000 | State Farm | No | 3.45 | 2.92 | 3.98 |
| 4 | 1000 | Local | Yes | 2.85 | 2.32 | 3.38 |
full_grid <- expand.grid(
Deductible = levels(df$Deductible),
Brand = levels(df$Brand),
Pay_as_you_drive = levels(df$Pay_as_you_drive)
)
full_grid$Deductible <- factor(full_grid$Deductible, levels = levels(df$Deductible))
full_grid$Brand <- factor(full_grid$Brand, levels = levels(df$Brand))
full_grid$Pay_as_you_drive <- factor(full_grid$Pay_as_you_drive, levels = levels(df$Pay_as_you_drive))
fit_full <- predict(m01, newdata = full_grid, interval = "confidence")
full_out <- cbind(full_grid, fit_full) %>%
arrange(desc(fit))
kable(
full_out,
digits = 2,
caption = paste("Predicted rating across all", nrow(full_grid), "possible combinations")
)
| Deductible | Brand | Pay_as_you_drive | fit | lwr | upr | |
|---|---|---|---|---|---|---|
| 4 | 500 | AAA | No | 4.70 | 4.17 | 5.23 |
| 13 | 500 | AAA | Yes | 4.70 | 4.17 | 5.23 |
| 1 | 500 | State Farm | No | 4.45 | 3.92 | 4.98 |
| 10 | 500 | State Farm | Yes | 4.45 | 3.92 | 4.98 |
| 15 | 2000 | AAA | Yes | 3.95 | 3.42 | 4.48 |
| 6 | 2000 | AAA | No | 3.95 | 3.42 | 4.48 |
| 16 | 500 | Local | Yes | 3.85 | 2.92 | 4.78 |
| 7 | 500 | Local | No | 3.85 | 2.66 | 5.04 |
| 14 | 1000 | AAA | Yes | 3.70 | 2.77 | 4.63 |
| 5 | 1000 | AAA | No | 3.70 | 2.94 | 4.46 |
| 12 | 2000 | State Farm | Yes | 3.70 | 3.17 | 4.23 |
| 3 | 2000 | State Farm | No | 3.70 | 3.17 | 4.23 |
| 11 | 1000 | State Farm | Yes | 3.45 | 2.69 | 4.21 |
| 2 | 1000 | State Farm | No | 3.45 | 2.92 | 3.98 |
| 18 | 2000 | Local | Yes | 3.10 | 2.17 | 4.03 |
| 9 | 2000 | Local | No | 3.10 | 1.91 | 4.29 |
| 17 | 1000 | Local | Yes | 2.85 | 2.32 | 3.38 |
| 8 | 1000 | Local | No | 2.85 | 2.09 | 3.61 |
The first prediction table ranks only the profiles that appear in the dataset. The second table expands the logic to all 18 theoretically possible policies.
Current model read: The best predicted
full-combination policy is 500 deductible, AAA, Pay-as-you-drive =
No, with a predicted rating of 4.70. The lowest
predicted combination is 1000 deductible, Local, Pay-as-you-drive
= No, with a predicted rating of 2.85.
This plot translates the centered utility table into a visual comparison of preferred and less-preferred levels within each attribute.
utility_plot_df <- utility_table %>%
mutate(
Level = factor(Level, levels = Level[order(Attribute, -Utility)])
)
ggplot(utility_plot_df, aes(x = reorder(Level, Utility), y = Utility, fill = Attribute)) +
geom_col(show.legend = FALSE) +
geom_hline(yintercept = 0, color = "#f87171", linetype = "dashed") +
geom_text(
aes(label = round(Utility, 2)),
hjust = ifelse(utility_plot_df$Utility >= 0, -0.15, 1.15),
color = "#f9fafb",
size = 3.5
) +
coord_flip() +
facet_wrap(~Attribute, scales = "free_y") +
labs(
title = "Part-Worth Utilities by Attribute Level",
subtitle = "Centered utilities: positive levels add preference within their attribute; negative levels subtract preference",
x = NULL,
y = "Centered Part-Worth Utility"
) +
theme_conjoint_dark(base_size = 13)
This is the most direct chart for explaining which attribute matters most overall because each bar is on the same 0% to 100% importance scale.
importance_plot_df <- importance_table %>%
mutate(Attribute = reorder(Attribute, Importance_Pct))
ggplot(importance_plot_df, aes(x = Attribute, y = Importance_Pct)) +
geom_col(fill = "#a78bfa") +
geom_text(
aes(label = paste0(Importance_Pct, "%")),
hjust = -0.15,
color = "#f9fafb",
size = 4
) +
coord_flip() +
labs(
title = "Relative Importance of Each Attribute",
subtitle = "Calculated from each attribute's part-worth utility range",
x = NULL,
y = "Relative Importance (%)"
) +
ylim(0, max(importance_plot_df$Importance_Pct) * 1.25) +
theme_conjoint_dark(base_size = 13)
This chart is a raw-data sanity check. It shows which actually observed profile received the highest average rating.
observed_plot_df <- observed_avg %>%
mutate(
profile_label = paste(Deductible, Brand, Pay_as_you_drive, sep = " | "),
is_top = rating == max(rating)
)
ggplot(observed_plot_df, aes(x = reorder(profile_label, rating), y = rating, fill = is_top)) +
geom_col(show.legend = FALSE) +
geom_text(
aes(label = round(rating, 2)),
hjust = -0.15,
color = "#f9fafb",
size = 3.5
) +
coord_flip() +
labs(
title = "Mean Rating by Observed Profile",
subtitle = "Raw average ratings for the exact policy profiles shown to respondents",
x = "Profile: Deductible | Brand | Pay-as-you-drive",
y = "Mean Rating"
) +
ylim(0, max(observed_plot_df$rating) * 1.15) +
theme_conjoint_dark(base_size = 13)
Marginal means show the raw average rating for each level of each attribute. This is a descriptive view: it does not control for the other attributes the way the regression model does.
mm_deduct <- df %>%
group_by(level = Deductible) %>%
summarise(mean_rating = mean(rating), .groups = "drop") %>%
mutate(attribute = "Deductible")
mm_brand <- df %>%
group_by(level = Brand) %>%
summarise(mean_rating = mean(rating), .groups = "drop") %>%
mutate(attribute = "Brand")
mm_pad <- df %>%
group_by(level = Pay_as_you_drive) %>%
summarise(mean_rating = mean(rating), .groups = "drop") %>%
mutate(attribute = "Pay-as-you-drive")
mm_all <- bind_rows(mm_deduct, mm_brand, mm_pad)
mm_all$level <- as.character(mm_all$level)
mm_all$level <- factor(
mm_all$level,
levels = c("500", "1000", "2000", "State Farm", "AAA", "Local", "No", "Yes")
)
kable(
mm_all,
digits = 2,
caption = "Marginal mean ratings by attribute level"
)
| level | mean_rating | attribute |
|---|---|---|
| 500 | 4.58 | Deductible |
| 1000 | 3.15 | Deductible |
| 2000 | 3.83 | Deductible |
| State Farm | 3.87 | Brand |
| AAA | 4.32 | Brand |
| Local | 2.85 | Brand |
| No | 3.95 | Pay-as-you-drive |
| Yes | 3.75 | Pay-as-you-drive |
ggplot(mm_all, aes(x = level, y = mean_rating, fill = attribute)) +
geom_col(show.legend = FALSE) +
facet_wrap(~attribute, scales = "free_x") +
labs(
title = "Average Rating by Attribute Level",
subtitle = "Raw averages before regression adjustment",
x = NULL,
y = "Mean Rating"
) +
theme_conjoint_dark(base_size = 13)
Current graph read: The highest raw marginal mean is
500 under Deductible, with an average rating
of 4.58. The lowest raw marginal mean is Local
under Brand, with an average rating of
2.85.
Use this graph as an intuitive first look. It is useful for seeing what respondents tended to rate higher or lower on average, but it should not be the final answer by itself because attribute levels are bundled together in profiles.
The coefficient plot shows the model-adjusted effects relative to the baseline levels.
co_plot <- coef_table %>%
filter(term != "(Intercept)")
ggplot(co_plot, aes(x = reorder(term, Estimate), y = Estimate)) +
geom_col(fill = "#60a5fa") +
geom_errorbar(
aes(
ymin = Estimate - 1.96 * Std_Error,
ymax = Estimate + 1.96 * Std_Error
),
width = 0.2,
color = "#f9fafb"
) +
geom_hline(yintercept = 0, color = "#f87171", linetype = "dashed") +
coord_flip() +
labs(
title = "Regression Coefficients Relative to Baseline",
subtitle = "Positive values increase predicted rating; negative values decrease it",
x = NULL,
y = "Estimated Rating Change"
) +
theme_conjoint_dark(base_size = 13)
Current graph read: The most positive coefficient is
BrandAAA at 0.25. The most negative
coefficient is Deductible1000 at -1.00. The
largest absolute movement is Deductible1000, which is the
strongest single modeled attribute-level effect in this lab.
The red zero line is important. Bars near zero suggest little modeled impact compared with the baseline. Bars far from zero suggest stronger estimated preference movement. Confidence intervals crossing zero indicate more uncertainty around whether that effect is clearly different from zero.
The model baseline is a policy with a 500 deductible, State Farm as the brand, and no pay-as-you-drive feature. The predicted baseline rating is 4.45.
The coefficients show how expected rating changes when one level changes while the other features are held constant. In this lab output, the strongest overall movement is Deductible1000, with an estimated effect of -1.00. The most positive movement is BrandAAA, while the most negative movement is Deductible1000.
The professor’s range-based importance calculation makes this more defensible than relying on visual guesswork alone. In the current output, Deductible is the largest overall driver, accounting for about 54.1% of the total modeled utility swing. I would use this as the primary prioritization signal while still checking the part-worth table and observed profile averages for context.
Strategically, the company should focus on the feature levels that increase predicted rating and be cautious about the levels that reduce predicted rating. The marginal means plot gives a quick descriptive read, while the regression coefficient plot is better for model-adjusted interpretation.
Visual summary for Question 1: interpreting the model baseline, coefficients, plots, and strategic implications.
Source-of-truth note: The coefficient table above should be treated as the official numeric output for this report. The infographic is included as a visual explanation aid.
Deductible1000 has the largest absolute coefficient, with an estimated effect of -1.00. This is demonstrated well in the regression coefficient graphic by the length of the bar. The longer the bar is from zero, the larger the expected movement in predicted rating.
This is the best answer because it uses the regression model rather than only raw averages. The marginal means plot is useful as a sanity check, but the coefficient plot is more appropriate for answering which attribute level moves predicted rating the most while holding the other attributes constant.
Visual summary for Question 2: identifying the single attribute level with the largest predicted rating movement.
The main challenges would be designing realistic attributes and thoughtfully curated extremes like in the evil corporation and customer utopia examples, choosing levels that respondents can understand, keeping the survey short and interesting and engaging enough to avoid fatigue, collecting enough high-quality responses, randomizing profile order, and cleaning the data into the correct format for conducting analysis.
A second challenge is interpretation. Conjoint results are easy to overstate. The model estimates preference patterns within this sample and survey design; it does not prove that the same effects would hold for every insurance customer in the real market. Sample size should be much larger in a real business study, but this lab is useful because it demonstrates proof of concept and workflow reproducibility.
Visual summary for Question 3: major challenges in independently conducting a conjoint study.
I would meet with a focus group of different subject matter experts and conduct a short workshop to identify and rank target attributes to compare.
I would use clear descriptions, realistic policy profiles, a consistent rating scale, randomized profile order, and simple instructions. Clear descriptions reduce confusion. Realistic policy profiles reduce fake or meaningless trade-offs. A consistent rating scale reduces measurement noise. Randomized profile order helps mitigate order effects and fatigue effects. Simple instructions reduce misunderstanding and incomplete responses.
I would also screen for respondents who understand or are likely to purchase auto insurance. This helps mitigate the risk of collecting ratings from people who do not understand the product category or are not relevant to the business decision. I would check for incomplete responses, extremely fast completions, repeated identical ratings across every profile, and patterned or careless rating behavior such as simply clicking through 1, 2, 3, 4, 5, 6, 7 without evaluating the profiles. These checks are meant to mitigate low-effort responses, straight-lining, patterned answering, missing data, and other data quality problems.
To improve quality, I would pilot the survey with a small group first, revise confusing wording, and make sure the final dataset has one row per respondent-profile combination so the regression model can be estimated correctly. I would optimize my collection process instead of handling it ad hoc like we did in the other short lab.
Visual summary for Question 4: safeguards for useful and meaningful participant responses.
This lab shows how conjoint analysis turns ratings of bundled product profiles into estimates of attribute-level preference. The most important steps are:
The business value is that a company can use a small, structured survey to estimate which product features are most likely to improve customer appeal before building or launching the final product.