Руководство по созданию собственной полносвязной нейронной сети с использованием Python и NumPy.

Нежное введение

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

Обусловленное поведение собак является результатом обратного распространения в их собственной нейронной сети. Объясняя это слишком упрощенно, предположим, что функция y=f(x) определяет поведение собаки, где x — ввод собакой стимуляторов (свет, звук, прикосновение), а y — выходное поведение собаки (ходьба, сидение). , гав). Обратное распространение обучает собаку сопоставлять x=звон колокольчика с y=есть, в то время как изначально x=звон колокольчика будет указывать на y=ничего не делать.

Теперь, когда мы знаем, что такое обратное распространение, мы можем использовать эту практику для обучения машинных алгоритмов, которые сопоставляют ввод X с желаемым Y. В этом посте я собираюсь обозначить распознавание цифр как задачу, которую мы хотим, чтобы машина обучила, где ввод X — изображение 28x28, а Y — цифра (0–9), которой соответствует изображение.

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

Чтобы понять обратное распространение, необходимо понять прямое распространение и определить несколько переменных. Начнем с определения большого X и большого Y сети.

Во время прямого распространения каждый уровень принимает входные данные x = выходные данные предыдущего уровня, выполняет вычисление взвешенной суммы и выводит y = выходные данные текущего слоя на следующий уровень. По мере того, как слои распространяются вверх (к выходному слою), конечный выход Y сети будет равен выходу y выходного слоя.

Теперь, поняв всю суть прямого распространения, давайте углубимся в вычисления взвешенной суммы в пределах одного слоя. Помимо входных x и выходных y, есть переменные 4 других типов:

Для отдельного нейрона он будет иметь столько весов, сколько входов имеет слой. Он также будет иметь член смещения b и заданную функцию активации g (сигмовидная, танх, относительная…). Нейрон будет использовать свои веса и смещение для вычисления смещенной взвешенной суммы своих входов и применит к ней функцию активации, чтобы найти выход нейрона y.

Вы спросите, а какой вообще смысл в функции активации? Наличие функции активации добавляет нелинейности в сеть. Без него многоуровневая сеть рухнула бы до одного уровня, что значительно уменьшило бы ее сложность. Так что последний шаг, a=g(z), не происходит без причины.

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

class DenseLayer:
    def __init__(self, num_neurons, num_inputs, activation_func):
        self.num_neurons = num_neurons
        self.num_inputs = num_inputs
        self.activation_func = activation_func
        self.x = np.zeros(num_inputs)
        self.z = np.zeros(num_neurons)
        self.w = np.random.randn(num_neurons, num_inputs) * 0.01
        self.b = np.random.randn(num_neurons) * 0.01
        self.dw = np.zeros((num_neurons, num_inputs))
        self.db = np.zeros(num_neurons)

    def forward_propagation(self, x):
        # x is a numpy array of dimension (#inputs)
        self.x = x  # store x for back prop
        for neuron_num in range(self.num_neurons):
            z = 0
            for weight_num in range(self.num_inputs):
                z += self.w[neuron_num][weight_num] * x[weight_num]
            z += self.b[neuron_num]
            self.z[neuron_num] = z  # stores z for back prop
        a = activation(self.z, self.activation_func)
        return a

Выполнение прямого распространения по сети:

# network is a list containing all the layers
for index, layer in enumerate(network):
    if index == 0:
        output = layer.forward_propagation(x)
    else:
        output= layer.forward_propagation(output)
output...  # is the final output of the network

Функция потерь и затрат

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

Начнем с определения функции потерь.

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

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

Это большая идея обратного распространения и градиентного спуска. Нахождение правильных ∂J/∂w и ∂J/∂b и обновление параметров до тех пор, пока градиенты не достигнут значения, близкого к 0.

Обратное распространение

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

Напомним, что функция стоимости

Вычислить производные J по ŷ = y выходного слоя

Если вы выполняете вычисления самостоятельно, вы можете заметить, что в производной отсутствует скаляр 1/m. Я намеренно опустил этот термин, потому что эффект этого скаляра будет смягчен скоростью обучения (о которой мы поговорим позже), поэтому нет смысла приводить его здесь сейчас.

Теперь, вычислив ∂J/∂a выходного слоя, мы можем распространить эту производную вниз (к входному слою), где каждый слой будет использовать введенные ∂J/∂a для вычисления ∂J/∂w, ∂J/ ∂b и ∂J/∂a следующего слоя.

Это общая картина обратного распространения. Теперь возникает вопрос: как, зная ∂J/∂a текущего слоя, найти ∂J/∂w, ∂J/∂b и ∂J/∂a следующего слоя? Ответ больше расчетный.

Напомним формулу прямого распространения

Если мы произведем вычисления по этим формулам, мы сможем найти нужные нам производные. Проще всего найти производную ∂J/∂z.

С ∂J/∂z мы можем легче вычислить другие производные

И, наконец, ∂J/∂a следующего слоя, чтобы мы могли распространяться вниз по сети.

Теперь у нас есть все формулы, мы можем собрать их в код.

def back_propagation(self, da):
    # da is a numpy array of dimension (#neurons)
    da_prev = np.zeros(self.num_inputs)
    for neuron_num in range(self.num_neurons):
        dz = da[neuron_num] * activation_prime(self.z[neuron_num], 
                 self.activation_func)
        for weight_num in range(self.num_inputs):
            self.dw[neuron_num][weight_num] = dz * 
                                               self.x[weight_num]
            da_prev[weight_num] += dz * self.w[neuron_num]       
                                       [weight_num]
        self.db[neuron_num] = dz
    return da_prev

Обратное распространение по сети:

# forward propagation...
# ∂J/∂a of the output layer
da = np.divide(yhat[:, sample_num] - y[:, sample_num], (1 - yhat[:, sample_num]) * y[:, sample_num] + epsilon)
for layer in reversed(network):
    da = layer.back_propagation(da)

Эпсилон — это очень маленькое число, которое предотвращает деление на 0. Я использую эпсилон = 1e-8.

Градиентный спуск

Теперь, когда мы вычислили производные ∂J/∂w и ∂J/∂b, можно делать градиентный спуск.

# back propagation...
for layer in network:
    layer.w = layer.w - alpha * layer.dw
    layer.b = layer.b - alpha * layer.db

И если мы запустим прямое распространение, обратное распространение и затем градиентный спуск, мы обновим параметры w и b ровно один раз и достигнем одного шага градиентного спуска. После выполнения многоступенчатого градиентного спуска значение функции стоимости будет постепенно уменьшаться, и если мы отобразим функцию стоимости, она может выглядеть примерно так:

Эпилог

Итак, мы успешно создали полносвязную нейронную сеть с прямым и обратным распространением. Если вы разработаете код и фактически обучите сеть самостоятельно, вы обнаружите, что обучение происходит довольно медленно. На моем ПК обучение 100 000 шагов в трехслойной сети занимает примерно 9 часов. Это неприемлемо долго. В следующем моем посте мы увидим, как векторизация может сократить время обучения до минут или даже секунд.