In [1]:
import functools
import random
import statistics
import math
import plotly.express as px


## set the number of simulations to run (higher = more accuracy & confidence, but slower)¶

use a low number (2,000 to 10,000) for speed while writing our code and iterating, then a high number (25,000 to 100,000+) when validating our results

In [2]:
simulations = 5000


## prep work: here we will define the parameters of our game¶

we've created a relatively simple game, with 3 combo sets and 2 instant-win prizes

you could easily extend this by adding more combo sets

In [3]:
# here's where we define the piece names, the prizes for each set, and the probability (ratio) of picking each piece
# Monopoly uses street names in Atlantic City, we'll use street names from other cities
combosets = {
# define our combos -- piece names, prize amount, and piece ratio
# ratios are universally relative... a piece with ratio 6 will be twice as common as a ratio of 3,
#     even across different groups
# groups should be set up with one rare piece, the higher the value of the group, the rarer the final piece should be
'New York' : {
'prize' : 50,
'pieces' : {
'Wall Street' : {'ratio' : 12 } ,
'5th Avenue' :  {'ratio' : 12 } ,
'Broadway' :    {'ratio' : 6 }
}
} ,
'Las Vegas' : {
'prize' : 100,
'pieces' : {
'Tropicana Ave' :  {'ratio' : 8 } ,
'Fremont St' :     {'ratio' : 8 } ,
'Las Vegas Blvd' : {'ratio' : 4 }
}
} ,
'Los Angeles' : {
'prize' : 200,
'pieces' : {
'Pacific Coast Hwy' : {'ratio' : 12 } ,
'Sunset Blvd' :        {'ratio' : 12 } ,
'Rodeo Drive' :        {'ratio' : 3 }
}
} ,
# offer instant prizes for specific pieces that aren't part of a combo
'InstantChicago' : {
'prize' : 20,
'pieces' : {
'Michigan Ave' : {'ratio' : 6 }
}
} ,
'InstantNewOrleans' : {
'prize' : 40,
'pieces' : {
'Bourbon Street' : {'ratio' : 3 }
}
}
}

# you can also offer a prize to avoid discouragement when players get multiples of the same piece
#   for example, 3 identical pieces = \$25
duplicate_award = { 'duplicates' : 3 , 'prize' : 25 }

# entire board grand prize - to the first person to cover the entire board, i.e. collect at least 1 of every piece
#   this should be guaranteed, i.e. given away via drawing if not won - but, we will try to set p(at least 1 winner) > 95%
grand_prize = 50000

# we also designated a prize for anyone who completed the board _after_ the grand prize was claimed
#   completing the board is a difficult accomplishment, and only a handful of players should be able to do it
not_grand_prize = 2500


#### a note about the game:¶

The typical McDonald's game gives away its grand prize (and all large prizes) when a player collects 1 extremely rare game piece. Two potential problems, when running a similar game on a smaller scale, is that the key game piece is just as likely to be found on Day 1 as the final day, which is an issue because the players' motivation is likely to be lost once the grand prize has been claimed. The other potential problem is the high likelihood that the key game piece is never collected. Especially when running the game for the first time, the total supply of pieces should exceed the expected number needed by about 20-30% in case the game is more popular than expected. This means that 20%+ of all pieces may never be awarded, and the super-rare piece could easily be in that group.

An alternative solution is to award the grand prize to the first player who completes the entire board, in other words, collects at least one of every game piece. By configuring the ratio of pieces (the point of this notebook), we can create a game with a likelihood of a grand prize winner at 95% (or higher, as desired), and be nearly guaranteed that the grand prize won't be claimed until right near the end of the promotion period, keeping motivation and excitement high as long as possible.

## need to provide an estimate of the number of players who will earn __ pieces¶

i.e. in the scenario below, we expect 250 players will earn 5 pieces (exactly 5, not "at least" 5)

This is the trickiest part of the model, because it's a guess -- we don't know exactly how popular the game will be or how many players will participate, and we can't do a "practice run." It is essential to review historical gaming data to make a highly educated guess backed by reliable data.

If our estimates are too low, we will give out more prizes than we expected, exceeding budget. If our estimates are too low, fewer players will win, players will be frustrated, and it is less likely the grand prize will be won during the promo period, necessitating a "second-chance" drawing or similar.

In [4]:
# a possible distribution for a 7-day game with max. 3 pieces earned per day

# example: we expect 1000 players to earn 1 piece, expect 600 players to earn 2 pieces, etc.

expected_players = {
1 : 1000 ,  2 : 600 ,  3 : 400 ,  4 : 300 ,  5 : 250 ,
6 : 200 ,   7 : 180 ,  8 : 160 ,  9 : 140 , 10 : 120 ,
11 : 100 ,  12 : 90 ,  13 : 80 ,  14 : 70 ,  15 : 60 ,
16 : 50 ,   17 : 40 ,  18 : 30 ,  19 : 15 ,  20 : 25 ,
21 : 40 ,   22 : 0 ,   23 : 0 ,   24 : 0 ,   25 : 0 ,
26 : 0 ,    27 : 0 ,   28 : 0 ,   29 : 0 ,   30 : 0
}


## first, we create objects we'll need when running our simulations¶

#### re-arranging attributes only - no data being changed here¶

these objects will come in handy soon

#### note: The following code makes heavy use of nested Python list/dict comprehensions, which can be difficult to follow. We display the output for every conversion, it's more important to see the result (what each function is intended to accomplish) than to understand the exact syntax. for loops would be more verbose but probably easier to follow. In other languages, reduce (or fold) and map functions are a better solution.¶

In [5]:
# for each city, get a list of streets

# .items() iterates through a dictionary and produces a (key & value) tuple for each

combos = { k : list(v["pieces"].keys()) for (k, v) in combosets.items() }
combos

Out[5]:
{'New York': ['Wall Street', '5th Avenue', 'Broadway'],
'Las Vegas': ['Tropicana Ave', 'Fremont St', 'Las Vegas Blvd'],
'Los Angeles': ['Pacific Coast Hwy', 'Sunset Blvd', 'Rodeo Drive'],
'InstantChicago': ['Michigan Ave'],
'InstantNewOrleans': ['Bourbon Street']}
In [6]:
# list of all pieces

# this is a common way to "flatten" multiple lists, i.e. combine a list of lists into one signle list
#   in this case our list of lists is combos.values()

all_pieces = [street for streets in combos.values() for street in streets] # list(piece_map.keys())
all_pieces

Out[6]:
['Wall Street',
'5th Avenue',
'Tropicana Ave',
'Fremont St',
'Las Vegas Blvd',
'Pacific Coast Hwy',
'Sunset Blvd',
'Rodeo Drive',
'Michigan Ave',
'Bourbon Street']
In [7]:
# list of all pieces that are eligible for a "duplicates" prize (basically everything that isn't an instant prize)

# same as above, but first we filter combos to take out the Instant prizes

duplicate_eligible = [street for streets in [v for (k,v) in combos.items() if "instant" not in k.lower()] for street in streets]
duplicate_eligible

Out[7]:
['Wall Street',
'5th Avenue',
'Tropicana Ave',
'Fremont St',
'Las Vegas Blvd',
'Pacific Coast Hwy',
'Sunset Blvd',
'Rodeo Drive']

## more object creation, we need to make a map to simulate probabilities of picking random pieces, based on the ratios we set initially¶

In [8]:
# this is the dict we will feed to Python's random.choices function to simulate players picking random game pieces

# keys() and values() will be lists of every street and its weighting ratios

ratios = { street : v["pieces"][street]["ratio"] for v in combosets.values() for street in v["pieces"] }
ratios

Out[8]:
{'Wall Street': 12,
'5th Avenue': 12,
'Tropicana Ave': 8,
'Fremont St': 8,
'Las Vegas Blvd': 4,
'Pacific Coast Hwy': 12,
'Sunset Blvd': 12,
'Rodeo Drive': 3,
'Michigan Ave': 6,
'Bourbon Street': 3}

## now we are ready to run the simulation¶

#### We will simulate the experience of players who collect 1 piece, then 2 pieces, then 3 pieces, etc, through the entire 'expected_players' dict. We run each individual simulation simulations times (defined at the top)¶

In [9]:
sim_results = {}
all_random_pieces = []


#### see the note preceding the first step -- picking the random pieces is all that is being simulated¶

In [10]:
# run the loop for every number of pieces possible to be earned
for pieces in [k for k in expected_players.keys() if (expected_players[k] > 0)]:

#
#  first, we simulate picking random pieces
#
#  ** this is the entire simulation !! **
#
#  everything before this step is definition and setup
#  everything after this step is just calculations to determine the prize values of the random pieces chosen here
#
#  this game does not have any decisions to be made, nor options, nor any second steps (i.e. pass line in craps)
#
#  players pick game pieces, those pieces earn prizes, that's it!
#

# python will make [pieces] number of random selections, weighted by 'ratios' - and we do it thousands of times
random_pieces = [ random.choices(
population=list(ratios.keys()),  # population - list of possible values
weights=list(ratios.values()),   # weights, i.e. ratios
k=pieces)                        # k = how many game pieces to pull - this will vary every loop
for _ in range(simulations) ]          # simulations - should be thousands, minimum

#
#  we now have a list (with length = simulations) of lists
#
#  second step is calculating prize values for each individual simulation
#
#  we will print output from these functions below... easier to understand each step when you can see the output
#

# count the number of pieces chosen within each combo group
piece_counts = [{k : [p.count(street) for street in v] for (k,v) in combos.items()} for p in random_pieces]

# number of successful combos completed * prize value
#   the number of successful combos = the minimum number of pieces in a group (no complex math needed)
#   instant prizes work just like combo prizes (essentially a combo of size = 1), don't need to do anything different
group_prizes = [{k : min(pc_dict[k]) * combosets[k]["prize"] for (k,v) in combos.items()} for pc_dict in piece_counts]

# number of completed duplicate (3-of-a-kind) game pieces * prize value
#   all we need to do is integer(floor) division against the count of each game piece, and multiply that by the prize value
duplicate_prizes = [[duplicate_award["prize"] * (piece_list.count(street) // duplicate_award["duplicates"])
for street in duplicate_eligible] for piece_list in random_pieces]

# sum the prizes for each simulation & "zip" into a 2-value tuple for: (combo group prize total, duplicate prize total)
prizes = [(sum(a.values()),sum(b)) for (a,b) in list(zip(group_prizes, duplicate_prizes))]

# boolean: did the player cover the entire board?
covered_entire_board = [all(item in piece_list for item in all_pieces) for piece_list in random_pieces]

#
#  we will print results of first two steps to help understand what each object does
#
#

# we will display limited samples of the simulations in order to follow along
#   printing everything would blow up our console and take a loooooong time
if pieces == 8 or pieces == 20:
print(f"------- first 10 simulations with {pieces} pieces ({simulations} simulations total) ------------------------------------\n")
print("pieces selected:\n", random_pieces[:10], "\n")
print("count of pieces by group:\n", piece_counts[:10], "\n")
print("prizes for group completion:\n", group_prizes[:10], "\n")
print("prizes for 3-of-a-kind:\n", duplicate_prizes[:10], "\n")
print("total prize values:\n", prizes[:10], "\n")
print("was board completed?:\n", covered_entire_board[:10], "\n")

#
#  last step is to get the average of all the individual simulation results, to calculate expected costs
#
#

# finally, let's summarize all the individual trials we've run
# average prizes won per player
avg_prize = statistics.mean([a + b for (a,b) in prizes])
# expected total cost: average prize * # number of players
expected_cost = avg_prize * expected_players[pieces]
# probability of a player covering the entire board
prob_covered = statistics.mean([1.0 if ceb else 0.0 for ceb in covered_entire_board])

# we add the results of the summation to our sim_results object
# we are running this loop once for every number of pieces earned, therefore that dict will have one entry for each loop
sim_results[pieces] = { 'avg_prize' : avg_prize ,
'expected_players' : expected_players[pieces],
'expected_cost' : expected_cost ,
'prob_covered' : prob_covered ,
'sim_count' : simulations }

# we will use this at the end to check total randomness
all_random_pieces.extend(random_pieces)

------- first 10 simulations with 8 pieces (5000 simulations total) ------------------------------------

pieces selected:
[['Fremont St', '5th Avenue', 'Broadway', 'Michigan Ave', 'Wall Street', 'Sunset Blvd', 'Fremont St', 'Tropicana Ave'], ['Las Vegas Blvd', 'Sunset Blvd', 'Broadway', 'Pacific Coast Hwy', 'Pacific Coast Hwy', 'Pacific Coast Hwy', 'Fremont St', 'Sunset Blvd'], ['Sunset Blvd', 'Bourbon Street', 'Pacific Coast Hwy', 'Fremont St', 'Broadway', '5th Avenue', 'Tropicana Ave', 'Michigan Ave'], ['Pacific Coast Hwy', 'Pacific Coast Hwy', 'Michigan Ave', 'Las Vegas Blvd', 'Sunset Blvd', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Pacific Coast Hwy'], ['Wall Street', 'Pacific Coast Hwy', 'Fremont St', '5th Avenue', 'Tropicana Ave', '5th Avenue', 'Broadway', 'Fremont St'], ['Tropicana Ave', 'Sunset Blvd', 'Broadway', 'Fremont St', 'Pacific Coast Hwy', 'Wall Street', 'Michigan Ave', 'Las Vegas Blvd'], ['Michigan Ave', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Tropicana Ave', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Wall Street', 'Tropicana Ave'], ['Wall Street', 'Wall Street', 'Sunset Blvd', 'Tropicana Ave', 'Sunset Blvd', 'Sunset Blvd', '5th Avenue', 'Michigan Ave'], ['Broadway', 'Sunset Blvd', '5th Avenue', 'Sunset Blvd', 'Pacific Coast Hwy', 'Wall Street', 'Fremont St', 'Michigan Ave'], ['Fremont St', 'Pacific Coast Hwy', 'Bourbon Street', 'Wall Street', 'Sunset Blvd', '5th Avenue', 'Wall Street', 'Wall Street']]

count of pieces by group:
[{'New York': [1, 1, 1], 'Las Vegas': [1, 2, 0], 'Los Angeles': [0, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [0, 0, 1], 'Las Vegas': [0, 1, 1], 'Los Angeles': [3, 2, 0], 'InstantChicago': [0], 'InstantNewOrleans': [0]}, {'New York': [0, 1, 1], 'Las Vegas': [1, 1, 0], 'Los Angeles': [1, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [1]}, {'New York': [0, 0, 0], 'Las Vegas': [0, 0, 2], 'Los Angeles': [4, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [1, 2, 1], 'Las Vegas': [1, 2, 0], 'Los Angeles': [1, 0, 0], 'InstantChicago': [0], 'InstantNewOrleans': [0]}, {'New York': [1, 0, 1], 'Las Vegas': [1, 1, 1], 'Los Angeles': [1, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [1, 0, 0], 'Las Vegas': [2, 0, 2], 'Los Angeles': [2, 0, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [2, 1, 0], 'Las Vegas': [1, 0, 0], 'Los Angeles': [0, 3, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [1, 1, 1], 'Las Vegas': [0, 1, 0], 'Los Angeles': [1, 2, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [3, 1, 0], 'Las Vegas': [0, 1, 0], 'Los Angeles': [1, 1, 0], 'InstantChicago': [0], 'InstantNewOrleans': [1]}]

prizes for group completion:
[{'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 40}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 40}]

prizes for 3-of-a-kind:
[[0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 25, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 25, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 25, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [25, 0, 0, 0, 0, 0, 0, 0, 0]]

total prize values:
[(70, 0), (0, 25), (60, 0), (20, 25), (50, 0), (120, 0), (20, 0), (20, 25), (70, 0), (40, 25)]

was board completed?:
[False, False, False, False, False, False, False, False, False, False]

------- first 10 simulations with 20 pieces (5000 simulations total) ------------------------------------

pieces selected:

count of pieces by group:
[{'New York': [1, 6, 1], 'Las Vegas': [1, 1, 1], 'Los Angeles': [4, 3, 1], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [2, 3, 2], 'Las Vegas': [4, 2, 1], 'Los Angeles': [0, 0, 1], 'InstantChicago': [3], 'InstantNewOrleans': [2]}, {'New York': [2, 3, 5], 'Las Vegas': [0, 0, 1], 'Los Angeles': [3, 3, 1], 'InstantChicago': [2], 'InstantNewOrleans': [0]}, {'New York': [4, 3, 1], 'Las Vegas': [2, 2, 1], 'Los Angeles': [3, 2, 0], 'InstantChicago': [1], 'InstantNewOrleans': [1]}, {'New York': [1, 3, 1], 'Las Vegas': [1, 2, 0], 'Los Angeles': [4, 3, 1], 'InstantChicago': [4], 'InstantNewOrleans': [0]}, {'New York': [3, 3, 5], 'Las Vegas': [2, 1, 0], 'Los Angeles': [3, 0, 0], 'InstantChicago': [2], 'InstantNewOrleans': [1]}, {'New York': [5, 2, 1], 'Las Vegas': [2, 2, 1], 'Los Angeles': [2, 4, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [3, 4, 2], 'Las Vegas': [2, 0, 0], 'Los Angeles': [0, 6, 1], 'InstantChicago': [2], 'InstantNewOrleans': [0]}, {'New York': [2, 2, 0], 'Las Vegas': [3, 2, 2], 'Los Angeles': [5, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [2]}, {'New York': [6, 3, 1], 'Las Vegas': [0, 2, 1], 'Los Angeles': [3, 3, 0], 'InstantChicago': [0], 'InstantNewOrleans': [1]}]

prizes for group completion:
[{'New York': 50, 'Las Vegas': 100, 'Los Angeles': 200, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 100, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 60, 'InstantNewOrleans': 80}, {'New York': 100, 'Las Vegas': 0, 'Los Angeles': 200, 'InstantChicago': 40, 'InstantNewOrleans': 0}, {'New York': 50, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 40}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 200, 'InstantChicago': 80, 'InstantNewOrleans': 0}, {'New York': 150, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 40, 'InstantNewOrleans': 40}, {'New York': 50, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 100, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 40, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 200, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 80}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 40}]

prizes for 3-of-a-kind:
[[0, 50, 0, 0, 0, 0, 25, 25, 0], [0, 25, 0, 25, 0, 0, 0, 0, 0], [0, 25, 25, 0, 0, 0, 25, 25, 0], [25, 25, 0, 0, 0, 0, 25, 0, 0], [0, 25, 0, 0, 0, 0, 25, 25, 0], [25, 25, 25, 0, 0, 0, 25, 0, 0], [25, 0, 0, 0, 0, 0, 0, 25, 0], [25, 25, 0, 0, 0, 0, 0, 50, 0], [0, 0, 0, 25, 0, 0, 25, 0, 0], [50, 25, 0, 0, 0, 0, 25, 25, 0]]

total prize values:
[(370, 100), (340, 50), (340, 100), (210, 75), (330, 75), (230, 100), (170, 50), (140, 100), (300, 50), (90, 125)]

was board completed?:
[False, False, False, False, False, False, False, False, False, False]


In [11]:
# here's what that big loop produces

# the total expected prizes awarded is the sum of all the expected cost fields + the grand prize value

# the probability of at least one grand prize winner is the inverse of not having a winner, which we can calculate (below)
#   using the prob_covered and expected_players fields

for p in sim_results:
print(p, sim_results[p])

1 {'avg_prize': 2.908, 'expected_players': 1000, 'expected_cost': 2908.0, 'prob_covered': 0.0, 'sim_count': 5000}
2 {'avg_prize': 5.372, 'expected_players': 600, 'expected_cost': 3223.2, 'prob_covered': 0.0, 'sim_count': 5000}
3 {'avg_prize': 10.069, 'expected_players': 400, 'expected_cost': 4027.6000000000004, 'prob_covered': 0.0, 'sim_count': 5000}
4 {'avg_prize': 16.866, 'expected_players': 300, 'expected_cost': 5059.8, 'prob_covered': 0.0, 'sim_count': 5000}
5 {'avg_prize': 27.609, 'expected_players': 250, 'expected_cost': 6902.25, 'prob_covered': 0.0, 'sim_count': 5000}
6 {'avg_prize': 39.088, 'expected_players': 200, 'expected_cost': 7817.6, 'prob_covered': 0.0, 'sim_count': 5000}
7 {'avg_prize': 53.47, 'expected_players': 180, 'expected_cost': 9624.6, 'prob_covered': 0.0, 'sim_count': 5000}
8 {'avg_prize': 72.025, 'expected_players': 160, 'expected_cost': 11524.0, 'prob_covered': 0.0, 'sim_count': 5000}
9 {'avg_prize': 88.337, 'expected_players': 140, 'expected_cost': 12367.18, 'prob_covered': 0.0, 'sim_count': 5000}
10 {'avg_prize': 107.766, 'expected_players': 120, 'expected_cost': 12931.92, 'prob_covered': 0.0, 'sim_count': 5000}
11 {'avg_prize': 127.934, 'expected_players': 100, 'expected_cost': 12793.4, 'prob_covered': 0.0, 'sim_count': 5000}
12 {'avg_prize': 150.883, 'expected_players': 90, 'expected_cost': 13579.470000000001, 'prob_covered': 0.0, 'sim_count': 5000}
13 {'avg_prize': 171.384, 'expected_players': 80, 'expected_cost': 13710.72, 'prob_covered': 0.0004, 'sim_count': 5000}
14 {'avg_prize': 195.769, 'expected_players': 70, 'expected_cost': 13703.83, 'prob_covered': 0.002, 'sim_count': 5000}
15 {'avg_prize': 219.257, 'expected_players': 60, 'expected_cost': 13155.42, 'prob_covered': 0.0036, 'sim_count': 5000}
16 {'avg_prize': 246.868, 'expected_players': 50, 'expected_cost': 12343.4, 'prob_covered': 0.0056, 'sim_count': 5000}
17 {'avg_prize': 268.351, 'expected_players': 40, 'expected_cost': 10734.04, 'prob_covered': 0.012, 'sim_count': 5000}
18 {'avg_prize': 292.598, 'expected_players': 30, 'expected_cost': 8777.94, 'prob_covered': 0.014, 'sim_count': 5000}
19 {'avg_prize': 318.353, 'expected_players': 15, 'expected_cost': 4775.295, 'prob_covered': 0.0268, 'sim_count': 5000}
20 {'avg_prize': 341.62, 'expected_players': 25, 'expected_cost': 8540.5, 'prob_covered': 0.035, 'sim_count': 5000}
21 {'avg_prize': 370.822, 'expected_players': 40, 'expected_cost': 14832.880000000001, 'prob_covered': 0.0448, 'sim_count': 5000}

In [12]:
# calculate the number of players likely to collect the "second-place" prize for completing the board, and its expected cost

other_board_finishers = (sum([sim['expected_players'] * sim['prob_covered'] for sim in sim_results.values()]) - 1)
cost_other_board_finishers = other_board_finishers * not_grand_prize


The total expected cost is just the sum of all the above expected_cost plus the grand prize and other board-completion prizes

The formula for determining the probability of having at least 1 grand prize winner is a little more complex:

$$\Large {1-}\large \prod _{i=1}^{n} {{(1 - P(covered))}^{expected\_players}}_i$$

...where n is the max number of entries a player can earn (21 in our example)

The goal should be to have it at right around 95-97%. Why not 100%? Keep in mind, the easier it is to win, the more likely the grand prize will be won earlier in the promotion. In a 7-day promo, ideally the grand prize would be won no earlier than the 6th or 7th day. In a 21-day promotion, ideally not until at least the 19th day. If the piece ratios are tweaked too much to guarantee a winner, it's also likely it will happen too early.

## now we've run the simulation and have the results, lets do the final calculations¶

In [13]:
total_expected_cost = sum([sim['expected_cost'] for sim in sim_results.values()]) + grand_prize + cost_other_board_finishers

In [14]:
# math.prod requires python 3.8+

# still an easy formula to represent and calculate

prob_at_least_one_winner = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in sim_results.values()])

In [15]:
total_expected_cost

Out[15]:
262425.545

#### ↑ how does this compare to budget target? -- if too low, raise prize values, or frequency

In [16]:
prob_at_least_one_winner

Out[16]:
0.9909658203888445

#### ↓ aim for 2-4

In [17]:
other_board_finishers

Out[17]:
3.6369999999999996
In [18]:
# the total number of game pieces you need to print should be the total expected from all player counts,
#   plus an extra safety margin if the game is more popular

game_pieces_needed = sum([ k * v for (k,v) in expected_players.items() ]) * 1.25
game_pieces_needed

Out[18]:
25993.75
In [19]:
# knowing the total number of pieces is nice, but what we really need to know is HOW MANY of each to print
#   our ratios need to be consistent with the model to keep the probabilities the same

ratios_sum = sum([v for v in ratios.values()])
piece_counts = {k : int(v * (game_pieces_needed / ratios_sum)) for (k,v) in ratios.items()}
piece_counts

Out[19]:
{'Wall Street': 3627,
'5th Avenue': 3627,
'Tropicana Ave': 2418,
'Fremont St': 2418,
'Las Vegas Blvd': 1209,
'Pacific Coast Hwy': 3627,
'Sunset Blvd': 3627,
'Rodeo Drive': 906,
'Michigan Ave': 1813,
'Bourbon Street': 906}

## we're done! (sort-of)¶

We have the values we need, the next step is to tweak the input parameters and re-run. We want to see how our changes affect the final results, in an attempt to get closer to the target budget, or to produce multiple configurations to choose from. We should also adjust the expected number of players, especially those collecting the highest number of pieces. Since these players inidividually collect the most prizes and are most likely to win the grand prize, sometimes small changes can affect the results quite a bit. The promotion might be more exciting than you expect, and you want to be prepared if more players become obsessed with earning the maximum amount of pieces!

In [20]:
prob_day7 = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in list(sim_results.values())[:21] ])
prob_day6 = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in list(sim_results.values())[:18] ])
prob_day5 = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in list(sim_results.values())[:15] ])
(prob_day7, prob_day6, prob_day5)

Out[20]:
(0.9909658203888445, 0.7930326534589696, 0.32195140820858736)

Computers can't generate true random values on their own, they can only "simulate" randomness. As long as the generated random values are uniform (equally likely), that's sufficient for a simulation like this one. You want to make sure, then, that the randomness algorithm is good at generating uniformity. Fortunately Python's default random generator uses the Mersenne-Twister algorithm which is very good at this. Be careful, however, if you translate this code to JavaScript, other languages, or attempt to do any randomness in Microsoft Excel. All of these have packages/add-ins to use Mersenne-Twister but they aren't part of the default language/application.

Below, we're going to test our simulation's uniformity

In [21]:
# all_random_pieces is a list of lists, we need to flatten to a single-list

# all_selected_pieces is a list of every random piece selection made by all the runs of all the simulations

all_selected_pieces = [street for streets in all_random_pieces for street in streets]
len(all_selected_pieces)

Out[21]:
1155000

### we can plot a histogram of every piece selected, but be careful -- when we do a lot of runs, we'll have millions of data points, which may require a long time to compute the histogram¶

we'll comment it out, but you can un-comment it of course

In [22]:
# restrict our histogram to the first 5000 pieces -- note that if you run this yourself you should eliminate this restriction
#   the benefit is a reduction in this file's size (for posting online)

fig = px.histogram(x=all_selected_pieces[:5000],
category_orders={"x":all_pieces},
labels={'x':'street name', 'y':'pieces'})
fig.show()