Привет, Хабр!

Алгоритм backpropagation, или обратное распространение ошибки, является некой базой для тренировки многослойных перцептронов и других типов искусственных нейронных сетей. Этот алгоритм впервые был предложен Полем Вербосом в 1974 году, а позже популяризирован Дэвидом Румельхартом, Джеффри Хинтоном и Рональдом Уильямсом в 1986 году.

В этой статье реализуем алгоритм на Питоне.

Немного про алгоритм

Backpropagation состоит из двух основных фаз: forward propagation (прямое распространение) и backward propagation (обратное распространение ошибки).

  1. Прямое распространение:

    • На этом этапе входные данные проходят через все слои нейронной сети, вычисляя выходные значения для каждого нейрона. Каждый нейрон активируется с помощью активационной функции, такой как сигмоида или ReLU.

    • Результат прямого распространения — предсказанные значения (выходы сети), которые затем сравниваются с истинными значениями (метками) для вычисления ошибки.

  2. Обратное распространение ошибки:

    • На этом этапе вычисляется градиент ошибки относительно каждого веса сети, начиная с выходного слоя и двигаясь обратно к входному.

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

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

Реализация алгоритма на Python

Инициализируем нейронную сеть

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

Класс Neuron будет содержать параметры каждого нейрона, включая его веса и функцию активации:

import numpy as np

class Neuron:
    def __init__(self, input_size):
        self.weights = np.random.randn(input_size + 1) * 0.1  # +1 для смещения (bias)
    
    def activate(self, inputs):
        z = np.dot(inputs, self.weights[:-1]) + self.weights[-1]  # линейная комбинация входов и весов
        return self.sigmoid(z)
    
    def sigmoid(self, x):
        return 1 / (1 + np.exp(-x))
    
    def sigmoid_derivative(self, x):
        return x * (1 - x)

Класс Layer будет содержать несколько нейронов и методы для активации всех нейронов в слое:

class Layer:
    def __init__(self, num_neurons, input_size):
        self.neurons = [Neuron(input_size) for _ in range(num_neurons)]
    
    def forward(self, inputs):
        return np.array([neuron.activate(inputs) for neuron in self.neurons])

Класс Network будет содержать несколько слоев и методы для прямого и обратного распространения:

class Network:
    def __init__(self, layers):
        self.layers = layers
    
    def forward(self, X):
        for layer in self.layers:
            X = layer.forward(X)
        return X
    
    def backward(self, X, y, output, learning_rate):
        # обратное распространение ошибки (подробности ниже)
        pass

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

# Параметры сети
input_size = 3  # количество входов
hidden_size = 5  # количество нейронов в скрытом слое
output_size = 1  # количество выходов

# Создание слоев
layer1 = Layer(hidden_size, input_size)
layer2 = Layer(output_size, hidden_size)

# Создание сети
network = Network([layer1, layer2])

# Пример входных данных
X = np.array([0.5, 0.1, 0.4])
output = network.forward(X)
print("Output:", output)

Создали два слоя сети: скрытый и выходой, а также процесс прямого распространения сигнала через сеть.

Добавим прямое распространение

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

Существует несколько активационных функций, каждая из которых имеет свои преимущества, добавим три основные функции: сигмоида, tanh и ReLU.

  1. Сигмоида

    Сигмоида преобразует входное значение в диапазон от 0 до 1. Их юзают в выходных слоях бинарных классификаторов:

    def sigmoid(x):
        return 1 / (1 + np.exp(-x))
    
    def sigmoid_derivative(x):
        return x * (1 - x)
  2. Tanh

    Гиперболический тангенс преобразует входное значение в диапазон от -1 до 1. Её юзают в скрытых слоях, т.к обычно имеет лучшее центральное распределение значений по сравнению с сигмоидой:

    def tanh(x):
        return np.tanh(x)
    
    def tanh_derivative(x):
        return 1 - np.tanh(x)**2
  3. ReLU

    ReLU — это наиболее часто используемая функция активации. Она преобразует отрицательные входные значения в ноль, оставляя положительные значения без изменений:

    def relu(x):
        return np.maximum(0, x)
    
    def relu_derivative(x):
        return np.where(x > 0, 1, 0)

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

import numpy as np

class Neuron:
    def __init__(self, input_size, activation='sigmoid'):
        self.weights = np.random.randn(input_size + 1) * 0.1  # +1 для смещения (bias)
        self.activation_function = self._get_activation_function(activation)
        self.activation_derivative = self._get_activation_derivative(activation)
    
    def _get_activation_function(self, activation):
        if activation == 'sigmoid':
            return lambda x: 1 / (1 + np.exp(-x))
        elif activation == 'tanh':
            return np.tanh
        elif activation == 'relu':
            return lambda x: np.maximum(0, x)
    
    def _get_activation_derivative(self, activation):
        if activation == 'sigmoid':
            return lambda x: x * (1 - x)
        elif activation == 'tanh':
            return lambda x: 1 - np.tanh(x)**2
        elif activation == 'relu':
            return lambda x: np.where(x > 0, 1, 0)
    
    def activate(self, inputs):
        z = np.dot(inputs, self.weights[:-1]) + self.weights[-1]
        return self.activation_function(z)

Класс Layer остается практически таким же, но теперь он может принимать разные функции активации для разных слоев.

class Layer:
    def __init__(self, num_neurons, input_size, activation='sigmoid'):
        self.neurons = [Neuron(input_size, activation) for _ in range(num_neurons)]
    
    def forward(self, inputs):
        return np.array([neuron.activate(inputs) for neuron in self.neurons])

В классе Network реализуем метод forward, который проходит через все слои сети:

class Network:
    def __init__(self, layers):
        self.layers = layers
    
    def forward(self, X):
        for layer in self.layers:
            X = layer.forward(X)
        return X

Пример использования:

# параметры сети
input_size = 3
hidden_size = 5
output_size = 1

# создание слоев
layer1 = Layer(hidden_size, input_size, activation='relu')
layer2 = Layer(output_size, hidden_size, activation='sigmoid')

# создание сети
network = Network([layer1, layer2])

# пример входных данных
X = np.array([0.5, 0.1, 0.4])
output = network.forward(X)
print("Output:", output)

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

Реализуем обратное распространение

Обратное распространение ошибки состоит из нескольких этапов:

  1. Вычисление ошибки на выходном слое.

  2. Передача ошибки обратно через слои сети.

  3. Обновление весов на основе вычисленных градиентов.

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

def mean_squared_error(y_true, y_pred):
    return np.mean((y_true - y_pred) ** 2)

На этом этапе будем использовать производные активационных функций для расчета градиентов. Рассмотрим пример для функции сигмоида:

def sigmoid_derivative(x):
    return x * (1 - x)

Для каждого нейрона в сети вычисляется ошибка и градиент. В обратном порядке (от выходного слоя к входному) обновляются веса нейронов.

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

learning_rate = 0.1

def update_weights(weights, gradients):
    return weights - learning_rate * gradients

Расширим ранее созданный класс Network, добавив метод backward, который будет выполнять обратное распространение ошибки и обновление весов:

class Network:
    def __init__(self, layers):
        self.layers = layers
    
    def forward(self, X):
        for layer in self.layers:
            X = layer.forward(X)
        return X
    
    def backward(self, X, y, learning_rate):
        output = self.forward(X)
        error = y - output
        
        for i in reversed(range(len(self.layers))):
            layer = self.layers[i]
            if i == len(self.layers) - 1:
                layer.error = error
                layer.delta = layer.error * sigmoid_derivative(output)
            else:
                next_layer = self.layers[i + 1]
                layer.error = np.dot(next_layer.delta, np.array([neuron.weights for neuron in next_layer.neurons]))
                layer.delta = layer.error * sigmoid_derivative(layer.output)
        
        for i in range(len(self.layers)):
            layer = self.layers[i]
            inputs = X if i == 0 else self.layers[i - 1].output
            for neuron in layer.neurons:
                for j in range(len(neuron.weights) - 1):
                    neuron.weights[j] += learning_rate * layer.delta[j] * inputs[j]
                neuron.weights[-1] += learning_rate * layer.delta[-1]

class Neuron:
    def __init__(self, input_size, activation='sigmoid'):
        self.weights = np.random.randn(input_size + 1) * 0.1  # +1 для смещения (bias)
        self.activation_function = self._get_activation_function(activation)
        self.activation_derivative = self._get_activation_derivative(activation)
    
    def _get_activation_function(self, activation):
        if activation == 'sigmoid':
            return lambda x: 1 / (1 + np.exp(-x))
        elif activation == 'tanh':
            return np.tanh
        elif activation == 'relu':
            return lambda x: np.maximum(0, x)
    
    def _get_activation_derivative(self, activation):
        if activation == 'sigmoid':
            return lambda x: x * (1 - x)
        elif activation == 'tanh':
            return lambda x: 1 - np.tanh(x)**2
        elif activation == 'relu':
            return lambda x: np.where(x > 0, 1, 0)
    
    def activate(self, inputs):
        z = np.dot(inputs, self.weights[:-1]) + self.weights[-1]
        self.output = self.activation_function(z)
        return self.output

class Layer:
    def __init__(self, num_neurons, input_size, activation='sigmoid'):
        self.neurons = [Neuron(input_size, activation) for _ in range(num_neurons)]
    
    def forward(self, inputs):
        self.output = np.array([neuron.activate(inputs) for neuron in self.neurons])
        return self.output

Метод backward выполняет следующее:

  1. Вычисляет ошибку на выходном слое.

  2. Передает ошибку обратно через слои.

  3. Обновляет веса каждого нейрона с учетом вычисленных градиентов.

Итак, в итоге мы получили такой код:

Полный код
import numpy as np

# активационные функции и их производные
def sigmoid(x):
    return 1 / (1 + np.exp(-x))

def sigmoid_derivative(x):
    return x * (1 - x)

def tanh(x):
    return np.tanh(x)

def tanh_derivative(x):
    return 1 - np.tanh(x)**2

def relu(x):
    return np.maximum(0, x)

def relu_derivative(x):
    return np.where(x > 0, 1, 0)

# класс Neuron
class Neuron:
    def __init__(self, input_size, activation='sigmoid'):
        self.weights = np.random.randn(input_size + 1) * 0.1  # +1 для смещения (bias)
        self.activation_function = self._get_activation_function(activation)
        self.activation_derivative = self._get_activation_derivative(activation)
        self.output = None
    
    def _get_activation_function(self, activation):
        if activation == 'sigmoid':
            return sigmoid
        elif activation == 'tanh':
            return tanh
        elif activation == 'relu':
            return relu
    
    def _get_activation_derivative(self, activation):
        if activation == 'sigmoid':
            return sigmoid_derivative
        elif activation == 'tanh':
            return tanh_derivative
        elif activation == 'relu':
            return relu_derivative
    
    def activate(self, inputs):
        z = np.dot(inputs, self.weights[:-1]) + self.weights[-1]
        self.output = self.activation_function(z)
        return self.output

# класс Layer
class Layer:
    def __init__(self, num_neurons, input_size, activation='sigmoid'):
        self.neurons = [Neuron(input_size, activation) for _ in range(num_neurons)]
        self.output = None
        self.error = None
        self.delta = None
    
    def forward(self, inputs):
        self.output = np.array([neuron.activate(inputs) for neuron in self.neurons])
        return self.output

# класс Network
class Network:
    def __init__(self, layers):
        self.layers = layers
    
    def forward(self, X):
        for layer in self.layers:
            X = layer.forward(X)
        return X
    
    def backward(self, X, y, learning_rate):
        # прямое распространение для получения выходных значений
        output = self.forward(X)
        
        # вычисление ошибки на выходном слое
        error = y - output
        self.layers[-1].error = error
        self.layers[-1].delta = error * self.layers[-1].neurons[0].activation_derivative(output)
        
        # передача ошибки обратно через слои
        for i in reversed(range(len(self.layers) - 1)):
            layer = self.layers[i]
            next_layer = self.layers[i + 1]
            layer.error = np.dot(next_layer.delta, np.array([neuron.weights[:-1] for neuron in next_layer.neurons]))
            layer.delta = layer.error * np.array([neuron.activation_derivative(neuron.output) for neuron in layer.neurons])
        
        # обновление весов
        for i in range(len(self.layers)):
            layer = self.layers[i]
            inputs = X if i == 0 else self.layers[i - 1].output
            for j, neuron in enumerate(layer.neurons):
                for k in range(len(neuron.weights) - 1):
                    neuron.weights[k] += learning_rate * layer.delta[j] * inputs[k]
                neuron.weights[-1] += learning_rate * layer.delta[j]  # Обновление смещения
    
    def train(self, X, y, learning_rate, epochs):
        for epoch in range(epochs):
            for xi, yi in zip(X, y):
                self.backward(xi, yi, learning_rate)

# создание и обучение сети
input_size = 3
hidden_size = 5
output_size = 1

layer1 = Layer(hidden_size, input_size, activation='relu')
layer2 = Layer(output_size, hidden_size, activation='sigmoid')

network = Network([layer1, layer2])

# пример данных для тренировки
X = np.array([[0.5, 0.1, 0.4], [0.9, 0.7, 0.3], [0.2, 0.8, 0.6]])
y = np.array([[1], [0], [1]])

# параметры обучения
learning_rate = 0.1
epochs = 10000

# обучение сети
network.train(X, y, learning_rate, epochs)

# тестирование сети
for xi in X:
    output = network.forward(xi)
    print("Input:", xi, "Output:", output)


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

  • Выбор правильной функции активации: каждая функция активации имеет свои преимущества и недостатки в зависимости от задачи.

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

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

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

Комментарии (0)