Mathew K Analytics

Lesson 6 · Real-World Data Analytics

Python Data Analytics #06: Price & Discount Impact Analysis in Python

Video six of the hundred-video real-world data analytics series. Measuring real price elasticity of demand directly from real historical price changes…

📓 Full notebook

Download .ipynb

Data Analytics 100, Video 6: Price and Discount Impact Analysis#

  • Video six of the hundred-video real-world data analytics series.
  • Measuring real price elasticity of demand directly from real historical price changes already sitting in the same retail data.
  • Let's get into it.

Part 1: Where Real Price Variation Comes From#

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
clean = pd.read_csv('online_retail_clean.csv', parse_dates=['InvoiceDate'])
clean.shape
(391150, 9)

Part 2: Real Description Lookup per Product#

desc_lookup = clean.groupby('StockCode')['Description'].agg(lambda s: s.mode().iloc[0])
desc_lookup.head(3)
StockCode
10002    INFLATABLE POLITICAL GLOBE 
10080       GROOVY CACTUS INFLATABLE
10120                   DOGGY RUBBER
Name: Description, dtype: object

Part 3: How Many Real Products Actually Have Price Variation#

price_variation = clean.groupby('StockCode')['UnitPrice'].nunique()
(price_variation > 1).sum(), len(price_variation)
(np.int64(2623), 3659)

Part 4: Picking One Real Product for a Deep Dive#

deep_dive_sc = '85099B'
desc_lookup[deep_dive_sc]
sub = clean[clean['StockCode'] == deep_dive_sc]
sub.shape[0]
1615

Part 5: Real Quantity Sold at Each Real Price Point#

by_price = sub.groupby('UnitPrice').agg(TotalQty=('Quantity', 'sum'), Orders=('InvoiceNo', 'nunique')).reset_index()
by_price.sort_values('UnitPrice')
UnitPrice TotalQty Orders
0 1.65 9230 65
1 1.74 1500 10
2 1.75 800 5
3 1.79 19136 135
4 1.95 3982 317
5 2.04 100 1
6 2.08 11324 1062
7 4.13 6 5

Part 6: Visualizing Real Price vs Real Quantity#

plt.figure(figsize=(8, 6))
plt.scatter(by_price['UnitPrice'], by_price['TotalQty'], s=80, color='darkorange')
plt.xlabel('Real Unit Price (GBP)')
plt.ylabel('Real Total Quantity Sold')
plt.title(f'Real Price vs Quantity for {desc_lookup[deep_dive_sc]}')
plt.tight_layout()
plt.savefig('price_vs_quantity_scatter.png', dpi=120)
plt.close()

Part 7: Real Log-Log Elasticity Regression#

valid = by_price[by_price['TotalQty'] > 0]
log_price = np.log(valid['UnitPrice'])
log_qty = np.log(valid['TotalQty'])
elasticity, intercept = np.polyfit(log_price, log_qty, 1)
round(elasticity, 2)
np.float64(-7.4)

Part 8: Interpreting the Real Elasticity Value#

is_elastic = abs(elasticity) > 1
print(f'Elasticity of {round(elasticity, 2)} means this product is {"elastic" if is_elastic else "inelastic"}: a 1% price cut is associated with roughly a {round(abs(elasticity), 1)}% change in units sold.')
Elasticity of -7.4 means this product is elastic: a 1% price cut is associated with roughly a 7.4% change in units sold.

Part 9: Defining a Real Regular Price vs Real Discount Price#

regular_price = sub['UnitPrice'].mode().iloc[0]
regular_price
sub = sub.copy()
sub['IsDiscounted'] = sub['UnitPrice'] < regular_price
sub['IsDiscounted'].mean().round(3)
np.float64(0.331)

Part 10: Real Average Order Size, Discounted vs Full Price#

avg_qty_discounted = sub[sub['IsDiscounted']]['Quantity'].mean()
avg_qty_full = sub[~sub['IsDiscounted']]['Quantity'].mean()
round(avg_qty_discounted, 1), round(avg_qty_full, 1)
(np.float64(64.9), np.float64(10.5))

Part 11: Did Real Discounting Actually Grow Total Revenue#

revenue_discounted = sub[sub['IsDiscounted']]['Revenue'].sum()
revenue_full = sub[~sub['IsDiscounted']]['Revenue'].sum()
round(revenue_discounted, 2), round(revenue_full, 2)
(np.float64(61461.84), np.float64(23578.7))

Part 12: Real Revenue per Percent Discount#

discount_rows = sub[sub['IsDiscounted']].copy()
discount_rows['DiscountPct'] = (regular_price - discount_rows['UnitPrice']) / regular_price * 100
discount_rows.groupby(pd.cut(discount_rows['DiscountPct'], bins=[0, 10, 20, 30, 100]), observed=True)['Quantity'].mean().round(1)
DiscountPct
(0, 10]      12.8
(10, 20]    142.9
(20, 30]    142.0
Name: Quantity, dtype: float64

Part 13: Scaling Up to Real Many Products#

qty_by_sc = clean.groupby('StockCode')['Quantity'].sum()
price_var_by_sc = clean.groupby('StockCode')['UnitPrice'].nunique()
candidates = price_var_by_sc[(price_var_by_sc >= 4) & (qty_by_sc > 500)].index
len(candidates)
373

Part 14: Real Elasticity Loop Across Products#

results = []
for sc in candidates:
    s = clean[clean['StockCode'] == sc]
    bp = s.groupby('UnitPrice')['Quantity'].sum()
    bp = bp[bp > 0]
    if len(bp) < 4 or bp.index.to_series().std() == 0:
        continue
    lp, lq = np.log(bp.index.values.astype(float)), np.log(bp.values.astype(float))
    e, _ = np.polyfit(lp, lq, 1)
    results.append((sc, desc_lookup[sc], e, len(bp)))
len(results)
373

Part 15: Real Elasticity Results Table#

elasticity_df = pd.DataFrame(results, columns=['StockCode', 'Description', 'Elasticity', 'NumPricePoints'])
elasticity_df.shape[0]
elasticity_df.sort_values('Elasticity').head(5)
StockCode Description Elasticity NumPricePoints
248 22978 PANTRY ROLLING PIN -8.827491 5
140 22386 JUMBO BAG PINK POLKADOT -8.745900 7
206 22730 ALARM CLOCK BAKELIKE IVORY -8.643995 5
49 21754 HOME BUILDING BLOCK WORD -8.480004 4
217 22766 PHOTO FRAME CORNICE -7.958406 4

Part 16: What Fraction Show Real Normal Demand Behavior#

pct_negative = (elasticity_df['Elasticity'] < 0).mean()
round(pct_negative * 100, 1)
np.float64(89.0)

Part 17: Real Average Elasticity Across the Catalog#

elasticity_df['Elasticity'].mean().round(2)
elasticity_df['Elasticity'].median().round(2)
np.float64(-2.99)

Part 18: Visualizing the Real Elasticity Distribution#

plt.figure(figsize=(9, 5))
plt.hist(elasticity_df['Elasticity'], bins=30, color='seagreen', edgecolor='white')
plt.axvline(-1, color='red', linestyle='--', label='Real elastic/inelastic boundary')
plt.xlabel('Real Estimated Elasticity')
plt.ylabel('Real Number of Products')
plt.title('Real Distribution of Price Elasticity Across the Catalog')
plt.legend()
plt.tight_layout()
plt.savefig('elasticity_distribution.png', dpi=120)
plt.close()

Part 19: Real Most Price-Sensitive Products#

most_elastic = elasticity_df.sort_values('Elasticity').head(10)
most_elastic[['Description', 'Elasticity']]
Description Elasticity
248 PANTRY ROLLING PIN -8.827491
140 JUMBO BAG PINK POLKADOT -8.745900
206 ALARM CLOCK BAKELIKE IVORY -8.643995
49 HOME BUILDING BLOCK WORD -8.480004
217 PHOTO FRAME CORNICE -7.958406
50 LOVE BUILDING BLOCK WORD -7.858437
357 CHILDRENS CUTLERY RETROSPOT RED -7.830124
287 JUMBO BAG 50'S CHRISTMAS -7.753061
150 GUMBALL COAT RACK -7.743492
181 PIGGY BANK RETROSPOT -7.721176

Part 20: Real Least Price-Sensitive Products#

least_elastic = elasticity_df[elasticity_df['Elasticity'] > -1].sort_values('Elasticity', ascending=False).head(10)
least_elastic[['Description', 'Elasticity']]
Description Elasticity
241 SET OF 20 VINTAGE CHRISTMAS NAPKINS 15.289603
270 JUMBO BAG VINTAGE DOILY 10.939941
259 PANTRY CHOPPING BOARD 6.685920
22 POTTERING IN THE SHED METAL SIGN 6.468089
71 JUMBO STORAGE BAG SKULLS 6.235546
250 SET 2 PANTRY DESIGN TEA TOWELS 5.879909
154 PICNIC BASKET WICKER LARGE 5.257591
295 WALL ART VILLAGE SHOW 4.902284
288 LETTER HOLDER HOME SWEET HOME 4.843712
366 JUMBO BAG BAROQUE BLACK WHITE 4.811251

Part 21: Real Revenue-Maximizing Price for the Deep-Dive Product#

candidate_prices = np.linspace(valid['UnitPrice'].min(), valid['UnitPrice'].max(), 50)
predicted_qty = np.exp(intercept) * candidate_prices ** elasticity
predicted_revenue = candidate_prices * predicted_qty
optimal_price = candidate_prices[np.argmax(predicted_revenue)]
round(optimal_price, 2)
np.float64(1.65)

Part 22: Visualizing the Real Predicted Revenue Curve#

plt.figure(figsize=(8, 6))
plt.plot(candidate_prices, predicted_revenue, color='purple')
plt.axvline(optimal_price, color='red', linestyle='--', label=f'Real optimal price: {round(optimal_price, 2)}')
plt.xlabel('Real Hypothetical Unit Price (GBP)')
plt.ylabel('Real Predicted Total Revenue')
plt.title(f'Real Predicted Revenue Curve for {desc_lookup[deep_dive_sc]}')
plt.legend()
plt.tight_layout()
plt.savefig('revenue_curve_optimal_price.png', dpi=120)
plt.close()

Part 23: Real Sanity Check on the Elasticity Math#

manual_pct_price_change = (valid['UnitPrice'].iloc[-1] - valid['UnitPrice'].iloc[0]) / valid['UnitPrice'].iloc[0]
manual_pct_qty_change = (valid['TotalQty'].iloc[-1] - valid['TotalQty'].iloc[0]) / valid['TotalQty'].iloc[0]
np.sign(manual_pct_qty_change) != np.sign(manual_pct_price_change)
np.True_

Part 24: Saving the Real Elasticity Results#

elasticity_df.sort_values('Elasticity').to_csv('product_price_elasticity.csv', index=False)
reloaded = pd.read_csv('product_price_elasticity.csv')
reloaded.shape[0] == elasticity_df.shape[0]
True

Part 25: Real Business Takeaway#

n_elastic = (elasticity_df['Elasticity'] < -1).sum()
n_inelastic = (elasticity_df['Elasticity'] >= -1).sum()
print(f'{n_elastic} real products are genuinely elastic and good discounting candidates; {n_inelastic} are inelastic, where discounting mostly just gives away real margin.')
303 real products are genuinely elastic and good discounting candidates; 70 are inelastic, where discounting mostly just gives away real margin.

Part 26: Real Weekday Effect on Discount Timing#

sub['Weekday'] = sub['InvoiceDate'].dt.day_name()
sub.groupby('Weekday')['IsDiscounted'].mean().round(3).sort_values(ascending=False)
Weekday
Tuesday      0.391
Monday       0.357
Wednesday    0.344
Friday       0.330
Thursday     0.318
Sunday       0.207
Name: IsDiscounted, dtype: float64

Part 27: Real Country Mix Among Discount Buyers#

sub[sub['IsDiscounted']]['Country'].value_counts().head(5)
Country
United Kingdom    483
Netherlands        14
Germany             8
France              8
Belgium             4
Name: count, dtype: int64

Part 28: Real Correlation Between Elasticity and Sales Volume#

volume_lookup = clean.groupby('StockCode')['Quantity'].sum()
elasticity_df['TotalVolume'] = elasticity_df['StockCode'].map(volume_lookup)
elasticity_df[['Elasticity', 'TotalVolume']].corr().iloc[0, 1].round(3)
np.float64(-0.074)

Part 29: Real Price Point Count vs Elasticity Reliability#

reliable = elasticity_df[elasticity_df['NumPricePoints'] >= 6]
reliable.shape[0]
reliable['Elasticity'].mean().round(2)
np.float64(-3.38)

Part 30: Real Recap Print#

print(f'Estimated price elasticity for {len(elasticity_df)} real products; {n_elastic} are genuinely discount-worthy.')
Estimated price elasticity for 373 real products; 303 are genuinely discount-worthy.

Wrap-Up: What You Learned#

  • Real price elasticity of demand can genuinely be estimated straight from a real product's own historical price changes, no experiment required.
  • A real log-log regression of quantity against price gives a real slope that is, by definition, the elasticity.
  • An elasticity below negative one means demand is genuinely elastic, discounting pays off in real extra volume; above negative one, discounting mostly gives away real margin.
  • The real most-frequent price makes a reasonable real stand-in regular price when no explicit discount flag exists in the raw data.
  • A real fitted elasticity curve can even suggest a real revenue-maximizing price directly, not just whether to discount at all.
  • Next video: real customer lifetime value modeling, projecting how much each real customer is actually worth going forward.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.