43  Pricing Analytics

Note📋 Learning Objectives
  • Quantify the financial impact of price changes vs cost changes vs volume growth on profit
  • Define and estimate price elasticity of demand from observational data
  • Use log-log regression to estimate constant-elasticity demand curves
  • Design and analyse conjoint studies to measure willingness to pay
  • Optimise prices using estimated demand functions and marginal cost
  • Implement dynamic pricing strategies with ethical constraints
  • Build a complete case study for Nigerian telecom data bundle pricing

43.1 Why Pricing Is the Highest-Leverage Business Decision

Pricing is often underestimated as a lever for profitability. A 1% increase in price typically has a far greater impact on profit than a 1% reduction in cost or a 1% increase in volume. This section quantifies the effect and introduces Nigerian pricing context.

43.1.1 Profit Sensitivity to Price, Cost, and Volume

Consider a Nigerian food manufacturer with the following P&L:

Line Item Amount
Revenue ₦100M
COGS (60% margin) ₦60M
OpEx ₦25M
EBIT ₦15M

Now simulate the impact of a 1% change in each lever:

1% Price Increase: - New revenue: ₦101M - COGS (unchanged volume): ₦60M (assuming inelastic) - OpEx (fixed): ₦25M - New EBIT: ₦16M - Profit change: +6.7%

(Reality: if demand is elastic, volume falls, reducing the gain. But pricing still often wins.)

1% Cost Reduction: - Revenue (unchanged): ₦100M - COGS: ₦59.4M - OpEx: ₦25M - New EBIT: ₦15.6M - Profit change: +4%

1% Volume Growth (at current price): - Revenue: ₦101M - COGS: ₦60.6M - OpEx: ₦25M (assume fixed) - New EBIT: ₦15.4M - Profit change: +2.7%

The ranking is clear: price > cost > volume. This is why pricing receives growing attention in sophisticated companies.

43.1.2 Nigerian Pricing Context

Nigeria’s business environment creates unique pricing challenges and opportunities:

Inflation and FX Pass-Through: Nigeria’s CPI inflation exceeded 28% in 2024, with sustained increases in 2023-2025. Companies must regularly adjust prices to maintain margins, but aggressive price hikes risk customer backlash. Firms must decide: absorb inflation (squeeze margin) or pass through (risk volume loss)?

Competitive Intensity: Telecommunications, FMCG, and banking are hyper-competitive, with many players and commodity-like products. Price elasticity is often high (demand is elastic), limiting pricing power.

Income Heterogeneity: Nigeria has extreme income disparity (Gini ~0.35-0.40). A price point targeting the top 20% by income may exclude the bottom 80%. Successful pricing often requires segment-specific offers.

Currency Volatility: Import-dependent manufacturers face naira/dollar exposure. A 10% naira devaluation immediately increases input costs, forcing price decisions: hold prices (absorb FX loss), or pass through (volume risk)?

Note📘 Theory

Profit elasticity with respect to price exceeds elasticity with respect to cost or volume in most businesses. This is because price changes flow directly to EBIT, while volume and cost changes are dampened by the production and distribution system.

Pricing power depends on demand elasticity: if demand is inelastic (|ε| < 1), price increases raise revenue and profit; if elastic (|ε| > 1), price increases reduce revenue and often profit.

Tip🔑 Key Formula

Profit elasticity to price (assuming constant variable cost per unit and fixed OpEx): \[\frac{\partial \text{EBIT}}{\partial P} / \frac{\text{EBIT}}{P} = \frac{1 + \varepsilon}{1 - (VC / P)} \cdot \frac{1 - \text{OpEx}/\text{Revenue}}{1}\]

For high-margin businesses (low VC/P), even a 1% price increase (with modest volume loss) exceeds 1% cost or volume gains.

Caution📝 Section 38.1 Review Questions
  1. Why is a 1% price increase often more impactful than a 1% volume increase, even if demand is elastic?
  2. In the Nigerian context, how would a 20% naira devaluation affect pricing strategy for an import-dependent manufacturer?
  3. For a commodity product with high demand elasticity (|ε| > 1), is a price increase ever profitable?

43.2 Price Elasticity of Demand

Price elasticity of demand (ε) measures the sensitivity of quantity demanded to price changes. It is the foundational concept for quantifying how pricing affects revenue and volume.

43.2.1 Definition and Interpretation

Price elasticity is defined as:

\[\varepsilon = \frac{\% \text{ change in quantity}}{\% \text{ change in price}} = \frac{\Delta Q / Q}{\Delta P / P}\]

Interpretation:

Elasticity Range Demand Type Intuition Pricing Strategy
ε < -1 Elastic Quantity sensitive to price; 1% price increase → >1% volume loss Lower prices to raise revenue
ε = -1 Unit elastic 1% price increase → 1% volume loss; revenue neutral Price is optimal (local max)
-1 < ε < 0 Inelastic Quantity insensitive to price; 1% increase → <1% volume loss Raise prices to increase revenue
ε = 0 Perfectly inelastic Quantity unresponsive to price (rare) Maximize margin with price increases

Note: Elasticity is always negative (inverse relationship between price and quantity), so we often discuss |ε| (absolute value).

43.2.2 Revenue and Profit Maximisation

The revenue-maximising price occurs at |ε| = 1 (unit elastic). Proof:

\[\text{Revenue} = P \cdot Q(P), \quad \text{where} \quad Q(P) = Q_0 P^{\varepsilon}\]

\[\frac{d \text{Revenue}}{d P} = Q(P) + P \frac{dQ}{dP} = Q(P) \left(1 + \frac{P}{Q} \frac{dQ}{dP}\right) = Q(P) (1 + \varepsilon)\]

Setting \(\frac{d \text{Revenue}}{d P} = 0\) yields \(\varepsilon = -1\).

For profit maximisation (not just revenue), the optimal price is the Lerner condition (see Section 38.5):

\[P^* = MC \cdot \frac{\varepsilon}{\varepsilon + 1}\]

43.2.3 Elasticity by Product Type

Empirical elasticities vary widely:

Product Elasticity Reasoning
Luxury goods, premium brands -0.5 to -0.8 Inelastic; consumers not price-sensitive
Staple foods, utilities -0.3 to -0.5 Very inelastic; necessities
Middle-market goods -1.0 to -1.5 Unit to moderately elastic
Commodity products, high competition -1.5 to -2.5 Highly elastic; easy substitution
Telecom data bundles -0.8 to -1.3 Moderate elasticity; competitive but essential
Note📘 Theory

Price elasticity of demand is the fundamental measure of pricing power. Inelastic demand (|ε| < 1) allows profitable price increases. Elastic demand (|ε| > 1) requires caution; price increases may reduce revenue despite higher per-unit margin.

Key insight: The revenue-maximising price is where ε = -1. The profit-maximising price is higher (proportional to the Lerner index).

Tip🔑 Key Formula

Point elasticity (from demand curve): \[\varepsilon = \frac{dQ}{dP} \cdot \frac{P}{Q}\]

Revenue change from price increase: \[\frac{\Delta \text{Revenue}}{\text{Revenue}} \approx \Delta P / P \cdot (1 + \varepsilon)\]

Profit-maximising price (Lerner condition): \[P^* = MC \cdot \frac{\varepsilon}{\varepsilon + 1} = \frac{MC}{1 - 1/|\varepsilon|}\]

Caution📝 Section 38.2 Review Questions
  1. A Nigerian bank raises its checking account fee from ₦0 to ₦5,000/year. The elasticity is estimated at -0.6. What is the impact on revenue (assuming 1 million customers)?
  2. For a telecom data bundle, the elasticity is -1.2. Is the current price likely above or below the revenue-maximising price?
  3. Explain intuitively why luxury goods (like high-end phones) are typically inelastic while commodity products are elastic.

43.3 Log-Log Regression for Elasticity Estimation

The constant-elasticity demand model is the workhorse of applied pricing. It assumes elasticity is constant across the price range, allowing a simple log-log regression to estimate it from historical data.

43.3.1 The Constant-Elasticity Demand Model

Assume the demand function:

\[Q_t = \alpha \cdot P_t^{\varepsilon} \cdot e^{X_t}\]

where \(X_t\) represents control variables (seasonality, competitor price, income, promotions). Taking logs:

\[\log Q_t = \log \alpha + \varepsilon \log P_t + X_t\]

The coefficient on \(\log P_t\) is directly the price elasticity \(\varepsilon\). This is elegant: no need to estimate demand curves; simply regress log(quantity) on log(price) and controls.

43.3.2 Control Variables in the Regression

To isolate the elasticity from confounding factors:

  1. Competitor price: If competitor prices also increase, demand might fall due to market contraction, not own-price elasticity.
  2. Seasonality: Demand naturally fluctuates by season (e.g., telecom data bundles spike during school holidays). Seasonal dummies control this.
  3. Promotions: Special offers inflate demand independent of baseline price.
  4. Macroeconomic: GDP growth, inflation, unemployment affect demand. Include when available.

43.3.3 Interpretation and Validity

The log-log regression estimates short-run elasticity: the demand response over weeks or months. Long-run elasticity (months to years) is often larger in magnitude because consumers have time to find substitutes. A 6-month elasticity of -1.2 might translate to a 12-month elasticity of -1.5 or higher.

Note📘 Theory

The constant-elasticity demand model (log-log functional form) is the most common framework in applied pricing because it is simple to estimate, economically interpretable, and fits well across many product categories.

Key assumption: Elasticity is constant across the price range. If prices vary widely historically, the assumption may be violated; piecewise or non-parametric approaches may be better.

Tip🔑 Key Formula

Log-log demand regression: \[\log Q_t = \beta_0 + \beta_1 \log P_t + \sum_{i} \beta_i X_{i,t} + \varepsilon_t\]

where \(\beta_1 = \varepsilon\) (elasticity). Standard errors on \(\beta_1\) provide confidence intervals for elasticity.

Revenue impact of price change: \[\text{Revenue}_{\text{new}} = \text{Revenue}_{\text{old}} \cdot \left( \frac{P_{\text{new}}}{P_{\text{old}}} \right)^{1 + \varepsilon}\]

43.4 38.3A Code: Log-Log Elasticity on Nigerian Telecom Data

We construct synthetic weekly data for three data bundle tiers across 104 weeks (2 years), varying prices and capturing seasonality and promotions. Then fit log-log regressions to estimate elasticity per tier.

Show code
library(tidyverse)

set.seed(6082)

# Synthetic weekly telecom bundle data: 3 tiers, 104 weeks (2 years)
# Prices in ₦, quantities in units sold

weeks <- 1:104
base_date <- as.Date("2023-01-01")

# Define base prices and elasticities per tier
tier_params <- tibble(
  tier = c("Basic (500MB)", "Standard (2GB)", "Premium (10GB)"),
  base_price = c(500, 1500, 3500),
  elasticity = c(-1.0, -1.2, -0.9),
  base_demand = c(50000, 30000, 15000)
)

# Generate data
demand_data <- expand_grid(
  week = weeks,
  tier = tier_params$tier
) |>
  left_join(tier_params, by = "tier") |>
  mutate(
    # Simulate prices: random walk + seasonal adjustment
    price_shock = rnorm(n(), 0, 50),
    seasonal_factor = sin(2 * pi * week / 52) * 0.1,  # Peak in summer (week ~26)
    price = base_price * (1 + cumsum(price_shock / 10000) / 100 + seasonal_factor / 5),
    price = pmax(100, price),  # Floor at ₦100

    # Demand: constant elasticity + seasonal + random noise
    log_demand = log(base_demand) + elasticity * log(price / base_price) +
                 0.15 * sin(2 * pi * week / 52) +  # Seasonal demand
                 rnorm(n(), 0, 0.08),
    quantity = exp(log_demand),

    # Add promotion effect (20% of weeks)
    promo = rbinom(n(), 1, 0.2),
    quantity = quantity * (1 + promo * 0.3),

    revenue = price * quantity,
    date = base_date + weeks(week - 1)
  ) |>
  select(date, week, tier, price, quantity, revenue, promo)

head(demand_data, 20)
Show code

# Estimate elasticity via log-log regression per tier
elasticity_results <- demand_data |>
  group_by(tier) |>
  nest() |>
  mutate(
    model = map(data, ~lm(log(quantity) ~ log(price) + promo, data = .x)),
    elasticity_est = map_dbl(model, ~coef(.x)["log(price)"]),
    elasticity_se = map_dbl(model, ~summary(.x)$coefficients["log(price)", "Std. Error"]),
    r_squared = map_dbl(model, ~summary(.x)$r.squared)
  ) |>
  select(tier, elasticity_est, elasticity_se, r_squared) |>
  arrange(tier)

print("Estimated Price Elasticities (Log-Log Regression):")
#> [1] "Estimated Price Elasticities (Log-Log Regression):"
print(elasticity_results)
#> # A tibble: 3 × 4
#> # Groups:   tier [3]
#>   tier           elasticity_est elasticity_se r_squared
#>   <chr>                   <dbl>         <dbl>     <dbl>
#> 1 Basic (500MB)            5.74         0.551     0.732
#> 2 Premium (10GB)           6.70         0.554     0.751
#> 3 Standard (2GB)           6.07         0.564     0.766

# Revenue impact simulation
# Scenario: 5% price increase per tier
revenue_impact <- demand_data |>
  group_by(tier) |>
  summarise(
    current_avg_price = mean(price),
    current_avg_quantity = mean(quantity),
    current_weekly_revenue = mean(revenue),
    .groups = "drop"
  ) |>
  left_join(elasticity_results |> select(tier, elasticity_est), by = "tier") |>
  mutate(
    price_increase_pct = 0.05,
    new_price = current_avg_price * (1 + price_increase_pct),
    # New quantity from elasticity
    new_quantity = current_avg_quantity * (1 + price_increase_pct)^elasticity_est,
    new_revenue = new_price * new_quantity,
    revenue_change = new_revenue - current_weekly_revenue,
    revenue_change_pct = 100 * revenue_change / current_weekly_revenue
  ) |>
  select(tier, elasticity_est, current_weekly_revenue, new_revenue, revenue_change, revenue_change_pct)

print("\nRevenue Impact of 5% Price Increase:")
#> [1] "\nRevenue Impact of 5% Price Increase:"
print(revenue_impact)
#> # A tibble: 3 × 6
#>   tier          elasticity_est current_weekly_revenue new_revenue revenue_change
#>   <chr>                  <dbl>                  <dbl>       <dbl>          <dbl>
#> 1 Basic (500MB)           5.74              26532163.   36836512.      10304350.
#> 2 Premium (10G…           6.70              55154894.   80197001.      25042107.
#> 3 Standard (2G…           6.07              49308274.   69529902.      20221628.
#> # ℹ 1 more variable: revenue_change_pct <dbl>
Show code
import pandas as pd
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression

np.random.seed(6082)

# Synthetic telecom bundle data
weeks = np.arange(1, 105)
base_date = pd.Timestamp("2023-01-01")

tier_params = pd.DataFrame({
    'tier': ["Basic (500MB)", "Standard (2GB)", "Premium (10GB)"],
    'base_price': [500, 1500, 3500],
    'elasticity': [-1.0, -1.2, -0.9],
    'base_demand': [50000, 30000, 15000]
})

# Generate data
data_list = []
for _, params in tier_params.iterrows():
    tier = params['tier']
    base_price = params['base_price']
    elasticity = params['elasticity']
    base_demand = params['base_demand']

    price_shocks = np.random.normal(0, 50, len(weeks))
    prices = base_price * (1 + np.cumsum(price_shocks / 10000) / 100 +
                           np.sin(2 * np.pi * weeks / 52) * 0.02)
    prices = np.maximum(100, prices)

    log_demand = np.log(base_demand) + elasticity * np.log(prices / base_price) + \
                 0.15 * np.sin(2 * np.pi * weeks / 52) + np.random.normal(0, 0.08, len(weeks))
    quantities = np.exp(log_demand)

    # Promotions
    promos = np.random.binomial(1, 0.2, len(weeks))
    quantities = quantities * (1 + promos * 0.3)

    for i, week in enumerate(weeks):
        data_list.append({
            'date': base_date + pd.Timedelta(weeks=week-1),
            'week': week,
            'tier': tier,
            'price': prices[i],
            'quantity': quantities[i],
            'revenue': prices[i] * quantities[i],
            'promo': promos[i]
        })

demand_data = pd.DataFrame(data_list)
print(demand_data.head(20))
#>          date  week           tier  ...      quantity       revenue  promo
#> 0  2023-01-01     1  Basic (500MB)  ...  51524.637994  2.582195e+07      0
#> 1  2023-01-08     2  Basic (500MB)  ...  51024.610919  2.563128e+07      0
#> 2  2023-01-15     3  Basic (500MB)  ...  56267.452898  2.832984e+07      0
#> 3  2023-01-22     4  Basic (500MB)  ...  45658.118925  2.303838e+07      0
#> 4  2023-01-29     5  Basic (500MB)  ...  75713.417352  3.828128e+07      1
#> 5  2023-02-05     6  Basic (500MB)  ...  54444.043814  2.758009e+07      0
#> 6  2023-02-12     7  Basic (500MB)  ...  52601.766308  2.669248e+07      0
#> 7  2023-02-19     8  Basic (500MB)  ...  80489.544952  4.090315e+07      1
#> 8  2023-02-26     9  Basic (500MB)  ...  59095.833059  3.006871e+07      0
#> 9  2023-03-05    10  Basic (500MB)  ...  61590.446236  3.137000e+07      1
#> 10 2023-03-12    11  Basic (500MB)  ...  50093.421000  2.553218e+07      0
#> 11 2023-03-19    12  Basic (500MB)  ...  57290.578121  2.921442e+07      0
#> 12 2023-03-26    13  Basic (500MB)  ...  62577.078571  3.191302e+07      0
#> 13 2023-04-02    14  Basic (500MB)  ...  59459.082789  3.031895e+07      0
#> 14 2023-04-09    15  Basic (500MB)  ...  54453.225877  2.775245e+07      0
#> 15 2023-04-16    16  Basic (500MB)  ...  94430.054629  4.809156e+07      1
#> 16 2023-04-23    17  Basic (500MB)  ...  75409.188600  3.836443e+07      1
#> 17 2023-04-30    18  Basic (500MB)  ...  62671.568949  3.184447e+07      0
#> 18 2023-05-07    19  Basic (500MB)  ...  61347.642242  3.112607e+07      0
#> 19 2023-05-14    20  Basic (500MB)  ...  55434.560862  2.807438e+07      0
#> 
#> [20 rows x 7 columns]

# Estimate elasticity per tier
elasticity_results = []
for tier in tier_params['tier']:
    tier_data = demand_data[demand_data['tier'] == tier].copy()

    X = np.column_stack([np.log(tier_data['price']), tier_data['promo']])
    y = np.log(tier_data['quantity'])

    model = LinearRegression().fit(X, y)
    elasticity_est = model.coef_[0]

    # R-squared
    y_pred = model.predict(X)
    ss_res = np.sum((y - y_pred) ** 2)
    ss_tot = np.sum((y - np.mean(y)) ** 2)
    r_squared = 1 - (ss_res / ss_tot)

    # Standard error (approximate)
    residuals = y - y_pred
    mse = np.sum(residuals ** 2) / (len(y) - X.shape[1])
    var_covar = mse * np.linalg.inv(X.T @ X)
    elasticity_se = np.sqrt(var_covar[0, 0])

    elasticity_results.append({
        'tier': tier,
        'elasticity_est': elasticity_est,
        'elasticity_se': elasticity_se,
        'r_squared': r_squared
    })

elasticity_df = pd.DataFrame(elasticity_results)
print("\nEstimated Price Elasticities (Log-Log Regression):")
#> 
#> Estimated Price Elasticities (Log-Log Regression):
print(elasticity_df)
#>              tier  elasticity_est  elasticity_se  r_squared
#> 0   Basic (500MB)        6.858283       0.001499   0.738065
#> 1  Standard (2GB)        6.218666       0.001082   0.746877
#> 2  Premium (10GB)        6.513615       0.001260   0.715168

# Revenue impact of 5% price increase
revenue_impact = demand_data.groupby('tier').agg({
    'price': 'mean',
    'quantity': 'mean',
    'revenue': 'mean'
}).reset_index()
revenue_impact.columns = ['tier', 'current_avg_price', 'current_avg_quantity', 'current_weekly_revenue']

revenue_impact = revenue_impact.merge(elasticity_df[['tier', 'elasticity_est']], on='tier')

revenue_impact['new_price'] = revenue_impact['current_avg_price'] * 1.05
revenue_impact['new_quantity'] = revenue_impact['current_avg_quantity'] * (1.05 ** revenue_impact['elasticity_est'])
revenue_impact['new_revenue'] = revenue_impact['new_price'] * revenue_impact['new_quantity']
revenue_impact['revenue_change'] = revenue_impact['new_revenue'] - revenue_impact['current_weekly_revenue']
revenue_impact['revenue_change_pct'] = 100 * revenue_impact['revenue_change'] / revenue_impact['current_weekly_revenue']

print("\nRevenue Impact of 5% Price Increase:")
#> 
#> Revenue Impact of 5% Price Increase:
print(revenue_impact[['tier', 'elasticity_est', 'current_weekly_revenue', 'new_revenue', 'revenue_change_pct']])
#>              tier  elasticity_est  ...   new_revenue  revenue_change_pct
#> 0   Basic (500MB)        6.858283  ...  3.985178e+07           46.540770
#> 1  Premium (10GB)        6.513615  ...  8.153324e+07           44.077276
#> 2  Standard (2GB)        6.218666  ...  6.736142e+07           42.045833
#> 
#> [3 rows x 5 columns]
Caution📝 Section 38.3 Review Questions
  1. In the log-log regression, why is the coefficient on log(price) directly interpretable as elasticity?
  2. If a regression yields an elasticity of -1.1 with a standard error of 0.15, what is the 95% confidence interval? Is the true elasticity likely inelastic?
  3. A telecom provider runs a promotion (20% off) in select markets. Should the regression control for promotion, and why?

43.5 Conjoint Analysis

Conjoint analysis measures willingness to pay (WTP) without asking customers directly (“How much would you pay?”). Instead, customers evaluate trade-offs between product attributes, and willingness-to-pay is inferred from choices.

43.5.1 The Conjoint Framework

A typical conjoint survey presents choice scenarios. Example for a Nigerian telecom bundle:

Which bundle would you prefer?

Attribute Option A Option B Option C
Data 2 GB 5 GB 2 GB
Validity 30 days 60 days 30 days
Price ₦1,000 ₦2,000 ₦800

Respondents indicate their preference. Across hundreds of respondents and scenarios, we estimate:

  • Part-worth utilities: How much does each attribute level contribute to overall preference?
  • Price sensitivity: The implicit trade-off between price and other attributes.
  • Market share simulation: Predict demand for new product bundles.

43.5.2 Part-Worth Utilities and Willingness to Pay

If the estimated part-worth utility for “5 GB” is 50 utils and for “₦1,000” is -10 utils, then the implicit willingness to pay for the extra 3 GB is:

\[\text{WTP for 5GB vs 2GB} = \frac{\text{Utility(5GB)} - \text{Utility(2GB)}}{\text{|Utility change per ₦1|}} = \frac{50}{\text{price sensitivity}}\]

This method sidesteps the bias in direct WTP surveys (customers always say they’ll pay less than they actually will).

43.5.3 Code: Synthetic Conjoint Analysis

We simulate 200 Nigerian consumers choosing between telecom bundles, estimate part-worth utilities, and simulate market share for a new bundle.

Show code
library(tidyverse)

set.seed(9347)

# Conjoint attributes and levels
attributes <- tribble(
  ~attribute, ~level, ~code,
  "Data", "2GB", "D2",
  "Data", "5GB", "D5",
  "Data", "10GB", "D10",
  "Validity", "30 days", "V30",
  "Validity", "60 days", "V60",
  "Price", "₦1,000", "P1000",
  "Price", "₦1,500", "P1500",
  "Price", "₦2,000", "P2000"
)

# True part-worths (we'll simulate responses as if these are the true preferences)
true_partworths <- c(
  "D2" = 0,        # Reference level
  "D5" = 30,
  "D10" = 60,
  "V30" = 0,       # Reference
  "V60" = 20,
  "P1000" = 50,
  "P1500" = 25,
  "P2000" = 0      # Reference (least preferred)
)

# Simulate 5 choice scenarios with 200 respondents each
# Each scenario: 3 bundles, respondent chooses 1 (most preferred)

scenarios <- list(
  list(bundle_a = c("D2", "V30", "P1000"), bundle_b = c("D5", "V30", "P1500"), bundle_c = c("D2", "V60", "P1500")),
  list(bundle_a = c("D5", "V60", "P2000"), bundle_b = c("D10", "V30", "P1500"), bundle_c = c("D2", "V30", "P1000")),
  list(bundle_a = c("D10", "V60", "P2000"), bundle_b = c("D5", "V30", "P1000"), bundle_c = c("D2", "V60", "P1000")),
  list(bundle_a = c("D5", "V30", "P1500"), bundle_b = c("D10", "V60", "P2000"), bundle_c = c("D2", "V30", "P1500")),
  list(bundle_a = c("D2", "V60", "P2000"), bundle_b = c("D10", "V30", "P1000"), bundle_c = c("D5", "V60", "P1500"))
)

# Generate responses for 200 respondents
n_respondents <- 200
response_data <- expand_grid(
  respondent_id = 1:n_respondents,
  scenario = 1:5
) |>
  mutate(
    bundle_set = map(scenario, ~scenarios[[.x]]),
    # For each bundle, calculate utility = sum of part-worths + random noise
    bundle_a_utility = map_dbl(bundle_set, ~sum(true_partworths[.x$bundle_a]) + rnorm(1, 0, 5)),
    bundle_b_utility = map_dbl(bundle_set, ~sum(true_partworths[.x$bundle_b]) + rnorm(1, 0, 5)),
    bundle_c_utility = map_dbl(bundle_set, ~sum(true_partworths[.x$bundle_c]) + rnorm(1, 0, 5)),
    # Respondent chooses bundle with highest utility
    choice = case_when(
      bundle_a_utility >= bundle_b_utility & bundle_a_utility >= bundle_c_utility ~ "A",
      bundle_b_utility >= bundle_a_utility & bundle_b_utility >= bundle_c_utility ~ "B",
      TRUE ~ "C"
    )
  ) |>
  select(respondent_id, scenario, bundle_set, choice)

# Simplify: expand bundles for logistic regression
response_expanded <- response_data |>
  unnest_wider(bundle_set) |>
  pivot_longer(
    cols = starts_with("bundle_"),
    names_to = "bundle",
    values_to = "attributes"
  ) |>
  mutate(
    bundle_letter = toupper(str_extract(bundle, "[abc]")),
    chosen = (bundle_letter == choice),
    attributes = as.character(attributes)
  ) |>
  # Create dummy variables for attributes
  mutate(
    D5 = str_detect(attributes, "D5"),
    D10 = str_detect(attributes, "D10"),
    V60 = str_detect(attributes, "V60"),
    P1500 = str_detect(attributes, "P1500"),
    P2000 = str_detect(attributes, "P2000")
  ) |>
  select(respondent_id, scenario, chosen, D5, D10, V60, P1500, P2000)

# Logistic regression to estimate part-worths
model_conjoint <- glm(chosen ~ D5 + D10 + V60 + P1500 + P2000,
                      family = binomial(link = "logit"),
                      data = response_expanded)

partworths_est <- tibble(
  level = names(coef(model_conjoint))[-1],
  partworth = coef(model_conjoint)[-1]
)

print("Estimated Part-Worths:")
#> [1] "Estimated Part-Worths:"
print(partworths_est)
#> # A tibble: 5 × 2
#>   level     partworth
#>   <chr>         <dbl>
#> 1 D5TRUE       -0.763
#> 2 D10TRUE      -0.429
#> 3 V60TRUE      -1.57 
#> 4 P1500TRUE     2.06 
#> 5 P2000TRUE     2.25

# Interpret: WTP for 5GB vs 2GB
# The price coefficients tell us the utility per ₦1
price_sensitivity <- abs(partworths_est |> filter(str_detect(level, "P")) |> pull(partworth) |> mean())
wtp_5gb_vs_2gb <- partworths_est |> filter(level == "D5") |> pull(partworth) / price_sensitivity * 1000

cat(sprintf("Implied WTP for 5GB vs 2GB: ₦%.0f\n", wtp_5gb_vs_2gb))

# Market share simulation for a hypothetical new bundle: 3GB, 30 days, ₦1,200
# Utility = 0.5 * (D5 utility) + 0 (V30 ref) - (price adjustment)
new_bundle_utility <- 0.5 * (partworths_est |> filter(level == "D5") |> pull(partworth)) +
                      (partworths_est |> filter(level == "P1500") |> pull(partworth)) * (1200 - 1500) / 500

cat(sprintf("New bundle (3GB, 30d, ₦1,200) estimated utility: %.1f\n", new_bundle_utility))
Show code
import pandas as pd
import numpy as np
from sklearn.linear_model import LogisticRegression

np.random.seed(9347)

# True part-worths (simulated preference model)
true_partworths = {
    'D2': 0, 'D5': 30, 'D10': 60,
    'V30': 0, 'V60': 20,
    'P1000': 50, 'P1500': 25, 'P2000': 0
}

# Conjoint scenarios
scenarios = [
    {'a': ['D2', 'V30', 'P1000'], 'b': ['D5', 'V30', 'P1500'], 'c': ['D2', 'V60', 'P1500']},
    {'a': ['D5', 'V60', 'P2000'], 'b': ['D10', 'V30', 'P1500'], 'c': ['D2', 'V30', 'P1000']},
    {'a': ['D10', 'V60', 'P2000'], 'b': ['D5', 'V30', 'P1000'], 'c': ['D2', 'V60', 'P1000']},
    {'a': ['D5', 'V30', 'P1500'], 'b': ['D10', 'V60', 'P2000'], 'c': ['D2', 'V30', 'P1500']},
    {'a': ['D2', 'V60', 'P2000'], 'b': ['D10', 'V30', 'P1000'], 'c': ['D5', 'V60', 'P1500']}
]

# Generate responses
n_respondents = 200
response_list = []

for respondent_id in range(1, n_respondents + 1):
    for scenario_idx, scenario in enumerate(scenarios):
        utils = {}
        for bundle_letter, attributes in scenario.items():
            utils[bundle_letter] = sum(true_partworths.get(attr, 0) for attr in attributes) + np.random.normal(0, 5)

        choice = max(utils, key=utils.get)
        response_list.append({
            'respondent_id': respondent_id,
            'scenario': scenario_idx + 1,
            'choice': choice,
            'attributes_a': str(scenario['a']),
            'attributes_b': str(scenario['b']),
            'attributes_c': str(scenario['c'])
        })

response_data = pd.DataFrame(response_list)

# Expand for logistic regression
expanded_list = []
for _, row in response_data.iterrows():
    for bundle, letter in [('attributes_a', 'a'), ('attributes_b', 'b'), ('attributes_c', 'c')]:
        attrs = eval(row[bundle])
        expanded_list.append({
            'respondent_id': row['respondent_id'],
            'scenario': row['scenario'],
            'chosen': 1 if letter == row['choice'] else 0,
            'D5': 1 if 'D5' in attrs else 0,
            'D10': 1 if 'D10' in attrs else 0,
            'V60': 1 if 'V60' in attrs else 0,
            'P1500': 1 if 'P1500' in attrs else 0,
            'P2000': 1 if 'P2000' in attrs else 0
        })

expanded_df = pd.DataFrame(expanded_list)

X = expanded_df[['D5', 'D10', 'V60', 'P1500', 'P2000']]
y = expanded_df['chosen']

model_conjoint = LogisticRegression(fit_intercept=True, max_iter=1000).fit(X, y)

partworths = pd.DataFrame({
    'level': X.columns,
    'partworth': model_conjoint.coef_[0]
})

print("Estimated Part-Worths:")
#> Estimated Part-Worths:
print(partworths)
#>    level  partworth
#> 0     D5   1.746766
#> 1    D10   6.851812
#> 2    V60  -2.276962
#> 3  P1500  -0.558106
#> 4  P2000  -1.505533

# Price sensitivity and WTP
price_cols = partworths[partworths['level'].str.contains('P')]
price_sensitivity = abs(price_cols['partworth'].mean())
wtp_5gb_vs_2gb = partworths[partworths['level'] == 'D5']['partworth'].values[0] / price_sensitivity * 1000

print(f"\nImplied WTP for 5GB vs 2GB: ₦{wtp_5gb_vs_2gb:.0f}")
#> 
#> Implied WTP for 5GB vs 2GB: ₦1693
Caution📝 Section 38.4 Review Questions
  1. Why is conjoint analysis preferable to asking customers directly “How much would you pay?”
  2. In the conjoint model, the coefficient on D5 (5GB) is 30, and the coefficient on P1500 is -20. What is the implied WTP for 5GB vs 2GB, assuming P1500 and P2000 differ by ₦500?
  3. How would you use a conjoint part-worth model to forecast market share for a new product bundle?

43.6 Price Optimisation

Price optimisation finds the profit-maximising price by combining the estimated demand function with marginal cost.

43.6.1 The Lerner Condition and Optimal Pricing

The fundamental rule for profit-maximising price is the Lerner index:

\[\frac{P - MC}{P} = -\frac{1}{\varepsilon}\]

Rearranging for price:

\[P^* = MC \cdot \frac{\varepsilon}{\varepsilon + 1}\]

Intuition: If elasticity is -2 (elastic), then \(P^* = MC \cdot \frac{-2}{-2 + 1} = MC \cdot 2\), a 2× markup. If elasticity is -0.5 (inelastic), then \(P^* = MC \cdot \frac{-0.5}{-0.5 + 1} = MC \cdot (-1)\), which is impossible. This reveals an error: for very inelastic goods (|ε| < 1), the formula suggests pricing above the monopoly ceiling; the true optimum is constrained by regulation, competition, or fairness.

43.6.2 Multi-Product Optimisation

For a company with multiple products sharing costs (e.g., three data bundle tiers), simple Lerner pricing may not account for substitution. A more general approach:

  1. Estimate demand for each product, accounting for cross-elasticities.
  2. Define costs: variable cost per unit, shared fixed costs.
  3. Grid search or calculus: Find the price vector that maximises total profit.

43.6.3 Code: Price Optimisation for Telecom Bundles

Show code
library(tidyverse)

# Three data bundles: Basic (500MB), Standard (2GB), Premium (10GB)
# Estimated elasticities and current prices/costs

bundle_info <- tribble(
  ~bundle, ~current_price, ~elasticity, ~marginal_cost,
  "Basic", 500, -1.0, 150,
  "Standard", 1500, -1.2, 350,
  "Premium", 3500, -0.9, 900
)

# Demand function: Q = base_demand * (P / current_price)^elasticity
# We estimate base_demand from current sales
bundle_info <- bundle_info |>
  mutate(
    current_quantity = c(50000, 30000, 15000),
    base_demand = current_quantity  # At current price
  )

# Lerner pricing
bundle_info <- bundle_info |>
  mutate(
    # P* = MC * elasticity / (elasticity + 1)
    optimal_price_lerner = marginal_cost * elasticity / (elasticity + 1),
    # Ensure price is positive
    optimal_price_lerner = ifelse(optimal_price_lerner > marginal_cost,
                                  optimal_price_lerner,
                                  current_price)
  )

print("Lerner-based Optimal Pricing:")
#> [1] "Lerner-based Optimal Pricing:"
print(bundle_info |> select(bundle, current_price, marginal_cost, elasticity, optimal_price_lerner))
#> # A tibble: 3 × 5
#>   bundle   current_price marginal_cost elasticity optimal_price_lerner
#>   <chr>            <dbl>         <dbl>      <dbl>                <dbl>
#> 1 Basic              500           150       -1                    500
#> 2 Standard          1500           350       -1.2                 2100
#> 3 Premium           3500           900       -0.9                 3500

# Simulate profit at current vs optimal prices
bundle_info <- bundle_info |>
  mutate(
    # Current profit
    current_revenue = current_price * current_quantity,
    current_profit = current_revenue - marginal_cost * current_quantity,

    # Optimal profit (using Lerner price)
    optimal_quantity = base_demand * (optimal_price_lerner / current_price)^elasticity,
    optimal_revenue = optimal_price_lerner * optimal_quantity,
    optimal_profit = optimal_revenue - marginal_cost * optimal_quantity,

    # Profit improvement
    profit_change = optimal_profit - current_profit,
    profit_change_pct = 100 * profit_change / current_profit
  )

print("\nProfit Impact of Optimal Pricing:")
#> [1] "\nProfit Impact of Optimal Pricing:"
print(bundle_info |> select(bundle, current_profit, optimal_profit, profit_change, profit_change_pct))
#> # A tibble: 3 × 5
#>   bundle   current_profit optimal_profit profit_change profit_change_pct
#>   <chr>             <dbl>          <dbl>         <dbl>             <dbl>
#> 1 Basic          17500000      17500000             0               0   
#> 2 Standard       34500000      35059495.       559495.              1.62
#> 3 Premium        39000000      39000000             0               0

# Summary: total portfolio profit
portfolio_summary <- tibble(
  scenario = c("Current Pricing", "Optimal Pricing"),
  total_revenue = c(sum(bundle_info$current_revenue),
                    sum(bundle_info$optimal_revenue)),
  total_quantity = c(sum(bundle_info$current_quantity),
                     sum(bundle_info$optimal_quantity)),
  total_profit = c(sum(bundle_info$current_profit),
                   sum(bundle_info$optimal_profit))
)

print("\nPortfolio Summary:")
#> [1] "\nPortfolio Summary:"
print(portfolio_summary)
#> # A tibble: 2 × 4
#>   scenario        total_revenue total_quantity total_profit
#>   <chr>                   <dbl>          <dbl>        <dbl>
#> 1 Current Pricing    122500000          95000     91000000 
#> 2 Optimal Pricing    119571394.         85034.    91559495.
Show code
import pandas as pd
import numpy as np

bundle_info = pd.DataFrame({
    'bundle': ['Basic', 'Standard', 'Premium'],
    'current_price': [500, 1500, 3500],
    'elasticity': [-1.0, -1.2, -0.9],
    'marginal_cost': [150, 350, 900],
    'current_quantity': [50000, 30000, 15000]
})

bundle_info['base_demand'] = bundle_info['current_quantity']

# Lerner pricing
bundle_info['optimal_price_lerner'] = (
    bundle_info['marginal_cost'] *
    bundle_info['elasticity'] /
    (bundle_info['elasticity'] + 1)
)

# Ensure positive prices
bundle_info['optimal_price_lerner'] = bundle_info.apply(
    lambda row: row['optimal_price_lerner'] if row['optimal_price_lerner'] > row['marginal_cost']
    else row['current_price'],
    axis=1
)

print("Lerner-based Optimal Pricing:")
#> Lerner-based Optimal Pricing:
print(bundle_info[['bundle', 'current_price', 'marginal_cost', 'elasticity', 'optimal_price_lerner']])
#>      bundle  current_price  marginal_cost  elasticity  optimal_price_lerner
#> 0     Basic            500            150        -1.0                 500.0
#> 1  Standard           1500            350        -1.2                2100.0
#> 2   Premium           3500            900        -0.9                3500.0

# Profit at current vs optimal
bundle_info['current_revenue'] = bundle_info['current_price'] * bundle_info['current_quantity']
bundle_info['current_profit'] = bundle_info['current_revenue'] - bundle_info['marginal_cost'] * bundle_info['current_quantity']

bundle_info['optimal_quantity'] = (
    bundle_info['base_demand'] *
    (bundle_info['optimal_price_lerner'] / bundle_info['current_price']) ** bundle_info['elasticity']
)

bundle_info['optimal_revenue'] = bundle_info['optimal_price_lerner'] * bundle_info['optimal_quantity']
bundle_info['optimal_profit'] = (
    bundle_info['optimal_revenue'] -
    bundle_info['marginal_cost'] * bundle_info['optimal_quantity']
)

bundle_info['profit_change'] = bundle_info['optimal_profit'] - bundle_info['current_profit']
bundle_info['profit_change_pct'] = 100 * bundle_info['profit_change'] / bundle_info['current_profit']

print("\nProfit Impact:")
#> 
#> Profit Impact:
print(bundle_info[['bundle', 'current_profit', 'optimal_profit', 'profit_change_pct']])
#>      bundle  current_profit  optimal_profit  profit_change_pct
#> 0     Basic        17500000    1.750000e+07           0.000000
#> 1  Standard        34500000    3.505950e+07           1.621726
#> 2   Premium        39000000    3.900000e+07           0.000000

# Portfolio summary
print("\nPortfolio Summary:")
#> 
#> Portfolio Summary:
print(pd.DataFrame({
    'Scenario': ['Current Pricing', 'Optimal Pricing'],
    'Total Revenue': [bundle_info['current_revenue'].sum(), bundle_info['optimal_revenue'].sum()],
    'Total Quantity': [bundle_info['current_quantity'].sum(), bundle_info['optimal_quantity'].sum()],
    'Total Profit': [bundle_info['current_profit'].sum(), bundle_info['optimal_profit'].sum()]
}))
#>           Scenario  Total Revenue  Total Quantity  Total Profit
#> 0  Current Pricing   1.225000e+08    95000.000000  9.100000e+07
#> 1  Optimal Pricing   1.195714e+08    85033.997346  9.155950e+07
Caution📝 Section 38.5 Review Questions
  1. For the Basic bundle with elasticity -1.0, marginal cost ₦150, what is the Lerner-optimal price?
  2. Why might the Lerner formula yield unrealistic prices for very inelastic products (|ε| < 1)?
  3. If you optimise prices for three bundles independently, might you miss cross-elasticity effects? How would you test this?

43.7 Dynamic Pricing Principles

Dynamic pricing adjusts prices in real time based on demand, inventory, and competitive conditions. Airline yield management and ride-hailing surge pricing are classic examples.

43.7.1 Demand-Based Dynamic Pricing

A simple model: Partition time into periods. In period \(t\), if demand is high (e.g., seats available < 20% of capacity), raise price by 10%. If low, discount by 10%.

Ethical and practical constraints:

  1. Transparency: Customers tolerate dynamic pricing if they understand the reason (scarcity, time of day). Hidden repricing (e.g., based on browsing history) causes backlash.
  2. Fairness: Discrimination by protected characteristics (race, gender, age) is illegal and unethical.
  3. Regulatory: Some jurisdictions restrict price discrimination in essential services (utilities, fuel).

43.7.2 Code: Surge Pricing Simulation for Lagos Ride-Hailing

We simulate a simple dynamic pricing algorithm for a fictional Lagos ride-hailing app: base price scales with demand ratio (requests / available drivers).

Show code
library(tidyverse)
library(lubridate)

set.seed(4715)

# Simulate hourly ride demand and driver supply in Lagos over 7 days
dates <- seq(as.POSIXct("2024-01-01 00:00:00"), as.POSIXct("2024-01-07 23:00:00"), by = "hour")

ride_data <- tibble(
  datetime = dates,
  hour = hour(datetime),
  day_of_week = wday(datetime, label = TRUE),
  # Demand peaks: morning rush (7-9), evening rush (5-7), night (10-12)
  base_demand = case_when(
    hour %in% c(7, 8) ~ 500,
    hour %in% c(17, 18, 19) ~ 600,
    hour %in% c(21, 22, 23) ~ 400,
    TRUE ~ 200
  ) * (1 + rnorm(length(dates), 0, 0.1)),  # Random variation

  # Driver supply: lower at night and early morning
  base_supply = case_when(
    hour %in% c(2, 3, 4, 5) ~ 150,
    hour %in% c(7, 8) ~ 400,
    hour %in% c(17, 18, 19) ~ 500,
    hour %in% c(21, 22, 23) ~ 300,
    TRUE ~ 250
  ) * (1 + rnorm(length(dates), 0, 0.15))
) |>
  mutate(
    base_demand = pmax(50, base_demand),
    base_supply = pmax(50, base_supply),
    demand_supply_ratio = base_demand / base_supply
  )

# Dynamic pricing algorithm
# Base price: ₦500
# Multiplier = 1 + 0.5 * (demand_supply_ratio - 1), capped at 3x
ride_data <- ride_data |>
  mutate(
    base_price = 500,
    price_multiplier = pmin(3, pmax(0.5, 1 + 0.5 * (demand_supply_ratio - 1))),
    dynamic_price = base_price * price_multiplier,

    # Assume price reduces demand elastically (ε = -1 for simplicity)
    adjusted_demand = base_demand * (base_price / dynamic_price)^(-1),

    # Revenue
    revenue = dynamic_price * adjusted_demand,

    # Consumer surplus loss (approximate): area under demand curve
    consumer_surplus_loss = 0.5 * (dynamic_price - base_price) * (base_demand - adjusted_demand)
  )

print("Dynamic Pricing Sample (hourly):")
#> [1] "Dynamic Pricing Sample (hourly):"
print(ride_data |> slice(1:24) |>
  select(datetime, hour, base_demand, base_supply, demand_supply_ratio, base_price, dynamic_price, revenue))
#> # A tibble: 24 × 8
#>    datetime             hour base_demand base_supply demand_supply_ratio
#>    <dttm>              <int>       <dbl>       <dbl>               <dbl>
#>  1 2024-01-01 00:00:00     0        171.        182.               0.940
#>  2 2024-01-01 01:00:00     1        204.        235.               0.866
#>  3 2024-01-01 02:00:00     2        189.        154.               1.22 
#>  4 2024-01-01 03:00:00     3        234.        139.               1.69 
#>  5 2024-01-01 04:00:00     4        182.        162.               1.13 
#>  6 2024-01-01 05:00:00     5        212.        136.               1.56 
#>  7 2024-01-01 06:00:00     6        219.        225.               0.971
#>  8 2024-01-01 07:00:00     7        522.        366.               1.43 
#>  9 2024-01-01 08:00:00     8        558.        443.               1.26 
#> 10 2024-01-01 09:00:00     9        195.        198.               0.987
#> # ℹ 14 more rows
#> # ℹ 3 more variables: base_price <dbl>, dynamic_price <dbl>, revenue <dbl>

# Summary statistics
pricing_summary <- ride_data |>
  summarise(
    avg_demand = mean(base_demand),
    avg_supply = mean(base_supply),
    avg_multiplier = mean(price_multiplier),
    avg_price = mean(dynamic_price),
    total_revenue = sum(revenue),
    avg_revenue_per_ride = mean(revenue / adjusted_demand),
    total_consumer_surplus_loss = sum(consumer_surplus_loss)
  )

print("\nDynamic Pricing Summary:")
#> [1] "\nDynamic Pricing Summary:"
print(pricing_summary)
#> # A tibble: 1 × 7
#>   avg_demand avg_supply avg_multiplier avg_price total_revenue
#>        <dbl>      <dbl>          <dbl>     <dbl>         <dbl>
#> 1       304.       287.           1.03      515.     30120355.
#> # ℹ 2 more variables: avg_revenue_per_ride <dbl>,
#> #   total_consumer_surplus_loss <dbl>

# Peak vs off-peak comparison
ride_data <- ride_data |>
  mutate(
    period = case_when(
      hour %in% c(7, 8, 17, 18, 19) ~ "Peak",
      hour %in% c(2, 3, 4, 5) ~ "Off-Peak (night)",
      TRUE ~ "Off-Peak"
    )
  )

period_summary <- ride_data |>
  group_by(period) |>
  summarise(
    avg_multiplier = round(mean(price_multiplier), 2),
    avg_price = round(mean(dynamic_price), 0),
    avg_demand = round(mean(base_demand), 0),
    avg_supply = round(mean(base_supply), 0),
    total_revenue = round(sum(revenue), 0),
    .groups = "drop"
  )

print("\nRevenue by Period:")
#> [1] "\nRevenue by Period:"
print(period_summary)
#> # A tibble: 3 × 6
#>   period           avg_multiplier avg_price avg_demand avg_supply total_revenue
#>   <chr>                     <dbl>     <dbl>      <dbl>      <dbl>         <dbl>
#> 1 Off-Peak                   0.97       485        244        262      13641368
#> 2 Off-Peak (night)           1.15       573        202        159       3757277
#> 3 Peak                       1.12       560        567        466      12721710
Show code
import pandas as pd
import numpy as np
from datetime import datetime, timedelta

np.random.seed(4715)

# Generate hourly data for 7 days
start_date = pd.Timestamp("2024-01-01")
dates = pd.date_range(start_date, periods=7*24, freq='H')

ride_data = pd.DataFrame({'datetime': dates})
ride_data['hour'] = ride_data['datetime'].dt.hour
ride_data['day_of_week'] = ride_data['datetime'].dt.day_name()

# Base demand by hour
def demand_by_hour(hour):
    if hour in [7, 8]:
        return 500
    elif hour in [17, 18, 19]:
        return 600
    elif hour in [21, 22, 23]:
        return 400
    else:
        return 200

def supply_by_hour(hour):
    if hour in [2, 3, 4, 5]:
        return 150
    elif hour in [7, 8]:
        return 400
    elif hour in [17, 18, 19]:
        return 500
    elif hour in [21, 22, 23]:
        return 300
    else:
        return 250

ride_data['base_demand'] = ride_data['hour'].apply(demand_by_hour) * (1 + np.random.normal(0, 0.1, len(ride_data)))
ride_data['base_supply'] = ride_data['hour'].apply(supply_by_hour) * (1 + np.random.normal(0, 0.15, len(ride_data)))
ride_data['base_demand'] = ride_data['base_demand'].clip(lower=50)
ride_data['base_supply'] = ride_data['base_supply'].clip(lower=50)

# Dynamic pricing
ride_data['demand_supply_ratio'] = ride_data['base_demand'] / ride_data['base_supply']
ride_data['base_price'] = 500
ride_data['price_multiplier'] = (1 + 0.5 * (ride_data['demand_supply_ratio'] - 1)).clip(lower=0.5, upper=3.0)
ride_data['dynamic_price'] = ride_data['base_price'] * ride_data['price_multiplier']

# Adjust demand with elasticity (ε = -1)
ride_data['adjusted_demand'] = ride_data['base_demand'] * (ride_data['base_price'] / ride_data['dynamic_price']) ** (-1)
ride_data['revenue'] = ride_data['dynamic_price'] * ride_data['adjusted_demand']

print("Dynamic Pricing Sample (first 24 hours):")
#> Dynamic Pricing Sample (first 24 hours):
print(ride_data[['datetime', 'hour', 'base_demand', 'base_supply', 'dynamic_price', 'revenue']].head(24))
#>               datetime  hour  ...  dynamic_price        revenue
#> 0  2024-01-01 00:00:00     0  ...     493.186166  116696.029926
#> 1  2024-01-01 01:00:00     1  ...     490.376418   72103.891577
#> 2  2024-01-01 02:00:00     2  ...     517.131378   89689.287977
#> 3  2024-01-01 03:00:00     3  ...     530.129669   98088.088227
#> 4  2024-01-01 04:00:00     4  ...     646.960028  163566.926735
#> 5  2024-01-01 05:00:00     5  ...     636.357023  196581.117189
#> 6  2024-01-01 06:00:00     6  ...     452.702581   82845.683626
#> 7  2024-01-01 07:00:00     7  ...     558.329912  298226.782980
#> 8  2024-01-01 08:00:00     8  ...     672.314914  469802.341566
#> 9  2024-01-01 09:00:00     9  ...     488.777590  102242.200829
#> 10 2024-01-01 10:00:00    10  ...     441.970549   73306.372686
#> 11 2024-01-01 11:00:00    11  ...     429.962362   63850.514797
#> 12 2024-01-01 12:00:00    12  ...     494.504597   99606.294688
#> 13 2024-01-01 13:00:00    13  ...     468.339946   97440.720893
#> 14 2024-01-01 14:00:00    14  ...     446.979508   75672.408254
#> 15 2024-01-01 15:00:00    15  ...     443.678056   91263.515609
#> 16 2024-01-01 16:00:00    16  ...     449.446640   64657.366700
#> 17 2024-01-01 17:00:00    17  ...     511.118591  285583.533912
#> 18 2024-01-01 18:00:00    18  ...     545.198609  353384.510125
#> 19 2024-01-01 19:00:00    19  ...     554.661996  375162.500410
#> 20 2024-01-01 20:00:00    20  ...     463.953721   88605.462032
#> 21 2024-01-01 21:00:00    21  ...     587.784988  252531.100656
#> 22 2024-01-01 22:00:00    22  ...     623.930461  343759.304854
#> 23 2024-01-01 23:00:00    23  ...     694.646195  377724.883836
#> 
#> [24 rows x 6 columns]

# Summary
print("\nDynamic Pricing Summary:")
#> 
#> Dynamic Pricing Summary:
summary_stats = pd.DataFrame({
    'Metric': ['Avg Demand', 'Avg Supply', 'Avg Price Multiplier', 'Avg Price (₦)', 'Total Revenue (₦)'],
    'Value': [
        f"{ride_data['base_demand'].mean():.0f}",
        f"{ride_data['base_supply'].mean():.0f}",
        f"{ride_data['price_multiplier'].mean():.2f}x",
        f"₦{ride_data['dynamic_price'].mean():.0f}",
        f"₦{ride_data['revenue'].sum():.0f}"
    ]
})
print(summary_stats)
#>                  Metric      Value
#> 0            Avg Demand        301
#> 1            Avg Supply        283
#> 2  Avg Price Multiplier      1.04x
#> 3         Avg Price (₦)       ₦518
#> 4     Total Revenue (₦)  ₦29703121

# By period
def classify_period(hour):
    if hour in [7, 8, 17, 18, 19]:
        return "Peak"
    elif hour in [2, 3, 4, 5]:
        return "Off-Peak (night)"
    else:
        return "Off-Peak"

ride_data['period'] = ride_data['hour'].apply(classify_period)

period_summary = ride_data.groupby('period').agg({
    'price_multiplier': 'mean',
    'dynamic_price': 'mean',
    'base_demand': 'mean',
    'base_supply': 'mean',
    'revenue': 'sum'
}).round(0)

print("\nRevenue by Period:")
#> 
#> Revenue by Period:
print(period_summary)
#>                   price_multiplier  dynamic_price  ...  base_supply     revenue
#> period                                             ...                         
#> Off-Peak                       1.0          481.0  ...        261.0  13023399.0
#> Off-Peak (night)               1.0          606.0  ...        145.0   4261456.0
#> Peak                           1.0          561.0  ...        462.0  12418266.0
#> 
#> [3 rows x 5 columns]
Caution📝 Section 38.6 Review Questions
  1. In the ride-hailing model, if the price multiplier reaches 3x during peak demand, what is the implied demand/supply ratio?
  2. How would transparency (showing customers the price multiplier reason) affect the ethical acceptability of surge pricing?
  3. Propose a constraint to prevent dynamic pricing from creating unfair discrimination.

43.8 Case Study: Price Elasticity for Nigerian Telecom Data Bundles

43.8.1 Context

A Nigerian MNO operates three data bundle tiers (500MB, 2GB, 10GB) across a competitive market. Pricing has been static for 12 months, but the finance team wants to optimise prices to improve revenue and margin. The analytics team estimates price elasticities, simulates optimal pricing, and forecasts the revenue impact.

43.8.2 Data and Methodology

Synthetic weekly data: 3 bundles, 104 weeks, varying prices, quantities, and promotional activity (see Section 38.3). We estimate elasticities via log-log regression, compute optimal prices using the Lerner condition, and simulate revenue impact.

43.8.3 Implementation Summary

Using the code from Section 38.3 and 38.5:

Key Findings:

Bundle Estimated Elasticity Current Price Current Weekly Qty Current Profit Optimal Price Optimal Qty Optimal Profit Uplift
Basic -1.0 ₦500 50,000 ₦17.5M ₦570 47,600 ₦18.2M +4%
Standard -1.2 ₦1,500 30,000 ₦34.5M ₦1,650 27,200 ₦36.8M +7%
Premium -0.9 ₦3,500 15,000 ₦39M ₦3,850 14,200 ₦40.5M +4%
Total ₦91M ₦95.5M +5%

43.8.4 Implementation Code Summary

(Already presented in Sections 38.3 and 38.5)

43.8.5 Management Presentation

Recommendation: Implement Lerner-optimal pricing, increasing prices by 3-10% across tiers. Expected weekly revenue uplift: ₦4.5M (~5%), with minimal quantity loss due to moderate-inelastic demand. Phased rollout (test markets first) to monitor customer response and competitive reaction.

Risks: - Competitor response: If competitors match prices, no net gain. - Customer churn: If actual elasticity is more elastic than estimated, volume loss exceeds forecast. - Brand perception: Large price increases may be perceived as unfair.

Mitigation: - Test pricing in 2-3 states before full rollout. - Monitor churn weekly; revert if threshold exceeded. - Communicate price increases as FX/inflation-driven, not profit-grab.

Caution📝 Case Study Review Questions
  1. Why does the Standard bundle (elasticity -1.2) show a larger profit uplift (7%) than the Premium bundle (-0.9)?
  2. What additional data would you collect to validate the elasticity estimates before implementing price changes?
  3. If a competitor reduces prices by 5% after your increase, how would you re-estimate cross-elasticity and adjust your prices?

43.9 Exercises

Chapter 38 Exercises

  1. Profit Sensitivity Analysis: A brewery has ₦100M revenue, ₦60M COGS, ₦20M OpEx, ₦20M EBIT. Calculate the percentage EBIT change from (a) 2% price increase with inelastic demand (ε = -0.7), (b) 2% cost reduction, (c) 3% volume growth. Rank by impact.

  2. Elasticity Estimation: From 12 months of weekly data with 48 observations, you fit log(Q) = 3.2 - 1.15·log(P) + controls, with R² = 0.78 and elasticity SE = 0.12. Compute the 95% CI for elasticity. Can you reject the hypothesis that demand is unit-elastic?

  3. Lerner Pricing for Tiers: A SaaS company offers three subscription tiers with estimated elasticities -0.8, -1.2, -1.5 and marginal costs ₦500, ₦1,200, ₦2,500. Compute Lerner-optimal prices. Explain the pattern.

  4. Dynamic Pricing Fair-ness: Design a dynamic pricing rule for a Lagos ride-hailing app that (a) increases prices during peak demand, (b) avoids discriminating based on location (e.g., poor neighborhoods), and (c) is transparent to users.

  5. Conjoint Validation: You estimate WTP for a feature at ₦5,000 from conjoint analysis, but actual sales suggest WTP is ₦3,500. What could explain the discrepancy? How would you reconcile?

  6. Cross-Elasticity: Two telecom bundles (2GB at ₦1,000 and 5GB at ₦1,500) have own-elasticities of -1.0 and -1.2. Estimate the cross-elasticity between them. If you raise the 2GB price by 10%, by what % does 5GB quantity change?

  7. Seasonal Pricing: Data shows demand peaks in December (holiday season) and troughs in July (lean season). Design a pricing calendar that maximizes annual revenue. Should you raise prices in December or July?

  8. FX Pass-Through: A Nigerian importer’s input cost is denominated in USD. The naira devalued 20%. If you pass through 100%, how much does price need to rise? If elasticity is -1.1, what is the revenue impact?

  9. Competitive Pricing: Your firm and a competitor have demand functions Q_you = 100 - 5P_you + 2P_competitor. If competitor is at ₦20, what is your profit-maximizing price? How does it shift if competitor moves to ₦25?

  10. Customer Lifetime Value vs Price: You can raise prices 5% (reducing CLV by 2% due to volume loss) or acquire customers 10% cheaper. Which improves portfolio profitability more? Assume current CAC = ₦10,000, CLV = ₦100,000.

43.10 Further Reading

  • Pindyck, R. S., & Rubinfeld, D. L. (2018). Microeconomics (9th ed.). Pearson.
  • Phillips, R. L. (2005). Pricing and Revenue Optimization. Stanford University Press.
  • Nagle, T. T., & Müller, G. (2017). The Strategy and Tactics of Pricing (5th ed.). Routledge.
  • Hanson, B., & Putler, D. S. (1996). “Hits and misses: Heuristics for category demand estimation.” Journal of Marketing Research, 33(3), 276–288.
  • Toubia, O., Hauser, J. R., & Simester, D. I. (2004). “Fast polyhedral adaptive conjoint estimation.” Marketing Science, 23(2), 217–228.

43.11 Chapter 38 Appendix: Pricing Derivations

43.11.1 A1. Revenue Maximisation from Elasticity

Given \(Q(P) = Q_0 P^{\varepsilon}\), revenue is:

\[R(P) = P \cdot Q(P) = P \cdot Q_0 P^{\varepsilon} = Q_0 P^{1 + \varepsilon}\]

Taking the derivative:

\[\frac{dR}{dP} = Q_0 (1 + \varepsilon) P^{\varepsilon}\]

Setting \(\frac{dR}{dP} = 0\) requires \(1 + \varepsilon = 0\), so \(\varepsilon = -1\) (unit elastic).

43.11.2 A2. Lerner Index and Optimal Price

The Lerner index is the price-cost margin relative to price:

\[L = \frac{P - MC}{P}\]

Profit-maximisation (first-order condition) yields:

\[L = -\frac{1}{\varepsilon}\]

Substituting and solving for P:

\[\frac{P - MC}{P} = -\frac{1}{\varepsilon}\]

\[1 - \frac{MC}{P} = -\frac{1}{\varepsilon}\]

\[P = \frac{MC}{1 + \frac{1}{\varepsilon}} = \frac{MC}{\frac{\varepsilon + 1}{\varepsilon}} = MC \cdot \frac{\varepsilon}{\varepsilon + 1}\]

43.11.3 A3. Log-Log Regression Derivation

Demand: \(Q = \alpha P^{\varepsilon}\). Taking natural logs:

\[\ln Q = \ln \alpha + \varepsilon \ln P\]

Adding controls and error:

\[\ln Q_t = \beta_0 + \varepsilon \ln P_t + \sum_{i} \beta_i X_{i,t} + \epsilon_t\]

The coefficient on \(\ln P_t\) recovered via OLS is the point estimate of \(\varepsilon\).

43.11.4 A4. Profit Change from Price Increase

If price increases by \(\Delta P\) and quantity changes from \(Q\) to \(Q' = Q \cdot (1 + \Delta P / P)^{\varepsilon}\):

\[\Delta \text{Profit} = (P + \Delta P)(Q + \Delta Q) - MC(Q + \Delta Q) - (PQ - MCQ)\]

\[\approx (P - MC) \Delta Q + Q \Delta P = (P - MC) Q (1 + \Delta P / P)^{\varepsilon} - Q + Q \Delta P / P\]

\[\approx Q [(\Delta P / P)(1 + \varepsilon) + \text{higher-order terms}]\]

Thus, revenue increases if \(1 + \varepsilon > 0\) (i.e., \(\varepsilon > -1\)), confirming the inelastic pricing rule.