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ⱼ| ≤ ϵ).

It is also available from the Models module as Models.hk.

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_opinion::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 = Schedulers.fastest, properties = Dict(:ϵ => ϵ))
    for i in 1:numagents
        o = rand(model.rng)
        add_agent!(model, o, o, -1)
    end
    return model
end

model = hk_model()
AgentBasedModel with 100 agents of type HKAgent
 space: nothing (no spatial structure)
 scheduler: fastest
 properties: ϵ

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_opinion = 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_opinion, a.new_opinion; rtol = 1e-12)
        for a in allagents(model)
    )
        return false
    else
        return true
    end
end

Agents.step!(model, agent_step!, model_step!, terminate)
model[1]
Main.HKAgent(1, 0.3275199624266325, 0.3275199624266325, 0.32751996242663267)

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.316055
26820.316055
36830.735645
46840.316055
56850.316055
66860.316055
76870.735645
86880.735645
96890.316055
106900.316055
116910.735645
126920.316055
136930.316055
146940.735645
156950.735645
166960.316055
176970.735645
186980.316055
196990.735645
2061000.316055

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.279004
2620.279004
3630.732437
4640.279004
5650.279004
6660.732437
7670.732437
8680.279004
9690.732437
106100.732437
116110.732437
126120.279004
136130.279004
146140.279004
156150.279004
166160.279004
176170.732437
186180.732437
196190.279004
206200.279004
216210.732437
226220.279004
236230.279004
246240.732437
256250.279004
266260.279004
276270.732437
286280.279004
296290.279004
306300.732437
316310.732437
326320.732437
336330.279004
346340.279004
356350.279004
366360.279004
376370.732437
386380.279004
396390.279004
406400.279004
416410.279004
426420.732437
436430.279004
446440.279004
456450.279004
466460.732437
476470.279004
486480.279004
496490.279004
506500.279004
516510.732437
526520.732437
536530.732437
546540.279004
556550.732437
566560.732437
576570.279004
586580.732437
596590.732437
606600.732437
616610.279004
626620.279004
636630.279004
646640.732437
656650.279004
666660.732437
676670.732437
686680.279004
696690.279004
706700.732437
716710.732437
726720.732437
736730.279004
746740.279004
756750.279004
766760.732437
776770.732437
786780.279004
796790.279004
806800.279004
816810.279004
826820.732437
836830.279004
846840.279004
856850.732437
866860.732437
876870.279004
886880.732437
896890.279004
906900.732437
916910.279004
926920.279004
936930.279004
946940.732437
956950.732437
966960.732437
976970.279004
986980.279004
996990.732437
10061000.279004

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

using DataFrames, CairoMakie

const cmap = cgrad(:lightrainbow)
plotsim(ax, data) =
    for grp in groupby(data, :id)
        lines!(ax, grp.step, grp.new_opinion, color = cmap[grp.id[1]/100])
    end

eps = [0.05, 0.15, 0.3]
figure = Figure(resolution = (600, 600))
for (i, e) in enumerate(eps)
    ax = figure[i, 1] = Axis(figure; title = "epsilon = $e")
    e_data = model_run(ϵ = e)
    plotsim(ax, e_data)
end
figure