Agent-based systems
Write the behaviour of individual agents as plain Python, and let them move, eat, reproduce, and die on a grid, a network, or a continuous space. Foragers wander a torus, find food, split when they are full, and starve when they are empty:
from typing import Annotated
from sage.all__sagemath_symbolics import oo
from sage.sets.real_set import RealSet
from skcomplex.core.components.elements import Element, GroupElement
from skcomplex.core.components.laws import RuleLaw
from skcomplex.core.components.spaces import LatticeSpace
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ParameterSpec, StateSpec, VisualSpec
class ForagerElement(Element):
def __init__(
self,
label,
visual=VisualSpec('agents', {'color_by': 'food'}),
*,
food: Annotated[float, StateSpec()] = 5.0,
hunger: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
):
super().__init__(label, visual=visual)
self.food = food
self.hunger = hunger
def _get_laws(self, s):
return [RuleLaw(self.forage)]
def forage(self, agent, system):
cells = agent.neighbor_cells()
agent.move_to(cells[int(system.rng.integers(len(cells)))])
agent.food += system.rng.poisson(0.8) - agent.hunger
if agent.food <= 0:
agent.remove()
elif agent.food > 10:
agent.food /= 2
agent.create(food=agent.food)
foragers = System(
elements=[GroupElement('foragers', element=ForagerElement, size=50, variable=True)],
space=LatticeSpace((30, 30), boundary='periodic'),
)
trajectory = Simulation(foragers, random_state=0).prepare().run(until=20).trajectory_
int(trajectory.values('foragers.count')[-1]), len(trajectory.agents('foragers', 20).ids)
(17, 17)
Agents are ordinary elements whose laws are rules written as code.
- An
Elementdeclares the states every agent holds, and the parameters its group shares, as annotated arguments of its__init__. - Its
_get_lawsreturnsRuleLaws, whose functions take the agent and the system. - An element or a group whose laws include rules is an agent, and so is every member of a variable group.
- In a system with rules, a group whose element type has no laws is a group of passive agents that rules read and change, unless an interaction whose laws are not rules binds it.
A system with rules is agent-based. It runs in discrete time on MesaSolver, one Mesa step per unit of time, with
every agent activated in random order. MesaSolver declares what it runs like any solver, so
PassCompiler(solver=MesaSolver()).prepare(system).report_ holds the laws, spaces, and times it does not support.
What an agent can do:
- An agent reads and sets its states as attributes, and reads its group's parameters, which change live like any parameter.
neighbors,neighbor_cells,agents_here,move_to,create, andremoveact on its place and its population. Only the members of a group declaredvariable=Trueare created and removed during a run.agent.neighbors(group=path)andagent.agents_here(path)read the agents of one group around the agent and at its place, so the agents of a variable group on the nodes of a network make a set of members per node that grows and that neighbours read, as the networks guide shows.agent.displacement_to(other)gives the vector to another agent or a location, the shortest one on a torus, andagent.distance_to(other)its length, or the number of hops on a network.agent.groupis the path of the agent's group, andagent.group_labelits label.
Where agents live:
GroupElement(label, element=..., size=..., location=...)places the agents at cell coordinates, network nodes, or positions, one per agent or one shared. Agents without a location take random places.LatticeSpacegives agents Moore or von Neumann neighbourhoods and an optional cell capacity,NetworkSpaceplaces agents on the nodes of a NetworkX graph, andContinuousSpacefinds neighbours within a radius.system.dimensionsgives the extent of the space per axis, andsystem.toruswhether it wraps.
What the system gives a rule:
- the random generator
system.rng, the other agents withsystem.agents(name)andsystem.count(name), and the empty cells. - Every rule receives the system view scoped to the interaction or agent group that owns it.
system.parameter('name')reads the owner's parameter.system.agents('role')reads the group bound to a role of the owner, then a group by its path relative to the owner's system, then by its path from the root. So a rule works unchanged in a subsystem. - Interactions with agent rules bind any number of agent groups, of any sizes, through their roles.
What a run records: frames and trajectories hold every group's agents with their ids, states, and locations, through
trajectory.agents(group, frame), and the count of every group as the reporter <group>.count.
The catalog has Schelling segregation as schelling, the voter system as voter_system, Boltzmann wealth exchange as
boltzmann_wealth, and the wolf and sheep system as wolf_sheep.
An interaction can hold a rule too, and read the groups bound to its roles. Here walkers drift along a periodic road
inside a subsystem, and an interaction counts the pairs closer than its reach:
from typing import Annotated
from skcomplex.core.components.elements import Element, GroupElement
from skcomplex.core.components.interactions import Interaction
from skcomplex.core.components.laws import RuleLaw
from skcomplex.core.components.spaces import ContinuousSpace
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ParameterSpec, RoleSpec
class WalkerElement(Element):
def _get_laws(self, s):
return [RuleLaw(self.step)]
def step(self, agent, system):
width, height = system.dimensions
x, y = agent.location
agent.move_to(((x + system.rng.uniform(0, 2)) % width, y))
class SpreadInteraction(Interaction):
def __init__(self, label, *, walkers: Annotated[str, RoleSpec()], reach: Annotated[float, ParameterSpec()] = 10.0):
super().__init__(label)
self.walkers = walkers
self.reach = reach
def _get_laws(self, s):
return [RuleLaw(self.measure)]
def measure(self, system):
walkers = system.agents('walkers')
close = [a for a in walkers for b in walkers if a != b and a.distance_to(b) < system.parameter('reach')]
counts.append(len(close) // 2)
counts = []
places = [(10.0 * index, 0.5) for index in range(5)]
road = System(
systems=[
System(
'road',
elements=[GroupElement('walkers', element=WalkerElement, size=5, location=places)],
interactions=[SpreadInteraction('spread', walkers='walkers')],
),
],
space=ContinuousSpace(extent=(50.0, 1.0), boundary='periodic'),
)
Simulation(road, random_state=0).prepare().run(until=5)
counts
[3, 2, 3, 3, 2]
Phases, markets, institutions, and learning
Order each step into phases, match buyers with sellers in a market, let an institution set policy, and let agents learn from their past. Traders quote prices in one phase and trade in a batch auction in the next, while a clerk sets a fee from the volume traded:
from typing import Annotated, ClassVar
import numpy as np
from skcomplex.core.components.elements import Element, GroupElement
from skcomplex.core.components.interactions import FeedbackInteraction, MatchingInteraction
from skcomplex.core.components.laws import RuleLaw
from skcomplex.core.components.spaces import DiscreteTime
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import BatchMatchingSpec, ParameterSpec, PhaseSpec, PhasesScheduleSpec, StateSpec
class TraderElement(Element):
def __init__(
self,
label,
*,
value: Annotated[float, StateSpec()] = 0.0,
bid: Annotated[float, StateSpec()] = 0.0,
ask: Annotated[float, StateSpec()] = 0.0,
demand: Annotated[float, StateSpec()] = 0.0,
supply: Annotated[float, StateSpec()] = 0.0,
money: Annotated[float, StateSpec()] = 0.0,
goods: Annotated[float, StateSpec()] = 0.0,
):
super().__init__(label)
self.value = value
self.bid = bid
self.ask = ask
self.demand = demand
self.supply = supply
self.money = money
self.goods = goods
def _get_laws(self, s):
return [RuleLaw(self.quote, phase='quote')]
def quote(self, agent, system):
agent.bid = system.rng.uniform(0, agent.value) if agent.demand else 0.0
agent.ask = system.rng.uniform(agent.value, 2.0) if agent.supply else 2.0
class ClerkInteraction(FeedbackInteraction):
reports: ClassVar[tuple[str, ...]] = ('traded',)
def __init__(self, label, *, rule=None, phase=None, fee: Annotated[float, ParameterSpec()] = 0.0):
super().__init__(label, rule=rule, phase=phase)
self.fee = fee
def act(self, system):
traded = sum(agent.goods for agent in system.agents('buyers'))
system.report('traded', traded)
system.set_parameter('fee', 0.01 * traded)
rng = np.random.default_rng(0)
buyers = {'value': rng.uniform(0.5, 2, 20), 'demand': 1.0, 'money': 2.0}
sellers = {'value': rng.uniform(0, 1.5, 20), 'supply': 1.0}
auction = System(
elements=[
GroupElement('buyers', element=TraderElement, size=20, values=buyers),
GroupElement('sellers', element=TraderElement, size=20, values=sellers),
],
interactions=[
MatchingInteraction(
'auction',
requesters='buyers',
providers='sellers',
rule=BatchMatchingSpec(),
limit=('bid', 'ask'),
quantity=('demand', 'supply'),
allowance='money',
transfers=(('money', -1), ('goods', 1)),
phase='trade',
),
ClerkInteraction('clerk', phase='trade'),
],
time=DiscreteTime(schedule=PhasesScheduleSpec([PhaseSpec('quote'), PhaseSpec('trade')])),
)
trajectory = Simulation(auction, random_state=0).prepare().run(until=5).trajectory_
prices = [round(float(price), 3) for price in trajectory.values('auction.value')[1:]]
prices, float(trajectory.values('clerk.traded')[-1]), round(float(trajectory.values('clerk.fee')[-1]), 3)
trajectory.values('auction.volume').tolist()
([1.018, 1.231, 1.074, nan, 1.155], 11.0, 0.11)
[nan, 4.0, 4.0, 1.0, 0.0, 2.0]
Phases. DiscreteTime(schedule=PhasesScheduleSpec([...])) lists the phases of a step.
- A
PhaseSpec(name, activation, repeat)runs its agents'random'ly,'sequential'ly in order of creation,'simultaneous'ly, or'by-type', forrepeatrounds, such as intraday rounds. - In a simultaneous phase every rule reads the states of the start of the phase, and the writes apply at its end.
- The activation
'visits'runsvisitstimes as many visits as the agents ofgroupsat the start of the phase. Each visit goes to an agent drawn with replacement, which runs its rules of the phase, its element's rules and then its member rules, at the timek + i / nof visitiofnin stepk. RuleLaw(function, phase=name)puts a rule in a phase. Rules without a phase run in the phase'default', which runs first unless the system lists it. So systems without phases keep one random activation per step.
Markets. MatchingInteraction(label, requesters=..., providers=..., rule=...), from
skcomplex.core.components.interactions, matches two groups.
- Requesters state the highest clearing value they accept in their
limitstate, the quantity they want inquantity, and the most value they can give inallowance. Providers state their lowest clearing value and the quantity they offer. A missing state counts as no limit, one unit, and no allowance. - A name given as a pair, such as
limit=('bid', 'ask'), names the state of the requesters and of the providers. - The rule is a frozen spec of
skcomplex.core.specsthat names the matching algorithm and holds only its settings: RandomSearchSpec(tries, clearing), the default,PartnerSwitchSpec(sample, intensity, partner), partner choice with switching,QueueSpec(order),IterativeMatchingSpec(adjustment, iterations, tolerance), a clearing value adjusted until requests and offers balance,ContinuousMatchingSpec(arrival), where arriving offers match resting ones by limit and arrival,BatchMatchingSpec(), one clearing value for all offers,StableMatchingSpec(requester_preference, provider_preference, clearing), stable matching by thematchinglibrary,PartnerSpec(candidates, choice, fallback, capacity, score, clearing), where requesters in random order pick among the candidates that still offer a quantity at a limit they accept.- The rule may also be a function
rule(requesters, providers, rng, capacity)that returns pairs, which match at the midpoint of both limits. - The matching runs the algorithm of its rule on the offers of every round. Each match is a
MatchSpec, whosevalueis the clearing value per unit. - Every match lowers the quantities of both sides and moves the states of
transfers. With('money', -1)the value times the quantity moves from the requester to the provider, and with('goods', 1)the quantity moves back, so the totals stay the same.on_match(match, system)adds any other effect. - The matching reports
<label>.value, the quantity-weighted clearing value,<label>.volume, and<label>.unmatched.
Agent operations. These interactions of skcomplex.core.components.interactions run as agent rules:
MovementInteractionmoves agents by neighbour, Gaussian, Lévy, best-cell, or user steps, with a condition, an acceptance, exclusion, and a cost.TransferInteractionmoves amounts of states between members, the agents of one cell, aSource(), and aSink(). It runs as transfer laws withform='symbolic', or between paired agents withform='agent'.LifeCycleInteractioncreates offspring by probability or rate, with inherited, split, and mutated states, and removes agents, the first or a random member of a group withcullandpick.system.composition(group, state)counts the agents of a group by the value of a state, kept up to date as agents change, so a birth probability can read the composition of the population.GroupingInteractiondraws pairs, partitions, neighbourhoods, or groups by a state every step, and acts on each.TopologyInteractionadds and removes nodes and edges of a network, and rewires edges towards a weight.
Institutions. A FeedbackInteraction, from skcomplex.core.components.interactions, acts once per round of its
phase with its method act.
- It reads
system.observable(name), the reporters and the observables of the run at the end of earlier steps throughsystem.history(name, window), and the agents. - It sets parameters of any element or interaction with
system.set_parameter(name, value). Rules see the change at once, and the trajectory records it. - A subclass that declares its own parameters takes them as annotated arguments of its
__init__, next toruleandphase. - Elements and interactions declare the values they publish in the class variable
reports, and publish them withsystem.report(name, value).system.storagekeeps private values of an element or an interaction across the steps of a run.
Learning. Agents learn from their own past and from each other.
agent.lags(state, window), a method of the agent view a rule receives, returns the past values of a state declared with lags,StateSpec(lags=m). A rule writes adaptive, trend-following, anchoring, and fundamentalist forecasts from these values.- In
skcomplex.core.components.interactions,ChoiceInteraction(form='agent')draws an option by the logit, Metropolis, Fermi, or best rule, from a utilityutility(agent, system, option)that can readsystem.historyandsystem.storage. ReinforcementInteractionmoves the scores of options towards their realized payoffs, or withchosenonly the score of the option a member chose.- Both run with
schedule='member'in the activation of every member, such as one visit. EvolutionInteractionreplaces strategies by selection, crossover, and mutation, with the system's random stream.ImitationInteraction(rule='best')copies the best neighbour.
Porting Mesa systems
Bring an existing Mesa model over, and gain live, validated, recorded parameters. A coin-flipping Mesa model becomes:
from typing import Annotated
from skcomplex.core.components.elements import Element, GroupElement
from skcomplex.core.components.laws import RuleLaw
from skcomplex.core.components.spaces import DiscreteTime
from skcomplex.core.components.system import System
from skcomplex.core.engine.runtime import Simulation
from skcomplex.core.specs import ParameterSpec, StateSpec
class CoinElement(Element):
def __init__(
self,
label,
*,
heads: Annotated[float, StateSpec()] = 0.0,
bias: Annotated[float, ParameterSpec()] = 0.5,
):
super().__init__(label)
self.heads = heads
self.bias = bias
def _get_laws(self, s):
return [RuleLaw(self.flip)]
def flip(self, agent, system):
agent.heads += float(system.rng.random() < agent.bias)
coins = System('coins', elements=[GroupElement('coins', element=CoinElement, size=10)], time=DiscreteTime())
int(Simulation(coins, random_state=0).prepare().run(until=10).trajectory_.agents('coins', 10).values['heads'].sum())
54
A Mesa model is ported to a system piece by piece:
- its agent class becomes an element whose step is a
RuleLaw, - its agent attributes become states,
- the attributes of its
Modelbecome parameters, which then change live, are validated, and are recorded like those of any system.
Mesa still runs the steps, as the backend of agent-based systems.