Подробное объяснение исчезающего градиента и сигмоид. Объясняется в коде вместе с ReLu и инициализацией Kaiming.

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

Хотя идея нейронных сетей возникла с середины 1900-х годов, они не использовались за пределами академического мира до 2010 года, но почему? Если они такие мощные, то почему специалисты по машинному обучению не используют их в реальных приложениях? Это правда, что развитие компьютеров и использование графических процессоров произвело революцию в использовании нейронных сетей. Тривиальная задача, которую мы теперь можем решить менее чем за час с помощью современных вычислительных технологий, раньше занимала дни, а иногда и недели, но вычислительная мощность была не единственной проблемой. Другой проблемой, с которой мы столкнулись, была сигмовидная активация и проблема исчезающего градиента, которую она принесла с собой.

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

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

Создаем наш кейс (в коде)

Как всегда, первое, что мы собираемся сделать, это импортировать необходимые библиотеки. Мы будем использовать PyTorch, но не будем использовать класс nn.Module. Вместо этого мы собираемся создавать наши нейронные сети с нуля, используя возможности автоградации PyTorch.

import torch
import matplotlib.pyplot as plt
import torch.nn as nn

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

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

def weights(out, inp):  
  return torch.randn((out, inp), dtype=float, requires_grad=True)

Наша вторая функция является линейной функцией. Это простая функция, которая выполняет матричное умножение между весами и признаками (X).

def linear(weights, X):
  return weights@X

Наша третья функция — функция статистики. Это возвращает среднее значение и стандартное отклонение любого переданного ему тензора. Хотя это простая функция, я обнаружил, что она делает мой код чище.

def stats(layer):
  return torch.mean(layer).item(), torch.std(layer).item()

Init_layers — наша четвертая функция. Это принимает аргументы dim и layer. Для целей этой статьи все наши слои будут выводить одинаковое количество строк и столбцов, кроме нашего последнего слоя.

def init_layers(dim, layers):
  W = []
  for i in range(layers):
    W.append(weights(dim, dim))
  W.append(weights(1, dim))
  return W

Наша последняя функция — это функция layer_metrics. Это возвращает dict, в котором будет храниться наша статистика для каждого соединения. Это значительно упрощает просмотр статистики каждого соединения.

def layer_metrics(len_W):
  metrics = {}
  for w in range(len(W)):
    metrics[w] = {'mean': [], 'std': []}
  return metrics

Наряду с этим мы будем использовать функции PyTorch BCELoss и Sigmoid. Если вам нужно освежить знания и вы хотите узнать, как писать их с нуля, обратитесь к моей книге. Я подробно описываю их там.

BCE = nn.BCELoss()
sigmoid = nn.Sigmoid()

Прежде чем мы перейдем к нашим нейронным сетям, нам понадобятся некоторые данные. Для целей этой статьи мы можем обойтись чем-то простым. Мы создадим тензор признаков (X), который содержит столько же строк, сколько и столбцов (т. е. 100), а также создадим целевой тензор (y), половина значений которого равна 0, а другая половина — 1.

dim = 100
X = torch.randn((dim,dim), dtype=float)
y = torch.cat((torch.zeros(dim//2, dtype=float), torch.ones(dim//2, dtype=float))).reshape(1,-1)

Теперь, когда мы, наконец, закончили всю скучную работу по дому, давайте начнем с создания нашей первой нейронной сети. Мы собираемся создать простую сеть прямой связи из 4 слоев, используя наши полезные функции выше. Давайте также создадим список, известный как epoch_loss, который будет содержать потери (BCE) в каждую эпоху. Мы будем использовать его для построения графика позже.

epoch_loss = []
layers = 4
W = init_layers(dim, layers)
metrics = layer_metrics(len(W))

Давайте обучим нашу нейронную сеть! Мы будем обучать 100 эпох со скоростью обучения 1e-1, печатая потери каждые 10 эпох. Мы также будем хранить статистику каждого градиента для каждой эпохи.

learning_rate = 1e-1
for epoch in range(100+1):
  y_pred = X
  for j in range(len(W)):
    y_pred = sigmoid(linear(W[j], y_pred))
loss = BCE(y_pred, y)
  epoch_loss.append(loss)
  if(epoch%10==0):
    print('Epoch {0} loss: {1:0.5}'.format(epoch, loss))
  loss.backward()
  with torch.no_grad():
    for w in range(len(W)):
      m, s = stats(W[w].grad)
      metrics[w]['mean'].append(m)
      metrics[w]['std'].append(s)
      W[w] -= learning_rate * W[w].grad
for w in W:
    w.grad.zero_()

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

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

def plot_metrics(epoch_loss, metrics):
  skip = len(metrics)//4 ##len(metrics) must be >= 4
  f, axarr = plt.subplots(6, sharex=True, figsize=(5, 15))
  axarr[0].plot(epoch_loss)
  axarr[0].set_title('Loss')
  for i in range(5):
    axarr[i+1].plot(metrics[i*skip]['mean'], label='mean')
    axarr[i+1].plot(metrics[i*skip]['std'], label='std')
    axarr[i+1].legend(loc="upper right")
    axarr[i+1].set_title('Connection ' + str(i*skip))
f.subplots_adjust(hspace=0.3)
  plt.show()

Давайте посмотрим на статистику нашей модели.

plot_metrics(epoch_loss, metrics)

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

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

Построение исчезающего градиента

Давайте обучим другую модель для того же количества эпох и скорости обучения, но на этот раз наша модель будет состоять из 100 слоев вместо 4.

epoch_loss = []
layers = 100
W = init_layers(dim, layers)
metrics = layer_metrics(len(W))
for epoch in range(100+1):
  y_pred = X
  for j in range(len(W)):
    y_pred = sigmoid(linear(W[j], y_pred))
loss = BCE(y_pred, y)
  epoch_loss.append(loss)
  if(epoch%10==0):
    print('Epoch {0} loss: {1:0.5}'.format(epoch, loss))
  loss.backward()
  with torch.no_grad():
    for w in range(len(W)):
      m, s = stats(W[w].grad)
      metrics[w]['mean'].append(m)
      metrics[w]['std'].append(s)
      W[w] -= learning_rate * W[w].grad
for w in W:
    w.grad.zero_()

Давайте посмотрим на нашу статистику для нашей 100-слойной модели.

plot_metrics(epoch_loss, metrics)

Здесь мы видим проблему исчезающего градиента в действии. Мы несколько видели это в предыдущей модели, но не в такой степени, как в этой модели.

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

Второе, что вы увидите, это то, что если вы изучите график Connection 100, вы заметите, что после небольшого колебания наше среднее значение и стандартное отклонение практически колеблются около нуля.

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

Наши начальные градиенты соединения очень малы. На самом деле они настолько малы, что если мы возьмем эти числа и умножим их на нашу скорость обучения (то есть на десятичное значение, лежащее между 0 и 1), они на самом деле уменьшатся еще больше. В итоге мы вычитаем такое небольшое число из наших весов на этих начальных слоях, что это почти эквивалентно тому, чтобы вообще ничего не вычитать. Другое осознание, которое вы, возможно, имели, состоит в том, что среднее значение и стандартное отклонение наших градиентов в наших более ранних соединениях приближаются к нулю намного быстрее, чем в наших более поздних соединениях.

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

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

Эта диаграмма объясняет значение производной сигмовидной функции активации в разных точках графика (если вам нужно напомнить, как производные требуются для обратного распространения, обратитесь к моей вводной книге по нейронным сетям). На приведенной выше диаграмме мы видим, что значения производных находятся в диапазоне от 0 до 0,25. Именно здесь и заключается фундаментальная проблема с сигмовидной функцией активации. Обратное распространение состоит из цепного правила, а цепное правило по существу состоит из перемножения производных. По сути, мы умножаем десятичное значение от 0 до 0,25 на другое десятичное значение от 0 до 0,25, и в нашей модели выше мы делаем это 100 раз. Умножение чего-либо с десятичным значением от 0 до 1 разрушает его. Делая это снова и снова, он затухает экспоненциально быстро.

Это было длинное объяснение, но я надеюсь, что оно не было слишком запутанным. Мне потребовалось некоторое время, чтобы понять, поэтому, если вам тяжело, я вас не виню. Может быть, поиграйте с кодом и перечитайте его несколько раз, чтобы он действительно усвоился.

РеЛу

Осознавая проблемы с сигмовидной функцией, исследователи глубокого обучения впервые применили ReLu (выпрямленную линейную единицу) в 2011 году. В настоящее время ReLu (наряду с его различными вариантами) являются наиболее часто используемыми функциями активации в практическом глубоком обучении.

Если мы посмотрим на диаграмму выше, мы увидим, что производные для ReLu избегают десятичных значений между 0 и 1, а имеют только 2 значения — 0 или 1 (что представляет собой другую проблему, но это обсуждение в другой раз). .

Хотя термин звучит причудливо, пусть он вас не пугает. Уравнение простое. Все, что нам нужно сделать, это превратить значения меньше 0 в 0, а остальные оставить такими, какими они есть. В коде это выглядит следующим образом.

def ourRelu(linear):
  return linear.clamp(min=0)

Мы собираемся использовать встроенную функцию PyTorch ReLu для согласованности, но вы можете легко заменить ее функцией ourRelu, которую мы сделали выше, и она должна дать вам те же результаты.

relu = nn.ReLU()

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

epoch_loss = []
W = init_layers(dim, layers)
metrics = layer_metrics(len(W))
for epoch in range(100+1):
  y_pred = X
  for j in range(len(W)):
    y_pred = relu(linear(W[j], y_pred))
loss = BCE(sigmoid(y_pred), y)
  epoch_loss.append(loss)
  if(epoch%10==0):
    print('Epoch {0} loss: {1:0.5}'.format(epoch, loss))
  loss.backward()
  with torch.no_grad():
    for w in range(len(W)):
      m, s = stats(W[w].grad)
      metrics[w]['mean'].append(m)
      metrics[w]['std'].append(s)
      W[w] -= learning_rate * W[w].grad
for w in W:
    w.grad.zero_()

Прежде чем мы объясним вывод, давайте быстро взглянем на наш код. Поскольку мы используем функцию BCELoss, она ожидает, что наши прогнозируемые значения будут находиться в диапазоне от 0 до 1, поэтому наш окончательный прогноз передается через сигмовидную функцию перед передачей в функцию BCELoss.

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

Давайте посмотрим на градиенты и посмотрим, сможем ли мы что-то вывести из них.

plot_metrics(epoch_loss, metrics)

Похоже, наши градиенты просто плоские на 0. Почему? Разве это не означает, что сигмовидная функция лучше? На самом деле, сигмовидная функция может быть причиной того, что мы получаем такие странные результаты с ReLu.

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

y_pred

Это интересно. Мы видим кучу нулей (в зависимости от того, когда вы запустите его, вы можете увидеть 0, очень большие числа или их смесь). Это немного прискорбно, потому что, если вы помните приведенную выше сигмовидную диаграмму, сигмоид превращает значение 0 в 0,5, а любое значение, превышающее примерно 5, в 1. Проблема в том, что это единственные 2 значения, которые мы можем получить — 0. и значения намного больше 5.

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

sigmoid(y_pred)

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

Инициализация Кайминга

Чтобы решить эту проблему, мы попытаемся настроить инициализацию наших весов. Это будет небольшое дополнение к нашему уже существующему уравнению. Этот твик был введен в 2015 году и известен как Инициализация Kaiming (названный в честь Kaiming He).

Твик довольно простой. Мы собираемся умножить наши веса на 2/n (сигмовидная версия для инициализации Kaiming — 1/n), где n — входное измерение для нашего соединения.

Давайте посмотрим на его код, если вышеизложенное немного сбивает с толку.

import math
def weights(out, inp):  
  w = torch.randn((out, inp), dtype=float, requires_grad=True)*math.sqrt(2/inp)
  return w.detach().requires_grad_(True)
return w.detach().requires_grad_(True)

Функция очень проста, если вы посмотрите на нее. Это почти то же самое, что и наша функция весов выше, за вычетом умножения на дробь. Кроме того, извините за бит detach().requires_grad_(True), но я не знаю другого способа легко умножить тензор на дробь, сохраняя его градиент.

Наконец, давайте обучим нашу модель с помощью инициализации Kaiming и ReLu.

Примечание: иногда эта часть немного привередлива. Если вы не видите улучшения в своем проигрыше, просто повторите это несколько раз, пока оно не сработает.

learning_rate = 1e-4
epoch_loss = []
W = init_layers(dim, layers)
metrics = layer_metrics(len(W))
  
for epoch in range(100+1):
  y_pred = X
  for j in range(len(W)):
    y_pred = relu(linear(W[j], y_pred))
loss = BCE(sigmoid(y_pred), y)
  epoch_loss.append(loss)
  if(epoch%10==0):
    print('Epoch {0} loss: {1:0.5}'.format(epoch, loss))
  loss.backward()
  with torch.no_grad():
    for w in range(len(W)):
      m, s = stats(W[w].grad)
      metrics[w]['mean'].append(m)
      metrics[w]['std'].append(s)
      W[w] -= learning_rate * W[w].grad
for w in W:
    w.grad.zero_()

Потрясающий! Похоже, наша модель учится! Мне пришлось уменьшить скорость обучения до 1e-4, но это работает.

Давайте нарисуем наши градиенты, чтобы увидеть, как они выглядят.

plot_metrics(epoch_loss, metrics)

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

Линейная функция PyTorch

На протяжении этого урока вы, вероятно, задавались вопросом, почему мы просто не использовали встроенную линейную функцию PyTorch. Причина этого в том, что линейная функция PyTorch фактически реализует для нас инициализацию Kaiming (запуск ячейки кода ниже в Google colab приведет к тому, что исходный код появится справа. Прокрутите его до части Kaiming в функции reset_parameters.) .

nn.Linear??

Надеюсь, вам понравилась статья. Мы рассказали о проблеме исчезающего градиента, а также о том, почему мы используем ReLu и как инициализировать веса с помощью инициализации Kaiming, но я подумал, что было бы несправедливо закончить эту статью, не упомянув, что есть несколько других методов инициализации. . Хорошая новость заключается в том, что они очень похожи на метод инициализации Kaiming. Очень популярной альтернативой является Инициализация Ксавьера (названа в честь Ксавьера Глорота).

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

И как всегда, вы можете найти код в Google Colab.