Свёрточная нейронная сеть с нуля. Часть 3. Активационный слой

Превью к статье о свёрточной нейронной сети

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

Зачем нужны слои активации

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

Что такое слои активации

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

Какие бывают функции активации

Функций активации существует довольно много. Наиболее известными являются сигмоидальная (sigmoid), гиперболический тангенс (tanh), выпрямитель (ReLU) и функция мягкого максимума (softmax). Также нередко встречаются такие функции активации, как LeakyReLU, ELU, parametric ReLU, SoftSign и другие. Важной особенностью функций активации является их дифференцируемость (хотя для некоторых функций это выполняется не всегда), поскольку при обратном распространении ошибки необходимо вычислять градиенты, использующие производную функции активации. В приведённой ниже таблице вы можете сравнить различные функции активации:

НазваниеФункцияПроизводнаяОбласть значенийГрафик функцииГрафик производной
Sigmoid
1
1 + e-x
f(x)(1 - f(x)) (0, 1)
Tanh
e2x - 1
e2x + 1
1 - f2(x) (-1, 1)
ReLU x, x > 0
0, x ≤ 0
1, x > 0
0, x ≤ 0
[0, +∞)
Leaky ReLU x, x > 0
αx, x ≤ 0
1, x > 0
α, x ≤ 0
(-∞, +∞)
ELU x, x > 0
α(ex - 1), x ≤ 0
1, x > 0
αex, x ≤ 0
[-α, +∞)
Softplus ln(1 + ex)
1
1 + e-x
(0, +∞)
Softsign
x
1 + |x|
1
(1 + |x|)2
(-1, 1)

Как выбрать функцию активации

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

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

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

Пишем слой активации

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

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

class ReLULayer {
    TensorSize size; // размер слоя

public:
    ReLULayer(TensorSize size); // создание слоя

    Tensor Forward(const Tensor &X); // прямое распространение
    Tensor Backward(const Tensor &dout, const Tensor &X); // обратное распространение
};

// создание слоя
ReLULayer::ReLULayer(TensorSize size) {
    this->size = size; // сохраняем размер
}

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

На этапе прямого распространения необходимо пройтись по каждому элементу входного тензора и применить активационную функцию. В данном случае функцией является ReLU = max(0, x):

// прямое распространение
Tensor ReLULayer::Forward(const Tensor &X) {
    Tensor output(size); // создаём выходной тензор

    // проходимся по всем значениям входного тензора
    for (int i = 0; i < size.height; i++)
        for (int j = 0; j < size.width; j++)
            for (int k = 0; k < size.depth; k++)
                output(k, i, j) = X(k, i, j) > 0 ? X(k, i, j) : 0; // вычисляем значение функции активации

    return output; // возвращаем выходной тензор
}

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

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

Tensor ReLULayer::Backward(const Tensor &dout, const Tensor &X) {
    Tensor dX(size); // создаём тензор градиентов

    // проходимся по всем значениям тензора градиентов
    for (int i = 0; i < size.height; i++)
        for (int j = 0; j < size.width; j++)
            for (int k = 0; k < size.depth; k++)
                dX(k, i, j) = dout(k, i, j) * (X(k, i, j) > 0 ? 1 : 0); // умножаем градиенты следующего слоя на производную функции активации

    return dX; // возвращаем тензор градиентов
}

Итоги

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

Следующая статья: Свёрточная нейронная сеть с нуля. Часть 4. Полносвязный слой