Алгоритм обратного распространения ошибки с регуляризацией на c#

в 11:39, , рубрики: .net, data mining, алгоритм, искусственный интеллект, нейронные сети, обратное распространение ошибки, регуляризация, метки: , , , , ,

Привет. Я хочу продолжить тему реализации методов машинного обучения на c#, и в этой статье я расскажу про алгоритм обратного распространения ошибки для обучения нейронной сети прямого распространения, а также приведу его реализацию на языке C#. Особенность данной реализации в том, что реализация алгоритма абстрагирована от реализаций целевой функции (той, которую нейросеть пытается минимизировать) и функции активации нейронов. В итоге получится некий конструктор, с помощью которого можно поиграться с различными параметрами сети и алгоритма обучения, посмотреть и сравнить результат. Предполагается, что вы уже знакомы с тем, что такое искусственная нейросеть (если нет, то настоятельно рекомендую для начала изучить википедию или одну из подобных статей). Интересно? Лезем под кат.

Обозначения

Для начала рассмотрим обозначения, которые я буду использовать в статье, а за одно вспомним основные понятия, я не буду приводить картинок с нейронами и слоями, этого всего полно в википедии и здесь, на хабре. Итак, сразу в бой, индуцированное локальное поле нейрона (или просто сумматор) выглядит следующим образом:
image

  • image — значение линейной комбинации вектора весов и вектора входных значений, j-ого нейрона слоя n
  • b — сдвиг или смещение нейрона; если условиться, что в нулевом значении входного вектора находится всегда единица image, то сдвиг можно обозначить как вес с нулевым индексом, и формулу можно будет упростить
  • image — количество нейронов слоя n

Функция активации нейрона, или передаточная функция от значения сумматора:
image

  • у каждого нейрона сети может быть своя функция активации
  • для всех слоев кроме первого, входным вектором будет являться выходной вектор предыдущего слоя, так что image

От нейрона перейдем к самой сети. Нейросеть — это модель, она обладает параметрами, и задача алгоритма обучения заключается в подборе таких параметров сети, чтобы минимизоровать значение функции ошибки. Функцию ошибки будем обозначать через E. Параметрами модели являются веса нейронов: image — вес j-ого нейрона слоя n, который берет свое начало в i-ом нейроне слоя (n — 1).

Греческой эта image обозначим гиперпараметр алгоритма обучения — скорость обучения.

Изменение веса обозначим через дельта:
image

  • направление градиента показывает нам в сторону роста значения функции, но нам для минимизации необходимо двигаться в обратном направлении

Таким образом, новый вес нейрона выглядит следующим образом: image
Стоит упомянуть, что к изменению веса еще можно (или, скорее, нужно) добавить регуляризацию. Функция регуляризации R — это функция от параметров модели, в нашем случае это веса нейронов. Таким образом, новая функция ошибки выглядит как E + R, а формула изменения веса преобразуется в следующую:
image

  • лямбда — гиперпараметр обучения, коэффициент регуляризации (похож на скорость обучения)
  • m — размер обучающей выборки

Вообще говоря, реализацию регуляризации тоже можно абстрагировать от алгоритма обучения, но я пока этого делать не буду, поскольку текущая реализация алгоритма обучения и так не самая быстрая, поскольку в противному случае на каждой эпохе обучения (прогон всех обучающих примеров) придется в одном цикле считать аккумулированную ошибку, а в другом — регуляризацию.Еще одна причина заключается в том, что существует не так много видов регуляризации (я, например, знаю только L1 и L2), которые применяются при обучении нейросетей. В данной реализации я буду использовать L2 норму, и она будет неотъемлемой частью алгоритма обучения.

  • image
  • image

Алгоритм обратного распространения ошибки

Для начала остановимся на режимах обучения. Изменять веса можно несколькими способами:

  • либо после каждого обучающего примера (обучение в реальном режиме времени, online обучение, batchSize = 1)
  • либо накопить изменения для всей обучающей выборки, а затем изменить все веса (full-batch, batchSize = trainingSet.Length)
  • либо после прогона некоторого количества обучающих примеров (batchSize = any_number < trainingSet.Length)

Рассмотрим ситуацию с онлайн-обучением, так будет проще. Итак, на вход сети пришел импульс image, сеть выдала отклик image, хотя правильной реакцией на x, является image.
Рассмотрим частную производную функции ошибки E:

  • image
    • но так как функцию ошибки можно выразить только через сумматор, а сумматор зависит от веса, то мы можем переписать эту запись следующим образом
  • image
  • image
    • так вычисляется частная производная сумматора по весу, тогда можно переписать выражение частной производной целевой функции по весу; в итоге получаем
  • image

Дальнейшее рассуждение разделяется на две ветки: для последнего слоя и для остальных слоев.

Выходной слой

Для выходного слоя все просто, для коррекции ошибки нам достаточно вычислить производную целевой функции по одному из весов и вычислить значение дельты. Учтем, что целевая функция полностью зависит только от выходного значения нейрона, или значения функции активации, а сама функция активации зависит только от сумматора

  • image

Тут видно, что для вычисления ошибки выходного слоя нужно вычислить значение частных производных в точках, независимо от того, какая у нас целевая функция или же функция активации нейрона.

Любой скрытый слой

Но если слой не выходной, то нам нужно аккумулировать значения ошибок всех последующих слоев.

  • image
    • далее учтем, что сумматор следующего слоя зависит только от выходов текущего слоя, а выходы текущего зависят только от сумматоров текущего слоя
  • image
  • image
    • производная сумматора по выходам предыдущего слоя выдаст нам просто вес между нейронами, учитывая это перепишем основной вывод
  • image

PS: я заметил, что в верхних индексах забыл ставить скобки, чтобы обозначить, что это не степень, а индекс слоя; учтите это, пока степеней нигде не было.

Что же мы имеем:

  • вычисление производной некой функции активации — это как раз то что нужно
  • вычисление частной производной целевой функции по значению сумматора следующего слоя; тут тоже все просто, мы ведь находимся не на последнем слое, и вычисление изменений весов ведем от последнего к первому, так что это значение уже вычислено на предыдущем шаге
    • image
    • в случае, если следующий слой последний, то мы вычислим это значение для текущего слоя, и таким образом распространим решение на всю сеть

Реализация

Функция ошибки

С формулами покончили, давайте перейдем к реализации, и начнем с понятия функции ошибки. У меня это представлено в виде метрики (по сути, это так и есть). Метод CalculatePartialDerivaitveByV2Index вычисляет значение частной производной функции для входных векторов по индексу переменное из v2.

    public interface IMetrics<T>
    {
        double Calculate(T[] v1, T[] v2);

        /// <summary>
        /// Calculate value of partial derivative by v2[v2Index]
        /// </summary>
        T CalculatePartialDerivaitveByV2Index(T[] v1, T[] v2, int v2Index);
    }

Таким образом, мы можем вычислить значение частной производной функции ошибки для последнего слоя по реальному выходу сети image.

Для примера давайте напишем несколько реализаций.

Минимизация половины квадрата Евклидова расстояния

image
А производная будет выглядеть следующим образом:
image

internal class HalfSquaredEuclidianDistance : IMetrics<T>
{
    public override double Calculate(double[] v1, double[] v2)
    {
        double d = 0;
        for (int i = 0; i < v1.Length; i++)
        {
            d += (v1[i] - v2[i]) * (v1[i] - v2[i]);
        }
        return 0.5 * d;
    }

    public override double CalculatePartialDerivaitveByV2Index(double[] v1, double[] v2, int v2Index)
    {
        return v2[v2Index] - v1[v2Index];
    }
}

Минимизация логарифмического правдоподобия

image
image

internal class Loglikelihood : IMetrics<double>
{
    public override double Calculate(double[] v1, double[] v2)
    {
        double d = 0;
        for (int i = 0; i < v1.Length; i++)
        {
            d += v1[i]*Math.Log(v2[i]) + (1 - v1[i])*Math.Log(1 - v2[i]);
        }
        return -d;
    }

    public override double CalculatePartialDerivaitveByV2Index(double[] v1, double[] v2, int v2Index)
    {
        return -(v1[v2Index]/v2[v2Index] - (1 - v1[v2Index])/(1 - v2[v2Index]));
    }
}

Здесь главное не забыть, что логарифмическое правдоподобие вычисляется со знаком минус в начале, и производная тоже будет с минусом. Я не заостряю внимания на проверках или избегания случаев деления на ноль, или логарифма от нуля.

Функция активации нейрона

Аналогичным способом опишем функцию активации нейрона.

public interface IFunction
{
    double Compute(double x);
    double ComputeFirstDerivative(double x);
}

И примеры.

Сигмоид

image
image

internal class SigmoidFunction : IFunction
{

    private double _alpha = 1;

    internal SigmoidFunction(double alpha)
    {
        _alpha = alpha;
    }

    public double Compute(double x)
    {
        double r = (1 / (1 + Math.Exp(-1 * _alpha * x)));
        //return r == 1f ? 0.9999999f : r;
        return r;
    }

    public double ComputeFirstDerivative(double x)
    {
        return _alpha * this.Compute(x) * (1 - this.Compute(x));
    }
}

Гиперболический тангенс

image
image

internal class HyperbolicTangensFunction : IFunction
{

    private double _alpha = 1;

    internal HyperbolicTangensFunction(double alpha)
    {
        _alpha = alpha;
    }

    public double Compute(double x)
    {
        return (Math.Tanh(_alpha * x));
    }

    public double ComputeFirstDerivative(double x)
    {
        double t = Math.Tanh(_alpha*x);
        return _alpha*(1 - t*t);
    }
}

Нейрон, слой и сеть

В данном разделе рассмотрим представление основных элементов сети, реализацию их я приводить не буду, т.к. она очевидно. Алгоритм будет приведен для полносвязной «слоеной» сети, так что и реализацию сети нужно будет делать соответствующую.

Итак, нейрон выглядит следующим образом.

public interface INeuron
{

    /// <summary>
    /// Weights of the neuron
    /// </summary>
    double[] Weights { get; }

    /// <summary>
    /// Offset/bias of neuron (default is 0)
    /// </summary>
    double Bias { get; set; }

    /// <summary>
    /// Compute NET of the neuron by input vector
    /// </summary>
    /// <param name="inputVector">Input vector (must be the same dimension as was set in SetDimension)</param>
    /// <returns>NET of neuron</returns>
    double NET(double[] inputVector);

    /// <summary>
    /// Compute state of neuron
    /// </summary>
    /// <param name="inputVector">Input vector (must be the same dimension as was set in SetDimension)</param>
    /// <returns>State of neuron</returns>
    double Activate(double[] inputVector);

    /// <summary>
    /// Last calculated state in Activate
    /// </summary>
    double LastState { get; set; }

    /// <summary>
    /// Last calculated NET in NET
    /// </summary>
    double LastNET { get; set; }

    IList<INeuron> Childs { get; }

    IList<INeuron> Parents { get; }

    IFunction ActivationFunction { get; set; }

    double dEdz { get; set; }
}

Т.к. мы рассматриваем полносвязную «слоеную» сеть, то Childs и Parents можно не имплементировать, но если делать общий алгоритм, то придется. Рассмотрим поля, которые особо важны для алгоритма обучения:

  • LastNET — сумматор нейрона, тут хранится последнее вычисленное значение
  • LastState — выход нейрона, тут хранится последнее вычисленное значение
  • dEdz — это то самое dE/dz нейрона, что упоминается выше, и вычисляется в зависимости от того на каком слое находится текущий нейрон; частная производная функции ошибки по сумматору нейрона

Слой сети выглядит проще:

public interface ILayer
{

    /// <summary>
    /// Compute output of the layer
    /// </summary>
    /// <param name="inputVector">Input vector</param>
    /// <returns>Output vector</returns>
    double[] Compute(double[] inputVector);

    /// <summary>
    /// Get last output of the layer
    /// </summary>
    double[] LastOutput { get; }

    /// <summary>
    /// Get neurons of the layer
    /// </summary>
    INeuron[] Neurons { get; }

    /// <summary>
    /// Get input dimension of neurons
    /// </summary>
    int InputDimension { get; }
}

И представление сети:

public interface INeuralNetwork
{

    /// <summary>
    /// Compute output vector by input vector
    /// </summary>
    /// <param name="inputVector">Input vector (double[])</param>
    /// <returns>Output vector (double[])</returns>
    double[] ComputeOutput(double[] inputVector);

    Stream Save();

    /// <summary>
    /// Train network with given inputs and outputs
    /// </summary>
    /// <param name="inputs">Set of input vectors</param>
    /// <param name="outputs">Set if output vectors</param>
    void Train(IList<DataItem<double>> data);
}

Но мы рассматриваем многослойную нейросеть, так что будет использоваться особое представление:

public interface IMultilayerNeuralNetwork : INeuralNetwork
{
    /// <summary>
    /// Get array of layers of network
    /// </summary>
    ILayer[] Layers { get; }
}
Алгоритм обучения

Алгоритм обучения будет реализован через паттерн стратегия:

public interface ILearningStrategy<T>
{
    /// <summary>
    /// Train neural network
    /// </summary>
    /// <param name="network">Neural network for training</param>
    /// <param name="inputs">Set of input vectors</param>
    /// <param name="outputs">Set of output vectors</param>
    void Train(T network, IList<DataItem<double>> data);
}

Для более наглядного понимания приведу типичную функцию Train любой нейросети в контексте данной реализации:

public void Train(IList<DataItem<double>> data)
{
    _learningStrategy.Train(this, data);
}
Формат входных данных

Я использую следующий формат входных данных:

public class DataItem<T>
{
    private T[] _input = null;
    private T[] _output = null;

    public DataItem()
    {
    }

    public DataItem(T[] input, T[] output)
    {
        _input = input;
        _output = output;
    }

    public T[] Input
    {
        get { return _input; }
        set { _input = value; }
    }

    public T[] Output 
    { 
        get { return _output; }
        set { _output = value; }
    }
}

Как видно из кода в предыдущих частях, нейросеть работает с DataItem<double>.

Параметры алгоритма обучения

Данным классом описываются параметры алгоритма обучения, я думаю названия полей говорят сами за себя (и комментарии), так что не буду дублировать текстом:

public class LearningAlgorithmConfig
{

    public double LearningRate { get; set; }

    /// <summary>
    /// Size of the butch. -1 means fullbutch size. 
    /// </summary>
    public int BatchSize { get; set; }

    public double RegularizationFactor { get; set; }

    public int MaxEpoches { get; set; }

    /// <summary>
    /// If cumulative error for all training examples is less then MinError, then algorithm stops 
    /// </summary>
    public double MinError { get; set; }

    /// <summary>
    /// If cumulative error change for all training examples is less then MinErrorChange, then algorithm stops 
    /// </summary>
    public double MinErrorChange { get; set; }

    /// <summary>
    /// Function to minimize
    /// </summary>
    public IMetrics<double> ErrorFunction { get; set; }

}
Алгоритм

Ну и наконец, показав весь контекст, можно перейти к собственно реализации алгоритма обучения нейросети internal class BackpropagationFCNLearningAlgorithm : ILearningStrategy<IMultilayerNeuralNetwork>, функция public void Train(IMultilayerNeuralNetwork network, IList<DataItem<double>> data).

Для начала подготавливаем некоторые переменные (общие для всех эпох обучения) для работы алгоритма:

if (_config.BatchSize < 1 || _config.BatchSize > data.Count)
{
    _config.BatchSize = data.Count;
}
double currentError = Single.MaxValue;
double lastError = 0;
int epochNumber = 0;
Logger.Instance.Log("Start learning...");

Затем запустится основной цикл работы алгоритма, в котором происходит прямой и обратный прогон всего массива данных, один прогон называется эпохой:

do
{
//...
} while (epochNumber < _config.MaxEpoches &&
                     currentError > _config.MinError &&
                     Math.Abs(currentError - lastError) > _config.MinErrorChange);

Заходим в цикл, и перед тем как пройтись по всем примерам, инициализируем вспомогательные переменные важные только для текущей эпохи. В случае, если batch не полный, то перемешиваем данные.

lastError = currentError;
DateTime dtStart = DateTime.Now;

//preparation for epoche
int[] trainingIndices = new int[data.Count];
for (int i = 0; i < data.Count; i++)
{
    trainingIndices[i] = i;
}
if (_config.BatchSize > 0)
{
    trainingIndices = Shuffle(trainingIndices);
}

Далее наступает процесс обработки данных, в зависимости от размера пачки, и изменение весов, это выглядит так:

//process data set
int currentIndex = 0;
do
{


    //accumulated error for batch, for weights and biases
    double[][][] nablaWeights = new double[network.Layers.Length][][];
    double[][] nablaBiases = new double[network.Layers.Length][]; 

    //process one batch
    for (int inBatchIndex = currentIndex; inBatchIndex < currentIndex + _config.BatchSize && inBatchIndex < data.Count; inBatchIndex++)
    {
        //forward pass
        double[] realOutput = network.ComputeOutput(data[trainingIndices[inBatchIndex]].Input);

        //backward pass, error propagation
        //last layer
        //.......................................ОБРАБОТКА ПОСЛЕДНЕГО СЛОЯ
                        
        //hidden layers
        //.......................................ОБРАБОТКА СКРЫТЫХ СЛОЕВ
    }

    //update weights and bias
    for (int layerIndex = 0; layerIndex < network.Layers.Length; layerIndex++)
    {
        for (int neuronIndex = 0; neuronIndex < network.Layers[layerIndex].Neurons.Length; neuronIndex++)
        {
            network.Layers[layerIndex].Neurons[neuronIndex].Bias -= nablaBiases[layerIndex][neuronIndex];
            for (int weightIndex = 0; weightIndex < network.Layers[layerIndex].Neurons[neuronIndex].Weights.Length; weightIndex++)
            {
                network.Layers[layerIndex].Neurons[neuronIndex].Weights[weightIndex] -=
                    nablaWeights[layerIndex][neuronIndex][weightIndex];
            }
        }
    }

    currentIndex += _config.BatchSize;
} while (currentIndex < data.Count);

Рассмотрим обработку последнего слоя:

  • инициализировали «наблЫ», там мы храним аккумулированное значение градиента для пачки входных данных (при онлайн обучении, там окажется просто градиент по одному примеру)
  • пробегаемся по всем нейронам последнего слоя
  • вычисляем dE/dz
  • а затем вычисляем значение градиента для весов и смещения

//last layer
nablaWeights[network.Layers.Length - 1] = new double[network.Layers[network.Layers.Length - 1].Neurons.Length][];
nablaBiases[network.Layers.Length - 1] = new double[network.Layers[network.Layers.Length - 1].Neurons.Length];
for (int j = 0; j < network.Layers[network.Layers.Length - 1].Neurons.Length; j++)
{
    network.Layers[network.Layers.Length - 1].Neurons[j].dEdz =
        _config.ErrorFunction.CalculatePartialDerivaitveByV2Index(data[inBatchIndex].Output,
                                                                    realOutput, j) *
        network.Layers[network.Layers.Length - 1].Neurons[j].ActivationFunction.
            ComputeFirstDerivative(network.Layers[network.Layers.Length - 1].Neurons[j].LastNET);

    nablaBiases[network.Layers.Length - 1][j] = _config.LearningRate *
                                                network.Layers[network.Layers.Length - 1].Neurons[j].dEdz;

    nablaWeights[network.Layers.Length - 1][j] = new double[network.Layers[network.Layers.Length - 1].Neurons[j].Weights.Length];
    for (int i = 0; i < network.Layers[network.Layers.Length - 1].Neurons[j].Weights.Length; i++)
    {
        nablaWeights[network.Layers.Length - 1][j][i] =
            _config.LearningRate*(network.Layers[network.Layers.Length - 1].Neurons[j].dEdz*
                                    (network.Layers.Length > 1 ? 
                                        network.Layers[network.Layers.Length - 1 - 1].Neurons[i].LastState : 
                                        data[inBatchIndex].Input[i])
                                        +
                                    _config.RegularizationFactor *
                                    network.Layers[network.Layers.Length - 1].Neurons[j].Weights[i]
                                        / data.Count);
    }
}

Очень похоже на последний слой выглядит обработка всех скрытых слоев сети:

  • пробегаемся по всем скрытым слоям
  • инициализировали «наблЫ», там мы храним аккумулированное значение градиента для пачки входных данных (при онлайн обучении, там окажется просто градиент по одному примеру)
  • пробегаемся по всем нейронам последнего слоя
  • вычисляем dE/dz, но уже для этого мы используем значения вычисленные, на слое старше текущего
  • а затем вычисляем значение градиента для весов и смещения

//hidden layers
for (int hiddenLayerIndex = network.Layers.Length - 2; hiddenLayerIndex >= 0; hiddenLayerIndex--)
{
    nablaWeights[hiddenLayerIndex] = new double[network.Layers[hiddenLayerIndex].Neurons.Length][];
    nablaBiases[hiddenLayerIndex] = new double[network.Layers[hiddenLayerIndex].Neurons.Length];
    for (int j = 0; j < network.Layers[hiddenLayerIndex].Neurons.Length; j++)
    {
        network.Layers[hiddenLayerIndex].Neurons[j].dEdz = 0;
        for (int k = 0; k < network.Layers[hiddenLayerIndex + 1].Neurons.Length; k++)
        {
            network.Layers[hiddenLayerIndex].Neurons[j].dEdz +=
                network.Layers[hiddenLayerIndex + 1].Neurons[k].Weights[j]*
                network.Layers[hiddenLayerIndex + 1].Neurons[k].dEdz;
        }
        network.Layers[hiddenLayerIndex].Neurons[j].dEdz *=
            network.Layers[hiddenLayerIndex].Neurons[j].ActivationFunction.
                ComputeFirstDerivative(
                    network.Layers[hiddenLayerIndex].Neurons[j].LastNET
                );

        nablaBiases[hiddenLayerIndex][j] = _config.LearningRate*
                                            network.Layers[hiddenLayerIndex].Neurons[j].dEdz;

        nablaWeights[hiddenLayerIndex][j] = new double[network.Layers[hiddenLayerIndex].Neurons[j].Weights.Length];
        for (int i = 0; i < network.Layers[hiddenLayerIndex].Neurons[j].Weights.Length; i++)
        {
            nablaWeights[hiddenLayerIndex][j][i] = _config.LearningRate * (
                network.Layers[hiddenLayerIndex].Neurons[j].dEdz *
                (hiddenLayerIndex > 0 ? network.Layers[hiddenLayerIndex - 1].Neurons[i].LastState : data[inBatchIndex].Input[i])
                    +
                _config.RegularizationFactor * network.Layers[hiddenLayerIndex].Neurons[j].Weights[i] / data.Count
                );
        }
    }
}

Тут основной код заканчивается, и остается сделать пару штрихов в виде вычисления средней ошибки сети на массиве данных, и логирование:

//recalculating error on all data
currentError = 0;
for (int i = 0; i < data.Count; i++)
{
    double[] realOutput = network.ComputeOutput(data[i].Input);
    currentError += _config.ErrorFunction.Calculate(data[i].Output, realOutput);
}
currentError *= 1d/data.Count;

epochNumber++;
Logger.Instance.Log("Eposh #" + epochNumber.ToString() +
                    " finished; current error is " + currentError.ToString() +
                    "; it takes: " +
                    (DateTime.Now - dtStart).Duration().ToString());

Этот блок находится прямиком перед выходом из основного цикла, продублирую код для наглядности:

} while (epochNumber < _config.MaxEpoches &&
                     currentError > _config.MinError &&
                     Math.Abs(currentError - lastError) > _config.MinErrorChange);

Итог

Собственно все. От себя хочу вот что добавить, данный текст я привел примерно в том виде, в каком я его хотел бы видеть, когда первый раз читал про алгоритм обратного распространения. Обычно в литературе авторы заранее говорят что будет использоваться такая то функция ошибки и активации нейрона. И в процессе вывода формул с частными производными они вставляют эти производные в вывод, и в итоге получается примерно так же, как, например, в википедии, где рассматривается алгоритм для минимизации квадрата Евклидова расстояния.
Вот формула для последнего слоя:
image
и для остальных:
image.

Конечно, если вы очень умны, то вам не составит труда понять что тут от какого дифференциала, но я смог понять это только когда взял в руки ручку и бумагу, и написал весь вывод. Потом то же самое сделал для другой функции ошибки и только потом обобщил и осознал -)

Автор: mephistopheies


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js