Это tutorial по библиотеке TensorFlow. Рассмотрим её немного глубже, чем в статьях про распознавание рукописных цифр. Это tutorial по методам оптимизации. Совсем без математики здесь не обойтись. Ничего страшного, если вы её совершенно забыли. Вспомним. Не будет никаких формальных доказательств и сложных выводов, только необходимый минимум для интуитивного понимания. Для начала небольшая предыстория о том, чем этот алгоритм может быть полезен при оптимизации нейронной сети. Полгода назад друг попросил показать, как на Python сделать нейросеть. Его компания выпускает приборы для геофизических измерений. Несколько различных зондов в процессе бурения измеряют набор сигналов, связаных с параметрами окружающей скважину среды. В некоторых сложных случаях точно вычислить параметры среды по сигналам долго даже на мощном компьютере, а необходимо интерпретировать результаты измерений в полевых условиях. Возникла идея посчитать на кластере несколько сот тысяч случаев, и на них натренировать нейронную сеть. Так как нейросеть работает очень быстро, её можно использовать для определения параметров, согласующихся с измеренными сигналами, прямо в процессе бурения. Детали есть в статье: Kushnir, D., Velker, N., Bondarenko, A., Dyatlov, G., & Dashevsky, Y. (2018, October 29). Real-Time Simulation of Deep Azimuthal Resistivity Tool in 2D Fault Model Using Neural Networks (Russian). Society of Petroleum Engineers. doi:10.2118/192573-RU Одним вечером я показал, как keras реализовать простую нейронную сеть, и друг на работе запустил обучение на насчитанных данных. Через пару дней обсудили результат. С моей точки зрения он выглядел перспективно, но друг сказал, что нужны вычисления с точностью прибора. И если средняя квадратичная ошибка (mean squared error) получилась в районе 1, то нужна была 1е-3. На 3 порядка меньше. В тысячу раз.  Эксперименты с архитектурой нейронной сети, нормализацией данных, подходами к оптимизации не дали почти ничего. Через пару недель друг позвонил и сказал, что он установил MatLab и решил проблему методом Левенберга-Марквардта (далее будем называть LM). Оптимизировалось долго (несколько дней), не работало на GPU, но результат получился нужный. Это звучало как вызов.  Беглый поиск готового оптимизатора LM для keras или TensorFlow не дал результата. Наткнулся только на библиотеку pyrenn, но её функционал показался мне бедным. Решил реализовать самостоятельно. На первый взгляд выглядело всё просто, и двух вечеров должно было хватить. Провозился дольше. Проблемы оказалось две: В итоге написал статью для забывших математику и желающих разобраться в TensorFlow чуть глубже, но без хардкора. В статье много текста и мало кода. Обратный вариант, когда мало текста и много кода, есть здесь Jupyter Notebook Levenberg-Marquardt. Тренировочные данные будем генерировать функцией Розенброка, которую часто используют в качестве эталонного теста алгоритмов оптимизации:   Чем же она хороша? С неё и начнём писать код подключив необходимые для дальнейшей работы библиотеки: Раз уж мы говорили об измерительном приборе, давайте дальше использовать аналогию. Наш прибор в вымышленном мире умеет измерять координаты  и высоту . Физики изучили мир и заявили: "Да это же Розенброк! Зная координаты, можно точно вычислить высоту, вам не надо её измерять.". Иными словами, учёные дали нам модель , которая зависит от параметров . Эти параметры хоть и постоянны в вымышленном мире, но неизвестны. Их нужно найти. Мы провели ряд экспериментов, которые дали  точек : Первый способ оптимизации — попытаться угадать параметры. Воспользуемся библиотекой Numpy: Как понять, насколько мы ошиблись? Посчитать невязки (residuals) — размеры ошибок.  точек дают  невязок — нужен интегральный показатель. Возведём каждую невязку в квадрат и посчитаем среднее:   Такая мера близости называется средняя квадратичная ошибка (mean squared error, далее будем называть mse): Минимизируя mse, мы решаем задачу о наименьших квадратах (nonlinear squares minimization): Видно, что параметры не угадали совершенно. Модель имеет вид . Приведём её к виду  (обычно математики пишут  вместо , но программисты не используют бету). Теперь модель имеет вид , где  — высота,  — вектор координат из двух элементов (компонент), а  — вектор параметров.  Программисты часто думают о векторах, как об одномерных массивах. Это не совсем корректно. Массив чисел — это средство представления вектора. Представить вектор можно массивом размерностью , двухмерным массивом , и даже массивом  в тех случаях, когда важен факт, что вектор — это вектор-столбец (например, для умножения матрицы на него):   В TensorFlow используется понятие тензор. Тензор, как и массив, может быть одномерным (для представления вектора), двухмерным (для матрицы или вектора-столбца) и любой большей размерности. Код на TensorFlow по форме не отличается от кода на Numpy. По содержанию отличия огромные. Код на Numpy производит вычисление значения mse. Код на TensorFlow не производит никаких вычислений вообще, он формирует граф потока данных (dataflow graph), который может вычислить mse. Очень выносящий мозг момент — работа функции rosenbrock. Мы её используем в обоих случаях. Но когда передаём массивы Numpy, она производит вычисления по формуле и возвращает числа. А когда передаём тензоры TensorFlow, формирует подграф потока данных и возвращает его ребро (edge) в виде тензора. Чудеса полиморфизма, но не стоит ими злоупотреблять: Благодаря наличию такого графа потока данных, TensorFlow в частности умеет вычислять производные автоматически (техникой reverse mode automatic differentiation).  Минутка математики. Блоки "для тех, кто забыл" буду прятать в спойлер. Скорее всего вы помните определение производной скалярной (возвращающей число) функции одной переменной: для , производная  в точке  определяется как:   Производные — это способ измерения изменений. В скалярном случае производная показывает, насколько изменится функция , если  изменится на небольшое значение :   Для удобства в дальнейшем обозначим , а производную  по  запишем как . Такая запись подчёркивает, что  — скорость изменения между переменными  и . Конкретнее, если  изменится на , то  изменится приблизительно на . Ещё можно записать это так:   Читается как: "меняя  на  меняем  на приблизительно ". Такая запись наглядно подчёркивает связь между изменением  и изменением . Мы построили граф потока данных, давайте запустим вычисление mse: Получился тот же результат, что и с Numpy. Значит не ошиблись. К сожалению угадать параметры не получилось. Но зато мы: На прошлом шаге мы сконструировали граф потока данных с конечным тензором loss (функция потерь). Цель оптимизации — найти значение вектора параметров , при котором значение функции потерь минимальное. Нам повезло, график этой функции очень простой (вогнутый и без локальных минимумов): Приступаем к оптимизации. Для начала напишем обобщённый цикл: Действия этого метода можно сравнить с ездой дерзкого горнолыжника, который всегда ставит лыжи по линии падения склона (в самом крутом направлении вниз). При этом учитывает только уклон в точке нахождения. И если уклон сильный, то до следующего перестроения лыжник пролетает большое расстояние. При слабом же уклоне движется маленькими шагами. Может как улететь в дерево (алгоритм расходится), так и застрять в яме (локальном минимуме). Записать можно так (меняем  на ):   Жирное  подчёркивает, что это точка фактического нахождения — значение вектора параметров на текущем шаге. На первом шаге это наша догадка (5, 5). В формуле есть два интересных момента:  — скорость обучения (learning rate),  — градиент (gradient) функции потерь по вектору параметров. Рассмотрим функцию, которая принимает на вход вектор, а выдаёт скаляр: . Производная  в точке  теперь называется градиентом и является вектором  (читается как "на́бла"), составленным из частных производных (partial derivatives):   Для этого случая запись зависимости изменения функции от изменения аргумента имеет такой вид:   Запись изменилась совсем немного, чтобы учесть, что ,  и  — векторы в , а  — скаляр. При умножении векторов  и  используется скалярное произведение (сумма произведений компонент). Потребовалось 582 шага: Почему движемся в обратном градиенту направлении? Вспомним запись со скалярным произведением: . Минимизируем . Так как известно поведение функции только в малой окрестности через производную, надо двигаться мелкими, но оптимальными шагами, минимизиуя произведение . По школьному определению скалярным произведением двух векторов называется число, равное произведению длин этих векторов на косинус угла между ними: . При фиксированной длине векторов это произведение достигает минимума при косинусе равном -1, т.е. при угле 180 градусов, когда вектора направлены в противоположные стороны. Соответственно минимум скалярного произведения  достигается при  в направлении антиградиента. Не будем дальше углубляться в градиентные методы, но вариаций есть масса. Почитать про них можно в статье Методы оптимизации нейронных сетей. В TensorFlow многие оптимизаторы уже реализованы. Например, Adam: Справились за 317 шагов. Гораздо быстрее. Действия методов второго порядка можно сравнить с ездой разумного сноубордиста-фрирайдера, который долго обдумывает следующую точку своего маршрута и учитывает не только уклон в точке нахождения, но и кривизну. Фактически, и методы градиентного спуска, и методы второго порядка пытаются угадать (аппроксимировать) функцию по текущей точке. Градиентные методы ориентируются только на уклон (slope) графика функции в точке — первую производную. Методы же второго порядка кроме уклона учитывают и кривизну (curvature) — вторую производную: "если кривизна сохранится, то где будет минимум?". Вычисляем и направляемся туда: Построить такую аппроксимацию и вычислить предполагаемую точку минимума можно с помощью ряда Тейлора (Taylor series). Для одномерного случая аппроксимация полиномом второго порядка в точке  выглядит так:   Минимум достигается при . Многомерный случай выглядит более серьёзно: Матрица Гессе (Hessian matrix) — это квадратная матрица, составленная из вторых производных:   Аппроксимация полиномом второго порядка для функции от вектора через градиент и матрицу Гессе в точке  выглядит так:   Минимум достигается при . Форма практически совпадает с одномерным случаем: заменили первую производную на градиент, вторую на матрицу Гессе и сделали поправку на работу с векторами. Делить вектор на матрицу нельзя, поэтому используется умножение на обратную (inverse) матрицу. Т означает транспонирование (transpose). В формуле подразумевается, что по умолчанию вектор — это столбец. Транспонирование превращает вектор-столбец в вектор-строку. При реализации на TensorFlow надо это учитывать, но в обратную сторону: по умолчанию вектор — это строка (одномерный тензор). На всякий случай: транспонирование — это не поворот на 90 градусов, это превращение строк в столбцы в том же порядке. Итак, шаг метода Ньютона имеет следующий вид:   В TensorFlow есть всё для реализации этого метода: Хватило 6 шагов: В методе Ньютона есть один недостаток — матрица Гессе. Благодаря TensorFlow мы можем посчитать её в одну строку кода. Согласно wiki, первое упоминание о своём методе Иоганн Карл Фридрих Гаусс сделал в 1809 году. Вычисление матрицы Гессе для нескольких параметров для метода наименьших квадратов тогда могло занять кучу времени. Сейчас можно считать, что алгоритм Гаусса-Ньютона использует аппроксимацию матрицы Гессе через матрицу Якоби, чтобы упростить вычисления. Но с точки зрения истории это не так: Людвиг Отто Гессе (который разработал матрицу, названную в честь него) родился 1811 году — через 2 года после первого упоминания алгоритма. А Карлу Густаву Якоби было 5 лет. Алгоритм Гаусса-Ньютона не работает с функцией потерь. Он работает с функцией невязок . Эта функция принимает на вход вектор параметров  и возвращает вектор невязок (residuals). В нашем случае, вектор  состоит из 2 компонент (параметры  и  функции Розенброка), а вектор невязок из  компонент (по числу экспериментов). Получается векторная функция векторного аргумента. Её производная: Рассмотрим функцию, которая на вход принимает вектор и выдаёт тоже вектор: . Производная  в точке  теперь имеет размер , называется матрицей Якоби, и состоит из всех комбинаций частных производных:   Можно заметить, что строками матрицы Якоби являются градиенты компонентов . Элемент  матрицы  равен  и говорит нам, насколько изменится , при изменении  на небольшое значение. Как и в предыдущих случаях можно записать:   Здесь  матрица , а  вектор размера , таким образом произведение  — это произведение матрицы на вектор, в результате которого получается вектор размера . Чтобы не путаться в обилии символов, будем считать, что  — матрица Якоби функции невязок в текущей точке . Тогда алгоритм Гаусса-Ньютона можно записать так:   Запись по форме полностью совпадает с записью метода Ньютона. Только вместо матрицы Гессе используется , а вместо градиента . Далее посмотрим, почему можно использовать такую аппроксимацию. Пока же приступим к реализации на TensorFlow: Хватило 4х шагов. Меньше чем для метода Ньютона. Как видно из кода, функция потерь не используется при оптимизации, только для критерия остановки и логирования. Как же оптимизационный алгоритм узнаёт, какую функцию минимизировать? Ответ удивителен: никак! Гаусс-Ньютон минимизирует только mean squared error. Мы повторили всю необходимую нам математику. Давайте немного закрепим, чтобы дальше сконцентрироваться только на программировании и TensorFlow. Возможно вам понадобится карандаш, чтобы проследить последовательность математических действий.  Есть модель , где  — вектор,  — вектор параметров размерности , а  — скаляр. Из экспериментов получили  точек  (data pairs). Векторная функция невязок зависит только от вектора параметров: , где . Может возникнуть вопрос, почему функция невязок зависит только от , ведь в формуле есть и ? Дело в том, что  уже известны из экспериментов и зафиксированы, и только вектор параметров является переменным. Надо найти такое значение вектора параметров , чтобы сумма квадратов невязок (sum of squared error — sse или residual sum-of-squares — rss) была минимальная. Вместо привычной нам mse мы будем использовать sse, чтобы не таскать везде деление на . Эти две функции достигают своего минимума на одном и том же векторе параметров. Наша функция потерь имеет вид:   Далее для простоты нигде не будем писать  в скобочках .  Чтобы воспользоваться методом Ньютона, нам нужен градиент этой функции и матрица Гессе. Градиент — это вектор частных производных. Производная суммы — это сумма производных, а производная квадрата функции  равна . Таким образом получаем:   Матрица Гессе состоит из всех комбинаций вторых производных. Ячейка матрицы Гессе имеет такой вид:   Матрица эта диагонально симметричная. При её вычислении мы воспользовались теми же правилами, что и при вычислении градиента, плюс школьной формулой .
Отлично! Мы можем воспользоваться методом Ньютона. Заметим, что в формуле для матрицы Гессе есть часть, которая уменьшается по мере приближения к минимуму, — слагаемое . Чем ближе к минимуму, тем меньше невязка, тем меньше , а следовательно и всё произведение. И именно этот компонент доставляет больше всего вычислительных неприятностей — он содержит настоящую вторую производную. А что будет, если этот компонент откинуть? Мы получим алгоритм Гаусса-Ньютона.  Для него нужна матрица Якоби:   Согласитесь, она выглядит гораздо проще, чем градиент и матрица Гессе. Заметим, что:   Заметить это "глазами" не так уж и просто. Лучше взять карандаш и начать производить матричное умножение (строка умножается на столбец). Тогда будет лучше видно, что результат — это выражение для матрицы Гессе без компонента , который мы и хотели откинуть.
С градиентом ещё проще (надо умножить матрицу на вектор):   Таким образом, у нас сошлось утверждение, что алгоритм Гаусса-Ньютона — это метод Ньютона с аппроксимацией матрицы Гессе через матрицу Якоби, который работает только с функцией потерь mse. Вымышленный прибор измерял координаты и высоту. Мы решили упростить железо, измерять только координаты, а высоту вычислять. Провели  экспериментов , физики дали модель . Разными методами оптимизации мы успешно нашли значение вектора параметров , при котором модель работает достаточно точно.  А теперь представьте, что учёные отказались давать модель: "Мы совершенно не представляем физику вашего вымышленного мира. Давайте как-нибудь сами!". В ситуациях, когда недостаточно знаний о природе вещей, или эта природа слишком сложная, можно попробовать решить задачу техникой машинного обучения (supervised learning). Например, с помощью нейронной сети. Такое решение имеет свою цену: данные (training set) — их надо много; сложность интерпретации — не всегда понятно почему модель (prediction model) работает или не работает; проблемы экстраполяции — модель стабильно работает только на данных из того же распределения, что и обучающая выборка.  В статье будем использовать многослойный перцептрон (multi-layer perceptron neural network или mlp). Есть аспекты, которые не будем учитывать в статье, но которые необходимо учитывать в реальной работе: В прошлый раз хватило 9 экспериментов. В этот раз проведём 500: Из экспериментов получили 500 точек. Наша задача — ничего не зная о функции Розенброка обучить модель (learner), которая будет прогнозировать высоту (outcome measurement) по координатам (features) для новых ещё неизвестных данных. Часто структуру перцептрона рисуют в виде кружочков со стрелочками (network diagram). Мне больше нравится такой вариант визуализации от MatLab: Вектор из двух компонент подаётся на вход (input). Он умножается на матрицу весов  (weights) размера 2x10, к результату прибавляется вектор смещения  (bias) размера 10, и результат передаётся нелинейной функции активации (activation). Так получается первый (единственный) скрытый уровень (hidden layer) из 10 нейронов. Во втором блоке к нему применяется другая матрица весов, другой вектор смещения, и уже без нелинейной функции получается скалярный результат (output). Самая удобная запись, на мой взгляд, такая (будем использовать ):   Или подробнее:   Здесь используются матричные операции. Размерность  однозначно определяется размерностью входного вектора и желаемой "шириной" скрытого уровня , а из неё однозначно определяется размерность вектора-столбца . Получили 41 параметр. Регулируя количество скрытых слоёв и их ширину, можно изменять количество параметров. Если входной вектор заменить на матрицу , то за один проход можно вычислить прогнозы для всех точек. Результатом буде вектор-столбец  из  компонентов: Обучение нейронной сети оптимизатором Adam ничем не отличается от поиска параметров для модели . Добавим подсчёт mse для валидационной выборки в конце: У вас могут получиться совершенно другие числа. Они во многом зависят от удачи: случайная инициализация весов, тренировочных данных, данных для валидации. Для модели  матрица Якоби вычислялась в 2 строки кода. Теперь ситуация сильно усложнилась: Соответственно реализация усложнилась тоже: Надо вычислить  матрицу Якоби функции невязок по вектору параметров. Но у нас нет единого вектора параметров, а есть 4 тензора . Считаем 4 матрицы  и объединяем в одну функцией tf.concat. Матрица Якоби состоит из градиентов каждого компонента функции невязок. tf.while_loop организует подграф потока данных, который пробегает по каждому компоненту , вычисляет градиент и складывает его в массив тензоров, который потом объединяется в матрицу методом stack. Градиент компонента  по тензору  выглядит так: . Функцией tf.reshape с параметром (-1,) он разворачивается в строку . Такое вычисление матрицы Якоби требует много дополнительных действий. Эффективней было бы сначала все параметры объединить в один длинный вектор-строку и считать матрицу по нему. Так сделать нельзя — TensorFlow для вычисления градиента требует маршрут в графе потока данных от параметра к функции. Другое решение — завести тензор-переменную в виде длинного вектора-строки и из него сконструировать . Тогда функция невязок будет зависеть от этого вектора-строки. Решение можно посмотреть здесь Levenberg-Marquardt Jupyter Notebook и здесь rosenbrock_train.py. Но даже такая реализация не очень быстрая, и зачастую TensorFlow считает реальную матрицу Гессе быстрее её аппроксимации. Если кто-то знает, как вычислить матрицу Якоби значительно (в разы) быстрее, пожалуйста, подскажите. Имея hess_approx и grad_approx можно запустить обучение нейронной сети методом Гаусса-Ньютона. Единственное отличие от оптимизации модели  заключается в том, что у нас вместо одного вектора параметров есть несколько тензоров параметров разной формы и размера. Нужны дополнительные действия: Что-то пошло не так. Сначала значение функции потерь резко выросло, а потом вообще всё сломалось с исключением. Метод Гаусса-Ньютона работает плохо, когда параметры очень далеки от оптимальных.  На этом шаге нас постигла неудача, но мы всё сделанное пригодится в следующем разделе. Подошли непосредственно к теме статьи. Самое понятное и полное описание алгоритма находится на портале Matlab руководство по функции trainlm. Все остальные статьи пытаются запутать неподготовленного читателя. Будем придерживаться терминологии компании MathWorks. Мы записывали алгоритм Гаусса-Ньютона так: . Метод Леверберга-Марквадта отличается одной маленькой деталью:   Скалярная переменная  умножается на диагональную единичную матрицу  размерности  (общее число параметров). Если число  равно нулю, то шаг полностью совпадает с шагом Гаусса-Ньютона. Если число большое, то получаем градиентный спуск. Можно сказать, что LM колеблется между градиентным спуском и алгоритмом Гаусса-Ньютона. Код практически совпадает с кодом из предыдущей части: Как же вычислить подходящее значение ? Алгоритм LM стремится перейти к методу Гаусса-Ньютона как можно быстрее. Если не получается, то переходит к градиентному спуску. Фактически, после каждого удачного шага оптимизации  уменьшается, а после каждой неудачи увеличивается. Ещё одна особенность алгоритма — производятся только шаги, которые действительно уменьшают значение mse. С деталями алгоритма проще разобраться по коду, он довольно простой: За 100 шагов LM получили mse более чем в 10 раз меньше, чем за 40К шагов Адама. Магические константы в реализации можно сделать настраиваемыми. Также можно заметить, что при каждой неудаче происходит полный пересчёт матрицы Якоби. Это неэффективно, более эффективный вариант реализации можно посмотреть здесь rosenbrock_train.py. Мы работали с 2D вариантом функции Розенброка. Работающая нейронная сеть для таких случаев не очень впечатляет. Интереснее становится с возрастанием числа измерений. В многомерном случае мы не можем достаточно плотно заполнить пространство, и нас начинает преследовать "проклятие размерности" (curse of dimentionality, Bellman, 1961). Интересная статья про многомерность Теория счастья. Закон арбузной корки и нормальность ненормальности.  Многомерный вариант функции Розенброка выглядит так:   Реализация есть здесь rosenbrock_train.py в функции get_rand_rosenbrock_points. Всю статью мы делали как-то так: "Вау! Алгоритм справился за 4 шага, а градиентный спуск за 300!". На самом деле, число шагов (эпох) интересует только редких математиков-теоретиков. Нас, инженеров, интересует время. Шаг Левенберга-Марквадта дороже шага Адама. Намного дороже. Что выгоднее: сделать много дешёвых шагов или мало дорогих? Надо заметить, что мы решали очень простую задачу на малом объёме данных. Так себе тест. Реально сложную задачу решать на своём домашнем компьютере не хотелось, но тест средней серьёзности я всё-таки провёл: Оптимизаторы работали на одних и тех же тренировочных данных и начинали с одинаковых параметров. Градиентный спуск побеждал алгоритм Левенберга-Марквадта в течение 2 часов. Потом для интереса запустил вариант LM с настоящей матрицей Гессе и настоящим градиентом. Он победил градиентный спуск за 20 минут. Код теста здесь rosenbrock_train.py. Это консольное приложение. Через параметры можно настраивать объём и размерность входных данных, структуру нейронной сети и свойства оптимизаторов. Как оказалось, я могу писать статьи только методом градиентного спуска. Сначала "набрасываю лопатой", потом сотни раз прочитываю, вношу небольшие дополнения и правки. Наверняка результат получился далёкий от оптимального, и осталось много неточностей и ошибок. Но я старался, а счётчик показывает 273 шага. Если что-то непонятно, то буду рад ответить на вопросы и помочь. Хотелось бы реализовать, но не успел: Конечно, алгоритм Левенберга-Марквадта не подходит для всех ситуаций. Если количество параметров больше нескольких тысяч, то скорее всего он будет бесполезен. Но в некоторых ситуациях он "решает". программист, аналитик данных