HK (Hegselmann and Krause) opinion dynamics model

This example showcases

  • How to do synchronous updating of Agent properties (also know as Synchronous update schedule). In a Synchronous update schedule changes made to an agent are not seen by other agents until the next step, see also Wilensky 2015, p.286).
  • How to terminate the system evolution on demand according to a boolean function.
  • How to terminate the system evolution according to what happened on the previous step.

Model overview

This is an implementation of a simple version of the Hegselmann and Krause (2002) model. It is a model of opinion formation with the question: which parameters' values lead to consensus, polarization or fragmentation? It models interacting groups of agents (as opposed to interacting pairs, typical in the literature) in which it is assumed that if an agent disagrees too much with the opinion of a source of influence, the source can no longer influence the agent's opinion. There is then a "bound of confidence". The model shows that the systemic configuration is heavily dependent on this parameter's value.

The model has the following components:

  • A set of n Agents with opinions xᵢ in the range [0,1] as attribute
  • A parameter ϵ called "bound" in (0, 0.3]
  • The update rule: at each step every agent adopts the mean of the opinions which are within the confidence bound ( |xᵢ - xⱼ| ≤ ϵ).

Core structures

We start by defining the Agent type and initializing the model. The Agent type has two fields so that we can implement the synchronous update.

using Agents
using Statistics: mean

mutable struct HKAgent <: AbstractAgent
    id::Int
    old_opinion::Float64
    new_opinion::Float64
    previous_opinon::Float64
end

There is a reason the agent has three fields that are "the same". The old_opinion is used for the synchronous agent update, since we require access to a property's value at the start of the step and the end of the step. The previous_opinion is the opinion of the agent in the previous step, as the model termination requires access to a property's value at the end of the previous step, and the end of the current step.

We could, alternatively, make the three opinions a single field with vector value.

function hk_model(; numagents = 100, ϵ = 0.2)
    model = ABM(HKAgent, scheduler = fastest, properties = Dict(:ϵ => ϵ))
    for i in 1:numagents
        o = rand()
        add_agent!(model, o, o, -1)
    end
    return model
end

model = hk_model()
AgentBasedModel with 100 agents of type HKAgent
 no space
 scheduler: fastest
 properties: Dict(:ϵ => 0.2)

Add some helper functions for the update rule. As there is a filter in the rule we implement it outside the agent_step! method. Notice that the filter is applied to the :old_opinion field.

function boundfilter(agent, model)
    filter(
        j -> abs(agent.old_opinion - j) < model.ϵ,
        [a.old_opinion for a in allagents(model)],
    )
end

Now we implement the agent_step!

function agent_step!(agent, model)
    agent.previous_opinon = agent.old_opinion
    agent.new_opinion = mean(boundfilter(agent, model))
end

and model_step!

function model_step!(model)
    for a in allagents(model)
        a.old_opinion = a.new_opinion
    end
end

From this implementation we see that to implement synchronous scheduling we define an Agent type with old and new fields for attributes that are changed via the synchronous update. In agent_step! we use the old field then, after updating all the agents new fields, we use the model_step! to update the model for the next iteration.

Running the model

The parameter of interest is now :new_opinion, so we assign it to variable adata and pass it to the run! method to be collected in a DataFrame.

In addition, we want to run the model only until all agents have converged to an opinion. From the documentation of step! one can see that instead of specifying the amount of steps we can specify a function instead.

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

step!(model, agent_step!, model_step!, terminate)
model[1]
Main.ex-HK.HKAgent(1, 0.2868049872723277, 0.2868049872723277, 0.28680498727232784)

Alright, let's wrap everything in a function and do some data collection using run!.

function model_run(; kwargs...)
    model = hk_model(; kwargs...)
    agent_data, _ = run!(model, agent_step!, model_step!, terminate; adata = [:new_opinion])
    return agent_data
end

data = model_run(numagents = 100)
data[(end - 19):end, :]

20 rows × 3 columns

stepidnew_opinion
Int64Int64Float64
16810.283151
26820.283151
36830.283151
46840.713054
56850.713054
66860.713054
76870.713054
86880.283151
96890.713054
106900.713054
116910.713054
126920.713054
136930.713054
146940.283151
156950.283151
166960.283151
176970.283151
186980.713054
196990.713054
2061000.283151

Notice that here we didn't speciy when to collect data, so this is done at every step. Instead, we could collect data only at the final step, by re-using the same function for the when argument:

model = hk_model()
agent_data, _ = run!(
    model,
    agent_step!,
    model_step!,
    terminate;
    adata = [:new_opinion],
    when = terminate,
)
agent_data

100 rows × 3 columns

stepidnew_opinion
Int64Int64Float64
1610.69425
2620.69425
3630.255704
4640.69425
5650.69425
6660.69425
7670.69425
8680.69425
9690.69425
106100.69425
116110.255704
126120.255704
136130.69425
146140.69425
156150.69425
166160.255704
176170.69425
186180.255704
196190.255704
206200.255704
216210.69425
226220.69425
236230.255704
246240.255704
256250.69425
266260.255704
276270.69425
286280.255704
296290.255704
306300.255704
316310.255704
326320.255704
336330.255704
346340.69425
356350.255704
366360.69425
376370.69425
386380.69425
396390.69425
406400.255704
416410.255704
426420.255704
436430.255704
446440.255704
456450.69425
466460.69425
476470.69425
486480.69425
496490.69425
506500.69425
516510.255704
526520.255704
536530.255704
546540.69425
556550.69425
566560.255704
576570.255704
586580.255704
596590.255704
606600.255704
616610.255704
626620.69425
636630.69425
646640.69425
656650.69425
666660.69425
676670.69425
686680.69425
696690.255704
706700.255704
716710.69425
726720.69425
736730.255704
746740.255704
756750.255704
766760.69425
776770.255704
786780.69425
796790.255704
806800.255704
816810.255704
826820.69425
836830.255704
846840.255704
856850.69425
866860.255704
876870.255704
886880.255704
896890.255704
906900.255704
916910.255704
926920.69425
936930.69425
946940.69425
956950.69425
966960.255704
976970.255704
986980.255704
996990.255704
10061000.69425

Finally we run three scenarios, collect the data and plot it.

using Plots

plotsim(data, ϵ) = plot(
    data.step,
    data.new_opinion,
    leg = false,
    group = data.id,
    title = "epsilon = $(ϵ)",
)


plt001, plt015, plt03 =
    map(e -> (model_run(ϵ = e), e) |> t -> plotsim(t[1], t[2]), [0.05, 0.15, 0.3])

plot(plt001, plt015, plt03, layout = (3, 1))