C. Simulation, part 2

Demand estimation is a technique to model how consumer behaviour changes due to changes in the price of the product, consumer income, or any other variable that impacts demand.1 The previous post demonstrated simple simulation through iterating through a normal distribution.

Demand estimation is a technique to model how consumer behaviour changes due to changes in the price of the product, consumer income, or any other variable that impacts demand.1

The previous post demonstrated simple simulation through iterating through a normal distribution. It also went over what a confidence interval is for, and how it can be reduced by increasing sample size.

C.2 Demand Estimation

To use demand estimation for optimization (remember optimization?), the steps are:

  1. Fit a distribution (normal, Weibull, Logistic, etc.) and its parameters such as mean and standard deviation to actual historical data. If there is no historical data, use a normal distribution and take an educated guess as to the mean and standard deviation.

  2. Predict the optimal future outcome on actual input parameters. For example, the optimal number of widgets to sell based on the current variable cost of production and the current selling price.

  3. Simulate over several iterations, changing the input parameters. Record the optimal solution. For example, change the variable costs and current selling prices for each iteration. Make note of the optimal input parameters.

C.2.1 Example with the Newsvendor Problem

Let’s put this simple 3-step process to work.

In the case of the 140-year-old Newsvendor Problem2, let us assume over the last 30 days the daily number of newspapers sold at a busy location were as such: $$ \scriptsize{\color{#717171}{ num\_sold = [92, 96, 75, 90, 67, 88, 64, 78, 93, 78, 42, 115, 94, 81,\\ 114, 110, 92, 83, 107, 63, 68, 114, 93, 129, 97, 77, 61, 42, 81, 114] }} $$

  1. Fit the distribution. In Excel, use the @Risk Fit function. In Python, StackOverflow user Saulo G.P. Castro demonstrated the equivalent3, modified by Timothy Davenport, and simplified below:

    #!/usr/bin/env python
    
    import warnings
    
    import numpy as np
    import scipy.stats as st
    from scipy.stats._continuous_distns import _distn_names
    
    num_sold=[
        92, 96, 75, 90, 67, 88, 64, 78, 93, 78, 42, 115, 94,
        81, 114, 110, 92, 83, 107, 63, 68, 114, 93, 129, 97,
        77, 61, 42, 81, 114
    ]
    BINS = 200  # for the histogram used in the fit
    SKIP_LIST = ['studentized_range']
    
    best_distributions = []
    
    # Get histogram of original data
    y, x = np.histogram(num_sold, bins=BINS, density=True)
    x = (x + np.roll(x, -1))[:-1] / 2.0
    
    for dist_name in _distn_names:
        if dist_name in SKIP_LIST:
            continue
        try:
            with warnings.catch_warnings():
                warnings.filterwarnings('ignore')
                distribution = getattr(st, dist_name)
                params = distribution.fit(num_sold)
    
                # separate parts of parameters
                arg = params[:-2] # estimates for shape parameters
                loc = params[-2]  #  mean
                scale = params[-1]  # standard deviation
    
                # probability distribution function
                pdf = distribution.pdf(x, *arg, loc=loc, scale=scale)
    
                # SSE is the sum of squares for error. Really
                # important machine learning. The lowest SSE is
                # the best result.
                sse = np.sum(np.power(y - pdf, 2.0))
    
        except ValueError:
            pass
        best_distributions.append(
            (distribution.name, loc, scale, sse)
        )
    
    best_distributions = sorted(
        best_distributions, key=lambda x:x[3]  # The lowest SSE wins.
    )
    best_distributions = [ round(i, 4) for i in best_distributions]
    
    print(best_distributions[0])
    

    And we get:

    ./best_dist.py
    ('dgamma', 9.3808, 85.7010, 0.23712)
    

    The “dgamma” is the most reasonable distribution, mean 86, and standard deviation 9.38. If one explores the other results’ SSE’s, however, one will see that the competing distributions are all very close to each other. Using “norm” is not only viable, it is simpler. I will continue to use “dgamma” just for my own curiosity. Since it is close to a normal distribution, I will set its alpha (not required for norm), to 1.

  2. Optimize with actual input variables. In this case, we want to optimize the vendor profits. We want specifically to know how many newspapers to buy from the publisher (stock) based on current publisher price and current retail price. Let us say the vendor buys at $1.00/pc, sells at $1.50/pc, and returns unsold papers at $0.40/pc. Since the mean of sales is 86 papers, we’ll generate stock values from 50 to 130. We will use the distribution to generate the uncertainty of actual purchases.

    Note on code: @Risk requires the number of simulations to correspond to the range of decision variables. It’s just the way spreadsheets work. Here we can be more sophisticated since Python is general programming, but we won’t. Note also the SEED. This is used on pseudorandom numbers so random results are actually reproducible by third parties.

    #!/usr/bin/env python
    
    import numpy as np
    import scipy.stats as st
    
    # The decision variable is the stock quantities
    START = 50
    STOP = 130
    stock_quantities = range(START, STOP+1, 5)  # Every 5.
    buy_price = 1.00
    sell_price = 1.50
    sellback_price = 0.40
    
    # The input variable is random, generated from the previous fit
    sigma = 9.3808
    mu = 86
    
    # The output random variable is profit. We'll record all here.
    profits = []
    
    # Seed
    SEED = 42  #  Life, the universe, and everything
    
    # Simulate!
    NUM_SIMULATIONS = int((STOP-START)/5)  # 16
    
    # Generate a series of purchases based on distribution
    random_purchases = st.dgamma.rvs(
        a=1, loc=mu, scale=sigma, random_state=SEED,
        size=int(NUM_SIMULATIONS)
    )  # alpha=1 means no right skew, i.e. centered
    
    i = 0
    while i < NUM_SIMULATIONS:
        num_bought = stock_quantities[i]
    
        # You cannot sell more than you bought
        num_sold = min(num_bought, int(random_purchases[i]))
        num_unsold = num_sold - num_bought
    
        cost = buy_price*num_bought
        revenue = sell_price*num_sold + sellback_price*num_unsold
        profits.append((num_bought, round(revenue - cost, 2)))
        i += 1
    
    print(profits)
    sorted_profits = sorted(profits, key=lambda x:x[1])
    print("Best result:", sorted_profits[-1])
    

    The results:

    [(50, 25.0), (55, 27.5), (60, 30.0), (65, 32.5), (70, 35.0),
    (75, 37.5), (80, 40.0), (85, 42.5), (90, 45.0), (95, 47.5),
    (100, 17.7), (105, 27.8), (110, 24.6), (115, 0.5),
    (120, -21.7), (125, -15.4)]
    Best result: (95, 47.5)
    

    Our vendor should buy 95 newspapers. Notice, however, that that’s at the edge of a cliff. Profits go down rapidly. One might mess around with the seed. I tried seed values of 36 and 236. Ordering 90 and 95 newspapers seems to be optimal in each case, but ordering 90 is safer, as sometimes 95 is a steep drop in profits. Going over 105 risks negative profit but sometimes it is very profitable indeed – perhaps this is too volatile for our newsvendor!

  3. Change the input parameters. What happens if the buy price goes to $1.75 and the sell price gets jacked to $2.25? Assume losing 20% of the readership, and the buyback only goes to $0.50 because the publisher is squeezing hard.

    One will need to find the mean and standard deviation again since the number of sales will have changed.

    ...
    buy_price = 1.75
    sell_price = 2.25
    sellback_price = 0.50
    
    # The input variable is random, generated from the previous fit
    
    # Original values
    num_sold=[
        92, 96, 75, 90, 67, 88, 64, 78, 93, 78, 42, 115, 94,
        81, 114, 110, 92, 83, 107, 63, 68, 114, 93, 129, 97,
        77, 61, 42, 81, 114
    ]
    num_sold_reduced = [int(0.8 * i) for i in num_sold]
    mu = int(np.mean(num_sold_reduced))
    sigma = np.std(num_sold_reduced)
    
    ...
    

    And we get:

    [(50, 25.0), (55, 27.5), (60, 30.0), (65, 32.5), (70, -14.5),
    (75, 10.0), (80, -9.5), (85, 15.0), (90, 12.0), (95, 42.0),
    (100, -49.0), (105, -16.25), (110, -19.25), (115, -74.5),
    (120, -127.0), (125, -105.25)]
    Best result: (95, 42.0)
    

    Once again, ninety-five newspapers, but that’s a weird bump among all the losses. Changing the seed seemed to give the same answer: a low number of stock makes a consistent but low profit, and only a very small sweet spot gives a better profit, surrounded by loss.

    One had better hope the publisher does not treat the newsvendors like this.

C.2.2 Experiment and conclusion

Mess around with the data. Although the example used Python’s double gamma distribution, the numbers given were very close to a normal distribution as well (which is why I set alpha to 1). The code samples are good for exploration and learning the concepts, but they are certainly no match for the ease of @Risk on Excel. A nice programming project would be to encapsulate the code in classes and methods, and add matplotlib calls to make pretty graphs.