If you just want to solve and plot scenarios where some parameter value changes, you can skip now to the section on the Scenario
type. Otherwise, if you want to understand the code at a more fundamental level, keep reading in a linear fashion.
The ProdFunc.jl
file implements a ProdFunc
(production function) type that contains the variables for the function
describing how the inputs Xs and Xp produce safety and performance for all players. You can create an instance of the ProdFunc
type like
prodFunc = ProdFunc(
n = 2,
A = 10.,
α = 0.5,
B = 10.,
β = 0.5,
θ = 0.25
)
If you want to supply different parameters for each player or just like typing more stuff out, you can also supply the parameters as vectors of equal length (equal to n
). The following creates the same object as the example above:
prodFunc = ProdFunc(
n = 2,
A = [10., 10.],
α = [0.5, 0.5],
B = [10., 10.],
β = [0.5, 0.5],
θ = [0.25, 0.25]
)
If you omit a keyword argument, it will be set at a default value.
[Tip: in general, if you want to see the different ways you can call something, you can use Julia's handy methods
function. For example, to see the constructors available for ProdFunc
, you can call methods(ProdFunc)
, which will return something like:
# 3 methods for type constructor:
[1] ProdFunc(; n, A, α, B, β, θ) in Main at ~/Documents/code/AIIncentives.jl/src/ProdFunc.jl:57
[2] ProdFunc(n::Int64, A::Vector{T}, α::Vector{T}, B::Vector{T}, β::Vector{T}, θ::Vector{T}) where T<:Real in Main at ~/Documents/code/AIIncentives.jl/src/ProdFunc.jl:49
[3] ProdFunc(A, α, B, β, θ) in Main at ~/Documents/code/AIIncentives.jl/src/ProdFunc.jl:79
This tells us that we can make a new ProdFunc
by [1] providing keyword arguments, like in the examples shown above (anything after a semicolon in a Julia function definition is a keyword argument), or [2]-[3] providing all the required arguments in order.]
To determine the outputs for all players, you can do
(s, p) = f(prodFunc, Xs, Xp)
or, equivalently,
(s, p) = prodFunc(Xs, Xp)
where Xs
and Xp
are vectors of length equal to prodFunc.n
. Here s
and p
will be vectors representing the safety and performance of each player. To get the safety and performance of a single player with index i
, just do
(s, p) = f(prodFunc, i, xs, xp)
or, equivalently,
(s, p) = prodFunc(i, xs, xp)
where xs
and xp
are both scalar values; the outputs s
and p
will also be scalar.
The RiskFunc.jl
file implements a RiskFunc
type, which represents σi in the model (the probability of a safe outcome given that player i is the contest winner). The default risk function is WinnerOnlyRisk
, which defines:
That is, the probability of a safe outcome is determined entirely by the safety s of whoever wins the contest, with si interpreted as the odds of a safe outcome, given that player i wins the contest.
If you don't like this assumption, you can change how the risk function is defined. Some options are pre-defined in RiskFunc.jl
, with another reasonable option being MultiplicativeRisk
, which defines, for some vector of weights w:
That is, the probability of a safe outcome is, regardless of who wins the contest, n times the weighted geometric average of each player's individual probability si / (1+si). If wi is the same for all players, then this is just
which we can interpret as the case where each player has an independent probability 1 / (1+si) of causing a disaster, and σi is the probability that no player causes a disaster.
There is also an AdditiveRisk
option implemented, which defines:
The CSF.jl
file implements a CSF
(contest success function) type, which represents qi in the model (the probability that player i wins the contest). The only version currently implemented is BasicCSF
, which defines:
The PayoffFunc.jl
files implements a PayoffFunc
type, which represents ρij in the model (the payoff that player i gets if player j wins the contest, and the outcome is safe). The only version currently implemented is LinearPayoff
, which defines
for constants aw, bw, al, and bl.
As a default, it is assumed that aw = 1 and bw = al = bl = 0, so a player gets a payoff of 1 if they win, and a payoff of zero otherwise.
The CostFunc.jl
file implements a CostFunc
type, which controls the cost ci that players pay to use inputs Xs, Xp. The default CostFunc
is FixedUnitCost
, which represents the case where players pay a constant marginal cost r for Xs and Xp, described by
Notice that FixedUnitCost
uses the same unit cost for both Xs and Xp, although we can provide a different ri for each player. If we want to allow Xs and Xp to have different unit costs, we can use FixedUnitCost2
, which implements
There are a few other options for CostFunc
s defined, which you can play with if you want to go poking around in the CostFunc.jl
file. And, of course, you can always define your own.
The Problem.jl
file implements a Problem
type that represents the payoff function
You can construct a Problem
like this:
problem = Problem(
n = 2, # default is 2
d = 1.,
r = 0.05,
prodFunc = yourProdFunc,
riskFunc = yourRiskFunc, # default is WinnerOnlyRisk()
csf = yourCSF, # default is BasicCSF()
payoffFunc = yourPayoffFunc, # default is LinearPayoff(1, 0, 0, 0)
)
Note that the lengths of d
and r
must match and be equal to n
and prodFunc.n
. Again, you can omit arguments to use default values or provide vectors instead of scalars if you want different values for each player.
Instead of providing r
, you can provide a CostFunc
with the keyword costFunc
, e.g., costFunc = FixedUnitCost(2, [0.1, 0.1])
. If you just provide r
, it will be interpreted as costFunc = FixedUnitCost(n, r)
.
[Tip: to get help for a function in Julia, just type ?
then the function name in the Julia REPL. For, example, ?Problem
will give you something like the following:
[...]
Handy multi-purpose constructor for Problem type
provide all params as keyword arguments
if n is not provided, it defaults to n = 2
d can be provided as a scalar or a vector of length n, if not provided, defaults to 0
to specify the costFunc to use, provide one of the following:
• costFunc, which isa pre-constructed CostFunc
• rs and rp as scalars or vectors of length n; this will be interpreted as
FixedUnitCost2(rs, rp)
• r as a scalar or vector of length n; this will be interpreted as
FixedUnitCost([n,] r)
If you provide more than one of the above, the first one in the list above will be
used. If none of the above are provided, will default to FixedUnitCost(n, 0.1)
to specify the prodFunc to use, provide one of the following:
• prodFunc, which isa pre-constructed ProdFunc
• arguments for ProdFunc constructor, e.g., one or more of (A, α, B, β, θ)
If you provide more than one of the above, the first one in the list above will be
used. If none of the above are provided, will default to ProdFunc()
riskFunc, csf, and payoffFunc can be provided as pre-constructed objects; otherwise
defaults riskFunc = WinnerOnlyRisk(), csf = BasicCSF(), payoffFunc = LinearPayoff(n)
will be used
This gives you some help on how to create a new Problem
.]
To calculate the payoffs for all the players, you can do
payoffs(problem, Xs, Xp)
or
problem(Xs, Xp)
and for just player i
,
payoff(problem, i, Xs, Xp)
or
problem(i, Xs, Xp)
(Note that in the above, Xs
and Xp
are vectors of length problem.n
.)
The solve.jl
file contains several methods for finding Nash equilibria for a given problem. You can call all of these using the solve
function, which takes a problem and optional keyword arguments and returns a SolverResult
(which is basically just a container for the equilibrium values of safety, performance, and payoffs, plus an indicator for whether the solver converged successfully).
By default, solve(problem)
will find a pure strategy solution for problem
using a method of iterating on players' best responses: it starts with an arbitrary choice of Xs and Xp and at each iteration figures out the choice of strategy for each player that will maximize their payoff given the others' strategies. When the best-response strategies stop changing significantly at each iteration, we've reached a Nash equilibrium. You can also explicitly specify that you want to use this method by calling:
solve(problem, method = :iters)
You can also supply keyword arguments to modify some details of the solver's behavior. For example:
solve(
problem,
method = :iters,
tol = 1e-8, # stop if max relative diff. between iterations is < 1e-8
max_iters = 1_000, # go up to 1k iterations
verbose = true # print updates while solving
)
To see what options are available, take a look at the fields in the SolverOptions
struct in solve.jl
. (You should be able to access these using the command fieldnames(AIIncentives.SolverOptions)
.)
The other methods you can use are the following:
method = :mixed
runs a variation of the iterating method that attempts to maximize the best responses over a history of strategies. If the size of that history is large enough and the solver is run for enough iterations, the result should be a sample from a mixed strategy Nash equilibrium. You can control the history size by setting then_points
keyword argument. For example,
solve(problem, method = :mixed)
will return a sample from a (proposed) mixed-strategy equilibrium for problem
. Note that this method can be very slow, but this is the only method that can give you mixed-strategy solutions.
The scenarios.jl
file includes some helpful tools for looking at cases where you vary one or two variables of a problem while holding the others fixed.
The main type defined here is Scenario
, which is essentially an object that generates a family of related Problem
s. You can create a scenario like
scenario = Scenario(
n = 2,
A = 10.,
α = 0.5,
B = 10.,
β = 0.5,
θ = 0.25,
d = 1.,
r = range(0.01, 0.1, length = 20),
varying = :r
)
which defines a 2-player scenario where r
varies over 20 values between 0.01 and 0.1. Notice that we construct the scenario with all the variables we would normally provide to create a ProdFunc
and Problem
. Most of the arguments are single values, but the parameter we want to vary is not; that parameter must be an array with a column for each player and a row for each value we want to use. (You can also provide a single vector, in which case it will be assumed that you want to use the same values for all players.) We also need to specify which parameter we're varying with the varying
keyword argument (if not included, the default is :r
).
If we want, we can include a second varying parameter, like so:
scenario = Scenario(
n = 2,
A = 10.,
α = 0.5,
B = 10.,
β = 0.5,
θ = range(0., 1., length = 4),
d = 1.,
r = range(0.01, 0.1, length = 20),
varying = :r,
varying2 = :θ
)
In this example, we look at the problem with every combination of the provided varying and secondary varying parameters. The only difference between the two is that when we plot the results, the varying parameter will vary along the x-axis, while the secondary varying parameter will vary in different series.
In the scenarios we've seen so far, we've provided only single values to the non-varying parameters. If we want the players to have different parameters, we need to provide a vector (with length equal to n
) instead. For example,
scenario = Scenario(
n = 2,
A = [10., 20.],
α = 0.5,
B = 10.,
β = 0.5,
θ = 0.25,
d = 1.,
r = range(0.01, 0.1, length = 20),
varying = :r
)
gives us a scenario where player 1 has A = 10, and player 2 has A = 20.
You can also supply riskFunc
, csf
, and payoffFunc
arguments to the scenario constructor if you want to use something other than the defaults, e.g.:
scenario = Scenario(
n = 2,
r = range(0.01, 0.1, length = 20),
varying = :r,
riskFunc = WinnerOnlyRisk(),
payoffFunc = LinearPayoff(1., 0.1, 0., 0.)
)
To find the equilibrium solutions for a scenario, use the solve
function:
results = solve(scenario)
This will return a ScenarioResult
object, which just packages together the given scenario and a vector or array of SolverResult
objects.
You can specify the method you want to use to solve with the method
keyword argument:
results = solve(scenario, method = :mixed)
The default method is :iters
.
Like with the solve
function for problems, you can also supply extra options as keyword arguments:
results = solve(
scenario,
method = :mixed,
n_points = 100,
verbose = true
)
You can plot the results as
plot(result)
where result
is a ScenarioResult
. You can provide arguments to customize the plot:
plot(
result,
plotsize = (900, 800),
title = "Mixed strategy solutions",
logscale = true,
take_avg = true
)
The plot
method will return a plot object that you can edit with the Plots.jl
package. This plot will have 6 subplots, but if you only want one of those subplots, you can use get_plots
instead of plot
to get a list of those subplots. For example, if we want just the plot of the probability-weighted σ, we can run
get_plots(result)[5]
and if we want to change the formatting, we can do that with the typical Plots.jl
API. For example,
plot!(plot_title = "Some fantastic plots", xlabel = "a new label for the x axis")
would change the title and x-axis label for the most recently created plot.
By default, actors in problems/scenarios are assumed to have perfect information (correct beliefs) about all parameters (their own and others'). The package also supports some special problems and scenarios that describe situations where players can have different beliefs about the model parameters. Currently, we can assume only that players vary in their beliefs about the model state but that their higher-order beliefs are perfect (i.e., they may disagree about the true model parameters but are perfectly informed about their competitors' beliefs).
The ProblemWithBeliefs
type is essentially a container that holds
- a
Problem
calledbaseProblem
which describes the true parameter values - a vector of
Problem
s calledbeliefs
, which describe theProblem
s that each player thinks they are in
For example, if we let the first element of beliefs
be equal to baseProblem
, then we're assuming that player 1 has correct beliefs; if we let the second element of beliefs
be the same as baseProblem
, but with A
higher, then we're assuming that player 2 thinks that safety productivity is higher than it actually is.
Here's how we could initialize the above example:
problem = ProblemWithBeliefs(
Problem(A = 10), # this is baseProblem
beliefs = [
Problem(A = 10), # same as baseProblem in this example
Problem(A = 20) # player 2 has different belief about A
]
)
We can use ProblemWithBeliefs
in essentially the same way we might use a Problem
; i.e., we can plug it into solve
, get payoffs for a particular strategy, and so on.
What if we want to create a Scenario
with heterogenous beliefs? Here, we can use the handy ScenarioWithBeliefs
, which works analogously to ProblemWithBeliefs
(as described above). To create a ScenarioWithBelefs
, we supply a baseScenario
, which represents the true parameter values for the scenario; then we supply beliefs
, which here is a vector of Dict
s that indicate how each player's beliefs differ from baseScenario
.
As an example, if we wanted a scenario with A = 10
, player 1's beliefs match the true parameters (with A = 10
), and player 2's believes that A = 20
, we could let
beliefs = [
Dict(), # supply an empty Dict to denote no difference from baseScenario
Dict(:A => 20)
]
To initialize the complete scenario:
scenario = ScenarioWithBeliefs(
# supply baseScenario first:
Scenario(
A = 10,
# ... other scenario params
),
beliefs = [
Dict(),
Dict(:A => 20)
]
)
We can use ScenarioWithBeliefs
just like we would a normal Scenario
; i.e., we can plug it into solve
, plot the result, and so on.