Neuroniniai klasifikatoriai ir DI: C++ neuroninio tinklo mokymas skaityti rašyseną ranka

Hello, World!

Before we start, note: This article only describes what I did during the first iteration of the model (i.e. https://git.ari.lt/ari/mnist-classify/src/commit/8a1e9150ca63d8e704163808f2d4f7542a9b59f4) now I've changed it, so consider this a part 1 :) I wrote this overnight for school so yea. The new model is relatively the same with some fixes to initialisation, optimization, and uses batch training as well as better quality data. To read the changes see the commit history of ari/mnist-classify.

Šiandiena pristatysiu savo mini projektą, kurį atlikau per porą valandų informatikos pamokai. Šiuo metu mokomės apie dirbtinį intelektą ir neuroninius modelius ir kadangi šia tema turiu tam tikrų žinių, aš dalyvavau pristatant teorinę medžiagą ir sukūriau praktinį pavyzdį - parašiau neuroninį modelį, kuris klasifikuoja ranka rašytus skaitmenis į 10 klasių, t.y. atpažįsta skaičius nuo 0 iki 9.

Šiuo projektu sau iškėliau iššūkį ir neleidau sau naudoti jokių nestandartinių bibliotekų, kurios palengvintų darbą su matricomis, dirbtinio intelekto (toliau -- DI) ar mašininio mokymo (toliau -- MM) algoritmų įgyvendinimą, arba suteiktų aukšto lygio prieigą prie genialios matematikos ir logikos, slypinčios už abstrakčių pavadinimų.

TL;DR: https://git.ari.lt/ari/mnist-classify/

# Pradmenys

Neuroniniai modeliai yra vienas efektyviausių būdų aproksimuoti funkcijas matematikoje, tačiau dėl didelių resursų reikalavimų jie dažniausiai taikomi informatikos ir DI/MM srityje. Viena populiariausių šių modelių formų yra pilnai sujungtas, reguliuojamas neuroninis tinklas, treniruojamas naudojant stochastinį gradientinį nusileidimą (angl. SGD). Šio tinklo paslėptuose sluoksniuose dažniausiai naudojama ReLU aktyvavimo funkcija, o išėjimo sluoksnyje - softmax funkcija. Suprantu, kad tai gali skambėti sudėtingai, tačiau toliau paaiškinsiu šias sąvokas :)

A hand-drawn illustration of a fully-connected neural network. Orange neurons (input layer) has no activation function other (yellow hidden and cyan output) layers have an activation function demonstrated by it being half-coloured
"A simple fully-connected neural network: Input, hidden, and output layers" by Arija A (CC0-1.0). Purpose: To demonstrate a simple neural network. Uploaded on Wed, 14 May 2025 16:45:25 GMT. (raw media here)

Ši iliustracija vaizduoja paprastą neuroninio tinklo schemą. Pirmieji neuronai yra įvesties sluoksnio neuronai, kuriems aktyvacijos funkcijos nereikia, todėl jie nėra užpildyti spalva. Po jų seka paslėptieji neuronai, kurie yra pusiau užpildyti - tai reiškia, kad jie naudoja aktyvacijos funkciją. Taip pat aktyvacijos funkcija taikoma ir dviem išvesties sluoksnio neuronams. Kiekvienas neuronas yra sujungtas su visais kitų sluoksnių neuronais, todėl tinklas yra pilnai sujungtas. Šiame pavyzdyje modelis turi vieną įvesties sluoksnį su 3 neuronais, du paslėptus sluoksnius po 3 neuronus kiekviename, ir vieną išvesties sluoksnį su 2 neuronais.

Pradėkime nuo neurono. Neuroninio tinklo neuronas apskaičiuoja įvesties reikšmių svertinę (weighted, weight = w) sumą ir prideda nuokrypį (bias, b), kuris matematiškai išreiškiamas kaip paprasta linijinė funkcija f(x) = wx + b, kai yra viena įvestis. Jei yra kelios įvestys, tuomet apskaičiuojama visų jų svorių ir reikšmių suma kaip z = w1*x1 + w2*x2 + ... + wn*xn + b prieš pritaikant neurono aktyvacijos funkciją.

Neuronų aktyvacijos funkcija įveda nelinijiškumą, todėl neuroninis tinklas gali modeliuoti ne tik paprastas linijines priklausomybes, bet ir sudėtingus ryšius tarp duomenų. Dažniausiai naudojamos aktyvacijos funkcijos yra šios:

Šiam projektui paslėptuose sluoksniuose buvo pasirinkta ReLU funkcija, o išvesties sluoksnyje - Softmax funkcija. ReLU padeda efektyviau mokyti tinklą, nes ji sumažina gradientų nykimo problemą ir leidžia greičiau bei stabilesniau konverguoti. Tuo tarpu Softmax funkcija leidžia klasifikuoti duomenis į daugiau nei dvi klases - šiuo atveju, į 10 skirtingų klasių, atitinkančių skaitmenis nuo 0 iki 9.

Toliau norėčiau paaiškinti parametrų reguliavimo (L2) ir stochastinio gradientinio nusileidimo (SGD) reikšmę:

Čia ir yra mūsų DI/MM pagrindai :)

# Projekto idėja

Šis DI/MM projektas nėra nieko groundbreaking ar kasnors "tokio". Aš nusprendžiau modeliuoti MNIST duomenis iš https://archive.org/download/mnist-dataset, kurie pateikia 42000 ranka rašytų skaitmenų sugrupuotus į 10 grupių, ir sukurti modelį kuris galėtų tai atpažinti. Tačiau man kilo problema: JPEG formatas yra labai sudėtingas, todėl aš, panaudodama FFMpeg, visus juos konvertavau į labai primityvų PPM P6 formatą kuris realiai yra tik spalvos vienas po kitos naudodamasi šia komanda:

1
parallel -j 8 'ffmpeg -y -i {} {.}.ppm && rm {}' ::: *.jpg

Tai davė man failus kaip:

A terminal window with `cat` being run on a binary file and outputing garbage (since P6 is a binary format) - below is a listing of that file showing its size (2.3 KB) and its filesystem metadata as usual for `ls -l`
"PPM file" by Arija A (CC0-1.0). Purpose: To showcase a PPM file. Uploaded on Wed, 14 May 2025 17:19:27 GMT. (raw media here)

T.y.

The number 0 (handwritten) on a black background in white colour
"MNIST 0/005" by Yann LeCun, Corinna Cortes, and Christopher J.C. Burges (CC-BY-SA-3.0). Purpose: To showcase a file from MNIST. Uploaded on Wed, 14 May 2025 17:22:42 GMT. (raw media here)

Visos nuotraukos buvo paverstos į vienmačias ryškumo realiųjų skaičių matricas, kiekviena dydžio 1x784, kurios aprašo kiekvieno paveikslėlio pikselio ryškumą. Šios matricos buvo perduotos kaip įvestis neuroniniam modeliui, kuris toliau darė savo išvadas.

Dariau projektą C++ kalba dėl mokyklos, nors ir pagrinde dirbu su C ir Python, bet norėjau pritaikyti projektą ir mokyklai ir publikai.

# Kaip Arijos epic skaičių klasifikacijos neuralinis modelis veikia

Gana paprastai.

# PPM P6 formatas

Pirma pradėjau nuo PPM P6 formato ir jo parsavimo: tai buvo gana paprasta, nes šis formatas dėl to ir egzistuoja:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
...
void load_PPM(const fs::path &filename) {
    std::ifstream infile(filename, std::ios::binary);
    if (!infile) {
        throw std::runtime_error("Cannot open file: " + filename.string());
    }

    std::string magic;
    infile >> magic;
    if (magic != "P6") {
        throw std::runtime_error("Unsupported PPM format (expected P6)");
    }

    uint32_t width, height, maxval;
    infile >> width >> height >> maxval;

    if (width != WIDTH || height != HEIGHT) {
        throw std::runtime_error("Unexpected image size (expected 28x28)");
    }
    if (maxval != 255) {
        throw std::runtime_error(
            "Unsupported max colour value (expected 255)");
    }

    infile.get();

    /* Read raw binary pixel data: width * height * 3 bytes */
    unsigned char rgb[3];
    for (uint32_t idx = 0; idx < PIXEL_COUNT; ++idx) {
        if (!infile.read(reinterpret_cast<char *>(rgb), 3)) {
            throw std::runtime_error(
                "Unexpected EOF or read error in pixel data");
        }

        pixels[idx] = (static_cast<uint32_t>(rgb[0]) << 16) |
                      (static_cast<uint32_t>(rgb[1]) << 8) |
                      (static_cast<uint32_t>(rgb[2]));
    }
}
...

# PPM P6 Normalizacija

Kadangi visos nuotraukos buvo 28x28, aš dėmesio į nuotraukų dydį daug nekreipiau. Be to, narį pixels naudojau kaip pagalbinį masyvą, kurį vėliau paverčiau į ryškumo masyvą:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
std::vector<double> image_to_input(const Image28x28 &img) {
    std::vector<double> input(Image28x28::PIXEL_COUNT);
    for (uint32_t idx = 0; idx < Image28x28::PIXEL_COUNT; ++idx) {
        uint8_t r        = (img.pixels[idx] >> 16) & 0xFF;
        uint8_t g        = (img.pixels[idx] >> 8) & 0xFF;
        uint8_t b        = img.pixels[idx] & 0xFF;
        double luminance = calculate_luminance(r, g, b);
        input[idx] =
            (luminance / 255.0 - 0.5) * 2.0; /* normalise to [-1, 1] */
    }
    return input;
}

Funkcija calculate_luminance apskaičiuoja santykinį ryškumą, kurį mato žmogaus akis, naudodama raudonos, žalios ir mėlynos spalvų konstantas, kurios atspindi žmogaus akies jautrumą kiekvienai spalvai: 0.2126 * r + 0.7152 * g + 0.0722 * b. Nors neuroninis modelis nėra žmogus, mano nuomone, tai buvo vienas iš būdų, kaip vaizdiniai duomenys galėjo būti normalizuojami. Bet norint dar labiau sumažinti statistinį triukšmą, galėjome tiesiog paversti nuotraukas dvejetainiais masyvais, naudojant epsiloną lygų ~0.5.

# Metodai

Būdama mini dirbtinio intelekto nerd, pasirinkau kelis metodus optimizuojant tinklą:

  1. Į priekį nukreiptas neuroninis tinklas (t.y., daugiasluoksnis perceptronas): Šis modelis yra pilnai sujungtas tinklas su vienu ar daugiau paslėptų sluoksnių. Ši architektūra pasirinkta dėl jos paprastumo ir veiksmingumo mokant modelius sudėtingų problemų sprendimo.
  2. Prižiūrimas mokymasis: Tinklas mokomas naudojant paženklintus duomenis (vaizdus su žinomomis skaitmenų etiketėmis). Prižiūrimas mokymasis idealiai tinka klasifikavimo užduotims, kai žinomas teisingas išėjimas, todėl modelis gali išmokti įėjimų ir išėjimų atvaizdavimą.
  3. Stochastinis gradientinis nusileidimas: Modelis atnaujina savo svorius po kiekvienos epochos (treneravimo žingsnio). SGD yra veiksmingas ir padeda modeliui greičiau konverguoti, ypač dideliuose duomenų rinkiniuose, nes įveda triukšmą, kuris gali padėti išvengti vietinių minimumų.
  4. Atgalinis skleidimas: Šis algoritmas apskaičiuoja nuostolių/praradimo funkcijos gradientą kiekvieno svorio atžvilgiu, skleisdamas klaidas atgal per visus neuronus - jis yra labai svarbus siekiant veiksmingai mokyti (giliuosius (t.y., 2+ paslėpti sluoksniai)) neuroninius tinklus, nes leidžia naudoti gradientu pagrįstą optimizavimą.
  5. ReLU aktyvacijos funkcija: Naudojama paslėptuosiuose sluoksniuose ir įveda netiesiškumą, leidžiantį tinklui mokytis sudėtingų funkcijų, ir padeda sušvelninti nykstančio gradiento problemą, kuri trugdo modelio treneravimo/mokymo procesui.
  6. Softmax aktyvavimo funkcija: Išvesties sluoksnyje Softmax paverčia neapdorotus išvesties balus tikimybėmis. Tai gerai tinka kelių klasių klasifikavimo užduotims, pavyzdžiui, skaitmenų atpažinimo užduotims kaip mūsų.
  7. Kryžminės entropijos nuostoliai/praradimai: nuostolių funkcija matuoja skirtumą tarp prognozuojamų tikimybių ir tikrųjų etikečių. Kryžminės entropijos nuostoliai yra priimtinesni klasifikavimui, nes pagal juos labiau baudžiama už užtikrintas ir klaidingas prognozes, todėl geriau išmokstama.
  8. Atmetimo reguliavimas: Mokymo metu atsitiktinai išjungiama dalis neuronų - tai neleidžia tinklui per daug pasikliauti konkrečiais neuronais, sumažina perteklinį pritaikymą ir pagerina generalizaciją.
  9. L2 reguliavimas (svorio nykimas): Į nuostolių funkciją įtraukiama bauda už didelius svorius. L2 reguliavimas neskatina kurti sudėtingų modelių su dideliais svoriais, padeda išvengti per didelio pritaikymo ir pagerina generalizaciją.
  10. Gradiento apkarpymas: Apriboja didžiausią gradientų vertę mokymo metu. Šis metodas apsaugo nuo sprogstančių gradientų, kurie gali destabilizuoti mokymą, ypač gilesniuose tinkluose.
  11. Reguliarizacijos parametrų kosinusinis „atkaitinimas“: Naudojant kosinusinį apauginimą, mokymo metu palaipsniui mažinamas iškritimo lygis ir L2 reguliarizacijos stiprumas. Tai leidžia modelį pradėti su stipriu reguliavimu (kad būtų išvengta ankstyvojo perteklinio pritaikymo) ir baigti su mažesniu reguliavimu (kad būtų galima tiksliai sureguliuoti modelį).
  12. He inicializacija: Svoriai inicijuojami naudojant normalųjį pasiskirstymą, kurio mastelis yra atvirkštinė kvadratinė šaknis iš įėjimų skaičiaus. Tai padeda išlaikyti stabilią aktyvacijų dispersiją visame tinkle, o tai ypač svarbu ReLU aktyvacijoms.
  13. One-Hot etikečių kodavimas: Tikslinis skaitmuo pateikiamas kaip matrica, kuriame ties teisingos klasės indeksu yra 1, o kitur - 0. One-Hot kodavimas yra standartinis klasifikavimo užduotims, nes leidžia tinklui išvesti kiekvienos klasės tikimybę.
  14. Duomenų maiša kiekvienoje epochoje: Kiekvienos epochos pradžioje mokymo duomenys išmaišomi/randomizuojami. Išmaišymas padeda išvengti, kad modelis neišmoktų duomenų eiliškumo, ir skatina geresnę generalizaciją, nes kiekvieną kartą duomenys pateikiami skirtinga tvarka.

# Perdavimas į priekį

Perdavimo funkcija apskaičiuoja neuroninio tinklo išvestį pagal tam tikrą įvestį/is, tai vadinama išvedimu (angl. inference). Tai pasiekiama:

  1. Perduodant įvestį per kiekvieną sluoksnį,
  2. Taikant tiesinę transformaciją (w & b),
  3. Taikant netiesinę aktyvaciją (ReLU paslėptiems sluoksniams, softmax išėjimui),
  4. Pasirinktinai paslėptiesiems sluoksniams mokymo metu taikant atmetima.

Funkcija, tiesą sakant, atrodo baisiai:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
std::vector<double> forward(const std::vector<double> &input) {
    activations.clear();
    zs.clear();

    activations.push_back(input);
    std::mt19937 rng(std::random_device{}());
    std::bernoulli_distribution dropout_dist(1.0 - dropout_rate);

    for (size_t layer = 1; layer < layers.size(); ++layer) {
        const auto &prev_activation = activations.back();
        std::vector<double> z_values(layers[layer], 0.0);
        std::vector<double> layer_activation(layers[layer], 0.0);

        for (size_t neuron = 0; neuron < layers[layer]; ++neuron) {
            for (size_t prev_neuron = 0; prev_neuron < layers[layer - 1];
                 ++prev_neuron) {
                z_values[neuron] +=
                    prev_activation[prev_neuron] *
                    weights[layer - 1]
                           [prev_neuron * layers[layer] + neuron];
            }
            z_values[neuron] += biases[layer - 1][neuron];
        }

        zs.push_back(z_values);

        for (size_t neuron = 0; neuron < layers[layer]; ++neuron) {
            layer_activation[neuron] = (layer == layers.size() - 1)
                                           ? z_values[neuron]
                                           : relu(z_values[neuron]);

            if (dropout_rate > 0.0 && layer < layers.size() - 1) {
                if (!dropout_dist(rng)) {
                    layer_activation[neuron] = 0.0;
                }
            }
        }

        if (layer == layers.size() - 1) {
            activations.push_back(softmax(layer_activation));
        } else {
            activations.push_back(layer_activation);
        }
    }

    return activations.back();
}

Bet ką ji realiai daro yra paprasta iteracija, neuronų aktyvavimas, bei būsenos valdymas - elementari linijinė algebra bei programavimas.

# Atgalinis skleidimas

Funkcija train_step atsakinga už:

  1. paleidžia priekinį perdavimą, kad būtų apskaičiuotos prognozės,
  2. nuostolių ir jų gradiento apskaičiavimą,
  3. atgalinį skleidimą, kad būtų apskaičiuoti visų svorių ir nuokrypių gradientai,
  4. reguliarizacijos ir gradiento apkarpymo taikymą,
  5. modelio parametrų atnaujinimas.

Tai yra jūsų neuroninio tinklo mokymosi esmė.

# Priekinis perdavimas

Norint apskaičiuoti nuostolius ir nuolydžius, pirma reikia prognozuojamų tikimybių, todėl mes paleidžiame priekinį perdavimą:

1
std::vector<double> probs = forward(input);

# Deltos

Toliau, mes apskaičiuojame deltas:

1
2
3
4
5
6
7
std::vector<std::vector<double>> deltas(layers.size());

deltas.back() = std::vector<double>(layers.back());
for (size_t idx = 0; idx < layers.back(); ++idx) {
    double target      = (idx == label ? 1.0 : 0.0);
    deltas.back()[idx] = probs[idx] - target;
}

Tai yra kryžminės entropijos nuostolių gradientas logitų atžvilgiu (išėjimai prieš softmax). Šis paklaidos signalas yra atgalinio skleidimo pradžios taškas.

# Atgalinis skleidimas

Algoritmas atrodo labai primityviai:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
for (size_t layer = layers.size() - 2; layer > 0; --layer) {
    deltas[layer] = std::vector<double>(layers[layer], 0.0);
    for (size_t neuron = 0; neuron < layers[layer]; ++neuron) {
        for (size_t next_neuron = 0; next_neuron < layers[layer + 1]; ++next_neuron) {
            double grad = weights[layer][neuron * layers[layer + 1] + next_neuron] *
                          deltas[layer + 1][next_neuron];
            deltas[layer][neuron] += grad * relu_derivative(zs[layer - 1][neuron]);
        }
    }
}

Jis kiekvienam paslėptajam sluoksniui (nuo paskutinio iki pirmojo) apskaičiuoja kiekvieno neurono deltą. Tada, susumuoja visų kito sluoksnio neuronų indėlį, pasvertą pagal jungčių svorius ir kito sluoksnio deltas. Galiausiai, daugina iš ReLU aktyvumo išvestinės (kuri yra 1, jei neuronas buvo aktyvus, 0, jei ne).

Tai yra grandininė atgalinio skleidimo taisyklė: klaidos skleidimas atgal per visą tinklą. Deltos rodo nuostolio gradientą kiekvieno neurono prieš aktyvavimą (z) vertės atžvilgiu.

# Svorių ir nuokrypių atnaujinimas

Mūsų pagrindinė treneravimo esmė vyksta čia:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
for (size_t layer = 1; layer < layers.size(); ++layer) {
    for (size_t neuron = 0; neuron < layers[layer]; ++neuron) {
        for (size_t prev_neuron = 0; prev_neuron < layers[layer - 1]; ++prev_neuron) {
            double grad = activations[layer - 1][prev_neuron] *
                          deltas[layer][neuron];
            if (l2_lambda > 0.0) {
                grad += l2_lambda *
                        weights[layer - 1][prev_neuron * layers[layer] + neuron];
            }
            grad = std::clamp(grad, -grad_clip, grad_clip);
            weights[layer - 1][prev_neuron * layers[layer] + neuron] -=
                learning_rate * grad;
        }
        double bias_grad = deltas[layer][neuron];
        bias_grad        = std::clamp(bias_grad, -grad_clip, grad_clip);
        biases[layer - 1][neuron] -= learning_rate * bias_grad;
    }
}

Kiekvienam svoriui funkcija apskaičiuoja gradientą kaip ankstesnio sluoksnio aktyvacijos ir dabartinio neurono deltos sandaugą. Jei L2 reguliarizacija įjungta, prideda L2 reguliarizacijos narį, kuris baudžia už didelius svorius. Toliau taikomas gradiento apkarpymas, kad atnaujinimai neviršytų priimtino intervalo. Galiausiai, atnaujina svorius ir nuokrypius, naudodamas apskaičiuotus gradientus ir mokymosi greitį.

Tai standartinis gradientinio nusileidimo svorio atnaujinimo epilogas.

# Treneravimas

Pagaliau, sudedame visas funkcijas į vieną:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
void train(const std::vector<TaggedImage28x28> &dataset,
           size_t epochs,
           double learning_rate) {
    size_t dataset_size                    = dataset.size();
    std::vector<TaggedImage28x28> shuffled = dataset;
    std::mt19937 rng(static_cast<unsigned>(std::time(nullptr)));

    bool decaying;

    for (size_t epoch = 1; epoch <= epochs; ++epoch) {
        double progress = static_cast<double>(epoch - 1) /
                          std::max<size_t>(1, epochs - 1);
        double dropout, lambda;

        /* We do this for less than half the training for it to learn better
         */
        if (progress < 0.3) {
            /* Phase 1: sane parameters to allow for learning */
            decaying = false;
            dropout  = 0.2;
            lambda   = 0.001;
        } else {
            /*
             * Phase 2: cosine annealing decay from
             * 0.2 -> 0.02 dropout,
             * 0.001 -> 0.0001 lambda.
             */

            decaying = true;

            double decay_progress = (progress - 0.5) / 0.5;
            double cosine_decay =
                0.5 *
                (1 + cos(M_PI * decay_progress)); /* cosine from 1 to 0 */

            dropout = 0.02 + (0.2 - 0.02) * cosine_decay;
            lambda  = 0.0001 + (0.001 - 0.0001) * cosine_decay;
        }

        set_training_params(dropout, lambda, grad_clip);

        std::shuffle(shuffled.begin(), shuffled.end(), rng);
        double total_loss = 0.0;
        size_t correct    = 0;

        for (const auto &sample : shuffled) {
            std::vector<double> input = image_to_input(sample.img);
            std::vector<double> probs = forward(input);

            for (size_t idx = 0; idx < probs.size(); ++idx) {
                double target = (idx == sample.tag ? 1.0 : 0.0);
                total_loss +=
                    -target * std::log(std::max(probs[idx], 1e-15));
            }

            size_t predicted =
                std::distance(probs.begin(),
                              std::max_element(probs.begin(), probs.end()));
            if (predicted == sample.tag)
                ++correct;

            train_step(input, sample.tag, learning_rate);
        }

        double avg_loss = total_loss / dataset_size;
        double accuracy = static_cast<double>(correct) / dataset_size;

        std::cout << "Epocha " << epoch
                  << (decaying ? " (derinimas ir reguliarizacija)"
                               : " (stabilus mokymas)")
                  << ": Praradimas (loss) = " << avg_loss
                  << ", Teisingumas = " << accuracy * 100.0 << "%\n";
    }
}

Tai mūsų finalinis treneravimo žingnis: epochinis mokymasis.

Pirma, duomenys išmaišomi, kad modelis neišmoktų jokių nenumatytų sekos modelių. Tuomet mokymas vykdomas epochomis, t. y. ištisais duomenų rinkinio perėjimais. Viso šio proceso metu L2 reguliarizavimo ir atmetimo parametrai koreguojami atsižvelgiant į mokymo eigą. Kiekvienos epochos metu modelis mokomas iš kiekvieno atskiro duomenų taško, ir šis procesas kartojamas tol, kol praeinamos visos nurodytos epochos.

# Klasifikacija (prognozė)

Galiausiai po apmokymo galime klasifikuoti rašyseną, konvertuodami ją į ryškumo matricą ir atlikdami vieną priekinį perėjimą per visą modelį bei pasirinkdami didžiausią tikimybę iš visų 10 išvesties sluoksnių:

1
2
3
4
5
6
7
8
void predict(const Image28x28 &img) {
    std::vector<double> input = image_to_input(img);
    std::vector<double> probs = forward(input);
    size_t predicted          = std::distance(
        probs.begin(), std::max_element(probs.begin(), probs.end()));
    std::cout << "Klasifikacija: " << predicted
              << ", Tikrumas: " << probs[predicted] << "\n";
}

# Kas iš to?

Fun stuff :D Man tai buvo labai įdomus iššūkis, kuriame galėjau pritaikyti daug savo žinių apie neuroninius modelius. Rekomenduoju visiems priimti tokį iššūkį ir pasinerti į DI/MM pasaulį :)

Be to,

A screenshot of a terminal emulator window with Lithuanian text reading output of mnist-classify after training and during classification of a drawn number 4
"Screenshot of mnist-classify" by Arija A (CC0-1.0). Purpose: To show training final product. Uploaded on Wed, 14 May 2025 18:49:24 GMT. (raw media here)

:3

One philosophical lesson one could pick up from this is that you have to go down before you go up. When the model is in its "stable learning" phase it goes up and down until it finds its place, I find that nice.

Ačiū už skaitymą! Iki kito karto :)