JobIt: Пишем свою нейросеть с нуля
2024-06-20 09:46:38 - MisterD
Команда Jobit провела первый мастер класс по созданию нейросети.
Основатель платформы Jobit Дмитрий Антонычев заявил о планах продолжить проводить бесплатные мероприятия и курсы по ИИ, которые лягут в основу развития знаний и области их применения.
Нейронные сети - довольно популярный нынче тренд: в той или иной мере они используются практически во всех областях, начиная от нефтедобычи, заканчивая играми. Давайте и мы приложим к ним руку: разберёмся, что это за зверь и попробуем создать свою сетку на Rust, тем более, что практически все примеры пишутся исключительно на Python и TensorFlow, что, словно, создаёт у них нечто вроде монополии, что, безусловно, удобно, но не интересно.
Попробую материал подать предельно просто: сперва минимум теории, затем - немного развитие темы, под финиш - работающая нейросетка. Ссылка на исходник - в конце.
Наша боевая нейросеть, которая умеет распознавать ламповые цифры... уже жутко от её возможностей
Пара наблюденийВообще, это сейчас нейросети называют искусственным интеллектом и преподносят как новый этап в развитии человечества, но на моей старенькой цифровой камере Canon, которую я много лет назад купил (задолго до того, как нейросети стали мейнстримом) - уже был алгоритм поиска лиц, основанный, кстати, на нейросети.
Впрочем, и это далеко не новшество. Первый нейросетки были уже в 70-х годах; однако недостаточно совершенное оборудование не позволило им активно развиваться.
Ещё хочется заметить, что в сети крайне непростой учебный материал для понимания того, что такое нейросети и как с ними работать. По какой-то необъяснимой мне причине, половина лекторов, пытаясь объяснить, раскрывая тему, погружаются в доказательство математических теорем и сыпят терминологией, которая для человека неподготовленного (или успевшего забыть вузовский курс высшей математики) звучит как эльфийский язык: прикольно, но ничего не понятно.
Попробую исправить это.
Что такое нейросетьВообще, любая нейросеть - это система принятия решения: она получает некоторые входные данные, анализирует их тем или иным способом и выдаёт некоторое решение, неважно что это - анализ ЭКГ или генерация изображения.
Ещё одной особенностью нейросети - это обучение или, проще говоря, настройка для достижения лучшего результата работы. Например, алгоритм поиска лица на фотографии позволяет не просто найти лицо, но и, благодаря обучению, сделать этот механизм эффективней.
Про нейросеть в двух словахЕсли не муссировать бесконечные повторения рассказов про дендриты, нейроны и аксоны, которые должны описать работу нейросети, то суть её сводится к следующему: есть некоторый набор входных данных (скажем, картинка, на которой надо найти лицо), данные имеют разное значение (есть, не интересная в плане задачи, рука, а есть - интересная - улыбка).
Теперь данные надо отделить: полезное от бесполещного. Для этого используются веса. Если данные - это какое-то значение (скажем, целочисленное или, возможно, массив), то вес, чаще всего, некая скалярная величина, позволяющая установить важность данного набора. Некоторые данные надо использовать, а некоторые - пропустить.
Короче говоря, попавшие в "нейрон" данные как-то рассчитываются, на выходе появляется некоторое значение, которое надо как-то использовать. Обычно, это делает функция активации: математический аппарат, определяющий результат работы нейрона.
Простой пример: надо ли надевать куртку? Если на улице холодно - надо, если сильно ветрено - надо, если не очень холодно и немного ветрено - тоже надо. То есть есть пороговые значения, при которых мы однозначно должны надеть куртку, а есть - расчётные, требующие надеть куртку лишь если температура воздуха и сила ветра таковы, что будет холодно. Входных условий, безусловно, может быть больше.
Первый заходПопробуем сделать первую систему принятия решения, ту, что я указывал в примере: на вход подаётся температура воздуха и наличие ветра, а на выходе - рекомендация, надо ли надевать куртку. Как это работает:
- Если холодно - куртку надо надевать;
- Если холодно и ветрено - куртку надо надевать;
- Если ветрено - куртку, возможно, стоит надеть;
- Иначе - надевать куртку не надо;
Очевидно, что показатель "холодно" - сильное важнее, чем "ветрено". Пусть вес первого будет - два, а второго - один. На вход виртуального нейрона будет подаваться два логических входа: холодно и ветрено (1 или 0). А на выход - решение: 0 - куртка не нужна, 1 - куртка рекомендована, 2 - куртка обязательна.
Нейросеть. Начало
Итак, я написал небольшой код. Он позволяет взять веса входных данных, помножить их на входное значение (есть или нет - 1 или 0) и получить некоторое суммарное значение.
Первый результат
И вот, у нас есть значение - 3. Но что нам с ним делать; оно совсем не укладывается в рамки системы рекомендаций. Нам не хватает функции активации, которая позволит это решение принять.
Тут надо немного остановиться: теоретически, нам ничего не мешает написать функцию с большим количествам условных операторов, но особенность нейронных сетей, часто, составляет два аспекта: многопроцессорность и множественность вычислений. Проще говоря, большое количество типовых задач решаются параллельно (как и при создании сложных сцен при помощи графического ускорителя), а наличие операций ветвления сильно этот процесс замедляет. Поэтому, нам нужна некоторая простая функция, которая позволит относительно быстро вычислить значение, а не проводить сравнение. Не то, чтобы это было обязательным условием (особенно, если учесть, что мы вряд ли будем запускать нашу нейросеть на графическом ускорителе) - скорее рекомендация.
Для нашего случая - будет такой график:
- если 0, то 0
- если 1, то 1
- если > 1 - то 2
Вынужден расписаться в своей тригонометрической безграмотности, но такой функции я не только не знаю, но и не могу даже придумать. Но мы пойдём на хитрость: воспользуемся математической функцией минимального значения (из двух значений выбирает минимальное) - в функции activation. Дополню код:
Система рекомендаций готова
И советы даже даёт
Я поигрался с разными входными значениями - со всеми работает. Поздравляю себя, первая версия нейросети, пусть и состоящая из всего одного нейрона, готова.
Заметки на поляхНаверное вы заметили, что я сразу использую принятие "сложного" решения, то есть решения, которое предполагает решение не "да" или "нет", а ещё некоторые оттенки. На этот шаг я пошёл сознательно: следует привыкать, что нейросети (особенно большие и сложные) всегда работают в вероятностном поле. То есть утверждение, что объект на фотографии является платком - не более, чем предположение. Просто вероятность может быть высокой или низкой.
Впрочем, принятие решение - явление детерминированное, то есть мы либо соглашаемся, с тем, что на экране платок, либо нет. В связи с этим, обычно, принимается некоторое пороговое значение: если результат выше или равен ему, считается, что решение принято, если меньше - не принято. Впрочем, в цепочке нейронов можно использовать различные данные.
Второй момент - функция активации. Наверное, вы уже слышали или читали про сигмоидальные функции, которые позволяют довольно гибко подстраивать выявление параметров и "вычислять" принятие решения. Главное их достоинство - предопределённый диапазон возможных значений: от 0 до 1. Не то, чтобы сигмоидальные функции были единственными, которые можно для этого использовать, просто они удобны.
Вот, например, тоже функция. Попробуем взять её, где альфа = 1/2
Наблюдательный читатель уже, наверное, заметил, что в программе мы используем тип u8 - то есть целочисленный с диапазоном от 0 до 255. Но это не проблема: если взять любое число от 0 до 1, умножить его на 255 и округлить - получим величину рабочей области.
Такая вот сигмоидальная функция получилась
А вот результат примерных вычислений:
f(x)
Как можно заметить, что при минимальном значении входного параметра - значение максимально (и равно половине), но уже скоро уходит в 0. Но я допустил в формуле ошибку - использовал в показатели степени положительную величину вместо отрицательной; если исправить - получается такой вот вывод:
Исправленная f(x)
То есть тут легко заметить, что расчётная величина растёт нелинейно, но вместе с ростом входной величины. При этом минимальное значение - 127.
Тут мы можем сделать простое ветвление:
- 0..185 - не надеваем куртку, если
- 186..223 - рекомендуем надеть
- 224..255 - обязываем надеть куртку
Кстати, если бы вместо целочисленных значений мы бы использовали вещественные, всё было бы так же, за исключением параметров - они все были бы менее 1, но больше 0.
Впрочем, чисто технически, ничего не мешает нам использовать и прочие функции (например, гиперболический тангенс), позволяющий получить значения от -1 до 1. Скажем, любая величина менее 0 - снять куртку, любая более - надеть, а 0 - по настроению.
Сеть вместо нейронаПовторюсь, предыдущий пример был проделан всего с 1 нейроном. Но нейронные сети не зря называются сетями - в них используется явно более 1 нейрона.
Вообще, что такое сеть: у нас много нейронов. Есть входные данные; это входные данные идут на вход нейронов (по какому-то признаку), потом, выходные данные идут на вход других нейронов, а потом, на выходе - формируется какой-то определённый набор данных.
Чаще всего, каждый нейрон, получая данные с разных входов - их, как-то, ранжирует, мол, это более важно, это менее (использует вес), на выход же отдаёт какое-то значение или набор значений. В самом простом виде - 1 или 0.
Связываются нейроны тоже своеобразно: наборы нейронов делятся на слои и каждый нейрон текущего слоя может быть связан с каждым нейроном следующего и так далее. Впрочем, такой тип связи не то, чтобы догма - карта связей может зависеть от типа решаемой задачи. Да и слой может быть всего один.
Кстати, если взять данные с какого-то нейрона (или нейронов) и направить на вход нейронов предыдущего слоя - получится рекуррентная сеть или сеть с элементами памяти (это используется в некоторых задачах, вроде восстановления сигнала), но я это написал лишь для информирования.
Что же самое важное в сетях? Умение их обучать! То есть настраивать так, чтобы результат соответствовал поставленной задаче (чтобы на фотографии искались лица, а не фрагменты плеча).
ПерсептронХотел я пропустить эту тему, но нельзя, т.к. это естественная часть всего (всё равно, что рассказывать про нейросети, но не рассказать про нейрон). Но вы можете - тут просто немного общих фраз, не более.
Короче, персептрон - это логическая реализация нейрона и выглядит она примерно так: есть много сенсоров, которые подают информацию; эта информация попадает на входы перспетрона, там она условно "взвешивается" и подаётся на условный сумматор и в функцию активации. А вот функция говорит либо да, либо нет. Это если очень просто. По факту, тут есть довольно большой разброс всевозможных действий, но они предпринимаются (или не предпринимаются) в зависимости от того, какой задачей мы занимаемся.
Пример перспетрона: на вход подаётся картинка, внутренняя логика выбирает полезную информацию, классифицирует её и, на основании некоторых данных (которым его надо ещё обучить) - принимает решение. Да-да, мы говорим о классификации объектов, то есть про те красивые ролики, где нейросеть, в режиме реального времени, определяет, что объект, который человек достаёт из холодильника - банка пива с такой-то вероятностью.
Поняли, да? Мы подаём на вход данные, анализируем их - а на выходе что-то да и определяется. А как это сделать?
Непросто. Надо, во-первых, выделить ключевые признаки, по которым объект будет классифицироваться (тут речь не только о картинке, но и про любые данные, скажем, анализ трафика). Во-вторых, алгоритм надо обучить отличать эти признаки (то есть задать некоторый барьер, преодоление которого позволяет утверждать, что данные - полезные). В-третьих, надо обучить уже классифицированными данными, чтобы быть уверенным, чтобы повысить точность разбора.
Последовательность разбораКак было сказано выше, персептроны хорошо умеют классифицировать данные. Но что делать, скажем, с разбором изображения? Как нам определить, что на фотографии - котик?
Тут, обычно, используется несколько этапов разбора, каждый из которых выполняет свою роль. Я приведу пример, как это может быть, но это необязательно должно быть именно так:
- Подготовка фотографии: обработка фильтрами для контрастного выделения основных объектов исследования (скажем, выделение контура объектов, если тема интересна - тут больше);
- Анализ изображения и выборка зон для анализа (не всё изображение может содержать полезные данные, но у полезных должен быть некоторый критерий отбора);
- Анализ подготовленных фрагментов на предмет соответствия их указанным критериям (похож ли он на котика или это ваза?);
- Анализ фрагмента изображения на критерии классификации (количество и длина лап, форма головы, наличие и форма ушей и тому подобное);
- Анализ классифицированных данных на предмет соответствия их пороговому значению функции активации последнего персептрона и выдача заключения.
Повторюсь, это один из многих вариантов разбора; можно придумать другие, где будет больше или меньше шагов, но главное - результат.
НоВы, наверное, заметили, что тут есть очень много "но": если что-то пойдёт не так, наш классификатор может назвать котиком то, что им не является, либо не назвать то, что котиком является. Потому что, если опустить все красивые названия, то всё сводится к простому: у нас есть некоторое количество точек входа (их, обычно, зовут сенсорами) и одна точка выхода, с принятым решением. И именно внутренняя работа с данными и становится самым главным условием успешной работы нейросети.
Обучение сетиВот мы и подошли к волшебству; к тому, что делает нейросеть полезной. Напомню, у нас есть входные данные, но каждый вход с данными обладает своим весом; данные обрабатываются и, на основании этой обработки - принимается решение. Если на вход подали картинку с котиком - сеть должна сказать "это котик", а иначе - сказать "нечто другое".
Обучение нейронной сети - это автоматический выбор весов для входных сигналов таким образом, чтобы результат работы соответствовал бы ожиданию.
Поскольку мы лишь в самом начале пути, то и нейросеть, у нас будет ну ооочень простенькая - она будет уметь определять цифры. Сперва я хотел определять буквы, но наглядных пособий не оказалось - поэтому взял то, что было (цифры).
Цифры для распознавания
Как нетрудно заметить, каждая из них умещается в прямоугольник 5 на 3 пикселя: каждая точка матрицы - это отдельный вход, соединённый сразу с анализатором (сенсор, считывающий данные, временно исключён, вернее, упрощён). То есть входов будет 15, а на выходе - что же это за цифра.
Забегая немного вперёд отмечу, что в дальнейшем можно будет взять практически любую написанную цифру, трансформировать её в ключевые точки, вписанные в матрицу 3 на 5 и попытаться распознать. Но пока что, будем распознавать эти данные.
Шаг 1: договор о намеренияхПреобразовываем данные: каждая цифра теперь просто массив данных, где 1 - черный квадрат, 0 - белый; данные хранятся в массиве длинной 15 символов. Напишем несложный код, который это обобщит.
Пока все веса равны 1
Вывод получился такой
Веса для каждой цифры и не уникальны и разброс довольно нестабильный пока что
Что надо сделать? Очевидно, продолжать! Давайте попробуем поиграть со входными данными. Если прямое сложение не сработает, то, возможно, из данных можно получить некоторую уникальную комбинацию, позволяющую понять, что перед нами та или иная цифра.
Если посмотреть константу STUDY_DATA в исходнике выше, то можно заметить, что набор 15 нулей и единиц выглядит весьма уникальным. Появляется соблазн взять число 2 в 16 степени, превратить нули и единицы в него и сопоставить значению...
Впрочем, если бы всё было так просто, нам не понадобилась бы нейросеть: вид разбираемой цифры может отличаться от тестового. А ведь нам может придётся разбирать что-то вроде такого
Я тоже не понимаю, что это за цифра, а вот нейросеть, вернее, персептрон, должен разобраться! Пожелаем ему удачи
Или такого:
Рукописные цифры; не совсем наш формат, но держать в уме такое тоже стоит
Рисунок потенциальной цифры я взял из "этих ваших интернетов", но уже понятно, что задача будет несколько сложней, чем казалась раньше. Придётся нам обучать нашу сеть, вернее персептрон - подбирать веса.
Начинаем обучениеОбучение - это, всего лишь, настройка весов. Но почти автоматическая.
Суть сводится к тому, что изначально все веса - нулевые. Потом они волшебным образом меняются, а потом, если входные данные были разобраны верно, то веса положительных сигналов - увеличиваются; если входные данные разобраны неверно - веса уменьшаются. Начиная с какого-то момента, правильное распознавание данных, более, не влияет на значение.
Алгоритм обучения будет, примерно, таким вот:
- Для начала формируем (да уже сформировали) набор идеальных значений, где цифры однозначно определяются.
- На каждое значение у нас 15 сенсоров (помните, матрица 3 на 5 символов, формирующая маску цифры), обнуляем все веса нашего перцептрона, задаём количество уроков обучения (то есть тех самых разборов, в результате которых будут назначаться веса) - у нас их будет N (хотелось бы взять 10, но их, вероятно, будет больше).
- Теперь случайным образом выбираем одну из цифр и подаём её на вход, предоставляя персептрону возможно определить, что же это такое.
- Если перспетрон не угадал значение (ошибся) - веса активных сенсоров уменьшаем; в противном случае переходим к следующему уроку.
- Если персептрон угадал значение - веса активных сенсоров увеличиваются; в противном случае переходим к следующему уроку.
- Делаем вывод.
Сложно? Ну да, запутанно немного. Ничего, сейчас разберёмся, как только код начнём писать.
Код основной функции
Код дополнительных функций
Собственно, пояснения:
Мы начинаем с чистого листа и делаем 100 повторений (STD_COUNT), пытаясь понять, какие веса надо задать так, чтобы перцептрон оценивал число как пятёрка. Если он оценивает правильно - веса, соответствующие активным сенсорам, увеличиваются, неправильно - уменьшаются (всё, как и было описано).
Вот так вот веса распределились, когда мы обучались пятёрке
Попробуем увеличить количество повторений обучения до 1000
Веса изменились, но не так, чтобы прямо принципиально
А вот и до 100.000
Не сильно отличается от предыдущего, по крайней мере эти -10
После обучения - мы проведём контроль. Это значит, что мы спросим наш персептрон, а является ли передаваемый набор данных - тем числом, которому мы его обучали?
Допишем в функцию после вывода весов
Результат обнадёживает: 5 учили, 5 и определил
Я немного поэкспериментировал и выяснил, что однозначно пятёрку перцептрон опознаёт при достаточно долгом обучении (скажем, 1000 циклов); при 100 - обучиться у него не вышло, при 200 - ошибочно определял и другие числа как пятёрку, но уже при 300 выдавал требуемый результат.
Всё же пусть лучше будет 1000 повторов. Есть ощущение, что надёжней.
Усложним задачу. Теперь будем случайным образом отключать один из сенсоров и узнаем, опознал ли его персептрон (я буду использовать те же тестовые данные, но при проверке - стану, постепенно, переключать от 1 до 5 точек сенсоров из единицы в ноль).
По мере потерей знаковых значений - опознание ухудшается
В принципе, работоспособно.
ИнтерлюдияВообще, есть и другие способы обучения, а не только тот, что я использовал. Если опустить какие-то конкретные решения - то это, чаще всего, некоторая формула, позволяющая на основании входных данных сбалансировать выходные значения. При этом, выбор того или иного подхода, чаще всего, зависит именно от решаемой задачи.
Если обобщить всё написанное выше, то нейроны (персептрон) должен обладать неким математическим аппаратом, который, в зависимости от количества повторений, должен формировать выходной результат.
Скажем, есть дельта-правило Хебба (о дельтах мы говорили уже):
Дельта-правило
Тут прекрасно всё: t - это шаг обучения (скажем от 1 до 1000), w - вес какого-то сенсора (или линии подводки данных), δ - величина ошибки (разница между правильным и выданным ответом, скажем 7 и 5; нетрудно заметить, что число может быть отрицательным); x - значение входного сигнала (да, тут входной сигнал может быть вещественным или не единичным); η - скорость обучения (тут нужно пояснение - чем ближе мы к оптимальному значению, тем меньше должна быть величина, чтобы ближе подойти к наилучшему значению: скажем, на 1 шаге значение может быть 1, а на 1000 - уже 0.01).
Стало сложно и непонятно? Не расстраивайтесь. Использовать это мы сейчас, конечно же, не будем.
Формируем выходное приложениеНаверное, в идеале для нас было бы создать 1 перцептрон, который, получает на вход какое-то значение, а на выходе - определяет, что это такое. Но так не получится. Поэтому мы сделаем 1 персептрон, а вот веса для каждого числа вычислим отдельно. Я дополню код и уберу лишнее, включая усложнённый контроль: мы ведь убедились, что он неплохо работает. Вообще, вынесу-ка я это всё в тесты...
Что будем делать:
- Теперь для каждого числа будет свой набор весов;
- Обучение будет происходить для каждого из чисел;
- Сложную проверку проводить не будем, только простую.
Итоговый код
Я не стал подробно всё описывать (если захотите - гляньте потом исходники, я ссылку дам). В результате, провёл тестирование несколько тысяч раз - всё хорошо распозналось.
ЗаключениеКак видите, нейросети не то, чтобы что-то сложное. Можно самому написать, можно взять нечто готовое (благо, сейчас обученных моделей предостаточно).
Ссылка на исходник, как и обещал.