Здравствуйте, уважаемые читатели. О нейронных сетях написано и сказано очень много, преимущественно о том, как и для чего их можно применить. При этом как-то не очень много внимания уделяется двум важным вопросам: а) как нейронную сеть упростить и быстро вычислить (одно вычисление экспоненты реализуется библиотечными функциями языков программирования, обычно, не менее чем за 15-20 процессорных инструкций), б) какова, хотя бы отчасти, логика работы построенной сети – в самом деле, получаемые после обучении сети огромные матрицы значений весов и смещений как-то не очень помогают понять закономерности, которые эта сеть нашла (они остаются скрытыми и задача их определить – задача вербализации – иногда очень важна). Я расскажу об одном своем подходе к решению этих вопросов для обычных нейронных сетей прямого распространения, при этом постараюсь обойтись минимумом математики.
Сеть прямого распространения, с математической точки зрения – очень большая функция, в которую входят значения входов сети, весовых коэффициентов и смещений нейронов. В каждом нейроне слоя значения входов слоя (вектор X) умножаются на веса нейрона (вектор
), складываются со смещением ![$B_i$](https://habrastorage.org/getpro/habr/formulas/06e/f31/8fc/06ef318fc72fe3265cd4058e0950debf.svg)
и поступают в активационные функции
, формирующие выходы нейронов слоя.
Активационные функции могут быть не очень простыми для вычисления, например, часто содержат экспоненты (экспоненциальная сигмоида, гиперболический тангенс). Если заглянуть в ассемблерный код, реализующий экспоненты, то можно обнаружить, во-первых, множество различных проверок, которые не всегда нужны, во-вторых, само вычисление экспоненты обычно производится минимум за две операции:
Поэтому, если мы хотим ускорить расчет сети, то первой задачей будет упрощение расчета активационной функции. Можно попробовать немного пожертвовать качеством за счет выигрыша в скорости, приближенно заменив расчет классической активационной функции на расчет более простой функции, которая (на имеющихся входных данных) дает примерно те же результаты. Это, вообще говоря, классическая задача интерполяции: имеем набор значений, вычисленных исходной функцией A(s), и подбираем более простую функцию, которая дает очень похожие значения. Такой простой функцией a(s) может быть обычный полином, или полином с отрицательными степенями или еще что-то в таком роде. Я использовал четыре вида таких функций:
;
;
;
;
Предположим, что для каждого нейрончика нам удалось заменить активационную функцию на немного более простую – это можно сделать, например, применив метод наименьших квадратов. Сама по себе такая замена очень большого выигрыша, возможно, и не даст. Но тут можно попробовать еще один прием:
В результате получаем нечто более простое и, возможно, немного более математически наглядное – здесь уже можно пытаться понять, какой вид имеет реализуемая сетью функция.
Это и есть вариант объяснения логики работы построенной сети.
Описанная задача, конечно, лишь на словах выглядит простой. Для применения в своих программах мне потребовалось написать собственный код символического упрощения выражений. Кроме того, я решал более сложную задачу, допустив, что у каждого нейрона с функцией A(s) может быть несколько вариантов альтернативной активационной функции
, поэтому общая задача свелась еще и к перебору вариантов таких функций и символическому упрощению сети для каждого такого варианта. Тут уже помогло только распараллеливание вычислений.
Результат меня порадовал. Я ускорял трехслойную сеть (с тремя входами) из восьми нейронов (с входными весами и смещениями) с активационными функциями «экспоненциальная сигмоида». Как показали замеры времени, удалось получить выигрыш примерно в 40% по времени без существенных потерь в качестве.
Иллюстрирую. Вот данные исходной сети:
![](https://habrastorage.org/r/w1560/webt/fs/9b/fh/fs9bfh-pby7ugyd_vwnkc9mlvt8.png)
![](https://habrastorage.org/r/w1560/webt/ky/j-/wu/kyj-wu-hmbtreqoi8ngqxniedzm.png)
И в третьем, выходном слое:
![](https://habrastorage.org/r/w1560/webt/4g/uc/ud/4gucud_k7puheqscdocjswtdtnu.png)
Если обозначить входы как a, b и c, то, после замен и упрощений, сетевая функция NET считается так:
Выигрыш – повторюсь, 40% по времени, без особого ущерба качеству. Думаю, можно применить такой подход в случаях, когда скорость вычисления нейронной сети критична – например, если она вычисляется многократно, в двойном или тройном цикле. Пример такой задачи: численное решение задачи аэродинамики на сетке, причем в каждом ее узле нейронная сеть вычисляет какой-либо полезный прогноз, например, для более точного расчета турбулентной вязкости. Тогда имеем внешний цикл по времени, в него вложен двойной или тройной цикл по координате и уже там, внутри, сидит расчет нейронной сети. В таком случае упрощение более чем уместно и полезно.
Немного теории
Сеть прямого распространения, с математической точки зрения – очень большая функция, в которую входят значения входов сети, весовых коэффициентов и смещений нейронов. В каждом нейроне слоя значения входов слоя (вектор X) умножаются на веса нейрона (вектор
и поступают в активационные функции
Активационные функции могут быть не очень простыми для вычисления, например, часто содержат экспоненты (экспоненциальная сигмоида, гиперболический тангенс). Если заглянуть в ассемблерный код, реализующий экспоненты, то можно обнаружить, во-первых, множество различных проверок, которые не всегда нужны, во-вторых, само вычисление экспоненты обычно производится минимум за две операции:
Поэтому, если мы хотим ускорить расчет сети, то первой задачей будет упрощение расчета активационной функции. Можно попробовать немного пожертвовать качеством за счет выигрыша в скорости, приближенно заменив расчет классической активационной функции на расчет более простой функции, которая (на имеющихся входных данных) дает примерно те же результаты. Это, вообще говоря, классическая задача интерполяции: имеем набор значений, вычисленных исходной функцией A(s), и подбираем более простую функцию, которая дает очень похожие значения. Такой простой функцией a(s) может быть обычный полином, или полином с отрицательными степенями или еще что-то в таком роде. Я использовал четыре вида таких функций:
Предположим, что для каждого нейрончика нам удалось заменить активационную функцию на немного более простую – это можно сделать, например, применив метод наименьших квадратов. Сама по себе такая замена очень большого выигрыша, возможно, и не даст. Но тут можно попробовать еще один прием:
- Записать аналитически огромную функцию NET(X), вычисляемую сетью в-целом;
- Заменить в NET(X) исходные функции A(s) на полученные для них заменяющие функции a(s);
- Алгебраически полученную NET(X) упростить (вернее, воспользоваться каким-либо готовым программным кодом символического упрощения выражений). Это уже возможно (по крайней мере, намного проще, чем мы бы пытались упростить сеть с исходными функциями, например, с экспонентами).
В результате получаем нечто более простое и, возможно, немного более математически наглядное – здесь уже можно пытаться понять, какой вид имеет реализуемая сетью функция.
Это и есть вариант объяснения логики работы построенной сети.
Описанная задача, конечно, лишь на словах выглядит простой. Для применения в своих программах мне потребовалось написать собственный код символического упрощения выражений. Кроме того, я решал более сложную задачу, допустив, что у каждого нейрона с функцией A(s) может быть несколько вариантов альтернативной активационной функции
Результат
Результат меня порадовал. Я ускорял трехслойную сеть (с тремя входами) из восьми нейронов (с входными весами и смещениями) с активационными функциями «экспоненциальная сигмоида». Как показали замеры времени, удалось получить выигрыш примерно в 40% по времени без существенных потерь в качестве.
Иллюстрирую. Вот данные исходной сети:
![](https://habrastorage.org/webt/fs/9b/fh/fs9bfh-pby7ugyd_vwnkc9mlvt8.png)
![](https://habrastorage.org/webt/ky/j-/wu/kyj-wu-hmbtreqoi8ngqxniedzm.png)
И в третьем, выходном слое:
![](https://habrastorage.org/webt/4g/uc/ud/4gucud_k7puheqscdocjswtdtnu.png)
Если обозначить входы как 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% по времени, без особого ущерба качеству. Думаю, можно применить такой подход в случаях, когда скорость вычисления нейронной сети критична – например, если она вычисляется многократно, в двойном или тройном цикле. Пример такой задачи: численное решение задачи аэродинамики на сетке, причем в каждом ее узле нейронная сеть вычисляет какой-либо полезный прогноз, например, для более точного расчета турбулентной вязкости. Тогда имеем внешний цикл по времени, в него вложен двойной или тройной цикл по координате и уже там, внутри, сидит расчет нейронной сети. В таком случае упрощение более чем уместно и полезно.