|
"""Streamlit entrypoint""" |
|
|
|
import base64 |
|
import time |
|
|
|
import numpy as np |
|
import streamlit as st |
|
import sympy |
|
|
|
from helpers.thompson_sampling import ThompsonSampler |
|
|
|
eta, a, p, D, profit, var_cost, fixed_cost = sympy.symbols("eta a p D Profit varcost fixedcost") |
|
np.random.seed(42) |
|
|
|
st.set_page_config( |
|
page_title="๐ธ Dynamic Pricing ๐ธ", |
|
page_icon="๐ธ", |
|
layout="centered", |
|
initial_sidebar_state="auto", |
|
menu_items={ |
|
'Get help': None, |
|
'Report a bug': None, |
|
'About': "https://www.ml6.eu/", |
|
} |
|
) |
|
|
|
st.title("๐ธ Dynamic Pricing ๐ธ") |
|
st.subheader("Setting optimal prices with Bayesian stats ๐") |
|
|
|
|
|
st.header("Let's start with the basics ๐") |
|
|
|
st.markdown("The beginning is usually a good place to start so we'll kick things off there.") |
|
st.markdown("""The one crucial piece information we need to find the optimal price is |
|
**how demand behaves over different price points**. \nIf we can get make a decent guess of what we |
|
can expect demand to be for a wide range of prices, we can figure out which price optimizes our target |
|
(i.e., revenue, profit, ...).""") |
|
st.markdown("""For the keen economists amongst you, this is beginning to sound a lot like a |
|
**demand curve**.""") |
|
|
|
st.markdown("""Estimating a demand curve, sounds easy enough right? \nLet's assume we have |
|
demand with constant price elasticity; so a certain percent change in price will cause a |
|
constant percent change in demand, independent of the price level. This is often seen as a |
|
reasonable proxy for demand curves in the wild.""") |
|
st.markdown("So our data will look something like this:") |
|
st.image("assets/images/ideal_case_demand.png") |
|
st.markdown("""Alright now we can get out our trusted regression toolbox and fit a nice curve |
|
through the data because we know that our constant-elasticity demand function looks something |
|
like this:""") |
|
st.latex(sympy.latex(sympy.Eq(sympy.Function(D)(p), a*p**(-eta), evaluate=False))) |
|
st.write("with shape parameter a and price elasticity ฮท") |
|
st.image("assets/images/ideal_case_demand_fitted.png") |
|
st.markdown("""Now that we have a reasonable estimate of our demand, we can derive our expected |
|
profit at different price points because we know the following holds:""") |
|
st.latex(f"{profit} = {p}*{sympy.Function(D)(p)} - [{var_cost}*{sympy.Function(D)(p)} + {fixed_cost}]") |
|
st.image("assets/images/ideal_case_profit_curve.png") |
|
st.markdown("""Finally we can dust off our good old-fashioned high-school math and find the |
|
price which we expect will optimize profit which is ultimately the goal of all this.""") |
|
st.image("assets/images/ideal_case_optimal_profit.png") |
|
st.markdown("""Voilร there you have it: we should price this product at 4.24 and we can expect |
|
a bottom-line profit of 7.34""") |
|
st.markdown("So can we kick back & relax now? \nWell, there are a few issues with what we just did.") |
|
|
|
|
|
st.header("The demands they are a-changin' ๐ธ") |
|
st.markdown("""We've got a first bit of bad news: unfortunately, you can't just estimate a demand |
|
curve once and be done with it. \nWhy? Because demand is influenced by many factors (e.g., market |
|
trends, competitor actions, human behavior, etc.) that tend to change a lot over time.""") |
|
st.write("Below you can see an (exaggerated) example of what we're talking about:") |
|
|
|
with open("assets/images/dynamic_demand.gif", "rb") as file_: |
|
contents = file_.read() |
|
data_url = base64.b64encode(contents).decode("utf-8") |
|
|
|
st.markdown( |
|
f'<img src="data:image/gif;base64,{data_url}" alt="dynamic demand">', |
|
unsafe_allow_html=True, |
|
) |
|
st.markdown("""Now, you may think we can solve this issue by periodically re-estimating the demand |
|
curve. \nAnd you would be very right! But also very wrong as this leads us nicely to the |
|
next issue.""") |
|
|
|
|
|
st.header("Where are we getting this data anyways? ๐ค") |
|
st.markdown("""So far, we have assumed that we get (and keep getting) data on demand levels at |
|
different price points. \n |
|
Not only is this assumption **unrealistic**, it is also very **undesirable**""") |
|
st.markdown("""Why? Because getting demand data on a wide spectrum of price points implies that |
|
we are spending a significant amount of time setting prices at levels that are either too high or |
|
too low! \n |
|
Which is ironically exactly the opposite of what we set out to achieve.""") |
|
st.markdown("In practice, our demand will rather look something like this:") |
|
st.image("assets/images/realistic_demand.png") |
|
st.markdown("""As we can see, we have tried three price points in the past (โฌ7.5, โฌ10 and โฌ11) for |
|
which we have collected demand data.""") |
|
st.markdown("""On a side note: keep in mind that we still assume the same latent demand curve and |
|
optimal price point of โฌ4.24 \n |
|
So (for the sake of the example) we have been massively overpricing our product in the past""") |
|
st.image("assets/images/realistic_demand_latent_curve.png") |
|
st.markdown("""This constrained data brings along a major challenge in estimating the demand curve |
|
though. \n |
|
Intuitively, it makes sense that we can make a reasonable estimate of expected demand at โฌ8 or โฌ9, |
|
given the observed demand at โฌ7.5 and โฌ10. \nBut can we extrapolate further to โฌ2 or โฌ20 with the |
|
same reasonable confidence?""") |
|
st.markdown("""This is a nice example of a very well-known problem in statistics called the |
|
**\"exploration-exploitation\" trade-off** \n |
|
๐ **Exploration**: We want to explore the demand for a diverse enough range of price points |
|
so that we can accurately estimate our demand curve. \n |
|
๐ **Exploitation**: We want to exploit all the knowledge we have gained through exploring and |
|
actually do what we set out to do: set our price at an optimal level.""") |
|
|
|
|
|
st.header("Enter: Thompson Sampling ๐") |
|
st.markdown("""As we mentioned, this is a well-known problem in statistics. So luckily for us, |
|
there is a pretty neat solution in the form of **Thompson sampling**!""") |
|
st.markdown("""Basically instead of estimating one demand function based on the data available to |
|
us, we will estimate a probability distribution of demand functions or simply put, for every |
|
possible demand function that fits our functional form (i.e. constant elasticity) |
|
we will estimate the probability that it is the correct one, given our data.""") |
|
st.markdown("""Or mathematically speaking, we will place a prior distribution on the parameters |
|
that define our demand function and update these priors to posterior distributions via Bayes rule, |
|
thus obtaining a posterior distribution for our demand function""") |
|
st.markdown("""Thompson sampling then entails just sampling a demand function out of this |
|
distribution, calculating the optimal price given this demand function, observing demand for this |
|
new price point and using this information to refine our demand function estimates.""") |
|
st.image("assets/images/flywheel_1.png") |
|
st.markdown("""So: \n |
|
๐ When we are **less certain** of our estimates, we will sample more diverse demand functions, |
|
which means that we will also explore more diverse price points. Thus, we will **explore**. \n |
|
๐ When we are **more certain** of our estimates, we will sample a demand function close to |
|
the real one & set a price close to the optimal price more often. Thus, we will **exploit**.""") |
|
|
|
st.markdown("""With that said, we'll take another look at our constrained data and see whether |
|
Thompson sampling gets us any closer to the optimal price of โฌ4.24""") |
|
st.image("assets/images/realistic_demand_latent_curve.png") |
|
st.markdown("""Let's start working our mathemagic: \n |
|
We'll start off by placing semi-informed priors on the parameters that make up our |
|
demand function.""") |
|
|
|
st.latex(f"{sympy.latex(a)} \sim N(ฮผ=0,ฯ=2)") |
|
st.latex(f"{sympy.latex(eta)} \sim N(ฮผ=0.5,ฯ=0.5)") |
|
st.latex("sd \sim Exp(\lambda=1)") |
|
st.latex(f"{sympy.latex(D)}|P=p \sim N(ฮผ={sympy.latex(a*p**(-eta))},ฯ=sd)") |
|
|
|
st.markdown("""These priors are semi-informed because we have the prior knowledge that |
|
price elasticity is most likely between 0 and 1. As for the other parameters, we have little |
|
knowledge about them so we can place a pretty uninformative prior.""") |
|
st.markdown("If that made sense to you, great. If it didn't, don't worry about it") |
|
|
|
st.markdown("""Now that are priors are taken care of, we can update these beliefs by incorporating |
|
the data at the โฌ7.5, โฌ10 and โฌ11 price levels we have available to us.""") |
|
st.markdown("The resulting demand & profit curve distributions look a little something like this:") |
|
st.image(["assets/images/posterior_demand.png", "assets/images/posterior_profit.png"]) |
|
|
|
st.markdown("""It's time to sample one demand curve out of this posterior distribution. \n |
|
The lucky curve is:""") |
|
st.image("assets/images/posterior_demand_sample.png") |
|
st.markdown("This results in the following expected profit curve") |
|
st.image("assets/images/posterior_profit_sample.png") |
|
st.markdown("""And eventually we arrive at a new price: โฌ5.25! Which is indeed considerably closer |
|
to the actual optimal price of โฌ4.24""") |
|
st.markdown("""Now that we have our first updated price point, why stop there? Let's simulate 10 |
|
demand data points at this price point from out latent demand curve and check whether Thompson |
|
sampling will edge us even closer to that optimal โฌ4.24 point""") |
|
st.image("assets/images/updated_prices_demand.png") |
|
st.markdown("""We know the drill by down. \n |
|
Let's recalculate our posteriors with this extra information.""") |
|
st.image(["assets/images/posterior_demand_2.png", "assets/images/posterior_profit_2.png"]) |
|
st.markdown("""We immediately notice that the demand (and profit) posteriors are much less spread |
|
apart this time around which implies that we are more confident in our predictions""") |
|
st.markdown("Now, we can sample just one curve from the distribution") |
|
st.image(["assets/images/posterior_demand_sample_2.png", "assets/images/posterior_profit_sample_2.png"]) |
|
st.markdown("""And finally we arrive at a price point of โฌ4.44 which is eerily close to |
|
the actual optimum of โฌ4.24.""") |
|
|
|
|
|
st.header("Demo time ๐ฎ") |
|
st.markdown("Now that we have covered the theory, you can go ahead and try it our for yourself!") |
|
|
|
thompson_sampler = ThompsonSampler() |
|
demo_button = st.checkbox( |
|
label='Ready for the Demo? ๐คฏ', |
|
help="Starts interactive Thompson sampling demo" |
|
) |
|
elasticity = st.slider( |
|
"Adjust latent elasticity", |
|
key="latent_elasticity", |
|
min_value=0.05, |
|
max_value=0.95, |
|
value=0.15, |
|
step=0.05, |
|
) |
|
while demo_button: |
|
thompson_sampler.run() |
|
time.sleep(1) |
|
|
|
|
|
st.header("Some final remarks") |
|
|
|
st.markdown("""Because we have purposefully kept the example above quite simple, you may still be |
|
wondering what happens when added complexities show up. \n |
|
Let's discuss some of those concerns FAQ-style:""") |
|
|
|
st.subheader("๐ Isn't this constant-elasticity model a bit too simple to work in practice?") |
|
st.markdown("Brief answer: usually yes it is.") |
|
st.markdown("""Luckily, more flexible methods exist. \n |
|
We would recommend to use Gaussian Processes. We won't go into how these work here but the main idea |
|
is that it doesn't impose a restrictive functional form onto the demand function but rather lets |
|
the data speak for itself.""") |
|
|
|
with open("assets/images/gaussian_process.gif", "rb") as file_: |
|
contents = file_.read() |
|
data_url = base64.b64encode(contents).decode("utf-8") |
|
|
|
st.markdown( |
|
f'<img src="data:image/gif;base64,{data_url}" alt="gaussian process">', |
|
unsafe_allow_html=True, |
|
) |
|
st.markdown("""If you do want to learn more, we recommend these links: |
|
[1](https://distill.pub/2019/visual-exploration-gaussian-processes/), |
|
[2](https://thegradient.pub/gaussian-process-not-quite-for-dummies/), |
|
[3](https://sidravi1.github.io/blog/2018/05/15/latent-gp-and-binomial-likelihood)""") |
|
|
|
st.subheader("""๐ Price optimization is much more complex than just finding the point that maximizes a simple profit function. What about inventory constraints, complex cost structures, ...?""") |
|
st.markdown("""It sure is but the nice thing about our setup is that it consists of three |
|
components that you can change pretty much independently from each other. \n |
|
This means that you can make the price optimization pillar arbitrarily custom/complex. As long as |
|
it takes in a demand function and spits out a price.""") |
|
st.image("assets/images/flywheel_2.png") |
|
st.markdown("You can tune the other two steps as much as you like too.") |
|
st.image("assets/images/flywheel_3.png") |
|
|
|
st.subheader("๐ Changing prices has a huge impact. How can I mitigate this during experimentation?") |
|
st.markdown("There are a few things we can do to minimize risk:") |
|
st.markdown("""๐ **A/B testing**: You can do a gradual roll-out of the new pricing system where a |
|
small (but increasing) percentage of your transactions are based on this new system. This allows you |
|
to start small & track/grow the impact over time.""") |
|
st.markdown("""๐ **Limit products**: Similarly to A/B testing, you can also segment on the |
|
product-level. For instance, you can start gradually rolling out dynamic pricing for one product |
|
type and extend this over time.""") |
|
st.markdown("""๐ **Bound price range**: Theoretically, Thompson sampling in its purest form can |
|
lead to any arbitrary price point (albeit with an increasingly low probability). In order to limit |
|
the risk here, you can simply place a upper/lower bound on the price range you are comfortable |
|
experimenting in.""") |
|
st.markdown("""On top of all this, Bayesian methods (by design) explicitly quantify uncertainty. |
|
This allows you to have a very concrete view on the variance of our demand estimates""") |
|
|
|
st.subheader("๐ What if I have multiple products that can cannibalize each other?") |
|
st.markdown("Here it really depends") |
|
st.markdown("""๐ **If you have a handful of products**, we can simply reformulate our objective while |
|
keeping our methods analogous. \n |
|
Instead of tuning one price to optimize profit for the demand function of one product, we tune N |
|
prices to optimize profit for the joint demand function of N products. This joint demand function |
|
can then account for correlations in demand within products.""") |
|
st.markdown("""๐ **If you have hundreds, thousands or more products**, we're sure you can imagine that |
|
the procedure described above becomes increasingly infeasible. \n |
|
A practical alternative is to group substitutable products into "baskets" and define the "price of |
|
the basket" as the average price of all products in the basket. \n |
|
If we assume that the products in baskets are subtitutable but the products in different baskets are |
|
not, we can optimize basket prices indepedently from one another. \n |
|
Finally, if we also assume that cannibalization remains constant if the ratio of prices remains |
|
constant, we can calculate individual product prices as a fixed ratio of its basket price. \n""") |
|
st.markdown("""For example, if a "burger basket" consists of a hamburger (โฌ1) and a cheeseburger |
|
(โฌ3), then the "burger price" is ((โฌ1 + โฌ3) / 2 =) โฌ2. So a hamburger costs 50% of the burger price |
|
and a cheeseburger costs 150% of the burger price. \n |
|
If we change the burger's price to โฌ3, a hamburger will cost (50% * โฌ3 =) โฌ1.5 and a cheeseburger |
|
will cost (150% * โฌ3 =) โฌ4.5 because we assume that the cannibalization effect between hamburgers & |
|
cheeseburgers is the same when hamburgers cost โฌ1 & cheeseburgers cost โฌ3 and when hamburgers cost |
|
โฌ1.5 & cheeseburgers cost โฌ4.5""") |
|
st.image("assets/images/cannibalization.png") |
|
|
|
st.subheader("๐ Is dynamic pricing even relevant for slow-selling products?") |
|
st.markdown("""The boring answer is that it depends. It depends on how dynamic the market is, the |
|
quality of the prior information, ...""") |
|
st.markdown("""But obviously this isn't very helpful. \nIn general, we notice that you can already |
|
get quite far with limited data, especially if you have an accurate prior belief on how the demand |
|
likely behaves.""") |
|
st.markdown("""For reference, in our simple example where we showed a Thompson sampling update, we |
|
were already able to gain a lot of confidence in our estimates with just 10 extra demand |
|
observations.""") |