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.6178272160842648, 0.6178272160842648, 0.6178272160842645)

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
15810.300276
25820.300276
35830.867144
45840.300276
55850.867144
65860.300276
75870.300276
85880.867144
95890.300276
105900.867144
115910.300276
125920.867144
135930.300276
145940.867144
155950.867144
165960.300276
175970.867144
185980.867144
195990.300276
2051000.300276

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.263022
2620.787071
3630.263022
4640.263022
5650.263022
6660.263022
7670.263022
8680.787071
9690.263022
106100.263022
116110.263022
126120.787071
136130.787071
146140.787071
156150.787071
166160.787071
176170.263022
186180.263022
196190.263022
206200.263022
216210.263022
226220.787071
236230.787071
246240.787071
256250.787071
266260.263022
276270.263022
286280.263022
296290.787071
306300.787071
316310.787071
326320.263022
336330.263022
346340.787071
356350.263022
366360.787071
376370.263022
386380.263022
396390.787071
406400.787071
416410.263022
426420.787071
436430.263022
446440.787071
456450.787071
466460.787071
476470.263022
486480.263022
496490.787071
506500.787071
516510.787071
526520.787071
536530.263022
546540.263022
556550.263022
566560.787071
576570.787071
586580.263022
596590.263022
606600.263022
616610.787071
626620.787071
636630.263022
646640.263022
656650.263022
666660.263022
676670.787071
686680.787071
696690.263022
706700.263022
716710.787071
726720.263022
736730.263022
746740.787071
756750.263022
766760.263022
776770.263022
786780.263022
796790.263022
806800.263022
816810.263022
826820.787071
836830.263022
846840.787071
856850.263022
866860.263022
876870.263022
886880.263022
896890.263022
906900.263022
916910.787071
926920.787071
936930.263022
946940.787071
956950.787071
966960.263022
976970.787071
986980.263022
996990.787071
10061000.263022

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))