C ++ 20: Простой классификатор Softmax для набора данных MNIST

Я написал простой классификатор softmax для классификации набора данных рукописного ввода цифр MNIST. Не стесняйтесь комментировать что угодно!

#include <algorithm>
#include <bit>
#include <cassert>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <execution>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <numeric>
#include <memory>
#include <random>
#include <string>
#include <type_traits>
#include <vector>

struct MNISTObject {
    std::vector<double> image;
    std::uint8_t label = -1;
    static std::uint32_t rows;
    static std::uint32_t cols;
    static std::uint8_t labels;
};

std::uint32_t MNISTObject::rows = 28;
std::uint32_t MNISTObject::cols = 28;
std::uint8_t MNISTObject::labels = 10;

constexpr std::uint32_t image_code = 2051;
constexpr std::uint32_t label_code = 2049;

void convertBigToLittleEndianIfNecessary(uint32_t& code) {
    if constexpr (std::endian::native == std::endian::little) {
        code = ((code & 0xFF000000) >> 24) |
               ((code & 0x00FF0000) >> 8) |
               ((code & 0x0000FF00) >> 8) |
               ((code & 0x000000FF) >> 24);
    }
}

void read_uint32(std::istream& is, std::uint32_t& code) {
    if (!is.read(reinterpret_cast<char*>(&code), sizeof code)) {
        std::cerr << "Cannot read uint32n";
        return;
    }
    convertBigToLittleEndianIfNecessary(code);
}

void fillDataset(std::istream& images, std::istream& labels, std::vector<MNISTObject>& data_set) {
    std::uint32_t code;
    read_uint32(images, code);
    if (code != image_code) {
        std::cerr << "Wrong image code : " << code << 'n';
        return;
    }
    read_uint32(labels, code);
    if (code != label_code) {
        std::cerr << "Wrong label code : " << code << 'n';
        return;
    }

    std::uint32_t cnt_images, cnt_labels;
    read_uint32(images, cnt_images);
    read_uint32(labels, cnt_labels);
    if (cnt_images != cnt_labels) {
        std::cerr << "Image/label counts do not matchn";
        return;
    } else {
        std::cout << cnt_images << " imagesn";
    }

    std::uint32_t rows, cols;

    read_uint32(images, rows);
    read_uint32(images, cols);
    if (rows != MNISTObject::rows || cols != MNISTObject::cols) {
        std::cerr << "Wrong rows and cols: " << rows << ' ' << cols << 'n';
        return;
    }
    std::cout << rows << " rows " << cols << " colsn";

    std::uint32_t img_size = rows * cols;

    data_set.reserve(cnt_images);
    for (std::size_t i = 0; i < cnt_images; ++i) {
        MNISTObject obj;
        obj.image.reserve(img_size);
        std::uint8_t pixel;
        for (std::size_t p = 0; p < img_size; ++p) {
            if (!images.read(reinterpret_cast<char*>(&pixel), sizeof pixel)) {
                std::cerr << "Pixel read failn";
                return;
            }
            double pixel_val = static_cast<double>(pixel) / 255.0;
            obj.image.push_back(pixel_val);
        }
        std::uint8_t label;
        if (!labels.read(reinterpret_cast<char*>(&label), sizeof label)) {
            std::cerr << "Label read failn";
            return;
        }
        if (label >= MNISTObject::labels) {
            std::cerr << "Wrong label : " << static_cast<std::uint32_t>(label) << 'n';
            return;
        }
        obj.label = label;
        data_set.push_back(obj);
    }
    std::cout << cnt_images << " images read successn";
}

std::pair<std::vector<MNISTObject>, std::vector<MNISTObject>> constructDataSets() {
    std::filesystem::path train_images_path = "train-images.idx3-ubyte";
    std::filesystem::path train_labels_path = "train-labels.idx1-ubyte";
    std::ifstream train_images_ifs {train_images_path, std::ios::binary};
    if (!train_images_ifs) {
        std::cerr << "Cannot open input file " << train_images_path;
    }
    std::ifstream train_labels_ifs {train_labels_path, std::ios::binary};
    if (!train_labels_ifs) {
        std::cerr << "Cannot open input file " << train_labels_path;
    }

    std::vector<MNISTObject> train_set;
    fillDataset(train_images_ifs, train_labels_ifs, train_set);

    std::filesystem::path test_images_path = "t10k-images.idx3-ubyte";
    std::filesystem::path test_labels_path = "t10k-labels.idx1-ubyte";
    std::ifstream test_images_ifs {test_images_path, std::ios::binary};
    if (!test_images_ifs) {
        std::cerr << "Cannot open input file " << test_images_path;
    }
    std::ifstream test_labels_ifs {test_labels_path, std::ios::binary};
    if (!test_labels_ifs) {
        std::cerr << "Cannot open input file " << test_labels_path;
    }

    std::vector<MNISTObject> test_set;
    fillDataset(test_images_ifs, test_labels_ifs, test_set);

    return {train_set, test_set};
}

template <typename T>
struct Matrix {
    static_assert(std::is_scalar_v<T>);
    const int R;
    const int C;
    std::unique_ptr<T[]> data;

    Matrix(int R, int C) : R {R}, C {C}, data(new T[R * C]) {
        assert(R > 0 && C > 0);
    }

    T& operator()(int r, int c) {
        assert(0 <= r && r < R && 0 <= c && c < C);
        return data[r * C + c];
    }

    const T& operator()(int r, int c) const {
        assert(0 <= r && r < R && 0 <= c && c < C);
        return data[r * C + c];
    }
};

std::mt19937 gen(std::random_device{}());

std::vector<double> computeProb(const Matrix<double>& W, const MNISTObject& mnist_sample) {
    assert(W.R == MNISTObject::labels && W.C == MNISTObject::rows * MNISTObject::cols);
    std::vector<double> probs;
    probs.reserve(MNISTObject::labels);
    double sum_probs = 0.0;
    for (int l = 0; l < MNISTObject::labels; l++) {
        auto inner_prod = std::transform_reduce(std::execution::par_unseq,
                                                &W.data[l * W.C], &W.data[(l + 1) * W.C],
                                                mnist_sample.image.begin(), 0.0);
        auto prob = std::exp(inner_prod);
        probs.push_back(prob);
        sum_probs += prob;
    }
    std::for_each(std::execution::par_unseq, probs.begin(), probs.end(), [&sum_probs](auto& p){p /= sum_probs;});
    auto real_sum = std::reduce(std::execution::par_unseq, probs.begin(), probs.end(), 0.0);
    if (std::fabs(real_sum - 1.0) >= 1e-5) {
        std::cerr << "Probability sum failed in softmax: " << real_sum << 'n';
    }
    return probs;
}

constexpr int num_epochs = 100;

double testSoftmaxClassifier(const Matrix<double>& W, const std::vector<MNISTObject>& test_set) {
    int correct = 0;
    int incorrect = 0;
    for (const auto& test_sample : test_set) {
        auto probs = computeProb(W, test_sample);
        auto predict = std::distance(probs.begin(), std::ranges::max_element(probs));
        if (predict == test_sample.label) {
            correct++;
        } else {
            incorrect++;
        }
    }
    return correct / (correct + incorrect * 1.0);
}

Matrix<double> trainSoftmaxClassifier(const std::vector<MNISTObject>& train_set,
                                      const std::vector<MNISTObject>& test_set,
                                      double lr, double weight_decay) {
    std::uniform_real_distribution<> weight_dist(-1.0, 1.0);
    const int k = MNISTObject::labels;
    const int sz = MNISTObject::rows * MNISTObject::cols;
    Matrix<double> W (k, sz);
    for (int i = 0; i < k * sz; ++i) {
        W.data[i] = weight_dist(gen);
    }

    for (int epoch = 0; epoch < num_epochs; epoch++) {
        auto t1 = std::chrono::high_resolution_clock::now();
        for (const auto& train_sample : train_set) {
            auto probs = computeProb(W, train_sample);
            auto correct = train_sample.label;
            for (int l = 0; l < k; ++l) {
                for (int p = 0; p < sz; ++p) {
                    W(l, p) = W(l, p) * (1.0 - lr * weight_decay)
                            - lr * train_sample.image[p] * probs[l];
                }
                if (l == correct) {
                    for (int p = 0; p < sz; ++p) {
                        W(l, p) += lr * train_sample.image[p];
                    }
                }
            }
        }
        auto t2 = std::chrono::high_resolution_clock::now();
        auto dt = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1);
        std::cout << "epoch " << epoch << " finished in " << dt.count() << "msn";

        auto accu = testSoftmaxClassifier(W, test_set);
        std::cout << "Accuracy : " << accu << 'n';
    }

    return W;
}

int main() {
    auto [train_set, test_set] = constructDataSets();

    constexpr double lr = 0.0005;
    constexpr double weight_decay = 0;

    auto W = trainSoftmaxClassifier(train_set, test_set, lr, weight_decay);

}

Вы можете скачать набор данных здесь: http://yann.lecun.com/exdb/mnist/

Результат на моей машине:

60000 images
28 rows 28 cols
60000 images read success
10000 images
28 rows 28 cols
10000 images read success
epoch 0 finished in 1132ms
Accuracy : 0.7536
epoch 1 finished in 1142ms
Accuracy : 0.8176
epoch 2 finished in 987ms
Accuracy : 0.8424
epoch 3 finished in 1094ms
Accuracy : 0.8574
epoch 4 finished in 1038ms
Accuracy : 0.8662
epoch 5 finished in 1191ms
Accuracy : 0.8724
epoch 6 finished in 1087ms
Accuracy : 0.8789
epoch 7 finished in 1020ms
Accuracy : 0.8838
epoch 8 finished in 1170ms
Accuracy : 0.8867
epoch 9 finished in 1150ms
Accuracy : 0.889
epoch 10 finished in 791ms
Accuracy : 0.8908
epoch 11 finished in 815ms
Accuracy : 0.8919
epoch 12 finished in 809ms
Accuracy : 0.8929
epoch 13 finished in 792ms
Accuracy : 0.8934
epoch 14 finished in 798ms
Accuracy : 0.894
epoch 15 finished in 816ms
Accuracy : 0.8959
epoch 16 finished in 826ms
Accuracy : 0.8965
epoch 17 finished in 990ms
Accuracy : 0.8978
epoch 18 finished in 804ms
Accuracy : 0.8983
epoch 19 finished in 907ms
Accuracy : 0.8995
epoch 20 finished in 800ms
Accuracy : 0.9002
...

3 ответа
3

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

Это выглядит сломанным и предполагает, что вам не хватает хорошего модульного теста:

void convertBigToLittleEndianIfNecessary(uint32_t& code) {
    if constexpr (std::endian::native == std::endian::little) {
        code = ((code & 0xFF000000) >> 24) |
               ((code & 0x00FF0000) >> 8) |
               ((code & 0x0000FF00) >> 8) |
               ((code & 0x000000FF) >> 24);
    }
}

Последнее подвыражение всегда будет нулевым — я думаю, что последние два подвыражения должны иметь << где ты написал >>.

Отчет об ошибках плохой — много void-функции возврата распечатывают предупреждения пользователя в std::cerr (хорошо), но у них нет возможности сообщить своим абонентам, что они будут работать с недопустимыми данными (плохо). Было бы полезнее throw исключение, чем просто return. (Опять же, было бы хорошо провести больше модульного тестирования — я обычно начинаю новый код, создавая тест с недопустимыми входными данными в качестве самого первого шага.)

Пара мелких недоработок с матричным классом:

  • Есть ли веская причина использовать (подписано) int по габаритам?
  • Я бы предпочел увидеть std::make_unique<T[]>(R*C) чем голый new.
  • Я не понимаю, почему это будет работать только для скалярных T. Как правило, лучше ограничить использование концепций там, где это возможно (например, template<std::semiregular T> struct Matrix).

Я бы написал correct++, incorrect++ а также epoch++ используя префиксный оператор, поскольку это хорошая общая привычка (используйте постфиксный оператор только тогда, когда нам действительно нужно исходное значение). Код непоследовательный, так как у нас есть ++i а также ++p в другом месте.

  • Хороший улов для преобразования порядка байтов, спасибо! Я всегда предпочитаю целые числа со знаком для измерений, потому что это помогает отловить непослушные конструкции, такие как Matrix<double> M(-1, -1). Я согласен со всем остальным, на что вы указали.

    — замороженный


  • Хорошие предупреждения компилятора (-Wconversion для GCC, я думаю) предупредит вас о таких злоупотреблениях, если вы не принимаете подписанные значения. Я предпочитаю получать уведомления во время компиляции, чем во время выполнения (и я использую -Werror чтобы убедиться, что проблемы решены). Это также уменьшает количество модульных тестов, которые нужно писать и поддерживать.

    — Тоби Спейт


В дополнение к замечаниям Тоби Спейта я бы добавил:

Использовать constexpr для всех констант

Я вижу, ты уже использовал constexpr для image_code а также label_code, но вы не использовали его для static переменные-члены rows, cols а также labels из MNISTObject. Но похоже, что они тоже константы. Вы можете просто написать:

struct MNISTObject {
    ...
    static constexpr std::uint32_t rows = 28;
    static constexpr std::uint32_t cols = 28;
    static constexpr std::uint8_t labels = 10;
};

Создавайте переменные и функции static где уместно

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

Предпочитать returnполучение значений из функций

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

std::uint32_t code;
read_uint32(images, code);
if (code != image_code) {...}

Если вместо этого вы позволите read_uint32() return прочитанное значение, вы можете записать приведенный выше фрагмент кода как:

if (read_uint32(images) != image_code) {...}

Еще одно преимущество заключается в том, что теперь он заставит вас return что-то, даже если произошла ошибка. Потому что в этом коде:

std::uint32_t code;
read_uint32(images, code);

Значение code остается неинициализированным, если есть ошибка чтения, но ваша программа продолжает работать. Есть небольшая вероятность, что значение 2051 было бы в памяти, зарезервированной для code, поэтому ваша программа может неправильно продолжить работу, предполагая, что был прочитан правильный код изображения. Я бы последовал совету Тоби здесь и throw исключение (желательно std::runtime_error или что-то из этого получено):

uint32_t read_uint32(std::istream& is) {
    if (uint32_t code; is.read(reinterpret_cast<char*>(&code), sizeof code)) {
        return convertBigToLittleEndianIfNecessary(code);
    } else {
        throw std::runtime_error("Read error");
    }
}

Также обратите внимание, что вам также следует return для больших объектов, который по-прежнему так же эффективен, как передача его через ссылочный параметр благодаря оптимизация возвращаемого значения. Так fillDataSet должен просто return набор данных (и изменить его имя на readDataSet() чтобы отразить это).

Оптимизировать чтение из файлов

Вы читаете данные изображения из входного файла побайтно. Это довольно неэффективно. Я предлагаю вам вместо этого прочитать все изображение за один присест:

auto pixels = std::make_unique_for_ovewrite<std::uint8_t[]>(img_size);
if (!images.read(reinterpret_cast<char*>(pixels.get()), img_size)) {
    throw std::runtime_error("Read error");
}

std::transform(pixels.get(), pixels.get() + img_size, std::back_inserter(obj.image),
               [](std::uint8_t pixel){ return pixel / 255.0; });

  • Проголосовали. Не make_unique предпочтительнее, чем make_unique_for_overwrite? Будет ненужная стоимость нулевого заполнения.

    — замороженный


  • std::make_unique_for_overwrite это тот, который позволяет избежать «затрат на нулевое заполнение», если это возможно; оно использует инициализация по умолчанию для хранения, тогда как std::make_unique использует инициализация значения. Имя также является подсказкой: его следует использовать, когда вы все равно собираетесь перезаписать значения, и это именно то, что мы делаем при чтении данных изображения в pixels.

    — Г. Сон


Тоби Спейт и Дж. Слипен дали отличные отзывы с точки зрения программиста;

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

  • Пользователь должен иметь возможность указывать гиперпараметры (lr, weight_decay и т. Д.), Либо код должен включать настройку гиперпараметров.

  • Неправильная инициализация матрицы весов. Он должен использовать гауссовское распределение со средним значением 0, stdev sqrt(2/(#fanin + #fanout))

  • Если тренировка достаточно схожа, ее следует прекратить раньше.

  • Эта реализация softmax уязвима для переполнения / потери значимости. Вместо этого используйте трюк logsumexp.

  • Было бы хорошо включить возможность указывать размер партии и работать с ней

  • double слишком много для таких простых задач, как MNIST: вместо этого используйте float32

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

— Тоби Спейт

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *