Hegselmann-Krause opinion dynamics

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, Random
using Statistics: mean

@agent struct HKAgent(NoSpaceAgent)
    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 = StandardABM(HKAgent; agent_step!, model_step!, rng = MersenneTwister(42),
                        scheduler = Schedulers.fastest, properties = Dict(:ϵ => ϵ))
    for i in 1:numagents
        o = rand(abmrng(model))
        add_agent!(model, o, o, -1)
    end
    return model
end
hk_model (generic function with 1 method)

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
boundfilter (generic function with 1 method)

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
agent_step! (generic function with 1 method)

and model_step!

function model_step!(model)
    for a in allagents(model)
        a.old_opinion = a.new_opinion
    end
end
model_step! (generic function with 1 method)

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

model = hk_model()

step!(model, terminate)
model[1]
Main.HKAgent(1, 0.6238087374881782, 0.6238087374881782, 0.6238087374881787)

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, terminate; adata = [:new_opinion])
    return agent_data
end

data = model_run(numagents = 100)
data[(end-19):end, :]
20×3 DataFrame
Rowtimeidnew_opinion
Int64Int64Float64
17810.623809
27820.623809
37830.220896
47840.220896
57850.623809
67860.623809
77870.220896
87880.623809
97890.623809
107900.220896
117910.220896
127920.623809
137930.623809
147940.623809
157950.623809
167960.220896
177970.623809
187980.623809
197990.220896
2071000.623809

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, terminate; adata = [:new_opinion],
                     when = terminate)
agent_data
100×3 DataFrame
Rowtimeidnew_opinion
Int64Int64Float64
1710.623809
2720.623809
3730.220896
4740.220896
5750.623809
6760.623809
7770.220896
8780.220896
9790.623809
107100.623809
117110.623809
127120.220896
137130.623809
147140.623809
157150.220896
167160.623809
177170.623809
187180.623809
197190.220896
207200.623809
217210.623809
227220.220896
237230.220896
247240.623809
257250.623809
267260.220896
277270.220896
287280.623809
297290.623809
307300.220896
317310.220896
327320.220896
337330.623809
347340.220896
357350.220896
367360.623809
377370.623809
387380.220896
397390.623809
407400.623809
417410.220896
427420.623809
437430.623809
447440.220896
457450.623809
467460.220896
477470.623809
487480.623809
497490.623809
507500.623809
517510.623809
527520.220896
537530.623809
547540.220896
557550.623809
567560.220896
577570.623809
587580.623809
597590.623809
607600.623809
617610.623809
627620.623809
637630.623809
647640.623809
657650.623809
667660.220896
677670.220896
687680.220896
697690.623809
707700.623809
717710.220896
727720.623809
737730.623809
747740.220896
757750.220896
767760.623809
777770.220896
787780.220896
797790.220896
807800.623809
817810.623809
827820.623809
837830.220896
847840.220896
857850.623809
867860.623809
877870.220896
887880.623809
897890.623809
907900.220896
917910.220896
927920.623809
937930.623809
947940.623809
957950.623809
967960.220896
977970.623809
987980.623809
997990.220896
10071000.623809

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.time, grp.new_opinion, color = cmap[grp.id[1]/100])
    end

eps = [0.05, 0.15, 0.3]
figure = Figure(size = (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
Example block output