Немного истории

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

Оказывается, ответ на этот вопрос давно уже есть: в этой задаче - ровно пять. Есть такая теорема Колмогорова-Арнольда, где доказано, что если взять пять нейронов, то для них существуют какие-то гладкие функции активации, при которых двухслойная нейронка будет решать почти любую простую задачу для двумерных входных данных. И это было доказано аж в конце 50х годов 20 века и решало одну из важнейших математических задач 20го века - 13ю проблему Гильберта. Ключевая проблема здесь - "какие-то гладкие функции активации". Ведь, какие они конкретно - никто не сказал, и поэтому нужно их искать.

Функций активаций нейронов придумано много, и для многих из них доказано, что многослойная сеть на их основе может аппроксимировать наши неизвестные цвета правильно. Но мы задаем вопрос - а какая-же функция активации лучше?

Если еще немножко зарыться в литературу по нейронным сетям, можно найти ответ и на этот вопрос. Дело в том, что современные многослойные нейронные сети обучаются методами градиентного спуска и его вариациями (ADAM, RMSProp и так далее), и именно тут собака зарыта.

Полносвязная нейронная сеть (например четырехслойная, состоящая только из классических нейронов) реализует операцию примерно такую:

y=f_1(f_2(f_3(f_4(x))))

где f_i=\phi(A_i \cdot x  + B_i) - то, что математически делает i-й слой нейронов со своим входным вектором, \phi- функции активации этих нейронов, а A_i, B_i- некие (матричные правда, но это сейчас не принципиально) коэффициенты, которые надо найти тем самым методом градиентного спуска.

Как их искать? Надо посчитать производную, и вот тут оказывается, что производная, необходимая для нахождения например A_4, имеет вид типа:

\frac {dy}{dA_4} \approx (\phi')^4

В этом и проблема. Если слоев в нейронной сети много (N), то для нахождения коэффициентов самого первого слоя вам необходимо возвести производную функции активации \phi' в N-ную степень. А числа в больших степенях имеют очень плохую особенность - если число больше единицы, то его высокая степень стремится в бесконечность, а если число меньше единицы - падает до нуля. Называется это взрывом и затуханием градиента соответственно, и очень мешает тренировать сети глубиной более 10-11 слоев. Именно для обхода этой проблемы придумывают разные функции активации и Residual - блоки в современных нейронных сетях.

Понятно, что лучше всего использовать функции активации, для которых эта производная равна единице. Самая простая такая функция - это линейная активация \phi(x)=x. Но ее почти не используют, потому-что несколько слоев с линейной активацией - все равно, что один слой, и глубокая сеть внезапно становится настолько-же точной, насколько однослойная. Поэтому линейная активация используется чаще всего только на выходных слоях нейронных сетей.

Для известной активации в виде сигмоиды:

\sigma(x)=\frac {1}{1+e^{-x}}

все плохо - там максимальное значение производной 1/4, а в 10й степени - это порядка одной миллионной, и первые слои десятислойной нейронки не обучатся, только последние. Поэтому с сигмоидами в скрытых слоях обучать глубокие сети смысла немного.

Намного лучше Tanh - гиперболический тангенс. Его производная в небольшой окрестности максимума обращается в 1, поэтому она чуть лучше сигмоиды и все обучается лучше и глубже.

Совсем другое дело ReLU - ее производная обращается в 1 на всей положительной части оси. На отрицательной она нулевая. Поэтому с ней все намнооого лучше - производная порядка 1, пока вы на светлой стороне силы положительной части оси.

Но все равно - хорошо, да не очень, ведь и на темной стороне есть и бочки с вареньем, и корзины с печеньем. Если внимательнее присмотреться к формуле выше, которая про четвертую степень производной, то становится очевидно, что производная может быть не только +1, но и -1, и все такие функции тоже не вызывают затухания градиента.

И самая простая такая нелинейная функция - модуль (абсолютное значение числа):

\phi(x)=| x|

. Про нее достаточно много и давно известно, но она в машинном обучении как-то не прижилась. У нее производная равна 1 на положительной полуоси, и -1 - на отрицательной. И поэтому N-ная степень этой производной не растет, и не падает, и всегда равна либо 1, либо -1.

Поэтому давайте посмотрим, что нам даст ее использование в какой-нибудь стандартной задаче, например задаче MNIST.

Пасы руками над задачей MNIST

Задача MNIST - это определение рукописных цифр. Каждая цифра - изображение 28x28 в градациях серого. Если вы спросите неважно кого - хоть ML-программиста, хоть ChatGPT, они вам сразу объяснят, что задачу MNIST нужно решать сверточными сетями. Одним из первых удачных решений является сверточная сеть LeNet-5, и как видно из предыдущей ссылки, без каких-то предобработок, ансамблей и аугментаций точность такого решения порядка 99.05%. Попробуем получить точность получше.

Задачка несложная, поэтому можно использовать библиотеку Tensorflow. Создадим файл lenet.py с кодом:

import tensorflow as tf
from tensorflow.keras import datasets, layers, models, losses

def lenet(x_train_shape,lr=1e-3):
  input = layers.Input(shape=x_train_shape)
  data = input
  data = layers.Conv2D(6, 5, padding='same', activation='linear')(data)
  data = tf.nn.tanh(data)
  data = layers.AveragePooling2D(2)(data)
  data = tf.nn.tanh(data)
  data = layers.Conv2D(16, 5, activation='linear')(data)
  data = tf.nn.tanh(data)
  data = layers.AveragePooling2D(2)(data)
  data = tf.nn.tanh(data)
  data = layers.Conv2D(120,1, activation='linear')(data)
  data = tf.nn.tanh(data)
  data = layers.Flatten()(data)
  data = layers.Dense(84, activation='linear')(data)
  data = tf.nn.tanh(data)
  data_out = layers.Dense(10, activation='softmax')(data)
  model = tf.keras.models.Model(inputs=input,outputs=data_out)
  return model
 

Это и будет наша исходная модель - стандартная LeNet-5.

Следующий шаг - обучить ее. Создадим файл custom_callback.py, и в него поместим код для чтения датасета MNIST и дополнительных функций, требующихся для обучения:

import tensorflow as tf
import matplotlib.pyplot as plt
from tensorflow.keras import datasets, layers, models, losses
import tensorflow.keras as keras
import os
import numpy as np
# f=open('./log_params.dat','wt')
# f.close()

(x_all,y_all),(x_test,y_test) = datasets.mnist.load_data()
x_all = tf.pad(x_all, [[0, 0], [2,2], [2,2]])
x_test = tf.pad(x_test, [[0, 0], [2,2], [2,2]])

x_valid = x_all[x_all.shape[0]*80//100:,:,:]
x_train = x_all[:x_all.shape[0]*80//100,:,:]
y_valid = y_all[y_all.shape[0]*80//100:]
y_train = y_all[:y_all.shape[0]*80//100]

# for conv2D expand dims - add color level
x_train = tf.expand_dims(x_train, axis=3, name=None)
x_valid = tf.expand_dims(x_valid, axis=3, name=None)
x_test = tf.expand_dims(x_test, axis=3, name=None)

LR=0.1
class CustomCallback(keras.callbacks.Callback):
    def __init__(self, patience=5, name='model'):
        super(CustomCallback, self).__init__()
        self.patience=patience
        self.max_expected_acc=-1e10
        self.save_model_name=name
        self.best_epoch=-1
        
    def on_epoch_end(self, epoch, logs=None):
        keys = list(logs.keys())
        res1=self.model.evaluate(x=x_valid[:y_valid.shape[0]//2],y=y_valid[:y_valid.shape[0]//2],verbose=0)
        res2=self.model.evaluate(x=x_valid[y_valid.shape[0]//2:],y=y_valid[y_valid.shape[0]//2:],verbose=0)

        self.full_acc=logs['accuracy']
        self.val_acc=logs['val_accuracy']
        
        self.expected_acc=np.minimum(res1[1],res2[1]) 

        if self.expected_acc>self.max_expected_acc:
          self.best_epoch=epoch
          self.max_expected_acc=self.expected_acc
          print('save as optimal model')
          tf.keras.models.save_model(self.model, './'+self.save_model_name)
          f=open('./'+self.save_model_name+'/params.dat','wt')
          f.write('learning rate:'+str(LR)+'\n')
          f.write('best epoch:'+str(epoch+1)+'\n')
          f.write('train acc:'+str(self.full_acc)+'\n')
          f.write('val acc:'+str(self.val_acc)+'\n')
          f.write('expected acc:'+str(self.expected_acc)+'\n')
          f.close()
        if epoch>self.best_epoch+10:
          self.model.stop_training=True

Здесь мы читаем стандартный датасет 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:

import tensorflow as tf
import matplotlib.pyplot as plt
from tensorflow.keras import datasets, layers, models, losses
import tensorflow.keras as keras
import os
import gc
import numpy as np

import custom_callback as cc
from lenet import lenet

def train(model_func,src,y,src_v,y_v,model_name='model',saveOpt=False,epochs=10,custom_callback=''):
    model=model_func
    ycat=y #tf.keras.utils.to_categorical(y,num_classes=10)
#    model.summary()
    history=model.fit(src,ycat,epochs=epochs,
                      validation_data=[src_v,y_v],
                      verbose=1,
                      callbacks=[custom_callback])
    model=tf.keras.models.load_model('./'+model_name)        
    return model,history


# LeNet
# tf.keras.utils.plot_model(model_lenet,show_layer_activations=True,show_shapes=True,to_file='./model.png')
import matplotlib.pyplot as pp
import numpy as np
        
model=lenet(cc.x_train.shape[1:],lr=0.01)
prev_lr=0
for cc.LR in [0.001,0.0001,0.00001,0.000001]:
 print("train learing rate",cc.LR)
 if (prev_lr>0):
  model=tf.keras.models.load_model('model')
 model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=cc.LR), 
                loss=losses.sparse_categorical_crossentropy, metrics=['accuracy'])
 my_ccb=cc.CustomCallback(patience=1,name='model')
 model_lenet,history_lenet = train(model,cc.x_train,cc.y_train,cc.x_valid,cc.y_valid,
                                  model_name='model',
                                  epochs=1000, #stop after not finding best results during 10 epochs, see callback
                                  saveOpt=True,
                                  custom_callback=my_ccb)
 prev_lr=cc.LR
 gc.collect()
model_lenet=tf.keras.models.load_model('model')
print('loss,accuracy:',model_lenet.evaluate(cc.x_test,cc.y_test,verbose=0),
                              model_lenet.evaluate(cc.x_train,cc.y_train,verbose=0),
                              model_lenet.evaluate(cc.x_valid,cc.y_valid,verbose=0))

Еще одной проблемой обучения является подбор шага обучения (learning rate,LR). Чтобы не мудрствовать лукаво, будем двигаться сначала огромными шагами, потом большими, потом нормальными, а потом потихоньку красться. Сначала обучим сеть на большом LR, а когда она перестанет обучаться (не сможет улучшить ожидаемую минимальную ожидаемую точность на любых тестовых датасетах в течение 10 эпох), уменьшим LR в 10 раз (сделаем шаги меньше). И так далее, от LR=1e-3 до LR=1e-6.И не пугаемся большого количества эпох (1000) - CustomCallback нас остановит, когда нужно будет.

Запускаем:

python train.py

и смотрим на результат.

В процессе обучения можно смотреть в файл model/params.dat , там сохраняются основные расчетные параметры для лучшей сети.

Мой результат:

loss,accuracy:

[0.04112757369875908, 0.9886999726295471]

[0.003718348452821374, 0.9994791746139526]

[0.04750677943229675, 0.9881666898727417]

Второе значение в первой паре - это точность на тестовом датасете: 98.87%, что вполне приемлимо и обычно для LeNet-5, и похожее значение и лежит в википедии.

Меняем активации и увеличиваем точность

Для начала идем в файл lenet.py и меняем все вызовы tf.nn.tanh на tf.nn.relu

Проверим, насколько хороша ReLU.

Результат обучения:

loss,accuracy:

[0.11699816584587097, 0.9884999990463257]

[1.4511227846014663e-06, 1.0]

[0.1058315858244896, 0.9909999966621399]

Точность почти не увеличилась - 98.85% , что в общем наверное ожидаемо, ведь сеть очень неглубокая.

Для проверки эффективности абсолютной активации заменим все активации tf.nn.relu в lenet.py на абсолютное значение tf.math.abs , запускаем и получаем:

loss,accuracy:

[0.058930616825819016, 0.9930999875068665]

[4.2039624759127037e-07, 1.0]

[0.07546615600585938, 0.9920833110809326]

И вот это уже сюрприз. Мы получили 99.31% точности против 98.87% базового решения, сократив количество ошибок примерно в 1.5 раза. И это - очень неплохой результат. По крайней мере, видно что использование Abs иногда выгоднее, чем использование Tanh или ReLU, и можно легко побороться за строчку в википедии.

Таким образом, мы показали, что использование абсолютной активации вместо Tanh или ReLU позволяет улучшить точность прогноза, хотя и за счет более сложного процесса обучения.

В качестве заключения

Более точные варианты сети (и с меньшим числом свободных параметров) можно найти на гитхабе:

https://github.com/berng/LeNetImproving

а описание некоторых особенностей обучения и статистики результатов сравнения - в препринте:

https://arxiv.org/abs/2304.11758

В двух словах - число коэффициентов в сети LeNet можно уменьшить, чуть-чуть ее модифицировав, одновременно увеличив итоговую точность на тестовом датасете. Но только если обучать более аккуратно, с учетом распределения точностей валидации.

Например, так:

import tensorflow as tf
from tensorflow.keras import datasets, layers, models, losses

def lenet(x_train_shape,deep=0,lr=1e-3):
  input = layers.Input(shape=x_train_shape)
  data = input
  data = layers.Conv2D(6, 5, padding='same', activation='linear')(data)
  data = tf.math.abs(data)
  data = layers.AveragePooling2D(2)(data)
  data = tf.math.abs(data)
  data = layers.Conv2D(16, 5, activation='linear')(data)
  data = tf.math.abs(data)
  data = layers.AveragePooling2D(2)(data)
  data = tf.math.abs(data)
  data = layers.Conv2D(120, 5, activation='linear')(data)
  data = tf.math.abs(data)
  data = layers.AveragePooling2D(2)(data)
  data = tf.math.abs(data)
  data = layers.Conv2D(120,1, activation='linear')(data)
  data = tf.math.abs(data)
  data = layers.Flatten()(data)
  data = layers.Dense(84, activation='linear')(data)
  data = tf.math.abs(data)
  data_out = layers.Dense(10, activation='softmax')(data)
  model = tf.keras.models.Model(inputs=input,outputs=data_out)
  return model

С результатом

loss,accuracy:

[0.029204312711954117, 0.9952999949455261]

[0.00018993842240888625, 1.0]

[0.04176783189177513, 0.9944166541099548]

Или 99.53% на тестовом датасете. Это дает в 3 раза меньше ошибку, чем написано в википедии для неискаженного, неаугментированного датасета без ансамблей, и в 2 раза меньше, чем в статье про LeNet-5.

При этом у нас в этой сети меньше 77тыс параметров, против более чем 360тыс. параметров в оригинальной LeNet-5. Она получилась не только более точная, но и компактная.

Таким образом, если в сети заменить функции активации на Abs и аккуратно ее обучить, то можно получить более высокие точности, а если повезет - то и хорошенько сократить размеры нейронной сети.

Удач!

P.S. Спасибо Kandinsky 2.1 за иллюстрацию.

Комментарии (17)