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.35426997639422714, 0.35426997639422714, 0.35426997639422714)

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
19810.482934
29820.482934
39830.482934
49840.482934
59850.482934
69860.482934
79870.482934
89880.482934
99890.482934
109900.482934
119910.482934
129920.482934
139930.482934
149940.482934
159950.482934
169960.482934
179970.482934
189980.482934
199990.482934
2091000.482934

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.712588
2620.283153
3630.283153
4640.283153
5650.712588
6660.283153
7670.283153
8680.712588
9690.712588
106100.712588
116110.283153
126120.712588
136130.283153
146140.283153
156150.283153
166160.712588
176170.283153
186180.283153
196190.283153
206200.712588
216210.712588
226220.283153
236230.712588
246240.283153
256250.712588
266260.283153
276270.712588
286280.712588
296290.283153
306300.712588
316310.712588
326320.283153
336330.712588
346340.712588
356350.283153
366360.712588
376370.283153
386380.712588
396390.283153
406400.283153
416410.712588
426420.712588
436430.283153
446440.712588
456450.712588
466460.283153
476470.283153
486480.283153
496490.283153
506500.712588
516510.283153
526520.283153
536530.712588
546540.712588
556550.712588
566560.712588
576570.712588
586580.283153
596590.712588
606600.283153
616610.283153
626620.712588
636630.283153
646640.712588
656650.283153
666660.712588
676670.283153
686680.712588
696690.712588
706700.712588
716710.712588
726720.712588
736730.712588
746740.712588
756750.712588
766760.712588
776770.712588
786780.712588
796790.283153
806800.712588
816810.712588
826820.283153
836830.712588
846840.712588
856850.283153
866860.283153
876870.712588
886880.283153
896890.712588
906900.283153
916910.283153
926920.283153
936930.712588
946940.283153
956950.283153
966960.283153
976970.712588
986980.712588
996990.283153
10061000.283153

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