Modeling Bounded Confidence Updating in Julia III

Igor Douven & Rainer Hegselmann

Setup

This is the third part of the tutorial, which uses again the Agents.jl package for Julia to replicate some of the results of "Mis- and disinformation in a bounded confidence model" .

We start again by load the requisite packages:

using Agents
using StatsBase
using Distributions
using DataFrames
using DataFramesMeta
using LinearAlgebra
using RCall
using GLM
using Gadfly
using LightGraphs
using Colors
using ColorSchemes
using Cairo
using Compose

The following define the types of agent that will populate our model. We make sure from the start the model has all the properties to implement the fullest version of the BCU model studied in our paper (i.e., including $\epsilon$-$\alpha$ dynamics). Note in particular that it allows agents to have parameters $\lambda_{\epsilon}$ and $\lambda_{\alpha}$, which regulate how fast they are willing to move away from their current values for $\epsilon$ and $\alpha$ when they come to know the values for those parameters held by their peers. (NB: the field previous opinion in the agent definitions is needed to have the possibility of running the model till convergence; see the function terminate below.)

mutable struct TruthSeeker <: AbstractAgent
    id::Int
    pos::Int
    ϵ_old::Float64
    ϵ_new::Float64
    α_old::Float64
    α_new::Float64
    λ_ϵ::Float64
    λ_α::Float64
    old_opinion::Float64
    new_opinion::Float64
    previous_opinion::Float64
end

mutable struct FreeRider <: AbstractAgent
    id::Int
    pos::Int
    ϵ_old::Float64
    ϵ_new::Float64
    α_old::Float64
    α_new::Float64
    λ_ϵ::Float64
    λ_α::Float64
    old_opinion::Float64
    new_opinion::Float64
    previous_opinion::Float64
end

mutable struct Campaigner <: AbstractAgent
    id::Int
    pos::Int
    ϵ_old::Float64
    ϵ_new::Float64
    α_old::Float64
    α_new::Float64
    λ_ϵ::Float64
    λ_α::Float64
    old_opinion::Float64
    new_opinion::Float64
    previous_opinion::Float64
end

This sets up the model:

function hk_model(
    τ::Float64, # truth
    ρ::Float64, # radical opinion
    ϵ_low::Float64,
    ϵ_upp::Float64,
    α_low::Float64,
    α_upp::Float64,
    λ_ϵ_low::Float64,
    λ_ϵ_upp::Float64,
    λ_α_low::Float64,
    λ_α_upp::Float64,
    numagents::Int,
    numfreeriders::Int,
    numcampaigners::Int
    )
    n0 = numagents + numfreeriders
    n = n0 + numcampaigners
    model = ABM(Union{TruthSeeker,FreeRider,Campaigner}, GraphSpace(complete_digraph(n)), scheduler = fastest, properties = Dict(=>τ); warn = false)
    for i in 1 : numagents
        e = !(ϵ_low < ϵ_upp) ? ϵ_low : rand(Uniform(ϵ_low, ϵ_upp))
        a = !(α_low < α_upp) ? α_low : rand(Uniform(α_low, α_upp))
        l1 = !(λ_ϵ_low < λ_ϵ_upp) ? λ_ϵ_low : rand(Uniform(λ_ϵ_low, λ_ϵ_upp))
        l2 = !(λ_α_low < λ_α_upp) ? λ_α_low : rand(Uniform(λ_α_low, λ_α_upp))
        o = rand()
        add_agent_single!(TruthSeeker(i, i,  e, e, a, a, l1, l2, o, o, -1), model)
    end
    for i in numagents + 1 : n0
        e = !(ϵ_low < ϵ_upp) ? ϵ_low : rand(Uniform(ϵ_low, ϵ_upp))
        a = !(α_low < α_upp) ? α_low : rand(Uniform(α_low, α_upp))
        l = !(λ_ϵ_low < λ_ϵ_upp) ? λ_ϵ_low : rand(Uniform(λ_ϵ_low, λ_ϵ_upp))
        o = rand()
        add_agent_single!(FreeRider(i, i, e, e, 0, 0, l, 0, o, o, -1), model)
    end
    for i in n0 + 1 : n
        add_agent_single!(Campaigner(i, i, 0, 0, 0, 0, 0, 0, ρ, ρ, -1), model)
    end
    return model
end

The functions below define the BCI, selecting the opinions of the agents in that interval or – for the $\epsilon$-$\alpha$ dynamics – the $\epsilon$ values and $\alpha$ values:

function boundfilter(agent, model)
    filter(j->isapprox(agent.old_opinion, j; atol=agent.ϵ_old), [ a.old_opinion for a in allagents(model) ])
end

function boundfilter_eps(agent, model)
    ind = findall(j->isapprox(agent.old_opinion, j; atol=agent.ϵ_old), [ a.old_opinion for a in allagents(model) ])
    return [ a.ϵ_old for a in allagents(model) ][ind]
end

function boundfilter_alp(agent, model)
    ind = findall(j->isapprox(agent.old_opinion, j; atol=agent.ϵ_old), [ a.old_opinion for a in allagents(model) ])
    return [ a.α_old for a in allagents(model) ][ind]
end

The following functions define the updating:

function agent_static_step!(agent::TruthSeeker, model)
    agent.new_opinion = agent.α_old*model.τ + (1 - agent.α_old)*mean(boundfilter(agent, model))
    agent.previous_opinion = agent.old_opinion
end

function agent_static_step!(agent::FreeRider, model)
    agent.new_opinion = mean(boundfilter(agent, model))
    agent.previous_opinion = agent.old_opinion
end

function agent_static_step!(agent::Campaigner, model)
    agent.new_opinion = agent.old_opinion
    agent.previous_opinion = agent.old_opinion
end

function agent_dynamic_step!(agent::TruthSeeker, model)
    agent.new_opinion = agent.α_old*model.τ + (1 - agent.α_old)*mean(boundfilter(agent, model))
    agent.ϵ_new = agent.λ_ϵ*agent.ϵ_old + (1 - agent.λ_ϵ)*mean(boundfilter_eps(agent, model))
    agent.α_new = agent.λ_α*agent.α_old + (1 - agent.λ_α)*mean(boundfilter_alp(agent, model))
    agent.previous_opinion = agent.old_opinion
end

function agent_dynamic_step!(agent::FreeRider, model)
    agent.new_opinion = mean(boundfilter(agent, model))
    agent.ϵ_new = agent.λ_ϵ*agent.ϵ_old + (1 - agent.λ_ϵ)*mean(boundfilter_eps(agent, model))
    agent.α_new = agent.α_old
    agent.previous_opinion = agent.old_opinion
end

function agent_dynamic_step!(agent::Campaigner, model)
    agent.old_opinion = agent.old_opinion
    agent.ϵ_new = agent.ϵ_old
    agent.α_new = agent.α_old
    agent.previous_opinion = agent.old_opinion
end

Helper function to make the model as a whole evolve:

function model_step!(model)
    for a in allagents(model)
        a.old_opinion = a.new_opinion
        a.ϵ_old = a.ϵ_new
        a.α_old = a.α_new
    end
end

Helper function for determining when convergence has been reached:

function terminate(model, s)
    if any(!isapprox(a.previous_opinion, a.new_opinion; atol=1e-5) for a in allagents(model))
        return false
    else
        return true
    end
end

To run the model, we need four methods, to run with and without $\epsilon$-$\alpha$ dynamics, and to run with and without a predetermined number of iterations ("without" means "running till convergence"):

function model_run( # without ϵ-α dynamcis
    τ::Float64,
    ρ::Float64,
    ϵ_low::Float64,
    ϵ_upp::Float64,
    α_low::Float64,
    α_upp::Float64,
    numagents::Int,
    numfreeriders::Int,
    numcampaigners::Int,
    iterations
    )
    model = hk_model(τ, ρ, ϵ_low, ϵ_upp, α_low, α_upp, 1., 1., 1., 1., numagents, numfreeriders, numcampaigners)
    adata = [:new_opinion, typeof]
    agent_data, _ = run!(
        model,
        agent_static_step!,
        model_step!,
        iterations;
        adata = adata
    )
    return agent_data
end

function model_run( # without ϵ-α dynamcis
    τ::Float64,
    ρ::Float64,
    ϵ_low::Float64,
    ϵ_upp::Float64,
    α_low::Float64,
    α_upp::Float64,
    numagents::Int,
    numfreeriders::Int,
    numcampaigners::Int;
    when = true
    )
    model = hk_model(τ, ρ, ϵ_low, ϵ_upp, α_low, α_upp, 1., 1., 1., 1., numagents, numfreeriders, numcampaigners)
    adata = [:new_opinion, typeof]
    agent_data, _ = run!(
        model,
        agent_static_step!,
        model_step!,
        terminate;
        adata = adata,
        when = when
    )
    return agent_data
end

function model_run(  # with ϵ-α dynamcis
        τ::Float64,
        ρ::Float64,
        ϵ_low::Float64,
        ϵ_upp::Float64,
        α_low::Float64,
        α_upp::Float64,
        λ_ϵ_low::Float64,
        λ_ϵ_upp::Float64,
        λ_α_low::Float64,
        λ_α_upp::Float64,
        numagents::Int,
        numfreeriders::Int,
        numcampaigners::Int,
        iterations::Int
    )
    model = hk_model(τ, ρ, ϵ_low, ϵ_upp, α_low, α_upp, λ_ϵ_low, λ_ϵ_upp, λ_α_low, λ_α_upp, numagents, numfreeriders, numcampaigners)
    adata = [:new_opinion, :λ_ϵ, :λ_α, typeof]
    agent_data, _ = run!(
        model,
        agent_dynamic_step!,
        model_step!,
        iterations;
        adata = adata
    )
    return agent_data
end

function model_run(  # with ϵ-α dynamcis
    τ::Float64,
    ρ::Float64,
    ϵ_low::Float64,
    ϵ_upp::Float64,
    α_low::Float64,
    α_upp::Float64,
    λ_ϵ_low::Float64,
    λ_ϵ_upp::Float64,
    λ_α_low::Float64,
    λ_α_upp::Float64,
    numagents::Int,
    numfreeriders::Int,
    numcampaigners::Int;
    when = true
    )
    model = hk_model(τ, ρ, ϵ_low, ϵ_upp, α_low, α_upp, λ_ϵ_low, λ_ϵ_upp, λ_α_low, λ_α_upp, numagents, numfreeriders, numcampaigners)
    adata = [:new_opinion, :λ_ϵ, :λ_α, typeof]
    agent_data, _ = run!(
        model,
        agent_dynamic_step!,
        model_step!,
        terminate;
        adata = adata,
        when = when
    )
    return agent_data
end

Here is a run with the static model, including free riders and campaigners:

data = model_run(.7, .9, .2, .2, .5, .5, 15, 5, 5)

palette = [colorant"darksalmon", colorant"gold", colorant"darkseagreen"]

Gadfly.plot(data, x=:step, y=:new_opinion, group=:id, color=:typeof,
        Geom.point, Geom.line, Scale.color_discrete_manual(palette..., order=[3, 2, 1]),
        Coord.cartesian(xmax=maximum(data.step)),
        Guide.xlabel("Update"), Guide.ylabel("Opinion"),
        Guide.colorkey(title="Group", labels=["Campaigners", "Free riders", "Truth seekers"]),
        yintercept=[.7], Geom.hline(style=:dot, color=colorant"grey"),
        Guide.annotation(compose(context(), Compose.text(maximum(data.step) - 1, 0.685, "τ"), fontsize(11pt))))

Conversion

Functions to calculate the average proportion of truth seekers that get converted by the campaigners (settings as in the paper).

function perc_conv(nmb_cmp, ϵ, τ, ρ)
    data = model_run(τ, ρ, ϵ, ϵ, 0., 0., 50, 0, nmb_cmp; when = terminate)
    dat_sel = @where(data, :typeof.==TruthSeeker)
    return sum(map(x->isapprox(x, ρ; atol=1e-3), dat_sel.new_opinion))/50
end

function perc_sim(nmb_cmp, ϵ, τ, ρ)
    return [ perc_conv(nmb_cmp, ϵ, τ, ρ) for _ in 1:100 ] |> mean
end

perc_mat = Matrix{Float64}(undef, 50, 50)
eps_vals = .01:.01:.5
cmp_nmbs = 1:50
ρ = .8
for i in 1:50, j in 1:50
    e = eps_vals[j]
    c = cmp_nmbs[i]
    perc_mat[j, i] = perc_sim(c, e, .0, ρ) # note that because α = 0, the value of τ doesn't matter
end

First some helper functions to create the plots:

function xlabelname(x)
    n = x/100
    return "$n"
end

function ylabelname(x)
    n = abs(x - 50)
    return "$n"
end

ticks = collect(0:10:50)

Plotting the outcome (left panel of Figure 2):

Gadfly.spy(rotl90(perc_mat * 50),
    Guide.ColorKey(title="Converted\ntruth seekers"),
    Guide.xlabel("ϵ"), Guide.ylabel("Campaigners"),
    Guide.title("ρ = "), # given that alpha is zero, the value of tau doesn't matter here
    Guide.xticks(ticks=ticks), Scale.x_continuous(labels=xlabelname),
    Guide.yticks(ticks=ticks), Scale.y_continuous(labels=ylabelname),
    Scale.ContinuousColorScale(p -> get(ColorSchemes.viridis, p), minvalue=0, maxvalue=50),
    Theme(grid_color=colorant"white", panel_fill="white"))

Functions to calculate the average proportion of truth seekers that actually end up believing the truth (for Figure 3).

function perc_truth(nmb_cmp, nmb_free, ϵ, α, τ, ρ)
    data = model_run(τ, ρ, ϵ, ϵ, α, α, 50, nmb_free, nmb_cmp; when = terminate)
    dat_sel = @where(data, :typeof.==TruthSeeker)
    return sum(map(x->isapprox(x, τ; atol=1e-3), dat_sel.new_opinion))/50
end

function truth_sim(nmb_cmp, nmb_free, ϵ, α, τ, ρ)
    return [ perc_truth(nmb_cmp, nmb_free, ϵ, α, τ, ρ) for _ in 1:100 ] |> mean
end

truth_mat = Matrix{Float64}(undef, 50, 50)
τ = .1
ρ = .3
α = .5
for i in 1:50, j in 1:50
    e = eps_vals[j]
    c = cmp_nmbs[i]
    truth_mat[j, i] = truth_sim(c, 0, e, α, τ, ρ)
end

Gadfly.spy(rotl90(truth_mat * 50),
    Guide.ColorKey(title="Truth\nseekers\nbelieving τ"),
    Guide.xlabel("ϵ"), Guide.ylabel("Campaigners"),
    Guide.title(" τ = , ρ = , α = "),
    Guide.xticks(ticks=ticks), Scale.x_continuous(labels=xlabelname),
    Guide.yticks(ticks=ticks), Scale.y_continuous(labels=ylabelname),
    Scale.ContinuousColorScale(p -> get(ColorSchemes.viridis, p), minvalue=0, maxvalue=50),
    Theme(grid_color=colorant"white", panel_fill="white"))

Now we add 10 free riders and look at the difference that makes (see Figure 4):

truth_mat_free = Matrix{Float64}(undef, 50, 50)
for i in 1:50, j in 1:50
    e = eps_vals[j]
    c = cmp_nmbs[i]
    truth_mat_free[j, i] = truth_sim(c, 10, e, α, τ, ρ)
end

Gadfly.spy(rotl90((truth_mat - truth_mat_free) * 50),
    Guide.xlabel("ϵ"), Guide.ylabel("Campaigners"),
    Guide.ColorKey(title="Difference"),
    Guide.title(" τ = , ρ = , α = , #FR = 10"),
    Guide.xticks(ticks=ticks), Scale.x_continuous(labels=xlabelname),
    Guide.yticks(ticks=ticks), Scale.y_continuous(labels=ylabelname),
    Scale.ContinuousColorScale(p -> get(ColorSchemes.viridis, p), minvalue=-50, maxvalue=50),
    Theme(grid_color=colorant"white", panel_fill="white"))

Societal costs

We are now going to look at the damage (in terms of loss of accuracy) inflicted upon truth seekers by the presence of campaigners in their community.

This will be easier if we add a function to model_run to measure squared distance from τ for each agent.

function model_run_se( # without ϵ-α dynamcis
    τ::Float64,
    ρ::Float64,
    ϵ_low::Float64,
    ϵ_upp::Float64,
    α_low::Float64,
    α_upp::Float64,
    numagents::Int,
    numfreeriders::Int,
    numcampaigners::Int,
    iterations::Int
    )
    model = hk_model(τ, ρ, ϵ_low, ϵ_upp, α_low, α_upp, 1., 1., 1., 1., numagents, numfreeriders, numcampaigners)
    dst(agent) = (agent.new_opinion - τ)^2
    adata = [:new_opinion, typeof, dst]
    agent_data, _ = run!(
        model,
        agent_static_step!,
        model_step!,
        iterations;
        adata = adata
    )
    return agent_data
end

function cost_sim(nmb_cmp, nmb_free, ϵ, α, ρ)
    data = model_run_se(.1, ρ, ϵ, ϵ, α, α, 50, nmb_free, nmb_cmp, 50)
    dat_sel = @where(data, :typeof.==TruthSeeker)
    return sum(dat_sel.dst)
end

wo = [ cost_sim(0, 0, .2, .5, .0) for _ in 1:1000 ]
wn = [ cost_sim(25, 0, .2, .5, .3) for _ in 1:1000 ]
wm = [ cost_sim(25, 0, .2, .5, .5) for _ in 1:1000 ]
ww = [ cost_sim(25, 0, .2, .5, .7) for _ in 1:1000 ]

Visualization (corresponding to Figure 5)

mns = DataFrame(WO = wo, WN = wn, WM = wm, WW = ww)
mns = stack(mns)
rename!(mns, [:Condition, :SSE])
Gadfly.plot(mns, x=:Condition, y=:SSE, Geom.boxplot,
    Guide.title("τ = 0.1, ρ = 0.3/0.5/0.7 (WN/WM/WW), ϵ = 0.2, α = 0.5"),
    Theme(default_color=colorant"#66c2a5", panel_fill="white"))

ANOVA

data = vcat(wo, wn, wm, ww)
groups = categorical(repeat(1:4, inner=1000))
df_aov = DataFrame(data = data, groups = groups)
@rput df_aov
R"""
m <- aov(data ~ groups, df_aov)
summary(m)
"""
RCall.RObject{RCall.VecSxp}
              Df Sum Sq Mean Sq F value Pr(>F)    
groups         3  24686    8229    1603 <2e-16 ***
Residuals   3996  20512       5                   
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1
R"TukeyHSD(m)"
RCall.RObject{RCall.VecSxp}
  Tukey multiple comparisons of means
    95% family-wise confidence level

Fit: aov(formula = data ~ groups, data = df_aov)

$groups
           diff         lwr        upr     p adj
2-1  5.78920515  5.52879209  6.0496182 0.0000000
3-1  0.27260372  0.01219067  0.5330168 0.0360512
4-1 -0.09140343 -0.35181648  0.1690096 0.8037217
3-2 -5.51660142 -5.77701448 -5.2561884 0.0000000
4-2 -5.88060857 -6.14102163 -5.6201955 0.0000000
4-3 -0.36400715 -0.62442021 -0.1035941 0.0018811

Basically the same as above but now with varying numbers of free riders

fr0 = [ cost_sim(15, 0, .2, .5, .3) for _ in 1:1000 ]
fr10 = [ cost_sim(15, 10, .2, .5, .3) for _ in 1:1000 ]
fr25 = [ cost_sim(15, 25, .2, .5, .3) for _ in 1:1000 ]
fr50 = [ cost_sim(15, 50, .2, .5, .3) for _ in 1:1000 ]

Visualization (corresponding to Figure 6)

mns = DataFrame(X = fr0, Y = fr10, Z = fr25, W = fr50)
rename!(mns, :X => Symbol("0"), :Y => Symbol("10"), :Z => Symbol("25"), :W => Symbol("50"))
mns = stack(mns)
rename!(mns, [:FR, :SSE])
Gadfly.plot(mns, x=:FR, y=:SSE, Geom.boxplot,
    Guide.xlabel("Free riders"),
    Guide.title("τ = 0.1, ρ = 0.3, ϵ = 0.2, α = 0.5"),
    Theme(default_color=colorant"#66c2a5", panel_fill="white"))

ANOVA

data = vcat(fr0, fr10, fr25, fr50)
groups = categorical(repeat(1:4, inner=1000))
df_aov = DataFrame(data = data, groups = groups)
@rput df_aov
R"""
m <- aov(data ~ groups, df_aov)
summary(m)
"""
RCall.RObject{RCall.VecSxp}
              Df Sum Sq Mean Sq F value  Pr(>F)   
groups         3     74  24.623   4.893 0.00214 **
Residuals   3996  20110   5.033                   
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1

The same with slightly different settings (see Figure 7)

fr0 = [ cost_sim(15, 0, .3, .25, .3) for _ in 1:1000 ]
fr10 = [ cost_sim(15, 10, .3, .25, .3) for _ in 1:1000 ]
fr25 = [ cost_sim(15, 25, .3, .25, .3) for _ in 1:1000 ]
fr50 = [ cost_sim(15, 50, .3, .25,  .3) for _ in 1:1000 ];

Visualization

mns = DataFrame(X = fr0, Y = fr10, Z = fr25, W = fr50)
rename!(mns, :X => Symbol("0"), :Y => Symbol("10"), :Z => Symbol("25"), :W => Symbol("50"))
mns = stack(mns)
rename!(mns, [:FR, :SSE])
Gadfly.plot(mns, x=:FR, y=:SSE, Geom.boxplot,
    Guide.xlabel("Free riders"),
    Guide.title("τ = 0.1, ρ = 0.3, ϵ = 0.3, α = 0.25"),
    Theme(default_color=colorant"#66c2a5", panel_fill="white"))

ANOVA

data = vcat(fr0, fr10, fr25, fr50)
groups = categorical(repeat(1:4, inner=1000))
df_aov = DataFrame(data = data, groups = groups)
@rput df_aov
R"""
m <- aov(data ~ groups, df_aov)
summary(m)
"""
RCall.RObject{RCall.VecSxp}
              Df Sum Sq Mean Sq F value Pr(>F)    
groups         3  15336    5112   365.8 <2e-16 ***
Residuals   3996  55846      14                   
---
Signif. codes:  0 ‘***’ 0.001 ‘**’ 0.01 ‘*’ 0.05 ‘.’ 0.1 ‘ ’ 1
R"TukeyHSD(m)"
RCall.RObject{RCall.VecSxp}
  Tukey multiple comparisons of means
    95% family-wise confidence level

Fit: aov(formula = data ~ groups, data = df_aov)

$groups
        diff       lwr      upr p adj
2-1 1.042752 0.6130666 1.472436     0
3-1 2.840690 2.4110053 3.270375     0
4-1 5.159952 4.7302668 5.589637     0
3-2 1.797939 1.3682538 2.227624     0
4-2 4.117200 3.6875153 4.546885     0
4-3 2.319262 1.8895766 2.748947     0

Confidence dynamics

Produce Figures 8 and 9

data = model_run(.7, .0, .25, .25, .25, .25, 50, 0, 0)

Gadfly.plot(data, x=:step, y=:new_opinion, group=:id, color=:id,
        Geom.point, Geom.line,
        Coord.cartesian(xmax=maximum(data.step)),
        Guide.xlabel("Update"), Guide.ylabel("Opinion"),
        Guide.title("τ = 0.7, ϵ = 0.25, α = 0.25"),
        yintercept=[.7], Geom.hline(style=:dot, color=colorant"grey"),
        Guide.annotation(compose(context(), Compose.text(maximum(data.step) - 3, 0.715, "τ"), fontsize(11pt))),
        Theme(key_position=:none))
data = model_run(.7, .0, .0, .5, .25, .25, .0, .0, .0, .0, 50, 0, 0)

Gadfly.plot(data, x=:step, y=:new_opinion, group=:id, color=:id,
        Geom.point, Geom.line,
        Coord.cartesian(xmax=maximum(data.step)),
        Guide.xlabel("Update"), Guide.ylabel("Opinion"),
        Guide.title("τ = 0.7, ϵ⁰ ∼ U(0, .5), α = 0.25"),
        yintercept=[.7], Geom.hline(style=:dot, color=colorant"grey"),
        Guide.annotation(compose(context(), Compose.text(maximum(data.step) - 3, 0.715, "τ"), fontsize(11pt))),
        Theme(key_position=:none))
data = model_run(.65, .9, .25, .25, .75, .75, 25, 20, 10)

Gadfly.plot(data, x=:step, y=:new_opinion, group=:id, color=:typeof,
        Geom.point, Geom.line, Scale.color_discrete_manual(palette..., order=[3, 2, 1]),
        Coord.cartesian(xmax=maximum(data.step)),
        Guide.xlabel("Update"), Guide.ylabel("Opinion"),
        Guide.title("τ = 0.65, ρ = 0.9, ϵ = 0.25, α = 0.75"),
        Guide.colorkey(title="Group", labels=["Campaigners", "Free riders", "Truth seekers"]),
        yintercept=[.65], Geom.hline(style=:dot, color=colorant"grey"),
        Guide.annotation(compose(context(), Compose.text(maximum(data.step) - 1, 0.635, "τ"), fontsize(11pt))))
data = model_run(.65, .9, .0, .5, .75, .75, .0, .0, .0, .0, 25, 20, 10)

Gadfly.plot(data, x=:step, y=:new_opinion, group=:id, color=:typeof,
        Geom.point, Geom.line, Scale.color_discrete_manual(palette..., order=[3, 2, 1]),
        Coord.cartesian(xmax=maximum(data.step)),
        Guide.xlabel("Update"), Guide.ylabel("Opinion"),
        Guide.title("τ = 0.65, ρ = 0.9, ϵ⁰ ∼ U(0, .5), α = 0.75"),
        Guide.colorkey(title="Group", labels=["Campaigners", "Free riders", "Truth seekers"]),
        yintercept=[.65], Geom.hline(style=:dot, color=colorant"grey"),
        Guide.annotation(compose(context(), Compose.text(maximum(data.step) - 3, 0.635, "τ"), fontsize(11pt))))

To reproduce Figures 10 and 11, some functions to calculate the average proportion of truth seekers that get converted by the campaigners (settings as in the paper).

function perc_conv_ts(nmb_cmp, nmb_free, ϵ_low, ϵ_upp, τ, ρ)
    data = model_run(τ, ρ, ϵ_low, ϵ_upp, .5, .5, 50, nmb_free, nmb_cmp; when = terminate)
    dat_sel = @where(data, :typeof.==TruthSeeker)
    return sum(map(x->isapprox(x, τ; atol=1e-3), dat_sel.new_opinion))/50
end

function perc_sim_ts(nmb_cmp, nmb_free, ϵ_low, ϵ_upp, τ, ρ)
    return [ perc_conv_ts(nmb_cmp, nmb_free, ϵ_low, ϵ_upp, τ, ρ) for _ in 1:100 ] |> mean
end

perc_mat = Matrix{Float64}(undef, 50, 50)
τ = .65
ρ = .75
for i in 1:50, j in 1:50
    perc_mat[j, i] = perc_sim_ts(j, i, .1, .1, τ, ρ)
end

Gadfly.spy(rotl90(perc_mat * 50),
    Guide.ColorKey(title="Truth seekers\nbelieving τ"),
    Guide.xlabel("Campaigners"), Guide.ylabel("Free riders"),
    Guide.title("τ = , ρ = , ϵ = 0.1, α = 0.5"), # given that alpha is zero, the value of tau doesn't matter here
    Guide.xticks(ticks=ticks), Scale.x_continuous(labels=xlabelname),
    Guide.yticks(ticks=ticks), Scale.y_continuous(labels=ylabelname),
    Scale.ContinuousColorScale(p -> get(ColorSchemes.viridis, p), minvalue=0, maxvalue=50),
    Theme(grid_color=colorant"white", panel_fill="white"))