Автоматическое дифференцирование с помощью Python и C++ для глубокого обучения

Автоматическое дифференцирование для глубокого обучения с помощью Python и C++

Эта история исследует автоматическое дифференцирование, функцию современных фреймворков глубокого обучения, которая автоматически вычисляет градиенты параметров во время цикла обучения. История представляет эту технологию в сочетании с практическими примерами, используя Python и C++.

Рисунок 1: Кодирование автодифференциации на C++ с использованием Eigen

План

  • Автоматическое дифференцирование: что это такое, мотивация и т.д.
  • Автоматическое дифференцирование в Python с использованием TensorFlow
  • Автоматическое дифференцирование на C++ с использованием Eigen
  • Заключение

Автоматическое дифференцирование

Современные фреймворки, такие как PyTorch или TensorFlow, имеют расширенную функциональность, называемую автоматическим дифференцированием [1] или, короче говоря, автодифференциацией. Как следует из названия, автодифференциация автоматически вычисляет производные функций, снижая ответственность разработчиков за реализацию этих производных самостоятельно.

В чем смысл автодифференциации?

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

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

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

Почему понимание автодифференциации важно?

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

Из-за таких факторов, как численная нестабильность, автодифференциация может не сработать в редких случаях. Поэтому понимание того, как работает автодифференциация, делает вас готовым (i) использовать автодифференциацию наиболее эффективно, (ii) обнаруживать сбои в автодифференциации и (iii) исправлять их при необходимости.

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

Автоматическое дифференцирование с использованием TensorFlow

Если вы используете TensorFlow от Google, вам, возможно, никогда не приходило в голову самостоятельно производить производные слои. Давайте начнем с простого примера [2]:

import tensorflow as tfclass CustomLayer(tf.keras.layers.Layer):  def __init__(self, num_outputs, activation):    super(CustomLayer, self).__init__()    self.num_outputs = num_outputs    self.activation = activation  def build(self, input_shape):    self.kernel = self.add_weight("kernel",                                  shape=[int(input_shape[-1]),                                         self.num_outputs])  def call(self, inputs):    Z = tf.matmul(inputs, self.kernel)    Y = self.activation(Z)    return Y

Этот пользовательский слой по сути является клоном tf.keras.layers.Dense без смещения. Мы можем использовать его следующим образом:

def sin_activation(x):    return tf.sin(x)my_custom_layer = CustomLayer(2, sin_activation)input = tf.constant([[-1., 0., 1.], [2., 3., 4.], [-1., -5., 2.]])with tf.GradientTape() as tape:    output = my_custom_layer(input)    loss = tf.reduce_sum(output**2)gradient = tape.gradient(loss, my_custom_layer.trainable_variables)print("my_custom_layer.trainable_variables:\n", my_custom_layer.trainable_variables[0].numpy())print("\ngradient:\n", gradient[0].numpy())

Этот код выводит что-то вроде:

Рисунок 2: Пример вывода пользовательского слоя

Поскольку мы не используем встроенную функцию активации (например, tf.keras.activation.relu), как TensorFlow узнает, как вычислять этот градиент? Ответ прост: с помощью автоматического дифференцирования.

Как работает автодифференцирование

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

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

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

Например, давайте рассмотрим, как была вычислена ошибка в последнем примере:

Рисунок 3: Вычисление графа ошибки

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

Рисунок 4: Поток вычисления для градиента W

Что может быть упрощено до:

Рисунок 5: Вычисление градиента W

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

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

Рисунок 6: Частные производные листьев

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

Рисунок 7: Окончательное вычисление градиента

Автодифференцирование выполняет этот графовый расчет без явного вмешательства разработчика. Отлично! Так в чем проблема? Проблема заключается в деталях!

Возникает численная неустойчивость

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

import tensorflow as tfinput = tf.Variable(100.0)def function_using_autodiff(x):    return 1./tf.exp(x)with tf.GradientTape() as tape:    output = function_using_autodiff(input)gradient = tape.gradient(output, input)print("output using autodiff: ", output.numpy())print("gradient using autodiff: ", gradient.numpy())

Эта программа выводит:

Рисунок 8: Численная неустойчивость с автодифференцированием в TensorFlow

В этом случае, хотя функция была правильно вычислена при x=100, градиент, предоставленный автодифференциацией, был nan. Давайте решим эту проблему, используя пользовательский градиент. Сначала давайте проверим выражение функции:

Производная этой функции:

Теперь мы можем реализовать эту производную как пользовательский градиент [4] следующим образом:

import tensorflow as tf@tf.custom_gradientdef function_using_customdiff(x):    e = tf.exp(x)    def grad(upstream):        return upstream * -tf.exp(-x)    return 1./tf.exp(x), gradwith tf.GradientTape() as tape:    output = function_using_customdiff(input)gradient = tape.gradient(output, input)print("output using custom diff: ", output.numpy())print("gradient using custom diff: ", gradient.numpy())

На этот раз градиент правильно вычислен:

Figure 9: Using a custom gradient

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

равна

что явно неопределено при x = 0, хотя f(0) = 0! Мы также можем использовать пользовательский градиент, чтобы предоставить удобное (инженерное) решение для подобных случаев.

Теперь, когда мы понимаем, как использовать автодифференциацию в Python/TensorFlow, давайте узнаем, как использовать эту технологию в программах на C++ с помощью Eigen.

Автодифференцирование в C++ с помощью Eigen

Eigen – одна из самых успешных высокопроизводительных алгебраических библиотек для C++. Если вы не знакомы с Eigen, рекомендую прочитать одну из моих предыдущих статей на VoAGI.

Использование Eigen Autodiff [5] довольно просто. Давайте начнем с простого, но наглядного примера. Рассмотрим следующую функцию:

template<typename T>T my_function(const T& x){    T result = T(1)/(T(1) + exp(-x));    return result;}

Обратите внимание, что мы определяем эту функцию как шаблонную функцию. Без вдавания в подробности, шаблонная функция – это форма для функции. Не сама функция, на самом деле. Такие шаблоны полезны, потому что мы можем использовать my_function с различными типами данных.

Обычно мы вызывали бы наши функции с использованием типов, таких как float, double или int. Однако, чтобы сделать работу Eigen Autodiff, мы должны передавать значения в виде Eigen::AutoDiffScalar. Проверьте пример ниже:

#include <iostream>#include <unsupported/Eigen/AutoDiff>int main(int, char **){    Eigen::AutoDiffScalar<Eigen::VectorXd> X;    X.derivatives() = Eigen::VectorXd::Unit(1, 0);    X.value() = 2.f;    auto Y = my_function(X);    std::cout << "Y: " << Y << "\n\n";    std::cout << "derivatives:\n" << Y.derivatives() << "\n";    return 0;}

Первое, что здесь нужно отметить, это заголовок unsupported/Eigen/AutoDiff. В этом файле Eigen определяет тип Eigen::AutoDiffScalar, используемый для набора переменных X. Еще раз проверьте следующие две строки:

X.derivatives() = Eigen::VectorXd::Unit(1, 0);X.value() = 2.f;

Эти строки устанавливают значение X и его индекс. Поскольку X является единственной переменной в этом примере, его индекс равен 0.

Теперь мы можем передать X в my_function как обычно:

auto Y = my_function(X);

Y также является Eigen::AutoDiffScalar. Как видно из кода, значение каждой частной производной Y сохраняется в массиве derivatives(). При выполнении этого кода получается следующий результат:

Рисунок 10: Запуск примера автодифференциации на C++

Y сохраняет как значение выхода функции, так и производную по отношению к X. Как мы можем узнать, что эти значения верны? Вы можете заметить, что на самом деле my_function является формулой сигмоиды:

Формула производной сигмоиды известна как:

Таким образом, простой калькулятор может проверить значения σ(2) = 0.8808 и σ’(2) = 0.10499.

Это был — намеренно — очень простой пример. Давайте попробуем что-то посложнее.

Реализация пользовательского слоя на C++ с использованием Eigen

Когда мы узнаем, как использовать автодифференциацию на C++ с Eigen, мы наконец можем переписать пример CustomLayer, на этот раз с использованием C++:

#include <unsupported/Eigen/CXX11/Tensor>template <typename T>Eigen::Tensor<T, 2> CustomLayer(Eigen::Tensor<T, 2> &X, Eigen::Tensor<T, 2> &W, std::function<Eigen::Tensor<T, 2>(Eigen::Tensor<T, 2>&)> activation){    Eigen::array<Eigen::IndexPair<Eigen::Index>, 1> dims = { Eigen::IndexPair<Eigen::Index>(1, 0) };    Eigen::Tensor<T, 2> Z = X.contract(W, dims);    Eigen::Tensor<T, 2> result = activation(Z);    return result;};

Здесь следует выделить три момента:

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

Кроме того, мы передаем активацию как std::function. Давайте определим ее сейчас:

template <typename T>T sine(T t) {    return sin(t);}template <typename T>Eigen::Tensor<T, 2> sin_activation(Eigen::Tensor<T, 2> & P) {    Eigen::Tensor<T, 2> result = P.unaryExpr(std::ref(sine<T>));    return result;};

Опять же, мы используем шаблоны. Все здесь просто. Мы просто используем unaryExpr для отображения P с использованием функции sin(t). Теперь мы можем, наконец, вызвать CustomLayer:

#include <unsupported/Eigen/AutoDiff>
typedef typename Eigen::AutoDiffScalar<Eigen::VectorXf> AutoDiff_T;
int main(int, char **) {
    Eigen::Tensor<float, 2> x_in(3, 3);
    x_in.setValues({{-1., 0., 1.}, {2., 3., 4.}, {-1., -5., 2.}});
    Eigen::Tensor<float, 2> w_in(3, 2);
    w_in.setRandom();
    Eigen::Tensor<AutoDiff_T, 2> X = convert(x_in);
    Eigen::Tensor<AutoDiff_T, 2> W = convert(w_in, 0, w_in.size());
    auto Y = CustomLayer(X, W, sin_activation<AutoDiff_T>);
    auto output = Y * Y;
    auto LOSS = ((Eigen::Tensor<AutoDiff_T, 0>)output.sum())(0);
    auto dY_dW = gradients(LOSS, W);
    std::cout << "trainable_variables:\n" << W << "\n\n";
    std::cout << "gradient:\n" << dY_dW << "\n\n";
    std::cout << "output:\n" << output << "\n\n";
    std::cout << "loss:\n" << LOSS << "\n\n";
    return 0;
}

Как следует из названия, функция convert преобразует исходные канонические тензоры x_in и w_in в тензоры Eigen::Tensor<AutoDiff_T, 2>. Как мы обсудили в последнем примере, тип Eigen::AutoDiffScalar обязателен для работы автоматического дифференцирования в Eigen. Функция convert определена следующим образом:

auto convert = [](const Eigen::Tensor<float, 2> &tensor, int offset = 0, int size = 0){
    const int rows = tensor.dimension(0);
    const int cols = tensor.dimension(1);
    Eigen::Tensor<AutoDiff_T, 2> result(rows, cols);
    for (int i = 0; i < rows; ++i)
    {
        for (int j = 0; j < cols; ++j)
        {
            int index = i * cols + j;
            result(i, j).value() = tensor(i, j);
            if (size) {
                result(i, j).derivatives() = Eigen::VectorXf::Unit(size, offset + index);
            }
        }
    }
    return result;
};

Обратите внимание на две строки при вызове функции convert:

Eigen::Tensor<AutoDiff_T, 2> X = convert(x_in);
Eigen::Tensor<AutoDiff_T, 2> W = convert(w_in, 0, w_in.size());

Оказывается, что мы ищем только частные производные W. В следующем разделе объясняется, как вычислить частные производные относительно X также.

И, наконец, Y имеет значение выхода слоя и частные производные относительно W. Затем можно использовать функцию gradients для извлечения градиентов:

auto gradients(const AutoDiff_T &LOSS, const Eigen::Tensor<AutoDiff_T, 2> &W){
    auto derivatives = LOSS.derivatives();
    int index = 0;
    Eigen::Tensor<float, 2> result(W.dimension(0), W.dimension(1));
    for (int i = 0; i < W.dimension(0); ++i)
    {
        for (int j = 0; j < W.dimension(1); ++j)
        {
            float val = derivatives[index];
            result(i, j) = val;
            index++;
        }
    }
    return result;
}

После построения и запуска этого кода он выводит нечто подобное:

Figure 11: CustomLayer C++ example output

Как и ожидалось, вывод аналогичен выводу, сгенерированному примером на Python/TensorFlow.

Получение производных по X

В последнем примере мы вычислили только градиент W. Если нас также интересует вычисление частных производных X, мы должны внести следующие изменения:

int size = x_in.size() + w_in.size();Eigen::Tensor<AutoDiff_T, 2> X = convert(x_in, 0, size);Eigen::Tensor<AutoDiff_T, 2> W = convert(w_in, x_in.size(), size);

Этот код просто уведомляет Eigen о необходимости отслеживать производную X. Обратите внимание, что для распаковки как X, так и W, необходимо также изменить функцию gradients:

auto gradients(const AutoDiff_T &Y, const Eigen::Tensor<AutoDiff_T, 2> &X, const Eigen::Tensor<AutoDiff_T, 2> &K){    auto derivatives = Y.derivatives();    int index = 0;    Eigen::Tensor<float, 2> dY_dX(X.dimension(0), X.dimension(1));    for (int i = 0; i < X.dimension(0); ++i)    {        for (int j = 0; j < X.dimension(1); ++j)        {            float val = derivatives[index];            dY_dX(i, j) = val;            index++;        }    }    Eigen::Tensor<float, 2> dY_dK(K.dimension(0), K.dimension(1));    for (int i = 0; i < K.dimension(0); ++i)    {        for (int j = 0; j < K.dimension(1); ++j)        {            float val = derivatives[index];            dY_dK(i, j) = val;            index++;        }    }    return std::make_pair(dY_dX, dY_dK);}

Теперь вызовите функцию gradients соответствующим образом:

auto [dY_dX, dY_dW] = gradients(LOSS, X, W);

Передавайте как X, так и W. После этих изменений повторный запуск программы приведет к следующему выводу:

Рисунок 12: Вычисление градиента по X

Альтернативы автодифференциации

Способ, которым мы вычислили градиент fourier_activation “вручную” в начале этой истории, известен как символьное дифференцирование.

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

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

Вывод

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

В частности, меня поражает простота и лаконичность Eigen Autodiff. К сожалению, о нем нет много документации. Если эти примеры не подходят для вашего случая использования, рекомендую поискать больше примеров в репозитории Eigen на GitLab.

Ссылки

[1] Baydin et al., Automatic Differentiation in Machine Learning: a Survey, Journal of Machine Learning Research 18 (2018) 1–43

[2] Документация TensorFlow, Пользовательские слои

[3] Роджер Гросс, Лекция 10 по CSC321: Автоматическое дифференцирование, CS в Торонто Университете

[4] Документация TensorFlow, Расширенное автоматическое дифференцирование

[5] Патрик Пельтцер, Йоханнес Лотц, Уве Науманн, Eigen-AD: Алгоритмическое дифференцирование библиотеки Eigen, ICCS 2020: 20-я Международная конференция