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.6238087374881787, 0.6238087374881787, 0.623808737488179)

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
200×3 DataFrame
Rowtimeidnew_opinion
Int64Int64Float64
1010.533183
2020.454029
3030.0176868
4040.172933
5050.958926
6060.973566
7070.30387
8080.176909
9090.956916
100100.584284
110110.937466
120120.160006
130130.422956
140140.602298
150150.363458
160160.383491
170170.592912
180180.745181
190190.262809
200200.502952
210210.61205
220220.210885
230230.167169
240240.497081
250250.969432
260260.0823367
270270.244683
280280.452418
290290.763719
300300.281987
310310.143638
320320.0048089
330330.570085
340340.115197
350350.249238
360360.72068
370370.667147
380380.141744
390390.841643
400400.6155
410410.264427
420420.83314
430430.809544
440440.210278
450450.561446
460460.0457302
470470.908978
480480.607331
490490.996559
500500.461232
510510.44583
520520.0978331
530530.651855
540540.228955
550550.895886
560560.343286
570570.557994
580580.536362
590590.880189
600600.586261
610610.581188
620620.455992
630630.530473
640640.632181
650650.411651
660660.010539
670670.333681
680680.115688
690690.536658
700700.957451
710710.129951
720720.410024
730730.696638
740740.0755795
750750.271634
760760.613521
770770.219907
780780.00561488
790790.321313
800800.787915
810810.401961
820820.661469
830830.0233463
840840.141595
850850.718536
860860.610602
870870.0666941
880880.999187
890890.536222
900900.242521
910910.142044
920920.858047
930930.765115
940940.388394
950950.866316
960960.126342
970970.948598
980980.430966
990990.193746
10001000.764233
101710.623809
102720.623809
103730.220896
104740.220896
105750.623809
106760.623809
107770.220896
108780.220896
109790.623809
1107100.623809
1117110.623809
1127120.220896
1137130.623809
1147140.623809
1157150.220896
1167160.623809
1177170.623809
1187180.623809
1197190.220896
1207200.623809
1217210.623809
1227220.220896
1237230.220896
1247240.623809
1257250.623809
1267260.220896
1277270.220896
1287280.623809
1297290.623809
1307300.220896
1317310.220896
1327320.220896
1337330.623809
1347340.220896
1357350.220896
1367360.623809
1377370.623809
1387380.220896
1397390.623809
1407400.623809
1417410.220896
1427420.623809
1437430.623809
1447440.220896
1457450.623809
1467460.220896
1477470.623809
1487480.623809
1497490.623809
1507500.623809
1517510.623809
1527520.220896
1537530.623809
1547540.220896
1557550.623809
1567560.220896
1577570.623809
1587580.623809
1597590.623809
1607600.623809
1617610.623809
1627620.623809
1637630.623809
1647640.623809
1657650.623809
1667660.220896
1677670.220896
1687680.220896
1697690.623809
1707700.623809
1717710.220896
1727720.623809
1737730.623809
1747740.220896
1757750.220896
1767760.623809
1777770.220896
1787780.220896
1797790.220896
1807800.623809
1817810.623809
1827820.623809
1837830.220896
1847840.220896
1857850.623809
1867860.623809
1877870.220896
1887880.623809
1897890.623809
1907900.220896
1917910.220896
1927920.623809
1937930.623809
1947940.623809
1957950.623809
1967960.220896
1977970.623809
1987980.623809
1997990.220896
20071000.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