The Portland Trail Blazers faced declining attendance and the challenge of attracting customers to multigame ticket packages. These packages combine several games into one purchase and may include preferred seating and promotional benefits. Package design is therefore a joint consumer-preference and managerial decision: an attractive offer must also respect seat-pricing policies and the costs of providing its features.
The case reports a survey of previous multigame or season-ticket purchasers who were not current season-ticket holders. Of 960 people contacted, 204 submitted valid responses (approximately 21%); respondents completed 20 conjoint choice tasks. The supplied analytical workbook contains attribute-level part-worth utility estimates, not the individual choice-task records. Consequently, this project uses those supplied utility estimates as model inputs. It does not claim to estimate individual-level choice coefficients from the unavailable survey responses.
The analysis addresses four questions:
Module 1 establishes the data foundation: it imports the workbook, validates the inputs, examines the utility and cost distributions, and identifies issues that must be addressed before willingness-to-pay and package evaluation. Subsequent modules perform the full valuation and decision analysis.
The principal input is
Portland Trail Blazers_Keman_Xiang.xlsx. The
Table 1. Utility score data. worksheet provides the
attribute-level utility estimates. The Product Analysis
worksheet provides corresponding numeric levels, seat-cost allocations,
and promotional-item costs. Other workbook sheets
(Utility per Cost, Willingness to pay, and
Attribute Tradeoff) contain downstream calculations and are
not treated as independent observations.
The survey considered four package attributes: number of games (3, 6, 10), ticket price ($15, $25, $35, $60 per seat per game), seat location (four levels), and promotional item (five levels). The 16 levels are estimated part-worths, not 16 respondents or 16 transactions.
The case also specifies two management restrictions: 200-level midcourt seats cannot be sold below $60, and 300-level midcourt seats cannot be sold below $25. These restrictions will enter the feasible-package analysis in Module 4. The seat costs are management’s allocated minimum-cost figures; they should not automatically be interpreted as incremental cash costs. The promotional-item costs are reported separately. In particular, the case describes a hot dog and soda offered with each ticket but quotes a negotiated cost of $3.25 per package; this ambiguity must be resolved or tested before interpreting package-level profitability.
The code locates the Excel file without modifying it. Reading worksheet names first prevents silent use of an incorrect file or tab.
expected_sheets <- c("Table 1. Utility score data.", "Product Analysis",
"Utility per Cost", "Willingness to pay", "Attribute Tradeoff")
actual_sheets <- readxl::excel_sheets(input_path)
check(all(expected_sheets %in% actual_sheets),
paste("Missing workbook sheets:", paste(setdiff(expected_sheets, actual_sheets), collapse = ", ")))
knitr::kable(data.frame(worksheet = actual_sheets), caption = "Worksheets in the project input file")| worksheet |
|---|
| Title Page |
| Table 1. Utility score data. |
| Utility per Cost |
| Product Analysis |
| Willingness to pay |
| Attribute Tradeoff |
The utility table is organized into four attribute sections. Each
section begins with a Utility header, followed by its
attribute levels. Reading both columns as text protects
negative values that use an en dash or Unicode minus sign; the parser
then converts these characters to a standard minus before numeric
conversion. The source row is retained for traceability.
raw_utility <- readxl::read_excel(input_path,
sheet = "Table 1. Utility score data.", range = "B2:C21",
col_names = FALSE, col_types = "text", .name_repair = "minimal")
attribute_names <- c("Number of Games", "Ticket Price", "Ticket Location", "Promotional Item")
current_attribute <- NA_character_
records <- list()
for (i in seq_len(nrow(raw_utility))) {
left <- clean_text(raw_utility[[1]][i]); right <- clean_text(raw_utility[[2]][i])
if (identical(left, "Utility") && right %in% attribute_names) {
current_attribute <- right
} else if (!is.na(current_attribute) && !is.na(right) && nzchar(right)) {
value <- num(left)
check(is.finite(value), paste("Invalid utility in source row", i + 1L, ":", left))
records[[length(records) + 1L]] <- data.frame(
attribute = current_attribute, level_label = right,
utility = value, source_utility_row = i + 1L)
}
}
partworth <- dplyr::bind_rows(records) |>
dplyr::group_by(attribute) |>
dplyr::mutate(level_id = dplyr::row_number()) |>
dplyr::ungroup() |>
dplyr::select(attribute, level_id, level_label, utility, source_utility_row)
knitr::kable(partworth, digits = 5, caption = "Imported conjoint part-worth utilities")| attribute | level_id | level_label | utility | source_utility_row |
|---|---|---|---|---|
| Number of Games | 1 | 3-game create-your-own pack, including one elite team and two very good teams | 0.03257 | 3 |
| Number of Games | 2 | 6-game create-your-own pack, including two elite teams and four very good teams | 0.24383 | 4 |
| Number of Games | 3 | 10-game create-your-own pack, including any combination of teams. | -0.27640 | 5 |
| Ticket Price | 1 | $15 per seat per game | 0.65646 | 7 |
| Ticket Price | 2 | $25 per seat per game | 0.22011 | 8 |
| Ticket Price | 3 | $35 per seat per game | 0.12600 | 9 |
| Ticket Price | 4 | $60 per seat per game | -1.00257 | 10 |
| Ticket Location | 1 | 300 level, behind the baskets | -0.73169 | 12 |
| Ticket Location | 2 | 300 level, on the corners | -0.43716 | 13 |
| Ticket Location | 3 | 300 level, midcourt | 0.15736 | 14 |
| Ticket Location | 4 | 200 level, midcourt | 1.01148 | 15 |
| Promotional Item | 1 | Priority for home playoff tickets | 0.12511 | 17 |
| Promotional Item | 2 | Hot dog and soda with each ticket | 0.17428 | 18 |
| Promotional Item | 3 | Trail Blazer apparel (hat, jersey, etc.) | 0.00158 | 19 |
| Promotional Item | 4 | $20 gift certificate for popular local restaurant | 0.01689 | 20 |
| Promotional Item | 5 | No promotional item | -0.31786 | 21 |
Interpretation. Positive part-worths indicate a level with higher relative utility under the table’s coding convention; negative part-worths indicate lower relative utility. A negative number is not a negative probability or a monetary loss. Meaningful comparisons are differences between levels and total utility for specified package profiles. Utility levels should not be compared as standalone dollar amounts.
The Product Analysis sheet stores its attribute-level
input table in columns B–E. This is distinct from its wide
product-combination calculation area. We read only the compact input
range, match each level to the utility table, and check that the utility
value agrees across the two sheets. The matching uses attribute
+ utility, which is unique in this workbook; the matched source
labels remain visible for auditing.
raw_product <- readxl::read_excel(input_path, sheet = "Product Analysis",
range = "B1:E17", col_names = FALSE, col_types = "text", .name_repair = "minimal")
product_inputs <- data.frame(
attribute_raw = clean_text(raw_product[[1]]),
product_level = clean_text(raw_product[[2]]),
utility_product = num(raw_product[[3]]),
cost_usd = num(raw_product[[4]]),
source_product_row = seq_len(nrow(raw_product)), stringsAsFactors = FALSE) |>
dplyr::filter(is.finite(utility_product)) |>
dplyr::mutate(attribute = dplyr::case_when(
tolower(attribute_raw) == "number of games" ~ "Number of Games",
tolower(attribute_raw) == "ticket price" ~ "Ticket Price",
tolower(attribute_raw) == "ticket location" ~ "Ticket Location",
tolower(attribute_raw) == "promotional item" ~ "Promotional Item",
TRUE ~ NA_character_))
check(nrow(product_inputs) == 16L && !anyNA(product_inputs$attribute),
"The Product Analysis input table must contain 16 recognized attribute levels.")
# Exact utility matching is used only as an alignment key; values are checked below.
match_key <- function(attribute, utility) paste(attribute, sprintf("%.5f", utility), sep = "||")
key_utility <- match_key(partworth$attribute, partworth$utility)
key_product <- match_key(product_inputs$attribute, product_inputs$utility_product)
check(!anyDuplicated(key_utility) && !anyDuplicated(key_product),
"Utility matching is not unique within an attribute.")
idx <- match(key_utility, key_product)
check(!anyNA(idx) && length(unique(idx)) == 16L,
"Utility levels could not be matched one-to-one between source worksheets.")
partworth$product_level <- product_inputs$product_level[idx]
partworth$cost_usd <- product_inputs$cost_usd[idx]
partworth$source_product_row <- product_inputs$source_product_row[idx]
partworth$level_numeric <- ifelse(partworth$attribute %in% c("Number of Games", "Ticket Price"),
num(partworth$product_level), NA_real_)
check(all(abs(partworth$utility - product_inputs$utility_product[idx]) < 1e-9),
"Part-worth utilities disagree between source sheets.")
knitr::kable(partworth[, c("attribute", "product_level", "utility", "cost_usd")],
digits = 5, caption = "Aligned utility estimates and cost inputs")| attribute | product_level | utility | cost_usd |
|---|---|---|---|
| Number of Games | 3 | 0.03257 | NA |
| Number of Games | 6 | 0.24383 | NA |
| Number of Games | 10 | -0.27640 | NA |
| Ticket Price | 15 | 0.65646 | NA |
| Ticket Price | 25 | 0.22011 | NA |
| Ticket Price | 35 | 0.12600 | NA |
| Ticket Price | 60 | -1.00257 | NA |
| Ticket Location | 300 level, behind the baskets | -0.73169 | 10.00 |
| Ticket Location | 300 level, on the corners | -0.43716 | 12.00 |
| Ticket Location | 300 level, midcourt | 0.15736 | 18.00 |
| Ticket Location | 200 level, midcourt | 1.01148 | 40.00 |
| Promotional Item | Priority for home playoff tickets | 0.12511 | 0.00 |
| Promotional Item | Hot dog and soda with each ticket | 0.17428 | 3.25 |
| Promotional Item | Trail Blazer apparel (hat, jersey, etc.) | 0.00158 | 12.00 |
| Promotional Item | $20 gift certificate for popular local restaurant | 0.01689 | 10.00 |
| Promotional Item | No promotional item | -0.31786 | 0.00 |
Interpretation. The cost_usd field is
populated for seat locations and promotional items; missing costs for
game-count and ticket-price levels mean not applicable,
not zero cost. Ticket price is revenue per seat per game, whereas the
reported cost values have their own accounting definitions. They should
not be combined into a package-profit figure without specifying the
number of games and cost frequency.
Validation covers the experimental design, numerical values, consistency across worksheets, and the expected price ordering. The zero-sum check is included as a descriptive check on the supplied effect-centered utility scores; it does not identify or validate the unknown original estimation algorithm.
expected_counts <- c("Number of Games" = 3L, "Ticket Price" = 4L,
"Ticket Location" = 4L, "Promotional Item" = 5L)
counts <- table(factor(partworth$attribute, levels = names(expected_counts)))
price <- partworth |> dplyr::filter(attribute == "Ticket Price") |>
dplyr::arrange(level_numeric)
games <- partworth |> dplyr::filter(attribute == "Number of Games") |>
dplyr::arrange(level_numeric)
location <- partworth |> dplyr::filter(attribute == "Ticket Location")
promotion <- partworth |> dplyr::filter(attribute == "Promotional Item")
validation <- data.frame(
test = c("Expected source sheets present", "Four attributes and 16 levels",
"Expected number of levels per attribute", "No missing or non-finite utilities",
"Unique attribute/level labels", "One-to-one alignment across source sheets",
"Game counts: 3, 6, 10", "Ticket prices: 15, 25, 35, 60",
"Price utility decreases with price", "Seat and promotion costs are nonnegative and complete",
"No cost entries assigned to game-count or ticket-price levels",
"Attribute-level utility sums approximately zero"),
passed = c(all(expected_sheets %in% actual_sheets),
nrow(partworth) == 16L && length(unique(partworth$attribute)) == 4L,
identical(as.integer(counts), as.integer(expected_counts)),
all(is.finite(partworth$utility)),
!anyDuplicated(partworth[c("attribute", "level_label")]),
length(unique(idx)) == 16L,
identical(as.numeric(games$level_numeric), c(3, 6, 10)),
identical(as.numeric(price$level_numeric), c(15, 25, 35, 60)),
all(diff(price$utility) < 0),
all(is.finite(c(location$cost_usd, promotion$cost_usd))) &&
all(c(location$cost_usd, promotion$cost_usd) >= 0),
all(is.na(partworth$cost_usd[partworth$attribute %in% c("Number of Games", "Ticket Price")])),
all(abs(tapply(partworth$utility, partworth$attribute, sum)) < 1e-4)))
knitr::kable(validation, caption = "Input quality and consistency checks")| test | passed |
|---|---|
| Expected source sheets present | TRUE |
| Four attributes and 16 levels | TRUE |
| Expected number of levels per attribute | TRUE |
| No missing or non-finite utilities | TRUE |
| Unique attribute/level labels | TRUE |
| One-to-one alignment across source sheets | TRUE |
| Game counts: 3, 6, 10 | TRUE |
| Ticket prices: 15, 25, 35, 60 | TRUE |
| Price utility decreases with price | TRUE |
| Seat and promotion costs are nonnegative and complete | TRUE |
| No cost entries assigned to game-count or ticket-price levels | TRUE |
| Attribute-level utility sums approximately zero | TRUE |
Validation result. 12 of 12 checks passed. The dataset contains 16 validated levels across 4 attributes. The price levels are correctly ordered and their part-worth utilities decrease as price rises. This is an input and computational consistency assessment, not evidence of predictive validity or respondent-level model fit.
The figure shows the relative desirability of each level within its attribute. Faceting prevents different categories from being interpreted as one continuous numerical scale.
partworth_plot <- ggplot(partworth,
aes(x = reorder(level_label, utility), y = utility)) +
geom_col(fill = "#2F617B", width = 0.72) +
coord_flip() + facet_wrap(~attribute, scales = "free_y", ncol = 2) +
geom_hline(yintercept = 0, colour = "grey45", linewidth = 0.3) +
labs(x = NULL, y = "Part-worth utility") +
theme_minimal(base_size = 11) + theme(strip.text = element_text(face = "bold"))
print(partworth_plot)Conjoint part-worth utilities by attribute level
Results and interpretation. Within the number-of-games attribute, the six-game package has the highest utility (0.24383), while the ten-game option has the lowest (-0.27640). This indicates that preference is not monotonic in package size: offering more games does not necessarily increase attractiveness. Within seat locations, 200-level midcourt has the highest utility, but its minimum allowed price is $60. The hot dog-and-soda option has the highest promotional utility, whereas no promotional item has the lowest. These patterns describe the supplied utility estimates; they do not by themselves establish sales volume or profitability.
The range of an attribute is the difference between its largest and smallest part-worths. Dividing each range by the sum of the four ranges produces a descriptive relative-importance measure:
\[ I_a = \frac{\max_{\ell}(u_{a\ell}) - \min_{\ell}(u_{a\ell})} {\sum_b [\max_{\ell}(u_{b\ell}) - \min_{\ell}(u_{b\ell})]}. \]
This measure depends on the levels selected for each attribute. It should not be interpreted as a universal or causal measure of what matters to all fans.
importance <- partworth |>
dplyr::group_by(attribute) |>
dplyr::summarise(n_levels = dplyr::n(), min_utility = min(utility),
max_utility = max(utility), utility_range = max(utility) - min(utility),
utility_sum = sum(utility), .groups = "drop") |>
dplyr::mutate(relative_importance = utility_range / sum(utility_range)) |>
dplyr::arrange(desc(relative_importance))
knitr::kable(importance, digits = 5, caption = "Utility ranges and relative attribute importance")| attribute | n_levels | min_utility | max_utility | utility_range | utility_sum | relative_importance |
|---|---|---|---|---|---|---|
| Ticket Location | 4 | -0.73169 | 1.01148 | 1.74317 | -1e-05 | 0.39487 |
| Ticket Price | 4 | -1.00257 | 0.65646 | 1.65903 | 0e+00 | 0.37581 |
| Number of Games | 3 | -0.27640 | 0.24383 | 0.52023 | 0e+00 | 0.11784 |
| Promotional Item | 5 | -0.31786 | 0.17428 | 0.49214 | 0e+00 | 0.11148 |
importance_plot <- ggplot(importance, aes(x = reorder(attribute, relative_importance),
y = relative_importance)) +
geom_col(fill = "#2F617B", width = 0.65) + coord_flip() +
scale_y_continuous(labels = scales::percent_format(accuracy = 1),
limits = c(0, max(importance$relative_importance) * 1.18)) +
labs(x = NULL, y = "Share of total utility range") + theme_minimal(base_size = 11)
print(importance_plot)Relative attribute importance based on utility ranges
Results and interpretation. The two leading attributes are ticket location (39.49%) and ticket price (37.58%). Number of games and promotional items contribute smaller shares of the total observed utility range. However, a low promotional-attribute range does not imply that all giveaways are uneconomic; that requires comparing the incremental utility relative to no promotion with the actual cost of each benefit, as developed in Module 3.
Ticket price is measured in dollars per seat per game. Plotting price on its actual numeric scale, rather than at equally spaced category positions, makes differences in price sensitivity visible.
price_steps <- data.frame(
price_from = head(price$level_numeric, -1),
price_to = tail(price$level_numeric, -1),
utility_change = diff(price$utility),
utility_per_dollar = diff(price$utility) / diff(price$level_numeric))
knitr::kable(price_steps, digits = 6, caption = "Adjacent price-level utility changes")| price_from | price_to | utility_change | utility_per_dollar |
|---|---|---|---|
| 15 | 25 | -0.43635 | -0.043635 |
| 25 | 35 | -0.09411 | -0.009411 |
| 35 | 60 | -1.12857 | -0.045143 |
price_plot <- ggplot(price, aes(x = level_numeric, y = utility)) +
geom_line(color = "#2F617B", linewidth = 0.85) +
geom_point(color = "#2F617B", size = 2.8) +
scale_x_continuous(breaks = price$level_numeric) +
labs(x = "Ticket price (USD per seat per game)", y = "Part-worth utility") +
theme_minimal(base_size = 11)
print(price_plot)Price part-worths at their observed dollar levels
Results and interpretation. Higher prices consistently reduce utility, but the slope is not constant. Between $15 and $25, utility changes by -0.43635; between $25 and $35, by -0.09411; and between $35 and $60, by -1.12857. A single dollar-per-utility conversion would conceal these differences. Module 3 will therefore compare clearly defined local-slope and piecewise-price approaches to WTP rather than assuming one globally linear price effect.
The cost schedule provides the managerial side of the package-design problem. Seat-location costs are allocated amounts specified by the case; promotional items carry direct reported costs. They are displayed in separate panels because their economic interpretations and potential units differ.
costs <- partworth |>
dplyr::filter(attribute %in% c("Ticket Location", "Promotional Item")) |>
dplyr::select(attribute, level_label, cost_usd, utility)
knitr::kable(costs, digits = 5, caption = "Reported seat-location and promotional-item costs")| attribute | level_label | cost_usd | utility |
|---|---|---|---|
| Ticket Location | 300 level, behind the baskets | 10.00 | -0.73169 |
| Ticket Location | 300 level, on the corners | 12.00 | -0.43716 |
| Ticket Location | 300 level, midcourt | 18.00 | 0.15736 |
| Ticket Location | 200 level, midcourt | 40.00 | 1.01148 |
| Promotional Item | Priority for home playoff tickets | 0.00 | 0.12511 |
| Promotional Item | Hot dog and soda with each ticket | 3.25 | 0.17428 |
| Promotional Item | Trail Blazer apparel (hat, jersey, etc.) | 12.00 | 0.00158 |
| Promotional Item | $20 gift certificate for popular local restaurant | 10.00 | 0.01689 |
| Promotional Item | No promotional item | 0.00 | -0.31786 |
cost_plot <- ggplot(costs, aes(x = reorder(level_label, cost_usd), y = cost_usd)) +
geom_col(fill = "#2F617B", width = 0.7) + coord_flip() +
facet_wrap(~attribute, scales = "free_y", ncol = 1) +
labs(x = NULL, y = "Reported cost (USD)") + theme_minimal(base_size = 11)
print(cost_plot)Reported seat-location and promotional-item costs (USD; accounting units require care)
Results and interpretation. The 200-level midcourt seat has the highest reported seat-cost allocation ($40.00). Apparel has the highest promotional cost ($12.00). Playoff-ticket priority and no promotion have zero reported direct promotional cost. A zero direct cost is not a claim that the option has no opportunity cost or operational consequences. The financial analysis must distinguish reported cost allocations from incremental costs and must state how a promotion is delivered across a multigame package.
With four attributes, the full factorial design contains 240 possible combinations before pricing restrictions. For each number of games, two of the four seat locations allow all four prices, 300-level midcourt allows three, and 200-level midcourt allows only $60. The feasible design therefore contains 3 × (4 + 4 + 3 + 1) × 5 = 180 profiles. Module 4 will generate these combinations explicitly and verify their utilities and costs.
design_summary <- data.frame(
metric = c("Attributes", "Attribute levels", "Full factorial profiles", "Feasible profiles after seat-price rules"),
count = c(length(counts), sum(counts), prod(as.integer(counts)), 3 * (4 + 4 + 3 + 1) * 5))
knitr::kable(design_summary, caption = "Design-space overview")| metric | count |
|---|---|
| Attributes | 4 |
| Attribute levels | 16 |
| Full factorial profiles | 240 |
| Feasible profiles after seat-price rules | 180 |
Interpretation. The 180 feasible profiles are possible product configurations, not 180 survey respondents, observed purchases, or independent choice-task observations. Their role is to define the management decision set for later analysis.
The following files make the remaining modules reproducible. They are generated from the source workbook at knit time; no utility estimates or cost amounts are manually substituted in the analysis code.
utils::write.csv(partworth,
file.path(project_root, "data", "processed", "partworth_levels.csv"), row.names = FALSE, na = "")
utils::write.csv(costs,
file.path(project_root, "data", "processed", "attribute_costs.csv"), row.names = FALSE, na = "")
save_table(validation, "01_data_validation.csv")
save_table(importance, "01_attribute_importance.csv")
save_table(price_steps, "01_price_utility_intervals.csv")
save_table(design_summary, "01_design_summary.csv")
outputs <- data.frame(
category = c(rep("Processed data", 2), rep("Table", 4), rep("Figure", 4)),
file = c("data/processed/partworth_levels.csv", "data/processed/attribute_costs.csv",
"outputs/tables/01_data_validation.csv", "outputs/tables/01_attribute_importance.csv",
"outputs/tables/01_price_utility_intervals.csv", "outputs/tables/01_design_summary.csv",
"outputs/figures/01_partworth_utilities.png", "outputs/figures/01_attribute_importance.png",
"outputs/figures/01_price_utility.png", "outputs/figures/01_attribute_costs.png"))
knitr::kable(outputs, caption = "Module 1 output manifest")| category | file |
|---|---|
| Processed data | data/processed/partworth_levels.csv |
| Processed data | data/processed/attribute_costs.csv |
| Table | outputs/tables/01_data_validation.csv |
| Table | outputs/tables/01_attribute_importance.csv |
| Table | outputs/tables/01_price_utility_intervals.csv |
| Table | outputs/tables/01_design_summary.csv |
| Figure | outputs/figures/01_partworth_utilities.png |
| Figure | outputs/figures/01_attribute_importance.png |
| Figure | outputs/figures/01_price_utility.png |
| Figure | outputs/figures/01_attribute_costs.png |
The validated data show that seat location and price account for the largest relative utility ranges, while six-game packages are preferred to both three-game and ten-game packages at the supplied utility levels. Promotional preferences also differ substantially across items; they must be evaluated against their costs, not dismissed because their overall importance is lower. The price–utility relationship is downward sloping but nonlinear across the observed price levels, an important consideration for willingness-to-pay estimates.
The next modules will use these inputs to calculate additive package utilities, evaluate incremental attribute values, and compare feasible ticket configurations. Product utility is not equivalent to purchase probability or profit. Without respondent-level choices, a calibrated outside option, and a defensible demand model, the project cannot infer actual sales or total-profit-maximizing packages from the part-worth table alone.
Portland Trail Blazers_Keman_Xiang.xlsx.
Primary numerical input for this analysis, including
Table 1. Utility score data. and
Product Analysis.