Ни Python, ни PyTorch, ни NumPy, … всего 260 строк кода на чистом C++ достаточно, чтобы обучить, оценить и протестировать простой двоичный классификатор, различающий рукописные цифры 0 и 1.

Недавно за чтением книги по глубокому обучению я ещё раз убедился, насколько прост алгоритм обратного распространения — его можно описать на одной странице. В то же время, я уже некоторое время не касался C++, хотя, мне всегда нравилось с ним работать. Так почему бы не реализовать нейронную сеть на C++? В результате у меня получилась модель, которая обучается примерно за 10 секунд и достигает ~99% точности. Код модели выложен на github.
Данные
Чтобы обучить двоичный классификатор, требуется датасет, элементы которого относятся к двум разным классам. В данном случае мы просто возьмём из датасета MNIST изображения нулей и единиц. Размер изображений — 28×28 пикселей. Выравнивая их, мы получаем векторы по 784 элемента в каждом.

Модель
Модель структурирована послойно, и в каждом слое содержатся единицы, называемые «нейронами».
-
Входной слой: его нейроны ничего не вычисляют, а просто содержат 784-пиксельные значения из поданного на вход изображения. Притом, что иметь такой «формальный» уровень не строго обязательно, с ним код получается более согласованным.
-
Скрытые слои: каждая единица в скрытом слое получает на вход информацию от всех единиц предыдущего уровня. Эти поступающие фрагменты информации линейно комбинируются в единое значение и, наконец, к нему применяется нелинейная функция, позволяющая получить вывод из единицы.
-
Последний слой: здесь находится всего одна единица (выдающая окончательный прогноз модели). Функционально она устроена точно как единицы из скрытых слоёв, за тем исключением, что к ней не применяется нелинейная функция.

Код: строим модель
Эта модель определяется в классе RegressionModel. Да, чтобы не усложнять пример, будем выполнять классификацию методом регрессии. Обучим модель выдавать 0.0 в ответ на цифру 0 и 1.0 в ответ на цифру 1. Классификация выполняется путём проверки величины вывода — она меньше или больше, чем 0,5.
Конструктор создаёт слои и помещает их в список слоёв (если быть точным, это std::vector). Во входном слое находятся единицы, используемые исключительно как ёмкости для значений пикселей того изображения, которое было подано на вход. Затем создаются скрытые слои и, наконец, последний слой, в котором содержится всего одна единица. Он также добавляется в список.
// создаём модель с заданным количеством единиц во входном слое, в скрытых слоях, а также с указанием количества слоёв
RegressionModel(size_t num_input, size_t num_hidden, size_t num_layer) {
// входной слой, в котором сохраняются значения, получаемые на вход при каждом проходе модели
layer.push_back(Layer());
for (size_t i = 0; i < num_input; ++i) {
layer.back().push_back(Unit(0));
}
// скрытые слои
if (num_layer >= 2) {
for (size_t i = 0; i < num_layer - 1; ++i) {
layer.push_back(Layer());
for (size_t j = 0; j < num_hidden; ++j) {
layer.back().push_back(Unit(i == 0 ? num_input : num_hidden, true));
}
}
}
// в последнем слое содержится всего одна единица
layer.push_back(Layer());
layer.back().push_back(Unit(num_hidden, false));
}
Как вы знаете, в каждом слое содержатся единицы-нейроны. Для входного слоя устанавливается только одно значение (пикселя) при вызове метода прямого распространения. В то же время, для других слоёв требуется список весов и значение смещения, чтобы обрабатывать входящие данные. Последние несколько переменных требуются для метода обратного распространения, в котором рассчитываются градиенты.
// Единица — это основной блок, из которого строится модель. Также единица называется "нейрон"
struct Unit {
Unit(size_t a_num_input, bool a_has_activation)
:has_activation(a_has_activation),
weight(random_weight(a_num_input)) {
}
explicit Unit(FloatType a_value)
: value(a_value) {
}
bool has_activation{};
FloatArray weight;
FloatType bias{};
FloatType value{};
FloatType delta{};
FloatArray grad_weight;
FloatType grad_bias{};
};
Код: обучение модели
Чтобы обучить модель, выполняются следующие шаги:
-
Прямое распространение: прогнать изображение через модель и вычислить её прогноз
-
Вычислить потери. В случае регрессии это делается просто: (истинное значение — прогнозное значение)²
-
Обратное распространение: для каждого параметра вычисляется производная потерь относительно этого параметра. Отсюда мы узнаём, как нужно изменить значение этого параметра, чтобы потери увеличились или уменьшились
-
Обновление: выполняем градиентный спуск, слегка корректируя каждый параметр в том направлении, чтобы потери шли на спад
Метод прямого распространения всякий раз принимает на вход одно изображение и записывает его значения в первый слой. Затем в работу вступает цикл, распространяющий данные от слоя к слою, пока не дойдёт до последнего. Выходное значение (прогноз модели) — это всего одна единица, которая возвращается в последнем слое.
// вычисляем прямое распространение
FloatType forward(const FloatArray& input_data) {
// сохраняем входные значения в первом слое, поскольку они потребуются на этапе обратного распространения
for (size_t i = 0; i < input_data.size(); ++i) {
layer[0][i].value = input_data[i];
}
// прямое распространение
for (size_t i = 1; i < layer.size(); ++i) {
Layer& curr_layer = layer[i];
const Layer& input_layer = layer[i - 1];
for (Unit& curr_unit : curr_layer) {
FloatType pre_activation = curr_unit.bias;
for (size_t j = 0; j < input_layer.size(); ++j) {
pre_activation += input_layer[j].value * curr_unit.weight[j];
}
curr_unit.value = curr_unit.has_activation ? std::max<FloatType>(0, pre_activation) : pre_activation;
}
}
// в качестве вывода возвращаем единственную единицу, содержащуюся в последнем слое
return layer.back()[0].value;
}
При помощи метода обратного распространения вычисляются градиенты параметров модели. Входящий сигнал ошибки вычисляется в главной функции. Подробнее об этом советую почитать в какой-нибудь книге, например, у Бишопа или Гудфеллоу.
// вычислить обратное распространение
void backward(FloatType error_signal) {
// установить сигнал ошибки (производная потерь относительно последней предактивации единиц) для единственной единицы, расположенной на последнем слое
layer.back()[0].delta = error_signal;
// обратный индекс r позволяет считать с конца, i будет использоваться в качестве индекса вектора
for (size_t r = 0; r < layer.size() - 1; ++r) {
const size_t i = layer.size() - r - 1; // индекс, который будет использоваться в векторе
// перебор всех единиц из актуального слоя
Layer& curr_layer = layer[i];
for (size_t j = 0; j < curr_layer.size(); ++j) {
Unit& curr_unit = layer[i][j];
// вычисляем дельту для всех слоёв кроме последнего (входящий сигнал ошибки)
if (r > 0) {
curr_unit.delta = 0;
const Layer& next_layer = layer[i + 1]; // ближе к выводу
for (size_t k = 0; k < next_layer.size(); ++k) {
const Unit& next_unit = next_layer[k];
curr_unit.delta += next_unit.delta * next_unit.weight[i];
}
}
// вычисляем градиент для весов и смещения (производная потерь относительно каждого параметра)
FloatArray grad;
const Layer& prev_layer = layer[i - 1];
for (size_t k = 0; k < prev_layer.size(); ++k) {
const Unit& prev_unit = prev_layer[k];
grad.push_back(curr_unit.delta * prev_unit.value);
}
curr_unit.grad_weight = grad;
curr_unit.grad_bias = curr_unit.delta;
}
}
}
Наконец, применяем этап градиентного спуска, и на данном этапе обновляем параметры так, чтобы уменьшить потери.
// сделать небольшой шаг в направлении отрицательного градиента, поскольку таким образом снижаются потери
void step(FloatType lr) {
for (size_t i = 1; i < layer.size(); ++i) {
Layer& curr_layer = layer[i];
for (Unit& unit : curr_layer) {
for (size_t j = 0; j < unit.weight.size(); ++j) {
unit.weight[j] -= lr * unit.grad_weight[j];
}
unit.bias -= lr * unit.grad_bias;
}
}
}
Результаты
Спустя несколько секунд модель полностью обучится и даёт прогнозы на тестовом множестве данных с точностью ~99%. Программа также выдаёт образцы и прогнозы, вот два примера.
X
XXXXXXXXXX
XXXXXXXXXXXX
XXX XXXX
XX XXX
XX XXX
XX XXX
XX XX
XX XX
XX XX
XX XX
XX XX
XX XXX
XX XX
XX XXX
XX XXX
XX XXXX
XXXXXXXXXXXXX
XXXXXXXXXX
XXXXXXX
Predicted: 0 (0.155461) Target: 0
X
XXX
XXX
XXX
XXX
XXXX
XXX
XXX
XXX
XXX
XXX
XXX
XXX
XXX
XXX
XX
XXX
XXX
XXX
X
Predicted: 1 (0.962643) Target: 1
Автор: ph_piter


