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()
        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.16864683489044416, 0.16864683489044416, 0.16864683489044416)

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.321694
26820.666219
36830.666219
46840.666219
56850.666219
66860.666219
76870.321694
86880.666219
96890.321694
106900.321694
116910.321694
126920.666219
136930.666219
146940.321694
156950.321694
166960.666219
176970.321694
186980.666219
196990.666219
2061000.666219

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.356515
2620.356515
3630.789107
4640.789107
5650.356515
6660.789107
7670.356515
8680.356515
9690.356515
106100.356515
116110.789107
126120.356515
136130.356515
146140.356515
156150.356515
166160.356515
176170.789107
186180.356515
196190.789107
206200.356515
216210.789107
226220.356515
236230.789107
246240.789107
256250.356515
266260.356515
276270.356515
286280.356515
296290.356515
306300.356515
316310.356515
326320.356515
336330.789107
346340.356515
356350.356515
366360.789107
376370.356515
386380.356515
396390.356515
406400.356515
416410.789107
426420.356515
436430.356515
446440.356515
456450.356515
466460.356515
476470.789107
486480.789107
496490.356515
506500.789107
516510.356515
526520.356515
536530.789107
546540.356515
556550.789107
566560.356515
576570.356515
586580.356515
596590.356515
606600.356515
616610.356515
626620.356515
636630.789107
646640.356515
656650.789107
666660.356515
676670.356515
686680.789107
696690.356515
706700.356515
716710.789107
726720.789107
736730.356515
746740.789107
756750.789107
766760.789107
776770.356515
786780.356515
796790.789107
806800.789107
816810.356515
826820.789107
836830.789107
846840.789107
856850.356515
866860.356515
876870.789107
886880.356515
896890.356515
906900.356515
916910.356515
926920.356515
936930.789107
946940.356515
956950.789107
966960.356515
976970.789107
986980.789107
996990.356515
10061000.356515

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