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_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(model.rng)
        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

Agents.step!(model, agent_step!, model_step!, terminate)
model[1]
Main.ex-hk.HKAgent(1, 0.5256944709611314, 0.5256944709611314, 0.5256944709611314)

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.720816
27820.720816
37830.244639
47840.720816
57850.720816
67860.244639
77870.244639
87880.244639
97890.720816
107900.244639
117910.720816
127920.720816
137930.720816
147940.244639
157950.244639
167960.720816
177970.720816
187980.720816
197990.244639
2071000.720816

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.750063
2620.750063
3630.750063
4640.750063
5650.750063
6660.309471
7670.750063
8680.309471
9690.309471
106100.309471
116110.750063
126120.309471
136130.750063
146140.750063
156150.309471
166160.309471
176170.309471
186180.750063
196190.750063
206200.750063
216210.309471
226220.750063
236230.750063
246240.750063
256250.750063
266260.750063
276270.750063
286280.309471
296290.750063
306300.750063
316310.309471
326320.309471
336330.309471
346340.309471
356350.309471
366360.309471
376370.309471
386380.309471
396390.750063
406400.750063
416410.309471
426420.309471
436430.309471
446440.309471
456450.309471
466460.750063
476470.309471
486480.750063
496490.750063
506500.309471
516510.309471
526520.309471
536530.309471
546540.750063
556550.750063
566560.309471
576570.309471
586580.309471
596590.750063
606600.309471
616610.750063
626620.750063
636630.750063
646640.750063
656650.309471
666660.750063
676670.750063
686680.750063
696690.309471
706700.309471
716710.750063
726720.750063
736730.750063
746740.309471
756750.309471
766760.750063
776770.750063
786780.750063
796790.309471
806800.750063
816810.309471
826820.309471
836830.750063
846840.309471
856850.750063
866860.750063
876870.309471
886880.309471
896890.309471
906900.750063
916910.309471
926920.309471
936930.309471
946940.750063
956950.309471
966960.750063
976970.309471
986980.309471
996990.750063
10061000.750063

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

using DataFrames, CairoMakie

const cmap = cgrad(:lightrainbow)
plotsim(ax, data, ϵ) =
    map(groupby(data, :id)) do grp
        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, 450))
for (i, e) in enumerate(eps)
    ax = figure[i, 1] = Axis(figure; title = "epsilon = $e")
    e_data = model_run(ϵ = e)
    plotsim(ax, e_data, e)
end
figure