Здравствуйте, уважаемые читатели. О нейронных сетях написано и сказано очень много, преимущественно о том, как и для чего их можно применить. При этом как-то не очень много внимания уделяется двум важным вопросам: а) как нейронную сеть упростить и быстро вычислить (одно вычисление экспоненты реализуется библиотечными функциями языков программирования, обычно, не менее чем за 15-20 процессорных инструкций), б) какова, хотя бы отчасти, логика работы построенной сети – в самом деле, получаемые после обучении сети огромные матрицы значений весов и смещений как-то не очень помогают понять закономерности, которые эта сеть нашла (они остаются скрытыми и задача их определить – задача вербализации – иногда очень важна). Я расскажу об одном своем подходе к решению этих вопросов для обычных нейронных сетей прямого распространения, при этом постараюсь обойтись минимумом математики.

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


Сеть прямого распространения, с математической точки зрения – очень большая функция, в которую входят значения входов сети, весовых коэффициентов и смещений нейронов. В каждом нейроне слоя значения входов слоя (вектор X) умножаются на веса нейрона (вектор $W_i$), складываются со смещением $B_i$

$s_i = W_iX+B_i$


и поступают в активационные функции $A(s_i)$, формирующие выходы нейронов слоя.

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

$exp(v)=2^{v*log_2(e)}$


Поэтому, если мы хотим ускорить расчет сети, то первой задачей будет упрощение расчета активационной функции. Можно попробовать немного пожертвовать качеством за счет выигрыша в скорости, приближенно заменив расчет классической активационной функции на расчет более простой функции, которая (на имеющихся входных данных) дает примерно те же результаты. Это, вообще говоря, классическая задача интерполяции: имеем набор значений, вычисленных исходной функцией A(s), и подбираем более простую функцию, которая дает очень похожие значения. Такой простой функцией a(s) может быть обычный полином, или полином с отрицательными степенями или еще что-то в таком роде. Я использовал четыре вида таких функций:

$a(s) = b_0 + b_1*s + b_2*s^2 + … + b_n*s^n$;
$a(s) = b_0 + b_1/s + b_2/s^2 + … + b_n/s^n$;
$a(s) = b_0 + b_1*s^{0,5} + b_2*s^1 + b_3*s^{1,5} + … + b_n*s^{0,5n}$;
$a(s) = b_0 + b_1/s^{0,5} + b_2/s^1 + b_3/s^{1,5} + … + b_n/s^{0,5n}$;

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

  1. Записать аналитически огромную функцию NET(X), вычисляемую сетью в-целом;
  2. Заменить в NET(X) исходные функции A(s) на полученные для них заменяющие функции a(s);
  3. Алгебраически полученную NET(X) упростить (вернее, воспользоваться каким-либо готовым программным кодом символического упрощения выражений). Это уже возможно (по крайней мере, намного проще, чем мы бы пытались упростить сеть с исходными функциями, например, с экспонентами).

В результате получаем нечто более простое и, возможно, немного более математически наглядное – здесь уже можно пытаться понять, какой вид имеет реализуемая сетью функция.

Это и есть вариант объяснения логики работы построенной сети.

Описанная задача, конечно, лишь на словах выглядит простой. Для применения в своих программах мне потребовалось написать собственный код символического упрощения выражений. Кроме того, я решал более сложную задачу, допустив, что у каждого нейрона с функцией A(s) может быть несколько вариантов альтернативной активационной функции $a_k(s)$, поэтому общая задача свелась еще и к перебору вариантов таких функций и символическому упрощению сети для каждого такого варианта. Тут уже помогло только распараллеливание вычислений.

Результат


Результат меня порадовал. Я ускорял трехслойную сеть (с тремя входами) из восьми нейронов (с входными весами и смещениями) с активационными функциями «экспоненциальная сигмоида». Как показали замеры времени, удалось получить выигрыш примерно в 40% по времени без существенных потерь в качестве.

Иллюстрирую. Вот данные исходной сети:





И в третьем, выходном слое:


Если обозначить входы как a, b и c, то, после замен и упрощений, сетевая функция NET считается так:

double a2 = a*a;
double b2 = b*b;
double c2 = c*c;
double a3 = a2*a;
double b3 = b2*b;
double c3 = c2*c;
double z01 = sqrt(-1.6302e-02+7.9324e-01*a+9.65149e-01*b+5.64151e-01*c);
double z06 = sqrt(1.583708e+00-8.907654e-01*a-2.844379e-01*a2+1.050942e+00*a3+1.178096e+01*b-1.865618e+00*b*a-3.145465e+00*b*a2-5.777153e+00*b2+3.138123e+00*b2*a-1.043599e+00*b3+1.32778e+00*c+5.849582e-01*c*a-3.440382e+00*c*a2+1.838371e+00*c*b+6.864703e+00*c*b*a-3.42434e+00*c*b2-3.013361e-01*c2+3.754167e+00*c2*a-3.745404e+00*c2*b-1.365524e+00*c3+1.014237e-01*z01);
double NET = (-1.477593e+00)/(z06)+1.370237e+00-6.303167e-02*a-1.495051e-03*a2+2.33748e-02*a3+5.558024e-02*b+1.178189e-02*b*a-6.996071e-02*b*a2+1.837937e-02*b2+6.97974e-02*b2*a-2.321149e-02*b3+7.924241e-02*c+3.392287e-03*c*a-7.652018e-02*c*a2-1.214263e-02*c*b+1.526831e-01*c*b*a-7.616337e-02*c*b2-1.915279e-03*c2+8.349931e-02*c2*a-8.33044e-02*c2*b-3.037166e-02*c3+1.949161e-02*z01;

Выигрыш – повторюсь, 40% по времени, без особого ущерба качеству. Думаю, можно применить такой подход в случаях, когда скорость вычисления нейронной сети критична – например, если она вычисляется многократно, в двойном или тройном цикле. Пример такой задачи: численное решение задачи аэродинамики на сетке, причем в каждом ее узле нейронная сеть вычисляет какой-либо полезный прогноз, например, для более точного расчета турбулентной вязкости. Тогда имеем внешний цикл по времени, в него вложен двойной или тройной цикл по координате и уже там, внутри, сидит расчет нейронной сети. В таком случае упрощение более чем уместно и полезно.

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


  1. lostmsu
    04.03.2019 01:50

    Все недавние сети используют ReLU, который по сути max(0, x).


    1. pekunov Автор
      04.03.2019 01:58

      Сигмоиды тоже используются, например, в моей работе. Думаю, с их помощью, проще приближать сетью сложные многоэкстремальные данные — возможно, меньше нейронов требуется.


      1. lostmsu
        04.03.2019 02:00

        У вас state of the art в какой-то области?


        1. pekunov Автор
          04.03.2019 02:06

          Иногда занимаюсь ускорением личных научных расчетов в области аэродинамики и сжатием полученных данных, собственно, именно там нейронные сети и применяю.


          1. lostmsu
            04.03.2019 02:09

            Я имею в виду в плане нейронных сетей.
            В общем, если вы не пробовали глубокие сети с ReLU, то не понятно с чем вы спорите.


            1. Sychuan
              04.03.2019 10:28

              В общем, если вы не пробовали глубокие сети с ReLU, то не понятно с чем вы спорите.

              Почему вы думаете, что автор их не использовал? Почему вы думаете, что на всех задачах relu лучшая активация? Я вот немного забавлялся с CPPN и заметли, что по каокй-то причине такие сети с relu обучаются очень плохо, а наилучшие результаты дает tanh. Я конечно, дилетант, но думаю, что могут быть разные ситцации.


  1. barkalov
    04.03.2019 05:59

    … например, если она вычисляется многократно, в двойном или тройном цикле. Пример такой задачи: численное решение задачи аэродинамики на сетке...
    Неявно таким образом работает вообще любая сверточная сеть.


  1. rsashka
    04.03.2019 08:30

    без существенных потерь в качестве
    Делать такой вывод на основе сети из 3х8 нейронов мягко говоря сомнительно.
    Ведь вы же не оценивали качество и скорость обучения этой сети для реальных задач.
    Подозреваю, что с уменьшением (огрублением) точности при подобном способе оптимизации могут возникнуть проблемы с величиной ошибки при процедуре обучения большой сети.
    Такой эффект может быть незаметен для небольшого количенства данных, но для реального применения может не иметь смысла, т.к. потребует дополнительных эпох обучения для компенсации уменьшения точности расчета.


  1. emmibox
    04.03.2019 10:07

    А что вам мешает представить результат вычисления сигмоидальной функции в виде таблицы с любой желаемой точностью? Быстрее этого уже вряд ли выйдет.


    1. Trept
      04.03.2019 11:40

      В посте представлен лишь один из вариантов оптимизации. Возможно, есть и более быстрые/точные методы.


      1. emmibox
        04.03.2019 13:26

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

        А если нет ограничений по памяти — можно просто таблицу сделать огромного размера и убрать интерполяцию.


    1. pekunov Автор
      04.03.2019 15:27

      Определение нужного элемента в таблице все равно требует 3-5 операций процессора, это как минимум. И ещё — Вы, возможно не обратили внимания, после замены экспонент полученная функция символьно упрощается, это даёт дополнительный выигрыш, который одним табулированием экспоненты не получить.


      1. emmibox
        04.03.2019 16:48

        Да но это уже простые умножения-деления — в целом они быстрее, хотя конечно в определенных случаях могут стать даже медленнее. Но зато мы имеем строго запрограммированную потерю в качестве и не трогаем активационную функцию. А если вы меняете функцию — возникает вопрос, почему не на такую.


  1. malkovsky
    04.03.2019 16:07

    Можно попробовать немного пожертвовать качеством за счет выигрыша в скорости, приближенно заменив расчет классической активационной функции на расчет более простой функции, которая (на имеющихся входных данных) дает примерно те же результаты. Это, вообще говоря, классическая задача интерполяции: имеем набор значений, вычисленных исходной функцией A(s), и подбираем более простую функцию, которая дает очень похожие значения.


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

    Другое дело, что функция активации — одномерная, можно например делать приближение не по набору точек, а по всей вещественной прямой, что вроде как возможно. Но вроде как методологически вернее просто брать простую функцию уже на этапе обучения, как например уже писали про ReLU.


    1. pekunov Автор
      04.03.2019 23:02

      Но вроде как методологически вернее просто брать простую функцию уже на этапе обучения, как например уже писали про ReLU

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


      1. malkovsky
        05.03.2019 12:38

        Что меня смущает в подходе с ReLU, так это то, что нейросетевая функция, вообще говоря, превращается, если не ошибаюсь, в обычный полином от множества переменных.

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

        Ну вот вы сами об этой проблеме пишете. Зачем же было тогда от сигмоид возвращаться обратно к полиномам (пусть и с отрицательными степенями, степенями кратности 1/2)? Вообще из статьи не очевидны некоторые существенные детали, например если вы обучали и тестировали нейросеть на одних и тех же данных, тогда результат вообще не имеет никакой ценности.


        1. pekunov Автор
          05.03.2019 15:48

          Нейросеть на ReLU не может превратиться в полином хотя бы потому, что ReLU — недифференцируемая функция

          По-моему, как раз может. На линейных положительных участках ReLU имеет вид y = x. Тогда на выходе y нейрона первого слоя имеем y = сумма w1*x, на выходе нейрона второго слоя имеем z = сумма w2*y =сумма w2*(сумма w1*x). Чем не полином, если скобки раскрыть?
          Зачем же было тогда от сигмоид возвращаться обратно к полиномам (пусть и с отрицательными степенями, степенями кратности 1/2)

          В данном случае в итоге обычно получаем комбинации полиномов, обычно сводимые к дроби полином/полином. Это уже, возможно, немного лучше.
          если вы обучали и тестировали нейросеть на одних и тех же данных

          На разных.


  1. slovak
    04.03.2019 23:46

    мне потребовалось написать собственный код символического упрощения выражений

    Чем не устроили существующие решения? Или же просто было интересно написать свой Common subexpression elimination?


    1. pekunov Автор
      05.03.2019 00:42

      Да, было интересно написать и тщательно распараллелить, чтобы задача в-целом считалась как можно быстрее. Кстати, там чуть сложнее, чем просто вынесение common subexpressions — после всех подстановок упрощенных активационных функций в общую нейросетевую функцию обычно получается громоздкое алгебраическое выражение, которое последовательно приводится к дроби, где и числитель и знаменатель — полиномы (от множества переменных), и делается попытка, если возможно, символически поделить полином на полином.