Last updated on 16th July 2024
In this tutorial, you will learn how to build a more complex model than the simple Trading model demonstrated in the introductory tutorial. You will also be taught some more advanced modelling concepts and explore best practice for the iterative development of models.
The model is a simulation of a commercial bank's mortgage portfolio. Mortgages are lent to households, who are hit by income shocks, earn income, pay taxes, consume -- and attempt to repay their mortgage. Households who are unable to make their repayments eventually default on their mortgage and the mortgage contract is 'written off' the bank's balance sheet.
This tutorial is divided into three parts: which reflects the iterative nature of building agent-based models. When we are done we should have the below model displayed on the console.
In the first instalment of this three-part tutorial, we will sketch out a simplified skeleton model. That skeleton model will introduce simple agents with limited behaviour. Parts two and three will add flesh to these bones.
The main model class sets up the agent groups and connects them together. It also specifies the variables that will be output to the console and the control parameters that can be tweaked between model runs.
The logic of the model -- which describes the sequence of events that takes place within one simulated time-step -- is written in a sequence at the bottom of this file.
The behaviours taking place for each agent at each step are captured in methods which belong to the agent classes. For example, the bank's methods are found in the Bank class, while the households' methods are found in the Household class. Writing the flow sequence in this way keeps it terse and easy to read. The natural logic of the model is thus easy to follow and edit.
MortgageModel.java
import simudyne.core.abm.Action;
import simudyne.core.abm.AgentBasedModel;
import simudyne.core.abm.GlobalState;
import simudyne.core.abm.Group;
import simudyne.core.annotations.Input;
import simudyne.core.graph.LongAccumulator;
public class MortgageModel extends AgentBasedModel<GlobalState> {
LongAccumulator accEquity = createLongAccumulator("equity", "Bank Equity (£)");
LongAccumulator accAssets = createLongAccumulator("assets", "Assets");
@Input(name = "Number of Households")
long nbHouseholds = 100;
@Override
public void setup() {
// Create our agent groups
Group<Household> householdGroup = generateGroup(Household.class, nbHouseholds);
Group<Bank> bankGroup = generateGroup(Bank.class, 1);
// Each household is connected to 1 bank
householdGroup.partitionConnected(bankGroup, Links.BankLink.class);
super.setup();
}
@Override
public void step() {
super.step();
run(
Action.create(
Household.class,
household -> {
household.earnIncome();
household.consume();
household.payMortgage();
}),
Action.create(
Bank.class,
bank -> {
bank.updateBalanceSheet();
}));
}
}
The Bank class describes stylised data and behaviours of a commercial bank. In this skeleton model, the bank's balance sheet is comprised of debt, assets and equity -- which is calculated as assets - debt. The bank updates its assets in line with the sum of mortgage repayments it receives.
Bank.java
import java.util.List;
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
import simudyne.core.annotations.Variable;
public class Bank extends Agent<GlobalState> {
@Variable int debt = 90;
public void updateBalanceSheet() {
int assets = 0;
List<Messages.RepaymentAmount> paymentMessages =
getMessagesOfType(Messages.RepaymentAmount.class);
for (Messages.RepaymentAmount payment : paymentMessages) {
assets += payment.getBody();
}
getLongAccumulator("assets").add(assets);
getLongAccumulator("equity").add(assets - this.debt);
}
}
The household class describes the stylised data and behaviours of a household with a mortgage. In the skeleton model, the household earns income, consumes, and compares its stock of wealth with its required mortgage repayment. If it can afford to pay its mortgage, the household sends a message to the bank containing its mortgage repayment.
Household.java
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
import simudyne.core.annotations.Variable;
public class Household extends Agent<GlobalState> {
@Variable int income = 5000;
@Variable int consumption = 3000;
@Variable int wealth = 1000;
@Variable int repayment = 100;
public void consume() {
wealth -= consumption;
}
public void earnIncome() {
wealth += income;
}
public void payMortgage() {
if (canPay()) {
getLinks(Links.BankLink.class).send(Messages.RepaymentAmount.class, repayment);
}
}
private Boolean canPay() {
return wealth >= repayment;
}
}
In this iteration of the model, we have introduced a more extensive set of agent behaviours -- as described by the two sequences at the bottom of the MortgageModel class. Households actively apply for mortgages, and Banks check that a mortgage application meets certain requirements before issuing it. Significantly more output is reported to the console -- in this stage of model development it is useful to track a large number of variables to ensure that the model is generating sensible intermediate and final outputs.
The other significant change in this version, compared to the last version, is the collection of the messages together inside an object (Messages). This is a stylistic choice, but one we recommend as best practice -- see Class Organisation.
MortgageModel.java
import simudyne.core.abm.Action;
import simudyne.core.abm.AgentBasedModel;
import simudyne.core.abm.GlobalState;
import simudyne.core.abm.Group;
import simudyne.core.annotations.Input;
import simudyne.core.annotations.ModelSettings;
import simudyne.core.annotations.Variable;
import simudyne.core.graph.LongAccumulator;
@ModelSettings(macroStep = 120)
public class MortgageModel extends AgentBasedModel<GlobalState> {
{
createLongAccumulator("equity", "Bank Equity (£)");
createLongAccumulator("badLoans", "Bad Loans");
createLongAccumulator("writeOffs", "Write-offs");
createLongAccumulator("impairments", "Impairments (£k)");
createLongAccumulator("debt", "Debt");
createLongAccumulator("income", "Income");
createLongAccumulator("mortgages", "Mortgages");
createLongAccumulator("assets", "Assets");
}
int income = 5000;
int wealth = 50000;
@Input(name = "Number of Households")
long nbHouseholds = 100;
@Override
public void setup() {
Group<Household> householdGroup =
generateGroup(
Household.class,
nbHouseholds,
house -> {
house.income = income;
house.wealth = wealth;
});
Group<Bank> bankGroup = generateGroup(Bank.class, 1);
householdGroup.partitionConnected(bankGroup, Links.BankLink.class);
super.setup();
}
@Override
public void step() {
super.step();
run(Household.applyForMortgage(), Bank.processApplication(), Household.takeOutMortgage());
run(
Action.create(
Household.class,
(Household h) -> {
h.incomeShock();
h.earnIncome();
h.consume();
h.payMortgage();
}),
Action.create(
Bank.class,
(Bank b) -> {
b.accumulateIncome();
b.processArrears();
b.calculateImpairments();
b.clearPaidMortgages();
b.updateAccumulators();
}));
}
}
Relative to the last version, the bank's behaviour is significantly more complicated. The bank has new methods for processing mortgage applications, processing arrears and cumulating impairments. A final method (UpdateAccumulators
) updates the bank's balance sheet data via accumulators which are reported on the console.
Bank.java
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
import simudyne.core.annotations.Variable;
public class Bank extends Agent<GlobalState> {
@Variable int debt = 0;
@Variable int assets = 10000000;
@Variable
int equity() {
return assets - debt;
}
@Variable private int nbMortgages = 0;
private int termInYears = 15; // Should be 25
private double interest = 1.05;
private int termInMonths = termInYears * 12;
private int impairments = 0;
private int income = 0;
private double LTILimit = 4.5;
private double LTVLimit = 0.95;
public static Action<Bank> processApplication() {
return new Action<>(
Bank.class,
bank ->
bank.getMessagesOfType(Messages.MortgageApplication.class)
.stream()
.filter(m -> m.amount / m.income <= bank.LTILimit)
.filter(m -> m.wealth > m.amount * (1 - bank.LTVLimit))
.forEach(
m -> {
bank.send(
Messages.ApplicationSuccessful.class,
newMessage -> {
newMessage.amount = m.amount;
newMessage.termInMonths = bank.termInMonths;
newMessage.repayment =
(int) ((m.amount * bank.interest) / bank.termInMonths);
})
.to(m.getSender());
bank.nbMortgages += 1;
bank.assets += m.amount;
bank.debt += m.amount;
}));
}
public void accumulateIncome() {
income = 0;
getMessagesOfType(Messages.Payment.class).forEach(payment -> income += payment.repayment);
double NIM = 0.25;
assets += (income * NIM);
}
/**
* Record how many bad loans the bank has. A loan is counted as bad if it has been more than 3
* months in arrears.
*/
public void processArrears() {
getMessagesOfType(Messages.Arrears.class)
.forEach(
arrears -> {
if (arrears.monthsInArrears > 3) {
getLongAccumulator("badLoans").add(1);
}
});
}
/** Calculate impairments from written off loans. */
public void calculateImpairments() {
impairments = 0;
getMessagesOfType(Messages.Arrears.class)
.forEach(
arrears -> {
// A mortgage is written off if it is more than 6 months in arrears.
if (arrears.monthsInArrears > 6) {
impairments += arrears.outstandingBalance;
getLongAccumulator("writeOffs").add(1);
}
});
// Remove any impairments from our assets total.
// Note that the debt from the written off loan remains.
assets -= impairments;
}
/** Remove any mortgages that have closed from the books. */
public void clearPaidMortgages() {
int balancePaidOff = 0;
for (Messages.MortgageCloseAmount close :
getMessagesOfType(Messages.MortgageCloseAmount.class)) {
balancePaidOff += close.getBody();
nbMortgages -= 1;
}
debt -= balancePaidOff;
assets -= balancePaidOff;
}
public void updateAccumulators() {
getLongAccumulator("debt").add(debt);
getLongAccumulator("impairments").add(impairments);
getLongAccumulator("mortgages").add(nbMortgages);
getLongAccumulator("income").add(income);
getLongAccumulator("assets").add(assets);
getLongAccumulator("equity").add(equity());
}
}
Similarly, the behaviour of the household has also been made more complex than in the skeleton version. The households have additional methods which capture behaviours to do with applying for, and taking out a mortgage. A simple class is also included to reflect a mortgage contract
Household.java
import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
import simudyne.core.annotations.Variable;
public class Household extends Agent<GlobalState> {
@Variable int income;
@Variable int wealth = 1000;
Mortgage mortgage;
int monthsInArrears = 0;
int consumption = 3000;
public void earnIncome() {
wealth += income;
}
public void payMortgage() {
if (mortgage != null) {
if (canPay()) {
wealth -= mortgage.repayment;
monthsInArrears = 0;
mortgage.term -= 1;
mortgage.balanceOutstanding -= mortgage.repayment;
checkMaturity();
} else {
monthsInArrears += 1;
getLinks(Links.BankLink.class)
.send(
Messages.Arrears.class,
(m, l) -> {
m.monthsInArrears = monthsInArrears;
m.outstandingBalance = mortgage.amount;
});
// If we have spent more than 6 months in arrears,
// default the mortgage (close it with 0 value).
if (monthsInArrears > 6) {
getLinks(Links.BankLink.class).send(Messages.MortgageCloseAmount.class, 0);
mortgage = null;
}
}
}
}
private void checkMaturity() {
if (mortgage.term == 0) {
getLinks(Links.BankLink.class).send(Messages.MortgageCloseAmount.class, mortgage.amount);
mortgage = null;
} else {
getLinks(Links.BankLink.class)
.send(
Messages.Payment.class,
(m, l) -> {
m.repayment = mortgage.repayment;
m.amount = mortgage.amount;
});
}
}
private Boolean canPay() {
return (mortgage == null) || (wealth >= mortgage.repayment);
}
public static Action<Household> applyForMortgage() {
return Action.create(
Household.class,
h -> {
if (h.mortgage == null) {
int purchasePrice = h.income * 4;
h.getLinks(Links.BankLink.class)
.send(
Messages.MortgageApplication.class,
(m, l) -> {
m.amount = purchasePrice;
m.income = h.income;
m.wealth = h.wealth;
});
}
});
}
public static Action<Household> takeOutMortgage() {
return Action.create(
Household.class,
h ->
h.hasMessageOfType(
Messages.ApplicationSuccessful.class,
message ->
h.mortgage =
new Mortgage(
message.amount,
message.amount,
message.termInMonths,
message.repayment)));
}
public void incomeShock() {
income += 200 * getPrng().gaussian(0, 1).sample();
if (income <= 0) {
income = 1;
}
}
public void consume() {
wealth -= consumption;
if (wealth < 0) {
wealth = 1;
}
}
public static class Mortgage {
private int amount;
private int balanceOutstanding;
private int term;
private int repayment;
public Mortgage(int amount, int balanceOutstanding, int term, int repayment) {
this.amount = amount;
this.balanceOutstanding = balanceOutstanding;
this.term = term;
this.repayment = repayment;
}
}
}
import simudyne.core.graph.Message;
public class Messages {
public static class Arrears extends Message {
int monthsInArrears;
int outstandingBalance;
}
public static class MortgageCloseAmount extends Message.Integer {}
public static class MortgageApplication extends Message {
public int amount;
public int income;
public int wealth;
}
public static class ApplicationSuccessful extends Message {
public int amount;
public int termInMonths;
public int repayment;
}
public static class Payment extends Message {
int repayment;
int amount;
}
}
package sandbox.models.advanced2;
import simudyne.core.graph.Link;
public class Links {
public static class BankLink extends Link.Empty {}
}
The final version of the model introduces additional features to ensure that it is a more accurate reflection of the system we are seeking to model.
Agents have more complex behaviour as well as additional data.
The distribution class is introduced to allow the Households to have their income drawn from data that is representative of the UK household sector as a whole. In this way we can accurately generate any number of realistically parameterised and yet heterogeneous agents.
For many applications of simulation modelling there is a need to run multiple runs of the same simulation. Due to the stochasticity and emergent properties of these types of model, results are best presented as a distribution.
The Simudyne SDK includes a multi-run setting which allows the user to specify a Monte Carlo evaluation of their model. We can look at this in action in the mortgage model developed in this tutorial:
The results illustrate the range of outcomes generated by 100 simulations of the model. The grey region shows the min-max range, while the red region shows the interquartile range. The red line represents the mean of each output.