Началось все на лекциях. Для иллюстрации работы нейронной сети нужны простые примеры. Достаточно хорошо известно, что одиночный нейрон формирует разделяющую гиперплоскость, и поэтому задачки типа "а найди мне, какой прямой разделяются два цвета на флаге Монако (который состоит из двух горизонтальных полос)" один нейрон решает на раз. Проблемы начинаются позже, например с флагом Японии  (который состоит из красного круга на белом фоне) - один нейрон эту задачу хорошо не решает. Обычно, стандартным методом решения является 'в лоб': а давайте увеличим число нейронов, поставим решающий слой, и задача решится. И тут возникает проблема номер один: сколько нейронов в скрытом слое ставить. Традиционный ответ изо всей обучающей литературы - подбирайте опытным путем. С одной стороны, их не должно быть сильно много, потому-что будет много неизвестных параметров, а с другой стороны - и сильно мало тоже не очень хорошо, ведь с одним нейроном мы уже обожглись. Итак, стандартный вопрос: сколько-же нейронов все-таки надо? Оказывается, ответ на этот вопрос давно уже есть: в этой задаче - ровно пять. Есть такая теорема Колмогорова-Арнольда, где доказано, что если взять пять нейронов, то для них существуют какие-то гладкие функции активации, при которых двухслойная нейронка будет решать почти любую простую задачу для двумерных входных данных. И это было доказано аж в конце 50х годов 20 века и решало одну из важнейших математических задач 20го века - 13ю проблему Гильберта. Ключевая проблема здесь - "какие-то гладкие функции активации". Ведь, какие они конкретно - никто не сказал, и поэтому нужно их искать. Функций активаций нейронов придумано много, и для многих из них доказано, что многослойная сеть на их основе может аппроксимировать наши неизвестные цвета правильно. Но мы задаем вопрос - а какая-же функция активации лучше? Если еще немножко зарыться в литературу по нейронным сетям, можно найти ответ и на этот вопрос. Дело в том, что современные многослойные нейронные сети обучаются методами градиентного спуска и его вариациями (ADAM, RMSProp и так далее), и именно тут собака зарыта. Полносвязная нейронная сеть (например четырехслойная, состоящая только из классических нейронов) реализует операцию примерно такую: где  - то, что математически делает i-й слой нейронов со своим входным вектором,  -  функции активации этих нейронов, а - некие (матричные правда, но это сейчас не принципиально) коэффициенты, которые надо найти тем самым методом градиентного спуска. Как их искать? Надо посчитать производную, и вот тут оказывается, что производная, необходимая для нахождения например , имеет вид типа: В этом и проблема. Если слоев в нейронной сети много (N), то для нахождения коэффициентов самого первого слоя вам необходимо возвести производную функции активации  в N-ную степень. А числа в больших степенях имеют очень плохую особенность - если число больше единицы, то его высокая степень стремится в бесконечность, а если число меньше единицы - падает до нуля. Называется это взрывом и затуханием градиента соответственно, и очень мешает тренировать сети глубиной более 10-11 слоев. Именно для обхода этой проблемы придумывают разные функции активации и Residual - блоки в современных нейронных сетях.  Понятно, что лучше всего использовать функции активации, для которых эта производная равна единице. Самая простая такая функция - это линейная активация . Но ее почти не используют, потому-что несколько слоев с линейной активацией - все равно, что один слой, и глубокая сеть внезапно становится настолько-же точной, насколько однослойная. Поэтому линейная активация используется чаще всего только на выходных слоях нейронных сетей. Для известной активации в виде сигмоиды: все плохо - там максимальное значение производной 1/4, а в 10й степени - это порядка одной миллионной, и первые слои десятислойной нейронки не обучатся, только последние. Поэтому с сигмоидами в скрытых слоях обучать глубокие сети смысла немного. Намного лучше Tanh - гиперболический тангенс. Его производная в небольшой окрестности максимума обращается в 1, поэтому она чуть лучше сигмоиды  и все обучается лучше и глубже. Совсем другое дело ReLU - ее производная обращается в 1 на всей положительной части оси. На отрицательной она нулевая. Поэтому с ней все намнооого лучше - производная порядка 1, пока вы на светлой стороне силы положительной части оси.  Но все равно - хорошо, да не очень, ведь и на темной стороне есть и бочки с вареньем, и корзины с печеньем. Если внимательнее присмотреться к формуле выше, которая про четвертую степень производной, то становится очевидно, что производная может быть не только +1, но и -1, и все такие функции  тоже не вызывают затухания градиента. И самая простая такая нелинейная функция - модуль (абсолютное значение числа): . Про нее достаточно много и давно известно, но она в машинном обучении как-то не прижилась. У нее производная равна 1 на положительной полуоси, и -1 - на отрицательной. И поэтому N-ная степень этой производной не растет, и не падает, и всегда равна либо 1, либо -1. Поэтому давайте посмотрим, что нам даст ее использование в какой-нибудь стандартной задаче, например задаче MNIST. Задача MNIST - это определение рукописных цифр. Каждая цифра - изображение 28x28 в градациях серого. Если вы спросите неважно кого - хоть ML-программиста, хоть ChatGPT, они вам сразу объяснят, что задачу MNIST нужно решать сверточными сетями. Одним из первых удачных решений является сверточная сеть LeNet-5, и как видно из предыдущей ссылки, без каких-то предобработок, ансамблей и аугментаций точность такого решения порядка 99.05%. Попробуем получить точность получше. Задачка несложная, поэтому можно использовать библиотеку Tensorflow. Создадим файл lenet.py с кодом: Это и будет наша исходная модель - стандартная LeNet-5.   Следующий шаг - обучить ее. Создадим файл custom_callback.py, и в него поместим код для чтения датасета MNIST и дополнительных функций, требующихся для обучения: Здесь мы читаем стандартный датасет MNIST, который исходно разделен на обучающую (x_all,y_all) и тестовую (x_test,y_test) выборки. Тестовую выборку трогать нельзя, она предназаначена только для определения итоговых точностей нашего решения. Все операции по обучению будем проводить с x_all,y_all. В первой матрице у нас изображения рукописных цифр 28x28, а во второй - значения этих написанных цифр. Мы разбили датасет x_all на два подсета: x_train - на котором будем обучать нейронную сеть, и x_valid - по которому будем определять, насколько хорошо у нас идет обучение, и не пора-ли остановиться. За останов обучения отвечает класс CustomCallback. Проблема в том, что нашей задачей является не получение максимальной точности на обучающем или валидационном датасете. Нашей задачей является получение максимальной точности на тестовом датасете, неизвестном нам при обучении. И все, что мы о нем знаем - что он в каком-то смысле похож на предыдущие два. Это значит, что точность на тестовом датасете будет отличаться и от точности на обучающем датасете, и от точности на валидационном датасете. Поэтому мы должны остановиться не тогда, когда точность на обучающем или валидационном датасетах максимальна. А тогда, когда мы будем считать, что на любом неизвестном нам тестовом датасете точность нашей сети будет максимальна. Как это сделать? Самое простое - вспомнить, что точность вычисляется по случайным величинам, а значит  точность - величина случайная (ведь валидационный датасет был выбран случайно из общего датасета) , и поэтому обладает неким распределением плотности вероятности, а точность на валидационном датасете - просто среднее значение этого распределения. Если мы хотим предсказать точность на тестовом датасете, нам нужно нечто другое - нам нужно угадать некую нижнюю границу этого распределения, ниже которой точность на тестовом датсете не упадет. Этому угадыванию посвящен класс CustomCallback. Идея достаточно простая - мы используем не точность на валидационном датасете, а минимальную точность из точностей, достигнутых на двух половинах валидационного датасета. Одна из них видимо будет ниже среднего, а насколько ниже - будет определяться распределением плотности вероятности точности, и она  получается ниже среднего примерно на одну ширину распределения (расчитанную по среднеквадратичному отклонению). В self.expected_acc - именно это значение. Ну, и как говорится в одном анекдоте, теперь мы со всем этим попытаемся взлететь. А для этого мы создадим главный файл train.py: Еще одной проблемой обучения является подбор шага обучения (learning rate,LR). Чтобы не мудрствовать лукаво, будем двигаться сначала огромными шагами, потом большими, потом нормальными, а потом потихоньку красться. Сначала обучим сеть на большом LR, а когда она перестанет обучаться (не сможет улучшить ожидаемую минимальную ожидаемую точность на любых тестовых датасетах в течение 10 эпох), уменьшим LR в 10 раз (сделаем шаги меньше). И так далее, от LR=1e-3 до LR=1e-6.И не пугаемся большого количества эпох (1000) - CustomCallback нас остановит, когда нужно будет. Запускаем: python train.py и смотрим на результат. В процессе обучения можно смотреть в файл model/params.dat , там сохраняются основные расчетные параметры для лучшей сети. Мой результат: loss,accuracy:    Второе значение в первой паре - это точность на тестовом датасете: 98.87%, что вполне приемлимо и обычно для LeNet-5, и похожее значение и лежит в википедии. Для начала идем в файл lenet.py и меняем все вызовы tf.nn.tanh на tf.nn.relu Проверим, насколько хороша ReLU. Результат обучения: loss,accuracy:    Точность почти не увеличилась - 98.85% , что в общем наверное ожидаемо, ведь сеть очень неглубокая. Для проверки эффективности абсолютной активации заменим все активации tf.nn.relu в lenet.py на абсолютное значение tf.math.abs , запускаем и получаем: loss,accuracy:    И вот это уже сюрприз. Мы получили 99.31% точности против 98.87% базового решения, сократив количество ошибок примерно в 1.5 раза. И это - очень неплохой результат. По крайней мере, видно что использование Abs иногда выгоднее, чем использование Tanh или ReLU, и можно легко побороться за строчку в википедии. Таким образом, мы показали, что использование абсолютной активации вместо Tanh или ReLU позволяет улучшить точность прогноза, хотя и за счет более сложного процесса обучения. Более точные  варианты сети (и с меньшим числом свободных параметров) можно найти на  гитхабе: https://github.com/berng/LeNetImproving а описание некоторых особенностей обучения и статистики результатов сравнения - в препринте: https://arxiv.org/abs/2304.11758 В двух словах - число коэффициентов в сети LeNet можно уменьшить, чуть-чуть ее модифицировав, одновременно увеличив итоговую точность на тестовом датасете. Но только если обучать более аккуратно, с учетом распределения точностей валидации. Например, так: С результатом loss,accuracy:  [0.029204312711954117, 0.9952999949455261]    [0.04176783189177513, 0.9944166541099548] Или 99.53% на тестовом датасете. Это дает в 3 раза меньше ошибку, чем написано в википедии для неискаженного, неаугментированного датасета без ансамблей, и в 2 раза меньше, чем в статье про LeNet-5. При этом у нас в этой сети меньше 77тыс параметров, против более чем 360тыс. параметров в оригинальной LeNet-5. Она получилась не только более точная, но и компактная. Таким образом, если в сети заменить функции активации на Abs и аккуратно ее обучить, то можно  получить более высокие точности, а если повезет  - то и хорошенько сократить размеры нейронной сети. Удач! P.S. Спасибо Kandinsky 2.1 за иллюстрацию.