В этом посте продолжаю публикацию отдельных глав своей книги, посвященной программированию на языке Си.
Предыдущий пост "Послевкусие Си: распространение сигнала в нейронной сети".
Глава 3. Нейронна мережа для класифікації зображень рукописних цифр
3.1 База даних рукописних цифр MNIST
На цей момент ми вже вміємо отримувати відгук нейронної мережі на заданий вхідний сигнал і тепер можемо перейти до вирішення наступного завдання – навчимо нейронну мережу розпізнавати рукописні цифри.
Розпізнавання цифр, написаних від руки, відноситься до галузі використання можливостей штучного інтелекту, оскільки ця проблема справді нетривіальна. Вона не така ясна і чітко визначена, як, наприклад, обрахунок добутку матриць, який було зроблено раніше. Класифікацію вмісту зображень за допомогою комп'ютера називають розпізнаванням образів і вирішальну роль тут зіграли технології нейронних мереж. Про існування складнощів можна судити хоча б по тому, що навіть ми, люди, іноді не можемо зрозуміти, яке саме зображення ми бачимо. Зокрема, проблему може викликати написана нерозбірливим почерком буква чи цифра. Вам немає необхідності бути експертом у галузі обчислень або лінійної алгебри, щоб створити програму, яка розпізнає рукописні цифри.
Ми з вами далеко не перші, хто зацікавився цією проблемою. Існує колекція зображень рукописних цифр, які використовуються дослідниками штучного інтелекту як популярний набір для тестування ідей та алгоритмів. Те, що колекція всім відома і користується популярністю, означає, що будь хто може перевірити те, як виглядає його чергова ідея в порівнянні з ідеями інших людей. Тобто різні ідеї та алгоритми тестуються з використанням одного і того ж тестового набору. Ось і ми теж скористаємося цим тестовим набором, щоб перевірити результати наших зусиль щодо створення класифікатора зображень рукописних цифр.
Таким тестовим набором є база даних рукописних цифр під назвою "MNIST". Ця база надається авторитетним дослідником у галузі нейронних мереж на ім'я Yann LeCun для безкоштовного загального доступу за адресою https://yann.lecun.com/exdb/mnist/. За бажанням ви знайдете на цьому сайті відомості щодо успішності колишніх і нинішніх спроб коректного розпізнавання рукописних символів і вже наприкінці нашої з вами роботи, ви зможете порівняти отримане нами рішення з попередніми спробами.
Для завдання класифікації зображень рукописних цифр із набору даних MNIST використовується повністю зв'язана нейронна мережа. Набір даних (dataset) MNIST складається з 60 тисяч навчальних і 10 тисяч тестових зображень розміром 28x28 пікселів, кожне з яких представляє одну з десяти можливих цифр від 0 до 9. Архітектура мережі включає вхідний шар, який представляє кожне зображення у вигляді одновимірного масиву (вектор) довжиною 784 елементи, один прихований шар та вихідний шар з 10 нейронами.
Тренувальний набір (https://www.pjreddie.com/media/files/mnist_train.csv) містить 60 тисяч промаркованих екземплярів, що використовуються для тренування нейронної мережі. Слово “промарковані” тут означає, що для кожного екземпляра вказана відповідна правильна відповідь.
Тестовий набір (https://www.pjreddie.com/media/files/mnist_test.csv) включає 10 тисяч екземплярів і використовується для перевірки правильності роботи тренованої нейронної мережі. Кожен рядок набору також містить коректні маркери, що дозволяють побачити, чи здатна нейронна мережа дати правильну відповідь.
Використання незалежних один від одного наборів тренувальних та тестових даних гарантує, що з тестовими даними нейронна мережа раніше не стикалася.
У цих файлах зберігаються довгі рядки тексту, які містять числа, розділені комами. В цьому можна переконатися, відкривши такий файл у текстовому редакторі. Текстові рядки досить довгі, тому кожен із них займає кілька рядків на екрані.
Зміст цих рядків тексту зрозуміти нескладно. Перше значення у рядку – це маркер , тобто фактична цифра, наприклад “7” або “5”, або якась інша з діапазону від “0” до “9”, яку представляє даний рукописний зразок. Цей маркер є правильною відповіддю (label), отриманню якої має навчитися нейронна мережа. Наступні значення, розділені комами, – це пікселі графічного зображення рукописної цифри. Піксельний масив має розмірність 28x28, тому за кожним маркером слідують 784 пікселі, точніше буде сказати, що ці 784 числа – це коди кольорів пікселів, з яких складається зображення.
Якщо цікаво побачити, як довгий список із 784 значень формує зображення, наприклад, рукописної цифри “5”, то ви повинні сформувати у графічному вигляді ці цифри та переконатися в тому, що вони дійсно є пікселями рукописної цифри. Придивіться уважно і ви помітите, що значення не виходять за межі діапазону від 0 до 255. Ви можете перевірити інші записи, щоб переконатися в тому, що ця умова виконується і для них. Так воно і є: значення кодів усіх кольорів потрапляють у діапазон чисел від 0 до 255. Ймовірно, це цікаво, але відволікатися на це зараз не будемо.
Глибоке навчання (Deep learning) – це різновид машинного навчання, в рамках якого штучні нейронні мережі навчаються на великих обсягах даних. Воно включає використання багаторівневої апроксимації нелінійних функцій, зазвичай у вигляді нейронних мереж, тобто це набір технік та методів використання нейронних мереж для виконання завдань машинного навчання.
Хочу нагадати, що ця книга не є підручником з глибокого навчання, а використовує приклади з нейронними мережами для демонстрації процесу розробки програм мовою С. Якщо ви вже намагалися дізнатися щось про нейронні мережі та глибоке навчання, то, швидше за все, зіткнулися з достатком ресурсів, від блогів до масових відкритих онлайн-курсів різної якості і навіть книг. Книг з глибокого навчання на даний момент написано вже дуже багато і тільки лінивий ще нічого не написав про те, як створюються і навчаються нейронні мережі. Саме тому переказувати вкотре те, що вже було неодноразово написано багатьма авторами, не має сенсу.
Деякі ресурси стосуються в основному концептуальної та математичної частини та містять малюнки, які, як правило, зустрічаються у поясненнях нейронних мереж, а також докладні математичні пояснення того, що відбувається, щоб ви могли зрозуміти суть. Прикладом цього є хороша книга Ian Goodfellow та ін. «Deep Learning» https://www.deeplearningbook.org/ І тим не менш, давати поради стосовно того, яку саме книгу слід прочитати, немає сенсу. У виданих книгах одні й ті самі принципи пояснюються авторами по-різному з тим чи іншим ступенем уваги до деталей. Для когось одне пояснення може бути зрозумілішим, ніж інше, тому пошукайте і спробуйте читати книги різних авторів. Ви самі відчуєте, яка з цих книг вам краще “заходить”.
3.2. Підготовка даних
Для створення повністю зв'язаної нейронної мережі для класифікації зображень рукописних цифр з набору даних MNIST можна використовувати найрізноманітніші засоби, але для навчальних цілей поки що будемо задовольнятися виключно можливостями, які надає стандартна бібліотека мови С.
Отже, є намір використати дані з файлів MNIST для навчання нейронної мережі, але перш ніж надавати дані мережі, необхідно їх трохи підготувати. Якщо на даний момент ви все ще смутно уявляєте собі те, як працює нейронна мережа, тоді просто прийміть мої слова на віру: нейронні мережі працюють краще, якщо вхідні та вихідні дані конфігуруються таким чином, щоб вони залишалися в діапазоні значень, оптимальному для функцій активації вузлів нейронної мережі. Тому перше, що треба зробити, – це перевести значення кодів кольорів з діапазону значень від 0 до 255 у набагато менший, що охоплює значення від 0,01 до 1,0. Свідомо вибираємо значення 0,01 як нижню межу діапазону, щоб уникнути проблем із нульовими вхідними значеннями, оскільки вони можуть штучно блокувати оновлення ваг. Не існує необхідності вибирати значення 0,99 в якості верхньої межі допустимого діапазону, оскільки немає потреби уникати значень 1,0 для вхідних сигналів. Розподіл вихідних значень, що змінюються в діапазоні від 0 до 255, на 255 приведе їх до діапазону від 0 до 1,0. Подальше множення цих значень на коефіцієнт 0,99 приведе їх до діапазону від 0,0 до 0,99. А тепер інкрементуємо їх значення на 0,01, щоб помістити їх у бажаний діапазон від 0,01 до 1,0.
Маємо тренувальний набір чисел, який містить 60 тисяч промаркованих екземплярів, що буде використовуватися для тренування нейронної мережі, і тепер потрібно перетворити список чисел, розділених комами, у відповідний масив, розбивши довгий текстовий рядок значень, на окремі значення, використовуючи символ коми як роздільник. Всі ці дії реалізує наступний код мовою С.
const size_t MAX_LEN = 2 + 28 * 28 * 4;
FILE *fd = fopen(csvfile, "r");
if (fd == NULL) {
fprintf(stderr, "Error reading file.\n");
return 1;
}
double(*M)[rows][cols] = (void *)arr;
char buf[MAX_LEN] = {0};
for (int i = 0; i < rows; i++) {
char *result = fgets(buf, MAX_LEN, fd);
if (result != NULL) {
int j = 0;
char *c = strtok(result, ",");
while (c != NULL) {
(*M)[i][j] = (j == 0) ? atoi(c) : atoi(c) / 255.0 * 0.99 + 0.01;
j++;
c = strtok(NULL, ",");
}
} else {
break;
}
}
fclose(fd);
return 0;
}
Для того, щоб скористатися цією функцією, необхідно зарезервувати памʼять для збереження тренувальних даних. Також було б корисно зʼясувати розмір набору даних (датасету) і вивести його на екран у зручній для сприйняття формі. Це можна зробити за допомогою функції human_size:
#define TRAIN_ROWS 60000
#define DATA_COLS (1 + 28 * 28)
static const char *human_size(uint64_t bytes) {
char *suffix[] = {"B", "KB", "MB", "GB", "TB"};
char length = sizeof(suffix) / sizeof(suffix[0]);
int i = 0;
double dbytes = bytes;
if (bytes > 1024) {
for (i = 0; (bytes / 1024) > 0 && i < length - 1; i++, bytes /= 1024) {
dbytes = bytes / 1024.0;
}
}
static char output[200];
sprintf(output, "%.02lf %s", dbytes, suffix[i]);
return output;
}
. . .
uint64_t bytes = (TRAIN_ROWS * DATA_COLS) * sizeof(double);
double *ptrtd = (double *)malloc(bytes);
/* Check if the memory has been successfully allocated by malloc or not */
if (ptrtd == NULL) {
fprintf(stderr, "Memory not allocated.");
exit(EXIT_FAILURE);
}
printf("Training data: %s\n", TRAIN_FILE);
readcsv(TRAIN_FILE, TRAIN_ROWS, DATA_COLS, (double *)ptrtd);
/* PRIu64 is a format specifier, introduced in C99, for printing uint64_t */
printf("Size of training data %" PRIu64 " Bytes: %s\n", bytes, human_size(bytes));
При виконанні цього фрагменту коду отримаємо наступне:
Size of training data 376800000 Bytes: 359.34 MB
Звертаю вашу увагу на те, що нейронні мережі навчаються на великих обсягах даних, іноді дуже великих. Дані, які використовуються для виконання завдання класифікації рукописних цифр, відносно невеликі, вони цілком можуть розміститися в оперативній пам'яті і не вимагають спеціальних заходів щодо їх обробки. Робота з великими обсягами тренувальних даних виходить за рамки теми цієї книги і тут не розглядається.
Тепер саме час задати кількість вузлів вхідного, прихованого та вихідного шарів. Кількість вузлів вхідного шару 784. Цю цифру ми детально обговорили вище. Кількість вузлів вихідного шару вибрано 10 з міркувань того, що мережа розпізнаватиме рукописні цифри з діапазону від 0 до 9. Інакше кажучи, очікується, що нейронна мережа класифікує зображення і надасть йому коректний маркер. Таким маркером може бути одне з десяти чисел в діапазоні від 0 до 9. Це означає, що вихідний шар мережі повинен мати 10 вузлів, по одному на кожну можливу відповідь, або маркер. Якщо відповіддю є “0”, то активізуватися повинен перший вузол, тоді як інші вузли мають бути пасивними. Якщо відповіддю є “9”, то активізуватися повинен останній вузол вихідного шару при інших пасивних вузлах. Кількість вузлів прихованого шару вибрано 200. Будемо поки вважати, що цей вибір був нами зроблений довільно. Ці дані визначають конфігурацію та розмір нейронної мережі:
const int INODES = 784;
const int HNODES = 200;
const int ONODES = 10;
Немає на цей момент ніякого суворого наукового обґрунтування вибору двох сотень прихованих вузлів. Це число було вибрано меньшим, ніж 784, з тих міркувань, що нейронна мережа має знаходити у вхідних значеннях такі особливості або шаблони, які можна виразити у більш короткій формі, ніж ці значення. Тому, вибираючи кількість вузлів меншою, ніж кількість вхідних значень, ми змушуємо мережу намагатися знаходити ключові особливості шляхом узагальнення інформації. У той же час, якщо вибрати кількість прихованих вузлів занадто малою, буде обмежено можливості мережі щодо визначення достатньої кількості відмітних ознак або шаблонів у зображенні. Тим самим мережа була б позбавлена можливості виносити власні судження щодо даних MNIST. З урахуванням того, що вихідний шар повинен забезпечувати виведення 10 маркерів, а отже, повинен мати десять вузлів, вибір проміжного значення 200 для кількості вузлів прихованого шару є цілком розумним.
Немає ідеального загального методу для вибору кількості прихованих вузлів. Також немає і ідеального методу вибору кількості прихованих шарів. На цей час найкращим підходом є проведення експериментів доти, доки не буде отримана конфігурація мережі, оптимальна для завдання, яке ви намагаєтесь вирішити. Саме цими експериментами вам пропонується зайнятися після того, як ми закінчимо вирішувати це завдання.
3.3. Ініціалізація мережі
Найважливіша частина нейронної мережі – матриці вагових коефіцієнтів зв'язків (ваги). Ці матриці використовуються для розрахунку поширення сигналів у прямому напрямку, а також зворотного розповсюдження помилок, і саме вагові коефіцієнти уточнюються у спробі покращити характеристики мережі. Подивіться ще раз уважно на графік функції активації: деякі надто великі ваги можуть змістити функцію активації в область великих значень, що призвело б до її насичення. Тому слід уникати великих початкових значень вагових коефіцієнтів, оскільки використання функції активації в цій області значень може призводити до насичення мережі та зниження здатності мережі вчитися на кращих значеннях. Найгірший вибір початкових значень вагових коефіцієнтів – нульові значення, оскільки вони повністю знищують вхідний сигнал. У цьому випадку функція оновлення ваг, яка залежить від вхідних сигналів, обнуляється, тим самим повністю виключаючи можливість оновлення ваг. Значення вагових коефіцієнтів внутрішніх зв'язків мають бути випадковими та невеликими і можуть мати як позитивні, так і негативні значення і змінюватися в діапазоні від -1,0 до +1,0. Для простоти віднімемо 0,5 із цих граничних значень, перейшовши до діапазону значень від -0,5 до +0,5.
Створення матриць початкових ваг здійснюється за допомогою функції fill_random:
double Who[ONODES][HNODES] = {0};
void fill_random(double *arr, int rows, int cols) {
srand(time(NULL));
for (int i = 0; i < rows; i++) {
int row = i * cols;
for (int j = 0; j < cols; j++) {
*((arr + row) + j) = 1.0 * rand() / RAND_MAX - 0.5; /* -0.5 .. +0.5 */
}
}
}
. . .
fill_random((double *)Wih, HNODES, INODES);
fill_random((double *)Who, ONODES, HNODES);
В літературі на тему глибокого навчання пишуть також про інші різні способи підготовки даних та ініціалізації вагових коефіцієнтів. Існує дещо вдосконалений підхід до створення випадкових початкових значень ваг. Для цього вагові коефіцієнти вибираються з нормального розподілу з центром у нулі та зі стандартним відхиленням, величина якого обернено пропорційна кореню квадратному з кількості вхідних зв'язків на вузол. Пропоную вам зробити це самостійно. Google вам допоможе.
Було б корисно поцікавитися розмірами пам'яті, яку займають матриці вагових коефіцієнтів:
printf("Size of Wih %" PRIu64 " Bytes: %s\n", bytes, human_size(bytes));
bytes = (ONODES * HNODES) * sizeof(double); // sizeof(Who);
printf("Size of Who %" PRIu64 " Bytes: %s\n", bytes, human_size(bytes));
При виконанні цього фрагменту коду отримаємо наступне:
Size of Who 16000 Bytes: 15.62 KB
Зрозуміло, що розмір пам'яті, яку займають ці дві матриці, відносно невеликий і не потребує додаткової уваги.
3.4. Тренування нейронної мережі
На цей момент ми вже вміємо обчислювати вихідні сигнали нейронної мережі за заданими величинами вхідних сигналів. Наступний крок полягає у порівнянні вихідних сигналів нейронної мережі з даними тренувального прикладу для визначення помилки. Помилка нейронної мережі є функцією ваг внутрішніх зв'язків. Потрібно знати величину цієї помилки, щоб можна було покращити вихідні результати шляхом налаштування параметрів мережі. Інакше кажучи, потрібно десь, щось трохи підкрутити у самій мережі, щоб вона правильно розпізнавала рукописні цифри. Підкрутити можна відповідні ваги, але які саме і наскільки – це головне питання. Безпосередній підбір відповідних ваг, наприклад, методом грубої сили наштовхується на значні труднощі. Альтернативний підхід полягає у ітеративному поліпшенні вагових коефіцієнтів шляхом зменшення функції помилки невеликими кроками. Кожен крок здійснюється у напрямку якнайшвидшого спуску з поточної позиції. Цей підхід називається градієнтним спуском. Градієнт помилки розраховується за допомогою диференційного обчислення.
Традиційно будь-яке програмне забезпечення, що реалізує такі когнітивні здібності, як сприйняття, пошук, планування та навчання, є частиною штучного інтелекту. Нейронну мережу можна сприймати як універсальний апроксиматор функцій, який теоретично може представити вирішення майже будь-якої контрольованої проблеми навчання. Однією з фундаментальних концепцій навчання мережі є зворотне поширення (backpropagation) помилки – метод градієнтної оцінки, який використовується для навчання моделей нейронної мережі. Оцінка градієнта використовується алгоритмом оптимізації для обчислення оновлень параметрів мережі. Якщо вам ці слова видалися страшними і незрозумілими, то зараз саме час поцікавитися тим, як працює алгоритм зворотного поширення помилки. Нічого такого, що потребувало б нелюдських розумових зусиль для розуміння ідеї цього алгоритму, насправді немає. Коротше кажучи, нейронні мережі навчаються уточненням вагових коефіцієнтів своїх зв'язків. Цей процес керується помилкою – різницею між правильною відповіддю, що надається тренувальними даними, і фактичним вихідним значенням нейронної мережі. Помилка на вихідних вузлах визначається простою різницею між бажаним і фактичним вихідними значеннями. У той самий час величина помилки, яка повязана з внутрішніми вузлами, менш очевидна. Одним із способів вирішення цієї проблеми є розподіл помилок вихідного шару між відповідними зв'язками пропорційно до ваги кожного зв'язку з наступним об'єднанням відповідних розрізнених частин помилки на кожному внутрішньому вузлі. Це є сутність алгоритму зворотного поширення. Все це описано і детально розтлумачено майже у кожній книзі, присвяченій темі глибокого навчання, тому опису алгоритму зворотного поширення у цій книзі немає. Розбираючись з цим алгоритмом, зверніть увагу на те, що зворотне поширення помилок описується за допомогою вже знайомого вам матричного множення. Вміння розраховувати зворотне розповсюдження помилок до кожного шару мережі дозволяє зрозуміти те, як мають бути змінені ваги зв'язків, щоб покращити загальну результуючу відповідь на виході нейронної мережі. Вихідний сигнал нейронної мережі є складною функцією з багатьма параметрами, ваговими коефіцієнтами зв'язків, які впливають на вихідний сигнал. Метою навчання нейронної мережі на тренувальних даних є отримання цієї функції.
Вихідні сигнали повинні знаходитися в межах діапазону, який може забезпечити функція активації. Значення, менші або рівні 0 або більші або рівні 1, не сумісні з логістичною сигмоїдою. Установка тренувальних цільових значень за межами допустимого діапазону призведе до ще більших значень ваг і, зрештою, до насичення мережі. Підходящим варіантом є діапазон значень від 0,01 до 0,99. Якщо тренувальний приклад позначений маркером “5”, то для вихідного вузла слід створити такий цільовий масив, в якому будуть малі значення всіх елементів, крім одного, що відповідає маркеру “5”. У разі цей масив міг би виглядати приблизно так: [0,0,0,0,0,1,0,0,0,0].
Насправді ці числа потребують додаткового масштабування, оскільки ми вже бачили, що спроби створення на виході нейронної мережі значень 0 і 1, недосяжні через використання функції активації, і призводять до великих ваг і насичення мережі. Отже, натомість використовуватимемо значення 0,01 і 0,99, і тому цільовим масивом для маркера “5” має бути масив
[0.01, 0.01, 0.01, 0.01, 0.01, 0.99, 0.01, 0.01, 0.01, 0.01]
Всі ці міркування реалізує наступний код мовою С.
for (int i = 0; i < TRAIN_ROWS; i++) {
double *ptrd = (*TD)[i];
double targets[ONODES] = {[0 ... ONODES - 1] = 0.01};
targets[(int)(*ptrd)] = 0.99;
ptrd++; // skip 0-element; now ptrd is a pointer to inputs
train(ptrd, targets);
}
Цей рядок коду вибирає перший елемент запису з набору даних MNIST, що є цільовим маркером тренувального набору:
double *ptrd = (*TD)[i];
Згадайте, що запис читається з вихідного файлу у вигляді текстового рядка, а не числа. Як тільки перетворення виконано, отриманий цільовий маркер використовується для встановлення значення відповідного елемента масиву рівним 0,99. Наприклад, маркер “0” буде перетворено на ціле число 0, що є коректним індексом даного маркера в масиві:
У тілі циклу присутній виклик функції train, якій передаються два параметри: масив тренувальних даних ptrd і масив targets, точніше буде сказати, що передаються вказівники, хоча це суті справи не змінює. Функція train реалізує процес тренування нейронної мережі, використовуючи тренувальні дані, що надаються їй на кожному кроці циклу. Усі необхідні для реалізації процесу тренування функції розташовані в окремому у файлі common.c.
3.5. Складання проекту на мові C
У програмуванні все починається з вихідного коду. Насправді вихідний код, який ще іноді називають кодовою базою, зазвичай складається з цілого ряду текстових файлів. Кожен файл містить інструкції, написані мовою програмування. У цьому підрозділі демонструється, як зібрати проект, написаний на C. Приклад, з яким ми будемо працювати, складається з кількох вихідних файлів, що характерно майже для всіх проектів цією мовою. Але перш ніж переходити до збірки, спочатку розглянемо структуру нашого проекту на C.
Будь-який проект на C містить вихідний код (або кодову базу) та інші документи, які описують розроблюваний додаток і використовувані стандарти. Код C зазвичай зберігається у файлах двох типів: у заголовкових файлах, які мають розширення .h; у вихідних файлах із розширенням .c. Для стислості заголовкові файли будуть в подальшому називатися заголовками.
Заголовок зазвичай містить макроси та визначення типів, а також оголошення функцій, глобальних змінних та структур. У мові програмування C оголошення та визначення деяких елементів програмування, таких як функції, змінні та структури, можуть знаходитись у різних файлах. Оголошення функції показує, як її використовувати, а визначення містить її реалізацію. Оголошення функцій рекомендується розміщувати в заголовках, а їх визначення – у відповідних вихідних файлах. Це особливо стосується функцій. І хоча це не є обов'язковою вимогою, такий підхід до проектування дозволяє зберігати визначення функцій поза заголовками. До вихідних файлів можна підключати лише заголовки. Заголовки не повинні містити нічого, крім оголошень. До вихідного файлу не підключаються інші вихідні коди, тому він завжди компілюється окремо. Пам'ятайте: відповідно до загальноприйнятих рекомендацій, вихідні файли C/C++ підключати не можна. (Мається на увазі за допомогою директиви #include).
Подивимося тепер на те, як усе влаштовано у нашому проекті нейронної мережі розпізнавання рукописних цифр. Створений на цей час код, розміщено в трьох файлах: одному заголовку і двох файлах з вихідним кодом. Усі вони знаходяться в одному каталозі:
. . .
-rw-r--r-- 1 vadim staff 8057 Jul 29 20:21 common.c
-rw-r--r-- 1 vadim staff 1589 Jul 29 20:20 common.h
-rw-r--r-- 1 vadim staff 4118 Jul 29 20:21 train.c
Я підкреслю ще раз: оголошення функцій зберігаються у заголовковому файлі, а визначення (або тіла) у вихідному. Порушувати це правило можна лише в окремих випадках. Крім того, щоб мати доступ до оголошення, вихідний файл повинен підключити заголовковий файл. Саме так це працює у C та C++.
Отже, наш проект складається з кількох файлів, тому зараз необхідно розібратися у тому, як правильно скомпілювати такий проект. Заголовковий файл відіграє роль містка, що сполучає два вихідні файли і дозволяє розділити код на дві частини, які збираються разом. Наведений нижче заголовковий файл common.h містить усе, що потрібно одному вихідному коду для використання функціональності іншого.
#ifndef COMMON_H
#define COMMON_H
#include <stdint.h> /* uint64_t */
#include <stdio.h> /* size_t */
#define LOG_ERROR(format, ...) fprintf(stderr, format, __VA_ARGS__)
/* number of input, hidden and output nodes */
#define INODES 784
#define HNODES 200
#define ONODES 10
double Wih[HNODES][INODES];
double Who[ONODES][HNODES];
const char *human_size(uint64_t bytes);
int readcsv(const char *csvfile, int rows, int cols, double *arr);
. . .
void train(double *inputs, double *targets);
. . .
#endif
Тут можна побачити попередні оголошення функцій. Попереднім називається оголошення, що знаходиться перед відповідним визначенням. Крім того, тут також застосовується запобігання дублюванню, яке не дає компілятору підключити заголовковий файл двічі. У будь-якому проекті мовою C функція main служить точкою входу у програму. Ця функція знаходиться у файлі train.c.
Файли, з якими ви познайомилися вище, потрібно зібрати, щоб отримати результуючий файл, тобто файл, який можна буде запустити на виконання. Складання проекту на C/C++ вимагає компіляції його кодової бази в об'єктні файли, які ще називають проміжними, і потім об'єднання їх в кінцеві продукти, такі як статичні бібліотеки або виконувані файли. Для компіляції представлених тут вихідних файлів ми не будемо використовувати інтегроване середовище розробки (Integrated Development Environment, IDE). Натомість ми застосуємо компілятор безпосередньо, без допоміжного програмного забезпечення. Описані кроки нічим не відрізняються від тих, які фоново виконує IDE, компілюючи набір вихідних файлів.
Компіляція набору вихідних файлів:
3.6. Додаткові пояснення
Тіло функції main файлу train.c містить код, який потребує додаткових пояснень.
clock_t begin = clock();
. . .
/* train the neural network */
/* epochs is the number of times the training data set is used for training */
int epochs = 5;
double(*TD)[TRAIN_ROWS][DATA_COLS] = (void *)ptrtd;
for (int e = 0; e < epochs; e++) {
printf("epoch %d\n", e);
for (int i = 0; i < TRAIN_ROWS; i++) {
. . .
ptrd++; // skip 0-element; now ptrd is a pointer to inputs
train(ptrd, targets);
}
}
free(ptrtd);
const char *wihname = getname("wih", HNODES, INODES);
tocsv(wihname, HNODES, INODES, Wih);
const char *whoname = getname("who", ONODES, HNODES);
tocsv(whoname, ONODES, HNODES, Who);
double time_spent = (double)(clock() - begin) / CLOCKS_PER_SEC;
printf("Time spent:\t%.2f seconds.\n", time_spent);
return 0;
}
Навчальний набір даних використовується для навчання кілька разів, тобто здійснюється багаторазове повторення циклів тренування з тим самим набором даних. Щодо одного тренувального циклу іноді використовують термін “епоха”. Тому сеанс тренування з п'яти епох означає п'ятиразовий прогін всього тренувального набору даних. А навіщо це робити особливо якщо для цього комп'ютеру потрібно більше часу? Причина полягає в тому, що тим самим ми намагаємося забезпечити більше маршрутів градієнтного спуску, що оптимізують вагові коефіцієнти. Це звичайна практика. Подробиці і обґрунтування ви за бажанням знайдете в інтернеті. Подивимося, що нам дадуть п'ять тренувальних епох. Ми виконали компіляцію набору вихідних файлів і отримали в результаті виконуваний файл, який можемо запустити і оцінити кількість часу, витраченого на виконання тренування:
Training data: mnist_dataset/mnist_train.csv
Size of training data 376800000 Bytes: 359.34 MB
epoch 0
epoch 1
epoch 2
epoch 3
epoch 4
Time spent: 385.85 seconds.
Ви не забули про те, що матриці вагових коефіцієнтів, елементи яких було змінено в процесі тренування, все ще знаходяться в оперативній памʼяті? Після закінчення процесу тренування, слід потурбуватися про те, щоб зберегти результати тренування на диску. Це надасть можливість в подальшому користуватися натренованою мережею. Збереження матриці у вигляді CSV-файлу на диску здійснює функція tocsv. Очевидно, що має бути функція, яка вміє відновлювати вміст матриці в оперативній пам'яті з файлу на диску. Така функція є. Це функція fromcsv:
double arr[rows][cols]);
void fromcsv(const char *csvfile, size_t rows, size_t cols,
double arr[rows][cols]);
У мові С є тип size_t, який зазвичай використовується для представлення розміру об’єктів у байтах і тому використовується як тип повернення оператором sizeof. Він гарантовано буде достатньо великим, щоб вмістити розмір найбільшого об’єкта, який може бути оброблений вашим компʼютером. В основному максимально допустимий розмір залежить від компілятора; якщо компілятор 32-розрядний, то це просто typedef (тобто псевдонім) для unsigned int, але якщо компілятор 64-бітний, то це буде typedef для unsigned long long. Тип даних size_t ніколи не є негативним. Тому багато функцій бібліотеки C такі, наприклад, як malloc, memcpy і strlen, оголошують свої аргументи з типом повернення як size_t. Це натяк на те, що в коді використовується в деяких місцях тип int, хоча правильніше було б використовувати тип size_t. В файлі common.c ви знайдете згадані дві функції, в яких було в якості приклада використано тип size_t. Для вас було б дуже корисно проаналізувати вже написаний код і внести редагування, змінивши тип int на size_t там, де це необхідно.
У тілі функції train() використовується коефіцієнт навчання - це множник, що згладжує величину змін, щоб уникнути перельотів за мінімум у методі градієнтного спуску. Коефіцієнт навчання можна налаштовувати з урахуванням особливостей конкретного завдання.
До речі, ви пам'ятаєте, що код написано на С із застосуванням засобів лише стандартної бібліотеки? З огляду на це, зовсім не дивує те, що у разі сучасних швидкодіючих домашніх комп'ютерів, а використано було MacBook Pro, обробка всіх 60 тисяч тренувальних прикладів, для кожного з яких необхідно обчислити поширення сигналів від 784 вхідних вузлів через двісті прихованих вузлів у прямому напрямку, а також зворотне поширення помилок і оновлення ваг, зайняло всього кілька хвилин, точніше 386 секунд. Для вашого комп'ютера тривалість обчислень може бути іншою.
Спробуйте повторити це на іншій мові програмування. А поки ви це будете робити, ми продовжимо писати на С, тому наш легкий для читання, але не ідеально оптимізований код все одно працюватиме на порядок швидше, ніж порівняний код будь-якою іншою мовою, яка розпухла від великої кількості усякої функціональності.
3.7. Тестування нейронної мережі
В результаті виконання процесу тренування мережі, ми маємо збереженим на диску вміст двох матриць: матриці ваг Wih, яка містить вагові коефіцієнти для зв'язків між вхідним та прихованим шарами, і матрицю Who, яка містить коефіцієнти для зв'язків між прихованим та вихідним шарами. Вміст цих матриць знаходиться у файлах wih_200_784.csv і wih_200_784.csv відповідно.
Далі ми додамо до нашого набору вихідних файлів новий файл query.c, розроблений для тестування ефективності мережі. На цей час набір файлів містить в собі:
$ ls -l
-rw-r--r-- 1 vadim staff 1589 Jul 29 20:20 common.h
-rw-r--r-- 1 vadim staff 2490 Jul 29 20:46 query.c
-rw-r--r-- 1 vadim staff 1916 Jul 29 20:28 train.c
-rw-r--r-- 1 vadim staff 37261 Jul 29 20:32 who_10_200.csv
-rw-r--r-- 1 vadim staff 2908419 Jul 29 20:32 wih_200_784.csv
Фрагментарний вміст файлу query.c представлено нижче:
#define TEST_FILE "mnist_dataset/mnist_test.csv"
#define TEST_ROWS 10000
#define DATA_COLS (1 + 28 * 28)
int idxmax(double *vec, int length) {
double res = vec[0];
int idx = 0;
for (int i = 1; i < length; i++) {
if (res < vec[i]) {
res = vec[i];
idx = i;
}
}
return idx;
}
void viewres(int *vec, int length) {
for (int i = 0; i < length; i++) {
printf("%3d\t", vec[i]);
}
putchar('\n');
}
int main() {
uint64_t bytes = (TEST_ROWS * DATA_COLS) * sizeof(double);
double *ptrtd = (double *)malloc(bytes);
/* Check if the memory has been successfully allocated by malloc or not */
. . .
printf("Testing data: %s\n", TEST_FILE);
readcsv(TEST_FILE, TEST_ROWS, DATA_COLS, (double *)ptrtd);
. . .
const char *wihname = getname("wih", HNODES, INODES);
fromcsv(wihname, HNODES, INODES, Wih);
. . .
const char *whoname = getname("who", ONODES, HNODES);
fromcsv(whoname, ONODES, HNODES, Who);
. . .
/* scorecard for how well the network performs, initially 0 */
int success[ONODES] = {0};
int fail[ONODES] = {0};
int total = 0;
double(*TD)[TEST_ROWS][DATA_COLS] = (void *)ptrtd;
for (int i = 0; i < TEST_ROWS; i++) {
. . .
ptrd++; // skip 0-element; now ptrd is a pointer to inputs
double final_outputs[ONODES] = {0};
query(ptrd, final_outputs);
int idx = idxmax(final_outputs, ONODES);
if (idx == label) {
success[label]++;
total++;
} else {
fail[label]++;
}
}
/* the performance score, the fraction of correct answers */
printf("Performance score: %.2f\n", (total / (double)TEST_ROWS));
viewres(success, ONODES);
viewres(fail, ONODES);
free(ptrtd);
return 0;
}
Функція getname формує ім’я файлу, з якого буде відновлено в оперативній пам'яті попередньо збережену матрицю вагових коефіцієнтів. В свою чергу, функція fromcsv відновлює вміст обох матриць в оперативній пам'яті з файлів на диску. Те, як влаштована функція query вже було розібрано вище, тому на ній зупинятися не будемо.
Перш ніж увійти в цикл, що обробляє всі записи тестового набору даних, створюється два порожніх масиви success і fail, які будуть виконувати роль журналу оцінок роботи мережі, і які оновлюються після обробки кожного запису. Потім робиться розрахунок ефективності і функція viewres виводить у стандартний потік виводу результати. Ще один суттєвий момент, про який часто забувають, це free(ptrtd). Не будемо забувати звільняти ресурси.
Компіляція файлу з вихідним кодом і запуск на виконання:
$ ./query
Testing data: mnist_dataset/mnist_test.csv
Size of testing data 62800000 Bytes: 59.89 MB
Size of Wih 1254400 Bytes: 1.20 MB
Size of Who 16000 Bytes: 15.62 KB
Performance score: 0.97
973 1124 999 983 954 863 933 987 937 977
7 11 33 27 28 29 25 41 37 32
Останні два рядки виведення містять по десять елементів. Наприклад, число 1124 – це кількість правильно розпізнаних зображень цифри “1” у тестовому наборі, а число 11 – відповідно неправильно розпізнаних цифр “1”.
Відповідно до результатів навчання нашої простої 3-шарової нейронної мережі з використанням набору даних, що включає 60 тисяч прикладів, та подальшого тестування на 10 тисяч записів показник загальної ефективності мережі складає 0.97. Точність розпізнавання становить 97%. Це досить непогано. Цей показник, що дорівнює 97%, можна порівняти з аналогічними результатами еталонних тестів, які можна знайти за вже відомою адресою http://yann.lecun.com/exdb/mnist/ . Там ви побачите, що в деяких випадках наші результати навіть кращі за еталонні і майже порівняні з наведеними на вказаному сайті результатами для найпростішої нейронної мережі, ефективність якої склала 95,3%. Як для навчального та демонстраційного прикладу, написаного “на коліні” і не відшліфованого до стану продукту, результат дійсно непоганий. Подальше вдосконалення цього коду залишаю вам для вашої самостійної творчості.
Отримання показника ефективності 97% при тестуванні набору даних MNIST нашою нейронною мережею – це зовсім непогано, і ваше бажання зупинитися на цьому можна було б вважати цілком виправданим. Однак давайте спробуємо поекспериментувати. Насамперед, ми можемо спробувати налаштувати коефіцієнт навчання. Перед цим ми задали його рівним LEARNING_RATE = 0.1, навіть не тестуючи інші значення. Якщо ми збільшимо це значення до 0,6 і подивимося, як це позначиться на можливості нейронної мережі вчитися, то побачимо, що виконання коду з таким значенням коефіцієнта навчання дає результат гірший від попереднього. Очевидно, збільшення коефіцієнта навчання порушує монотонність процесу мінімізації помилок методом градієнтного спуску та супроводжується перескоками через мінімум. А що станеться, якщо ми зменшимо коефіцієнт навчання до 0,01? Це також призводить до зменшення показника ефективності мережі. Очевидно, занадто малі значення коефіцієнта навчання знижують ефективність. Це є логічним, оскільки малі кроки зменшують швидкість градієнтного спуску. Врахуйте, якщо ви самостійно будете виконувати цей код, то ваші оцінки трохи відрізнятимуться від наведених тут, оскільки процес в цілому містить елементи випадковості. Ваш випадковий вибір початкових значень вагових коефіцієнтів не співпадатиме з моїм, а тому маршрут градієнтного спуску для вашого коду буде іншим.
Подібно до того, як ми налаштовували коефіцієнт навчання, проведемо експеримент з використанням різної кількості епох і оцінимо залежність показника ефективності від цього фактору. Щось підказує, що чим більше тренувань, тим вища ефективність, але можна припустити, що занадто велика кількість тренувань може призвести до погіршення ефективності через так зване перенавчання мережі на тренувальних даних, що знижує ефективність при роботі з незнайомими даними. Слід побоюватися фактора перенавчання у будь-яких видах машинного навчання, а не лише у нейронних мережах. Для студентів перенавчання можливо теж загрожує певними наслідками, тому не слід робити експерименти з перенавчанням власного мозку.
Вихідний код, який було розглянуто в цій главі, а також два CSV-файли з матрицями можна завантажити за адресою на GitHub: https://github.com/iamvadimov/cmnist
Підведемо підсумки. У штучному інтелекті є область, присвячена створенню програмного забезпечення для виконання завдань, які потребують інтелекту, через навчання на основі даних. Вона називається машинне навчання, у якого в свою чергу є три основні напрями: навчання з учителем, без вчителя та навчання з підкріпленням. Навчання з учителем передбачає використання промаркованих даних. У процесі навчання людина вирішує, які дані потрібно зібрати і як їх помітити. Мета цього напрямку машинного навчання – узагальнення. Класичний приклад – створена тут програма для розпізнавання цифр, написаних від руки: одна добра людина зібрала зображення з рукописними цифрами і промаркувала їх, а ми навчили мережу правильно розпізнавати та категоризувати ці цифри. При цьому очікувалось, що навчена мережа зможе узагальнювати та категоризувати зображення з такими цифрами. Судячи з отриманих результатів, створена мережа дійсно може це робити.
Акцентную увагу на наступному. Нейронна мережа це не алгоритм, а структура, яка складається з кількох верств математичних перетворень, що застосовуються до вхідних значень, створення якої було виконано з використанням можливостей лише стандартної бібліотеки мови С, без залучення спеціальних засобів.
Зараз, коли у вас є код, і з'явилося відчуття задоволення від того, що все вийшло, необхідно трохи остудити ваш запал. Насправді, все не так просто, як могло здатися. Зауважте, що для завдання було взято у достатній кількості рафіновані, тобто завчасно дбайливо підготовлені дані, в яких мінімум помилок. Частіше може статися так, що тренувальних даних буде недостатньо для ефективного навчання мережі. У тренувальних даних можуть бути помилки, у зв'язку з чим справедливість припущення про те, що вони істинні і на них можна вчитися, під питанням. Кількість шарів або вузлів у самій мережі може бути недостатньою для того, щоб правильно моделювати рішення задачі тощо. Це означає, що підходи, які ви будете використовувати в подальшому, повинні враховувати зазначені нюанси. Сподіваюся, що у вас вже з'явилися ідеї щодо вдосконалення коду та проведення нових експериментів з більшою кількістю шарів мережі та вузлів.
Комментариев нет:
Отправить комментарий