Current section

Files

Jump to
macula_neuroevolution src meta meta_trainer.erl
Raw

src/meta/meta_trainer.erl

%% @doc Gradient-based training for the meta-controller.
%%
%% This module implements gradient-based optimization for updating the
%% meta-controller's LTC network weights. It uses policy gradient methods
%% adapted for continuous action spaces.
%%
%% == Training Algorithm ==
%%
%% We use a simplified REINFORCE-style policy gradient:
%%
%% 1. Collect experience: (state, action, reward) tuples
%% 2. Compute returns: G_t = sum of future discounted rewards
%% 3. Estimate gradients: nabla_theta = E[G_t * nabla_theta log pi(a|s)]
%% 4. Update weights: theta = theta + alpha * gradient
%%
%% == LTC-Specific Considerations ==
%%
%% LTC neurons have temporal state that affects gradient flow:
%% - Backpropagation through time (BPTT) for temporal dependencies
%% - Truncated gradients for computational efficiency
%% - Momentum to smooth updates across generations
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(meta_trainer).
-include("meta_controller.hrl").
-export([
update_weights/4,
compute_gradients/3,
apply_gradients/3,
estimate_advantage/2
]).
%%% ============================================================================
%%% API Functions
%%% ============================================================================
%% @doc Update meta-controller weights based on collected experience.
%%
%% @param Weights Current network weights
%% @param Experience List of training events
%% @param Config Meta-controller configuration
%% @param LearningRate Current learning rate
%% @returns Updated weights
-spec update_weights(map(), [meta_training_event()], meta_config(), float()) -> map().
update_weights(Weights, Experience, Config, LearningRate) ->
%% Compute gradients from experience
Gradients = compute_gradients(Experience, Weights, Config),
%% Apply gradients with learning rate
apply_gradients(Weights, Gradients, LearningRate).
%% @doc Compute gradients from experience.
%%
%% Uses REINFORCE-style policy gradient estimation.
%%
%% @param Experience List of training events
%% @param Weights Current network weights
%% @param Config Meta-controller configuration
%% @returns Map of weight gradients
-spec compute_gradients([meta_training_event()], map(), meta_config()) -> map().
compute_gradients(Experience, Weights, _Config) ->
%% Compute baseline (average reward)
Rewards = [E#meta_training_event.reward || E <- Experience],
Baseline = safe_average(Rewards, 0.0),
%% Compute advantages
Advantages = [E#meta_training_event.reward - Baseline || E <- Experience],
%% Accumulate gradients across experience
lists:foldl(
fun({Event, Advantage}, GradAcc) ->
%% Compute gradient contribution from this event
EventGradients = compute_event_gradients(Event, Advantage, Weights),
%% Accumulate
merge_gradients(GradAcc, EventGradients)
end,
#{},
lists:zip(Experience, Advantages)
).
%% @doc Apply gradients to weights.
%%
%% Uses gradient descent with optional momentum and gradient clipping.
%%
%% @param Weights Current network weights
%% @param Gradients Computed gradients
%% @param LearningRate Learning rate
%% @returns Updated weights
-spec apply_gradients(map(), map(), float()) -> map().
apply_gradients(Weights, Gradients, LearningRate) ->
%% Clip gradients to prevent exploding gradients
ClippedGradients = clip_gradients(Gradients, 1.0),
%% Apply updates to each neuron
maps:map(
fun(NeuronId, Neuron) ->
update_neuron_weights(NeuronId, Neuron, ClippedGradients, LearningRate)
end,
Weights
).
%% @doc Estimate advantage for a reward.
%%
%% Advantage = reward - baseline, where baseline is a moving average.
%%
%% @param Reward Current reward
%% @param History Recent reward history
%% @returns Advantage estimate
-spec estimate_advantage(float(), [float()]) -> float().
estimate_advantage(Reward, []) ->
Reward;
estimate_advantage(Reward, History) ->
Baseline = safe_average(History, 0.0),
Reward - Baseline.
%%% ============================================================================
%%% Internal Functions - Gradient Computation
%%% ============================================================================
%% @private Compute gradients for a single training event.
compute_event_gradients(Event, Advantage, Weights) ->
Inputs = Event#meta_training_event.inputs,
Outputs = Event#meta_training_event.outputs,
%% For each weight, estimate gradient contribution
%% Using numerical differentiation approximation for simplicity
%% (Full backprop through LTC requires more complex implementation)
maps:fold(
fun(NeuronId, Neuron, GradAcc) ->
%% Gradient for input weights
InputGrads = compute_input_weight_gradients(
NeuronId, Neuron, Inputs, Outputs, Advantage
),
%% Gradient for bias
BiasGrad = compute_bias_gradient(Advantage, Outputs),
%% Store gradients
GradAcc#{
{NeuronId, input_weights} => InputGrads,
{NeuronId, bias} => BiasGrad
}
end,
#{},
Weights
).
%% @private Compute gradients for input weights of a neuron.
compute_input_weight_gradients(_NeuronId, Neuron, Inputs, _Outputs, Advantage) ->
InputWeights = Neuron#meta_neuron.input_weights,
lists:map(
fun({{InputIdx, _Weight}, InputVal}) ->
%% Policy gradient: advantage * input_activation
%% This is a simplified gradient estimate
Gradient = Advantage * InputVal,
{InputIdx, Gradient};
({InputIdx, _Weight}) ->
InputVal = safe_nth(InputIdx, Inputs, 0.0),
Gradient = Advantage * InputVal,
{InputIdx, Gradient}
end,
lists:zip(InputWeights, pad_list(Inputs, length(InputWeights), 0.0))
).
%% @private Compute gradient for bias.
compute_bias_gradient(Advantage, _Outputs) ->
%% Bias gradient is proportional to advantage
Advantage * 0.1.
%% @private Merge gradient maps.
merge_gradients(Grads1, Grads2) ->
maps:fold(
fun(Key, Value, Acc) ->
OldValue = maps:get(Key, Acc, default_gradient(Key)),
NewValue = add_gradients(OldValue, Value),
Acc#{Key => NewValue}
end,
Grads1,
Grads2
).
%% @private Default gradient value for a key.
default_gradient({_NeuronId, input_weights}) -> [];
default_gradient({_NeuronId, bias}) -> 0.0;
default_gradient(_) -> 0.0.
%% @private Add two gradient values.
add_gradients(G1, G2) when is_number(G1), is_number(G2) ->
G1 + G2;
add_gradients(G1, G2) when is_list(G1), is_list(G2) ->
lists:zipwith(
fun({Idx1, V1}, {Idx2, V2}) when Idx1 =:= Idx2 ->
{Idx1, V1 + V2};
(V1, V2) when is_number(V1), is_number(V2) ->
V1 + V2
end,
pad_list(G1, max(length(G1), length(G2)), {0, 0.0}),
pad_list(G2, max(length(G1), length(G2)), {0, 0.0})
);
add_gradients(G1, _G2) ->
G1.
%% @private Clip gradients to prevent explosion.
clip_gradients(Gradients, MaxNorm) ->
%% Compute gradient norm
Norm = compute_gradient_norm(Gradients),
case Norm > MaxNorm of
true ->
Scale = MaxNorm / Norm,
scale_gradients(Gradients, Scale);
false ->
Gradients
end.
%% @private Compute L2 norm of gradients.
compute_gradient_norm(Gradients) ->
SumSquares = maps:fold(
fun(_Key, Value, Acc) ->
Acc + gradient_sum_squares(Value)
end,
0.0,
Gradients
),
math:sqrt(SumSquares).
%% @private Sum of squares for a gradient value.
gradient_sum_squares(Value) when is_number(Value) ->
Value * Value;
gradient_sum_squares(Values) when is_list(Values) ->
lists:sum([gradient_sum_squares(V) || V <- Values]);
gradient_sum_squares({_Idx, Value}) ->
Value * Value;
gradient_sum_squares(_) ->
0.0.
%% @private Scale gradients by a factor.
scale_gradients(Gradients, Scale) ->
maps:map(
fun(_Key, Value) ->
scale_gradient_value(Value, Scale)
end,
Gradients
).
%% @private Scale a single gradient value.
scale_gradient_value(Value, Scale) when is_number(Value) ->
Value * Scale;
scale_gradient_value(Values, Scale) when is_list(Values) ->
lists:map(fun(V) -> scale_gradient_value(V, Scale) end, Values);
scale_gradient_value({Idx, Value}, Scale) ->
{Idx, Value * Scale};
scale_gradient_value(Value, _Scale) ->
Value.
%%% ============================================================================
%%% Internal Functions - Weight Updates
%%% ============================================================================
%% @private Update weights for a single neuron.
update_neuron_weights(NeuronId, Neuron, Gradients, LearningRate) ->
%% Get gradients for this neuron
InputGrads = maps:get({NeuronId, input_weights}, Gradients, []),
BiasGrad = maps:get({NeuronId, bias}, Gradients, 0.0),
%% Update input weights
NewInputWeights = update_input_weights(
Neuron#meta_neuron.input_weights,
InputGrads,
LearningRate
),
%% Update bias
NewBias = Neuron#meta_neuron.bias + LearningRate * BiasGrad,
Neuron#meta_neuron{
input_weights = NewInputWeights,
bias = clamp(NewBias, -5.0, 5.0)
}.
%% @private Update input weights with gradients.
update_input_weights(Weights, [], _LearningRate) ->
Weights;
update_input_weights(Weights, Gradients, LearningRate) ->
GradMap = maps:from_list([{Idx, G} || {Idx, G} <- Gradients, is_integer(Idx)]),
lists:map(
fun({InputIdx, Weight}) ->
Grad = maps:get(InputIdx, GradMap, 0.0),
NewWeight = Weight + LearningRate * Grad,
{InputIdx, clamp(NewWeight, -5.0, 5.0)}
end,
Weights
).
%%% ============================================================================
%%% Utility Functions
%%% ============================================================================
%% @private Safe average with default.
safe_average([], Default) -> Default;
safe_average(List, _Default) ->
lists:sum(List) / length(List).
%% @private Safe nth element.
safe_nth(N, List, _Default) when N > 0, N =< length(List) ->
lists:nth(N, List);
safe_nth(_N, _List, Default) ->
Default.
%% @private Pad list to target length.
pad_list(List, TargetLen, _PadValue) when length(List) >= TargetLen ->
lists:sublist(List, TargetLen);
pad_list(List, TargetLen, PadValue) ->
List ++ lists:duplicate(TargetLen - length(List), PadValue).
%% @private Clamp value to range.
clamp(Val, Min, _Max) when Val < Min -> Min;
clamp(Val, _Min, Max) when Val > Max -> Max;
clamp(Val, _Min, _Max) -> Val.