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.21383482688015415, 0.21383482688015415, 0.21383482688015418)

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
17810.671719
27820.671719
37830.268666
47840.671719
57850.671719
67860.671719
77870.268666
87880.671719
97890.671719
107900.671719
117910.671719
127920.268666
137930.268666
147940.671719
157950.671719
167960.268666
177970.671719
187980.671719
197990.671719
2071000.268666

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.380216
2620.809828
3630.380216
4640.809828
5650.809828
6660.380216
7670.380216
8680.809828
9690.380216
106100.809828
116110.809828
126120.380216
136130.380216
146140.809828
156150.809828
166160.380216
176170.380216
186180.809828
196190.380216
206200.380216
216210.380216
226220.380216
236230.380216
246240.809828
256250.380216
266260.380216
276270.809828
286280.380216
296290.809828
306300.380216
316310.380216
326320.380216
336330.809828
346340.809828
356350.380216
366360.380216
376370.809828
386380.380216
396390.380216
406400.380216
416410.380216
426420.809828
436430.380216
446440.380216
456450.809828
466460.809828
476470.809828
486480.809828
496490.380216
506500.380216
516510.380216
526520.380216
536530.809828
546540.380216
556550.380216
566560.380216
576570.380216
586580.380216
596590.380216
606600.380216
616610.380216
626620.380216
636630.809828
646640.809828
656650.809828
666660.380216
676670.809828
686680.380216
696690.380216
706700.809828
716710.380216
726720.809828
736730.380216
746740.380216
756750.380216
766760.380216
776770.380216
786780.380216
796790.380216
806800.809828
816810.380216
826820.380216
836830.380216
846840.380216
856850.809828
866860.380216
876870.809828
886880.380216
896890.809828
906900.380216
916910.809828
926920.380216
936930.380216
946940.809828
956950.380216
966960.380216
976970.380216
986980.380216
996990.809828
10061000.809828

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