Standard Price Elasticity
Executive Summary

Executive Summary

Price elasticity of Total Volume with respect to AveragePrice

Price Elasticity
-1.184
CI Lower (95%)
-1.265
CI Upper (95%)
-1.103
R Squared
0.702
Observations
18249
Demand Class
elastic
The estimated price elasticity of Total Volume with respect to AveragePrice is -1.18 (95% CI -1.26 to -1.10, p<0.001): a 1% increase in AveragePrice is associated with a 1.18% decrease in Total Volume (95% CI 1.10% to 1.26% 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: AveragePrice 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.
What this means

The short answer

Avocado demand is elastic: a 1% price increase is associated with a 1.18% decrease in volume (95% CI 1.10% to 1.26%). This means revenue moves in the opposite direction from price — price cuts are consistent with higher revenue in the observed data.

The detail

Estimated price elasticity: −1.184 (95% CI −1.265 to −1.103, p<0.001). The confidence interval excludes −1.0, confirming elastic demand at conventional significance levels across 18,249 observations. The model explains 70.2% of variation in log volume. Because prices were observed rather than randomized, this elasticity is associational: seasonality, promotions, and cost-driven repricing can move price and volume together in historical data in ways a controlled experiment might not replicate.

What this can't tell you

The historical association does not guarantee the causal effect of a price change. A pricing experiment would be needed to confirm whether cutting prices would actually increase revenue in practice.

Overview

Analysis Overview

Log-log OLS elasticity of Total Volume with respect to AveragePrice on 18,249 observations.

N Observations18249
N Controls2
Elasticity-1.184
R Squared0.702
What this means

The short answer

Avocado demand is price-sensitive: a 1% increase in price is associated with a 1.184% decrease in quantity sold. This elastic demand means price cuts are associated with higher total revenue in the observed data. The pattern holds across 18,249 observations after accounting for type and region differences.

The detail

The analysis uses log-log ordinary least squares regression on 18,249 observations. The elasticity coefficient is −1.184, meaning demand exceeds unit elastic. The model explains 70.2% of the variation in log Total Volume. Because AveragePrice was observed rather than randomized, the −1.184 slope describes association in historical data: it reflects how quantity moved together with price across different types and regions, holding those factors constant. The elasticity magnitude of 1.184 exceeds 1, classifying demand as elastic.

What this can't tell you

The observed prices were not set experimentally, so the −1.184 elasticity describes historical association, not the guaranteed revenue outcome of a specific price change. Generalization to future or counterfactual prices depends on whether the relationship remains stable. A longer time window or transaction-level data would clarify whether elasticity varies by season or market segment.

Data Preparation

Data Quality

Row filtering for the log transform, and control handling.

Initial Rows18249
Final Rows18249
Rows Removed0
Rows Nonpositive0
Rows Missing0
What this means

The short answer

All 18,249 rows in the dataset had positive prices and volumes, so no rows were dropped. Every observation was eligible for the log transform and included in the regression, ensuring the full sample was used.

The detail

Initial rows loaded: 18,249; final rows used: 18,249; rows removed: 0. No observations had zero or negative AveragePrice or Total Volume. Categorical controls (type, region) were lumped to at most 8 levels; numeric controls were median-imputed. No missing values required exclusion.

What this can't tell you

Data quality checks confirm structural completeness for the log-log regression but do not assess whether the price and volume measurements themselves are accurate or whether the data capture the full transaction universe (e.g., direct-to-consumer sales not in the dataset).

Visualization

Demand Curve

Median Total Volume at each AveragePrice decile.

What this means

The short answer

The demand curve slopes sharply downward across the observed price range (0.44 to 3.25), with median volume falling from 858,069.55 at the lowest price decile to 9,529.15 at the highest. The smooth, consistent decline supports the constant-elasticity model: there are no kinks or flat stretches that would suggest different price regimes.

The detail

Each of the 10 price deciles shows a median volume point. At AveragePrice = 0.83, median Total Volume = 858,069.55; at AveragePrice = 2.12, it = 9,529.15. Intermediate deciles show a steady downward progression (e.g., 335,108.11 at 1.00, 117,579.21 at 1.32, 24,059.71 at 1.54). The smooth pattern across all deciles is consistent with a single elasticity coefficient rather than a piecewise or kinked relationship.

What this can't tell you

The decile medians mask within-decile variation and do not reveal whether individual transactions cluster tightly around the median or scatter widely. A transaction-level export would show the full distribution and identify any outlier subgroups.

Data Table

Elasticity Estimates

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

TermEstimateCI LowCI HighP ValueInterpretation
log(AveragePrice)-1.184-1.265-1.103<0.001A 1% increase in AveragePrice is associated with a 1.18% decrease in Total Volume.
type: organic-3.23-3.277-3.184<0.001Rows at this level show about 96.0% lower Total Volume than the baseline level, holding AveragePrice constant.
region: Atlanta1.4791.291.669<0.001Rows at this level show about 339.1% higher Total Volume than the baseline level, holding AveragePrice constant.
region: BaltimoreWashington2.2332.0452.422<0.001Rows at this level show about 833.2% higher Total Volume than the baseline level, holding AveragePrice constant.
region: Boise-0.201-0.391-0.0120.038Rows at this level show about 18.2% lower Total Volume than the baseline level, holding AveragePrice constant.
region: Boston1.7371.5481.926<0.001Rows at this level show about 468.3% higher Total Volume than the baseline level, holding AveragePrice constant.
region: BuffaloRochester0.5320.3440.721<0.001Rows at this level show about 70.3% higher Total Volume than the baseline level, holding AveragePrice constant.
region: California4.0933.9044.283<0.001Rows at this level show about 5,895% higher Total Volume than the baseline level, holding AveragePrice constant.
region: Charlotte1.0110.8221.2<0.001Rows at this level show about 174.9% higher Total Volume than the baseline level, holding AveragePrice constant.
region: Other1.7441.6091.88<0.001Rows at this level show about 472.1% higher Total Volume than the baseline level, holding AveragePrice constant.
What this means

The short answer

The price elasticity of −1.184 (95% CI −1.265 to −1.103, p<0.001) is the decisive row: it directly answers the question of price sensitivity. Both bounds are below −1, confirming elastic demand. Type and region controls are all statistically significant (all p<0.001 except Boise at p=0.038) and show large volume differences across product lines and geographies, but they do not change the price elasticity.

The detail

log(AveragePrice) coefficient: −1.184, CI [−1.265, −1.103], p<0.001. Type organic: −3.23 (p<0.001), meaning organic rows show about 96.0% lower volume than conventional, holding price constant. Region controls range from −0.201 (Boise, p=0.038) to +4.093 (California, p<0.001), reflecting large regional demand differences. All region and type estimates are significant at the 0.05 level, confirming systematic geographic and product-line effects.

What this can't tell you

The controls explain regional and type differences but do not clarify which factors drive them (population, climate, distribution infrastructure). Finer-grained controls (e.g., store count, local income) would sharpen understanding of regional leverage.

Data Table

Fit and Classification

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

MetricValueInterpretation
R-squared (log-log fit)0.702The model explains a strong share of the variation in log Total Volume.
Observations used18,249Rows with positive AveragePrice and Total Volume 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.
What this means

The short answer

The model fits well: it explains 70.2% of the variation in log Total Volume across all 18,249 rows. Demand is classified as elastic, and the data are consistent with a regime where price cuts increase revenue—though this is an association, not proof of a causal effect.

The detail

R-squared is 0.702, a strong fit for a cross-sectional relationship. All 18,249 observations had positive price and quantity; 0 rows were dropped. The elasticity classification is elastic because the magnitude (1.184) exceeds 1. The sign of 1 plus the elasticity (1 + (−1.184) = −0.184) is negative, which implies that in the historical data, price cuts are associated with revenue increases. This reading is associational because prices were observed, not randomized.

What this can't tell you

Fit quality does not establish causation: the 0.702 R-squared confirms the log-log model captures much of the historical relationship, but does not prove that lowering prices will mechanically raise revenue in a future setting. Seasonal or market-segment variation in elasticity would not appear in a single aggregate coefficient. Consider a segment-level export to test whether elasticity is stable across avocado types or regions.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

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 — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

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