Customers choosing between two shops of perishable goods
Customers who choose between two shops of perishable goods, preferring the one that sold fresher goods.
- Name:
perishable_goods_duopoly - Field:
economics/markets - Formalism: agent-based model
- References: Bertin 2021; Bouchaud 2013
Factory
@register_model(
field='economics/markets',
tags=('agents', 'discrete choice', 'symmetry breaking', 'phase transition', 'retail'),
references=('Bertin 2021', 'Bouchaud 2013'),
defaults={'until': 300},
observables=lambda system: [ObservableSpec('m', group='customers', expression=CustomerElement.preference)],
)
def perishable_goods_duopoly(
customers: int = 200,
*,
stock: float = 0.5,
T: float = 0.1,
alpha: float = 0.9,
tau_a: float = 1.0,
random_state: int | np.random.Generator | None = 0,
) -> System:
"""Return customers who choose between two shops of perishable goods, preferring the one that sold fresher goods.
Each shop holds `stock` products per customer, and every sale replaces a product with a fresh one, so the shop
with more customers sells fresher goods and attracts more customers. With the control parameter
`R = 2 stock / tau_a`, the symmetric state `m = p_1 - p_2 = 0` loses its stability below the temperature
`T_c = R / (1 + R)^2`, and one shop takes most customers. The transition is continuous for `R > sqrt(3) - 1` and
discontinuous below. One unit of time is `N` visits by customers drawn with replacement. A visited customer
chooses a shop with the probability `1 / (1 + exp(-(S_1 - S_2) / T))` for the first one, moves to it, buys a random
product of age `tau`, of freshness `h = exp(-tau / tau_a)`, which the shop replaces with a fresh one, and updates
its satisfaction with that shop alone to `alpha S + (1 - alpha) h`. The shops are the two nodes of a network on
which their products stand.
Args:
customers:
The number of customers `N`.
stock:
The number of products of each shop per customer.
T:
The temperature of the choice, the randomness of the customers.
alpha:
The memory of the satisfaction, between 0 and 1.
tau_a:
The time over which the products age.
random_state:
The seed or generator of the initial satisfactions, which scatter by 0.01 around 0.5.
Returns:
system:
The model, with the groups `customers` and `products`, and the asymmetry `m` of the probabilities of
visiting the two shops.
Examples:
>>> from skcomplex.models.economics.markets import perishable_goods_duopoly
>>> perishable_goods_duopoly(20).elements[0].check().prepare().size_
20
"""
rng = np.random.default_rng(random_state)
shelf = max(round(stock * customers), 1)
satisfaction = 0.5 + 0.01 * rng.standard_normal((N_SHOPS, customers))
choice = ChoiceInteraction(
'choice',
target='customers',
state='shop',
options=tuple(float(shop) for shop in range(N_SHOPS)),
utility=CustomerElement.utility,
schedule='member',
form='agent',
phase=PHASE,
)
movement = MovementInteraction(
'movement',
target='customers',
step=CustomerElement.destination,
schedule='member',
phase=PHASE,
)
purchase = TransferInteraction(
'purchase',
source='products',
target='customers',
state='shelved',
into=Sink(),
amount=ProductElement.restock,
legs=[(Source(), 'freshness', ProductElement.sell)],
pairing='cell',
form='agent',
schedule='member',
actor='target',
partners=1,
phase=PHASE,
)
satisfaction_update = ReinforcementInteraction(
'satisfaction',
target='customers',
scores=('s1', 's2'),
payoff=CustomerElement.payoff,
rate=1 - alpha,
chosen='shop',
schedule='member',
form='agent',
phase=PHASE,
)
return System(
elements=[
GroupElement(
'customers',
element=CustomerElement,
size=customers,
values={'s1': satisfaction[0], 's2': satisfaction[1], 'T': T},
),
GroupElement(
'products',
element=ProductElement,
size=N_SHOPS * shelf,
values={'tau_a': tau_a},
location=np.repeat(np.arange(N_SHOPS), shelf).tolist(),
),
],
interactions=[choice, movement, purchase, satisfaction_update],
space=NetworkSpace(nx.empty_graph(N_SHOPS)),
time=DiscreteTime(
schedule=PhasesScheduleSpec([PhaseSpec(PHASE, activation='visits', groups=('customers',))]),
),
)
Components
class CustomerElement(Element):
"""Customer who remembers how satisfied it was with the products of each of two shops.
The customer visits the shop `k` of larger satisfaction `S_k` more often, with the logit utility `S_k / T`, and
holds the freshness of the product it bought last.
Args:
label:
The label of the agent or group.
visual:
How the element is drawn, or `None` for `default_visual_`.
s1:
The satisfaction with the first shop.
s2:
The satisfaction with the second shop.
shop:
The index of the shop the customer visits, 0 or 1.
freshness:
The freshness of the product the customer bought last.
T:
The temperature of the choice, the randomness of the customer.
Examples:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.models.economics.markets import CustomerElement
>>> GroupElement('customers', element=CustomerElement, size=10, values={'s1': 0.5, 's2': 0.5}).size
10
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
s1: Annotated[float, StateSpec()] = 0.5,
s2: Annotated[float, StateSpec()] = 0.5,
shop: Annotated[float, StateSpec(domain=FiniteEnumeratedSet([0, 1]))] = 0.0,
freshness: Annotated[float, StateSpec(domain=RealSet.closed(0, 1))] = 1.0,
T: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 0.1,
) -> None:
super().__init__(label, visual=visual)
self.s1 = s1
self.s2 = s2
self.shop = shop
self.freshness = freshness
self.T = T
@classmethod
def _get_view(cls) -> type[CustomerView]:
"""Return the view of a customer, with its satisfactions, shop, freshness, and temperature."""
return CustomerView
@staticmethod
def utility(agent: CustomerView, model: ModelView, option: float) -> float:
"""Return the utility `S_k / T` of visiting shop `k`.
Args:
agent:
The customer.
model:
The model view of the choice.
option:
The index of the shop.
Returns:
utility:
The satisfaction with the shop divided by the temperature.
"""
return (agent.s1 if option == 0 else agent.s2) / agent.T
@staticmethod
def destination(agent: AgentViewProtocol, model: ModelViewProtocol) -> int | None:
"""Return the node of the shop the customer chose.
Args:
agent:
The customer.
model:
The model view of the movement.
Returns:
node:
The node of the shop, or `None` to stay for an agent that is not a customer.
"""
return int(agent.shop) if isinstance(agent, CustomerView) else None
@staticmethod
def payoff(agent: CustomerView, model: ModelView, option: int) -> float:
"""Return the freshness of the product the customer bought last, the payoff of the shop it visited.
Args:
agent:
The customer.
model:
The model view of the reinforcement.
option:
The index of the shop the customer visited.
Returns:
freshness:
The freshness of the product.
"""
return agent.freshness
@staticmethod
def preference(agent: CustomerView) -> float:
"""Return `p_1 - p_2 = tanh((S_1 - S_2) / 2T)`, how much more likely the customer visits the first shop.
Args:
agent:
The customer.
Returns:
preference:
The difference of the probabilities of visiting the first and the second shop.
"""
return float(np.tanh((agent.s1 - agent.s2) / (2 * agent.T)))
class ProductElement(Element):
"""Product on the shelf of a shop, which ages from the time it was shelved, its freshness `exp(-age / tau_a)`.
Args:
label:
The label of the agent or group.
visual:
How the element is drawn, or `None` for `default_visual_`.
shelved:
The time at which the product was put on the shelf.
tau_a:
The time over which the product ages.
Examples:
>>> from skcomplex.core.elements import GroupElement
>>> from skcomplex.models.economics.markets import ProductElement
>>> GroupElement('products', element=ProductElement, size=4, location=[0, 0, 1, 1]).size
4
"""
def __init__(
self,
label: str,
visual: VisualSpec | None = None,
*,
shelved: Annotated[float, StateSpec()] = 0.0,
tau_a: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
) -> None:
super().__init__(label, visual=visual)
self.shelved = shelved
self.tau_a = tau_a
@classmethod
def _get_view(cls) -> type[ProductView]:
"""Return the view of a product, with its shelving time and ageing time."""
return ProductView
@staticmethod
def restock(product: ProductView, customer: CustomerView, model: ModelView) -> float:
"""Return the amount `shelved - t` that resets the shelving time of a sold product to the time of the sale.
Args:
product:
The product sold.
customer:
The customer who buys it.
model:
The model view of the purchase.
Returns:
amount:
The amount taken from the shelving time.
"""
return product.shelved - model.time
@staticmethod
def sell(product: ProductView, customer: CustomerView, model: ModelView) -> float:
"""Return the amount that sets the freshness the customer holds to `exp(-(t - shelved) / tau_a)`.
Args:
product:
The product sold.
customer:
The customer who buys it.
model:
The model view of the purchase.
Returns:
amount:
The change of the freshness the customer holds.
"""
return float(np.exp(-(model.time - product.shelved) / product.tau_a)) - customer.freshness
class ChoiceInteraction(Interaction):
"""Choice of every member of a group among options, drawn every step from the utilities of the options by a rule.
The rule `'logit'`, which is the heat-bath rule, draws option `k` of utility `u_k` with probability
`exp(beta u_k) / sum_j exp(beta u_j)`, where `beta` is the intensity of choice. The rules `'metropolis'` and
`'fermi'` propose one of the other options uniformly, or with the proposal `'all'` one of all the options
including the current one, and accept it with probability `min(1, exp(beta (u_new - u_old)))` or
`1 / (1 + exp(-beta (u_new - u_old)))`, so the rule `'fermi'` with the proposal `'all'` is the heat-bath rule of a
uniform proposal. The rule `'best'` draws uniformly among the options of largest utility under the ties
`'random'`, and takes the first of them under `'first'`. Every step, each member revises its choice with probability
`activity` and keeps it otherwise.
In the `'symbolic'` form, `utility(namespace, option)` returns the utility as a symbolic expression, where
`namespace.target` holds the states and parameters of the group, and every member draws at once. In the `'agent'`
form, the group is an agent group and `utility(agent, model, option)` returns the utility as a number, so it can
read the agent, its neighbours, and the history and storage of the model view. Its members draw at once, from the
choices of the last step, under the `'synchronous'` schedule, one after the other in their order or in a new random
order, each seeing the choices made before it, under the `'sequential'` or `'random-sequential'` schedule, and each
in its own activation under the `'member'` schedule, together with the rules of its element and the other member
interactions, in the order the system lists them. An option of utility `-inf` is never drawn in the agent form.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the element or group whose members choose.
state:
The name of the discrete state of the members that holds the chosen option.
options:
The values the state can take, at least two.
utility:
The function `utility(namespace, option)` in the symbolic form, or `utility(agent, model, option)` in the
agent form, that returns the utility of an option.
rule:
The rule of the draw, `'logit'`, `'metropolis'`, `'fermi'`, or `'best'`.
intensity:
The intensity of choice `beta`, zero for uniform choice.
schedule:
The order in which the members revise, `'synchronous'` or, in the agent form, `'sequential'`,
`'random-sequential'`, or `'member'`.
activity:
The probability that a member revises its choice in a step.
form:
`'symbolic'` for update laws of the states of a group, or `'agent'` for a rule over an agent group.
phase:
The phase of the step the agent form runs in, or `None` for the default phase.
proposal:
The options the rules `'metropolis'` and `'fermi'` propose from, `'others'` for the options other than the
current one, or `'all'` for every option.
ties:
The option the rule `'best'` takes among options of equal largest utility, `'random'` for one drawn
uniformly or `'first'` for the first in the order of the options.
Raises:
ValidationError:
The rule, the schedule, the form, the proposal, or the ties are unknown, the schedule is not synchronous in
the symbolic form, there are fewer than two options, or the utility is not callable.
Examples:
>>> from skcomplex.core.interactions import ChoiceInteraction
>>> from skcomplex.core.laws.utils import mean
>>> choice = ChoiceInteraction(
... 'choice', target='spins', state='spin', options=(-1, 1), utility=lambda s, o: o * mean(s.target.spin)
... )
>>> choice.check().rule
'logit'
>>> agents = ChoiceInteraction(
... 'choice', target='traders', state='plan', options=(0, 1), utility=lambda a, m, o: o, form='agent'
... )
>>> type(agents.check().prepare().run(SimpleNamespace()).laws_[0]).__name__
'RuleLaw'
>>> ChoiceInteraction(
... 'heat-bath', target='spins', state='spin', options=(-1, 0, 1), utility=lambda s, o: o, rule='fermi',
... proposal='all'
... ).check().proposal
'all'
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
state: str,
options: Sequence[float],
utility: Callable[..., float | Expression],
rule: str = 'logit',
intensity: Annotated[float, ParameterSpec(domain=RealSet.closed_open(0, oo))] = 1.0,
schedule: str = 'synchronous',
activity: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 1.0,
form: str = 'symbolic',
phase: str | None = None,
proposal: str = 'others',
ties: str = 'random',
) -> None:
super().__init__(label)
self.target = target
self.state = state
self.options = options
self.utility = utility
self.rule = rule
self.intensity = intensity
self.schedule = schedule
self.activity = activity
self.form = form
self.phase = phase
self.proposal = proposal
self.ties = ties
def _check_variants(self) -> None:
"""Raise an error for an unknown variant, or a symbolic choice that is not synchronous."""
self._check_variant('rule', self.rule, RULES, (BaseSpec,))
for name, value, variants in (
('schedule', self.schedule, SCHEDULES),
('form', self.form, FORMS),
('proposal', self.proposal, PROPOSALS),
('ties', self.ties, TIES),
):
if value not in variants:
error_msg = f'Unknown {name} {value!r} of {self.label!r}. Use one of {variants}.'
raise ValidationError(error_msg)
if self.form == 'symbolic' and self.schedule != 'synchronous':
error_msg = (
f'{self.label!r} has the schedule {self.schedule!r}, which only the agent form runs. Use '
"form='agent' or schedule='synchronous'."
)
raise ValidationError(error_msg)
def _check(self) -> None:
"""Raise an error for an unknown variant, fewer than two options, or a utility that cannot be called.
Raises:
ValidationError:
The type declares states, the rule, the schedule, the form, the proposal, or the ties are unknown, the
schedule is not synchronous in the symbolic form, there are fewer than two options, or the utility is
not callable.
"""
super()._check()
self._check_variants()
if len(self.options) < MIN_OPTIONS:
error_msg = f'{self.label!r} chooses among {list(self.options)}. Give at least {MIN_OPTIONS} options.'
raise ValidationError(error_msg)
if not callable(self.utility):
error_msg = f'The utility of {self.label!r} must be callable, but it is {self.utility!r}.'
raise ValidationError(error_msg)
def _select(self, index: Expression, values: Sequence[Expression]) -> Expression:
"""Return the value at a symbolic index."""
return sum((where(index == position, value, 0) for position, value in enumerate(values)), SR(0))
def _draw_softmax(self, state: Expression, utilities: Sequence[Expression], intensity: Expression) -> Expression:
"""Return the option drawn with probabilities proportional to `exp(beta u_k)`."""
index = categorical(f'{state}_option', [exp(intensity * utility) for utility in utilities])
return self._select(index, [SR(option) for option in self.options])
def _draw_best(self, state: Expression, utilities: Sequence[Expression]) -> Expression:
"""Return an option drawn uniformly among the options of largest utility."""
largest = utilities[0]
for utility in utilities[1:]:
largest = where(utility > largest, utility, largest)
weights = [where(utility >= largest, 1, 0) for utility in utilities]
if self.ties == 'first':
weights = [
weight * where(sum(weights[:position], SR(0)) > 0, 0, 1) for position, weight in enumerate(weights)
]
index = categorical(f'{state}_best', weights)
return self._select(index, [SR(option) for option in self.options])
def _draw_proposal(self, state: Expression, utilities: Sequence[Expression], intensity: Expression) -> Expression:
"""Return a uniform proposal of an option, accepted by the Metropolis or the Fermi probability."""
weights = [SR(1) if self.proposal == 'all' else where(state == option, 0, 1) for option in self.options]
index = categorical(f'{state}_proposal', weights)
proposal = self._select(index, [SR(option) for option in self.options])
gain = self._select(index, utilities) - sum(
(where(state == option, utility, 0) for option, utility in zip(self.options, utilities, strict=True)),
SR(0),
)
if self.rule == 'metropolis':
acceptance = where(gain >= 0, 1, exp(intensity * gain))
else:
acceptance = 1 / (1 + exp(-intensity * gain))
return where(uniform(f'{state}_acceptance') < acceptance, proposal, state)
def _draw_symbolic(self, namespace: SimpleNamespace) -> Expression:
"""Return the option a member draws, as a symbolic expression of the namespace."""
state = getattr(self._get_member(namespace, 'target'), self.state)
utilities = [SR(self.utility(namespace, option)) for option in self.options]
if self.rule == 'logit':
return self._draw_softmax(state, utilities, self._get_parameter_symbol('intensity', namespace))
if self.rule == 'best':
return self._draw_best(state, utilities)
if self.rule in ('metropolis', 'fermi'):
return self._draw_proposal(state, utilities, self._get_parameter_symbol('intensity', namespace))
raise self._get_variant_error(self.rule, 'rule', '_draw_symbolic')
def _get_utilities(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> np.ndarray:
"""Return the utility of every option for an agent."""
return np.array([float(self.utility(agent, model, option)) for option in self.options])
def _draw_agent_proposal(self, agent: AgentViewProtocol, model: ModelViewProtocol, intensity: float) -> float:
"""Return a uniform proposal of an option of an agent if accepted, or its current option."""
current = float(getattr(agent, self.state))
others = [option for option in self.options if self.proposal == 'all' or option != current]
proposal = float(others[int(model.rng.integers(len(others)))])
before = float(self.utility(agent, model, current)) if current in self.options else 0.0
gain = float(self.utility(agent, model, proposal)) - before
if self.rule == 'metropolis':
acceptance = 1.0 if gain >= 0 else math.exp(intensity * gain)
else:
acceptance = float(expit(intensity * gain))
return proposal if model.rng.random() < acceptance else current
def _draw_agent(self, agent: AgentViewProtocol, model: ModelViewProtocol, intensity: float) -> float:
"""Return the option an agent draws by the rule."""
if self.rule in ('metropolis', 'fermi'):
return self._draw_agent_proposal(agent, model, intensity)
utilities = self._get_utilities(agent, model)
if self.rule == 'best':
return float(self.options[self._pick_best(utilities, self.ties, model)])
if self.rule != 'logit':
raise self._get_variant_error(self.rule, 'rule', '_draw_agent')
scaled = np.where(np.isneginf(utilities), -np.inf, intensity * utilities)
probabilities = softmax(scaled)
return float(self.options[int(model.rng.choice(len(self.options), p=probabilities))])
def _is_active(self, model: ModelViewProtocol, activity: float) -> bool:
"""Return whether a member revises its choice in this step."""
return activity >= 1 or bool(model.rng.random() < activity)
def _get_choice(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> float | None:
"""Return the option an agent draws if it revises in this step, or `None` to keep its own."""
if not self._is_active(model, self._get_parameter_value('activity', model)):
return None
return self._draw_agent(agent, model, self._get_parameter_value('intensity', model))
def _set_choice(self, agent: AgentViewProtocol, option: float | None) -> None:
"""Set the state of an agent to the option it draws, if any."""
if option is not None:
setattr(agent, self.state, option)
def _choose_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Set the state of one agent to the option it draws if it revises, in its own activation."""
if agent.alive:
self._set_choice(agent, self._get_choice(agent, model))
def choose(self, model: ModelViewProtocol) -> None:
"""Set the state of every agent that revises in this step to the option it draws, by the schedule.
Args:
model:
The model view of the interaction.
"""
self._update_agents(model, self.schedule, self._get_choice, self._set_choice)
def _get_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the update of the state of every member that revises, or the rule of the agent form.
Args:
namespace:
The namespace of the symbols the laws can use, with `namespace.target`, `namespace.intensity`, and
`namespace.activity`.
Returns:
laws:
The update law of the state, or the rule that runs the choice of the agents.
"""
if self.form == 'agent' and self.schedule == 'member':
return [RuleLaw(self._choose_member, phase=self.phase, members=self._get_role('target'))]
if self.form == 'agent':
return [RuleLaw(self.choose, phase=self.phase)]
state = getattr(self._get_member(namespace, 'target'), self.state)
revised = where(
uniform(f'{state}_activity') < self._get_parameter_symbol('activity', namespace),
self._draw_symbolic(namespace),
state,
)
return [UpdateLaw(state, revised)]
class MovementInteraction(Interaction):
"""Movement of the agents of a group by steps, every step of discrete time.
A step is one of five kinds:
- `'neighbor'` moves an agent to a uniformly drawn cell or node next to its own, on a lattice or a network.
- `'gaussian'` adds to the position of an agent a normal vector whose components have the standard deviation
`scale`, in a continuous space.
- `'levy'` moves an agent in a uniformly drawn direction by a Pareto length, whose least value is `scale` and whose
tail exponent is `exponent`, in a continuous space.
- `'best'` moves an agent to the cell or node next to its own with the highest `score(agent, cell, model)`. With a
`vision`, a number or a function of the agent and the model view, it looks instead along every direction of the
neighbourhood of a lattice up to that many cells, and at the cell of the agent, in the order of the axes, the
positive direction first. Among cells of equal highest score, the ties `'random'` draw one uniformly, `'first'`
take the first in that order, and `'nearest'` take the nearest, the first of them among equal distances. By
default the ties are `'random'` without a vision and `'nearest'` with one.
- A function `step(agent, model)` returns the destination, or `None` to stay.
An agent for which `condition(agent, model)` holds attempts one step every step of time, or a Poisson number of
steps with the mean `rate`, and takes each attempt with the probability `probability` and then with the probability
`accept(agent, destination, model)`. An exclusive movement never moves an agent to a cell another agent of the group
holds. The agents move one after the other, in their order under the schedule `'sequential'`, in a new random order
every step under `'random-sequential'`, or each in its own activation under `'member'`, together with the rules of
its element and the other member interactions, in the order the system lists them. Every step taken charges its
`cost`, a `TransferInteraction` from the agent to itself or a function of the agent and the model view. The
movement is an agent rule, so the system runs in discrete time.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the group whose agents move.
step:
The kind of step, `'neighbor'`, `'gaussian'`, `'levy'`, or `'best'`, or a function of the agent and the
model view that returns the destination.
probability:
The probability that an agent takes an attempted step.
rate:
The mean number of attempted steps of an agent in one unit of time, or `None` for one attempt every step.
condition:
A function of the agent and the model view that returns whether the agent moves, or `None` for every agent.
accept:
A function of the agent, its destination, and the model view that returns the probability of taking the
step, or `None` to take every step.
exclusive:
Whether an agent moves only to cells or nodes no other agent of the group holds.
score:
The function of the agent, a neighbouring cell, and the model view that the step `'best'` maximizes.
scale:
The standard deviation of every component of a Gaussian step, or the least length of a Lévy step.
exponent:
The tail exponent of the length of a Lévy step.
cost:
A `TransferInteraction` that the agent pays to itself or to the environment for every step taken, a function
of the agent and the model view run for every step taken, or `None`.
vision:
The number of cells the step `'best'` looks along every direction of a lattice, a number or a function of
the agent and the model view, or `None` for the cells next to the agent.
schedule:
The order of the agents, `'sequential'`, `'random-sequential'`, or `'member'`.
phase:
The phase of the step the movement runs in, or `None` for the default phase.
ties:
The cell the step `'best'` takes among cells of equal highest score, `'random'`, `'first'`, or `'nearest'`,
or `None` for `'random'` without a vision and `'nearest'` with one.
Raises:
ValidationError:
The step, the schedule, or the ties are unknown, the rate is not positive, a function is not callable, or
the step `'best'` has no score, raised by `check`.
Examples:
>>> from skcomplex.core.elements import Element, GroupElement
>>> from skcomplex.core.interactions import MovementInteraction
>>> from skcomplex.core.spaces import LatticeSpace
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> walkers = GroupElement('walkers', element=Element, size=2, location=[(2, 2), (2, 2)])
>>> walk = MovementInteraction('walk', target='walkers', step='neighbor')
>>> space = LatticeSpace((5, 5), neighborhood='von_neumann')
>>> system = System(elements=[walkers], interactions=[walk], space=space)
>>> trajectory = Simulation(system, random_state=0).check().prepare().run(until=1).trajectory_
>>> [bool(abs(cell - 2).sum() == 1) for cell in trajectory.agents('walkers', 1).locations]
[True, True]
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
step: Step = 'neighbor',
probability: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 1.0,
rate: float | None = None,
condition: Callable[[AgentViewProtocol, ModelViewProtocol], bool] | None = None,
accept: Callable[[AgentViewProtocol, object, ModelViewProtocol], float] | None = None,
exclusive: bool = False,
score: Callable[[AgentViewProtocol, object, ModelViewProtocol], float] | None = None,
scale: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.0,
exponent: Annotated[float, ParameterSpec(domain=RealSet.open(0, oo))] = 1.5,
cost: Cost | None = None,
vision: Vision | None = None,
schedule: str = 'random-sequential',
phase: str | None = None,
ties: str | None = None,
) -> None:
super().__init__(label)
self.target = target
self.step = step
self.probability = probability
self.rate = rate
self.condition = condition
self.accept = accept
self.exclusive = exclusive
self.score = score
self.scale = scale
self.exponent = exponent
self.cost = cost
self.vision = vision
self.schedule = schedule
self.phase = phase
self.ties = ties
def _check_functions(self) -> None:
"""Raise an error for a condition, an acceptance, a score, or a cost that cannot be called."""
for name in ('condition', 'accept', 'score'):
value = getattr(self, name)
if value is not None and not callable(value):
error_msg = f'The {name} of {self.label!r} is {value!r}. Give a function, or None.'
raise ValidationError(error_msg)
if isinstance(self.cost, TransferInteraction):
self.cost.check()
elif self.cost is not None and not callable(self.cost):
error_msg = f'The cost of {self.label!r} is {self.cost!r}. Give a TransferInteraction, a function, or None.'
raise ValidationError(error_msg)
def _check_space(self, space: BaseSpace | None) -> None:
"""Raise an error for a step to a neighbouring cell or node in a continuous space."""
if isinstance(self.step, str) and self.step in GRID_STEPS:
self._check_grid('step', self.step, space, "step='gaussian' or step='levy', or a step function")
def _check(self) -> None:
"""Raise an error for an unknown step, a rate that is not positive, or a function that cannot be called.
Raises:
ValidationError:
The step, the schedule, or the ties are unknown, the rate is not positive, a function is not callable,
or the step `'best'` has no score.
"""
super()._check()
self._check_variant('step', self.step, STEPS, functions=True)
if self.rate is not None and (isinstance(self.rate, bool) or not self.rate > 0):
error_msg = f'The rate of {self.label!r} is {self.rate!r}. Give a positive number, or None.'
raise ValidationError(error_msg)
if self.schedule not in SCHEDULES:
error_msg = f'Unknown schedule {self.schedule!r} of {self.label!r}. Use one of {", ".join(SCHEDULES)}.'
raise ValidationError(error_msg)
if self.ties is not None:
self._check_variant('ties', self.ties, TIES)
if self.vision is not None and not callable(self.vision) and not isinstance(self.vision, int | float):
error_msg = f'The vision of {self.label!r} is {self.vision!r}. Give a number, a function, or None.'
raise ValidationError(error_msg)
if self.step == 'best' and self.score is None:
error_msg = f'The step `best` of {self.label!r} maximizes a score. Pass `score(agent, cell, model)`.'
raise ValidationError(error_msg)
self._check_functions()
def _count_attempts(self, model: ModelViewProtocol) -> int:
"""Return the number of steps an agent attempts in one step of time."""
return 1 if self.rate is None else int(model.rng.poisson(self.rate))
def _get_cells(self, agent: AgentViewProtocol, occupied: set[object]) -> list[object]:
"""Return the cells or nodes around an agent that it may move to."""
cells = agent.neighbor_cells()
if not self.exclusive:
return cells
return [cell for cell in cells if cell not in occupied]
def _get_best(
self,
agent: AgentViewProtocol,
cells: list[object],
model: ModelViewProtocol,
distances: list[int] | None = None,
) -> object:
"""Return the cell of the highest score, chosen among ties by the tie rule."""
if self.score is None or not cells:
return None
scores = [self.score(agent, cell, model) for cell in cells]
ties = self.ties or ('random' if distances is None else 'nearest')
if ties != 'nearest':
return cells[self._pick_best(scores, ties, model)]
values = np.asarray(scores, dtype=float)
best = np.flatnonzero(values == values.max())
near = np.asarray([1 if distances is None else distances[int(index)] for index in best])
return cells[int(best[near == near.min()][0])]
@staticmethod
def _shift(here: tuple[int, ...], cell: tuple[int, ...], model: ModelViewProtocol) -> tuple[int, ...]:
"""Return the unit step from a cell to a neighbouring cell, across the wrapped edge of a torus."""
shift = [b - a for a, b in zip(here, cell, strict=True)]
if model.torus:
sizes = [int(item) for item in model.dimensions or ()]
shift = [d - round(d / n) * n for d, n in zip(shift, sizes, strict=True)]
return tuple(shift)
def _get_sight(
self,
agent: AgentViewProtocol,
occupied: set[object],
model: ModelViewProtocol,
) -> tuple[list[object], list[int]]:
"""Return the cells in sight of an agent along every direction of a lattice, its own first, with distances."""
vision = self.vision(agent, model) if callable(self.vision) else self.vision
reach = int(vision or 0)
here = tuple(int(item) for item in np.atleast_1d(np.asarray(agent.location_key)))
sizes = [int(item) for item in model.dimensions or ()]
cells: list[object] = [agent.location_key]
distances = [0]
shifts = [
self._shift(here, tuple(int(item) for item in np.atleast_1d(np.asarray(neighbor))), model)
for neighbor in agent.neighbor_cells()
]
for shift in sorted(shifts, key=lambda step: tuple((item == 0, -item) for item in step)):
for distance in range(1, reach + 1):
point = [a + distance * d for a, d in zip(here, shift, strict=True)]
if model.torus:
point = [item % size for item, size in zip(point, sizes, strict=True)]
elif any(not 0 <= item < size for item, size in zip(point, sizes, strict=True)):
break
cell = tuple(point)
if not self.exclusive or cell not in occupied:
cells.append(cell)
distances.append(distance)
return cells, distances
def _get_destination(self, agent: AgentViewProtocol, model: ModelViewProtocol, occupied: set[object]) -> object:
"""Return the location a step of an agent leads to, or `None` to stay."""
rng = model.rng
if callable(self.step):
return self.step(agent, model)
if self.step == 'best' and self.vision is not None:
cells, distances = self._get_sight(agent, occupied, model)
return self._get_best(agent, cells, model, distances)
if self.step in ('neighbor', 'best'):
cells = self._get_cells(agent, occupied)
if self.step == 'best':
return self._get_best(agent, cells, model)
return cells[int(rng.integers(len(cells)))] if cells else None
position = np.asarray(agent.location, dtype=float)
if self.step == 'gaussian':
return position + rng.normal(0.0, self._get_parameter_value('scale', model), position.shape)
direction = rng.normal(size=position.shape)
length = self._get_parameter_value('scale', model) * (
1.0 + rng.pareto(self._get_parameter_value('exponent', model))
)
return position + length * direction / np.linalg.norm(direction)
def _charge(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Charge the cost of one step taken to an agent."""
if isinstance(self.cost, TransferInteraction):
self.cost.transfer(agent, agent, model)
elif self.cost is not None:
self.cost(agent, model)
def _take(self, agent: AgentViewProtocol, destination: object, occupied: set[object]) -> bool:
"""Move an agent to a destination unless an exclusive movement finds it held, and return whether it moved."""
if not self.exclusive:
agent.move_to(destination)
return True
if destination == agent.location_key:
return True
if destination in occupied:
return False
occupied.discard(agent.location_key)
agent.move_to(destination)
occupied.add(agent.location_key)
return True
def _move(
self,
agent: AgentViewProtocol,
model: ModelViewProtocol,
probability: float,
occupied: set[object],
) -> None:
"""Take the attempted steps of one agent, each with its probability and acceptance, charging every step."""
for _ in range(self._count_attempts(model)):
if probability < 1.0 and model.rng.random() >= probability:
continue
destination = self._get_destination(agent, model, occupied)
if destination is None:
continue
if self.accept is not None and model.rng.random() >= self.accept(agent, destination, model):
continue
if self._take(agent, destination, occupied):
self._charge(agent, model)
def _move_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Move one agent whose condition holds, in its own activation."""
if agent.alive and (self.condition is None or self.condition(agent, model)):
occupied = (
{other.location_key for other in model.agents(self._get_role('target'))} if self.exclusive else set()
)
self._move(agent, model, self._get_parameter_value('probability', model), occupied)
def _move_all(self, model: ModelViewProtocol) -> None:
"""Move every agent of the group whose condition holds, in their order or in random order."""
agents = model.agents(self._get_role('target'))
occupied = {agent.location_key for agent in agents} if self.exclusive else set()
probability = self._get_parameter_value('probability', model)
order = model.rng.permutation(len(agents)) if self.schedule == 'random-sequential' else range(len(agents))
for index in order:
agent = agents[int(index)]
if agent.alive and (self.condition is None or self.condition(agent, model)):
self._move(agent, model, probability, occupied)
def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
"""Return the rule of the movement.
Args:
namespace:
The namespace of the symbols the laws can use.
Returns:
laws:
The rule that moves the agents every step.
"""
if self.schedule == 'member':
return [RuleLaw(self._move_member, phase=self.phase, members=self._get_role('target'))]
return [RuleLaw(self._move_all, phase=self.phase)]
class TransferInteraction(Interaction):
"""Transfer of amounts of states from the members of a source role to the members of a target role.
A leg moves an amount out of a state of the source member, or out of `Source()`, into a state of the target member,
or into `Sink()`. The transfer has a first leg, `state` into `into` by `amount`, and any further `legs`, each
`(state, into, amount)` or `(state, into, amount, flow)`. The amounts of all legs are read before any of them
moves, so a leg sees the states as they were before the transfer. A guard skips the transfer where it does not
hold.
The symbolic form reports the amount of every leg as a flow. A leg named by its `flow` is reported under that
name: under the element or group, as `<path>.<flow>`, when the source and the target are one element or group,
since the flow then moves a quantity within it like a flow of its own, and under the interaction, as
`<label>.<flow>`, otherwise, as it is for a subclass that names its roles otherwise. A leg without a name is
reported under the interaction as `<label>.flow_<index>`.
The form chooses how the transfer lowers:
- `'symbolic'` lowers to `TransferLaw`s between the source and the target of one element, or member `i` of both
groups. The amounts, the guard, and the rates are numbers or functions of the namespace, and the roles are read
as `namespace.source` and `namespace.target`. With a `rate`, the amount is the size of a jump at the rate, and
with a `reverse_rate` jumps also move the amounts back from the target to the source at that rate, so a leg from
`Source()` returns its amount to the environment. With an `offset`, the source is a group and every member
transfers along an edge of the group order, to the member `offset` places along it, which `namespace.target`
reads. Past the ends of a `'fixed'` boundary no member is there, and the edge moves nothing. A draw in an amount
is one draw per edge, which both of its ends read, so the amounts of a step move in parallel. A leg end
`'<role>.<state>'` names the state of the element or group another role binds, so one transfer moves amounts
between several holders, such as the payments of a circuit of sectors, and a subclass that declares parameters
reads them in its amounts. A subclass that names its roles otherwise returns their names from `_get_role`, or
the namespaces of the source and the target of every pair from `_get_holders`.
- `'agent'` lowers to an agent rule, whose amounts and guard are numbers or functions of the source agent, the
target agent, and the model view. The pairing `'members'` pairs member `i` of both groups, `'random'` pairs every
source member with a random target member, `'pairs'` draws disjoint random pairs, `'neighbors'` pairs every
source member with the target members in the cells or nodes around it, `'neighbor-pairs'` draws disjoint random
pairs of neighbours, every source member in random order taking a random unpaired target member around it, and
`'cell'` pairs the source and target members that stand in one cell or node. With a `fallback` group, the members
of that group are the targets of a step in which no target member lives. With the pairing `'site'` the target
role binds a field, and every source member moves its amounts into the site of the field at its cell or node, so
a negative amount takes from the site. The functions of this pairing take the cell of the source agent in place
of a target agent. With `remove`, the source or the target member of every pair that transfers is removed, as an
agent eaten whole. The named pairings other than `'members'` and `'site'` are shorthands of a `PartnerSpec`, which
pairs every source member with partners within a radius, by score, or with a fallback. A `settlement` receives
both partners and the amounts of the legs, read once, and changes the states in place of the legs, so one draw
credits both ends. A subclass that names its roles otherwise returns their names from `_get_role`.
Under the schedule `'random-sequential'` the pairs act in random order. Under `'member'` every member of the
`actor` role acts in its own activation, together with the rules of its element and the other member
interactions, in the order the system lists them: it transfers with its partners, the members of the other role
that the pairing `'random'`, `'neighbors'`, or `'cell'` gives it, or with `partners` of them drawn at random, and
with itself under the pairing `'members'` within one group.
A leg end of the agent form may name `'<reference>.<state>'`, the state of the member that a reference state of
the agent names, such as `'bank.deposits'` for the deposits of a customer's bank, declared
`StateSpec(reference='banks')`.
Args:
label:
The label of the interaction, unique among the parts of its system.
source:
The path of the element or group the amounts leave.
target:
The path of the element or group the amounts enter, the source again for a transfer within members.
state:
The name of the state of the source the first leg takes from, or `Source()` for the environment.
into:
The name of the state of the target the first leg adds to, `Sink()` for the environment, or `None` for
`state`.
amount:
The amount of the first leg, a number or a function.
flow:
The name the symbolic form reports the first leg under, or the empty string for `flow_0`.
pairing:
How members are paired, one of `'members'`, `'random'`, `'pairs'`, `'neighbors'`, `'neighbor-pairs'`,
`'cell'`, or `'site'`, the agent form alone taking the others than `'members'`, or, in the agent form, a
`PartnerSpec`, or a function `pairing(sources, targets, rng, capacity)` of the source and target agents
that returns their pairs, whose capacity is `partners`, or 1 without it. Under the schedule `'member'` the
spec or the function receives the actor alone as its first sequence and the actor's partners are the second
items of its pairs.
legs:
Further legs, each a tuple of a state or `Source()`, a state, `Sink()`, or `None`, an amount, and
optionally the name of its flow.
fallback:
The path of the group whose members the agent form pairs with instead of the target members in a step in
which no target member lives, or `None` for no fallback.
guard:
A function that returns whether a pair transfers, a condition of the namespace or a boolean of the agents,
or `None` for every pair.
rate:
The rate of jumps of the symbolic form, a number or a function of the namespace, or `None` for a
continuous flow.
reverse_rate:
The rate of the jumps of the symbolic form that move the amounts back from the target to the source, a
number or a function of the namespace, or `None` for no reverse jumps.
offset:
The number of places along the group order from every source member to its target member, or 0 to pair
the members of the roles.
boundary:
Past the ends of the group order, `'periodic'` closes a ring and `'fixed'` holds no member.
form:
The lowering, `'symbolic'` to transfer laws or `'agent'` to an agent rule.
schedule:
The order of the agent form, `'random-sequential'` over the pairs, or `'member'` over the actors.
actor:
The role whose members act under the schedule `'member'`, `'source'` or `'target'`.
partners:
The number of partners an actor draws at random under the schedule `'member'`, or `None` for all of them.
remove:
The member of a pair that transfers that is removed, `'source'`, `'target'`, or `None`.
phase:
The phase the rule of the agent form runs in, or `None` for the default phase.
settlement:
A function `settlement(source, target, amounts, model)` that settles a pair of agents from the amounts of
the legs, read once, in place of moving them, or `None` to move them.
Raises:
ValidationError:
The form, the pairing, the schedule, the actor, the removal, or the boundary is unknown, a leg is
malformed, two legs share a flow name, the symbolic form has an agent pairing, a fallback, or a settlement,
the agent form a rate, an offset, or a flow name, a reverse rate has no rate, an offset is not a whole
number or pairs two groups, a leg end of the symbolic form names no role, the settlement cannot be called
or pairs with a site, or the schedule `'member'` has a pairing it cannot draw, raised by `check`.
Examples:
>>> from skcomplex.core.elements import Element
>>> from skcomplex.core.interactions import TransferInteraction
>>> from skcomplex.core.system import System
>>> from skcomplex.engine.runtime import Simulation
>>> from skcomplex.specs import StateSpec
>>> from typing import Annotated
>>> class TankElement(Element):
... def __init__(self, label, visual=None, *, level: Annotated[float, StateSpec()] = 0.0):
... super().__init__(label, visual=visual)
... self.level = level
>>> flow = TransferInteraction(
... 'flow', source='upper', target='lower', state='level', amount=lambda s: s.source.level / 2
... )
>>> system = System(elements=[TankElement('upper', level=4.0), TankElement('lower')], interactions=[flow])
>>> trajectory = Simulation(system).check().prepare().run(until=1.0).trajectory_
>>> round(float(trajectory.values('upper.level')[-1] + trajectory.values('lower.level')[-1]), 6)
4.0
"""
def __init__(
self,
label: str,
*,
source: Annotated[str, RoleSpec()],
target: Annotated[str, RoleSpec()],
state: str | Source,
into: str | Sink | None = None,
amount: Amount,
flow: str = '',
pairing: str | Pairing = 'members',
legs: Sequence[Leg] = (),
fallback: Annotated[str | None, RoleSpec()] = None,
guard: Callable[..., object] | None = None,
rate: Amount | None = None,
reverse_rate: Amount | None = None,
offset: int = 0,
boundary: str = 'periodic',
form: str = 'symbolic',
schedule: str = 'random-sequential',
actor: str = 'source',
partners: int | None = None,
remove: str | None = None,
phase: str | None = None,
settlement: Settlement | None = None,
) -> None:
super().__init__(label)
self.source = source
self.target = target
self.state = state
self.into = into
self.amount = amount
self.flow = flow
self.pairing = pairing
self.legs = legs
self.fallback = fallback
self.guard = guard
self.rate = rate
self.reverse_rate = reverse_rate
self.offset = offset
self.boundary = boundary
self.form = form
self.schedule = schedule
self.actor = actor
self.partners = partners
self.remove = remove
self.phase = phase
self.settlement = settlement
def _get_legs(self) -> list[NamedLeg]:
"""Return every leg with the state it adds to resolved and the name of its flow, empty when it has none."""
legs: list[NamedLeg] = []
for state, into, amount, *flow in ((self.state, self.into, self.amount, self.flow), *self.legs):
name = flow[0] if flow else ''
legs.append((state, state if into is None and isinstance(state, str) else into, amount, name))
return legs
def _check_leg(self, state: object, into: object, amount: object, flow: object) -> None:
"""Raise an error for a leg with unusable ends, amount, or flow name, or a symbolic end that names no role."""
if not isinstance(state, str | Source) or not isinstance(into, str | Sink):
error_msg = (
f'A leg of {self.label!r} moves {state!r} into {into!r}. Give a state name or `Source()` it leaves, '
'and a state name or `Sink()` it enters.'
)
raise ValidationError(error_msg)
if isinstance(state, Source) and isinstance(into, Sink):
error_msg = f'A leg of {self.label!r} moves from `Source()` into `Sink()`. Name a state of one end.'
raise ValidationError(error_msg)
if not callable(amount) and not isinstance(amount, int | float):
error_msg = f'The amount {amount!r} of {self.label!r} is neither a number nor a function.'
raise ValidationError(error_msg)
if not isinstance(flow, str):
error_msg = f'A leg of {self.label!r} names its flow {flow!r}. Give a string, or the empty string.'
raise ValidationError(error_msg)
roles = [name for name, spec in self._get_specs().items() if isinstance(spec, RoleSpec)]
referenced = [end for end in (state, into) if isinstance(end, str) and '.' in end]
unknown = [end for end in referenced if end.partition('.')[0] not in roles]
if unknown and self.form == 'symbolic':
error_msg = (
f'{self.label!r} moves {unknown[0]!r}, but its symbolic form names the states of roles, one of '
f'{", ".join(roles)}. Name a role, or use `form="agent"` for a referenced member.'
)
raise ValidationError(error_msg)
def _check_flows(self) -> None:
"""Raise an error for flow names that two legs share, or that the agent form, which reports no flows, takes."""
names = [leg[3] for leg in self._get_legs() if leg[3]]
if len(set(names)) < len(names):
error_msg = f'Two legs of {self.label!r} share a flow name among {names!r}. Give every flow its own name.'
raise ValidationError(error_msg)
if names and self.form == 'agent':
error_msg = f'The agent form of {self.label!r} reports no flows, but it names {names!r}. Remove the names.'
raise ValidationError(error_msg)
def _check_space(self, space: BaseSpace | None) -> None:
"""Raise an error for a pairing of cells, nodes, or sites in a continuous space."""
if isinstance(self.pairing, str) and self.pairing in GRID_PAIRINGS:
alternative = "a radius pairing, `PartnerSpec(candidates=<radius>)`, or pairing='random'"
self._check_grid('pairing', self.pairing, space, alternative)
self._check_partners(self.pairing, space)
def _check(self) -> None:
"""Raise an error for an unknown form or pairing, a malformed leg, or an option the form does not take.
Raises:
ValidationError:
The form, the pairing, the schedule, the actor, the removal, or the boundary is unknown, a leg is
malformed, two legs share a flow name, the symbolic form has an agent pairing, a fallback, or a leg end
that names no role, the agent form has a rate, an offset, or a flow name, a reverse rate has no rate,
an offset is not a whole number or pairs two groups, or the schedule `'member'` has a pairing it
cannot draw.
"""
super()._check()
if self.form not in FORMS:
error_msg = f'Unknown form {self.form!r} of {self.label!r}. Use one of {", ".join(FORMS)}.'
raise ValidationError(error_msg)
if self.form == 'symbolic' and self.pairing != 'members':
error_msg = f'The symbolic form of {self.label!r} pairs members. Use `form="agent"` for {self.pairing!r}.'
raise ValidationError(error_msg)
self._check_variant('pairing', self.pairing, PAIRINGS, (PartnerSpec,), functions=True)
if isinstance(self.pairing, PartnerSpec):
self.pairing.check()
self._check_settlement()
if self.form == 'symbolic' and self.fallback is not None:
error_msg = f'The symbolic form of {self.label!r} pairs members. Use `form="agent"` for a fallback group.'
raise ValidationError(error_msg)
for leg in self.legs:
if not isinstance(leg, tuple) or len(leg) not in LEG_LENGTHS:
error_msg = f'A leg of {self.label!r} is {leg!r}. Give a tuple `(state, into, amount[, flow])`.'
raise ValidationError(error_msg)
for state, into, amount, flow in self._get_legs():
self._check_leg(state, into, amount, flow)
self._check_flows()
if self.rate is not None and self.form == 'agent':
error_msg = f'{self.label!r} has a rate, which only the symbolic form takes. Give `rate=None`.'
raise ValidationError(error_msg)
if self.guard is not None and not callable(self.guard):
error_msg = f'The guard of {self.label!r} is {self.guard!r}. Give a function, or None.'
raise ValidationError(error_msg)
self._check_edges()
self._check_schedule()
def _check_settlement(self) -> None:
"""Raise an error for a settlement that cannot be called, or that the form or the pairing does not take."""
if self.settlement is None:
return
if not callable(self.settlement):
error_msg = f'The settlement of {self.label!r} is {self.settlement!r}. Give a function, or None.'
raise ValidationError(error_msg)
if self.form != 'agent' or self.pairing == 'site':
error_msg = f'{self.label!r} settles a pair of agents. Use `form="agent"` and a pairing other than `site`.'
raise ValidationError(error_msg)
def _check_edges(self) -> None:
"""Raise an error for an unusable reverse rate, offset, or boundary."""
if self.reverse_rate is not None and self.rate is None:
error_msg = f'{self.label!r} has a reverse rate but no rate. Give the rate of the forward jumps.'
raise ValidationError(error_msg)
reverse = self.reverse_rate
if reverse is not None and not callable(reverse) and not isinstance(reverse, int | float):
error_msg = f'The reverse rate {reverse!r} of {self.label!r} is neither a number nor a function.'
raise ValidationError(error_msg)
if isinstance(self.offset, bool) or not isinstance(self.offset, int):
error_msg = f'The offset of {self.label!r} is {self.offset!r}. Give a whole number of members.'
raise ValidationError(error_msg)
if self.boundary not in BOUNDARIES:
error_msg = f'Unknown boundary {self.boundary!r} of {self.label!r}. Use one of {", ".join(BOUNDARIES)}.'
raise ValidationError(error_msg)
if self.offset and (self.form != 'symbolic' or self.source != self.target):
error_msg = (
f'{self.label!r} transfers along the order of one group by an offset. Give the group as both source '
'and target, and `form="symbolic"`.'
)
raise ValidationError(error_msg)
def _check_schedule(self) -> None:
"""Raise an error for an unknown schedule, actor, or removal, or partners the schedule cannot draw."""
for name, value, variants in (
('schedule', self.schedule, SCHEDULES),
('actor', self.actor, ACTORS),
('removal', self.remove, (*REMOVALS, None)),
):
if value not in variants:
error_msg = f'Unknown {name} {value!r} of {self.label!r}. Use one of {", ".join(map(str, variants))}.'
raise ValidationError(error_msg)
own = self.pairing == 'members' and self.source == self.target
if self.schedule == 'member' and self.pairing in {'members', 'pairs', 'neighbor-pairs'} and not own:
error_msg = (
f'The actors of {self.label!r} draw partners by `random`, `neighbors`, `cell`, or `site`, or pair '
'with themselves by `members` within one group.'
)
raise ValidationError(error_msg)
if self.pairing == 'site' and self.actor != 'source':
error_msg = f'The sources of {self.label!r} move amounts into a field. Give `actor="source"`.'
raise ValidationError(error_msg)
if self.schedule == 'member' and self.form != 'agent':
error_msg = f'The schedule `member` of {self.label!r} runs agent rules. Use `form="agent"`.'
raise ValidationError(error_msg)
if isinstance(self.pairing, PartnerSpec) and self.schedule == 'member' and self.pairing.capacity is not None:
error_msg = (
f'The partners of {self.label!r} have a capacity of one step, but under the schedule `member` every '
'actor draws alone. Use the schedule `random-sequential`, or no capacity.'
)
raise ValidationError(error_msg)
if isinstance(self.pairing, PartnerSpec) and self.partners is not None:
error_msg = f'{self.label!r} draws partners by a `PartnerSpec`. Give its capacity, not `partners`.'
raise ValidationError(error_msg)
if self.partners is not None and (isinstance(self.partners, bool) or not self.partners >= 1):
error_msg = f'The partners of {self.label!r} are {self.partners!r}. Give a positive whole number, or None.'
raise ValidationError(error_msg)
def _get_owner(self) -> str:
"""Return the path of the element or group the source and target roles both bind, or the empty string."""
roles = self._get_specs()
bound = all(isinstance(roles.get(self._get_role(role)), RoleSpec) for role in ('source', 'target'))
return self.source if bound and self.source == self.target else ''
def _get_holders(self, namespace: SimpleNamespace) -> list[tuple[SimpleNamespace, SimpleNamespace]]:
"""Return the namespaces of the source and the target of every pair the symbolic form transfers between."""
return [(getattr(namespace, self._get_role('source')), getattr(namespace, self._get_role('target')))]
def _shift(self, holder: SimpleNamespace) -> SimpleNamespace:
"""Return the namespace of a group with every symbol read at the member the offset names."""
if not self.offset:
return holder
return SimpleNamespace(
**{
name: (
shift_member(value, self.offset, boundary=self.boundary) if isinstance(value, Expression) else value
)
for name, value in vars(holder).items()
},
)
@staticmethod
def _get_holder(namespace: SimpleNamespace, end: str, own: SimpleNamespace) -> object:
"""Return the symbol of a leg end, the state of the role it names or the state of its own side."""
role, _, state = end.rpartition('.')
return getattr(getattr(namespace, role) if role else own, state)
def _get_law(
self,
namespace: SimpleNamespace,
leg: NamedLeg,
rate: Amount | None,
reverse: bool,
) -> TransferLaw:
"""Return the transfer law of one leg of a pair, forward or back, reported under its flow name."""
state, into, amount, flow = leg
owner = self._get_owner() if flow else ''
held = (
state
if isinstance(state, Source)
else self._get_holder(namespace, state, self._get_member(namespace, 'source'))
)
sink = (
into
if isinstance(into, Sink)
else self._get_holder(namespace, str(into), self._get_member(namespace, 'target'))
)
value = amount(namespace) if callable(amount) else amount
if reverse:
held, sink = (
Source(sink.label) if isinstance(sink, Sink) else sink,
Sink(held.label) if isinstance(held, Source) else held,
)
flow = f'{flow}_reverse' if flow else ''
if rate is None:
if self.guard is not None:
value = where(self.guard(namespace), value, 0)
return TransferLaw(held, sink, value, label=flow, owner=owner)
jumps = rate(namespace) if callable(rate) else rate
if self.guard is not None:
jumps = where(self.guard(namespace), jumps, 0)
return TransferLaw(held, sink, value, jumps, label=flow, owner=owner)
def _get_transfer_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
"""Return the transfer laws of every leg of every pair, forward and back, of the symbolic form."""
laws: list[BaseLaw] = []
for source, target in self._get_holders(namespace):
paired = SimpleNamespace(
**{**vars(namespace), self._get_role('source'): source, self._get_role('target'): self._shift(target)},
)
laws += [self._get_law(paired, leg, self.rate, False) for leg in self._get_legs()]
if self.reverse_rate is not None:
laws += [self._get_law(paired, leg, self.reverse_rate, True) for leg in self._get_legs()]
return laws
def _get_pairing(self) -> MatchLaw:
"""Return the law of the pairs of the pairing, whose capacity for a function is `partners`, or 1 without it."""
pairing = self.pairing
spec = NAMED_PARTNERS[pairing] if isinstance(pairing, str) else pairing
return MatchLaw(self.source, self.target, spec, capacity=self.partners or 1)
def _get_pairs(self, model: ModelViewProtocol) -> list[tuple[AgentViewProtocol, AgentViewProtocol]]:
"""Return the pairs of source and target agents of one step in random order."""
sources = model.agents(self._get_role('source'))
targets = sources if self.source == self.target else model.agents(self._get_role('target'))
if self.fallback is not None and not any(target.alive for target in targets):
targets = model.agents(self._get_role('fallback'))
pairs: list[tuple[AgentViewProtocol, AgentViewProtocol]] = []
if self.pairing == 'members':
pairs = list(zip(sources, targets, strict=False))
else:
pairs = cast(
list[tuple[AgentViewProtocol, AgentViewProtocol]],
self._get_pairing().get_pairs(sources, targets, model.rng),
)
return [pairs[int(index)] for index in model.rng.permutation(len(pairs))]
def _evaluate(
self,
amount: Amount,
source: AgentViewProtocol,
target: object,
model: ModelViewProtocol,
) -> float:
"""Return the amount of a leg between two agents as a number."""
value = amount(source, target, model) if callable(amount) else amount
if not isinstance(value, int | float | np.floating | np.integer):
error_msg = f'An amount of {self.label!r} between agents is {value!r}. Return a number.'
raise ValidationError(error_msg)
return float(value)
def _transfer_pair(self, source: AgentViewProtocol, target: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Transfer between two living agents, and remove the member of the pair that a removal names."""
if source.alive and target.alive and self.transfer(source, target, model) and self.remove is not None:
(source if self.remove == 'source' else target).remove()
def _deposit(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Move the amounts of every leg from an agent into the site of the target field at its cell or node."""
cell = agent.location_key
if not agent.alive or (self.guard is not None and not self.guard(agent, cell, model)):
return
legs = self._get_legs()
amounts = [self._evaluate(amount, agent, cell, model) for _, _, amount, _ in legs]
field = self._get_role('target')
for (state, into, _, _), amount in zip(legs, amounts, strict=True):
if isinstance(state, str):
setattr(agent, state, getattr(agent, state) - amount)
if isinstance(into, str):
model.deposit(f'{field}.{into}', cell, amount)
def _get_partners(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> list[AgentViewProtocol]:
"""Return the partners of an actor by the pairing, or the number of them drawn at random."""
if self.pairing == 'members':
return [agent]
role = self._get_role('source' if self.actor == 'target' else 'target')
if callable(self.pairing) or isinstance(self.pairing, PartnerSpec):
others = [other for other in model.agents(role) if other is not agent]
pairs = self._get_pairing().get_pairs([agent], others, model.rng)
return [cast(AgentViewProtocol, partner) for _, partner in pairs]
if self.pairing == 'cell':
groups = set(model.groups(role))
others = [other for other in agent.agents_here() if other.group in groups]
else:
others = [other for other in model.agents(role) if other is not agent]
if self.pairing == 'neighbors':
cells = set(agent.neighbor_cells())
others = [other for other in others if other.location_key in cells]
elif self.pairing == 'random' and others:
others = [others[int(model.rng.integers(len(others)))]]
if self.partners is None or len(others) <= self.partners:
return others
return [others[int(index)] for index in model.rng.permutation(len(others))[: self.partners]]
def _transfer_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Transfer between one actor and its partners, in the activation of the actor."""
if self.pairing == 'site':
self._deposit(agent, model)
return
for partner in self._get_partners(agent, model):
if agent.alive:
pair = (partner, agent) if self.actor == 'target' else (agent, partner)
self._transfer_pair(*pair, model)
def _transfer_pairs(self, model: ModelViewProtocol) -> None:
"""Transfer between every pair of agents of one step, or into the sites of the source agents."""
if self.pairing == 'site':
sources = model.agents(self._get_role('source'))
for index in model.rng.permutation(len(sources)):
self._deposit(sources[int(index)], model)
return
for source, target in self._get_pairs(model):
self._transfer_pair(source, target, model)
def _get_laws(self, namespace: SimpleNamespace) -> list[BaseLaw]:
"""Return the transfer laws of the symbolic form, or the rule of the agent form.
Args:
namespace:
The namespace of the symbols the laws can use.
Returns:
laws:
A transfer law per leg, or the rule that transfers between the pairs of agents.
"""
if self.form == 'agent' and self.schedule == 'member':
return [RuleLaw(self._transfer_member, phase=self.phase, members=self._get_role(self.actor))]
if self.form == 'agent':
return [RuleLaw(self._transfer_pairs, phase=self.phase)]
return self._get_transfer_laws(namespace)
def transfer(self, source: AgentViewProtocol, target: AgentViewProtocol, model: ModelViewProtocol) -> bool:
"""Move the amounts of every leg from one source agent to one target agent, when the guard holds.
Args:
source:
The agent the amounts leave.
target:
The agent the amounts enter.
model:
The model view of the rule.
Returns:
transferred:
Whether the guard held, so the amounts moved.
Raises:
ValidationError:
An amount function returns something other than a number.
SimulationError:
A leg end names the state of a referenced member, and the reference state names no member.
"""
if self.guard is not None and not self.guard(source, target, model):
return False
legs = self._get_legs()
amounts = [self._evaluate(amount, source, target, model) for _, _, amount, _ in legs]
if self.settlement is not None:
self.settlement(source, target, amounts, model)
return True
ends = [
(
self._get_end(source, state) if isinstance(state, str) else None,
self._get_end(target, into) if isinstance(into, str) else None,
)
for state, into, _, _ in legs
]
for (taken, given), amount in zip(ends, amounts, strict=True):
if taken is not None:
holder, name = taken
setattr(holder, name, getattr(holder, name) - amount)
if given is not None:
holder, name = given
setattr(holder, name, getattr(holder, name) + amount)
return True
def _get_end(self, agent: AgentViewProtocol, name: str) -> tuple[AgentViewProtocol, str]:
"""Return the agent that holds the state a leg end names and the name of the state.
A name `'<reference>.<state>'` names the state of the member that the reference state of the agent names.
"""
reference, _, state = name.rpartition('.')
if not reference:
return agent, name
holder = agent.referenced(reference)
if holder is None:
error_msg = (
f'A leg of {self.label!r} moves {name!r}, but the state {reference!r} of agent {agent.id} names no '
'member.'
)
raise SimulationError(error_msg)
return holder, state
class ReinforcementInteraction(Interaction):
"""Reinforcement of the scores of options, each moving towards the payoff its option realized in the step.
Every step, the score `s_k` of option `k` of every member moves towards the realized payoff `P_k` at the rate `a`,
`s_k <- s_k + a (P_k - s_k)`, so a score is an exponentially weighted mean of past payoffs, and the last payoff at
the rate 1, by the rule `'average'`. By the rule `'cumulative'` the score adds the payoff, `s_k <- s_k + P_k`, so
it is the sum of past payoffs, such as a virtual score. In the agent form the scores may be the components of one
vector state, one per option, and a group of one member holds scores that several groups share. In the
`'symbolic'` form, `payoff(namespace, k)` returns the payoff of option `k` as a symbolic
expression, where `namespace.target` holds the states and parameters of the group. In the `'agent'` form, the
group is an agent group and `payoff(agent, model, k)` returns the payoff as a number, so it can read the agent and
the history and storage of the model view. With `chosen`, the name of a state that holds the index of the option a
member chose, only the score of that option moves, and the payoff of the other options is not read. In the agent
form every agent reinforces at once under the schedule `'synchronous'`, one after the other in their order or in a
new random order every step under `'sequential'` or `'random-sequential'`, and each in its own activation under
`'member'`, together with the rules of its element and the other member interactions, in the order the system
lists them, such as after a choice and a transfer in one visit.
Args:
label:
The label of the interaction, unique among the parts of its system.
target:
The path of the element or group whose members hold the scores.
scores:
The names of the states that hold the score of every option, in the order of the options, or in the agent
form the name of one vector state whose components are the scores.
payoff:
The function `payoff(namespace, k)` in the symbolic form, or `payoff(agent, model, k)` in the agent form,
that returns the realized payoff of option `k`.
rate:
The rate `a` at which the scores move towards the payoffs, between 0 and 1, by the rule `'average'`.
rule:
`'average'` to move the scores towards the payoffs, or `'cumulative'` to add the payoffs to the scores.
form:
`'symbolic'` for update laws of the states of a group, or `'agent'` for a rule over an agent group.
phase:
The phase of the step the agent form runs in, or `None` for the default phase.
chosen:
The name of the state that holds the index of the option a member chose, so only its score moves, or
`None` to move every score.
schedule:
The order of the agent form, `'synchronous'` for every agent at once, `'sequential'` or
`'random-sequential'` for one agent after the other, or `'member'` for every agent in its own activation.
Raises:
ValidationError:
The form, the rule, or the schedule is unknown, the symbolic form runs member by member, there are no
scores, or the payoff is not callable.
Examples:
>>> from skcomplex.core.interactions import ReinforcementInteraction
>>> reinforcement = ReinforcementInteraction(
... 'reinforcement', target='learners', scores=('first', 'second'), payoff=lambda s, k: k, rate=0.5
... )
>>> reinforcement.check().rate
0.5
>>> ReinforcementInteraction(
... 'virtual', target='players', scores='scores', payoff=lambda a, m, k: k, rule='cumulative', form='agent'
... ).check().rule
'cumulative'
>>> ReinforcementInteraction(
... 'satisfaction', target='customers', scores=('s1', 's2'), payoff=lambda a, m, k: 1.0, chosen='shop',
... schedule='member', form='agent',
... ).check().chosen
'shop'
"""
def __init__(
self,
label: str,
*,
target: Annotated[str, RoleSpec()],
scores: str | Sequence[str],
payoff: Callable[..., float | Expression],
rate: Annotated[float, ParameterSpec(domain=RealSet.closed(0, 1))] = 0.1,
rule: str = 'average',
form: str = 'symbolic',
phase: str | None = None,
chosen: str | None = None,
schedule: str = 'synchronous',
) -> None:
super().__init__(label)
self.target = target
self.scores = scores
self.payoff = payoff
self.rate = rate
self.rule = rule
self.form = form
self.phase = phase
self.chosen = chosen
self.schedule = schedule
def _check(self) -> None:
"""Raise an error for an unknown variant, no scores, or a payoff that cannot be called.
Raises:
ValidationError:
The type declares states, the form, the rule, or the schedule is unknown, the symbolic form runs member
by member, there are no scores, or the payoff is not callable.
"""
super()._check()
for name, value, variants in (
('form', self.form, FORMS),
('rule', self.rule, RULES),
('schedule', self.schedule, SCHEDULES),
):
if value not in variants:
error_msg = f'Unknown {name} {value!r} of {self.label!r}. Use one of {variants}.'
raise ValidationError(error_msg)
if self.form == 'symbolic' and self.schedule != 'synchronous':
error_msg = f'The schedule {self.schedule!r} of {self.label!r} runs agent rules. Use `form="agent"`.'
raise ValidationError(error_msg)
vector = isinstance(self.scores, str) and bool(self.scores) and self.form == 'agent'
if not vector and (isinstance(self.scores, str) or not self.scores):
error_msg = (
f'{self.label!r} has the scores {self.scores!r}. Give a sequence of state names, one per option, or '
'in the agent form the name of a vector state.'
)
raise ValidationError(error_msg)
if not callable(self.payoff):
error_msg = f'The payoff of {self.label!r} must be callable, but it is {self.payoff!r}.'
raise ValidationError(error_msg)
def _read(self, agent: AgentViewProtocol) -> Array:
"""Return the scores of an agent, one per option."""
if isinstance(self.scores, str):
return np.array(getattr(agent, self.scores), dtype=float).ravel()
return np.array([getattr(agent, name) for name in self.scores], dtype=float)
def _get_scores(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> Array:
"""Return the new scores of an agent, every score or only that of its chosen option moved by its payoff."""
old = self._read(agent)
options = range(len(old)) if self.chosen is None else [int(getattr(agent, self.chosen))]
rate = 1.0 if self.rule == 'cumulative' else self._get_parameter_value('rate', model)
change = np.zeros_like(old)
for k in options:
payoff = float(self.payoff(agent, model, k))
change[k] = payoff if self.rule == 'cumulative' else rate * (payoff - old[k])
return old + change
def _write(self, agent: AgentViewProtocol, scores: Array) -> None:
"""Set the scores of an agent."""
if isinstance(self.scores, str):
setattr(agent, self.scores, scores)
return
for name, value in zip(self.scores, scores, strict=True):
setattr(agent, name, float(value))
def _reinforce_member(self, agent: AgentViewProtocol, model: ModelViewProtocol) -> None:
"""Move the scores of one agent towards its payoffs, in its own activation."""
if agent.alive:
self._write(agent, self._get_scores(agent, model))
def reinforce(self, model: ModelViewProtocol) -> None:
"""Move the scores of every agent towards the payoffs of its options, by the schedule.
Args:
model:
The model view of the interaction.
"""
self._update_agents(model, self.schedule, self._get_scores, self._write)
def _get_laws(self, namespace: SimpleNamespace) -> list[BasePart]:
"""Return the update of every score towards its payoff, or the rule of the agent form.
Args:
namespace:
The namespace of the symbols the laws can use, with `namespace.target` and `namespace.rate`.
Returns:
laws:
The update laws of the scores, or the rule that reinforces the scores of the agents.
"""
if self.form == 'agent' and self.schedule == 'member':
return [RuleLaw(self._reinforce_member, phase=self.phase, members=self._get_role('target'))]
if self.form == 'agent':
return [RuleLaw(self.reinforce, phase=self.phase)]
laws: list[BasePart] = []
for index, name in enumerate(self.scores):
score = getattr(self._get_member(namespace, 'target'), name)
payoff = self.payoff(namespace, index)
change = (
payoff
if self.rule == 'cumulative'
else self._get_parameter_symbol('rate', namespace) * (payoff - score)
)
if self.chosen is not None:
change = where(getattr(self._get_member(namespace, 'target'), self.chosen) == index, change, 0)
laws.append(UpdateLaw(score, score + change))
return laws