11. Monte Carlo and Root Finding¶
11.1. The Monty-Hall Game¶
%%html
<iframe width="912" height="513" src="https://www.youtube.com/embed/rn1y-HrmA5c?end=23" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
Is it better to change the initial pick? What is the chance of winning if we change?
Hint: There are two doors to choose from, and only one of the doors has treasure behind.
Let’s use the following program to play the game a couple of times.
import random
def play_monty_hall(num_of_doors=3):
'''Main function to run the Monty Hall game.'''
doors = {str(i) for i in range(num_of_doors)}
door_with_treasure = random.sample(doors, 1)[0]
# Input initial pick of the door.
while True:
initial_pick = input(f'Pick a door from {", ".join(sorted(doors))}: ')
if initial_pick in doors: break
# Open all but one other door. Opened door must have nothing.
doors_to_open = doors - {initial_pick, door_with_treasure}
other_door = door_with_treasure if initial_pick != door_with_treasure \
else doors_to_open.pop()
print('Door(s) with nothing behind:', *doors_to_open)
# Allow player to change the initial pick the other (unopened) door.
change_pick = input(
f'Would you like to change your choice to {other_door}? [y/N] ').lower(
) == 'y'
# Check and recording winning.
if not change_pick:
mh_stats['# no change'] += 1
if door_with_treasure == initial_pick:
mh_stats['# win without changing'] += 1
return print('You won!')
else:
mh_stats['# change'] += 1
if door_with_treasure == other_door:
mh_stats['# win by changing'] += 1
return print('You won!')
print(f'You lost. The door with treasure is {door_with_treasure}.')
mh_stats = dict.fromkeys(('# win by changing',
'# win without changing',
'# change',
'# no change'),0)
def monty_hall_statistics():
'''Print the statistics of the Monty Hall games.'''
print('-' * 30, 'Statistics', '-' * 30)
if mh_stats['# change']:
print(f"% win by changing: \
{mh_stats['# win by changing'] / mh_stats['# change']:.0%}")
if mh_stats['# no change']:
print(f"% win without changing: \
{mh_stats['# win without changing']/mh_stats['# no change']:.0%}")
play_monty_hall()
monty_hall_statistics()
You may also play the game online.
To get a good estimate of the chance of winning, we need to play the game many times.
We can write a Monty-Carlo simulation instead.
# Do not change any variables defined here, or some of the tests may fail.
import numpy as np
np.random.randint?
np.random.seed(0) # for reproducible result
num_of_games = int(10e7)
door_with_treasure = np.random.randint(1, 4, num_of_games, dtype=np.uint8)
initial_pick = np.random.randint(1, 4, num_of_games, dtype=np.uint8)
print(f"{'Door with treasure:':>19}", *door_with_treasure[:10],'...')
print(f"{'Initial pick:':>19}", *initial_pick[:10],'...')
door_with_treasure
stores as 8-bit unsigned integersuint8
the door numbers randomly chosen from \(\{1, 2, 3\}\) as the doors with treasure behind for a numbernum_of_games
of Monty-Hall games.initial_pick
stores the initial choices for the different games.
If players do not change their initial pick, the chance of winning can be estimated as follows:
def estimate_chance_of_winning_without_change(door_with_treasure,
initial_pick):
'''Estimate the chance of winning the Monty Hall game without changing
the initial pick using the Monte Carlo simulation of door_with_treasure
and initial_pick.'''
count_of_win = 0
for x, y in zip(door_with_treasure, initial_pick):
if x == y: count_of_win += 1
return count_of_win / n
n = num_of_games // 100
estimate_chance_of_winning_without_change(door_with_treasure[:n],
initial_pick[:n])
However, the above code is inefficient and takes a long time to run. You may try running it on the entire sequences of door_with_treasure
and initial_pick
but DO NOT put the code in your notebook, as jupyterhub refuses to autograde notebook that take too much time or memory to run.
A simpler and also more efficient solution with well over 100 times speed up is as follows:
def estimate_chance_of_winning_without_change(door_with_treasure,
initial_pick):
'''Estimate the chance of winning the Monty Hall game without changing
the initial pick using the Monte Carlo simulation of door_with_treasure
and initial_pick.'''
return (door_with_treasure == initial_pick).mean()
estimate_chance_of_winning_without_change(door_with_treasure,
initial_pick)
The code uses the method mean
of ndarray
that computes the mean of the numpy
array.
In computing the mean, True
and False
are regarded as 1
and 0
respectively, as illustrated below.
for i in True, False:
for j in True, False:
print(f'{i} + {j} == {i + j}')
Exercise Define the function estimate_chance_of_winning_by_change
same as estimate_chance_of_winning_without_change
above but returns the estimate of the chance of winning by changing the initial choice instead. Again, implement efficiently or jupyterhub may refuse to autograde your entire notebook.
Hint: Since there are only two unopened doors at the end of each game, a player will win by changing the initial pick if the initially picked door is not the door with treasure behind.
def estimate_chance_of_winning_by_change(door_with_treasure,
initial_pick):
'''Estimate the chance of winning the Monty Hall game by changing
the initial pick using the Monte Carlo simulation of door_with_treasure
and initial_pick.'''
# YOUR CODE HERE
raise NotImplementedError()
# tests
assert np.isclose(
estimate_chance_of_winning_by_change(door_with_treasure[:10],
initial_pick[:10]), 0.7)
11.2. Solving a 3-by-3 system of linear equations¶
numpy
has a module linalg
for linear algebra, and the module provides a function called solve
that can solve a system of linear equations. For the example in the lecture
we can obtain the solution as follows:
np.linalg.solve?
A = np.array([[2.,2],[0,2]])
b = np.array([1.,1])
x = np.linalg.solve(A,b)
As explained in the lecture, the arguments A
and b
are obtained from the matrix form of the system of linear equations:
However, the function returns an error when there is no unique solutions.
# Case with infinitely many solution
A = np.array([[2.,2],[2,2]])
b = np.array([1.,1])
x = np.linalg.solve(A,b)
# Case without solution
A = np.array([[2.,2],[2,2]])
b = np.array([1.,0])
x = np.linalg.solve(A,b)
A unique solution does not exist if and only if the determinant of \(\mathbf{A}\) is \(0\), in which case \(\mathbf{A}\) is called a singular matrix. For a \(2\)-by-\(2\) matrix, the determinant is defined as follows:
For example, the first system has a unique solution because
The last two systems do not have unique solutions because
We can use the function det
from np.linalg
to compute the determinant as follows:
np.linalg.det?
np.linalg.det(np.array([[2.,2],[0,2]])), np.linalg.det(np.array([[2.,2],[2,2]]))
Exercise Use the det
and solve
functions to assign x
to the numpy
array storing the solution of the following linear system if the solution is unique else None
.
# YOUR CODE HERE
raise NotImplementedError()
x
# tests
# As the main test must be hidden, you may want to verify your solution
# as explained in the lecture using matrix multiplication.
assert isinstance(x,np.ndarray) and x.shape == (3,)
11.3. Solving non-linear equations¶
Suppose we want to solve:
for some possibly non-linear real-valued function \(f(x)\) in one real-valued variable \(x\). Quadratic equation with an \(x^2\) term is an example. The following is another example.
import matplotlib.pyplot as plt
import numpy as np
f = lambda x: x*(x - 1)*(x - 2)
x = np.linspace(-0.5,2.5)
plt.plot(x,f(x))
plt.axhline(color='gray',linestyle=':')
plt.xlabel(r'$x$')
plt.title(r'Plot of $f(x)=x(x-1)(x-2)$')
plt.show()
While it is clear that the above function has three roots, namely, \(x=0, 1, 2\), can we write a program to compute a root of any given continuous function \(f\)?
%%html
<iframe width="912" height="513" src="https://www.youtube.com/embed/PXSLcEGkXkU" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
The following function bisection
takes as arguments
a continuous function
f
,two real values
a
andb
,a positive integer
n
indicating the maximum depth of the recursion, and
returns a list
[xstart, xstop]
if the bisection succeeds in capturing a root in the interval[xstart, xstop]
bounded bya
andb
, or else, returns a empty list[]
.
def bisection(f,a,b,n=10):
if f(a) * f(b) > 0:
return [] # because f(x) may not have a root between x=a and x=b
elif n <= 0: # base case when recursion cannot go any deeper
return [a,b] if a <= b else [b,a]
else:
c = (a + b)/2 # bisect the interval between a and b
return bisection(f,a,c,n-1) or bisection(f,c,b,n-1) # recursion
# bisection solver
import ipywidgets as widgets
@widgets.interact(a=(-0.5,2.5,0.5),b=(-0.5,2.5,0.5),n=(0,10,1))
def bisection_solver(a=-0.5,b=0.5,n=0):
x = np.linspace(-0.5,2.5)
plt.plot(x,f(x))
plt.axhline(color='gray',linestyle=':')
plt.xlabel(r'$x$')
plt.title(r'Bisection on $f(x)$')
[xstart,xstop] = bisection(f,a,b,n)
plt.plot([xstart,xstop],[0,0],'r|-')
print('Interval: ',[xstart,xstop])
Try setting the values of \(a\) and \(b\) as follows and change \(n\) to see the change of the interval step-by-step.
bisection(f,-0.5,0.5), bisection(f,1.5,0.5), bisection(f,-0.1,2.5)
Exercise Modify the function bisection
to
take the floating point parameter
tol
instead ofn
, andreturn the interval from the bisection method represented by a list
[xstart,xstop]
but as soon as the gapxstop - xstart
is \(\leq\)tol
.
def bisection(f,a,b,tol=1e-9):
# YOUR CODE HERE
raise NotImplementedError()
# tests
import numpy as np
f = lambda x: x*(x - 1)*(x - 2)
bisection(f,1.5,0.5)
assert np.isclose(bisection(f,-0.5,0.5),[-9.313225746154785e-10, 0.0]).all()
assert np.isclose(bisection(f,1.5,0.5,1e-2), [1.0, 1.0078125]).all()
assert np.isclose(bisection(f,-0.1,2.5,1e-3), [1.9998046875000002, 2.0004394531250003]).all()