import ternary
import numpy as np
import matplotlib.patches as mpatches
from ternary.helpers import project_point
from matplotlib.legend_handler import HandlerPatch
from sympy import *
init_printing()
Studying rock paper scissors using Python
A generalized rock paper scissors game
Imagine a population with three types of individuals, which we will call \(R\), \(P\), and \(S\). Individuals compete over resources. When two individuals of the same type interact, they obtain neither a benefit or a cost. However, if individuals of two different types interact, they fight until one defeats the other. The winner of the fight gets a benefit of \(V\), and the loser incurs a cost \(C\). Further, the three types obey the following rules:
- R always defeats S
- S always defeats P
- P always defeats R
What can we say about how the distribution of types changes over time?
The payoff matrix
Thre payoff matrix for our game is given by
\[\mathbf{A} = \begin{bmatrix} 0 & -C & V \\ V & 0 & -C \\ -C & V & 0 \end{bmatrix}\]
We can rewrite this in Python symbolic math:
= symbols('V C') #Value and Cost
V,C
= Matrix([[0, -C, V],[V,0,-C],[-C,V,0]])
Payoff_matrix
Payoff_matrix
\(\displaystyle \left[\begin{matrix}0 & - C & V\\V & 0 & - C\\- C & V & 0\end{matrix}\right]\)
Fitnesses
As in the hawk dove case, the fitness of the \(i^{\text{th}}\) strategy is given by
\[ w_i = w_0 + s\left(\mathbf{p}^{\mathrm{T}}\mathbf{Ap}\right) \]
where \(w_0\) is a baseline fitness, \(s\) parametrizes the relative importance of the game for fitness, and \(\mathbf{p} = (p_R, p_P, p_S)\) is the vector describing the frequencies of the three types
#Variables for fitness
= symbols('omega_R omega_P omega_S')
wr,wp,ws
#Parameters affecting fitness
= symbols('omega_0 s p_R p_P')
w0,s,pr,pp
#Variables for fitness differences (for nullclines)
= symbols('alpha beta') alpha,beta
Payoffs for each strategy are obtained from the matrix
= Payoff_matrix*Matrix([pr,pp,(1-pr-pp)])
fitness_payoffs
fitness_payoffs
\(\displaystyle \left[\begin{matrix}- C p_{P} + V \left(- p_{P} - p_{R} + 1\right)\\- C \left(- p_{P} - p_{R} + 1\right) + V p_{R}\\- C p_{R} + V p_{P}\end{matrix}\right]\)
#Payoff equations for the three strategies
= w0 + s*(fitness_payoffs[0])
wr = w0 + s*(fitness_payoffs[1])
wp = w0 + s*(fitness_payoffs[2]) ws
If we wish, we can also plot nullclines (curves for which fitnesses of two strategies are equal). Fixed points are points at which all nullclines intersect
= wr-wp
alpha = wp-ws
beta
= solve([alpha],pp,pr)
r_p_nullcline = solve([beta],pp,pr)
p_s_nullcline = solve([alpha+beta],pp,pr)
r_s_nullcline
= solve([alpha,beta],pr,pp)
fixed_points
fixed_points
\(\displaystyle \left\{ p_{P} : \frac{1}{3}, \ p_{R} : \frac{1}{3}\right\}\)
Thus, the internal fixed point is always at (1/3,1/3,1/3).
Plotting
To implement these nullclines, we need to convert the nullcline equations from symbolic math to Python functions.
def r_p_null_func(PR,v,c):
= lambdify([V,C,pr],r_p_nullcline[pp],'numpy')
null_sol
try:
= null_sol(v,c,PR)
PP
except ZeroDivisionError: #Occurs when V=C because of how Sympy implements analytical solutions
return
= 1-PP-PR
PS
return (PR,PP,PS)
def p_s_null_func(PR,v,c):
= lambdify([V,C,pr],p_s_nullcline[pp],'numpy')
null_sol try:
= null_sol(v,c,PR)
PP
except ZeroDivisionError:
return
= 1-PP-PR
PS
return (PR,PP,PS)
def r_s_null_func(PR,v,c):
= lambdify([V,C,pr],r_s_nullcline[pp],'numpy')
null_sol try:
= null_sol(v,c,PR)
PP
except ZeroDivisionError:
return
= 1-PP-PR
PS
return (PR,PP,PS)
Since all the frequency values are constrained to lie on a simplex (because sum of all frequencies equals 1), we can plot the system on a ternary plot. In a ternary plot, Cartesian distance is converted to distance from the edges of an equilateral triangle. The below functions are to assist with this.
To plot the vector field for trajectories, I have used the replicator equation:
\[ \dot{f_s}(t) = (\omega_{s} - \bar{\omega})f_{s}(t) \]
where \(f_s\) is the frequency of strategy s, \(\omega_s\) is the fitness of strategy s, and \(\bar{\omega}\) is the mean fitness of the population.
def within_simplex(r): #Find out whether a given point lies within the S_1 simplex
if r is not None:
if r[0]+r[1]+r[2] <= 1 and r[0] >= 0 and r[1] >= 0 and r[2] >= 0:
return True
else:
return False
else:
return False
#Convert the nullclines into Python functions
= lambdify([pr,pp,V,C,w0,s],wr)
wr_func = lambdify([pr,pp,V,C,w0,s],wp)
wp_func = lambdify([pr,pp,V,C,w0,s],ws)
ws_func
def get_vector(pr,pp,v,c,w0,s):
#get the fitness values
= wr_func(pr,pp,v,c,w0,s)
wr_val = wp_func(pr,pp,v,c,w0,s)
wp_val = ws_func(pr,pp,v,c,w0,s)
ws_val
#Get relative fitness
= pr*wr_val + pp*wp_val + (1-pr-pp)*ws_val
w_bar -= w_bar
wr_val -= w_bar
wp_val -= w_bar
ws_val
#Project the 3D Cartesian coordinates onto a 2D simplex
= project_point((pr,pp,1-pr-pp))
r_proj = project_point((pr*wr_val,pp*wp_val,(1-pr-pp)*ws_val))
dr_proj
return r_proj,dr_proj
def get_color(pr,pp,v,c,w0,s): #To indicate which strategy is best at a given point
= wr_func(pr,pp,v,c,w0,s)
wr_val = wp_func(pr,pp,v,c,w0,s)
wp_val = ws_func(pr,pp,v,c,w0,s)
ws_val
if wr_val >= wp_val and wr_val >= ws_val:
return 'red'
if wp_val >= wr_val and wp_val >= ws_val:
return 'green'
if ws_val >= wr_val and ws_val >= wp_val:
return 'blue'
else:
return 'black'
#copied below function from stack overflow: https://stackoverflow.com/questions/22348229/matplotlib-legend-for-an-arrow
def make_legend_arrow(legend, orig_handle,xdescent, ydescent,width, height, fontsize):
= mpatches.FancyArrow(0, 0.5*height, width, 0, length_includes_head=True, head_width=0.75*height )
p return p
def make_ternary_plot(V,C,w0=0,s=1,plot_field=True,plot_nullclines=False,save=False,filename=''):
= ternary.figure(scale=1)
figure, tax
= 16
fontsize = 0.11
offset
10,10)
figure.set_size_inches(
#set background
="#ffffff", alpha=0.7)
tax.set_background_color(color
# Draw Boundary and Gridlines
=2.0)
tax.boundary(linewidth="#000000", linestyle='-',linewidth=1, multiple=1)
tax.gridlines(color="#000000", linestyle='-',linewidth=0.5, multiple=0.2,alpha=0.5)
tax.gridlines(color
#Depict parameter values
#tax.set_title("V = "+ str(V) + "\nC = " + str(C) + '\nV-C = ' + format(V-C,'.2f'),position=(-0.05,1.5,-0.03),fontsize=18)
#Label the axes and vertices
"Pure Paper", fontsize=fontsize,position=(-0.1,1.2,-0.03),offset=0)
tax.top_corner_label("Pure\nScissors", fontsize=fontsize,position=(-0.05,-0.01,1.1),offset=0)
tax.left_corner_label("Pure\nRock", fontsize=fontsize,position=(1.05,0,-0.02),offset=0)
tax.right_corner_label("$P_S$", fontsize=18, offset=0.16)
tax.left_axis_label("$P_P$", fontsize=18, offset=0.16)
tax.right_axis_label("$P_R$", fontsize=18, offset=0.16)
tax.bottom_axis_label(
if plot_nullclines:
= np.arange(0,1.01,0.01)
pr_list = []
r_p_null = []
p_s_null = []
r_s_null for p in pr_list:
#Get the value of the R-P null
= r_p_null_func(p,V,C)
tuple1 if within_simplex(tuple1):
r_p_null.append(tuple1)
#Get the value of the P-S null
= p_s_null_func(p,V,C)
tuple2 if within_simplex(tuple2):
p_s_null.append(tuple2)
#Get the value of the R-S null
= r_s_null_func(p,V,C)
tuple3 if within_simplex(tuple3):
r_s_null.append(tuple3)
#Plot nullclines
if len(r_p_null) > 0:
=2.0,color='#ffff00',label='R-P nullcline')
tax.plot(r_p_null,linewidthif len(p_s_null) > 0:
=2.0,color='#00ffff',label='P-S nullcline')
tax.plot(p_s_null,linewidthif len(r_s_null) > 0:
=2.0,color='#ff00ff',label='R-S nullcline')
tax.plot(r_s_null,linewidth
#draw the vector field
if plot_field:
= 0.02
length = np.arange(0,1.2,0.05) #Make this finer if you want more arrows
prop_list for PR in prop_list:
for PP in prop_list:
if within_simplex((PR,PP,1-PR-PP)):
= get_vector(PR,PP,V,C,w0,s)
r, dr if np.hypot(dr[0],dr[1]) != 0:
= length*dr/np.hypot(dr[0],dr[1])
dx,dy else:
= 0,0
dx,dy = get_color(PR,PP,V,C,w0,s)
color 0],r[1],dx,dy,color=color,width=0.005,scale=0.1,scale_units='inches')
tax.get_axes().quiver(r[
='lbr', fontsize=13, multiple=0.2, linewidth=0.5, tick_formats="%.1f",axes_colors={'l':'black','r':'black','b':'black'}, offset=0.02)
tax.ticks(axis'off')
tax.get_axes().axis(
tax.clear_matplotlib_ticks()if plot_nullclines:
tax.legend()if plot_field:
= mpatches.FancyArrow(10,10,0.01,0.01,color='red',label='Rock dominates')
red_arrow = mpatches.FancyArrow(10,10,0.1,0.1,color='green',label='Paper dominates')
blue_arrow = mpatches.FancyArrow(10,10,0.1,0.1,color='blue',label='Scissors dominates')
green_arrow = [red_arrow,blue_arrow,green_arrow],handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow),})
tax.legend(handles
if save:
str(filename+".png"))
tax.savefig( tax.show()
Analyzing the dynamics
Case 1: V > C
# Note that I'm not plotting nullclines here.
10,0) make_ternary_plot(
When V > C, the interior fixed point is an attractor. Trajectories that are not on the edges flow into the fixed point.
Case 2: V = C
10,10) make_ternary_plot(
When V=C, the game is ‘zero-sum’. Here, the internal fixed point is neither an attractor nor a repeller. Trajectories form closed orbits which circle the fixed point.
Case 3: V < C
0,10) make_ternary_plot(
When V < C, the internal fixed point is unstable. Trajectories move away from the center and go towards the edges of the simplex. In both computer simulations and real populations, this generally means that one of the strategies will go extinct, even though theoretically, the edges are never reached.