Как стать автором
Обновить

Как я перестал беспокоиться и полюбил абсолютную активацию

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров25K

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

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

Оказывается, ответ на этот вопрос давно уже есть: в этой задаче - ровно пять. Есть такая теорема Колмогорова-Арнольда, где доказано, что если взять пять нейронов, то для них существуют какие-то гладкие функции активации, при которых двухслойная нейронка будет решать почти любую простую задачу для двумерных входных данных. И это было доказано аж в конце 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 за иллюстрацию.

Теги:
Хабы:
Всего голосов 27: ↑27 и ↓0+27
Комментарии17

Публикации

Истории

Работа

Data Scientist
70 вакансий

Ближайшие события