Optimizing Reorder Points with a Noisy Simulator

· updated

A genetic algorithm picks reorder points for five products. When I re-measured its best answers with 200 runs, they were 11 to 15 percent worse than it said.

Written in September 2025, when I finished the optimizer behind the simulation-optimization demo. Rerun on 8 October 2026 with the same code; the numbers below are from that rerun.

The inventory simulator tells you what one set of reorder points costs. The next question is which reorder points to choose. With five products and a noisy simulator there is no formula, so I put a genetic algorithm (GA) around the simulation. My earlier ANN metamodel paper (code) took the other route: learn a cheap model of the simulation first.

The setup

Five products with fixed parameters. The GA only chooses the reorder point r of each one.

ProductPolicyCustomers/periodLead time (mean)Range for r
A(s,S), S = 50, backorders1030 to 49
B(r,Q), Q = 15, lost sales820 to 69
C(s,S), S = 40, backorders640 to 39
D(r,Q), Q = 20, lost sales122.50 to 59
E(s,S), S = 30, backorders53.50 to 29

For an (r,Q) product the upper bound is the expected lead-time demand plus three standard deviations plus 25 percent.

The GA proposes reorder points, the simulator runs each candidate 3 times for 60 periods, the mean cost becomes its fitness, and selection, crossover and mutation produce the next generation until the cost stops improving

GA settings are the API defaults: population 40, crossover rate 0.9, mutation rate 0.3 (adaptive), tournament of 2, elitism 2, 15 percent random immigrants per generation. It stops after at least 25 generations once the best cost has not improved for 10 generations (a change below 0.0005), or at the time limit.

The experiment

I ran the GA three times with different seeds. Then I re-measured each best solution with 200 fresh simulation runs, and did the same for the midpoint of every search range as a naive baseline.

RunReorder points (A to E)Cost the GA reportedCost re-measured (200 runs)
Midpoints24, 34, 19, 29, 14399.2
GA, seed 127, 6, 21, 11, 23329.3366.3
GA, seed 227, 11, 28, 3, 19322.9360.2
GA, seed 333, 13, 16, 14, 24327.8376.9

Each GA run stopped on convergence after 27 to 39 generations, 1,052 to 1,512 evaluations and 7 to 9 seconds.

What the numbers say

The GA’s own score is optimistic. It judges each candidate on 3 noisy runs, so the winner is partly the candidate that got lucky draws. Re-measured, every winner was 11 to 15 percent more expensive than reported. The real gain over the naive midpoints is 6 to 10 percent, not the 18 percent the GA’s own numbers suggest.

Different seeds, different answers, similar cost. Product D’s reorder point came out as 11, 3 and 14. The cost surface is flat and noisy, so the GA stops on whichever plateau it finds first.

The cost function pushes lost-sales products toward stockouts. B and D end with low reorder points and fill rates of 36 to 51 percent. The model has no revenue, so losing a sale (cost 5) is cheaper than serving it (about 5.1). I explain this in the simulation note.

What went wrong

The seed does not make a run repeatable. The GA seeds its own random choices, but every fitness evaluation calls the simulator with base_seed=None. Calling the API twice with "seed": 1 gave [41, 3, 29, 7, 17] and [39, 12, 34, 12, 20]. It also means two candidates are never compared on the same demand. The fix is common random numbers: pass the same seeds to every candidate’s replicates. That makes runs repeatable and the comparison fairer.

/simulate ignores its seed field. It is accepted and then dropped.

My “30 seconds” change did nothing. In September 2025 I raised the time limit from 20 to 30 seconds in optimization.py, but the API request model still defaults to 20, so the API never used it. With these defaults it does not matter: every run converged in under 10 seconds.

Reporting the search score as the result. The demo shows the GA’s own best cost. It should re-measure the final candidates with many more runs before showing a number.

How to repeat this

The code is not public yet; the API and the demo are.

# One GA run (about 8 seconds). Expect different reorder points each time; see above.
curl -s -X POST https://api-simopt.oploy.eu/optimize \
  -H "Content-Type: application/json" -d '{"seed": 1}'

# Re-measure a solution with 50 runs (the API's maximum per call).
curl -s -X POST https://api-simopt.oploy.eu/simulate \
  -H "Content-Type: application/json" \
  -d '{"reorder_points": {"Product A": 27, "Product B": 6, "Product C": 21, "Product D": 11, "Product E": 23},
       "replicates": 50, "horizon": 60}'
  1. Run the optimizer a few times and note best_cost and best_solution.
  2. Re-measure each best_solution with four calls to /simulate at 50 replicates and average the objective values.
  3. Compare: the re-measured cost should come out about 10 to 15 percent above best_cost.

The fill rates of backordered products (A, C, E) are wrong until the fix in the simulation note is deployed. The idea of replacing the expensive simulator with a learned model is in my ANN metamodel paper.

← All notes