Давайте повеселимся, реализовав функции стоимости на чистом C++ и Eigen.

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

Эта история является продолжением нашего предыдущего разговора о свертках. Сегодня мы познакомимся с концепцией функций стоимости, покажем распространенные примеры и научимся кодировать и строить их графики. Как всегда, с нуля на чистом C++ и Eigen.

Об этой серии

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

Эта история: Функции стоимости в C++

Проверьте другие истории:

0 — Основы программирования глубокого обучения на современном C++

1 — Кодирование 2D сверток на C++

3 — Реализация градиентного спуска

4 — Активация функций

… еще не все.

Моделирование в машинном обучении

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

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

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

Мы можем написать модель для предоставления изображения данной последовательности слов:

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

Часто возникает проблема: как узнать формулу F()?

Аппроксимирующие функции

Действительно, определить F(X) с помощью формулы или последовательности правил невозможно (когда-нибудь я объясню, почему).

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

На первый взгляд это бессмысленно: если нам нужно найти аппроксимационную функцию H(X), почему бы нам не попытаться найти F(X) напрямую?

Ответ таков: мы знаем H(X). В то время как мы мало что знаем о F(X), мы знаем почти все о H(X): его формулу, параметры и т.д. Единственное, чего мы не знаем знать о H(X) — это значения его параметров.

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

В терминологии машинного обучения H(X) называется "аппроксимацией F(X)". Существование H(X) покрывается Теоремой универсального приближения.

Функция стоимости и теорема об универсальном приближении

Рассмотрим случай, когда мы знаем значение входа X и соответствующего выхода Y = F(X), но мы не знаем формулу F(X). Например, мы знаем, что если ввод X = 1.0, то F(1.0)результат Y = 2.0.

Теперь предположим, что у нас есть известная функция H(X), и нам интересно, является ли H(X) хорошим приближением для F(X). Таким образом, мы вычисляем T = H(1.0) и находим T = 1.9 .

Насколько плохо это значение T = 1.9, поскольку мы знаем, что истинное значение равно Y = 2.0 при X = 1.0?

Метрика для количественной оценки стоимости разницы между Y и T называется Функция стоимости.

Обратите внимание, что Y — ожидаемое значение, а T — фактическое значение, полученное в результате нашего предположения H(X)

Концепция функций стоимости является основной в машинном обучении. Давайте представим наиболее распространенную функцию стоимости в качестве примера.

Среднеквадратическая ошибка

Наиболее известной функцией стоимости является среднеквадратичная ошибка:

где Tᵢ задается сверткой Xᵢ по ядру k:

Мы обсуждали Convolution в предыдущей истории

Обратите внимание, что у нас есть n пар (Yₙ, Tₙ), каждая из которых является комбинацией ожидаемого значения Yᵢ и фактическое значение Tₙ. Например:

Следовательно, MSE оценивается следующим образом:

Мы можем написать нашу первую версию MSE следующим образом:

auto MSE = [](const std::vector<double> &Y_true, const std::vector<double> &Y_pred) {

    if (Y_true.empty()) throw std::invalid_argument("Y_true cannot be empty.");

    if (Y_true.size() != Y_pred.size()) throw std::invalid_argument("Y_true and Y_pred sizes do not match.");

    auto quadratic = [](const double a, const double b) {
        double result = a - b;
        return result * result;
    };
    const int N = Y_true.size();
    double acc = std::inner_product(Y_true.begin(), Y_true.end(), Y_pred.begin(), 0.0, std::plus<>(), quadratic);

    double result = acc / N;

    return result;
};

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

Интуиция использования MSE для поиска лучших параметров

Предположим, что у нас есть отображение F(X), созданное синтетическим путем:

F(X) = 2*X + N(0, 0.1)

где N(0, 0,1) представляет собой случайное значение, взятое из нормального распределения со средним значением = 0 и стандартным отклонением = 0,1. Мы можем генерировать образцы данных следующим образом:

#include <random>

std::default_random_engine dre(time(0));

std::normal_distribution<double> gaussian_dist(0., 0.1);
std::uniform_real_distribution<double> uniform_dist(0., 1.);

std::vector<std::pair<double, double>> sample(90);

std::generate(sample.begin(), sample.end(), [&gaussian_dist, &uniform_dist]() {
    double x = uniform_dist(dre);
    double noise = gaussian_dist(dre);
    double y = 2. * x + noise;
    return std::make_pair(x, y);
});

Если мы построим этот образец с помощью любого программного обеспечения для работы с электронными таблицами, мы получим что-то вроде этого:

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

В реальной жизни все, что мы знаем, — это предположение о том, что функция гипотезы H(X), определяемая формулой H(X) = kX, может быть хорошим приближением F (Х). Конечно, мы пока не знаем, каково значение k .

Давайте посмотрим, как использовать MSE, чтобы найти подходящее значение k. Действительно, это так же просто, как построить график MSE для диапазона различных значений k:

std::vector<std::pair<double, double>> measures;

double smallest_mse = 1'000'000'000.;
double best_k = -1;
double step = 0.1;

for (double k = 0.; k < 4.1; k += step) {
    std::vector<double> ts(sample.size());
    std::transform(sample.begin(), sample.end(), ts.begin(), [k](const auto &pair) {
        return pair.first * k;
    });

    double mse = MSE(ys, ts);
    if (mse < smallest_mse) {
        smallest_mse = mse;
        best_k = k;
    }

    measures.push_back(std::make_pair(k, mse));
}

std::cout << "best k was " << best_k << " for a MSE of " << smallest_mse << "\n";

Очень часто эта программа выводит что-то вроде этого:

best k was 2.1 for a MSE of 0.00828671

Если мы построим график MSE(k) по k, мы увидим очень интересный факт:

Обратите внимание, что значение MSE(k) минимально в окрестности k = 2. Действительно, 2 — это параметр образующей функции G(X) = 2X.

Учитывая данные и используя шаги 0,1, меньшее значение MSE(k) находится, когда k = 2,1. Это говорит о том, что H(X) =2,1Xявляется хорошей аппроксимациейF(X). На самом деле, если построить график, F(X), G(X) и H(X), имеем:

По приведенной выше диаграмме мы можем понять, что H(X) на самом деле аппроксимирует F(X). Однако мы можем попробовать использовать меньшие шаги, такие как 0,01 или 0,001, чтобы найти лучшее приближение.

Код можно найти в этом репозитории

Поверхность стоимости

Кривая MSE(k) по k является одномерным примером поверхности затрат.

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

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

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

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

Наименьшее значение стоимости также известно как глобальный минимум.

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

Многомерным может быть не только k. В реальных задачах очень часто выходы тоже многомерны. Давайте узнаем, как рассчитать MSE в таких случаях.

MSE на многомерных выходных данных

В реальных задачах Y и T являются векторами или матрицами. Давайте посмотрим, как работать с такими данными.

Если вывод одномерный, сработает предыдущая формула MSE. Но если вывод многомерный, нам нужно немного изменить формулу. Например:

В этом случае вместо скалярных значений Yₙ и Tявляются матрицами размера (2,3). Прежде чем применять MSE к этим данным, нам нужно изменить формулу следующим образом:

В этой формуле N — количество пар, R — количество строк, а C — количество столбцов в каждой паре. Как обычно, мы можем реализовать эту версию MSE с помощью лямбда-выражений:

#include <numeric>
#include <iostream>

#include <Eigen/Core>

using Eigen::MatrixXd;

int main() 
{

    auto MSE = [](const std::vector<MatrixXd> &Y_true, const std::vector<MatrixXd> &Y_pred) 
    {

        if (Y_true.empty()) throw std::invalid_argument("Y_true cannot be empty.");

        if (Y_true.size() != Y_pred.size()) throw std::invalid_argument("Y_true and Y_pred sizes do not match.");

        const int N = Y_true.size();
        const int R = Y_true[0].rows();
        const int C = Y_true[0].cols();

        auto quadratic = [](const MatrixXd a, const MatrixXd b) 
        {
            MatrixXd result = a - b;
            return result.cwiseProduct(result).sum();
        };

        double acc = std::inner_product(Y_true.begin(), Y_true.end(), Y_pred.begin(), 0.0, std::plus<>(), quadratic);

        double result = acc / (N * R * C);

        return result;
    };

    std::vector<MatrixXd> A(4, MatrixXd::Zero(2, 3)); 
    A[0] << 1., 2., 1., -3., 0, 2.;
    A[1] << 5., -1., 3., 1., 0.5, -1.5; 
    A[2] << -2., -2., 1., 1., -1., 1.; 
    A[3] << -2., 0., 1., -1., -1., 3.;

    std::vector<MatrixXd> B(4, MatrixXd::Zero(2, 3)); 
    B[0] << 0.5, 2., 1., 1., 1., 2.; 
    B[1] << 4., -2., 2.5, 0.5, 1.5, -2.; 
    B[2] << -2.5, -2.8, 0., 1.5, -1.2, 1.8; 
    B[3] << -3., 1., -1., -1., -1., 3.5;

    std::cout << "MSE: " << MSE(A, B) << "\n";

    return 0;
}

Следует отметить, что независимо от того, являются ли k или Y многомерными или нет, MSE всегда является скалярным значением.

Другие функции затрат

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

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

Заключение и следующие шаги

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

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