Visualise the Matthew Effect with Swift Charts

I came across the Matthew effect recently, which struck me as very unfair so I wondered if I could reproduce the results demonstrated. This article outlines the Swift code for an app to test the redistribution of coins in a closed population based on random coin tosses between agents in the population.

SwiftUI Charts, introduced in iOS 16, is used to display the distribution of coins in the population. A number of scenarios are investigated to determine the outcome.



Matthew effect

The Matthew effect can be summarised by the adage "the rich get richer and the poor get poorer". The term was coined by sociologists Robert K. Merton and Harriet Zuckerman in 1968 and takes its name from the parable of the talents or minas in the biblical Gospel of Matthew.

For unto every one that hath shall be given, and he shall have abundance: but from him, that hath not shall be taken away even that which he hath. And cast ye the unprofitable servant into outer darkness: there shall be weeping and gnashing of teeth.
[Matthew 25:24–30]


Pareto distribution

The Pareto distribution or "80-20 rule", originally used to describe the distribution of wealth in a society states that 80% of the wealth is held by 20% of the population. this has been extended to many other observations stating that 80% of the outcomes are due to 20% of the causes


The experiment is based on Computer Animation of Money Exchange Models, which has an animated video on youtube at Pareto Distribution and Price's Law. The original paper is Statistical mechanics of money, which goes into way too much details on the analysis. There is a lot of discussion on this paper of being a closed system and only showing the money transfer and not representing the value of any potential property that may be transferred, if this was a real-world scenario. It does seem odd to me to just ignore transaction when the loser does not have sufficient funds to cover the transfer.

Four scenarios are considered:

  • Transfer of a random number of coins if possible (ignore if insufficient coins)
  • Transfer of a single coin if possible (ignore if insufficient coins)
  • Allow agents to have a negative number of coins (debt)
  • Remove agents with zero coins from the game

Transaction or exchange:

I believe in the original paper that a single transaction is the transfer of a specified amount of coins from one agent to another within the population. The app here started out with this, but it was taking too long to get anywhere, especially when the system was set up to start with 1,000 or 50,000 agents. An alternative approach is to play the simulation in rounds, whereby every agent in the population takes part in an exchange. There is also an issue with this approach where all the agents coin number will switch from even numbers to odd numbers in the population until some agents run out of coins and some of the exchanges fail. So the compromise used in these simulations is to select a random number of the population to exchange coins at each iteration of the game. This allows the simulations to proceed rapidly without the odd/even bars being displayed. The individual transaction count is displayed in the app and the failed transactions are counted as well just for interest.



Set up Model and ViewModel

Swift Charts are used to visualise the changes over time as the exchanges progress. A number of structures are defined for Agent and Coin so that collections of these can be used in the Simulation. The model is based on Computer Animation of Money Exchange Models which will hold a collection of agents, each starting with a set amount of coins. The model will allow transactions to take place to exchange a random or fixed amount of coins between two agents, also chosen at random.

1struct Agent: Identifiable {
2    let id: Int
3    var total: Int
4}
5
6struct Coin: Identifiable {
7    let id: Int
8    var coinCount: Int
9}

Model

 1struct MatthewModel {
 2    private(set) var agents: [Int: Agent]
 3    private(set) var transactionCount: Int
 4    private(set) var successfulTransactionCount: Int
 5    private(set) var allowNegative: Bool
 6    private(set) var allowAgentsWithZero: Bool
 7
 8    init(capacity: Int) {
 9        self.init(capacity: capacity, allowNegativeCount: false, allowAgentsWithZero: true)
10    }
11
12    init(capacity: Int, allowNegativeCount: Bool, allowAgentsWithZero: Bool) {
13        self.agents = [:]
14        for identity in 1...capacity {
15            self.agents[identity] = Agent(id: identity, total: Config.totalCoin)
16        }
17        self.transactionCount = 0
18        self.successfulTransactionCount = 0
19        self.allowNegative = allowNegativeCount
20        self.allowAgentsWithZero = allowAgentsWithZero
21    }
22
23    mutating func exchangeCoins(from: Int, to: Int, amount: Int) {
24        self.transactionCount += 1
25        if self.allowNegative || agents[from]!.total >= amount {
26            self.successfulTransactionCount += 1
27            agents[from]!.total = agents[from]!.total - amount
28            agents[to]!.total = agents[to]!.total + amount
29        }
30    }
31    
32    mutating func performTransaction(amount: Int? = nil) async {
33        let remainingIndexes = self.allowAgentsWithZero ? agents.map {$0.key} : agents.filter{ $0.value.total > 0 }.map { $0.key }
34
35        if remainingIndexes.count > 1 {
36            // pick a random two agents in the game
37            let first = remainingIndexes.randomElement()!
38            var second = remainingIndexes.randomElement()!
39            while second == first {
40                second = remainingIndexes.randomElement()!
41            }
42            
43            exchangeCoins(from: first,
44                          to: second,
45                          amount: (amount == nil) ? Int.random(in: 1...Config.totalCoin) : amount!)
46        }
47    }
48    
49    mutating func performTransactionSuite(amount: Int? = nil) async {
50        var remainingIndexes = self.allowAgentsWithZero ? agents.map {$0.key} : agents.filter{ $0.value.total > 0 }.map {$0.key}
51
52        if remainingIndexes.count > 1 {
53            remainingIndexes.shuffle()
54            
55            let randomSample = Int.random(in: 1...remainingIndexes.count)
56            while remainingIndexes.count > randomSample {
57                let first = remainingIndexes.removeLast()
58                let second = remainingIndexes.removeLast()
59                exchangeCoins(from: first,
60                              to: second,
61                              amount: (amount == nil) ? Int.random(in: 1...Config.totalCoin) : amount!)
62            }
63        }
64    }
65}

ViewModel

 1struct Config {
 2    static let playerNumber = 1000
 3    static let totalCoin = 10
 4}
 5
 6
 7class MatthewViewModel: ObservableObject {
 8    @Published private(set) var game: MatthewModel
 9    @Published private(set) var isRunning: Bool
10
11    private(set) var allowNegative: Bool
12    private(set) var allowAgentsWithZero: Bool
13
14    init(allowNegativeCount: Bool = false, allowAgentsWithZero: Bool = true) {
15        self.allowNegative = allowNegativeCount
16        self.allowAgentsWithZero = allowAgentsWithZero
17        self.game = MatthewModel(capacity: Config.playerNumber,
18                                 allowNegativeCount: self.allowNegative,
19                                 allowAgentsWithZero: self.allowAgentsWithZero)
20        self.isRunning = false
21    }
22    
23    var coinStatus: [Coin] {
24        var sum: [Coin] = []
25        for index in 0...(Config.totalCoin * 7) {
26            sum.append(Coin(id: index,
27                            coinCount: game.agents.filter{ $0.value.total == index }.count))
28        }
29
30        return sum
31    }
32    
33    var coinStatusNeg: [Coin] {
34        var sum: [Coin] = []
35        for index in ((Config.totalCoin + 1) * (-7))...((Config.totalCoin + 1) * 7) {
36            sum.append(Coin(id: index,
37                            coinCount: game.agents.filter{ $0.value.total == index }.count))
38        }
39
40        return sum
41    }
42    
43    var maxAgentsWithSameCoinCount: Int {
44        let coins = self.coinStatus.map { $0.coinCount }
45        return coins.max() ?? 0
46    }
47    
48    var transactionsCount: Int {
49        return game.transactionCount
50    }
51    
52    var failedTransactionsCount: Int {
53        return game.transactionCount - game.successfulTransactionCount
54    }
55    
56    var agentsWithZero: Int {
57        return game.agents.filter{ $0.value.total == 0 }.count
58    }
59    
60    @MainActor
61    func performRandomTransactions(number: Int, amount: Int? = nil) async {
62        self.isRunning = true
63        for _ in 1...number {
64            // await game.performTransaction(amount: amount)
65            await game.performTransactionSuite(amount: amount)
66        }
67        self.isRunning = false
68    }
69    
70    func restart() {
71        self.game = MatthewModel(capacity: Config.playerNumber,
72                                 allowNegativeCount: self.allowNegative,
73                                 allowAgentsWithZero: self.allowAgentsWithZero)
74    }
75}


Display Coin Distribution using Swift Charts

A chart is used in the view to display the Coin distribution in the population in a bar chart and the view is bound to the model, so that changes are automatically reflected in the chart. The first scenario is to consider a population of 500 agents, each of which start with 10 coins each. Two agents are picked at random and a coin toss is simulated, whereby one agent wins and the other looses. The loser transfers a random number of coins (between 1 and 10) to the winner. If the loser has insufficient coins to fulfil the transfer amount, then no exchange takes place.

Scenario 1: Transfer random amount:

  1. All agents start with the same number of coins
  2. Each transaction involves 2 agents of the population at random
  3. The number of coins to transfer is a random number between 1 and the average number of coins per user
  4. If the loser does not have the required number of coins – then no transfer takes place

This distribution quickly drifts to a skewed distribution and eventually reaches a steady state demonstrating Pareto distribution or Price’s law.

 1struct ScenarioOneView: View {
 2    @ObservedObject var mathewGame = MatthewViewModel()
 3    
 4    var body: some View {
 5        ZStack {
 6            Color(hue: 0.58, saturation: 0.07, brightness: 1.0)
 7                    .edgesIgnoringSafeArea(.all)
 8            
 9            VStack {
10                CoinBarChartView(mathewGame: mathewGame,
11                                 chartTitle: "1: Exchange of random amounts")
12                
13                SettingsView(transferAmount: "random 1 to \(Config.totalCoin)",
14                             allowPlayWithZero: mathewGame.allowAgentsWithZero,
15                             allowNegative: mathewGame.allowNegative)
16
17                RunTransactionsView(mathewGame: mathewGame,
18                                    numOfTransactions: 10)
19
20                Spacer()
21            }
22        }
23    }
24}
 1struct CoinBarChartView: View {
 2    @ObservedObject var mathewGame: MatthewViewModel
 3    var chartTitle: String
 4    var displayNegative = false
 5    var displayFailedTransactionCount = true
 6    
 7    @State var isChartVisible = true
 8    
 9    var body: some View {
10        VStack {
11            GroupBox(chartTitle) {
12                if self.isChartVisible {
13                    BarChart(yMax: mathewGame.maxAgentsWithSameCoinCount)
14                } else {
15                    Colors.bgGradient
16                        .overlay(Text("Chart hidden"))
17                }
18            }
19            .groupBoxStyle(YellowGroupBoxStyle())
20            .padding()
21            .frame(height: 400)
22            
23            VStack {
24                HStack {
25                    Text("Numer of Transactions = ").frame(width: 250, alignment: .trailing)
26                    Text("\(mathewGame.transactionsCount)")
27                    Spacer()
28                }
29                if displayFailedTransactionCount {
30                    HStack {
31                        Text("Failed Transactions = ").frame(width: 250, alignment: .trailing)
32                        Text("\(mathewGame.failedTransactionsCount)")
33                        Spacer()
34                    }
35                } else {
36                    HStack {
37                        Text("Agents with Zero coins = ").frame(width: 250, alignment: .trailing)
38                        Text("\(mathewGame.agentsWithZero)")
39                        Spacer()
40                    }
41                }
42            }
43            
44            Button("\(self.isChartVisible ? "Hide Chart" : "Show Chart")") {
45                self.isChartVisible.toggle()
46            }
47            .buttonStyle(ActionButtonStyle())
48        }
49    }
50    
51    @ViewBuilder
52    func BarChart(yMax: Int) -> some View {
53        Chart(self.displayNegative ? mathewGame.coinStatusNeg : mathewGame.coinStatus) {
54            BarMark(
55                x: .value("number", $0.id),
56                y: .value("count", $0.coinCount),
57                width: 7
58            )
59            .foregroundStyle(Colors.barColor)
60        }
61        .chartPlotStyle { plotArea in
62            plotArea
63                .background(Colors.bgPlotColor)
64        }
65        .chartYAxis() {
66            AxisMarks(position: .leading)
67        }
68    }
69}
 1struct SettingsView: View {
 2    var transferAmount: String
 3    var allowPlayWithZero: Bool = true
 4    var allowNegative: Bool = false
 5    
 6    var body: some View {
 7        VStack(spacing: 5) {
 8            Text("Scenario Settings")
 9                .font(.custom("Helvetica Neue", size: 22, relativeTo: .largeTitle))
10            .fontWeight(.bold)
11            
12            Grid() {
13                GridRow {
14                    Text("Number of Agents:")
15                        .frame(width: 250, alignment: .trailing)
16                        .gridColumnAlignment(.trailing)
17                    
18                    Text("\(Config.playerNumber)")
19                        .gridColumnAlignment(.leading)
20                }
21                GridRow {
22                    Text("Number of Coins:")
23                    Text("\(Config.totalCoin)")
24                }
25                GridRow {
26                    Text("Transfer amount:")
27                    Text(transferAmount)
28                }
29                GridRow {
30                    Text("Allow play with zero coin:")
31                    Text("\(allowPlayWithZero ? "Yes" : "No")")
32                }
33                GridRow {
34                    Text("Negative coin count allowed:")
35                    Text("\(allowNegative ? "Yes" : "No")")
36                }
37            }
38        }
39        .padding()
40    }
41}
 1struct RunTransactionsView: View {
 2    @ObservedObject var mathewGame: MatthewViewModel
 3    @State var numOfTransactions: Int
 4    var transferAmount: Int? = nil
 5    
 6    let repeatOptions = [10, 100, 500, 5000, 10000]
 7    
 8    var body: some View {
 9        VStack {
10            VStack {
11                Text("Number of Transactions")
12                    .font(.custom("Helvetica Neue", size: 22, relativeTo: .largeTitle))
13                    .fontWeight(.bold)
14                Picker("", selection: $numOfTransactions) {
15                    ForEach(repeatOptions, id: \.self) {
16                        Text("\($0)")
17                            .fontWeight(.heavy)
18                    }
19                }
20                .pickerStyle(.segmented)
21                .background(Color(hue: 0.10, saturation: 0.10, brightness: 0.98))
22            }
23            .padding(.horizontal, 35)
24            
25            
26            Button("Perform Transactions") {
27                Task {
28                    await mathewGame.performRandomTransactions(number: numOfTransactions,
29                    amount: transferAmount)
30                }
31            }
32            .buttonStyle(ActionButtonStyle())
33            .disabled(mathewGame.isRunning)
34            
35            Spacer().frame(height: 20)
36            
37            Button("Reset") {
38                mathewGame.restart()
39            }
40            .buttonStyle(ActionButtonStyle())
41            .disabled(mathewGame.isRunning)
42        }
43    }
44}

Colors and styles used in the App.

 1struct YellowGroupBoxStyle: GroupBoxStyle {
 2    func makeBody(configuration: Configuration) -> some View {
 3        configuration.content
 4            .padding(.top, 20)
 5            .padding(20)
 6            .background(Colors.bgGradient)
 7            .cornerRadius(20)
 8            .overlay(
 9                configuration.label.padding(10),
10                alignment: .top
11            )
12    }
13}
14
15struct ActionButtonStyle: ButtonStyle {
16    @Environment(\.isEnabled) var isEnabled
17    
18    func makeBody(configuration: Configuration) -> some View {
19        configuration.label
20            .font(.system(size: 20, weight: .bold, design: .rounded))
21            .frame(height:30)
22            .padding(.horizontal, 20)
23            .foregroundColor(.white)
24            .padding(5)
25            .background(isEnabled ? Colors.buttonGradient :  Colors.disabledbuttonGradient)
26            .cornerRadius(25)
27            .shadow(color: isEnabled ? .gray : .clear, radius:5, x:3.0, y:3.0)
28            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
29    }
30}
31
32struct Colors {
33    static let barColor = Color(hue: 0.8, saturation: 0.7, brightness: 0.5)
34    static let bgPlotColor = Color(hue: 0.12, saturation: 0.10, brightness: 0.92)
35
36    static let bgGradient = LinearGradient(
37        gradient: Gradient(colors: [
38            Color(hue: 0.10, saturation: 0.10, brightness: 1.0),
39            Color(hue: 0.10, saturation: 0.20, brightness: 0.95)
40        ]),
41        startPoint: .topLeading,
42        endPoint: .bottomTrailing)
43    
44    static let buttonGradient1 = LinearGradient(
45        gradient: Gradient(colors: [
46            Color(hue: 0.95, saturation: 0.7, brightness: 0.9),
47            Color(hue: 0.0, saturation: 0.8, brightness: 0.93)
48        ]),
49        startPoint: .top,
50        endPoint: .bottom)
51    
52    static let buttonGradient = LinearGradient(
53        gradient: Gradient(colors: [
54            Color(hue: 0.75, saturation: 0.8, brightness: 0.8),
55            Color(hue: 0.75, saturation: 0.8, brightness: 0.5),
56            Color(hue: 0.75, saturation: 0.8, brightness: 0.4)
57        ]),
58        startPoint: .top,
59        endPoint: .bottom)
60    
61    static let disabledbuttonGradient = LinearGradient(
62        gradient: Gradient(colors: [
63            Color(.gray),
64            Color(.black).opacity(0.5)]),
65        startPoint: .top,
66        endPoint: .bottom)
67}

Random number of coins transferred between two agents repeatedly in a population of 500 agents
Random number of coins transferred between two agents repeatedly in a population of 500 agents


Scenario 1: Agents transfer random number of coins when possible

Scenario 1: Agents transfer random number of coins when possible



Fixed coin transfer at 1 coin

Scenario 2: Transfer of a single coin:

  1. All agents start with the same number of coins
  2. Each transaction involves 2 agents of the population at random
  3. The number of coins to transfer is fixed at 1
  4. If the loser does not have the required number of coins – then no transfer takes place

This starts initially to normalise and then drifts to a skewed distribution and eventually reaches a steady state demonstrating Pareto distribution or Price’s law.

 1struct ScenarioTwoView: View {
 2    @ObservedObject var mathewGame = MatthewViewModel()
 3    
 4    var body: some View {
 5        ZStack {
 6            Color(hue: 0.58, saturation: 0.07, brightness: 1.0)
 7                    .edgesIgnoringSafeArea(.all)
 8            
 9            VStack {
10                Spacer().frame(height: 50)
11                CoinBarChartView(mathewGame: mathewGame,
12                                 chartTitle: "2: Exchange of 1 coin")
13                
14                SettingsView(transferAmount: "1",
15                             allowPlayWithZero: mathewGame.allowAgentsWithZero,
16                             allowNegative: mathewGame.allowNegative)
17
18
19                RunTransactionsView(mathewGame: mathewGame,
20                                    numOfTransactions: 10,
21                                    transferAmount: 1)
22
23                Spacer()
24            }
25        }
26    }
27}

Scenario 2: Agents transfer 1 coin at a time in a population of 100 agents showing changes over time

Scenario 2: Agents transfer 1 coin at a time in a population of 100 agents showing changes over time



Allow negative coin count

It seems odd to ignore transactions where the losing agent has insufficient coins to transfer. One option is to allow the transaction to take place and allow agents to go into debt. This scenario examines the outcome if this was allowed.

Scenario 3: Allow negative coin count:

  1. All agents start with the same number of coins
  2. Each transaction involves 2 agents of the population at random
  3. The number of coins to transfer is fixed at 1
  4. Coin transfer always takes place and agents may go into negative coin count

The distribution in this scenario seems to exhibit a normal distribution with an average around the initial average value. The curve seems to flatten over time with extremes in the positive and negative directions.

 1struct ScenarioThreeView: View {
 2    @ObservedObject var mathewGame = MatthewViewModel(allowNegativeCount: true)
 3    
 4    var body: some View {
 5        ZStack {
 6            Color(hue: 0.58, saturation: 0.07, brightness: 1.0)
 7                    .edgesIgnoringSafeArea(.all)
 8            
 9            VStack {
10                Spacer().frame(height: 50)
11
12                CoinBarChartView(mathewGame: mathewGame,
13                                 chartTitle: "3: Allow negative coin count",
14                                 displayNegative: true)
15
16                SettingsView(transferAmount: "1",
17                             allowPlayWithZero: mathewGame.allowAgentsWithZero,
18                             allowNegative: mathewGame.allowNegative)
19
20
21                RunTransactionsView(mathewGame: mathewGame,
22                                    numOfTransactions: 10,
23                                    transferAmount: 1)
24                
25                Spacer()
26            }
27        }
28    }
29}

Scenario 3: Agents transfer 1 coin and are allowed to go into negative coin count

Scenario 3: Agents transfer 1 coin and are allowed to go into negative coin count



Remove Agents with zero coins

The final scenario is where agents are excluded from playing once they have zero coins remaining. I find this scenario fascinating as it demonstrates the Matthew Effect, where one agent will end up with all the coins.

Scenario 4: Remove agents with zero coin:

  1. All agents start with the same number of coins
  2. Each transaction involves 2 random agents of the population with coin count greater than 0
  3. The number of coins to transfer is fixed at 1
  4. Coin transfer always takes place as both agents have at least 1 coin at the time of the transaction
 1struct ScenarioFourView: View {
 2    @ObservedObject var mathewGame = MatthewViewModel(allowAgentsWithZero: false)
 3    
 4    var body: some View {
 5        ZStack {
 6            Color(hue: 0.58, saturation: 0.07, brightness: 1.0)
 7                    .edgesIgnoringSafeArea(.all)
 8            
 9            VStack {
10                Spacer().frame(height: 50)
11
12                CoinBarChartView(mathewGame: mathewGame,
13                                 chartTitle: "4: Agents with zero coin are out",
14                                 displayFailedTransactionCount: false)
15                
16                SettingsView(transferAmount: "1",
17                             allowPlayWithZero: mathewGame.allowAgentsWithZero,
18                             allowNegative: mathewGame.allowNegative)
19
20                RunTransactionsView(mathewGame: mathewGame,
21                                    numOfTransactions: 10,
22                                    transferAmount: 1)
23                
24                Spacer()
25            }
26        }
27    }
28}

Scenario 4: Agents with zero coins are out in a population of 100 agents showing changes over time
Scenario 4: Agents with zero coins are out in a population of 100 agents showing changes over time


Scenario 4: Agents with zero coins are out in a population of 500 agents

Scenario 4: Agents with zero coins are out in a population of 500 agents



Matthews Effect

Removing players with zero coins from the game demonstrates the Matthew effect. Change the settings to have 10 agents with 10 coins each. Selecting 2 agents at ramdom and transferring a single coin at random results in an initial Pareto distribution and eventually with one agent getting all the coins as the other 9 agents are eliminated. This works with any numbers for coins and agents, but obviously takes more time and transactions for larger numbers.


The Matthews effect shown where one agent gets all the coins when agents with zero are eliminated

The Matthews effect shown where one agent gets all the coins when agents with zero are eliminated




Conclusion

The transactions of the coin exchange is conducted with an asynchronous task and the Swift Chart is bound to the model to display the coin distribution as the data changes. I found that the speed of the exchange simulation could be increased by hiding the chart, so the refreshing of the chart does seem to slow down the app performance.

I was not quite able to reproduce the values in the charts as shown on Computer Animation of Money Exchange Models, never the less, I was able to produce the over all effect.

  • When agents with zero coins remaining are allowed to continue to play the game, the game enters a steady state with the coins distributed in a Pareto distribution.
  • If agents are allowed to go into negative coin equity, then the distribution seems to remain normal centered around the starting average coin value.
  • Finally, if players are removed from the game when their coin count reaches zero, then this illustrates the Matthew effect with 1 player ultimately getting all the coins.

The code for MathewEffectApp is available on GitHub.