Brains and generations

Small neural networks per agent, their weights read from the genes and run in one batched pass, the rays that feed them, and a generational genetic algorithm that evolves them.

agents.Brains

agents.Brains(
    layers=(16, 16, 4),
    activation=Activation.TANH,
    *,
    output=Activation.TANH,
)

The shape of a small neural network that every agent of a population runs.

The network is fully connected: each layer’s outputs are the activation of a weighted sum of the layer before, plus a bias. Brains holds only the shape; the weights are per agent, an (n, weight_count) array of genes in [0, 1] (usually Genome.weights), each gene g standing for the weight 2 g - 1. For each layer of a inputs and b outputs, an agent’s genes hold the a * b weights (input by input, so the weights from input i are genes i * b to i * b + b - 1) and then the b biases.

Parameters

Name Type Description Default
layers Sequence[int] The number of values in each layer, inputs first and outputs last, such as (16, 12, 2): 16 inputs, a hidden layer of 12, 2 outputs. At least two. (16, 16, 4)
activation Activation | str The activation of the hidden layers, an Activation. Activation.TANH
output Activation | str The activation of the output layer. TANH, the default, gives outputs in (-1, 1), as drive wants. Activation.TANH

Attributes

Name Type Description
layers The layer sizes, a tuple.
activation The hidden layers’ Activation.
output The output layer’s Activation.

Raises

Name Type Description
ValueError If there are fewer than two layers, a size is below 1, or an activation is not an Activation.

Methods

Name Description
forward Runs every agent’s network on its inputs, all at once.

forward

agents.Brains.forward(weights, inputs, *, dtype=np.float64)

Runs every agent’s network on its inputs, all at once.

Each layer is one batched matrix product over the agents. The weights are read as 2 g - 1 from the genes without making a copy of them: the sum x . (2 g - 1) is worked out as 2 (x . g) - sum(x).

dtype=np.float32 runs the network in single precision, about twice as fast for thousands of agents, to within about 1e-5 of float64. Genes of another dtype are copied to it on every call, so for the speed cast them once a generation, when they change, and pass the copy::

w32 = genome.weights.astype(np.float32)  # after each next_generation
outputs = brains.forward(w32, inputs, dtype=np.float32)
Parameters
Name Type Description Default
weights ArrayLike (n, weight_count) genes in [0, 1], one row per agent, such as genome.weights. required
inputs ArrayLike (n, layers[0]) inputs, one row per agent. required
dtype DTypeLike The precision to run in: np.float64 (the default) or np.float32. np.float64
Returns
Name Type Description
np.ndarray (n, layers[-1]) outputs, a new array of dtype.
Raises
Name Type Description
ValueError If the shapes don’t fit the network or each other, or dtype is neither float64 nor float32.

agents.Activation

agents.Activation()

What a layer of a Brains does to its sums.

Attributes

Name Type Description
TANH tanh, in (-1, 1). The default.
RELU max(0, x).
SIGMOID 1 / (1 + exp(-x)), in (0, 1), worked out as (1 + tanh(x / 2)) / 2 so large sums don’t overflow.
LINEAR The sums as they are.

agents.rays

agents.rays(pop, k, fov, reach, *, kinds=(), fields=(), radii=1.0, who=None)

What each agent reads along k rays: how close agents of some kinds are, and fields.

The rays fan out from each agent’s position across fov radians centred on its heading, as ray_directions gives them: ray 0 is turned furthest toward -y, and an agent with no velocity looks along +x. Rays are thin: a body between two rays is not seen, so give small bodies a larger radii or use more rays.

For each kind in kinds, a ray reads its closeness to the body of the first agent of that kind it meets (a disc of radius radii): 1 - distance / reach, so 0 is nothing within reach and 1 is touching. An agent standing inside a body reads 1, and never sees its own. The search is in plain space, as pop.sense is: rays don’t wrap around the edges of the world.

For each field in fields, a ray reads the field at its tip, reach from the agent (Field.sample), as it is. A tip past the field’s world follows the field’s edge: it wraps round with FieldEdge.WRAP and reads the edge cells with FieldEdge.CLOSED.

Parameters

Name Type Description Default
pop Population A 2D population. required
k int The number of rays per agent, a whole number, 1 or more. required
fov float The width of the fan in radians, above 0 and at most 2 * pi. required
reach float The length of each ray in world units, above 0. required
kinds Sequence[str] The kinds of agent the rays see, each read separately, each once. ()
fields Sequence[Field] The Fields read at the rays’ tips (not FlowFields). ()
radii ArrayLike The radius of every agent’s body, as the rays see it: one for all, or (n,). 0 or more. 1.0
who ArrayLike | None A boolean mask, one per agent, of the agents that cast rays; None for all. Agents left out are still seen, and cost nothing to sense for. None

Returns

Name Type Description
np.ndarray An (n, k * (len(kinds) + len(fields))) float64 array, or one row per agent
np.ndarray who marks, in order: k columns per kind, in the order given, then k
np.ndarray per field.

Raises

Name Type Description
ValueError If the population is not 2D, k, fov, reach or radii is out of range, a kind is not one of the population’s or is given twice, a field is a FlowField, who is not a boolean mask of one per agent, or a position is not finite.

agents.ray_directions

agents.ray_directions(pop, k, fov, *, who=None)

The unit direction of each of the k rays that rays casts from each agent.

The rays fan out across fov radians centred on each agent’s heading (the way its velocity points; +x for an agent with no velocity), each in the middle of an equal slice of the view, so k=1 looks straight ahead, an odd k always has a ray straight ahead, and fov=2 * np.pi looks evenly all around. Ray 0 is the one turned furthest toward -y (counter-clockwise on screen, where y points down), and the rest follow in order. Take them in the same step as rays to draw the rays an agent sensed with: its heading changes once it is driven.

Parameters

Name Type Description Default
pop Population A 2D population. required
k int The number of rays per agent, a whole number, 1 or more. required
fov float The width of the fan in radians, above 0 and at most 2 * pi. required
who ArrayLike | None A boolean mask, one per agent, of the agents whose rays to give; None for all. None

Returns

Name Type Description
np.ndarray An (n, k, 2) float64 array of unit vectors, or one row per agent who marks.

Raises

Name Type Description
ValueError If the population is not 2D, k or fov is out of range, or who is not a boolean mask of one per agent.

agents.drive

agents.drive(pop, outputs, dt, *, max_speed, max_turn)

New velocities from two outputs per agent: one turns it, the other sets its speed.

The first output, in [-1, 1], turns each agent’s heading (the way its velocity points) by up to max_turn radians per second, positive toward +y. The second, in [-1, 1], sets its speed from 0 (at -1) to max_speed (at 1). Outputs past [-1, 1] are clipped. Set them with pop.vel[:] = drive(...).

The heading lives in the velocity: there is no separate heading field. An agent told to stop keeps a crawl of a millionth of max_speed, so its velocity still holds its heading for the next step; but one whose velocity is zeroed (by a wall, or by your code) loses its heading and turns from +x again.

Parameters

Name Type Description Default
pop Population A 2D population. required
outputs ArrayLike (n, 2) or wider: the turn and the speed, as from Brains.forward with a TANH output. Columns after the first two are ignored. required
dt float The step length in seconds, 0 or more. required
max_speed ArrayLike The top speed, 0 or more: one for all, or (n,). required
max_turn float The fastest turn, in radians per second, 0 or more. required

Returns

Name Type Description
np.ndarray (n, 2) velocities.

Raises

Name Type Description
ValueError If the population is not 2D, outputs is not (n, 2) or wider, or dt, max_speed or max_turn is negative, not finite or (for max_speed) neither one number nor one per agent.

agents.next_generation

agents.next_generation(
    genomes,
    fitness,
    rng,
    *,
    elite=0.05,
    selection=Selection.TOURNAMENT,
    crossover=Crossover.UNIFORM,
    mutation=0.03,
    sigma=0.15,
    tournament_size=3,
)

The genes of the next generation, bred from this one’s by fitness.

The fittest elite share of the population is copied as it is, best first; that is ceil(elite * n) agents (with no rounding up from float error, so 0.07 of 100 is 7), and any elite above 0 keeps at least the best. Every other row is a child: two parents picked by selection, their genes mixed by crossover (or one parent’s copied, with None), then mutated: each gene, with odds mutation, moves by a normal step of sigma and is reflected back into [0, 1], as in reproduce. A parent can be picked twice, even as its own mate.

Parameters

Name Type Description Default
genomes ArrayLike (n, g) genes in [0, 1], one row per agent, such as pop.genome. required
fitness ArrayLike (n,) scores, higher is better; any finite numbers. required
rng np.random.Generator The random generator for the picks, the crossover and the mutation. required
elite float The share of the population kept unchanged, 0 to 1. 0.05
selection Selection | str How each parent is picked, a Selection. Selection.TOURNAMENT
crossover Crossover | str | None How two parents’ genes are mixed, a Crossover; None for one parent. Crossover.UNIFORM
mutation float The odds that each gene of a child changes, 0 to 1. 0.03
sigma float How far a changed gene moves: the standard deviation of its step. 0.15
tournament_size int How many agents each TOURNAMENT pick compares, 1 or more. 3

Returns

Name Type Description
np.ndarray An (n, g) float64 array of new genes: the elite first, best first, then the
np.ndarray children. The rows are no longer in the population’s order.

Raises

Name Type Description
ValueError If genomes is not 2-D, fitness is not (n,) or not finite, or elite, mutation, sigma or tournament_size is out of range, or a mode is unknown.

agents.Selection

agents.Selection()

How next_generation picks each parent from the scored population.

Attributes

Name Type Description
TOURNAMENT The fittest of a few agents drawn at random (tournament_size). It only compares fitnesses, so their scale and sign don’t matter. The default.
ROULETTE An agent drawn with odds in proportion to its fitness, above the lowest one: the least fit is never picked, unless all are equal, when all are as likely. It suits fitness that is 0 or more, such as food eaten: one very low score (a large penalty) squeezes everyone else’s odds together, so for signed fitness, or fitness with outliers, use TOURNAMENT.