Comparing Scenarios chose between two solutions. This notebook covers the second of the four types of problems: a small, discrete set of solutions. We discuss two ways to spend the simulation budget, an equal split over all solutions and a smarter one that discards bad solutions early, and apply both to the newsvendor problem.
Learning outcomes
On completion of this notebook, you will be able to:
formulate the newsvendor problem as a simulation optimization problem
apply ranking and selection with an equal split of the simulation budget, and explain its drawbacks
formulate the one-sided tests that discard solutions, and explain the Šidák correction for multiple testing
apply ranking and selection with discarding, and read and adapt the Python code that does so
The Setting¶
We solve the simulation optimization problem
where can only be evaluated by simulation. In this notebook is discrete and relatively small, with solutions: the simulation budget of runs is much larger than the number of solutions (), so it suffices to simulate every solution at least a couple of times. The task is to select the best solution, and on the way we rank the solutions by their estimated performance, hence the name ranking and selection.
The Newsvendor Problem¶
The newsvendor problem is a typical ranking and selection problem. A newsvendor buys newspapers in the morning for a price each, and sells them during the day for a price . Demand is random, and newspapers that are left at the end of the day are worthless. The newsvendor sells papers, so the profit is
and the newsvendor chooses from . The upper bound comes from the newsvendor’s stand: it has room for at most newspapers, so ordering more is not possible. The example in Comparing Scenarios was a newsvendor problem with only two order sizes, 9 and 10.
In the code below, simulate simulates the profit of one order size n times:
import numpy as np
price, cost = 1, 0.75
mean_demand = 10
orders = np.arange(21) # S = {0, 1, ..., 20}
def profit(order, demand):
return price * np.minimum(demand, order) - cost * order
def simulate(order, n, rng):
"""Simulated profits of n days with the given order size."""
return profit(order, rng.poisson(mean_demand, n))For this simple example, the expected profit of every order size can also be calculated exactly. How to do this is outside the scope of this course, and for real-life simulation optimization problems it is in general not possible. We use the exact values, stored in true_value by the hidden cell below, only to check how well the methods do.
Source
# exact expected profit, only to check the methods (outside the scope of
# the course)
from scipy import stats
expected_sales = [
stats.poisson.sf(np.arange(o), mean_demand).sum() for o in orders
]
true_value = price * np.array(expected_sales) - cost * orders
best_order = orders[true_value.argmax()]
print(f"best order size: {best_order}")
print(f"expected profit of order size {best_order}: {true_value.max():.3f}")
print(f"expected profit of order size 10: {true_value[10]:.3f}")best order size: 8
expected profit of order size 8: 1.540
expected profit of order size 10: 1.249
Ordering 8 papers is best, not 10: with leftovers worthless and a small margin of €0.25 per sold paper, the newsvendor should order less than the mean demand.
Option 1: Split the Budget Equally¶
The natural first approach is to simulate every solution times, estimate for each by the average of its runs, and return the solution with the highest average. Let and denote the sample average and sample variance computed from the runs of (the and of Comparing Scenarios). With we can also give a 95% CI for each .
For the newsvendor, a budget of runs gives 200 runs per order size:
rng = np.random.default_rng(1)
budget = 4200
runs_per_order = budget // len(orders)
means = np.zeros(len(orders))
sds = np.zeros(len(orders))
for idx, order in enumerate(orders):
profits = simulate(order, runs_per_order, rng)
means[idx] = profits.mean()
sds[idx] = profits.std(ddof=1)
half_widths = 1.96 * sds / np.sqrt(runs_per_order)
best = means.argmax()
print(f"selected order size: {orders[best]}")
print(f"estimated expected profit: {means[best]:.3f}")selected order size: 8
estimated expected profit: 1.600
Figure 1:95% CIs for the expected profit of every order size, each based on 200 runs, with the true expected profit.
Figure 1 shows the CIs. The CIs for small order sizes are very narrow (see Exercise 1). Around the optimum, the CIs of order sizes 7, 8 and 9 overlap: the budget is not enough to tell them apart. At the same time, a large part of the budget went to order sizes 14 to 20, whose CIs lie far below the others after a few runs already.
Because of the noise, option 1 shows both problems mentioned in About Simulation Optimization: it may select the wrong order size, and the estimated profit of the selected order size tends to be too high. To see how often this happens, we repeat option 1 1000 times:
def option_1(budget, rng):
"""Equal split: return the selected order size and its estimate."""
n = budget // len(orders)
estimates = [simulate(order, n, rng).mean() for order in orders]
best = int(np.argmax(estimates))
return orders[best], estimates[best]
rng = np.random.default_rng(2)
results_1 = [option_1(budget, rng) for _ in range(1000)]
selected_1 = np.array([order for order, _ in results_1])
overestimate = [est - true_value[order] for order, est in results_1]
print(f"fraction best order size selected: {np.mean(selected_1 == 8):.2f}")
print(f"average overestimation of the profit: {np.mean(overestimate):.3f}")fraction best order size selected: 0.54
average overestimation of the profit: 0.056
Option 1 selects the best order size 8 only about half of the time; otherwise it mostly selects 7 or 9. And the estimate of the selected order size’s expected profit is too high on average, because we select the order size whose estimate happened to come out high.
Common Random Numbers¶
The code above draws new demands for every order size. As in Comparing Scenarios, we can also use common random numbers: simulate one set of demands and evaluate every order size on the same demands. This does not make the individual CIs narrower, but it reduces the variance of the differences between order sizes, and these differences decide which order size is selected. In a discrete-event simulation it is the same: reuse the same customer arrival moments for every candidate solution.
Option 2: Discard Bad Solutions Early¶
Option 1 spends as many runs on clearly bad solutions as on promising ones. Option 2 first simulates all solutions a limited number of times, discards the solutions that are significantly worse than another solution, and spends the rest of the budget on the remaining ones. In general terms, with solutions and a budget of runs:
Simulate every solution times, where is the number of runs per solution in this first round ( rounds down to an integer). Compute the average and the sample variance of each solution.
For every pair of solutions , test whether is significantly worse than .
Discard every solution that is significantly worse than at least one other solution. The solutions that remain form the set .
Divide the remaining budget evenly over the solutions in , and return the solution in with the highest average.
When to Discard a Solution¶
Consider first one pair of solutions and , each simulated times, independently. We maximize, so is worse than if . We only discard if the data give strong evidence for this, so this is the alternative hypothesis of a one-sided test:
The difference of the averages has variance , which we estimate with the sample variances. So the test statistic
has approximately a -distribution with degrees of freedom if the two expectations are equal, and a large value of is evidence for . As on the slides, let be the inverse of this -distribution at : the number with . In Python, is stats.t.ppf(q, m0 - 1). A one-sided test at significance level rejects if , that is, if
For and , . For large , the -distribution is close to the standard normal distribution, so the normal critical values are a good approximation (1.64 here); the exercises use them for calculations by hand.
The Multiple-Testing Problem and the Šidák Correction¶
With solutions, we discard as soon as one of the tests against the other solutions rejects . Even if is the best solution, each of these tests can reject by chance, and the more tests, the larger the probability that at least one of them does. Compare it with a multiple-choice exam that you take together with monkeys that answer at random: the more monkeys, the larger the chance that one of them beats you by luck, and we should not conclude from that that this lucky monkey knows more about the exam’s topic than you do. In the same way, a solution that beats in one of many tests by luck is not necessarily better than . So if every test is done at level , the probability of discarding the best solution is much larger than 5%.
We want the probability of discarding the best solution to be at most . Therefore we do each test at a stricter, “conservative” level . If is the best solution, all hypotheses are true, and each test does not reject with probability (at least) . The tests are not independent: they all use the same average , so if happens to be low, looks bad in all comparisons at once. This dependence is positive (the tests tend to reject together), and as a result the probability that none of the tests rejects is at least what it would be for independent tests:
The Šidák correction chooses such that this lower bound equals :
Then the best solution survives with probability at least .
You may know the Bonferroni correction from the statistics part of the course, which takes . It also guarantees that the best solution survives with probability at least , but the Šidák level is always a bit larger: . So with the Šidák correction each test is slightly less strict and discards bad solutions a bit more often, with the same guarantee for the best solution. Here the difference is small: for and , Bonferroni gives and Šidák .
For the newsvendor, with and , this gives and, with , instead of : a much stricter bar for declaring a solution worse, because it is tested against many other solutions at once. The set of solutions that survive the first round is
Option 2 for the Newsvendor¶
We use the same budget as for option 1, with runs per order size in the first round. That leaves runs for the second round. Steps 1 to 3:
from scipy import stats
rng = np.random.default_rng(3)
budget = 4200
m0 = 100
k = len(orders)
alpha = 0.05
alpha_star = 1 - (1 - alpha) ** (1 / (k - 1))
beta = stats.t.ppf(1 - alpha_star, m0 - 1)
print(f"alpha* = {alpha_star:.4f}, beta = {beta:.2f}")
# step 1: simulate every order size m0 times
first_round = {order: simulate(order, m0, rng) for order in orders}
y = {order: runs.mean() for order, runs in first_round.items()}
s2 = {order: runs.var(ddof=1) for order, runs in first_round.items()}
# steps 2 and 3: discard every order size that is worse than another one
survivors = []
for order in orders:
discard = False
for other in orders:
if other != order:
margin = beta * np.sqrt(s2[order] + s2[other]) / np.sqrt(m0)
if y[order] <= y[other] - margin:
discard = True
if not discard:
survivors.append(order)
print("I =", [int(order) for order in survivors])alpha* = 0.0026, beta = 2.86
I = [7, 8, 9, 10, 11]
Figure 2:95% CIs for the expected profit of every order size after the first round of option 2 (100 runs each). The grey order sizes are discarded.
Figure 2 shows which order sizes survive. Small order sizes are discarded: because their CIs are so narrow, the data show clearly that they are worse than the best order sizes. In step 4 we divide the remaining 2100 runs over the survivors, add them to the runs of the first round, and select the order size with the highest average:
n_each = (budget - k * m0) // len(survivors)
final = []
for order in survivors:
extra_runs = simulate(order, n_each, rng)
all_runs = np.concatenate([first_round[order], extra_runs])
final.append(all_runs.mean())
best = int(np.argmax(final))
print(f"{n_each} extra runs per surviving order size")
print(f"selected order size: {survivors[best]}, estimate: {final[best]:.3f}")420 extra runs per surviving order size
selected order size: 8, estimate: 1.498
The five survivors now get 520 runs each instead of 200. To compare the two options fairly, we repeat both 1000 times with the same budget, with option 2 wrapped in a function option_2 that follows the code above line by line:
Source
def option_2(budget, m0, alpha, rng):
"""Discard bad order sizes first: return the selected order size."""
k = len(orders)
alpha_star = 1 - (1 - alpha) ** (1 / (k - 1))
beta = stats.t.ppf(1 - alpha_star, m0 - 1)
first_round = {order: simulate(order, m0, rng) for order in orders}
y = {order: runs.mean() for order, runs in first_round.items()}
s2 = {order: runs.var(ddof=1) for order, runs in first_round.items()}
survivors = []
for order in orders:
discard = False
for other in orders:
if other != order:
margin = beta * np.sqrt(s2[order] + s2[other]) / np.sqrt(m0)
if y[order] <= y[other] - margin:
discard = True
if not discard:
survivors.append(order)
n_each = (budget - k * m0) // len(survivors)
final = []
for order in survivors:
extra_runs = simulate(order, n_each, rng)
all_runs = np.concatenate([first_round[order], extra_runs])
final.append(all_runs.mean())
return survivors[int(np.argmax(final))]rng = np.random.default_rng(4)
selected_2 = np.array([option_2(budget, m0, alpha, rng) for _ in range(1000)])
print(f"option 1: best order size selected {np.mean(selected_1 == 8):.2f}")
print(f"option 2: best order size selected {np.mean(selected_2 == 8):.2f}")option 1: best order size selected 0.54
option 2: best order size selected 0.66
With the same budget, option 2 finds the best order size more often than option 1, because it spends more runs where it matters: on the order sizes that are hard to tell apart. It does not remove the problems of noise completely: the expected profits of order sizes 7 and 9 are only €0.03 and €0.08 below that of order size 8, and telling them apart reliably needs many more runs.
References¶
Koole, G. (2019). An Introduction to Business Analytics. §8.2.
Šidák, Z. (1967). “Rectangular confidence regions for the means of multivariate normal distributions.” Journal of the American Statistical Association, 62:626–633.