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.775505497474792, 0.775505497474792, 0.7755054974747924)

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
15810.323859
25820.775505
35830.323859
45840.775505
55850.323859
65860.323859
75870.775505
85880.775505
95890.775505
105900.775505
115910.775505
125920.323859
135930.775505
145940.323859
155950.775505
165960.323859
175970.775505
185980.775505
195990.775505
2051000.775505

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.710824
2020.0644853
3030.477843
4040.177709
5050.0535199
6060.303093
7070.447577
8080.143591
9090.208728
100100.462638
110110.194236
120120.806648
130130.376059
140140.802455
150150.107498
160160.325582
170170.69722
180180.734579
190190.884732
200200.561824
210210.861369
220220.587883
230230.519535
240240.79065
250250.653704
260260.146824
270270.493272
280280.469055
290290.854765
300300.352602
310310.1629
320320.0930083
330330.358555
340340.889985
350350.492994
360360.174023
370370.0171277
380380.529866
390390.835088
400400.402096
410410.894119
420420.336605
430430.751976
440440.446684
450450.0334686
460460.905424
470470.901271
480480.337541
490490.75534
500500.135595
510510.794044
520520.231996
530530.86495
540540.377651
550550.760413
560560.482851
570570.0583644
580580.181132
590590.728723
600600.596795
610610.384965
620620.109381
630630.900231
640640.702811
650650.789719
660660.855213
670670.300097
680680.817891
690690.253018
700700.96368
710710.977752
720720.99337
730730.909887
740740.815208
750750.255645
760760.335435
770770.865256
780780.397427
790790.672106
800800.333603
810810.48499
820820.684155
830830.215746
840840.648166
850850.500533
860860.42758
870870.782539
880880.699284
890890.782768
900900.930608
910910.902028
920920.192865
930930.83302
940940.344744
950950.780491
960960.392631
970970.716752
980980.734836
990990.708464
10001000.593175
101510.775505
102520.323859
103530.323859
104540.323859
105550.323859
106560.323859
107570.323859
108580.323859
109590.323859
1105100.323859
1115110.323859
1125120.775505
1135130.323859
1145140.775505
1155150.323859
1165160.323859
1175170.775505
1185180.775505
1195190.775505
1205200.323859
1215210.775505
1225220.775505
1235230.323859
1245240.775505
1255250.775505
1265260.323859
1275270.323859
1285280.323859
1295290.775505
1305300.323859
1315310.323859
1325320.323859
1335330.323859
1345340.775505
1355350.323859
1365360.323859
1375370.323859
1385380.323859
1395390.775505
1405400.323859
1415410.775505
1425420.323859
1435430.775505
1445440.323859
1455450.323859
1465460.775505
1475470.775505
1485480.323859
1495490.775505
1505500.323859
1515510.775505
1525520.323859
1535530.775505
1545540.323859
1555550.775505
1565560.323859
1575570.323859
1585580.323859
1595590.775505
1605600.775505
1615610.323859
1625620.323859
1635630.775505
1645640.775505
1655650.775505
1665660.775505
1675670.323859
1685680.775505
1695690.323859
1705700.775505
1715710.775505
1725720.775505
1735730.775505
1745740.775505
1755750.323859
1765760.323859
1775770.775505
1785780.323859
1795790.775505
1805800.323859
1815810.323859
1825820.775505
1835830.323859
1845840.775505
1855850.323859
1865860.323859
1875870.775505
1885880.775505
1895890.775505
1905900.775505
1915910.775505
1925920.323859
1935930.775505
1945940.323859
1955950.775505
1965960.323859
1975970.775505
1985980.775505
1995990.775505
20051000.775505

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