суббота, 5 октября 2024 г.

Послевкусие Си: нейронная сеть для классификации изображений рукописных цифр

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

Предыдущий пост "Послевкусие Си: распространение сигнала в нейронной сети".

Глава 3. Нейронна мережа для класифікації зображень рукописних цифр

3.1 База даних рукописних цифр MNIST

На цей момент ми вже вміємо отримувати відгук нейронної мережі на заданий вхідний сигнал і тепер можемо перейти до вирішення наступного завдання – навчимо нейронну мережу розпізнавати рукописні цифри.

Розпізнавання цифр, написаних від руки, відноситься до галузі використання можливостей штучного інтелекту, оскільки ця проблема справді нетривіальна. Вона не така ясна і чітко визначена, як, наприклад, обрахунок добутку матриць, який було зроблено раніше. Класифікацію вмісту зображень за допомогою комп'ютера називають розпізнаванням образів і вирішальну роль тут зіграли технології нейронних мереж. Про існування складнощів можна судити хоча б по тому, що навіть ми, люди, іноді не можемо зрозуміти, яке саме зображення ми бачимо. Зокрема, проблему може викликати написана нерозбірливим почерком буква чи цифра. Вам немає необхідності бути експертом у галузі обчислень або лінійної алгебри, щоб створити програму, яка розпізнає рукописні цифри.

Ми з вами далеко не перші, хто зацікавився цією проблемою. Існує колекція зображень рукописних цифр, які використовуються дослідниками штучного інтелекту як популярний набір для тестування ідей та алгоритмів. Те, що колекція всім відома і користується популярністю, означає, що будь хто  може перевірити те, як виглядає його чергова ідея в порівнянні з ідеями інших людей. Тобто різні ідеї та алгоритми тестуються з використанням одного і того ж тестового набору. Ось і ми теж скористаємося цим тестовим набором, щоб перевірити результати наших зусиль щодо створення класифікатора зображень рукописних цифр. 

Таким тестовим набором є база даних рукописних цифр під назвою "MNIST". Ця база надається авторитетним дослідником у галузі нейронних мереж на ім'я Yann LeCun для безкоштовного загального доступу за адресою https://yann.lecun.com/exdb/mnist/. За бажанням ви знайдете на цьому сайті відомості щодо успішності колишніх і нинішніх спроб коректного розпізнавання рукописних символів і вже наприкінці нашої з вами роботи, ви зможете порівняти отримане нами рішення з попередніми спробами.

Для завдання класифікації зображень рукописних цифр із набору даних MNIST використовується повністю зв'язана нейронна мережа. Набір даних (dataset) MNIST складається з 60 тисяч навчальних і 10 тисяч тестових зображень розміром 28x28 пікселів, кожне з яких представляє одну з десяти можливих цифр від 0 до 9. Архітектура мережі включає вхідний шар, який представляє кожне зображення у вигляді одновимірного масиву (вектор) довжиною 784 елементи, один прихований шар та вихідний шар з 10 нейронами.

Розбиратися з форматом бази даних MNIST ми з вами зараз не будемо, а скористаємося заздалегідь підготовленими CSV-файлами, в яких окремі значення є звичайним текстом і розділені комами. Ці значення можна переглядати в будь-якому текстовому редакторі, і більшість електронних таблиць або програм, призначених для аналізу даних, можуть працювати з CSV-файлами. Це досить універсальний формат. 

Тренувальний набір  (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 тисяч промаркованих екземплярів, що буде використовуватися для тренування нейронної мережі, і тепер потрібно перетворити список чисел, розділених комами, у відповідний масив, розбивши довгий текстовий рядок значень, на окремі значення, використовуючи символ коми як роздільник.  Всі ці дії реалізує наступний код мовою С.

int readcsv(const char *csvfile, int rows, int cols, double *arr) {
  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_FILE "mnist_dataset/mnist_train.csv"
#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));

При виконанні цього фрагменту коду отримаємо наступне:

Training data: mnist_dataset/mnist_train.csv
Size of training data 376800000 Bytes: 359.34 MB

Звертаю вашу увагу на те, що нейронні мережі навчаються на великих обсягах даних, іноді дуже великих. Дані, які використовуються для виконання завдання класифікації рукописних цифр, відносно невеликі, вони цілком можуть розміститися в оперативній пам'яті і не вимагають спеціальних заходів щодо їх обробки. Робота з великими обсягами тренувальних даних виходить за рамки теми цієї книги і тут не розглядається.

Тепер саме час задати кількість вузлів вхідного, прихованого та вихідного шарів.  Кількість вузлів вхідного шару 784. Цю цифру ми детально обговорили вище. Кількість вузлів вихідного шару вибрано 10 з міркувань того, що мережа розпізнаватиме рукописні цифри з діапазону від 0 до 9. Інакше кажучи, очікується, що нейронна мережа класифікує зображення і надасть йому коректний маркер. Таким маркером може бути одне з десяти чисел в діапазоні від 0 до 9. Це означає, що вихідний шар мережі повинен мати 10 вузлів, по одному на кожну можливу відповідь, або маркер. Якщо відповіддю є “0”, то активізуватися повинен перший вузол, тоді як інші вузли мають бути пасивними. Якщо відповіддю є “9”, то активізуватися повинен останній вузол вихідного шару при інших пасивних вузлах. Кількість вузлів прихованого шару вибрано 200. Будемо поки вважати, що цей вибір був нами зроблений довільно. Ці дані визначають конфігурацію та розмір нейронної мережі:

/* number of input, hidden and output nodes */
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 Wih[HNODES][INODES] = {0}; 
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 вам допоможе.

Було б корисно поцікавитися розмірами пам'яті, яку займають матриці вагових коефіцієнтів:

uint64_t bytes = (HNODES * INODES) * sizeof(double); // sizeof(Wih);
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 Wih 1254400 Bytes: 1.20 MB
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]

Всі ці міркування реалізує наступний код мовою С.

double(*TD)[TRAIN_ROWS][DATA_COLS] = (void *)ptrtd;
  
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, що є коректним індексом даного маркера в масиві:

targets[(int)(*ptrd)] = 0.99;

У тілі циклу присутній виклик функції train, якій передаються два параметри: масив тренувальних даних ptrd і масив targets, точніше буде сказати, що передаються вказівники, хоча це суті справи не змінює. Функція train реалізує процес тренування нейронної мережі, використовуючи тренувальні дані, що надаються їй на кожному кроці циклу.  Усі необхідні для реалізації процесу тренування функції розташовані в окремому у файлі common.c.

3.5. Складання проекту на мові C

У програмуванні все починається з вихідного коду. Насправді вихідний код, який ще іноді називають кодовою базою, зазвичай складається з цілого ряду текстових файлів. Кожен файл містить інструкції, написані мовою програмування. У цьому підрозділі демонструється, як зібрати проект, написаний на C. Приклад, з яким ми будемо працювати, складається з кількох вихідних файлів, що характерно майже для всіх проектів цією мовою. Але перш ніж переходити до збірки, спочатку розглянемо структуру нашого проекту на C.

Будь-який проект на C містить вихідний код (або кодову базу) та інші документи, які описують розроблюваний додаток і використовувані стандарти. Код C зазвичай зберігається у файлах двох типів: у заголовкових файлах, які мають розширення .h; у вихідних файлах із розширенням .c. Для стислості заголовкові файли будуть в подальшому  називатися заголовками.

Заголовок зазвичай містить макроси та визначення типів, а також оголошення функцій, глобальних змінних та структур. У мові програмування C оголошення та визначення деяких елементів програмування, таких як функції, змінні та структури, можуть знаходитись у різних файлах. Оголошення функції показує, як її використовувати, а визначення містить її реалізацію. Оголошення функцій рекомендується розміщувати в заголовках, а їх визначення – у відповідних вихідних файлах.  Це особливо стосується функцій. І хоча це не є обов'язковою вимогою, такий підхід до проектування дозволяє зберігати визначення функцій поза заголовками.  До вихідних файлів можна підключати лише заголовки.  Заголовки не повинні містити нічого, крім оголошень. До вихідного файлу не підключаються інші вихідні коди, тому він завжди компілюється окремо. Пам'ятайте: відповідно до загальноприйнятих рекомендацій, вихідні файли C/C++ підключати не можна. (Мається на увазі за допомогою директиви #include).

Подивимося тепер на те, як усе влаштовано у нашому проекті нейронної мережі розпізнавання рукописних цифр.  Створений на цей час код, розміщено в трьох файлах: одному заголовку і двох файлах з вихідним кодом. Усі вони знаходяться в одному каталозі:

$ ls -l
. . .
-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 містить усе, що потрібно одному вихідному коду для використання функціональності іншого.

/* File 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, компілюючи набір вихідних файлів.

Компіляція набору вихідних файлів:

$ clang -Wall train.c common.c -lm -o nn


3.6. Додаткові пояснення

Тіло функції main файлу train.c містить код, який потребує додаткових пояснень.

int main(void) {
  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;
}

Навчальний набір даних  використовується для навчання кілька разів, тобто здійснюється багаторазове повторення циклів тренування з тим самим набором даних. Щодо одного тренувального циклу іноді використовують термін “епоха”. Тому сеанс тренування з п'яти епох означає п'ятиразовий прогін всього тренувального набору даних. А навіщо це робити особливо якщо для цього комп'ютеру потрібно більше часу? Причина полягає в тому, що тим самим ми намагаємося забезпечити більше маршрутів градієнтного спуску, що оптимізують вагові коефіцієнти. Це звичайна практика. Подробиці і обґрунтування ви за бажанням знайдете в інтернеті. Подивимося, що нам дадуть п'ять тренувальних епох. Ми виконали компіляцію набору вихідних файлів і отримали в результаті виконуваний файл, який можемо запустити і оцінити кількість часу, витраченого на виконання тренування:

$ ./nn
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:

void tocsv(const char *csvfile, size_t rows, size_t cols, 
       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() використовується коефіцієнт навчання - це множник, що згладжує величину змін, щоб уникнути перельотів за мінімум у методі градієнтного спуску.  Коефіцієнт навчання можна налаштовувати з урахуванням особливостей конкретного завдання.

const float LEARNING_RATE = 0.1;

До речі, ви пам'ятаєте, що код написано на С із застосуванням засобів лише стандартної бібліотеки? З огляду на це, зовсім не дивує те, що у разі сучасних швидкодіючих домашніх комп'ютерів, а використано було 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     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     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).  Не будемо забувати звільняти ресурси.

Компіляція файлу з вихідним кодом і запуск на виконання:

$ clang -Wall query.c common.c -lm -o query
$ ./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 

Підведемо підсумки. У штучному інтелекті є область, присвячена створенню програмного забезпечення для виконання завдань, які потребують інтелекту, через навчання на основі даних. Вона називається машинне навчання, у якого в свою чергу є три основні напрями: навчання з учителем, без вчителя та навчання з підкріпленням. Навчання з учителем передбачає використання промаркованих даних. У процесі навчання людина вирішує, які дані потрібно зібрати і як їх помітити. Мета цього напрямку машинного навчання – узагальнення. Класичний приклад – створена тут програма для розпізнавання цифр, написаних від руки: одна добра людина зібрала зображення з рукописними цифрами і промаркувала їх, а ми навчили мережу правильно розпізнавати та категоризувати ці цифри. При цьому очікувалось, що навчена мережа зможе узагальнювати та категоризувати зображення з такими цифрами. Судячи з отриманих результатів, створена мережа дійсно може це робити. 

Акцентную увагу на наступному. Нейронна мережа це не алгоритм, а структура, яка складається з кількох верств математичних перетворень, що застосовуються до вхідних значень, створення якої було виконано з використанням можливостей лише стандартної бібліотеки мови С, без залучення спеціальних засобів. 

Зараз, коли у вас є код, і з'явилося відчуття задоволення від того, що все вийшло, необхідно трохи остудити ваш запал. Насправді, все не так просто, як могло здатися. Зауважте, що для завдання було взято у достатній кількості рафіновані, тобто завчасно дбайливо підготовлені дані, в яких мінімум помилок. Частіше може статися так, що тренувальних даних буде недостатньо для ефективного навчання мережі. У тренувальних даних можуть бути помилки, у зв'язку з чим справедливість припущення про те, що вони істинні і на них можна вчитися, під питанням. Кількість шарів або вузлів у самій мережі може бути недостатньою для того, щоб правильно моделювати рішення задачі тощо. Це означає, що підходи, які ви будете використовувати в подальшому, повинні враховувати зазначені нюанси. Сподіваюся, що у вас вже з'явилися ідеї щодо вдосконалення коду та проведення нових експериментів з більшою кількістю шарів мережі та вузлів.

Послевкусие Си: распространение сигнала в нейронной сети

 

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

Предыдущий пост "Послевкусие Си: решетка Кардано".

Глава 2. На початку була матриця

2.1. Множення матриць

Матрицею розміру n×m називається прямокутна таблиця спеціального вигляду, що складається з n рядків та m стовпців, заповнених числами. Елементи матриці A позначають aij, де i – номер рядка, в якому знаходяться елементи матриці, j – номер стовпця. Кількість рядків та стовпців задають розміри матриці. Зазвичай матрицю програмісти уявляють у вигляді двовимірного масиву.

Як знайти добуток матриць? Для обрахунку добутку матриць, зробимо детальний розв'язок прикладу, який дозволить зрозуміти алгоритм розв'язання таких задач.

Результатом множення матриці Am×n на матрицю Bn×k буде матриця Cm×k така, що елемент матриці C, який знаходиться в i-тому рядку та j-тому стовпчику (cij), дорівнює сумі добутків елементів i-того рядку матриці A на відповідні елементи j-того стовпчика матриці B. Дві матриці можна перемножити якщо кількість стовпчиків першої матриці дорівнює кількості рядків другої матриці.

Трохи спростим приклад і у якості матриці B візьмемо матрицю, яка містить тільки один стовпчик. Таку матрицю називають матрицею-стовпцем, або вектором. Наприклад:


Компоненти матриці C обраховуються наступним чином:

Програмна реалізація алгоритму обрахунку добутку матриці і вектора:

/* File mxv.c */
#include <stdio.h>

const int ROWS = 4;
const int COLS = 3;

void mvp(int rows, int cols, double *matrix, double *vector, double *resvec) {
  /* Matrix-vector product */
  for (int i = 0; i < cols; i++) {
    for (int j = 0; j < rows; j++) {
      resvec[j] += *((matrix + j * cols) + i) * vector[i];
    }
  }
}

void printv(double *vec, int len) {
  for (int i = 0; i < len; i++) {
    printf("%g\t", vec[i]);
  }
  puts("");
}

int main() {
  double matrix[ROWS][COLS] = {
      {0.9, 0.3, 0.4},
      {0.2, 0.8, 0.2},
      {0.1, 0.5, 0.6},
      {0.9, 0.7, 0.9},
  };
  double vector[COLS] = {2, 1, 2};
  double result_vector[ROWS] = {0};
  mvp(ROWS, COLS, (double *)matrix, vector, result_vector);
  printv(result_vector, ROWS);
  return 0;
}

У разі, якщо оновлення результуючого вектора за допомогою арифметики вказівників у тілі функції mvp вам не до смаку, то можна реалізувати цю функцію інакше:


void mvp(int rows, int cols, double matrix[rows][cols], double vector[cols],
          double result_vector[rows]) {
  for (int i = 0; i < cols; i++) {
    for (int j = 0; j < rows; j++) {
      result_vector[j] += matrix[j][i] * vector[i];
    }
  }
}

і викликати її так

mvp(ROWS, COLS, matrix, vector, result_vector);

Функція printv роздруковує вміст вектора, довжина якого передається через її другий параметр.

Компіляція файлу з вихідним кодом і запуск на виконання дадуть нам очікуваний результат:


$ clang -Wall mxv.c
$ ./a.out          
2.9     1.6     1.9     4.3

Вдоскональте цей код: спробуйте самостійно реалізувати перевірку можливості обрахунку добутку матриць – кількість стовпчиків першої матриці має дорівнювати кількості рядків другої матриці.

Примітка: для компіляції коду прикладів перших трьох глав цієї книги використовувався компілятор:

$ clang --version
Apple clang version 15.0.0 (clang-1500.3.9.4)
Target: x86_64-apple-darwin23.5.0 

Є один нюанс, на який слід звернути увагу.  Якщо замість компілятора clang ви використаєте gcc, то йому (в залежності від його версії) дещо в цьому коді може не сподобатися. Ви можете отримати від компілятора gcc, наприклад, версії 7.4.0 приблизно таке повідомлення:


variable-sized object may not be initialized
   double matrix[ROWS][COLS] = {
   ^~~~~~

Спробуйте самостійно скомпілювати цей файл за допомогою компілятора gcc і проаналізуйте всі отримані від нього повідомлення.

Щоб впоратися з цією особливістю компіляторів, слід замінити в коді константи :

const int ROWS = 3;
const int COLS = 4;

на макроси: 

#define ROWS 3
#define COLS 4

і цей код стане “їстівним” для обох компіляторів.

2.2. Поширення сигналів в нейронній мережі

Використовуючи розпізнавання голосу у смартфоні або у Google Translate, ви неодмінно маєте справу з нейронною мережею, натренованою глибоким навчанням. За останні кілька років глибоке навчання забезпечило компанії Google прибуток, достатній для того, щоб покрити витрати на всі футуристичні проекти Google X, включаючи безпілотні автомобілі, окуляри Google Glass та науково-дослідний проект Google Brain. Google однією з перших почала застосовувати глибоке навчання. Ще у 2013 році Google найняла Джеффрі Хінтона, батька-засновника глибокого навчання, і зараз інші компанії намагаються її наздогнати. Ну і нам теж не до смаку пасти задніх, і якщо ви опанували обрахунок добутку матриць і розібралися з програмним кодом, що втілює цей алгоритм, то нам час перейти до вирішення нового завдання. Спробуємо використати ці знання для отримання відгуку нейронної мережі на заданий вхідний сигнал. 

Коротко поясню ідею. Нейронна мережа – це математична функція, яка приймає вхідні та виробляє вихідні дані. Нейронна мережа складається з шарів, кожен із яких можна розглядати як ряд «нейронів». Кожен нейрон у нейронній мережі приймає вхідний сигнал від декількох нейронів, що знаходяться перед ним, і, у свою чергу, також передає сигнал багатьом іншим нейронам у разі збудження. Одним із способів відтворення такої поведінки нейронів, що спостерігається в живій природі, у штучній моделі є створення багатошарових нейронних структур, в яких кожен нейрон з'єднаний з кожним з нейронів у попередньому та наступному шарах. Ця ідея пояснюється наступною ілюстрацією.

Рисунок 2.1 – Приклад нейронної мережі

Як ми можемо змоделювати штучний нейрон? Перші штучні нейронні мережі були названі перцептронами. Кожен нейрон має кілька входів (i). Нейрон, підсумовуючи відповідні вхідні значення, обчислює результуючу суму, яка стане аргументом функції сигмоїди (σ), що управляє вихідним значенням нейрона. Така схема відбиває принцип роботи нейронної мережі. Нижче наведена діаграма (Рис. 2.2) ілюструє ідею комбінування вхідних значень і обробку результуючої суми функцією сигмоїди.  Якщо комбінований сигнал недостатньо сильний, сигмоїда пригнічує вихідний сигнал, але  якщо сума вхідних сигналів досить велика, то функція σ збуджує нейрон.

Рисунок 2.2 – Схема нейрона

Функція, яка отримує вхідний сигнал, та генерує вихідний сигнал з урахуванням порогового значення, називається функцією активації. З математичної точки зору існує безліч таких функцій активації, які могли б забезпечити такий ефект. В якості приклада функції активації було згадано S-подібну функцію, яку називають сигмоїдою σ, або сигмоїдальною функцією: 

Рисунок 2.3 – Сигмоїдальна функція

Буквою e математиці прийнято позначати константу, рівну 2.71828. Це так зване трансцендентне число. Якщо ви раптом не знаєте, що це таке, можете знайти цей термін в інтернеті; його пояснення виходить за межі теми цієї книги.

Дослідниками в галузі штучного інтелекту використовуються також інші функції активації аналогічного виду, але сигмоїда проста і дуже популярна, тому вона буде в нашому випадку підходящим вибором. У перцептроні вхідні дані (i) множаться на вагові коефіцієнти (w) і підсумовуються. Крім того, існує сутність під назвою зсув (bias, b) або параметр активації, який також додається до суми (Σ) вхідних даних, помножених на ваги. Параметр активації дає додаткову гнучкість навчальним можливостям персептрона, хоча перцептрон може працювати і без нього. Сума Σ проходить через сигмоїдальну функцію σ і перетворює Σ у вихідний сигнал нейрона.

Ми далі не заглиблюватимемося в теорію побудови нейронних мереж. На цю тему написано багато хороших книг, назву однієї з них ви знайдете в списку літератури  [1].

Повернемося до наведеного вище зображення нейронної мережі  (Рис. 2.1). На цій ілюстрації представлені три шари, перший і останній з яких включають по три штучні нейрони, або вузли, а середній шар – чотири вузли. Легко помітити, тут кожен вузол з'єднаний з кожним із вузлів попереднього та наступного шарів. З кожним з'єднанням асоціюється певна вага w. Низький ваговий коефіцієнт послаблює сигнал, високий – посилює його. Перший шар вузлів – вхідний. Його єдине призначення – представляти вхідні сигнали. У вхідних вузлах функція активації до вхідних сигналів не застосовується. Зараз немає необхідності висувати щодо цього якісь розумні доводи. Просто сприймаємо як даність: перший шар нейронних мереж є лише вхідним шаром, що представляє вхідні сигнали.

Розрахувати поширення вхідних сигналів по всіх шарах, поки вони не трансформуються у вихідні сигнали, насправді доволі просто, за умови, що ви розумієте, як працює множення матриць.  Математики розробили надзвичайно компактний спосіб запису операцій, які доводиться виконувати для обчислення вихідного сигналу нейронної мережі, навіть якщо вона містить багато шарів та вузлів. Ця компактність сприятлива не тільки для людей, які виписують або читають відповідні формули, але й для комп'ютерів, оскільки програмні інструкції виходять коротшими і виконуються набагато швидше. У цьому компактному підході передбачається використання матриць, про які йшлося раніше.

Для початку нам знадобиться кілька матриць. Перша (вектор) містить сигнали вхідного шару. Друга містить вагові коефіцієнти для зв'язків між вузлами двох шарів – вхідного та другого (так званого прихованого шару). Результатом множення цих двох матриць є об’єднаний згладжений сигнал, що надходить на вузли другого (вихідного) шару.
Обчислення вхідних сигналів, що надходять на кожен із вузлів другого шару, може бути виконано з використанням матричного множення. Немає потреби в тому, щоб якось особливо дбати про те, скільки вузлів входить у кожен шар. Збільшення кількості шарів призводить до збільшення розміру матриць. Щоб отримати вихідний сигнал другого шару, потрібно застосувати сигмоїду до кожного окремого елемента вектора, отриманого в результаті множення матриці вагових коефіцієнтів на вектор вхідних сигналів. Такий підхід застосовується для обчислення сигналів, що проходять від одного шару до наступного шару. Наприклад, за наявності трьох шарів ми просто знову виконаємо операцію множення матриць, використовуючи вихідні сигнали другого шару як вхідні для третього, але, зрозуміло, попередньо скомбінувавши їх і згладивши за допомогою додаткових вагових коефіцієнтів.

Задумайтесь на хвилину про значущість зв'язків між нейронами.  У вашому мозку сотня мільярдів нейронів, але справжнє джерело вашої індивідуальності – зв'язок між цими нейронами. Кожен нейрон створює щонайменше тисячу зв'язків із іншими нейронами. Тобто у вашому мозку близько сотні трильйонів зв'язків. Наскільки велике це число? У Чумацькому Шляху приблизно 400 мільярдів зірок. У трильйоні – 1000 мільярдів. Виходить, що у вашому мозку більше нейронних зв'язків, ніж зірок у 5 тисячах Чумацьких Шляхів. Ось реальний масштаб індивідуальності – розмір вашої сутності. У світі не було, немає і мабуть ніколи не буде людини з ідентичною сотнею трильйонів зв'язків. Те, що ви запам'ятали, забули, що змушує вас сміятися, нервувати, радіти, що лякає, заспокоює чи виснажує – все це становить унікальну структуру, яка властива лише вам. Світ, який ви бачите протягом життя, видно тільки вам. Ваші реакції на цей світ – тільки ваші. Те, що ви любите – дії, морозиво в літню спеку, сміх коханої людини, рядок комп'ютерного коду, ідеальна відповідність візерунків двох потертих плиток на підлозі вашої кухні – все це доступно тільки вам. Ви все це пам'ятаєте. Усередині вас цілі галактики: вони світитимуть яскраво лише до тих пір, поки ви живі. Варто їм згаснути після вашої смерті, і ніщо і ніхто ніколи не сяятиме так само, як вони…

З теорією і філософією закінчено. Тепер час подивитися, як можна втілити теорію в коді, звернувшись до конкретного прикладу, який представлятиме нейронну мережу з трьома шарами по три вузли у вхідному і вихідному шарі та чотири вузли у прихованому шарі. Така мережа була представлена раніше.

2.3. Використання матричного множення у мережі з трьома шарами

Відомо, перший шар – вхідний, проміжний шар називається прихованим шаром, а третій – вихідний.  Тепер можна розпочати створення власної нейронної мережі за допомогою мови С.
Матриця ваг Wih (input-hidden) містить вагові коефіцієнти для зв'язків між вхідним та прихованим шарами. Коефіцієнти для зв'язків між прихованим та вихідним шарами будуть зберігатися в іншій матриці, яку позначено Who (hidden-output) прихований-вихідний.
Нижче представлені вхідний вектор і всі вагові коефіцієнти (кожен з них вибирався як випадкове число). Ніякі особливі міркування поки що за їх вибором не стояли.

const int INODES = 3;
const int HNODES = 4;
const int ONODES = 3;
double Wih[HNODES][INODES] = {
    {0.9, 0.3, 0.4},
    {0.2, 0.8, 0.2},
    {0.1, 0.5, 0.6},
    {0.9, 0.7, 0.9},
};
double Who[ONODES][HNODES] = {
    {0.3, 0.7, 0.5, 0.6},
    {0.6, 0.5, 0.2, 0.3},
    {0.8, 0.1, 0.9, 0.4},
};
double inputs[INODES] = {0.9, 0.1, 0.8};

Ваговий коефіцієнт для зв'язку між першим вхідним вузлом та першим вузлом проміжного прихованого шару w11 = 0.9. Так само ваговий коефіцієнт для зв'язку між другим вхідним вузлом і другим вузлом прихованого шару w22 = 0.8. Між першим і другим w12 = 0.2.

/* File simple_query.c */
#include <math.h>
#include <stdio.h>

/* clang -Wall simple_query.c -lm */

const double EULER_NUMBER = 2.71828;
const int INODES = 3;
const int HNODES = 4;
const int ONODES = 3;
double Wih[HNODES][INODES] = {
    {0.9, 0.3, 0.4},
    {0.2, 0.8, 0.2},
    {0.1, 0.5, 0.6},
    {0.9, 0.7, 0.9},
};

double Who[ONODES][HNODES] = {
    {0.3, 0.7, 0.5, 0.6},
    {0.6, 0.5, 0.2, 0.3},
    {0.8, 0.1, 0.9, 0.4},
};

void mvp(int rows, int cols, double *matrix, double *vector, double *resvec) {
  /* Matrix-vector product */
  for (int i = 0; i < cols; i++) {
    for (int j = 0; j < rows; j++) {
      resvec[j] += *((matrix + j * cols) + i) * vector[i];
    }
  }
}

void printv(double *vec, int length) {
  for (int i = 0; i < length; i++) {
    printf("%.3f\t", vec[i]);
  }
  putchar('\n');
}

double sigmoid(double n) { return (1 / (1 + pow(EULER_NUMBER, -n))); }
void map(double (*func)(double), int length, double *vector, double *result) {
  for (int i = 0; i < length; i++) {
    result[i] = (*func)(vector[i]);
  }
}

/* query the neural network */
void query(double *inputs) {
  /* calculate signals into hidden layer */
  double hidden_inputs[HNODES] = {0};
  mvp(HNODES, INODES, (double *)Wih, inputs, hidden_inputs);
  /* calculate the signals emerging from hidden layer */
  double hidden_outputs[HNODES] = {0};
  map(sigmoid, HNODES, hidden_inputs, hidden_outputs);
  /* calculate signals into final output layer */
  double final_inputs[ONODES] = {0};
  mvp(ONODES, HNODES, (double *)Who, hidden_outputs, final_inputs);
  /* calculate the signals emerging from final output layer */
  double final_outputs[ONODES] = {0};
  map(sigmoid, ONODES, final_inputs, final_outputs);
  printf("final_outputs:\n");
  printv(final_outputs, ONODES);
}

int main() {
  double inputs[INODES] = {0.9, 0.1, 0.8};
  query(inputs);
  return 0;
}

Компіляція файлу з вихідним кодом і запуск на виконання:

$ clang -Wall simple_query.c -lm
$ ./a.out                        
final_outputs:
0.814   0.757   0.830

Для того, щоб сформувати відгук шару на вхідний сигнал, необхідно застосувати до суми (Σ) вхідних даних, помножених на вагові коефіцієнти, функцію активації σ, яка перетворює Σ у вихідний сигнал нейрона. Саме це завдання виконує функція map(), якій в якості першого параметра передається вказівник на функцію sigmoid().
Функція query() приймає вхідні дані нейронної мережі через параметр, виконує необхідні розрахунки і друкує результат.
Давайте тепер підсумуємо все те, що на цей час вже зроблено: розраховано проходження сигналу через вихідний та прихований шари, тобто визначено значення сигналів на виходах цих шарів. Для повної ясності уточнимо, що ці значення були отримані шляхом застосування функції активації до комбінованих вхідних сигналів відповідного шару.
Варто запам'ятати таке: незалежно від кількості шарів у нейронній мережі, обчислювальна процедура для кожного з них однакова – комбінування вхідних сигналів, згладжування сигналів для кожного зв'язку між вузлами за допомогою вагових коефіцієнтів та отримання вихідного сигналу за допомогою функції активації. Для нас несуттєво те, скільки шарів утворюють нейронну мережу – 3 або, наприклад,  203, адже до кожного з них застосовується один і той самий підхід.

Повністю зв’язані нейронні мережі (fully connected feedforward artificial neural network), також відомі як багатошарові перцептрони (multilayer perceptron MLP), складаються що щонайменше з трьох шарів нейронів (вхідний, вихідний і принаймні один прихований шар), які перетворюють вхідні дані у вихідні через послідовність зважених сум та нелінійних функцій активації. У повністю зв’язаних шарах кожен нейрон має зв'язок з усіма нейронами попереднього шару, що дозволяє ефективно навчати моделі для різних завдань, включаючи класифікацію зображень. 

Послевкусие Си: решетка Кардано

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

 (Из моих не отредактированных премудростей)


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

У издательства Manning есть такой маркетинговый ход, который называется MEAP. Это чем-то напоминает укладку рельсов перед летящим вперед паровозом: вам предлагают читать новую книгу глава за главой, пока сама книга находится в процессе написания, а потом получить ее экземпляр еще до того, как она появится в магазинах. Мне такой издательский ход показался интересным, и я решил выложить здесь отдельные главы своей книги. На самом деле моя книга уже написана, но она пока еще свет не увидела и барахтается где-то в бюрократическом омуте всяких-разных методических советов и прочих университетских инстанций, дабы милостиво быть допущенной до изучения ее студентами. О чем книга? Несложно догадаться. Она о программировании. Вот тут вам вполне уместно было бы произнести уже ставшую крылатой фразу: «Шо, опять?». Ну да, опять о программировании. 

Как нынче студенты обучаются языку программирования? О синтаксисе языка им рассказывают и короткие примеры показывают, упражнения на дом задают и суету с лабораторными работами устраивают. А живое программирование где? То самое, твое, авторское, которому ты учишь, показывающее то, как ты сам решаешь задачу и создаешь код, о котором хотелось бы рассказать, а главное еще и показать студентам, изучающим основы языка Си, дать возможность им "допилить", сделанную тобой заготовку, подхватывая твой собственный стиль. Вот оно-то никак не помещается в отведенные учебные часы. (Здесь цитата, приписываемая Деду Панасу, опущена из эстетических соображений.) Сие несоответствие повлекло за собой написание книги и попытку предложить ее неискушенным неофитам для самостоятельного изучения. 

Это была прелюдия. Теперь - непосредственно сама "людия" и будет она звучать на украинском языке.

Глава 1. Решітка Кардано

1.1. Постановка задачі

Перш ніж поринути у таємничий світ бібліотек та інструментів мови С, давайте спробуємо уявити собі реальність, у якій ще майже нічого не створено: світ тільки народжується, а навколо лише безмежна і холодна пустеля, якою у ранкових багряних сутінках інколи промайне поодиноке програмне створіння на мові асемблера… 

Все це більше схоже на сюжет для науково-фантастичного роману, тому будемо вважати, що в цій реальності ми непогано озброєні, бо в нашому розпорядженні є компілятор і стандартна бібліотека мови С, а також непереборне бажання впоратися із завданням, суть якого полягає в наступному. Необхідно розробити програму, яка реалізує алгоритм шифрування "Решітка Кардано". 

Для ведення зашифрованого листування призначена спеціальна решітка – маска із заданими елементами, які використовуються для введення символів повідомлення, що шифрується або розшифровується. За допомогою решітки розміром 8х8 можна зашифрувати повідомлення максимальної довжини 64 символи. Алгоритм передбачає наявність однакових решіток у розпорядженні як відправника повідомлення, так і його одержувача. Детальний опис ідеї використання цього методу шифрування наведено у книзі Яківа Перельмана “Жива математика”,  у розділі 6 “Математичні оповідання та головоломки”. Історія створення цього методу шифрування та загальна ідея описані в Cardan Grille. Друга частина цього завдання – зворотна задача, тобто відновлення втраченої решітки, спираючись на наявність зашифрованого та розшифрованого повідомлень.

1.2. Генерація решітки Кардано


Перше, що слід зробити, – створити функцію генерації решітки. Очевидно, що може існувати безліч варіантів решіток розміром 8х8. Кількість можливих варіантів перевищує 4 мільярди.

Для створення решітки необхідна базова матриця, яка має вигляд:
 1  2  3  4 13  9  5  1
 5  6  7  8 14 10  6  2
 9 10 11 12 15 11  7  3
13 14 15 16 16 12  8  4
 4  8 12 16 16 15 14 13
 3  7 11 15 12 11 10  9
 2  6 10 14  8  7  6  5
 1  5  9 13  4  3  2  1

У коді програми можна описати таку матрицю наступним чином:

const int matrix[][8] = {
{ 1,  2,  3,  4, 13,  9,  5,  1},
{ 5,  6,  7,  8, 14, 10,  6,  2},
{ 9, 10, 11, 12, 15, 11,  7,  3},
{13, 14, 15, 16, 16, 12,  8,  4},
{ 4,  8, 12, 16, 16, 15, 14, 13},
{ 3,  7, 11, 15, 12, 11, 10,  9},
{ 2,  6, 10, 14,  8,  7,  6,  5},
{ 1,  5,  9, 13,  4,  3,  2,  1}
};

Якщо уважно придивитися до структури базової матриці, можна помітити певну закономірність – матриця складається з 4 окремих матриць, кожна з яких повернена за годинниковою стрілкою на фіксоване число градусів, починаючи з 90 градусів. Помічену особливість можна проігнорувати та ініціалізувати матрицю так, як було показано вище, але цього разу обійдемося без спрощувань і все-таки скористаємося знайденою особливістю. Функція для заповнення базової матриці:

const int SIDE = 8;

void init_quarters(int (*arr)[SIDE]) {
    const int dim = SIDE/2;
    int x = 1;
    for (int i = 0; i < dim; i++) {
        for (int j = 0; j < dim; j++) {
            arr[i][j] = x;                 // 1 quarter
            arr[j][SIDE-1-i] = x;          // 2 quarter
            arr[SIDE-1-j][i] = x;          // 3 quarter
            arr[SIDE-1-i][SIDE-1-j] = x;   // 4 quarter
            x++;
        }
    }

Виведення на екран вмісту матриці буде корисним для візуальної перевірки правильності роботи функції заповнення базової матриці:

void print_matrix(const int (*arr)[SIDE]) {
    for (int i = 0; i < SIDE; i++) {
        for (int j = 0; j < SIDE; j++) {
             printf("%2d ", arr[i][j]);
        } 
        putchar('\n');
    }
}

Щоб упевнитися у правильності роботи функції заповнення базової матриці, додаємо в тіло функції main наступний код:

int matrix[SIDE][SIDE] = {0};
init_quarters(matrix);
print_matrix(matrix);

В результаті виконання цього коду на екрані з'явиться вже знайома матриця:

 1  2  3  4 13  9  5  1
 5  6  7  8 14 10  6  2
 9 10 11 12 15 11  7  3
13 14 15 16 16 12  8  4
 4  8 12 16 16 15 14 13
 3  7 11 15 12 11 10  9
 2  6 10 14  8  7  6  5
 1  5  9 13  4  3  2  1

Базову матрицю отримано і тепер можна створити функцію генерації решітки.
Як було зазначено вище, існує понад 4 мільярди можливих варіантів таких решіток. Для створення решітки доречно використовувати генератор випадкових чисел.
Функція генерації решітки може бути реалізована наступним чином:

const int QUARTER = SIDE * 2;

void gen_rand_cells(const int (*arr)[SIDE], int (*cells)[2], 
                    const int length, const int lower, const int upper) {
const int *ptr = &arr[0][0];
int uniq[QUARTER] = {0};
int count = 0;
time_t t;
srand((unsigned) time(&t)); 
while (count < length) { 
int rand_idx = (rand() % (upper - lower + 1)) + lower;
bool found = false;
for (int i = 0; i < length; i++) { 
     if (uniq[i] == *(ptr + rand_idx)) { 
   found = true; 
   break;
     } 
if (!found) { 
    uniq[count] = *(ptr + rand_idx); 
    div_t output = div(rand_idx, SIDE);
        cells[count][0] = output.quot;
    cells[count][1] = output.rem;
    count++; 
}

Зверніть увагу, що крім


#include <stdio.h>

потрібно також додати заголовки:

#include <time.h> 
#include <stdlib.h> 
#include <stdbool.h>

Додаткова функція, яка виводить на екран результати роботи генератора решіток, буде корисною для візуалізації роботи процесу генерації решітки:

void print_cells(const int (*arr)[SIDE], const int (*cell)[2]) {
    for (int i = 0; i < QUARTER; i++) {
        int row = cell[i][0];
        int col = cell[i][1];
        printf("matrix[%d][%d] = %2d\n", row, col, arr[row][col]);
   }
}

Додаємо в тіло функції main виклик функції генерації решіток:

int matrix[SIDE][SIDE] = {0};
int cells[QUARTER][2] = {0};
init_quarters(matrix);
print_matrix(matrix);

gen_rand_cells(matrix, cells, QUARTER, 0, SIDE*SIDE-1);
print_cells(matrix, cells);

Після виконання цього коду на екрані отримаємо один з можливих варіантів решітки:

matrix[2][5] = 11
matrix[4][0] =  4
matrix[3][5] = 12
matrix[6][1] =  6
matrix[4][6] = 14
matrix[6][0] =  2
matrix[0][5] =  9
matrix[3][0] = 13
matrix[5][0] =  3
matrix[4][4] = 16
matrix[5][6] = 10
matrix[7][7] =  1
matrix[6][5] =  7
matrix[5][3] = 15
matrix[6][7] =  5
matrix[4][1] =  8

Результат роботи функції print_matrix(matrix) тут не показано. Такий варіант візуалізації решітки може здатися незручним, тому створимо функцію, яка покаже решітку у вигляді матриці, в якій віконця представлені символом '1':

void print_grid(const int (*cell)[2]) {
    int grid[SIDE][SIDE] = {0};
    for (int i = 0; i < QUARTER; i++) {
    int row = cell[i][0];
        int col = cell[i][1];
        grid[row][col] = 1;
    }
    print_matrix(grid);
}

Результат виконання цієї функції:


 0  0  0  0  0  1  0  0
 0  0  0  0  0  0  0  0
 0  0  0  0  0  1  0  0
 1  0  0  0  0  1  0  0
 1  1  0  0  1  0  1  0
 1  0  0  1  0  0  1  0
 1  1  0  0  0  1  0  1
 0  0  0  0  0  0  0  1

Слід взяти до уваги те, що в коді використовується генератор випадкових чисел, тому при кожному запуску програми будуть створюватися нові решітки. 

Така особливість створює певну проблему для подальшої розробки:  потрібен один зафіксований результат виконання функції генерації решіток. Це можна зробити за допомогою спеціальної функції, яка створить решітку у вигляді вихідного коду мовою С. Згенерований код решітки можна включити у вихідний код і тимчасово використовувати для подальшої розробки:

void int_cells(const int (*arr)[2]) {
    printf("const int cells[][2] = {\n");
    for (int i = 0; i < QUARTER; i++) {
        printf("\t{%2d,%2d}", arr[i][0], arr[i][1]);
        if (i < QUARTER-1) {
        putchar(',');
        } 
        putchar('\n');
    } 
    printf("};\n");
}

Результат виконання цієї функції виглядає так:

const int cells[][2] = {
{ 2, 5},
{ 4, 0},
{ 3, 5},
{ 6, 1},
{ 4, 6},
{ 6, 0},
{ 0, 5},
{ 3, 0},
{ 5, 0},
{ 4, 4},
{ 5, 6},
{ 7, 7},
{ 6, 5},
{ 5, 3},
{ 6, 7},
{ 4, 1}
};

Перша частина завдання на цьому закінчена. 

1.3. Шифрування та дешифрування за допомогою решітки Кардано


Тепер необхідно створити функції шифрування та дешифрування повідомлення за допомогою решітки.  Для цього скористаємося наступним варіантом решітки:

const int cells[][2] = {
{ 5, 6},
{ 0, 5},
{ 2, 3},
{ 0, 6},
{ 4, 3},
{ 3, 2},
{ 7, 0},
{ 1, 7},
{ 3, 6},
{ 2, 2},
{ 2, 6},
{ 4, 6},
{ 7, 3},
{ 0, 3},
{ 7, 5},
{ 6, 1}
};

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

const char *message = 
       "Hey dude, do not let me down. Take a bad code and make it better.";

Згідно з описом алгоритму, тут знадобиться функція повороту решітки (матриці) за годинниковою стрілкою на 90 градусів.

Функція повороту матриці може бути реалізована так:

void rotate_clockwise(int (*arr)[SIDE]) {
   for (int i = 0; i < SIDE; i++){
      for (int j = 0; j < SIDE-i; j++) {
         int x = arr[i][j];
         arr[i][j] = arr[SIDE-1-j][SIDE-1-i];
         arr[SIDE-1-j][SIDE-1-i] = x;
      }
   }
   for (int i = 0; i < SIDE/2; i++) {
      for (int j = 0; j < SIDE; j++) {
         int x = arr[i][j];
         arr[i][j] = arr[SIDE-1-i][j];
         arr[SIDE-1-i][j] = x;
      }
   }
}

Крім того, потрібна функція запису символів повідомлення в вікна решітки. Реалізація такої функції має передбачати ситуацію, коли довжина повідомлення буде меншою за 64 символи. Іншими словами, якщо довжина повідомлення коротше 64 символів, то в цьому випадку залишаються вільні вікна. Якщо ці вікна залишити порожніми, це значно спростить процес злому шифру. Тому необхідно передбачити можливість заповнення вікон, що залишаються порожніми, випадковими символами.

Це ще не все. У повідомленні можуть бути пробіли між словами, розділові знаки і великі літери. Якщо залишити їх у зашифрованому повідомленні, це теж знизить стійкість зашифрованого повідомлення до злому. Отже, необхідно виключити із зашифрованого повідомлення такі символи. Великі літери слід замінити малими.


Функція заповнення решітки може бути реалізована так:

void fill_side(const char *message, int *count, int (*grid)[SIDE], 
               char (*encrypted)[SIDE]) {
    srand(time(0));
    const char *ptr = &message[0];
    const size_t len = strlen(ptr);
    printf("[%2zu] \"%s\"\n", strlen(ptr + *count), ptr + *count);
    for (int i = 0; i < SIDE; i++) {
  for (int j = 0; j < SIDE; j++) { 
      if (grid[i][j] == 1) {
   while ( (ispunct(message[*count]) 
                      || isspace(message[*count])) && *count < len) {
(*count)++;
   }
   if (*count < len) {
      encrypted[i][j] = tolower(message[*count]);
   } else {
char lower = 'a', upper = 'z';
encrypted[i][j] = (rand() % (upper - lower + 1)) + lower;
   }
   (*count)++;
  }
    }
}

Для контролю результатів роботи цієї функції скористаємось додатковою функцією виведення:

void print_symb(const char (*arr)[SIDE]) {
    for (int i = 0; i < SIDE; i++) {
      for (int j = 0; j < SIDE; j++) {
          printf("%2c ", arr[i][j] ? arr[i][j] : '.');
     
      putchar('\n');
    }
}

Функція заповнення решітки має бути викликана 4 рази за кількістю сторін решітки всередині функції шифрування. Заповнюємо та повертаємо решітку 4 рази:

void encryptor(const char *message, char (*encrypted)[SIDE]) {
int grid[SIDE][SIDE] = {0};
init_grid(cells, grid);
//print_matrix(grid);
int count = 0; // counter of symbols in message 
for (int side = 0; side < 4; side++) {
    fill_side(message, &count, grid, encrypted);
    rotate_clockwise(grid);
    //print_symb(encrypted);
}
}

Для перевірки роботи функції шифрування додамо до тіла функції main наступні рядки:

char encrypted[SIDE][SIDE] = {0};
const char *message = 
          "Hey dude, do not let me down. Take a bad code and make it better.";
encryptor(message, encrypted);

В результаті виконання отримаємо на екрані:

[65] "Hey dude, do not let me down. Take a bad code and make it better."
 .  .  .  h  .  e  y  .
 .  .  .  .  .  .  .  d
 .  .  u  d  .  .  e  .
 .  .  d  .  .  .  o  .
 .  .  .  n  .  .  o  .
 .  .  .  .  .  .  t  .
 .  l  .  .  .  .  .  .
 e  .  .  t  .  m  .  .
[43] "e down. Take a bad code and make it better."
 e  .  .  h  .  e  y  .
 .  d  .  .  .  .  .  d
 .  .  u  d  o  w  e  .
 n  .  d  t  .  a  o  k
 .  .  .  n  .  .  o  .
 e  .  .  .  .  .  t  a
 .  l  b  a  d  c  .  o
 e  .  .  t  .  m  d  .
[21] "e and make it better."
 e  .  e  h  a  e  y  n
 .  d  .  .  .  .  d  d
 .  m  u  d  o  w  e  .
 n  a  d  t  k  a  o  k
 .  e  .  n  .  i  o  .
 e  t  .  .  b  e  t  a
 t  l  b  a  d  c  .  o
 e  t  e  t  r  m  d  .
[ 1] "."
 e  r  e  h  a  e  y  n
 m  d  h  s  p  u  d  d
 l  m  u  d  o  w  e  n
 n  a  d  t  k  a  o  k
 y  e  c  n  c  i  o  v
 e  t  a  p  b  e  t  a
 t  l  b  a  d  c  r  o
 e  t  e  t  r  m  d  j

Останній етап – дешифрування повідомлення. Дешифрування значно простіше, ніж процес шифрування і здійснюється за допомогою функції:

void decryptor(char *readme, const char (*encrypted)[SIDE]) {
int grid[SIDE][SIDE] = {0};
int count = 0;
init_grid(cells, grid);
//print_matrix(grid);
for (int side = 1; side <= 4; side++) {
printf("Side #%d : ", side);
for (int i = 0; i < SIDE; i++) {
for (int j = 0; j < SIDE; j++) { 
if (grid[i][j] == 1) {
putchar(encrypted[i][j]);
readme[count] = encrypted[i][j];
count++;
}
}
}
putchar('\n');
rotate_clockwise(grid);
}
}

При дешифруванні використовується та сама решітка, що і при шифруванні!

Зберемо все разом. Додамо до тіла функції main рядки:

char readme[SIDE*SIDE+1] = {0};
decryptor(readme, encrypted);
printf("readme=%s\n", readme);

Тут readme – символьний масив для збереження розшифрованого повідомлення.
Після виконання функції дешифрування отримаємо:

Side #1 : heydudedonotletm
Side #2 : edowntakeabadcod
Side #3 : eandmakeitbetter
Side #4 : ovsgpgtjmfcarytz
readme=heydudedonotletmedowntakeabadcodeandmakeitbetterovsgpgtjmfcarytz

Зверніть увагу на те, що з міркувань збільшення криптостійкості, описаних вище, вихідне повідомлення доповнено в кінці випадковими символами “ovsgpgtjmfcarytz”. Через те, що розділові знаки та пробіли прибрані, а також великі літери замінені малими, вихідний вигляд повідомлення змінився, але саме повідомлення цілком читаємо.

Візьмемо в якості теста повідомлення, довжина якого з урахуванням видалення розділових знаків і пропусків перевищує допустимі 64 символи:

const char *message = 
   "Hey dude, don't let me down. Take a bad soft and make it better. "
   "Remember to let it under your skin. Then you'll begin to make it better.";

Результат виконання програми:

[137] "Hey dude, don't let me down. Take a bad soft and make it better. Remember to let it under your skin. Then you'll begin to make it better."
 .  .  .  h  .  e  y  .
 .  .  .  .  .  .  .  d
 .  .  u  d  .  .  e  .
 .  .  d  .  .  .  o  .
 .  .  .  n  .  .  t  .
 .  .  .  .  .  .  l  .
 .  e  .  .  .  .  .  .
 t  .  .  m  .  e  .  .
[115] " down. Take a bad soft and make it better. Remember to let it under your skin. Then you'll begin to make it better."
 d  .  .  h  .  e  y  .
 .  o  .  .  .  .  .  d
 .  .  u  d  w  n  e  .
 t  .  d  a  .  k  o  e
 .  .  .  n  .  .  t  .
 a  .  .  .  .  .  l  b
 .  e  a  d  s  o  .  f
 t  .  .  m  .  e  t  .
[93] " and make it better. Remember to let it under your skin. Then you'll begin to make it better."
 d  .  a  h  n  e  y  d
 .  o  .  .  .  .  m  d
 .  a  u  d  w  n  e  .
 t  k  d  a  e  k  o  e
 .  i  .  n  .  t  t  .
 a  b  .  .  e  t  l  b
 t  e  a  d  s  o  .  f
 t  e  r  m  r  e  t  .
[71] "emember to let it under your skin. Then you'll begin to make it better."
 d  e  a  h  n  e  y  d
 m  o  e  m  b  e  m  d
 r  a  u  d  w  n  e  t
 t  k  d  a  e  k  o  e
 o  i  l  n  e  t  t  t
 a  b  i  t  e  t  l  b
 t  e  a  d  s  o  u  f
 t  e  r  m  r  e  t  n

Side #1 : heydudedontletme
Side #2 : downtakeabadsoft
Side #3 : andmakeitbetterr
Side #4 : emembertoletitun
readme=heydudedontletmedowntakeabadsoftandmakeitbetterremembertoletitun

Очевидно, що вихідне повідомлення не помістилося повністю у відведені позиції решітки. Це цілком очікуваний результат.

1.4. Відновлення решітки


Перейдемо тепер до зворотного завдання, яке нагадує злом шифру. Вважаємо, що нам до рук потрапив текст зашифрованого та розшифрованого повідомлень. Для зручності подальшого розгляду зашифрований текст подаємо у вигляді двовимірного масиву:

const char encrypted[][8] = {
{'e' , 'a' , 'e' , 'n' , 'd' , 'u' , 'f' , 'k' },
{'m' , 'a' , 'd' , 'k' , 'e' , 'i' , 'q' , 'e' },
{'t' , 'o' , 'w' , 'n' , 'b' , 'e' , 't' , 't' },
{'t' , 'a' , 'k' , 'e' , 'k' , 'r' , 'b' , 'r' },
{'e' , 'a' , 'h' , 'b' , 'e' , 'x' , 'g' , 'u' },
{'y' , 'd' , 'u' , 'd' , 'f' , 'm' , 'c' , 'e' },
{'a' , 'd' , 'd' , 'o' , 'n' , 'g' , 'o' , 't' },
{'c' , 'o' , 'd' , 'l' , 'e' , 'r' , 't' , 'm' }
};

Розшифрований текст подаємо у вигляді фрагмента повідомлення:

const char *message = "heydudedonotletm";

Наше завдання полягає тепер у пошуку решітки, яка використовувалася для отримання зашифрованого повідомлення.

Ми не будемо розглядати будь-які витончені схеми злому, а спробуємо написати розв'язання задачі методом "грубої сили", тобто спираючись більшою мірою на обчислювальну міць сучасного комп'ютера.
Для вирішення цього завдання немає потреби обертати матрицю. Достатньо мати тільки текст, отриманий за допомогою першої сторони решітки, щоб прийняти рішення, що шифр зламаний. Завдання зводиться до послідовної генерації варіантів решіток. Кожну згенеровану решітку слід “прикладати” до зашифрованого повідомлення, читати символи та порівнювати їх із фрагментом розшифрованого повідомлення.

Основна складність полягає у послідовному переборі варіантів решіток. Відомо, що решітка – це 16 унікальних “віконець” у базовій матриці. Базову матрицю було розглянуто у першій частині завдання:


 1  2  3  4 13  9  5  1
 5  6  7  8 14 10  6  2
 9 10 11 12 15 11  7  3
13 14 15 16 16 12  8  4
 4  8 12 16 16 15 14 13
 3  7 11 15 12 11 10  9
 2  6 10 14  8  7  6  5
 1  5  9 13  4  3  2  1

Нагадую, базова матриця складається з 4-х субматриць, отриманих шляхом послідовного повороту матриці 4х4 за годинниковою стрілкою. Якщо розглядати кожну з 4 матриць як 16-розрядне ціле число без знака і використовувати його розряди для того, щоб забезпечити унікальність віконців у кожній решітці, то тоді можна використати бітову арифметику для перевірки унікальності віконців у кожній із згенерованих решіток.

Іншими словами, для вирішення цього завдання найбільш очевидним способом, знадобиться чотири цикли (за кількістю матриць 4х4):

typedef unsigned int UINT;
const int SIDE = 8;

const UINT top = 65536; // 65536 = 2^(SIDE*2)

for (UINT i = 0; i < top; i++) {
. . .
    for (UINT j = 0; j < top; j++) {
. . .
         for (UINT k = 0; k < top; k++) {
. . .
              for (UINT m = 0; m < top; m++) {
. . .

Крім того,  цікавить час, витрачений програмою на пошук відповідних решіток. Для цього помістимо код пошуку решітки всередину наступного фрагмента коду:

#include <time.h>

const clock_t before = clock();

. . . // код пошуку варіанта решітки

const clock_t difference = clock() - before;
const int msec = difference * 1000 / CLOCKS_PER_SEC;
printf(":( Time taken %d seconds %d milliseconds\n", msec/1000, msec%1000);

Спираючись на всі вищеописані міркування, отримаємо такий код:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

const int matrix[][SIDE] = {
    { 1,  2,  3,  4, 13,  9,  5,  1},
    { 5,  6,  7,  8, 14, 10,  6,  2},
    { 9, 10, 11, 12, 15, 11,  7,  3},
    {13, 14, 15, 16, 16, 12,  8,  4},
    { 4,  8, 12, 16, 16, 15, 14, 13},
    { 3,  7, 11, 15, 12, 11, 10,  9},
    { 2,  6, 10, 14,  8,  7,  6,  5},
    { 1,  5,  9, 13,  4,  3,  2,  1}
};

const clock_t before = clock();
const UINT top = 65536; // 65536 = 2^(SIDE*2)
int grid[SIDE][SIDE] = {0};
const size_t area = sizeof(grid[0][0]) * SIDE * SIDE;
unsigned long int iter = 0;

...
for (UINT i = 0; i < top; i++) {
  UINT uniq_i = i;
  for (UINT j = 0; j < top; j++) {
    if (uniq_i & j)
      continue;
    UINT uniq_j = uniq_i | j;
    for (UINT k = 0; k < top; k++) {
      if (uniq_j & k)
        continue;
      UINT uniq_k = uniq_j | k;
      int base[SIDE][SIDE] = {0};
      make_grid_quarter(base, i, 1);
      make_grid_quarter(base, j, 2);
      make_grid_quarter(base, k, 3);
      for (UINT m = 0; m < top; m++) {
        if ((uniq_k ^ m) == 65535) {
          printf("%5d %5d %5d %5d\n", i, j, k, m);
          memcpy(grid, base, area);
          make_grid_quarter(grid, m, 4);
          char readme[DIGITS + 1] = {'\0'};
          decryptor(grid, encrypted, readme);
          if (strcmp(readme, message) == 0) {
            printf("Bingo!\n");
            printf("%5d %5d %5d %5d\n", i, j, k, m);
            print_grid(matrix, grid);
            const clock_t difference = clock() - before;
            const int msec = difference * 1000 / CLOCKS_PER_SEC;
            printf("Time taken %d seconds %d milliseconds\n", msec / 1000,
                   msec % 1000);
            return 0;
          }
          iter++;
        }
      }
    }
  }
}
...

У коді використано деякі додаткові функції. Код цих функцій наведено нижче:

const int DIGITS = SIDE * 2;

void int_to_bin_digit(UINT in, int count, int *out) {
    UINT mask = 1U << (count-1);
    for (int i = 0; i < count; i++) {
        out[i] = (in & mask) ? 1 : 0;
        in <<= 1;
    }
}

void get_coords(const int quarter, const int index, int *x, int *y) {
const div_t t = div(index, SIDE/2);
const int row = t.quot;
const int col = t.rem;

switch (quarter) {
    case 1:
      *x = row; 
      *y = col;
      break;
    case 2:
      *x = col; 
      *y = SIDE-1-row;
      break;
    case 3:
      *x = SIDE-1-col; 
      *y = row;
      break;
    case 4:
      *x = SIDE-1-row;  
      *y = SIDE-1-col;
      break;
}
}

void make_grid_quarter(int (*grid)[SIDE], const UINT value, const int quarter) {
int digit[DIGITS];
int_to_bin_digit(value, DIGITS, digit);
for (int i = 0; i < DIGITS; i++) {
if (digit[i] == 0) {
continue;
}
int x, y;
get_coords(quarter, i, &x, &y);
grid[x][y] = 1;
}
}

Результат виконання цієї програми для заданих умов завдання:

Bingo!
    0     0  8830 56705
 .  .  .  .  .  .  .  .
 .  .  .  .  .  .  .  .
 .  .  .  .  .  .  .  .
 .  .  .  .  .  .  .  .
 .  . 12  . 16  .  .  .
 3  7 11 15  .  .  .  9
 .  . 10 14  8  .  6  5
 .  .  . 13  4  .  2  1

Time taken 0 seconds 917 milliseconds

Злом такого шифру не слід вважати тривіальним завданням. Візьміть до уваги те, що тут продемонстровано прямолінійний варіант вирішення завдання – злом методом грубої сили. Метод послідовного перебору варіантів решіток має ряд недоліків, які нескладно помітити при спробі задіяти запропоноване вище рішення для визначення решітки, яка була використана в прикладі першої частини завдання. Ви також мабуть звернете увагу на те, що час вирішення завдання може бути значно більшим, ніж показано у наведеному вище прикладі. Подібний підхід, що спирається на метод грубої сили, зовсім не виключає необхідності створювати більш витончений код, який виконуватиметься швидше. Тут є достатній простір для творчості, який залишається вам. Моє завдання полягало в тому, щоб пройти разом з вами весь шлях від постановки задачі, її аналізу та пошуку відповідного варіанта рішення до отримання кінцевого результату, тим самим показавши вам на конкретному прикладі можливий підхід до вирішення подібних завдань.

Вихідний код рішення цього завдання – це наступні чотири файли:

$ ls -l
total 56
-rw-r--r-- 1 vadim  staff  5058 31 лип 13:38 brute.c
-rw-r--r-- 1 vadim  staff  4152 31 лип 13:38 encrypt.c
-rw-r--r-- 1 vadim  staff  2854 31 лип 13:39 grid.c
-rw-r--r-- 1 vadim  staff  4486 31 лип 13:39 solver.c


У цій главі продемонстровано покроковий процес розв'язання класичної задачі використання решітки Кардано для шифрування і дешифрування коротких повідомлень, а також відновлення втраченої решітки методом грубої сили. На прикладі розв'язання цієї задачі показані базові етапи розробки програмного забезпечення: аналіз, декомпозиція на окремі функції та перевірка працездатності створеного коду. Від вас було потрібно, як кажуть у цьому випадку фокусники, стежити за руками, тобто стежити за тим, як на ваших очах народжується код, розмірковуючи, сумніваючись, відчуваючи осяяння власними ідеями. 
Тепер саме час завантажити на свій комп'ютер вихідний код за адресою на GitHub: https://github.com/iamvadimov/grille  та почати з ним роботу. Код свідомо було написано так, щоб залишити достатній простір для вашої самостійної творчості. Особливо це стосується відновлення втраченої решітки.