Standard Price Elasticity
Executive Summary

Executive Summary

Price elasticity of Units Sold with respect to Unit Price

Price Elasticity
-1.829
CI Lower (95%)
-1.899
CI Upper (95%)
-1.758
R Squared
0.87
Observations
400
Demand Class
elastic
The estimated price elasticity of Units Sold with respect to Unit Price is -1.83 (95% CI -1.90 to -1.76, p<0.001): a 1% increase in Unit Price is associated with a 1.83% decrease in Units Sold (95% CI 1.76% to 1.90% decrease). Demand is elastic. Because 1 plus the elasticity is negative, the data is consistent with a regime where price cuts increase revenue. Important caveat: Unit Price was not randomly assigned in this data, so this elasticity describes an association in historical records — promotions, seasonality, and cost-driven repricing can move price and demand together — not the guaranteed causal effect of a price change.
Interpretation

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.

Overview

Analysis Overview

Log-log OLS elasticity of Units Sold with respect to Unit Price on 400 observations.

N Observations400
N Controls1
Elasticity-1.829
R Squared0.87
Interpretation

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 Preparation

Data Quality

Row filtering for the log transform, and control handling.

Initial Rows400
Final Rows400
Rows Removed0
Rows Nonpositive0
Rows Missing0
Interpretation

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.

Visualization

Demand Curve

Median Units Sold at each Unit Price decile.

Interpretation

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.

Data Table

Elasticity Estimates

The log-log regression coefficients with 95% confidence intervals.

TermEstimateCI LowCI HighP ValueInterpretation
log(Unit Price)-1.829-1.899-1.758<0.001A 1% increase in Unit Price is associated with a 1.83% decrease in Units Sold.
Promo Flag0.2170.1540.28<0.001Each one-step increase is associated with about 24.2% higher Units Sold, holding Unit Price constant.
Interpretation

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.

Data Table

Fit and Classification

Model fit, the elasticity classification, and the revenue direction.

MetricValueInterpretation
R-squared (log-log fit)0.870The model explains a strong share of the variation in log Units Sold.
Observations used400Rows with positive Unit Price and Units Sold used in the regression.
Rows dropped (nonpositive or missing)0No rows were dropped — every row had positive price and quantity.
Elasticity classificationelasticThe magnitude of the elasticity exceeds 1: demand responds more than proportionally to price.
Revenue direction (associational)price cuts increase revenueRead from the sign of 1 plus the elasticity; associational because prices were not randomized.
Interpretation

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

Methodology

Statistical methodology and diagnostics for Price Elasticity of Demand

Statistical Method

Price Elasticity of Demand

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).

Data
N = 400 observations
Assumptions
  • 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
Limitations
  • 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
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

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) <- NULL

Step 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) <- NULL

Step 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
  )
}
Your data has more stories to tell. Run any analysis on your own data — 60+ validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing