Current section

Files

Jump to
macula_tweann src fitness_postprocessor.erl
Raw

src/fitness_postprocessor.erl

%% @doc Fitness postprocessing for multi-objective optimization.
%%
%% This module provides fitness transformation functions to apply
%% domain-specific knowledge or constraints to raw fitness values
%% before selection. Common use cases:
%%
%% - Penalize complex solutions (parsimony pressure)
%% - Normalize fitness across objectives
%% - Apply Pareto dominance for multi-objective optimization
%% - Age-based fitness adjustments
%%
%% == Postprocessing Strategies ==
%%
%% Size Proportional:
%% - Penalize agents with more neurons/connections
%% - Encourages simpler solutions (Occam's Razor)
%% - Helps prevent bloat in network topology
%% - Formula: AdjustedFitness = RawFitness - (Size * Penalty)
%%
%% Normalize:
%% - Scale fitness to [0, 1] range within population
%% - Prevents one objective from dominating in multi-objective case
%% - Useful when objectives have different scales
%% - Formula: Normalized = (F - Min) / (Max - Min)
%%
%% Pareto Dominance:
%% - Assign ranks based on Pareto dominance relationships
%% - Non-dominated solutions get rank 1 (best)
%% - Enables true multi-objective optimization
%% - Returns Pareto fronts as fitness ranks
%%
%% == Multi-Objective Fitness ==
%%
%% All functions work with fitness vectors [F1, F2, ...].
%% Single-objective case is just a vector with one element [F1].
%%
%% @author Macula.io
%% @copyright 2025 Macula.io, Apache-2.0
-module(fitness_postprocessor).
-include("records.hrl").
-export([
size_proportional/2,
normalize/1,
pareto_dominance/1
]).
%% ============================================================================
%% API
%% ============================================================================
%% @doc Apply size-proportional fitness penalty (parsimony pressure).
%%
%% Penalizes agents with larger networks to encourage compact solutions.
%% This helps prevent bloat where networks grow unnecessarily complex
%% without improving performance.
%%
%% The penalty is calculated as: Size * PenaltyFactor
%% where Size is the number of neurons in the agent's network.
%%
%% Example:
%% AgentFitnesses = [
%% {agent1, [100.0]},
%% {agent2, [100.0]},
%% {agent3, [80.0]}
%% ]
%% PenaltyFactor = 0.1
%%
%% Assuming agent1 has 10 neurons, agent2 has 5 neurons, agent3 has 15 neurons:
%% Result = [
%% {agent1, [99.0]}, % 100.0 - (10 * 0.1)
%% {agent2, [99.5]}, % 100.0 - (5 * 0.1)
%% {agent3, [78.5]} % 80.0 - (15 * 0.1)
%% ]
%%
%% @param AgentFitnesses list of {AgentId, Fitness} tuples
%% @param PenaltyFactor penalty per neuron (typically 0.01 to 0.5)
%% @returns list of {AgentId, AdjustedFitness} tuples
-spec size_proportional([{term(), [float()]}], float()) -> [{term(), [float()]}].
size_proportional(AgentFitnesses, PenaltyFactor) ->
lists:map(
fun({AgentId, Fitness}) ->
%% Get agent's network size
Size = get_agent_size(AgentId),
%% Calculate penalty
Penalty = Size * PenaltyFactor,
%% Apply penalty to first fitness objective
%% (assumes first objective is primary performance metric)
AdjustedFitness = case Fitness of
[F1 | Rest] -> [F1 - Penalty | Rest];
[] -> []
end,
{AgentId, AdjustedFitness}
end,
AgentFitnesses
).
%% @doc Normalize fitness values to [0, 1] range.
%%
%% Scales each fitness objective independently to the range [0, 1]
%% based on the min and max values in the population. This prevents
%% objectives with large magnitudes from dominating selection.
%%
%% If all agents have the same fitness for an objective (no variance),
%% that objective is set to 0.5 for all agents.
%%
%% Example:
%% AgentFitnesses = [
%% {agent1, [100.0, 10.0]},
%% {agent2, [50.0, 20.0]},
%% {agent3, [0.0, 15.0]}
%% ]
%% Result = [
%% {agent1, [1.0, 0.0]}, % Max on F1, min on F2
%% {agent2, [0.5, 1.0]}, % Mid on F1, max on F2
%% {agent3, [0.0, 0.5]} % Min on F1, mid on F2
%% ]
%%
%% @param AgentFitnesses list of {AgentId, Fitness} tuples
%% @returns list of {AgentId, NormalizedFitness} tuples
-spec normalize([{term(), [float()]}]) -> [{term(), [float()]}].
normalize([]) ->
[];
normalize(AgentFitnesses) ->
%% Extract all fitness vectors
Fitnesses = [F || {_Id, F} <- AgentFitnesses],
%% Calculate min/max for each objective
MinMax = calculate_min_max(Fitnesses),
%% Normalize each agent's fitness
lists:map(
fun({AgentId, Fitness}) ->
NormalizedFitness = normalize_fitness(Fitness, MinMax),
{AgentId, NormalizedFitness}
end,
AgentFitnesses
).
%% @doc Assign Pareto ranks based on dominance relationships.
%%
%% Implements fast non-dominated sorting (NSGA-II algorithm) to assign
%% Pareto ranks to agents. Lower rank is better (rank 1 = non-dominated).
%%
%% Agent A dominates Agent B if:
%% - A is no worse than B in all objectives, AND
%% - A is strictly better than B in at least one objective
%%
%% The result is a single-objective fitness [Rank] where Rank indicates
%% which Pareto front the agent belongs to.
%%
%% Example:
%% AgentFitnesses = [
%% {agent1, [100.0, 10.0]}, % Good on F1, poor on F2
%% {agent2, [50.0, 50.0]}, % Balanced
%% {agent3, [10.0, 100.0]}, % Poor on F1, good on F2
%% {agent4, [30.0, 30.0]} % Dominated by agent2
%% ]
%% Result = [
%% {agent1, [1.0]}, % Front 1 (non-dominated)
%% {agent2, [1.0]}, % Front 1 (non-dominated)
%% {agent3, [1.0]}, % Front 1 (non-dominated)
%% {agent4, [2.0]} % Front 2 (dominated by agent2)
%% ]
%%
%% @param AgentFitnesses list of {AgentId, Fitness} tuples
%% @returns list of {AgentId, [Rank]} tuples where lower rank is better
-spec pareto_dominance([{term(), [float()]}]) -> [{term(), [float()]}].
pareto_dominance([]) ->
[];
pareto_dominance(AgentFitnesses) ->
%% Build dominance relationships
Dominates = build_dominance_map(AgentFitnesses),
%% Assign ranks (fronts)
Ranks = assign_pareto_ranks(AgentFitnesses, Dominates),
%% Convert ranks to fitness format
[{AgentId, [float(Rank)]} || {AgentId, Rank} <- Ranks].
%% ============================================================================
%% Internal Functions - Size Proportional
%% ============================================================================
%% @private Get number of neurons in agent's network.
-spec get_agent_size(term()) -> non_neg_integer().
get_agent_size(AgentId) ->
case genotype:read({agent, AgentId}) of
undefined ->
0; % Agent not found, assume size 0
Agent ->
CortexId = Agent#agent.cx_id,
case genotype:read({cortex, CortexId}) of
undefined -> 0;
Cortex -> length(Cortex#cortex.neuron_ids)
end
end.
%% ============================================================================
%% Internal Functions - Normalize
%% ============================================================================
%% @private Calculate min and max for each fitness objective.
-spec calculate_min_max([[float()]]) -> [{float(), float()}].
calculate_min_max([]) ->
[];
calculate_min_max([FirstFitness | _] = Fitnesses) ->
%% Initialize with first fitness
NumObjectives = length(FirstFitness),
%% For each objective index
lists:map(
fun(ObjIdx) ->
%% Extract values for this objective
Values = [lists:nth(ObjIdx, F) || F <- Fitnesses, length(F) >= ObjIdx],
%% Find min and max
case Values of
[] -> {0.0, 1.0}; % Default range
_ ->
Min = lists:min(Values),
Max = lists:max(Values),
{Min, Max}
end
end,
lists:seq(1, NumObjectives)
).
%% @private Normalize a single fitness vector.
-spec normalize_fitness([float()], [{float(), float()}]) -> [float()].
normalize_fitness(Fitness, MinMax) ->
lists:zipwith(
fun(Value, {Min, Max}) ->
case Max - Min of
+0.0 -> 0.5; % No variance, use middle value
Range -> (Value - Min) / Range
end
end,
Fitness,
MinMax
).
%% ============================================================================
%% Internal Functions - Pareto Dominance
%% ============================================================================
%% @private Build map of which agents dominate which others.
-spec build_dominance_map([{term(), [float()]}]) ->
#{term() => {[term()], non_neg_integer()}}.
build_dominance_map(AgentFitnesses) ->
%% For each agent, track:
%% - List of agents it dominates
%% - Count of agents that dominate it
lists:foldl(
fun({AgentId, Fitness}, Map) ->
{Dominated, DominatedBy} = find_dominance_relationships(
AgentId, Fitness, AgentFitnesses
),
maps:put(AgentId, {Dominated, DominatedBy}, Map)
end,
#{},
AgentFitnesses
).
%% @private Find which agents this agent dominates and is dominated by.
-spec find_dominance_relationships(term(), [float()], [{term(), [float()]}]) ->
{[term()], non_neg_integer()}.
find_dominance_relationships(AgentId, Fitness, AllAgents) ->
lists:foldl(
fun({OtherId, OtherFitness}, {Dominated, DominatedBy}) ->
if
OtherId =:= AgentId ->
{Dominated, DominatedBy};
true ->
case dominates(Fitness, OtherFitness) of
true ->
%% This agent dominates the other
{[OtherId | Dominated], DominatedBy};
false ->
case dominates(OtherFitness, Fitness) of
true ->
%% Other agent dominates this one
{Dominated, DominatedBy + 1};
false ->
%% No dominance relationship
{Dominated, DominatedBy}
end
end
end
end,
{[], 0},
AllAgents
).
%% @private Check if fitness A dominates fitness B.
%%
%% A dominates B if:
%% - A is greater than or equal to B for all objectives (no worse)
%% - A is strictly greater than B for at least one objective (strictly better)
-spec dominates([float()], [float()]) -> boolean().
dominates(FitnessA, FitnessB) ->
%% Check both conditions
NoWorse = lists:all(
fun({A, B}) -> A >= B end,
lists:zip(FitnessA, FitnessB)
),
StrictlyBetter = lists:any(
fun({A, B}) -> A > B end,
lists:zip(FitnessA, FitnessB)
),
NoWorse andalso StrictlyBetter.
%% @private Assign Pareto ranks using fast non-dominated sorting.
-spec assign_pareto_ranks([{term(), [float()]}],
#{term() => {[term()], non_neg_integer()}}) ->
[{term(), pos_integer()}].
assign_pareto_ranks(AgentFitnesses, DominanceMap) ->
%% Find first front (non-dominated agents)
FirstFront = [Id || {Id, {_Dominated, 0}} <- maps:to_list(DominanceMap)],
%% Get all agent IDs for verification
AllAgentIds = [Id || {Id, _F} <- AgentFitnesses],
%% Assign ranks recursively, tracking processed agents
Result = assign_ranks_recursive(FirstFront, DominanceMap, 1, [], sets:new()),
%% Ensure all agents were ranked (safety check)
RankedIds = [Id || {Id, _Rank} <- Result],
case length(RankedIds) =:= length(AllAgentIds) of
true -> Result;
false ->
%% Some agents weren't ranked - this shouldn't happen with correct algorithm
%% But assign remaining agents to final front as fallback
Unranked = AllAgentIds -- RankedIds,
MaxRank = case Result of
[] -> 1;
_ -> lists:max([R || {_Id, R} <- Result])
end,
Result ++ [{Id, MaxRank + 1} || Id <- Unranked]
end.
%% @private Recursively assign ranks to Pareto fronts.
-spec assign_ranks_recursive([term()],
#{term() => {[term()], non_neg_integer()}},
pos_integer(),
[{term(), pos_integer()}],
sets:set(term())) ->
[{term(), pos_integer()}].
assign_ranks_recursive([], _DominanceMap, _Rank, Acc, _Processed) ->
lists:reverse(Acc);
assign_ranks_recursive(CurrentFront, DominanceMap, Rank, Acc, Processed) ->
%% Assign current rank to all agents in this front
RankedAgents = [{Id, Rank} || Id <- CurrentFront],
%% Mark current front as processed
NewProcessed = lists:foldl(fun sets:add_element/2, Processed, CurrentFront),
%% Find next front (excluding already processed agents)
NextFront = find_next_front(CurrentFront, DominanceMap, NewProcessed),
%% Continue with next front
assign_ranks_recursive(NextFront, DominanceMap, Rank + 1,
RankedAgents ++ Acc, NewProcessed).
%% @private Find the next Pareto front.
-spec find_next_front([term()],
#{term() => {[term()], non_neg_integer()}},
sets:set(term())) ->
[term()].
find_next_front(CurrentFront, DominanceMap, Processed) ->
%% For each agent in current front, decrement dominated count
%% for agents it dominates
DecrementedMap = lists:foldl(
fun(AgentId, Map) ->
case maps:get(AgentId, Map, {[], 0}) of
{Dominated, _Count} ->
%% Decrement count for each dominated agent
lists:foldl(
fun(DomId, M) ->
case maps:get(DomId, M, {[], 0}) of
{DomList, DomCount} ->
maps:put(DomId, {DomList, DomCount - 1}, M)
end
end,
Map,
Dominated
)
end
end,
DominanceMap,
CurrentFront
),
%% Next front = agents with count 0 (excluding already processed)
[Id || {Id, {_Dominated, 0}} <- maps:to_list(DecrementedMap),
not sets:is_element(Id, Processed)].