Executive Summary
Price elasticity of Units Sold with respect to Unit Price
The estimated price elasticity is -1.829 (95% CI -1.899 to -1.758, p<0.001): a 1% price increase is associated with a 1.83% decrease in Units Sold. Demand is elastic (magnitude exceeds 1), and the data is consistent with a regime where price cuts increase revenue. However, Unit Price was observed rather than randomized, so this elasticity describes historical association—confounding from promotions, seasonality, and cost-driven repricing may move price and demand together—rather than the guaranteed causal effect of a price change.
Analysis Overview
Log-log OLS elasticity of Units Sold with respect to Unit Price on 400 observations.
The analysis estimates price elasticity of demand using log-log OLS regression on 400 observations. On the log scale, the slope coefficient directly equals the price elasticity: the percent change in Units Sold per 1% change in Unit Price. The regression controls for Promo Flag, isolating price variation from promotional confounding. The estimated elasticity of -1.829 reflects association in observed historical data—since Unit Price was not randomized, it captures correlational patterns rather than causal effects of price manipulation. The R-squared of 0.87 indicates the log-log model fits strongly.
Data Quality
Row filtering for the log transform, and control handling.
All 400 rows loaded were retained; zero rows were dropped for nonpositive or missing values. Every observation had positive Unit Price and Units Sold, satisfying the requirements for log transformation. Promo Flag was included as a numeric control with median imputation applied where needed. No rows were removed during filtering, and no categorical controls were lumped—the dataset entered the regression complete and ready for analysis.
Demand Curve
Median Units Sold at each Unit Price decile.
The observed demand curve slopes downward consistently across all ten price deciles. Median Units Sold falls from 3,441.50 at the lowest price decile (Unit Price 9.7) to 236.00 at the highest (Unit Price 39.7). The smooth, monotonic decline across the full price range from 3.76 to 56.46 supports the constant-elasticity interpretation: no kinks or flat stretches appear, suggesting the single elasticity number -1.829 adequately summarizes the price–quantity relationship across the observed data.
Elasticity Estimates
The log-log regression coefficients with 95% confidence intervals.
| Term | Estimate | CI Low | CI High | P Value | Interpretation |
|---|---|---|---|---|---|
| log(Unit Price) | -1.829 | -1.899 | -1.758 | <0.001 | A 1% increase in Unit Price is associated with a 1.83% decrease in Units Sold. |
| Promo Flag | 0.217 | 0.154 | 0.28 | <0.001 | Each one-step increase is associated with about 24.2% higher Units Sold, holding Unit Price constant. |
The price elasticity coefficient is -1.829 (95% CI -1.899 to -1.758), with p<0.001, indicating strong statistical significance. The confidence interval is tight and entirely below -1, confirming elastic demand. Promo Flag carries an estimate of 0.217 (95% CI 0.154 to 0.28, p<0.001), meaning each one-step increase in promotional status is associated with approximately 24.2% higher Units Sold while holding Unit Price constant. Both estimates are statistically significant, and the narrow CI on price elasticity reflects precise estimation from the 400-observation sample.
Fit and Classification
Model fit, the elasticity classification, and the revenue direction.
| Metric | Value | Interpretation |
|---|---|---|
| R-squared (log-log fit) | 0.870 | The model explains a strong share of the variation in log Units Sold. |
| Observations used | 400 | Rows with positive Unit Price and Units Sold used in the regression. |
| Rows dropped (nonpositive or missing) | 0 | No rows were dropped — every row had positive price and quantity. |
| Elasticity classification | elastic | The magnitude of the elasticity exceeds 1: demand responds more than proportionally to price. |
| Revenue direction (associational) | price cuts increase revenue | Read from the sign of 1 plus the elasticity; associational because prices were not randomized. |
The log-log regression achieves an R-squared of 0.870, explaining a strong 87.0% of variation in log Units Sold across all 400 observations. No rows were dropped; every observation had positive price and quantity. Demand is classified as elastic (elasticity magnitude -1.829 exceeds 1), and the sign of 1 plus the elasticity (negative) is consistent with a regime where price cuts increase revenue. This classification and revenue direction are associational readings from historical data, not causal conclusions from a pricing experiment.
Methodology
Statistical methodology and diagnostics for Price Elasticity of Demand
Statistical Method
Standard-library analysis: how sensitive is demand to price? A log-log OLS regression of quantity on price estimates the price elasticity of demand from your transactional or aggregated sales rows, with a 95% confidence interval, an elastic/inelastic classification, the revenue direction the data is consistent with, and a 10-point demand curve. Optionally hold other factors constant (promo flag, season, region).
- Demand follows an approximately constant-elasticity (log-log linear) relationship over the observed price range
- Price and quantity are positive (rows with zero or negative values are excluded)
- Mapped controls capture the main confounders you care about
- Prices were not randomized: the elasticity is associational, not the guaranteed causal effect of a price change
- Omitted factors that move price and demand together (demand shocks, cost-based repricing) can bias the estimate
- The estimate applies within the observed price range; extrapolating far outside it is unreliable
Analysis Code
Complete R source code for this analysis
Price Elasticity of Demand — How Price-Sensitive Are Your Customers?
Estimates the price elasticity of demand from transactional or aggregated rows via log-log OLS: regress log(quantity) on log(price) plus optional controls. The slope on log(price) is the elasticity — the percent change in demand associated with a 1% change in price.
Why This Method?
On the log-log scale a constant-elasticity demand curve is a straight line, so one OLS slope answers the pricing question directly, with a confidence interval and a significance test. Comparing the magnitude of the elasticity to 1 gives the revenue direction: elastic demand means price cuts raise revenue, inelastic demand means price increases do.
What This Analysis Covers
- Elasticity estimate with 95% CI and significance
- Elastic / inelastic classification (CI-aware)
- Associational revenue direction (dRev/dP sign = 1 + elasticity)
- Ten-decile observed demand curve
- Control-variable adjustments (promo, season, region, ...)
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {price, quantity, control_1..control_5}. All narrative is derived from the user's own column names and computed values. Prices in user data are observed, not randomized — every conclusion is phrased as association, not causation.
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
# rows_nonpositive / rows_missing $ why rows were dropped
# h_price / h_quantity $ humanized column names
# used_controls / dropped_controls / ctrl_names $ control accounting
# elasticity / ci_low / ci_high / p_value $ slope on log(price)
# r2 / n_used $ fit quality
# demand_class $ "elastic"|"inelastic"|"unit elastic"
# unit_in_ci / zero_in_ci $ CI-aware qualifiers
# rev_direction $ sign(1+e) revenue clause
# price_min / price_max $ observed price range
# decile_df $ data.frame(price_level, median_quantity)
# results_df $ elasticity_results table
# fitq_df $ fit_quality table
# metrics / json_output
# === /SHARED EXPORTS ===Step 1: Locate and coerce the price and quantity columns
initial_rows <- nrow(df)
h_price <- humanize_semantic("price", col_map)
h_quantity <- humanize_semantic("quantity", col_map)
h_by_key <- c(price = h_price, quantity = h_quantity)
for (key in c("price", "quantity")) {
if (!key %in% names(df)) {
stop(sprintf("The %s column(%s) was not found in the mapped data.",
key, h_by_key[[key]]))
}
v <- df[[key]]
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & as.character(v) != "")
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
df[[key]] <- conv
} else {
stop(sprintf("The %s column(%s) must be numeric — fewer than 95%% of its values could be read as numbers.",
key, h_by_key[[key]]))
}
}
}Step 2: Drop rows the logarithm cannot use (nonpositive or missing)
p <- df$price
q <- df$quantity
rows_missing <- sum(is.na(p) | is.na(q))
rows_nonpositive <- sum(!is.na(p) & !is.na(q) & (p <= 0 | q <= 0))
keep <- !is.na(p) & !is.na(q) & p > 0 & q > 0
df <- df[keep, , drop = FALSE]
final_rows <- nrow(df)
rows_removed <- initial_rows - final_rows
if (final_rows < 30) {
stop(sprintf("Only %d usable rows with positive %s and %s — price elasticity estimation needs at least 30.",
final_rows, h_price, h_quantity))
}
if (length(unique(df$price)) < 3) {
stop(sprintf("%s takes fewer than 3 distinct positive values — elasticity cannot be estimated without real price variation.",
h_price))
}
price_min <- min(df$price)
price_max <- max(df$price)Step 3: Prepare controls — numeric via the 95% rule + median impute,
categorical lumped to at most 8 levels (+ "Missing"/"Other")
ctrl_cols <- grep("^control_[0-9]+$", names(df), value = TRUE)
ctrl_cols <- ctrl_cols[order(as.integer(sub("^control_", "", ctrl_cols)))]
ctrl_names <- setNames(humanize_semantic(ctrl_cols, col_map), ctrl_cols)
used_controls <- character(0)
dropped_controls <- character(0)
md <- data.frame(log_quantity = log(df$quantity), log_price = log(df$price))
for (cc in ctrl_cols) {
v <- df[[cc]]
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & as.character(v) != "")
is_num <- is.numeric(v) || (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig)
if (is_num) {
nv <- if (is.numeric(v)) v else conv
med <- median(nv, na.rm = TRUE)
if (is.na(med)) { dropped_controls <- c(dropped_controls, cc); next }
nv[is.na(nv)] <- med
if (is.na(var(nv)) || isTRUE(var(nv) == 0)) {
dropped_controls <- c(dropped_controls, cc); next
}
md[[cc]] <- nv
} else {
ch <- as.character(v)
ch[is.na(ch) | trimws(ch) == ""] <- "Missing"
n_lvl <- length(unique(ch))
if (n_lvl < 2 || n_lvl > 0.9 * final_rows) {
# constant or identifier-like — useless as a control
dropped_controls <- c(dropped_controls, cc); next
}
if (n_lvl > 8) {
top <- names(sort(table(ch), decreasing = TRUE))[1:8]
ch[!ch %in% top] <- "Other"
}
md[[cc]] <- factor(ch)
}
used_controls <- c(used_controls, cc)
}Step 4: Fit the log-log OLS and read off the elasticity
fit <- lm(log_quantity ~ ., data = md)
sm <- summary(fit)
co <- sm$coefficients
if (!"log_price" %in% rownames(co) || is.na(co["log_price", 1])) {
stop(sprintf("The price term could not be estimated — check that %s varies independently of the mapped controls.",
h_price))
}
elasticity <- unname(co["log_price", 1])
p_value <- unname(co["log_price", 4])
ci_all <- suppressMessages(confint(fit, level = 0.95))
ci_low <- unname(ci_all["log_price", 1])
ci_high <- unname(ci_all["log_price", 2])
if (is.na(ci_low) || is.na(ci_high)) {
stop(sprintf("The confidence interval for the %s elasticity could not be computed — the model may be collinear.",
h_price))
}
r2 <- unname(sm$r.squared)
n_used <- nrow(md)Step 5: Classify — CI-aware language around unit elasticity
abs_e <- abs(elasticity)
demand_class <- if (abs_e > 1) "elastic" else if (abs_e < 1) "inelastic" else "unit elastic"
unit_in_ci <- if (elasticity < 0) (ci_low <= -1 && ci_high >= -1) else (ci_low <= 1 && ci_high >= 1)
zero_in_ci <- (ci_low <= 0 && ci_high >= 0)
# dRev/dP sign = 1 + elasticity: negative => cutting price raises revenue
rev_direction <- if ((1 + elasticity) < 0) "price cuts increase revenue" else "price increases increase revenue"Step 6: Ten-decile observed demand curve
br <- unique(quantile(df$price, probs = seq(0, 1, by = 0.1), na.rm = TRUE, type = 7))
if (length(br) < 3) br <- unique(range(df$price))
grp <- cut(df$price, breaks = br, include.lowest = TRUE)
agg_p <- tapply(df$price, grp, median)
agg_q <- tapply(df$quantity, grp, median)
ok <- !is.na(agg_p) & !is.na(agg_q)
decile_df <- data.frame(
price_level = round(unname(agg_p[ok]), 2),
median_quantity = round(unname(agg_q[ok]), 2),
stringsAsFactors = FALSE
)
decile_df <- decile_df[order(decile_df$price_level), , drop = FALSE]
rownames(decile_df) <- NULLStep 7: Coefficient table — the log(price) row first, controls after
pretty_term <- function(term) {
if (term == "log_price") return(paste0("log(", h_price, ")"))
for (cc in used_controls) {
if (startsWith(term, cc)) {
lvl <- substring(term, nchar(cc) + 1)
nm <- ctrl_names[[cc]]
return(if (nzchar(lvl)) paste0(nm, ": ", lvl) else nm)
}
}
term
}
interp_term <- function(term, est) {
if (term == "log_price") {
dir_word <- if (est < 0) "decrease" else "increase"
return(sprintf("A 1%% increase in %s is associated with a %s%% %s in %s.",
h_price, fmt_dec(abs(est), 2), dir_word, h_quantity))
}
pct <- 100 * (exp(est) - 1)
dir_word <- if (pct < 0) "lower" else "higher"
pct_txt <- if (abs(pct) >= 1000) fmt_amount(abs(pct), 0) else fmt_dec(abs(pct), 1)
is_level <- {
hit <- FALSE
for (cc in used_controls) if (term != cc && startsWith(term, cc)) hit <- TRUE
hit
}
if (is_level) {
sprintf("Rows at this level show about %s%% %s %s than the baseline level, holding %s constant.",
pct_txt, dir_word, h_quantity, h_price)
} else {
sprintf("Each one-step increase is associated with about %s%% %s %s, holding %s constant.",
pct_txt, dir_word, h_quantity, h_price)
}
}
term_names <- setdiff(rownames(co), "(Intercept)")
term_names <- term_names[!is.na(co[term_names, 1])]
term_names <- c("log_price", setdiff(term_names, "log_price"))
results_df <- do.call(rbind, lapply(term_names, function(tn) {
data.frame(
term = pretty_term(tn),
estimate = round(unname(co[tn, 1]), 3),
ci_low = round(unname(ci_all[tn, 1]), 3),
ci_high = round(unname(ci_all[tn, 2]), 3),
p_value = fmt_p_cell(unname(co[tn, 4])),
interpretation = interp_term(tn, unname(co[tn, 1])),
stringsAsFactors = FALSE
)
}))
rownames(results_df) <- NULLStep 8: Fit-quality table
r2_word <- if (r2 >= 0.6) "strong" else if (r2 >= 0.3) "moderate" else "weak"
class_interp <- if (unit_in_ci) {
sprintf("The point estimate is %s, but the 95%% CI spans an absolute elasticity of 1, so unit elasticity cannot be ruled out.", demand_class)
} else if (abs_e > 1) {
"The magnitude of the elasticity exceeds 1: demand responds more than proportionally to price."
} else {
"The magnitude of the elasticity is below 1: demand responds less than proportionally to price."
}
fitq_df <- data.frame(
metric = c("R-squared(log-log fit)", "Observations used",
"Rows dropped(nonpositive or missing)",
"Elasticity classification", "Revenue direction(associational)"),
value = c(fmt_dec(r2, 3), fmt_count(n_used), fmt_count(rows_removed),
demand_class, rev_direction),
interpretation = c(
sprintf("The model explains a %s share of the variation in log %s.", r2_word, h_quantity),
sprintf("Rows with positive %s and %s used in the regression.", h_price, h_quantity),
if (rows_removed == 0) {
"No rows were dropped — every row had positive price and quantity."
} else {
"Logarithms require strictly positive values, so these rows could not be used."
},
class_interp,
"Read from the sign of 1 plus the elasticity; associational because prices were not randomized."
),
stringsAsFactors = FALSE
)
metrics <- list(
`Price Elasticity` = round(elasticity, 3),
`CI Lower(95%)` = round(ci_low, 3),
`CI Upper(95%)` = round(ci_high, 3),
`R Squared` = round(r2, 3),
`Observations` = n_used,
`Demand Class` = demand_class
)
json_output <- list(
answer = paste0(
"Log-log OLS on ", fmt_count(n_used), " rows: the estimated price elasticity of ",
h_quantity, " with respect to ", h_price, " is ", fmt_dec(elasticity, 2),
" (95% CI ", fmt_dec(ci_low, 2), " to ", fmt_dec(ci_high, 2), ", ", fmt_p(p_value),
"). Demand is ", demand_class,
if (unit_in_ci) ", though the interval spans unit elasticity" else "",
"; the data is consistent with a regime where ", rev_direction,
". Prices were not randomized, so this is an association, not a causal effect."
),
cards = lapply(
c("tldr", "overview", "preprocessing", "demand_curve",
"elasticity_table", "fit_quality"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed,
rows_nonpositive = rows_nonpositive, rows_missing = rows_missing,
h_price = h_price, h_quantity = h_quantity,
used_controls = used_controls, dropped_controls = dropped_controls,
ctrl_names = ctrl_names,
elasticity = elasticity, ci_low = ci_low, ci_high = ci_high,
p_value = p_value, r2 = r2, n_used = n_used,
demand_class = demand_class, unit_in_ci = unit_in_ci,
zero_in_ci = zero_in_ci, rev_direction = rev_direction,
price_min = price_min, price_max = price_max,
decile_df = decile_df, results_df = results_df, fitq_df = fitq_df,
metrics = metrics, json_output = json_output
)
}