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