Как можно упростить и ускорить вычисление нейронной сети прямого распространения
Здравствуйте, уважаемые читатели. О нейронных сетях написано и сказано очень много, преимущественно о том, как и для чего их можно применить. При этом как-то не очень много внимания уделяется двум важным вопросам: а) как нейронную сеть упростить и быстро вычислить (одно вычисление экспоненты реализуется библиотечными функциями языков программирования, обычно, не менее чем за 15–20 процессорных инструкций), б) какова, хотя бы отчасти, логика работы построенной сети — в самом деле, получаемые после обучении сети огромные матрицы значений весов и смещений как-то не очень помогают понять закономерности, которые эта сеть нашла (они остаются скрытыми и задача их определить — задача вербализации — иногда очень важна). Я расскажу об одном своем подходе к решению этих вопросов для обычных нейронных сетей прямого распространения, при этом постараюсь обойтись минимумом математики.
Немного теории
Сеть прямого распространения, с математической точки зрения — очень большая функция, в которую входят значения входов сети, весовых коэффициентов и смещений нейронов. В каждом нейроне слоя значения входов слоя (вектор X) умножаются на веса нейрона (вектор ), складываются со смещением
и поступают в активационные функции , формирующие выходы нейронов слоя.
Активационные функции могут быть не очень простыми для вычисления, например, часто содержат экспоненты (экспоненциальная сигмоида, гиперболический тангенс). Если заглянуть в ассемблерный код, реализующий экспоненты, то можно обнаружить, во-первых, множество различных проверок, которые не всегда нужны, во-вторых, само вычисление экспоненты обычно производится минимум за две операции:
.
Поэтому, если мы хотим ускорить расчет сети, то первой задачей будет упрощение расчета активационной функции. Можно попробовать немного пожертвовать качеством за счет выигрыша в скорости, приближенно заменив расчет классической активационной функции на расчет более простой функции, которая (на имеющихся входных данных) дает примерно те же результаты. Это, вообще говоря, классическая задача интерполяции: имеем набор значений, вычисленных исходной функцией A (s), и подбираем более простую функцию, которая дает очень похожие значения. Такой простой функцией a (s) может быть обычный полином, или полином с отрицательными степенями или еще что-то в таком роде. Я использовал четыре вида таких функций:
;
;
;
;
Предположим, что для каждого нейрончика нам удалось заменить активационную функцию на немного более простую — это можно сделать, например, применив метод наименьших квадратов. Сама по себе такая замена очень большого выигрыша, возможно, и не даст. Но тут можно попробовать еще один прием:
- Записать аналитически огромную функцию NET (X), вычисляемую сетью в-целом;
- Заменить в NET (X) исходные функции A (s) на полученные для них заменяющие функции a (s);
- Алгебраически полученную NET (X) упростить (вернее, воспользоваться каким-либо готовым программным кодом символического упрощения выражений). Это уже возможно (по крайней мере, намного проще, чем мы бы пытались упростить сеть с исходными функциями, например, с экспонентами).
В результате получаем нечто более простое и, возможно, немного более математически наглядное — здесь уже можно пытаться понять, какой вид имеет реализуемая сетью функция.
Это и есть вариант объяснения логики работы построенной сети.
Описанная задача, конечно, лишь на словах выглядит простой. Для применения в своих программах мне потребовалось написать собственный код символического упрощения выражений. Кроме того, я решал более сложную задачу, допустив, что у каждого нейрона с функцией A (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% по времени, без особого ущерба качеству. Думаю, можно применить такой подход в случаях, когда скорость вычисления нейронной сети критична — например, если она вычисляется многократно, в двойном или тройном цикле. Пример такой задачи: численное решение задачи аэродинамики на сетке, причем в каждом ее узле нейронная сеть вычисляет какой-либо полезный прогноз, например, для более точного расчета турбулентной вязкости. Тогда имеем внешний цикл по времени, в него вложен двойной или тройной цикл по координате и уже там, внутри, сидит расчет нейронной сети. В таком случае упрощение более чем уместно и полезно.