Current section

Files

Jump to
macula_tweann src crossover.erl
Raw

src/crossover.erl

%% @doc Genetic crossover (recombination) for neural networks.
%%
%% This module implements sexual reproduction by combining genetic
%% material from two parent agents to create offspring. Crossover
%% enables exploration of the solution space by mixing successful
%% traits from different individuals.
%%
%% == Crossover Strategies ==
%%
%% Network-Level Crossover:
%% - Select matching neurons from both parents
%% - Randomly choose which parent contributes each neuron
%% - Inherit connections and weights from selected neurons
%% - Handles structural differences (different topologies)
%%
%% Neuron-Level Crossover:
%% - For matching neurons, can mix properties:
%% * Activation function from parent A
%% * Weights from parent B
%% * Bias from parent A
%% - Creates fine-grained genetic mixing
%%
%% Weight-Level Crossover:
%% - For matching connections, blend weights:
%% * Averaging: W_child = (W_A + W_B) / 2
%% * Random selection: W_child = random choice of W_A or W_B
%% * Weighted average based on fitness
%% - Preserves promising weight configurations
%%
%% == Implementation Notes ==
%%
%% Matching Strategy:
%% - Neurons match by layer coordinate
%% - Connections match by source/target pair
%% - Unmatched elements inherited from fitter parent
%%
%% Compatibility:
%% - Parents must have compatible morphologies
%% - Sensor/actuator counts must match
%% - Layer structure should align (though not required)
%%
%% @author Macula.io
%% @copyright 2025 Macula.io, Apache-2.0
-module(crossover).
-include("records.hrl").
-export([
crossover/2,
neuron_crossover/3,
weight_crossover/3
]).
%% ============================================================================
%% API
%% ============================================================================
%% @doc Perform genetic crossover between two parent agents.
%%
%% Creates a new offspring agent by combining genetic material from
%% two parents. The offspring inherits a mix of neurons, connections,
%% and weights from both parents.
%%
%% Algorithm:
%% 1. Clone one parent as base structure
%% 2. For each matching neuron, perform neuron-level crossover
%% 3. For unmatched neurons, inherit from fitter parent
%% 4. Return offspring agent ID
%%
%% Example:
%% Parent1 = {1.0, agent}
%% Parent2 = {2.0, agent}
%% Offspring = crossover(Parent1, Parent2)
%% % Offspring inherits mix of traits from both parents
%%
%% @param Parent1Id first parent agent identifier
%% @param Parent2Id second parent agent identifier
%% @returns offspring agent identifier
-spec crossover(term(), term()) -> {float(), agent}.
crossover(Parent1Id, Parent2Id) ->
%% Read parent agents
Parent1 = genotype:read({agent, Parent1Id}),
Parent2 = genotype:read({agent, Parent2Id}),
case {Parent1, Parent2} of
{undefined, _} ->
%% Parent1 not found, clone Parent2
genotype:clone_Agent(Parent2Id);
{_, undefined} ->
%% Parent2 not found, clone Parent1
genotype:clone_Agent(Parent1Id);
{_, _} ->
%% Both parents available, perform crossover
perform_crossover(Parent1Id, Parent2Id, Parent1, Parent2)
end.
%% @doc Perform neuron-level crossover.
%%
%% Combines properties from matching neurons in both parents.
%% Can mix activation function, weights, LTC parameters, etc.
%%
%% Strategy:
%% - Activation function: random choice from parents
%% - Input weights: weight-level crossover
%% - Aggregation function: random choice
%% - Output connections: union of both parents
%% - LTC parameters: random choice with averaging for continuous values
%%
%% Example:
%% Neuron1 = #neuron{af = tanh, ...}
%% Neuron2 = #neuron{af = sigmoid, ...}
%% Result = neuron_crossover(Neuron1, Neuron2, 0.5)
%% % Result might have tanh from parent 1
%%
%% @param Neuron1 first parent neuron record
%% @param Neuron2 second parent neuron record
%% @param CrossoverRate probability of taking trait from parent 1 (0.0-1.0)
%% @returns crossed neuron record
-spec neuron_crossover(#neuron{}, #neuron{}, float()) -> #neuron{}.
neuron_crossover(Neuron1, Neuron2, CrossoverRate) ->
%% Choose activation function
AF = case rand:uniform() < CrossoverRate of
true -> Neuron1#neuron.af;
false -> Neuron2#neuron.af
end,
%% Choose aggregation function
AggrF = case rand:uniform() < CrossoverRate of
true -> Neuron1#neuron.aggr_f;
false -> Neuron2#neuron.aggr_f
end,
%% Crossover input weights
InputWeights = weight_crossover(
Neuron1#neuron.input_idps,
Neuron2#neuron.input_idps,
CrossoverRate
),
%% Take output connections from both (union)
Outputs = lists:usort(
Neuron1#neuron.output_ids ++ Neuron2#neuron.output_ids
),
%% Crossover LTC parameters
{NeuronType, Tau, Bound, BackboneW, HeadW} = crossover_ltc_params(
Neuron1, Neuron2, CrossoverRate
),
%% Build crossed neuron
Neuron1#neuron{
af = AF,
aggr_f = AggrF,
input_idps = InputWeights,
output_ids = Outputs,
neuron_type = NeuronType,
time_constant = Tau,
state_bound = Bound,
ltc_backbone_weights = BackboneW,
ltc_head_weights = HeadW,
internal_state = 0.0 %% Reset state for offspring
}.
%% @doc Perform weight-level crossover.
%%
%% Combines weight vectors from two parent neurons.
%% For matching connections (same source), blends weights.
%% For unmatched connections, includes from both parents.
%%
%% Blending strategies:
%% - Averaging: (W1 + W2) / 2
%% - Random selection: random choice
%% - Crossover rate determines probability
%%
%% Example:
%% Weights1 = [{sensor1, [{0.5, 0.0, 0.01, []}]}]
%% Weights2 = [{sensor1, [{-0.3, 0.0, 0.01, []}]}]
%% Result = weight_crossover(Weights1, Weights2, 0.5)
%% % Might average to [{sensor1, [{0.1, 0.0, 0.01, []}]}]
%%
%% @param Weights1 first parent's weighted inputs
%% @param Weights2 second parent's weighted inputs
%% @param CrossoverRate probability of taking weight from parent 1
%% @returns crossed weighted inputs
-spec weight_crossover(weighted_inputs(), weighted_inputs(), float()) ->
weighted_inputs().
weight_crossover([], Weights2, _CrossoverRate) ->
Weights2;
weight_crossover(Weights1, [], _CrossoverRate) ->
Weights1;
weight_crossover(Weights1, Weights2, CrossoverRate) ->
%% Build map of all sources
Sources1 = [{Source, Ws} || {Source, Ws} <- Weights1],
Sources2 = [{Source, Ws} || {Source, Ws} <- Weights2],
AllSources = lists:usort(
[S || {S, _} <- Sources1] ++ [S || {S, _} <- Sources2]
),
%% For each source, crossover weights
lists:map(
fun(Source) ->
W1 = proplists:get_value(Source, Sources1, []),
W2 = proplists:get_value(Source, Sources2, []),
CrossedWeights = crossover_weight_vectors(W1, W2, CrossoverRate),
{Source, CrossedWeights}
end,
AllSources
).
%% ============================================================================
%% Internal Functions - Crossover Execution
%% ============================================================================
%% @private Perform full crossover between two parents.
-spec perform_crossover(term(), term(), #agent{}, #agent{}) -> {float(), agent}.
perform_crossover(Parent1Id, Parent2Id, Parent1, Parent2) ->
%% Determine which parent is fitter (for tie-breaking)
FitterParentId = case Parent1#agent.fitness >= Parent2#agent.fitness of
true -> Parent1Id;
false -> Parent2Id
end,
%% Clone fitter parent as base
OffspringId = genotype:clone_Agent(FitterParentId),
%% Get cortex info from both parents
Cortex1 = genotype:read({cortex, Parent1#agent.cx_id}),
Cortex2 = genotype:read({cortex, Parent2#agent.cx_id}),
%% Perform neuron-level crossover
crossover_neurons(OffspringId, Cortex1, Cortex2),
OffspringId.
%% @private Crossover neurons between parents.
-spec crossover_neurons({float(), agent}, #cortex{neuron_ids :: [term()]}, #cortex{neuron_ids :: [term()]}) -> ok.
crossover_neurons(OffspringId, Cortex1, Cortex2) ->
%% Get offspring cortex
Offspring = genotype:read({agent, OffspringId}),
OffspringCortex = genotype:read({cortex, Offspring#agent.cx_id}),
%% Get neuron lists
Neurons1 = Cortex1#cortex.neuron_ids,
Neurons2 = Cortex2#cortex.neuron_ids,
%% Build maps by layer coordinate for matching
Map1 = build_neuron_map(Neurons1),
Map2 = build_neuron_map(Neurons2),
%% Get all layer coordinates
AllCoords = lists:usort(maps:keys(Map1) ++ maps:keys(Map2)),
%% For each layer coordinate, perform crossover
lists:foreach(
fun(Coord) ->
N1List = maps:get(Coord, Map1, []),
N2List = maps:get(Coord, Map2, []),
%% Crossover neurons at this layer
crossover_neurons_at_layer(OffspringCortex, N1List, N2List, 0.5)
end,
AllCoords
),
ok.
%% @private Build map of neurons by layer coordinate.
-spec build_neuron_map([term()]) -> #{float() => [term()]}.
build_neuron_map(NeuronIds) ->
lists:foldl(
fun(NeuronId, Map) ->
{{LayerCoord, _UniqueId}, neuron} = NeuronId,
Neurons = maps:get(LayerCoord, Map, []),
maps:put(LayerCoord, [NeuronId | Neurons], Map)
end,
#{},
NeuronIds
).
%% @private Crossover neurons at a specific layer.
-spec crossover_neurons_at_layer(#cortex{}, [term()], [term()], float()) -> ok.
crossover_neurons_at_layer(_Cortex, [], [], _Rate) ->
ok;
crossover_neurons_at_layer(Cortex, Neurons1, Neurons2, CrossoverRate) ->
%% Match neurons by index (assumes similar structure)
Pairs = lists:zip(Neurons1, Neurons2),
%% For each pair, perform neuron crossover
lists:foreach(
fun({N1Id, N2Id}) ->
N1 = genotype:read({neuron, N1Id}),
N2 = genotype:read({neuron, N2Id}),
case {N1, N2} of
{undefined, _} -> ok;
{_, undefined} -> ok;
{_, _} ->
%% Perform crossover and update
Crossed = neuron_crossover(N1, N2, CrossoverRate),
%% Find corresponding neuron in offspring
{{Layer, _}, neuron} = N1Id,
OffspringNeurons = [
NId || NId <- Cortex#cortex.neuron_ids,
begin
{{L, _}, neuron} = NId,
L =:= Layer
end
],
%% Update first matching neuron
case OffspringNeurons of
[OffspringNId | _] ->
OffspringN = genotype:read({neuron, OffspringNId}),
UpdatedN = OffspringN#neuron{
af = Crossed#neuron.af,
aggr_f = Crossed#neuron.aggr_f
},
genotype:write(UpdatedN);
[] ->
ok
end
end
end,
Pairs
),
ok.
%% ============================================================================
%% Internal Functions - Weight Operations
%% ============================================================================
%% @private Crossover weight vectors.
-spec crossover_weight_vectors([weight_spec()], [weight_spec()], float()) ->
[weight_spec()].
crossover_weight_vectors([], W2, _Rate) -> W2;
crossover_weight_vectors(W1, [], _Rate) -> W1;
crossover_weight_vectors(W1, W2, CrossoverRate) ->
%% Take equal number from both or combine
NumWeights = min(length(W1), length(W2)),
Combined = lists:zipwith(
fun({Weight1, DW1, LR1, P1}, {Weight2, _DW2, _LR2, _P2}) ->
%% Choose weight based on crossover rate
NewWeight = case rand:uniform() < CrossoverRate of
true -> Weight1;
false -> Weight2
end,
{NewWeight, DW1, LR1, P1}
end,
lists:sublist(W1, NumWeights),
lists:sublist(W2, NumWeights)
),
%% Add remaining weights from longer list
Remaining = if
length(W1) > NumWeights ->
lists:nthtail(NumWeights, W1);
length(W2) > NumWeights ->
lists:nthtail(NumWeights, W2);
true ->
[]
end,
Combined ++ Remaining.
%% ============================================================================
%% Internal Functions - LTC Parameter Crossover
%% ============================================================================
%% @private Crossover LTC (Liquid Time-Constant) parameters between neurons.
%%
%% Strategy for LTC parameters:
%% - neuron_type: Select from parent with matching type, or random choice
%% - time_constant: Average if both LTC, otherwise from LTC parent or default
%% - state_bound: Average if both LTC, otherwise from LTC parent or default
%% - backbone/head weights: Crossover weight vectors if both have them
%%
%% @param Neuron1 first parent neuron
%% @param Neuron2 second parent neuron
%% @param CrossoverRate probability of taking from parent 1
%% @returns {NeuronType, Tau, Bound, BackboneWeights, HeadWeights}
-spec crossover_ltc_params(#neuron{}, #neuron{}, float()) ->
{atom(), float(), float(), [float()], [float()]}.
crossover_ltc_params(Neuron1, Neuron2, CrossoverRate) ->
Type1 = Neuron1#neuron.neuron_type,
Type2 = Neuron2#neuron.neuron_type,
%% Determine offspring neuron type
NeuronType = crossover_neuron_type(Type1, Type2, CrossoverRate),
%% Crossover time constant (tau)
Tau = crossover_continuous_param(
Neuron1#neuron.time_constant,
Neuron2#neuron.time_constant,
Type1, Type2,
1.0 %% default tau
),
%% Crossover state bound (A)
Bound = crossover_continuous_param(
Neuron1#neuron.state_bound,
Neuron2#neuron.state_bound,
Type1, Type2,
1.0 %% default bound
),
%% Crossover backbone weights
BackboneW = crossover_ltc_weights(
Neuron1#neuron.ltc_backbone_weights,
Neuron2#neuron.ltc_backbone_weights,
CrossoverRate
),
%% Crossover head weights
HeadW = crossover_ltc_weights(
Neuron1#neuron.ltc_head_weights,
Neuron2#neuron.ltc_head_weights,
CrossoverRate
),
{NeuronType, Tau, Bound, BackboneW, HeadW}.
%% @private Crossover neuron types.
%% If both parents have same type, inherit it.
%% If one is LTC/CfC and other is standard, probabilistic choice.
-spec crossover_neuron_type(atom(), atom(), float()) -> atom().
crossover_neuron_type(Type1, Type1, _Rate) ->
%% Same type, inherit it
Type1;
crossover_neuron_type(Type1, Type2, CrossoverRate) ->
%% Different types, choose based on crossover rate
case rand:uniform() < CrossoverRate of
true -> Type1;
false -> Type2
end.
%% @private Crossover continuous LTC parameter (tau or bound).
%% Averages values if both parents are LTC/CfC neurons.
-spec crossover_continuous_param(float(), float(), atom(), atom(), float()) -> float().
crossover_continuous_param(Val1, Val2, Type1, Type2, Default) ->
IsLtc1 = is_ltc_type(Type1),
IsLtc2 = is_ltc_type(Type2),
crossover_continuous_impl(Val1, Val2, IsLtc1, IsLtc2, Default).
crossover_continuous_impl(Val1, Val2, true, true, _Default) ->
%% Both LTC: average
(Val1 + Val2) / 2.0;
crossover_continuous_impl(Val1, _Val2, true, false, _Default) ->
%% Only parent1 is LTC
Val1;
crossover_continuous_impl(_Val1, Val2, false, true, _Default) ->
%% Only parent2 is LTC
Val2;
crossover_continuous_impl(_Val1, _Val2, false, false, Default) ->
%% Neither is LTC, use default
Default.
%% @private Check if neuron type is LTC or CfC.
-spec is_ltc_type(atom()) -> boolean().
is_ltc_type(ltc) -> true;
is_ltc_type(cfc) -> true;
is_ltc_type(_) -> false.
%% @private Crossover LTC weight vectors.
%% Uses weight-level crossover for matching indices.
-spec crossover_ltc_weights([float()], [float()], float()) -> [float()].
crossover_ltc_weights([], W2, _Rate) -> W2;
crossover_ltc_weights(W1, [], _Rate) -> W1;
crossover_ltc_weights(W1, W2, CrossoverRate) ->
NumWeights = min(length(W1), length(W2)),
%% Crossover matching weights
Combined = lists:zipwith(
fun(Weight1, Weight2) ->
case rand:uniform() < CrossoverRate of
true -> Weight1;
false -> Weight2
end
end,
lists:sublist(W1, NumWeights),
lists:sublist(W2, NumWeights)
),
%% Add remaining weights from longer list
Remaining = get_remaining_weights(W1, W2, NumWeights),
Combined ++ Remaining.
%% @private Get remaining weights from the longer list.
-spec get_remaining_weights([float()], [float()], non_neg_integer()) -> [float()].
get_remaining_weights(W1, W2, NumWeights) ->
Len1 = length(W1),
Len2 = length(W2),
get_remaining_impl(W1, W2, Len1, Len2, NumWeights).
get_remaining_impl(W1, _W2, Len1, _Len2, NumWeights) when Len1 > NumWeights ->
lists:nthtail(NumWeights, W1);
get_remaining_impl(_W1, W2, _Len1, Len2, NumWeights) when Len2 > NumWeights ->
lists:nthtail(NumWeights, W2);
get_remaining_impl(_W1, _W2, _Len1, _Len2, _NumWeights) ->
[].