/* * Copyright (c) Contributors to the Open 3D Engine Project. * For complete copyright and license terms please see the LICENSE at the root of this distribution. * * SPDX-License-Identifier: Apache-2.0 OR MIT * */ #include #include #include #include #include #include #include #include #include #include namespace MachineLearning { AZ_CVAR(bool, ml_logGradients, false, nullptr, AZ::ConsoleFunctorFlags::Null, "Dumps some gradient metrics so they can be monitored during training"); AZ_CVAR(bool, ml_logGradientsVerbose, false, nullptr, AZ::ConsoleFunctorFlags::Null, "Dumps complete gradient values to the console for examination, this can be a significant amount of data"); void DumpVectorGradients(const AZ::VectorN& value, const char* label) { AZStd::string vectorString(label); for (AZStd::size_t iter = 0; iter < value.GetDimensionality(); ++iter) { vectorString += AZStd::string::format(" %.02f", value.GetElement(iter)); } AZLOG_INFO(vectorString.c_str()); } void DumpMatrixGradients(const AZ::MatrixMxN& value, const char* label) { for (AZStd::size_t i = 0; i < value.GetRowCount(); ++i) { AZStd::string rowString(label); rowString += AZStd::string::format(":%u", static_cast(i)); for (AZStd::size_t j = 0; j < value.GetColumnCount(); ++j) { rowString += AZStd::string::format(" %.02f", value.GetElement(i, j)); } AZLOG_INFO(rowString.c_str()); } } void AccumulateBiasGradients(AZ::VectorN& biasGradients, const AZ::VectorN& activationGradients, AZStd::size_t currentSamples) { AZ::Vector4 divisor(static_cast(currentSamples)); AZStd::vector& biasValues = biasGradients.GetVectorValues(); const AZStd::vector& activationValues = activationGradients.GetVectorValues(); for (AZStd::size_t iter = 0; iter < biasValues.size(); ++iter) { // average += (next - average) / samples biasValues[iter] += (activationValues[iter] - biasValues[iter]) / divisor; } } void AccumulateWeightGradients(const AZ::VectorN& activationGradients, const AZ::VectorN& lastInput, AZ::MatrixMxN& weightGradients, AZStd::size_t currentSamples) { // The following performs an outer product between activationGradients and lastInput // The reason we're not simply iteratively invoking OuterProduct is so that we can compute a more numerically stable average and preserve our gradients better over large batch sizes const AZ::Simd::Vec4::FloatType divisor = AZ::Simd::Vec4::Splat(static_cast(currentSamples)); for (AZStd::size_t colIter = 0; colIter < weightGradients.GetColumnGroups(); ++colIter) { AZ::Simd::Vec4::FloatType rhsElement = lastInput.GetVectorValues()[colIter].GetSimdValue(); AZ::Simd::Vec4::FloatType splat0 = AZ::Simd::Vec4::SplatIndex0(rhsElement); AZ::Simd::Vec4::FloatType splat1 = AZ::Simd::Vec4::SplatIndex1(rhsElement); AZ::Simd::Vec4::FloatType splat2 = AZ::Simd::Vec4::SplatIndex2(rhsElement); AZ::Simd::Vec4::FloatType splat3 = AZ::Simd::Vec4::SplatIndex3(rhsElement); for (AZStd::size_t rowIter = 0; rowIter < weightGradients.GetRowGroups(); ++rowIter) { AZ::Simd::Vec4::FloatType lhsElement = activationGradients.GetVectorValues()[rowIter].GetSimdValue(); AZ::Matrix4x4& outputElement = weightGradients.GetSubmatrix(rowIter, colIter); AZ::Simd::Vec4::FloatType next0 = AZ::Simd::Vec4::Sub(AZ::Simd::Vec4::Mul(lhsElement, splat0), outputElement.GetSimdValues()[0]); AZ::Simd::Vec4::FloatType next1 = AZ::Simd::Vec4::Sub(AZ::Simd::Vec4::Mul(lhsElement, splat1), outputElement.GetSimdValues()[1]); AZ::Simd::Vec4::FloatType next2 = AZ::Simd::Vec4::Sub(AZ::Simd::Vec4::Mul(lhsElement, splat2), outputElement.GetSimdValues()[2]); AZ::Simd::Vec4::FloatType next3 = AZ::Simd::Vec4::Sub(AZ::Simd::Vec4::Mul(lhsElement, splat3), outputElement.GetSimdValues()[3]); // average += (next - average) / samples outputElement.GetSimdValues()[0] = AZ::Simd::Vec4::Add(outputElement.GetSimdValues()[0], AZ::Simd::Vec4::Div(next0, divisor)); outputElement.GetSimdValues()[1] = AZ::Simd::Vec4::Add(outputElement.GetSimdValues()[1], AZ::Simd::Vec4::Div(next1, divisor)); outputElement.GetSimdValues()[2] = AZ::Simd::Vec4::Add(outputElement.GetSimdValues()[2], AZ::Simd::Vec4::Div(next2, divisor)); outputElement.GetSimdValues()[3] = AZ::Simd::Vec4::Add(outputElement.GetSimdValues()[3], AZ::Simd::Vec4::Div(next3, divisor)); } } weightGradients.FixUnusedElements(); } void GetMinMaxElements(const AZ::VectorN& source, float& min, float& max) { const AZStd::vector& elements = source.GetVectorValues(); if (!elements.empty()) { AZ::Vector4 minimum = elements[0]; AZ::Vector4 maximum = elements[0]; for (AZStd::size_t i = 1; i < elements.size(); ++i) { minimum.GetMin(elements[i]); maximum.GetMax(elements[i]); } min = AZ::GetMin(AZ::GetMin(minimum.GetX(), minimum.GetY()), AZ::GetMin(minimum.GetZ(), minimum.GetW())); max = AZ::GetMax(AZ::GetMax(minimum.GetX(), minimum.GetY()), AZ::GetMax(minimum.GetZ(), minimum.GetW())); } } void GetMinMaxElements(AZ::MatrixMxN& source, float& min, float& max) { AZStd::vector& elements = source.GetMatrixElements(); if (!elements.empty()) { AZ::Vector4 minimum = elements[0].GetRow(0); AZ::Vector4 maximum = elements[0].GetRow(0); for (AZStd::size_t i = 1; i < elements.size(); ++i) { for (int32_t j = 0; j < 4; ++j) { minimum = minimum.GetMin(elements[i].GetRow(j)); maximum = maximum.GetMax(elements[i].GetRow(j)); } } min = AZ::GetMin(AZ::GetMin(minimum.GetX(), minimum.GetY()), AZ::GetMin(minimum.GetZ(), minimum.GetW())); max = AZ::GetMax(AZ::GetMax(minimum.GetX(), minimum.GetY()), AZ::GetMax(minimum.GetZ(), minimum.GetW())); } } void Layer::Reflect(AZ::ReflectContext* context) { if (auto serializeContext = azrtti_cast(context)) { serializeContext->Class() ->Version(1) ->Field("OutputSize", &Layer::m_outputSize) ->Field("ActivationFunction", &Layer::m_activationFunction) ; if (AZ::EditContext* editContext = serializeContext->GetEditContext()) { editContext->Class("A single layer of a neural network", "") ->ClassElement(AZ::Edit::ClassElements::EditorData, "") ->DataElement(AZ::Edit::UIHandlers::Default, &Layer::m_outputSize, "Layer Size", "The number of neurons the layer should have") ->Attribute(AZ::Edit::Attributes::ChangeNotify, &Layer::OnSizesChanged) ->DataElement(AZ::Edit::UIHandlers::ComboBox, &Layer::m_activationFunction, "Activation Function", "The activation function applied to this layer") ->Attribute(AZ::Edit::Attributes::EnumValues, &GetActivationEnumValues) ; } } auto behaviorContext = azrtti_cast(context); if (behaviorContext) { behaviorContext->Class()-> Attribute(AZ::Script::Attributes::Scope, AZ::Script::Attributes::ScopeFlags::Common)-> Attribute(AZ::Script::Attributes::Module, "machineLearning")-> Attribute(AZ::Script::Attributes::ExcludeFrom, AZ::Script::Attributes::ExcludeFlags::ListOnly)-> Constructor()-> Attribute(AZ::Script::Attributes::Storage, AZ::Script::Attributes::StorageType::Value)-> Property("OutputSize", BehaviorValueProperty(&Layer::m_outputSize))-> Property("ActivationFunction", BehaviorValueProperty(&Layer::m_activationFunction)) ; } } Layer::Layer(ActivationFunctions activationFunction, AZStd::size_t activationDimensionality, AZStd::size_t layerDimensionality) : m_activationFunction(activationFunction) , m_inputSize(activationDimensionality) , m_outputSize(layerDimensionality) { OnSizesChanged(); } const AZ::VectorN& Layer::Forward(LayerInferenceData& inferenceData, const AZ::VectorN& activations) { inferenceData.m_output = m_biases; AZ::VectorMatrixMultiply(m_weights, activations, inferenceData.m_output); Activate(m_activationFunction, inferenceData.m_output, inferenceData.m_output); return inferenceData.m_output; } void Layer::AccumulateGradients(AZStd::size_t samples, LayerTrainingData& trainingData, LayerInferenceData& inferenceData, const AZ::VectorN& previousLayerGradients) { // Ensure our bias gradient vector is appropriately sized if (trainingData.m_biasGradients.GetDimensionality() != m_outputSize) { trainingData.m_biasGradients = AZ::VectorN::CreateZero(m_outputSize); } // Ensure our weight gradient matrix is appropriately sized if ((trainingData.m_weightGradients.GetRowCount() != m_outputSize) || (trainingData.m_weightGradients.GetColumnCount() != m_inputSize)) { trainingData.m_weightGradients = AZ::MatrixMxN::CreateZero(m_outputSize, m_inputSize); } // Ensure our backpropagation gradient vector is appropriately sized if (trainingData.m_backpropagationGradients.GetDimensionality() != m_inputSize) { trainingData.m_backpropagationGradients = AZ::VectorN::CreateZero(m_inputSize); } // Compute the partial derivatives of the output with respect to the activation function Activate_Derivative(m_activationFunction, inferenceData.m_output, previousLayerGradients, trainingData.m_activationGradients); // Accumulate the partial derivatives of the weight matrix with respect to the loss function AccumulateWeightGradients(trainingData.m_activationGradients, *trainingData.m_lastInput, trainingData.m_weightGradients, samples); // Accumulate the partial derivatives of the bias vector with respect to the loss function AccumulateBiasGradients(trainingData.m_biasGradients, trainingData.m_activationGradients, samples); // Accumulate the gradients to pass to the preceding layer for back-propagation AZ::VectorMatrixMultiplyLeft(trainingData.m_activationGradients, m_weights, trainingData.m_backpropagationGradients); if (ml_logGradients) { float min = 0.f; float max = 0.f; GetMinMaxElements(trainingData.m_weightGradients, min, max); AZLOG_INFO("Weight gradients: min value %f, max value %f", min, max); GetMinMaxElements(trainingData.m_biasGradients, min, max); AZLOG_INFO("Bias gradients: min value %f, max value %f", min, max); GetMinMaxElements(trainingData.m_backpropagationGradients, min, max); AZLOG_INFO("Back-propagation gradients: min value %f, max value %f", min, max); } if (ml_logGradientsVerbose) { DumpMatrixGradients(trainingData.m_weightGradients, "WeightGradients"); DumpVectorGradients(trainingData.m_biasGradients, "BiasGradients"); } } void Layer::ApplyGradients(LayerTrainingData& trainingData, float learningRate) { m_weights -= trainingData.m_weightGradients * learningRate; m_biases -= trainingData.m_biasGradients * learningRate; trainingData.m_biasGradients.SetZero(); trainingData.m_weightGradients.SetZero(); trainingData.m_backpropagationGradients.SetZero(); } bool Layer::Serialize(AzNetworking::ISerializer& serializer) { return serializer.Serialize(m_inputSize, "inputSize") && serializer.Serialize(m_outputSize, "outputSize") && serializer.Serialize(m_weights, "weights") && serializer.Serialize(m_biases, "biases") && serializer.Serialize(m_activationFunction, "activationFunction"); } AZStd::size_t Layer::EstimateSerializeSize() const { const AZStd::size_t padding = 64; // 64 bytes of extra padding just in case return padding + sizeof(m_inputSize) + sizeof(m_outputSize) + sizeof(AZStd::size_t) // for m_weights row count + sizeof(AZStd::size_t) // for m_weights column count + sizeof(AZStd::size_t) // for m_weights vector size + sizeof(float) * m_outputSize * m_inputSize // m_weights buffer + sizeof(AZStd::size_t) // for m_biases dimensionality + sizeof(AZStd::size_t) // for m_biases vector size + sizeof(float) * m_outputSize // m_biases buffer + sizeof(m_activationFunction); } void Layer::OnSizesChanged() { // Specifically for ReLU, we use Kaiming He initialization as this is optimal for convergence // For other activation functions we just use a standard normal distribution float standardDeviation = (m_activationFunction == ActivationFunctions::ReLU) ? 2.0f / m_inputSize : 1.0f / m_inputSize; std::random_device rd{}; std::mt19937 gen{ rd() }; auto dist = std::normal_distribution{ 0.0f, standardDeviation }; m_weights.Resize(m_outputSize, m_inputSize); for (AZStd::size_t row = 0; row < m_weights.GetRowCount(); ++row) { for (AZStd::size_t col = 0; col < m_weights.GetRowCount(); ++col) { m_weights.SetElement(row, col, dist(gen)); } } m_biases = AZ::VectorN(m_outputSize, 0.01f); } }