7. Modeling Non-linear Pricing Elasiticity

The pricing elasticity curve, defined as

  • demand/quantity (y-axis) versus

  • price (x-axis),

is often modeled using linear models. However, we sometime observe that the pricing elasticity curve is curvilinear; namely, there is an exponential decay between quantity and price; as price goes up, quantity decays exponentially. Let’s see how we can model the exponential decay relationship.

7.1. Simulate price vs quantity

The exponential decay model is defined as follows.

\(N(t) = N_0 e^{-\lambda t}\)

Where,

  • \(t\) is time,

  • \(N_0\) is the starting amount at time zero, and

  • \(\lambda\) is the decay rate.

We will generate an exponential decay relationships between price and quantity. Below, we show some simulations where we add Gaussian noise.

[1]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

np.random.seed(37)

def get_elasticity_curve(N_0=1, decay_rate=-0.05, n=1_000, start=0, end=100, std=0.01):
    x = np.linspace(start, end, n)
    y = (N_0 * np.exp(decay_rate * x)) + np.random.normal(0, std, len(x))

    return pd.Series(y, x)

fig, ax = plt.subplots()

get_elasticity_curve(N_0=500, decay_rate=-0.05, std=10).plot(kind='line', ax=ax, label='-0.05')
get_elasticity_curve(N_0=500, decay_rate=-0.1, std=10).plot(kind='line', ax=ax, label='-0.01')
get_elasticity_curve(N_0=500, decay_rate=-0.5, std=10).plot(kind='line', ax=ax, label='-0.5')

ax.set_xlabel('P')
ax.set_ylabel('Q')
ax.set_title('Simulated price vs quantity exponential decay')

ax.legend()

fig.tight_layout()
_images/non-linear-pricing-elasticity_2_0.png

7.2. Model price vs quantity

Let’s simulate a single elasticity curve with an exponential decay relationship between price and quantity. We will then use GAM to model the relationship between \(P\) and \(Q\).

[2]:
from pygam import LinearGAM, s, f

def get_elasticity_curve_df(N_0=1, decay_rate=-0.05, n=1_000, start=0, end=100, std=0.01):
    return get_elasticity_curve(N_0, decay_rate, n, start, end, std) \
        .to_frame() \
        .reset_index() \
        .rename(columns={
            'index': 'P',
            0: 'Q'
        })

Xy = get_elasticity_curve_df(N_0=500, std=10)
X = Xy[['P']]
y = Xy['Q']

pq_model = LinearGAM(s(0, n_splines=10)).fit(X, y)
pq_df = X.assign(Q=pq_model.predict(X))

You can see below that GAM does a very good job at smoothing the noisy relationship.

[3]:
fig, ax = plt.subplots(figsize=(7, 3.5))

Xy.plot(kind='line', x='P', y='Q', ax=ax, label='Simulated')
pq_df.plot(kind='line', x='P', y='Q', ylabel='Q', ax=ax, label='GAM Predicted', color='red')

ax.set_title('Simulated vs GAM predicted P vs Q')

fig.tight_layout()
_images/non-linear-pricing-elasticity_6_0.png

7.3. Model quantity vs revenue

The revenue, \(R\), is defined as \(R = PQ\). We will use GAM to model the relationship between \(Q\) and \(R\).

[4]:
Xy = pq_df.assign(R=lambda d: d['P'] * d['Q'])

X = Xy[['Q']]
y = Xy['R']

qr_model = LinearGAM(s(0, n_splines=10)).fit(X, y)
pqr_df = pq_df.assign(R=lambda d: qr_model.predict(d[['Q']]))
[5]:
fig, ax = plt.subplots(figsize=(7, 3.5))

Xy.plot(kind='line', x='Q', y='R', ax=ax, label='Simulated')
pqr_df.plot(kind='line', x='Q', y='R', ylabel='R', ax=ax, label='GAM predicted')

ax.set_title('Simulated vs GAM predicted Q vs R')

fig.tight_layout()
_images/non-linear-pricing-elasticity_9_0.png

7.4. Marginal revenue

The marginal revenue, \(R'\), is defined as follows.

\(R' = \dfrac{\mathrm{d} R}{\mathrm{d} Q}\)

In the linear model, we can analytically solve for \(R'\) (also denoted as, MR). With a non-linear relationship, especially using GAM, we need to use numerical methods to estimate \(R'\). Here, we use numdifftools.

[6]:
%%time

import numdifftools as nd

dfun = nd.Gradient(lambda Q: qr_model.predict(pd.DataFrame(Q.reshape(-1,1), columns=['Q'])))
mr = np.array([dfun(v) for v in pqr_df['Q'].values])
pqrr_df = pqr_df.assign(MR=mr)
CPU times: user 10.3 s, sys: 22.1 ms, total: 10.4 s
Wall time: 9.99 s

Now we can plot \(P, Q, R\) and \(R'\).

  • As \(P\) goes up, \(Q\) goes down

  • As \(P\) goes up, \(R\) goes up and then down

  • As \(P\) goes up, \(R'\) goes up

  • As \(Q\) goes up, \(R\) goes up and then down

  • As \(Q\) goes up, \(R'\) goes down

  • As \(R'\) goes up, \(R\) goes up and then down

[7]:
fig, axes = plt.subplots(3, 3, figsize=(15, 7.5))
axes = np.ravel(axes)

pqrr_df.plot(kind='line', x='P', y='Q', ylabel='Q', ax=axes[0])
pqrr_df.plot(kind='line', x='P', y='R', ylabel='R', ax=axes[1])
pqrr_df.plot(kind='line', x='P', y='MR', ylabel='MR', ax=axes[2])
axes[3].axis('off')
pqrr_df.plot(kind='line', x='Q', y='R', ylabel='R', ax=axes[4])
pqrr_df.plot(kind='line', x='Q', y='MR', ylabel='MR', ax=axes[5])
axes[6].axis('off')
axes[7].axis('off')
pqrr_df.plot(kind='line', x='MR', y='R', ylabel='R', ax=axes[8])

fig.tight_layout()
_images/non-linear-pricing-elasticity_13_0.png

7.5. Optimal price

The optimal \(R'\) is found when it is equal to the marginal cost, \(C'\) (or MC). Let’s say \(C'\) is given as 20, then we can find the closest matching \(R'\) and simply lookup the corresponding \(R, Q,\) and \(P\). According to the calculations, the optimal \(P\) is 38.63, expected to sell \(Q=73\) units, with a revenue of \(R=2,870.97\).

[8]:
pqrr_df \
    .assign(MC=20) \
    .assign(diff=lambda d: np.abs(d['MC'] - d['MR'])) \
    .sort_values(['diff']) \
    .head(5)
[8]:
P Q R MR MC diff
386 38.638639 73.383914 2870.970368 20.016115 20 0.016115
385 38.538539 73.735715 2877.985594 19.865738 20 0.134262
387 38.738739 73.033705 2863.934233 20.166475 20 0.166475
384 38.438438 74.089122 2884.979680 19.715358 20 0.284642
388 38.838839 72.685076 2856.877481 20.316345 20 0.316345

Define the total cost as \(C = C'Q\), then the profit, \(T\) is defined as \(T= PQ - C'Q\).

[9]:
pqrr_df \
    .assign(MC=20) \
    .assign(diff=lambda d: np.abs(d['MC'] - d['MR'])) \
    .sort_values(['diff']) \
    .assign(C=lambda d: d['MC'] * d['Q']) \
    .assign(T=lambda d: d['R'] - d['C']) \
    .drop(columns=['diff']) \
    .head(5)
[9]:
P Q R MR MC C T
386 38.638639 73.383914 2870.970368 20.016115 20 1467.678282 1403.292086
385 38.538539 73.735715 2877.985594 19.865738 20 1474.714310 1403.271284
387 38.738739 73.033705 2863.934233 20.166475 20 1460.674101 1403.260132
384 38.438438 74.089122 2884.979680 19.715358 20 1481.782437 1403.197243
388 38.838839 72.685076 2856.877481 20.316345 20 1453.701513 1403.175967
[10]:
pqrct_df = pqrr_df \
    .assign(MC=20) \
    .assign(C=lambda d: d['MC'] * d['Q']) \
    .assign(T=lambda d: d['R'] - d['C'])
pqrct_df
[10]:
P Q R MR MC C T
0 0.0000 488.882022 50.394511 -21.968368 20 9777.640441 -9727.245930
1 0.1001 486.895001 94.018082 -21.939546 20 9737.900029 -9643.881947
2 0.2002 484.910265 137.530696 -21.907096 20 9698.205305 -9560.674609
3 0.3003 482.927849 180.924531 -21.871031 20 9658.556983 -9477.632452
4 0.4004 480.947789 224.191824 -21.831366 20 9618.955777 -9394.763953
... ... ... ... ... ... ... ...
995 99.5996 3.832953 477.329433 48.253121 20 76.659067 400.670366
996 99.6997 3.821495 476.776521 48.257495 20 76.429906 400.346615
997 99.7998 3.809963 476.219975 48.261897 20 76.199260 400.020715
998 99.8999 3.798353 475.659636 48.266329 20 75.967063 399.692573
999 100.0000 3.786662 475.095342 48.270791 20 75.733249 399.362094

1000 rows × 7 columns

[11]:
opt_s = pqrr_df \
    .assign(MC=20) \
    .assign(C=lambda d: d['MC'] * d['Q']) \
    .assign(T=lambda d: d['R'] - d['C']) \
    .assign(diff=lambda d: np.abs(d['MC'] - d['MR'])) \
    .sort_values(['diff']) \
    .head(1) \
    .iloc[0]
opt_s
[11]:
P         38.638639
Q         73.383914
R       2870.970368
MR        20.016115
MC        20.000000
C       1467.678282
T       1403.292086
diff       0.016115
Name: 386, dtype: float64
[12]:
fig, ax = plt.subplots(1, 4, figsize=(15, 3.5))

for y, _ax in zip(['Q', 'R', 'MR', 'T'], ax):
    pqrct_df.plot(kind='line', x='P', y=y, ax=_ax)
    _ax.axvline(x=opt_s['P'], color='r', linestyle='--')
    _ax.axhline(y=opt_s[y], color='g', linestyle='--')
    _ax.set_ylabel(y)
    _ax.set_title(rf'Optimal $P$={opt_s["P"]:.2f}, ${y}$={opt_s[y]:,.0f}')
    _ax.legend().remove()

fig.tight_layout()
_images/non-linear-pricing-elasticity_20_0.png

7.6. Point elasticity

The point elasticity is defined as follows.

\(E_p = \dfrac{\mathrm{d}q_i}{\mathrm{d}p_i} \dfrac{p_i}{q_i}\)

[14]:
%%time

dfun = nd.Gradient(lambda P: pq_model.predict(pd.DataFrame(P.reshape(-1,1), columns=['P'])))
slope = np.array([dfun(v) for v in pqr_df['P'].values])
pqrrs_df = pqrr_df.assign(slope=slope)
CPU times: user 10.3 s, sys: 41.9 ms, total: 10.3 s
Wall time: 10.2 s
[15]:
ped_df = pqrrs_df \
    .assign(MC=20) \
    .assign(C=lambda d: d['MC'] * d['Q']) \
    .assign(T=lambda d: d['R'] - d['C']) \
    .assign(PED=lambda d: d['slope'] * d['P'] / d['Q'])

ped_df
[15]:
P Q R MR slope MC C T PED
0 0.0000 488.882022 50.394511 -21.968368 -19.861627 20 9777.640441 -9727.245930 -0.000000
1 0.1001 486.895001 94.018082 -21.939546 -19.838985 20 9737.900029 -9643.881947 -0.004079
2 0.2002 484.910265 137.530696 -21.907096 -19.815985 20 9698.205305 -9560.674609 -0.008181
3 0.3003 482.927849 180.924531 -21.871031 -19.792629 20 9658.556983 -9477.632452 -0.012308
4 0.4004 480.947789 224.191824 -21.831366 -19.768916 20 9618.955777 -9394.763953 -0.016458
... ... ... ... ... ... ... ... ... ...
995 99.5996 3.832953 477.329433 48.253121 -0.114106 20 76.659067 400.670366 -2.965057
996 99.6997 3.821495 476.776521 48.257495 -0.114831 20 76.429906 400.346615 -2.995857
997 99.7998 3.809963 476.219975 48.261897 -0.115590 20 76.199260 400.020715 -3.027804
998 99.8999 3.798353 475.659636 48.266329 -0.116381 20 75.967063 399.692573 -3.060914
999 100.0000 3.786662 475.095342 48.270791 -0.117205 20 75.733249 399.362094 -3.095205

1000 rows × 9 columns

The price with near unit elasticity is 19.12.

[16]:
ped_df.assign(diff=lambda d: np.abs(d['PED'] + 1)).sort_values(['diff']).head(1)
[16]:
P Q R MR slope MC C T PED diff
194 19.419419 187.437686 3628.303408 0.557074 -9.672721 20 3748.753713 -120.450305 -1.002139 0.002139

The optimal prices is highly elastic; if there is a 1% increase in price, then that will lead to a 1.8% decrease in demand/quantity.

[17]:
ped_df.query(f'P == {opt_s["P"]}')
[17]:
P Q R MR slope MC C T PED
386 38.638639 73.383914 2870.970368 20.016115 -3.506521 20 1467.678282 1403.292086 -1.846279
[18]:
 ped_df \
    .plot(kind='line', x='P', y='PED', ylabel='PED') \
    .legend().remove()
_images/non-linear-pricing-elasticity_28_0.png

7.7. Mesh plots

Let’s look at some mesh plots for the following models.

  • \(T \sim f_T(P, Q)\)

  • \(R \sim f_R(P, Q)\)

  • \(R' \sim f_{R'}(P, Q)\)

  • \(C \sim f_C(P, Q)\)

  • \(E \sim f_E(P, Q)\)

[19]:
from sklearn.ensemble import RandomForestRegressor

def get_XYZ(_X, _Y, _Z):
    X = ped_df[[_X, _Y]]
    y = ped_df[_Z]

    m = RandomForestRegressor(n_jobs=-1, random_state=37).fit(X, y)

    # X = np.linspace(ped_df['P'].min(), ped_df['P'].max(), 500)
    # Y = np.linspace(ped_df['Q'].min(), ped_df['Q'].max(), 500)
    X = np.linspace(18, 45, 500)
    Y = np.linspace(60, 100, 500)

    X, Y = np.meshgrid(X, Y)
    Z = np.array([m.predict(pd.DataFrame({'P': x, 'Q': y})) for x, y in zip(X, Y)])

    return X, Y, Z

7.7.1. \(T \sim f_T(P, Q)\)

[20]:
X, Y, Z = get_XYZ('P', 'Q', 'T')
[21]:
fig = plt.figure(figsize=(10, 10))

ax = fig.add_subplot(111, projection='3d')

surf = ax.plot_surface(X, Y, Z, cmap='viridis')

ax.set_xlabel('P')
ax.set_ylabel('Q')
ax.set_zlabel('T')

ax.view_init(azim=90, elev=20)

fig.tight_layout()
_images/non-linear-pricing-elasticity_33_0.png

7.7.2. \(R \sim f_R(P, Q)\)

[22]:
X, Y, Z = get_XYZ('P', 'Q', 'R')
[23]:
fig = plt.figure(figsize=(10, 10))

ax = fig.add_subplot(111, projection='3d')

surf = ax.plot_surface(X, Y, Z, cmap='viridis')

ax.set_xlabel('P')
ax.set_ylabel('Q')
ax.set_zlabel('R')

fig.tight_layout()
_images/non-linear-pricing-elasticity_36_0.png

7.7.3. \(R' \sim f_{R'}(P, Q)\)

[24]:
X, Y, Z = get_XYZ('P', 'Q', 'MR')
[25]:
fig = plt.figure(figsize=(10, 10))

ax = fig.add_subplot(111, projection='3d')

surf = ax.plot_surface(X, Y, Z, cmap='viridis')

ax.set_xlabel('P')
ax.set_ylabel('Q')
ax.set_zlabel('MR')

ax.view_init(azim=90, elev=20)

fig.tight_layout()
_images/non-linear-pricing-elasticity_39_0.png

7.7.4. \(C \sim f_C(P, Q)\)

[26]:
X, Y, Z = get_XYZ('P', 'Q', 'C')
[27]:
fig = plt.figure(figsize=(10, 10))

ax = fig.add_subplot(111, projection='3d')

surf = ax.plot_surface(X, Y, Z, cmap='viridis')

ax.set_xlabel('P')
ax.set_ylabel('Q')
ax.set_zlabel('C')

fig.tight_layout()
_images/non-linear-pricing-elasticity_42_0.png

7.7.5. \(E \sim f_E(P, Q)\)

[28]:
X, Y, Z = get_XYZ('P', 'Q', 'PED')
[29]:
fig = plt.figure(figsize=(10, 10))

ax = fig.add_subplot(111, projection='3d')

surf = ax.plot_surface(X, Y, Z, cmap='viridis')

ax.set_xlabel('P')
ax.set_ylabel('Q')
ax.set_zlabel('PED')

fig.tight_layout()
_images/non-linear-pricing-elasticity_45_0.png