Русский
Русский
English
Статистика
Реклама

Градиентный спуск

Динамическая балансировка нагрузки в pull-схеме

01.09.2020 22:17:56 | Автор: admin
В прошлой новости про принципы работы коллекторов логов PostgreSQL я упомянул, что одним из недостатков pull-модели является необходимость динамической балансировки нагрузки. Но если делать ее аккуратно, то недостаток превращается в достоинство, а система в целом становится гораздо более устойчивой к изменениям потока данных.


Давайте посмотрим, какие решения есть у этой задачи.

Распределение объектов по мощности


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

Равномощные объекты мониторинга


В качестве примера можно привести наши коллекторы метрик для Zabbix, которые исторически имеют с коллекторами логов PostgreSQL общую архитектуру.

И правда, каждый объект мониторинга (хост) генерирует для zabbix практически стабильно один и тот набор метрик с одной и той же частотой все время:


Как видно на графике, разница между min-max значениями количества генерируемых метрик не превышает 15%. Поэтому мы можем считать все объекты равными в одинаковых попугаях.

Сильный дисбаланс между объектами


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

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


Ну, а раз нагрузка может меняться настолько сильно, то надо учиться ей управлять

Координатор


Сразу понимаем, что нам явно понадобится масштабирование системы коллекторов, поскольку один отдельный узел со всей нагрузкой когда-то точно перестанет справляться. А для этого нам потребуется координатор тот, кто будет управлять всем зоопарком.

Получается примерно такая схема:


Каждый worker свою нагрузку в попугаях и в процентах CPU периодически сбрасывает master'у, те коллектору. А он, на основании этих данных, может выдать команду типа новый хост посадить на ненагруженный worker#4 или hostA надо пересадить на worker#3.

Тут еще надо помнить, что, в отличие от объектов мониторинга, сами коллекторы имеют вовсе не равную мощность например, на одном у вас может оказаться 8 ядер CPU, а на другом только 4, да еще и меньшей частоты. И если нагрузить их задачами поровну, то второй начнет затыкаться, а первый простаивать. Отсюда и вытекают

Задачи координатора


По сути, задача всего одна обеспечивать максимально равномерное распределение всей нагрузки (в %cpu) по всем доступным worker'ам. Если мы сможем решить ее идеально, то и равномерность распределения %cpu-нагрузки по коллекторам получим автоматом.

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

Динамическая балансировка


Простую задачу (zabbix) мы можем решить достаточно банально:

  • вычисляем относительную мощность каждого коллектора в задачах
  • делим все задачи между ними пропорционально
  • между worker'ами распределяем равномерно



Но что делать в случае сильно неравных объектов, как для коллектора логов?..

Оценка равномерности


Выше мы все время употребляли термин "максимально равномерное распределение", а как вообще можно формально сравнить два распределения, какое из них равномернее?

Для оценки равномерности в математике давно существует такая вещь как среднеквадратичное отклонение. Кому лениво вчитываться:
S[X] = sqrt( sum[ ( x - avg[X] ) ^ 2 of X ] / count[X] )

Поскольку количество worker'ов на каждом из коллекторов у нас тоже может отличаться, то нормировать разброс по нагрузке надо не только между ними, но и между коллекторами в целом.

То есть распределение нагрузки по worker'ам двух коллекторов [ (10%, 10%, 10%, 10%, 10%, 10%) ; (20%) ] это тоже не очень хорошо, поскольку на первом получается 10%, а на втором 20%, что как бы вдвое больше в относительных величинах.

Поэтому введем единую метрику-расстояние для общей оценки равномерности:
d([%wrk], [%col]) = sqrt( S[%wrk] ^ 2 + S[%col] ^ 2 )
То есть величины среднеквадратичного отклонения для наборов величин нагрузки по всем worker'ам и по всем коллекторам воспринимаем как координаты вектора, длину которого будем стараться минимизировать.

Моделирование


Если бы объектов у нас было немного, то мы могли бы полным перебором разложить их между worker'ами так, чтобы метрика оказалась минимальной. Но объектов у нас тысячи, поэтому такой способ не подойдет. Зато мы знаем, что коллектор умеет перемещать объект с одного worker'а на другой давайте этот вариант и смоделируем, используя метод градиентного спуска.

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

То есть нам осталось всего лишь определить, какой объект и на какой worker эффективнее всего переместить. И сделаем это банальным переборным моделированием:

  • для каждой пары (целевой host, worker) моделируем перенос нагрузки
  • нагрузку от host внутри исходного worker'а считаем пропорционально попугаям
    В нашем случае за попугая оказалось вполне разумно взять объем получаемого потока логов в байтах.
  • относительную мощность между коллекторами считаем пропорциональной суммарным попугаям
  • вычисляем метрику d для перенесенного состояния

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

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

Если же уменьшать некуда вот он локальный минимум!

Пример на картинке:


Запускать итерации до упора при этом вовсе не обязательно. Например, можно делать усредненный анализ нагрузки на интервале 1 мин, и по его завершению делать единственный перенос.

Микро-оптимизации


Понятно, что алгоритм со сложностью T(целей) x W(процессов) это не очень хорошо. Но в нем стоит не забыть применить некоторые более-менее очевидные оптимизации, которые его могут ускорить в разы.

Нулевые попугаи


Если на замеренном интервале объект/задача/хост сгенерировал нагрузку 0 штук, то его не то что перемещать куда-то его даже рассматривать и анализировать не надо.

Самоперенос


При генерации пар нет необходимости оценивать эффективность переноса объекта на тот же самый worker, где он и так находится. Все-таки уже будет T x (W - 1) мелочь, а приятно!

Неразличимая нагрузка


Поскольку мы моделируем все-таки перенос именно нагрузки, а объект всего лишь инструмент, то пробовать переносить одинаковый %cpu нет смысла значения метрик останутся точно те же, хоть и для другого распределения объектов.

То есть достаточно оценить единственную модель для кортежа (wrkSrc, wrkDst, %cpu). Ну, а одинаковыми вы можете считать, например, значения %cpu, совпадающие до 1 знака после запятой.

Пример реализации на JavaScript
var col = {  'c1' : {    'wrk' : {      'w1' : {        'hst' : {          'h1' : 5        , 'h2' : 1        , 'h3' : 1        }      , 'cpu' : 80.0      }    , 'w2' : {        'hst' : {          'h4' : 1        , 'h5' : 1        , 'h6' : 1        }      , 'cpu' : 20.0      }    }  }, 'c2' : {    'wrk' : {      'w1' : {        'hst' : {          'h7' : 1        , 'h8' : 2        }      , 'cpu' : 100.0      }    , 'w2' : {        'hst' : {          'h9' : 1        , 'hA' : 1        , 'hB' : 1        }      , 'cpu' : 50.0      }    }  }};// вычисляем опорные метрики и нормализуем по "мощности"let $iv = (obj, fn) => Object.values(obj).forEach(fn);let $mv = (obj, fn) => Object.values(obj).map(fn);// initial reparsefor (const [cid, c] of Object.entries(col)) {  $iv(c.wrk, w => {    w.hst = Object.keys(w.hst).reduce((rv, hid) => {      if (typeof w.hst[hid] == 'object') {        rv[hid] = w.hst[hid];        return rv;      }      // нулевые значения ничего не решают, поэтому сразу отбрасываем      if (w.hst[hid]) {        rv[hid] = {'qty' : w.hst[hid]};      }      return rv;    }, {});  });  c.wrk = Object.keys(c.wrk).reduce((rv, wid) => {    // ID воркеров должны быть глобально-уникальны    rv[cid + ':' + wid] = c.wrk[wid];    return rv;  }, {});}// среднеквадратичное отклонениеlet S = col => {  let wsum = 0    , wavg = 0    , wqty = 0    , csum = 0    , cavg = 0    , cqty = 0;  $iv(col, c => {    $iv(c.wrk, w => {      wsum += w.cpu;      wqty++;    });    csum += c.cpu;    cqty++;  });  wavg = wsum/wqty;  wsum = 0;  cavg = csum/cqty;  csum = 0;  $iv(col, c => {    $iv(c.wrk, w => {      wsum += (w.cpu - wavg) ** 2;    });    csum += (c.cpu - cavg) ** 2;  });  return [Math.sqrt(wsum/wqty), Math.sqrt(csum/cqty)];};// метрика-расстояниеlet distS = S => Math.sqrt(S[0] ** 2 + S[1] ** 2);// выбираем оптимальный перенос и моделируем егоlet iterReOrder = col => {  let qty = 0    , max = 0;  $iv(col, c => {    c.qty = 0;    c.cpu = 0;    $iv(c.wrk, w => {      w.qty = 0;      $iv(w.hst, h => {        w.qty += h.qty;      });      w.max = w.qty * (100/w.cpu);      c.qty += w.qty;      c.cpu += w.cpu;    });    c.cpu = c.cpu/Object.keys(c.wrk).length;    c.max = c.qty * (100/c.cpu);    qty += c.qty;    max += c.max;  });  $iv(col, c => {    c.nrm = c.max/max;    $iv(c.wrk, w => {      $iv(w.hst, h => {        h.cpu = h.qty/w.qty * w.cpu;        h.nrm = h.cpu * c.nrm;      });    });  });  // "текущее" среднеквадратичное отклонение  console.log(S(col), distS(S(col)));  // формируем набор хостов и воркеров  let wrk = {};  let hst = {};  for (const [cid, c] of Object.entries(col)) {    for (const [wid, w] of Object.entries(c.wrk)) {      wrk[wid] = {        wid      , cid      , 'wrk' : w      , 'col' : c      };      for (const [hid, h] of Object.entries(w.hst)) {        hst[hid] = {          hid        , wid        , cid        , 'hst' : h        , 'wrk' : w        , 'col' : c        };      }    }  }  // реализация переноса нагрузки на целевой worker  let move = (col, hid, wid) => {    let w = wrk[wid]      , h = hst[hid];    let wsrc = col[h.cid].wrk[h.wid]      , wdst = col[w.cid].wrk[w.wid];    wsrc.cpu -= h.hst.cpu;    wsrc.qty -= h.hst.qty;    wdst.qty += h.hst.qty;    // перенос на другой коллектор с "процентованием" нагрузки на CPU    if (h.cid != w.cid) {      let csrc = col[h.cid]        , cdst = col[w.cid];      csrc.qty -= h.hst.qty;      csrc.cpu -= h.hst.cpu/Object.keys(csrc.wrk).length;      wsrc.hst[hid].cpu = h.hst.cpu * csrc.nrm/cdst.nrm;      cdst.qty += h.hst.qty;      cdst.cpu += h.hst.cpu/Object.keys(cdst.wrk).length;    }    wdst.cpu += wsrc.hst[hid].cpu;    wdst.hst[hid] = wsrc.hst[hid];    delete wsrc.hst[hid];  };  // моделирование и оценка переноса для пары (host, worker)  let moveCheck = (orig, hid, wid) => {    let w = wrk[wid]      , h = hst[hid];    // тот же воркер - ничего не делаем    if (h.wid == w.wid) {      return;    }    let col = JSON.parse(JSON.stringify(orig));    move(col, hid, wid);    return S(col);  };  // хэш уже проверенных переносов (hsrc,hdst,%cpu)  let checked = {};  // перебираем все возможные пары (какой хост -> на какой воркер)  let moveRanker = col => {    let currS = S(col);    let order = [];    for (hid in hst) {      for (wid in wrk) {        // нет смысла пробовать повторно перемещать одну и ту же (с точностью до 0.1%) "мощность" между одной парой воркеров        let widsrc = hst[hid].wid;        let idx = widsrc + '|' + wid + '|' + hst[hid].hst.cpu.toFixed(1);        if (idx in checked) {          continue;        }                let _S = moveCheck(col, hid, wid);        if (_S === undefined) {          _S = currS;        }        checked[idx] = {          hid        , wid        , S : _S        };        order.push(checked[idx]);      }    }    order.sort((x, y) => distS(x.S) - distS(y.S));    return order;  };  let currS = S(col);  let order = moveRanker(col);  let opt = order[0];  console.log('best move', opt);  // реализуем перенос  if (distS(opt.S) < distS(currS)) {    console.log('move!', opt.hid, opt.wid);    move(col, opt.hid, opt.wid);    console.log('after move', JSON.parse(JSON.stringify(col)));    return true;  }  else {    console.log('none!');  }  return false;};// пока есть что-куда переноситьwhile(iterReOrder(col));

В результате, нагрузка по нашим коллекторам распределяется практически одинаково в каждый момент времени, оперативно нивелируя возникающие пики:

Подробнее..

Перевод Объясняем на пальцах принцип действия оптимизаторов для нейронных сетей основные алгоритмы, и зачем они нужны

16.04.2021 18:11:03 | Автор: admin

Оптимизаторы важный компонент архитектуры нейронных сетей. Они играют важную роль в процессе тренировки нейронных сетей, помогая им делать всё более точные прогнозы. Специально к старту нового потока расширенного курса помашинному и глубокому обучению, делимся с вами простым описанием основных методик, используемых оптимизаторами градиентного спуска, такими как SGD, Momentum, RMSProp, Adam и др.


Оптимизаторы определяют оптимальный набор параметров модели, таких как вес и смещение, чтобы при решении конкретной задачи модель выдавала наилучшие результаты.

Самой распространённой техникой оптимизации, используемой большинством нейронных сетей, является алгоритм градиентного спуска.

Большинство популярных библиотек глубокого обучения, например PyTorch и Keras, имеют множество встроенных оптимизаторов, базирующихся на использовании алгоритма градиентного спуска, например SGD, Adadelta, Adagrad, RMSProp, Adam и пр.

Но почему алгоритмов оптимизации так много? Как выбрать из них правильный?

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

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

Обзор методов оптимизации на базе алгоритма градиентного спуска

Кривая потерь

Начнём с того, что рассмотрим на 3D-изображении, как работает стандартный алгоритм градиентного спуска.

Кривая потерь для алгоритма градиентного спускаКривая потерь для алгоритма градиентного спуска

На рисунке показана сеть с двумя весовыми параметрами:

  • На горизонтальной плоскости размещаются две оси для весов w1 и w2, соответственно.

  • На вертикальной оси откладываются значения потерь для каждого сочетания весов.

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

Синяя линия соответствует траектории алгоритма градиентного спуска при оптимизации:

  • Работа алгоритма начинается с выбора некоторых случайных значений обоих весов и вычисления значения потери.

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

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

Вычисление градиента

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

Обновление параметра градиентного спускаОбновление параметра градиентного спуска

Градиент измеряет уклон и рассчитывается как значение изменения в вертикальном направлении (dL), поделённое на значение изменения горизонтальном направлении (dW). Другими словами, для крутых уклонов значение градиента большое, для пологих маленькое.

Вычисление градиентаВычисление градиента

Практическое применение алгоритма градиентного спуска

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

  • Во-первых, на приведённом выше рисунке кривая потерь идёт идеально плавно. На самом деле горбов и впадин на ней гораздо больше, чем плавных мест.

Ландшафт потерь в нейронной сетиЛандшафт потерь в нейронной сети
  • Во-вторых, мы будем работать не с двумя параметрами, а с гораздо большим их числом. Параметров может быть десятки или даже сотни миллионов, их просто невозможно визуализировать и представить даже мысленно.

На каждой итерации алгоритм градиентного спуска работает, "озираясь по всем направлениям, находя самый перспективный уклон, по которому можно спуститься вниз". Но что произойдёт, если алгоритм выберет лучший, по его мнению, уклон, но это будет не лучшее направление? Что делать, если:

  • Ландшафт круто уходит вниз в одном направлении, но, чтобы попасть в самую нижнюю точку, нужно двигаться в направлении с меньшими изменениями высоты?

  • Весь ландшафт представляется алгоритму плоским во всех направлениях?

  • Алгоритм попадает в глубокую канаву? Как ему оттуда выбраться?

Давайте рассмотрим несколько примеров кривых, перемещение по которым может создавать трудности.

Трудности при оптимизации градиентного спуска

Локальные минимумы

На стандартной кривой потерь, кроме глобального минимума, может встретиться множество локальных минимумов. Главной задачей алгоритма градиентного спуска, как следует из его названия, является спуск всё ниже и ниже. Но, стоит ему спуститься до локального минимума и подняться оттуда наверх часто становится непосильной задачей. Алгоритм может просто застрять в локальном минимуме, так и не попав на глобальный минимум.

Локальный минимум и глобальный минимумЛокальный минимум и глобальный минимум

Седловые точки

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

Седловая точкаСедловая точка

Какую опасность таят в себе седловые точки, которые алгоритм может встретить на своём пути? Опасность в том, что область, непосредственно окружающая седловую точку, как правило, довольно плоская, она напоминает плато. Плоская область означает практически нулевые градиенты. Оптимизатор начинает колебаться (осциллировать) вокруг седловой точки в направлении первого параметра, не догадываясь спуститься вниз по уклону в направлении второго параметра.

Алгоритм градиентного спуска при этом ошибочно полагает, что минимум им найден.

Овраги

Ещё одна головная боль алгоритма градиентного спуска пересечение оврагов. Овраг это протяжённая узкая долина, имеющая крутой уклон в одном направлении (т.е. по сторонам долины) и плавный уклон в другом (т.е. вдоль долины). Довольно часто овраг приводит к минимуму. Поскольку навигация по такой форме кривой затруднена, такую кривую часто называют патологическим искривлением (Pathological Curvature).

ОврагиОвраги

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

Алгоритм ванильного градиентного спуска, несмотря на его недостатки, по-прежнему считается основным, в частности, по той причине, что существуют специальные методы его оптимизации.

Первое улучшение алгоритма градиентного спуска стохастический градиентный спуск (SGD)

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

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

Случайность выбора хороша тем, что она помогает глубже исследовать ландшафт потерь.

Ранее мы говорили, что кривая потерь получается за счёт изменения параметров модели с сохранением фиксированного набора входных данных. Однако если начать изменять входные данные, выбирая различные данные в каждом мини-пакете, значения потерь и градиентов также будут меняться. Другими словами, изменяя набор входных данных, для каждого мини-пакета мы получим собственную кривую потерь, которая немного отличается от других.

То есть, даже если алгоритм застрянет на каком-либо месте ландшафта в одном мини-пакете, можно будет запустить другой мини-пакет с другим ландшафтом, и вполне вероятно, что алгоритм сможет двигаться дальше. Данная техника предотвращает застревание алгоритма на определённых участках ландшафта, особенно на ранних этапах тренировки.

Второе усовершенствование алгоритма градиентного спуска накопление импульса (Momentum)

Динамическая корректировка количества обновлений

Один из интересных аспектов алгоритма градиентного спуска связан с его поведением на крутых уклонах. Так как градиент на крутых уклонах большой, алгоритм в этих местах может делать большие шаги, между тем, именно здесь вы на самом деле хотите перемещаться медленно и осторожно. Это может привести к тому, что алгоритм будет "скакать" назад и вперёд, замедляя тем самым процесс тренировки.

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

Алгоритм градиентного спуска обновляет веса на каждом шаге, используя в качестве параметров значения градиента и скорости обучения. Таким образом, для изменения количества обновлений можно выполнить два действия:

  • Скорректировать градиент.

  • Скорректировать значение скорости обучения.

SGD с функцией накопления импульса и обычный SGD

Первое действие, то есть корректировка градиента, выполняется с помощью функции накопления импульса.

В SGD учитывается только текущее значение градиента, а значения всех прошлых градиентов игнорируются. Это означает, что, если алгоритм внезапно натолкнётся на аномалию на кривой потерь, он может пойти не по нужной траектории.

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

  1. Сразу встаёт вопрос как далеко можно углубляться в прошлое? Чем глубже мы погрузимся в прошлое, тем меньше вероятность воздействия аномалий на конечный результат.

  2. И второй вопрос все ли градиенты из прошлого можно расценивать одинаково? Вполне логично рассудить, что вещи из недавнего прошлого должны иметь более высокую значимость, чем вещи из далёкого прошлого. Поэтому, если изменение ландшафта представляет собой не аномалию, а естественное структурное изменение, на такие изменения нужно реагировать соответственно и менять курс постепенно.

Функция накопления импульса использует при работе экспоненциальное скользящее среднее, а не текущее значение градиента.

Переходы через овраги с помощью функции накопления импульса

Функция накопления импульса помогает решить проблему узкого оврага с патологическим искривлением, градиент которого, с одной стороны, очень большой для одного весового параметра, а, с другой стороны, очень маленький для другого параметра.

Функция накопления импульса помогает преодолевать оврагиФункция накопления импульса помогает преодолевать овраги

С помощью функции накопления импульса можно сгладить зигзагообразные скачки алгоритма SGD.

  • Для первого параметра с крутым уклоном большое значение градиента приведёт к резкому повороту от одной стороны оврага к другой. Однако на следующем шаге такое перемещение будет скомпенсировано резким поворотом в обратном направлении.

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

Вот некоторые примеры алгоритмов оптимизации, использующих функцию накопления импульса в разных формулах:

  • SGD с накоплением импульса

  • Ускоренный градиент Нестерова

Третье усовершенствование алгоритма градиентного спуска изменение скорости обучения (на базе значения градиента)

Как уже было сказано выше, вторым способом изменения количества обновлений параметров является изменение скорости обучения.

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

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

Мы можем использовать это обстоятельство, чтобы адаптировать скорость обучения к каждому параметру. Чтобы выбрать скорость обучения для данного параметра, мы можем обратиться к прошлым градиентам (для каждого параметра отдельно).

Данная функциональность реализована в нескольких алгоритмах оптимизации, использующих разные, но похожие методы, например Adagrad, Adadelta, RMS Prop.

Например, метод Adagrad возводит в квадрат значения прошлых градиентов и суммирует их в равных весовых пропорциях. Метод RMSProp также возводит в квадрат значения прошлых градиентов, но приводит их к экспоненциальному скользящему среднему, тем самым придавая более высокую значимость последним по времени градиентам.

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

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

Рассуждая аналогично, если уклоны небольшие, накопления также будут небольшими, поэтому при вычислении скорости обучения алгоритм разделит накопленные квадраты градиентов на коэффициент с меньшим значением. Таким образом, скорость обучения на пологих склонах будет увеличена.

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

Четвёртое усовершенствование алгоритма градиентного спуска изменение скорости обучения (на базе тренировочной выборки)

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

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

Заключение

Мы ознакомились с основными приёмами, используемыми методиками оптимизации на базе алгоритма градиентного спуска, рассмотрели причины их использования и взаимосвязи друг с другом. Представленная информация позволит лучше понять принцип действия многих специфических алгоритмов оптимизации. А если вы хотите получить больше знаний обратите внимание на наш расширенныйкурс по машинному и глубокому обучению, в рамках которого вы научитесь строить собственные нейронные нейронные сети и решать различные задачи с их помощью.

Узнайте, как прокачаться и в других специальностях или освоить их с нуля:

Другие профессии и курсы
Подробнее..

Перевод Анимации градиентного спуска и ландшафта потерь нейронных сетей на Python

10.01.2021 14:07:00 | Автор: admin
Во время изучения различных алгоритмов машинного обучения я наткнулся на ландшафт потерь нейронных сетей с их горными территориями, хребтами и долинами. Эти ландшафты потерь сильно отличались от выпуклых и гладких ландшафтов потерь, с которыми я столкнулся при использовании линейной и логистической регрессий. Здесь мы создадим ландшафты потерь нейронных сетей и анимированного градиентного спуска с помощью датасета MNIST.


Рисунок 1 Ландшафт потерь свёрточной нейронной сети с 56 слоями (VGG-56, источник)



На приведённом выше изображении показан ландшафт нейронной сети с высокой степенью поверхностных потерь. Ландшафт потерь это визуальное представление значений, которые функция стоимости берёт на себя для заданного диапазона значений параметров с учётом наших тренировочных данных. Поскольку нашей целью является визуализация затрат в трёх измерениях, необходимо выбрать два конкретных параметра, которые будут варьироваться в наших графиках, тогда как все остальные параметры модели остаются неизменными. Стоит, однако, отметить, что существуют более продвинутые методы (например, уменьшение размерности, нормализация фильтра), которые могут использоваться для приближения ландшафтов потерь нейронной сети в подпространстве с низкой размерностью. Трёхмерное представление ландшафта потерь нейронной сети VGG с 56 слоями показано на рисунке 1. Однако это выходит за рамки данной статьи.

Искусственная нейронная сеть, с которой мы будем работать, состоит из одного входного слоя (с 784 узлами), двух скрытых слоёв (с 50 и 500 узлами соответственно) и одного выходного слоя (с 10 узлами). Мы будем повсеместно использовать сигмовидную функцию в качестве функции активации. Нейронная сеть не будет подвержена предвзятости. Обучающие данные состоят из изображений 28x28 пикселей, рукописных цифр в диапазоне от 0 до 9 из набора данных MNIST. Технически мы могли бы выбрать любой из 784*50+50*500+500*10=69,200 весов, которые мы используем в нашей нейронной сети. Я произвольно решил использовать веса w250, 5 (2) и w251,5(2), которые соединяют 250-й и 251-й узлы второго скрытого слоя с 6-м выходным нейроном соответственно. В нашей модели 6-й выходной нейрон возвращает активацию для модели, прогнозируя наличие цифры 5 на изображении. На рисунке 2 схематично показана архитектура нейронной сети, с которой мы будем работать. Из соображений ясности некоторые связи между нейронами и большая часть весовых аннотаций были намеренно опущены.


Рисунок 2 Архитектура нейронной сети

Мы импортируем MNIST в скрипт на Python. Рукописные цифры набора данных MNIST представлены в виде изображений в оттенках серого, поэтому мы можем нормализовать входные данные путём масштабирования значений пикселей из диапазона 0-255 в диапазон 0-1,2 в нашем коде, следовательно, мы делим x-значения на 255.

# Import librariesimport numpy as npimport gzipfrom sklearn.preprocessing import OneHotEncoderimport matplotlib.pyplot as pltfrom mpl_toolkits.mplot3d import Axes3Dfrom scipy.special import expitimport celluloidfrom celluloid import Camerafrom matplotlib import animation # Open MNIST-files: def open_images(filename):    with gzip.open(filename, "rb") as file:             data=file.read()        return np.frombuffer(data,dtype=np.uint8, offset=16).reshape(-1,28,28).astype(np.float32) def open_labels(filename):    with gzip.open(filename,"rb") as file:        data = file.read()        return np.frombuffer(data,dtype=np.uint8, offset=8).astype(np.float32)     X_train=open_images("C:\\Users\\tobia\\train-images-idx3-ubyte.gz").reshape(-1,784).astype(np.float32) X_train=X_train/255 # rescale pixel values to 0-1y_train=open_labels("C:\\Users\\tobia\\train-labels-idx1-ubyte.gz")oh=OneHotEncoder(categories='auto') y_train_oh=oh.fit_transform(y_train.reshape(-1,1)).toarray() # one-hot-encoding of y-values

Чтобы создать ландшафты потерь, создадим график поверхности стоимости по отношению к вышеупомянутым весам w_250, 5(2) и w_251,5(2). Для этого мы определим среднеквадратичную функцию стоимости ошибки по отношению к весам w_a и w_b. Стоимости нашей модели J эквивалентны усреднённой сумме квадратичных ошибок между прогнозом модели и фактическим значением каждого из 10 выходных нейронов нашего обучающего набора данных с размером N:



С y и pred, представляющим матрицы фактических и прогнозируемых значений y соответственно. Прогнозируемые значения вычисляются путём прямого распространения входных данных через нейронную сеть на конечный слой. Вывод каждого слоя служит входными данными для следующего слоя. Входная матрица умножается на весовую матрицу соответствующего слоя. После этого сигмоидная функция применяется для получения выходных данных этого конкретного слоя. Весовые матрицы инициализируются малыми случайными числами с помощью генератора псевдослучайных чисел numpy. С помощью seed мы гарантируем воспроизводимость результатов. После этого подставляем два веса, которые могут изменяться в зависимости от аргументов функции w_a и w_b. Мы разработали функцию затрат на Python следующим образом:

hidden_0=50 # number of nodes of first hidden layerhidden_1=500 # number of nodes of second hidden layer# Set up cost function:def costs(x,y,w_a,w_b,seed_):          np.random.seed(seed_) # insert random seed         w0=np.random.randn(hidden_0,784)  # weight matrix of 1st hidden layer        w1=np.random.randn(hidden_1,hidden_0) # weight matrix of 2nd hidden layer        w2=np.random.randn(10,hidden_1) # weight matrix of output layer        w2[5][250] = w_a # set value for weight w_250,5(2)        w2[5][251] = w_b # set value for weight w_251,5(2)        a0 = expit(w0 @ x.T)  # output of 1st hidden layer        a1=expit(w1 @ a0)  # output of 2nd hidden layer        pred= expit(w2 @ a1) # output of final layer        return np.mean(np.sum((y-pred)**2,axis=0)) # costs w.r.t. w_a and w_b

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

# Set range of values for meshgrid: m1s = np.linspace(-15, 17, 40)   m2s = np.linspace(-15, 18, 40)  M1, M2 = np.meshgrid(m1s, m2s) # create meshgrid # Determine costs for each coordinate in meshgrid: zs_100 = np.array([costs(X_train[0:100],y_train_oh[0:100].T                                 ,np.array([[mp1]]), np.array([[mp2]]),135)                         for mp1, mp2 in zip(np.ravel(M1), np.ravel(M2))])Z_100 = zs_100.reshape(M1.shape) # z-values for N=100zs_10000 = np.array([costs(X_train[0:10000],y_train_oh[0:10000].T                                 ,np.array([[mp1]]), np.array([[mp2]]),135)                         for mp1, mp2 in zip(np.ravel(M1), np.ravel(M2))])Z_10000 = zs_10000.reshape(M1.shape) # z-values for N=10,000# Plot loss landscapes: fig = plt.figure(figsize=(10,7.5)) # create figureax0 = fig.add_subplot(121, projection='3d' )ax1 = fig.add_subplot(122, projection='3d' )fontsize_=20 # set axis label fontsizelabelsize_=12 # set tick label size# Customize subplots: ax0.view_init(elev=30, azim=-20)ax0.set_xlabel(r'$w_a$', fontsize=fontsize_, labelpad=9)ax0.set_ylabel(r'$w_b$', fontsize=fontsize_, labelpad=-5)ax0.set_zlabel("costs", fontsize=fontsize_, labelpad=-30)ax0.tick_params(axis='x', pad=5, which='major', labelsize=labelsize_)ax0.tick_params(axis='y', pad=-5, which='major', labelsize=labelsize_)ax0.tick_params(axis='z', pad=5, which='major', labelsize=labelsize_)ax0.set_title('N:100',y=0.85,fontsize=15) # set title of subplot ax1.view_init(elev=30, azim=-30)ax1.set_xlabel(r'$w_a$', fontsize=fontsize_, labelpad=9)ax1.set_ylabel(r'$w_b$', fontsize=fontsize_, labelpad=-5)ax1.set_zlabel("costs", fontsize=fontsize_, labelpad=-30)ax1.tick_params(axis='y', pad=-5, which='major', labelsize=labelsize_)ax1.tick_params(axis='x', pad=5, which='major', labelsize=labelsize_)ax1.tick_params(axis='z', pad=5, which='major', labelsize=labelsize_)ax1.set_title('N:10,000',y=0.85,fontsize=15)# Surface plots of costs (= loss landscapes):  ax0.plot_surface(M1, M2, Z_100, cmap='terrain', #surface plot                             antialiased=True,cstride=1,rstride=1, alpha=0.75)ax1.plot_surface(M1, M2, Z_10000, cmap='terrain', #surface plot                             antialiased=True,cstride=1,rstride=1, alpha=0.75)plt.tight_layout()plt.show()


Рисунок 3 Ландшафты с различными размерами образцов

На рисунке 3 показаны два примерных ландшафта потерь с одинаковыми весами (w_250, 5 (2) и w_251,5(2)) и одинаковыми случайными начальными весами. Левый участок поверхности создавался с помощью первых 100 изображений набора данных MNIST, в то время как участок справа был создан с помощью первых 10 000 изображений. Если мы присмотримся к левому графику, то увидим некоторые типичные черты невыпуклых ландшафтов потерь: локальные минимумы, плато, хребты (иногда также называемые седловыми точками) и глобальный минимум. Однако термин минимум следует использовать с осторожностью, поскольку мы видим только заданный диапазон значений, вместе с тем не проводился тест первой производной.


Рисунок 4

Градиентный спуск


Эти географические барьеры резко контрастируют с гладкими и выпуклыми ландшафтами потерь, которые можно увидеть в линейной и логистической регрессиях. Считается, что эти барьеры замедляют достижение глобального минимума и даже препятствуют этому, а следовательно, негативно влияют на производительность модели [3]. Для исследования явления я решил анимировать градиентный спуск с этим конкретным ландшафтом потерь и тремя характерными начальными точками. Градиентный спуск в основном компрометирует обновление параметров модели (например, весов) в соответствии со следующим уравнением:



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

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



где w определяется как вес между j-м узлом слоя до и i-м узлом текущего слоя, который является выходным слоем в нашем случае. Вход i-го нейрона в выходном слое просто обозначается как in () и эквивалентен сумме активаций слоя до умножения на их соответствующие веса соединения, ведущие к этому узлу. Выход * i*-го нейрона в выходном слое обозначается как out () и соответствует (in ()). Решая уравнение выше, мы получаем:



с * out (), соответствующим активации j-го узла в слое, перед которым в выходном слое через w. соединен с n-м узлом. Переменная target обозначает целевой вывод для каждого из 10 выходных нейронов. Ссылаясь на рисунок 2, out () будет соответствовать активации h или h, в зависимости от того веса, от которого мы намереваемся вычислить частную производную. Отличное объяснение, включая подробный математический вывод, можно найти здесь [4].

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

# Store values of costs and weights in lists: weights_2_5_250=[] weights_2_5_251=[] costs=[] seed_= 135 # random seedN=100 # sample size # Set up neural network: class NeuralNetwork(object):    def __init__(self, lr=0.01):        self.lr=lr        np.random.seed(seed_) # set random seed        # Intialize weight matrices:         self.w0=np.random.randn(hidden_0,784)          self.w1=np.random.randn(hidden_1,hidden_0)        self.w2=np.random.randn(10,hidden_1)        self.w2[5][250] = start_a # set starting value for w_a        self.w2[5][251] = start_b # set starting value for w_b        def train(self, X,y):        a0 = expit(self.w0 @ X.T)          a1=expit(self.w1 @ a0)          pred= expit(self.w2 @ a1)        # Partial derivatives of costs w.r.t. the weights of the output layer:         dw2= (pred - y.T)*pred*(1-pred)  @ a1.T / len(X)   # ... averaged over the sample size        # Update weights:         self.w2[5][250]=self.w2[5][250] - self.lr * dw2[5][250]         self.w2[5][251]=self.w2[5][251] - self.lr * dw2[5][251]         costs.append(self.cost(pred,y)) # append cost values to list        def cost(self, pred, y):        return np.mean(np.sum((y.T-pred)**2,axis=0))    # Initial values of w_a/w_b: starting_points = [  (-9,15),(-10.1,15),(-11,15)] for j in starting_points:    start_a,start_b=j    model=NeuralNetwork(10) # set learning rate to 10    for i in range(10000):  # 10,000 epochs                    model.train(X_train[0:N], y_train_oh[0:N])         weights_2_5_250.append(model.w2[5][250]) # append weight values to list        weights_2_5_251.append(model.w2[5][251]) # append weight values to list# Create sublists of costs and weight values for each starting point: costs = np.split(np.array(costs),3) weights_2_5_250 = np.split(np.array(weights_2_5_250),3)weights_2_5_251 = np.split(np.array(weights_2_5_251),3)

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

fig = plt.figure(figsize=(10,10)) # create figureax = fig.add_subplot(111,projection='3d' ) line_style=["dashed", "dashdot", "dotted"] #linestylesfontsize_=27 # set axis label fontsizelabelsize_=17 # set tick label fontsizeax.view_init(elev=30, azim=-10)ax.set_xlabel(r'$w_a$', fontsize=fontsize_, labelpad=17)ax.set_ylabel(r'$w_b$', fontsize=fontsize_, labelpad=5)ax.set_zlabel("costs", fontsize=fontsize_, labelpad=-35)ax.tick_params(axis='x', pad=12, which='major', labelsize=labelsize_)ax.tick_params(axis='y', pad=0, which='major', labelsize=labelsize_)ax.tick_params(axis='z', pad=8, which='major', labelsize=labelsize_)ax.set_zlim(4.75,4.802) # set range for z-values in the plot# Define which epochs to plot:p1=list(np.arange(0,200,20))p2=list(np.arange(200,9000,100))points_=p1+p2camera=Camera(fig) # create Camera objectfor i in points_:    # Plot the three trajectories of gradient descent...    #... each starting from its respective starting point    #... and each with a unique linestyle:    for j in range(3):         ax.plot(weights_2_5_250[j][0:i],weights_2_5_251[j][0:i],costs[j][0:i],                linestyle=line_style[j],linewidth=2,                color="black", label=str(i))        ax.scatter(weights_2_5_250[j][i],weights_2_5_251[j][i],costs[j][i],                   marker='o', s=15**2,               color="black", alpha=1.0)    # Surface plot (= loss landscape):    ax.plot_surface(M1, M2, Z_100, cmap='terrain',                              antialiased=True,cstride=1,rstride=1, alpha=0.75)    ax.legend([f'epochs: {i}'], loc=(0.25, 0.8),fontsize=17) # set position of legend    plt.tight_layout()     camera.snap() # take snapshot after each iteration    animation = camera.animate(interval = 5, # set delay between frames in milliseconds                          repeat = False,                          repeat_delay = 0)animation.save('gd_1.gif', writer = 'imagemagick', dpi=100)  # save animation   


Рисунок 5 Траектории градиентного спуска

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

fig = plt.figure(figsize=(10,10)) # create figureax0=fig.add_subplot(2, 1, 1) ax1=fig.add_subplot(2, 1, 2) # Customize subplots: ax0.set_xlabel(r'$w_a$', fontsize=25, labelpad=0)ax0.set_ylabel(r'$w_b$', fontsize=25, labelpad=-20)ax0.tick_params(axis='both', which='major', labelsize=17)ax1.set_xlabel("epochs", fontsize=22, labelpad=5)ax1.set_ylabel("costs", fontsize=25, labelpad=7)ax1.tick_params(axis='both', which='major', labelsize=17)contours_=21 # set the number of contour linespoints_=np.arange(0,9000,100) # define which epochs to plotcamera = Camera(fig) # create Camera objectfor i in points_:    cf=ax0.contour(M1, M2, Z_100,contours_, colors='black', # contour plot                     linestyles='dashed', linewidths=1)    ax0.contourf(M1, M2, Z_100, alpha=0.85,cmap='terrain') # filled contour plots         for j in range(3):        ax0.scatter(weights_2_5_250[j][i],weights_2_5_251[j][i],marker='o', s=13**2,               color="black", alpha=1.0)        ax0.plot(weights_2_5_250[j][0:i],weights_2_5_251[j][0:i],                linestyle=line_style[j],linewidth=2,                color="black", label=str(i))                ax1.plot(costs[j][0:i], color="black", linestyle=line_style[j])    plt.tight_layout()    camera.snap()    animation = camera.animate(interval = 5,                          repeat = True, repeat_delay = 0)  # create animation animation.save('gd_2.gif', writer = 'imagemagick')  # save animation as gif


Рисунок 6 Траектории градиентного спуска в 2D

Обе анимации показывают, что градиентный спуск может застревать в локальных минимумах, седловых точках или плато с невыпуклыми ландшафтами потерь. Для преодоления некоторых из этих препятствий были реализованы многочисленные варианты градиентного спуска (ADAGRAD, Adam и др.). Однако я хотел бы прояснить, что не все ландшафты потерь настолько невыпуклые в определённом диапазоне значений для w_a и w_b. Выпуклость ландшафта потерь зависит, среди прочего, от количества скрытых слоев, при этом глубокие нейронные сети приводят к сильно невыпуклым ландшафтам потерь.

Я решил создать несколько произвольных ландшафтов потерь, перебирая случайные начальные значения для генерации. На этот раз веса, которые могут изменяться, находятся на втором скрытом уровне (код). Репрезентативный образец ландшафтов потерь, с которыми я столкнулся при таком подходе, можно увидеть ниже. Размер выборки и индексы весов, представленных w_a и w_b, указаны соответственно в заголовках.


Рисунок 7 N=500, w20030(1), w20031(1) (создано автором, код)


Рисунок 8 N=1000, w55(1), w56(1) (создано автором, код)

Визуализация ландшафта потерь может быть полезна для лучшего понимания лежащей в основе теории и потенциальных недостатков различных алгоритмов оптимизации. Однако на практике актуальность локальных минимумов, плато или хребтов всё ещё обсуждается. Некоторые авторы утверждают, что локальные минимумы очень редки в многомерных пространствах и что седловые точки могут быть даже более проблематичными, чем локальные минимумы в отношении оптимизации параметров. Другие авторы даже предполагают, что минимизация затрат, связанная с локальными минимумами, является достаточной и может предотвратить переобучение [7].

Я надеюсь, что вам понравилось! Полный код Jupyter Notebook можно найти на моём GitHub.

Приложение



Представленное изображение

Ссылки
База данных MNIST

  1. Li, Hao, et al. Visualizing the loss landscape of neural nets. Advances in neural information processing systems. 2018.
  2. Как нормализовать, центрировать и стандартизировать пиксели изображения в Keras
  3. Почему сложно обучать нейронную сеть
  4. Пример пошагового обратного распространения ошибки
  5. Staib, Matthew & J. Reddi, Sashank & Kale, Satyen & Kumar, Sanjiv & Sra, Suvrit. (2019). Escaping Saddle Points with Adaptive Gradient Methods.
  6. Dauphin, Yann et al. Identifying and attacking the saddle point problem in high-dimensional non-convex optimization. NIPS (2014).
  7. Choromanska, A., Henaff, M., Mathieu, M., Arous, G. B., & LeCun, Y. (2015). The loss surfaces of multilayer networks. Journal of Machine Learning Research, 38, 192204.


image



Подробнее..

Градиентный спуск в Python

16.03.2021 20:17:50 | Автор: admin

Задача и требования

  • Основная цель - создать алгоритм, который найдет максимальное значение по модулю минимума на заданном радиусе.

  • Алгоритм должен быть эффективным и работать достаточно быстро

  • Результат должен быть отображен на графике

Введение, описание алгоритма

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

Реализация

Прежде всего, numpy необходим для функций sinus, cosinus и exp. Также необходимо добавить matplotlib для построения графиков.

import numpy as npimport matplotlib.pyplot as plot

Константы

radius = 8                                  # working plane radiuscentre = (global_epsilon, global_epsilon)   # centre of the working circlearr_shape = 100                             # number of points processed / 360step = radius / arr_shape                   # step between two points

arr_shape должна быть 100, потому что, если она больше, программа начинает работать значительно медленнее. И не может быть меньше, иначе это испортит расчеты.

Функция, для которой рассчитывается минимум:

def differentiable_function(x, y):    return np.sin(x) * np.exp((1 - np.cos(y)) ** 2) + \           np.cos(y) * np.exp((1 - np.sin(x)) ** 2) + (x - y) ** 2

Затем выбирается приращение аргумента:

Поскольку предел аргумента стремится к нулю, точность должна быть небольшой по сравнению с радиусом рабочей плоскости:

global_epsilon = 0.000000001                # argument increment for derivative

Для дальнейшего разбиения плоскости необходим поворот вектора:

Если вращение применяется к вектору (x, 0), повернутый вектор будет вычисляться следующим образом:

def rotate_vector(length, a):    return length * np.cos(a), length * np.sin(a)

Расчет производной по оси Y, где эпсилон - значение y:

def derivative_y(epsilon, arg):    return (differentiable_function(arg, epsilon + global_epsilon) -            differentiable_function(arg, epsilon)) / global_epsilon

Вычисление производной по оси X, где эпсилон - значение x:

def derivative_x(epsilon, arg):    return (differentiable_function(global_epsilon + epsilon, arg) -            differentiable_function(epsilon, arg)) / global_epsilon

Расчет градиента:

Поскольку градиент вычисляется для 2D-функции, k равно нулю

gradient = derivative_x(x, y) + derivative_y(y, x)

Схема генерации точек

Возвращаемое значение представляет собой массив приблизительных локальных минимумов.

Локальный минимум распознается по смене знака производной с минуса на плюс. Подробнее об этом здесь: https://en.wikipedia.org/wiki/Maxima_and_minima

def calculate_flip_points():    flip_points = np.array([0, 0])    points = np.zeros((360, arr_shape), dtype=bool)    cx, cy = centre    for i in range(arr_shape):        for alpha in range(360):            x, y = rotate_vector(step, alpha)            x = x * i + cx            y = y * i + cy            points[alpha][i] = derivative_x(x, y) + derivative_y(y, x) > 0            if not points[alpha][i - 1] and points[alpha][i]:                flip_points = np.vstack((flip_points, np.array([alpha, i - 1])))    return flip_points

Выбор точки из flip_points, значение функции от которой минимально:

def pick_estimates(positions):    vx, vy = rotate_vector(step, positions[1][0])    cx, cy = centre    best_x, best_y = cx + vx * positions[1][1], cy + vy * positions[1][1]    for index in range(2, len(positions)):        vx, vy = rotate_vector(step, positions[index][0])        x, y = cx + vx * positions[index][1], cy + vy * positions[index][1]        if differentiable_function(best_x, best_y) > differentiable_function(x, y):            best_x = x            best_y = y    for index in range(360):        vx, vy = rotate_vector(step, index)        x, y = cx + vx * (arr_shape - 1), cy + vy * (arr_shape - 1)        if differentiable_function(best_x, best_y) > differentiable_function(x, y):            best_x = x            best_y = y    return best_x, best_y

Метод градиентного спуска:

def gradient_descent(best_estimates, is_x):    derivative = derivative_x if is_x else derivative_y    best_x, best_y = best_estimates    descent_step = step    value = derivative(best_y, best_x)    while abs(value) > global_epsilon:        descent_step *= 0.95        best_y = best_y - descent_step \            if derivative(best_y, best_x) > 0 else best_y + descent_step        value = derivative(best_y, best_x)    return best_y, best_x

Нахождение точки минимума:

def find_minimum():    return gradient_descent(gradient_descent(pick_estimates(calculate_flip_points()), False), True)

Формирование сетки точек для построения:

def get_grid(grid_step):    samples = np.arange(-radius, radius, grid_step)    x, y = np.meshgrid(samples, samples)    return x, y, differentiable_function(x, y)

Построение графика:

def draw_chart(point, grid):    point_x, point_y, point_z = point    grid_x, grid_y, grid_z = grid    plot.rcParams.update({        'figure.figsize': (4, 4),        'figure.dpi': 200,        'xtick.labelsize': 4,        'ytick.labelsize': 4    })    ax = plot.figure().add_subplot(111, projection='3d')    ax.scatter(point_x, point_y, point_z, color='red')    ax.plot_surface(grid_x, grid_y, grid_z, rstride=5, cstride=5, alpha=0.7)    plot.show()

Функция main:

if __name__ == '__main__':    min_x, min_y = find_minimum()    minimum = (min_x, min_y, differentiable_function(min_x, min_y))    draw_chart(minimum, get_grid(0.05))

График:

Заключение

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

Исходный код:

import numpy as npimport matplotlib.pyplot as plotradius = 8                                  # working plane radiusglobal_epsilon = 0.000000001                # argument increment for derivativecentre = (global_epsilon, global_epsilon)   # centre of the working circlearr_shape = 100                             # number of points processed / 360step = radius / arr_shape                   # step between two pointsdef differentiable_function(x, y):    return np.sin(x) * np.exp((1 - np.cos(y)) ** 2) + \           np.cos(y) * np.exp((1 - np.sin(x)) ** 2) + (x - y) ** 2def rotate_vector(length, a):    return length * np.cos(a), length * np.sin(a)def derivative_x(epsilon, arg):    return (differentiable_function(global_epsilon + epsilon, arg) -            differentiable_function(epsilon, arg)) / global_epsilondef derivative_y(epsilon, arg):    return (differentiable_function(arg, epsilon + global_epsilon) -            differentiable_function(arg, epsilon)) / global_epsilondef calculate_flip_points():    flip_points = np.array([0, 0])    points = np.zeros((360, arr_shape), dtype=bool)    cx, cy = centre    for i in range(arr_shape):        for alpha in range(360):            x, y = rotate_vector(step, alpha)            x = x * i + cx            y = y * i + cy            points[alpha][i] = derivative_x(x, y) + derivative_y(y, x) > 0            if not points[alpha][i - 1] and points[alpha][i]:                flip_points = np.vstack((flip_points, np.array([alpha, i - 1])))    return flip_pointsdef pick_estimates(positions):    vx, vy = rotate_vector(step, positions[1][0])    cx, cy = centre    best_x, best_y = cx + vx * positions[1][1], cy + vy * positions[1][1]    for index in range(2, len(positions)):        vx, vy = rotate_vector(step, positions[index][0])        x, y = cx + vx * positions[index][1], cy + vy * positions[index][1]        if differentiable_function(best_x, best_y) > differentiable_function(x, y):            best_x = x            best_y = y    for index in range(360):        vx, vy = rotate_vector(step, index)        x, y = cx + vx * (arr_shape - 1), cy + vy * (arr_shape - 1)        if differentiable_function(best_x, best_y) > differentiable_function(x, y):            best_x = x            best_y = y    return best_x, best_ydef gradient_descent(best_estimates, is_x):    derivative = derivative_x if is_x else derivative_y    best_x, best_y = best_estimates    descent_step = step    value = derivative(best_y, best_x)    while abs(value) > global_epsilon:        descent_step *= 0.95        best_y = best_y - descent_step \            if derivative(best_y, best_x) > 0 else best_y + descent_step        value = derivative(best_y, best_x)    return best_y, best_xdef find_minimum():    return gradient_descent(gradient_descent(pick_estimates(calculate_flip_points()), False), True)def get_grid(grid_step):    samples = np.arange(-radius, radius, grid_step)    x, y = np.meshgrid(samples, samples)    return x, y, differentiable_function(x, y)def draw_chart(point, grid):    point_x, point_y, point_z = point    grid_x, grid_y, grid_z = grid    plot.rcParams.update({        'figure.figsize': (4, 4),        'figure.dpi': 200,        'xtick.labelsize': 4,        'ytick.labelsize': 4    })    ax = plot.figure().add_subplot(111, projection='3d')    ax.scatter(point_x, point_y, point_z, color='red')    ax.plot_surface(grid_x, grid_y, grid_z, rstride=5, cstride=5, alpha=0.7)    plot.show()if __name__ == '__main__':    min_x, min_y = find_minimum()    minimum = (min_x, min_y, differentiable_function(min_x, min_y))    draw_chart(minimum, get_grid(0.05))
Подробнее..

Математика за оптимизаторами нейронных сетей

07.06.2021 16:20:09 | Автор: admin
В этой статье мы поговорим о математике градиентного спуска, почему при обучении нейронных сетей применяется стохастический градиентный спуск и о вариации SGD (Stochastic Gradient Descent) с использованием скользящего среднего (SGD с momentum и Nesterov Accelerated Gradient).





Градиентный спуск



Предполагается, что вы знакомы с понятием нейронной сети, вы имеете представление, какие задачи можно решать с помощью этого алгоритма машинного обучения и что такое параметры (веса) нейронной сети. Также, важно понимать, что градиент функции это направление наискорейшего роста функции, а градиент взятый с минусом это направление наискорейшего убывания. - \nabla_\theta J(\theta) это антиградиент функционала,
где \theta параметры функции (веса нейронной сети), J(\theta) функционал ошибки.



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

\Delta \theta(t) = - \eta\nabla_\theta J(\theta(t)),

\theta(t + 1) = \theta(t) + \Delta \theta(t) = \theta(t)- \eta\nabla_\theta J(\theta(t)),

где t это номер шага, \eta размер шага обучения (learning rate). В результате шага оптимизации веса нейронной сети принимают новые значения.



Виды градиентного спуска


  • Пакетный градиентный спуск (batch gradient descent).

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

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

  • Mini-batch градиентный спуск

    Гибрид двух подходов SGD и BatchGD, в этом варианте изменение параметров происходит, беря в расчет случайное подмножество примеров обучающей выборки. Благодаря более точной аппроксимации полного градиента, процесс оптимизации имеет большую эффективность, не утрачивая при этом преимущества SGD. Поведение трёх описанных варианта хорошо проиллюстрировано на картинке ниже.


imageИсточник

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

SGD с импульсом и Nesterov Accelerated Gradient



Следующие две модификации SGD призваны помочь в решении проблемы попадания в локальные минимумы при оптимизации невыпуклого функционала.
image
Гладкая выпуклая функция
image
Функция с множеством локальных минимумов (источник)

Первая модификация



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

v(t) = \alpha v(t-1) + \eta \nabla_\theta J(\theta(t)),

\theta(t+1) = \theta(t) - v(t),

v(t) это накопленное среднее градиентов на шаге t, коэфициент \alpha \in [0,1] требуется для сохранения истории значений среднего (обычно выбирается близким к 0.9.



Вторая модификация


Nesterov accelerated gradient отличается от метода с импульсом, его особенностью является вычисление градиента при обновлении v(t) в отличной точке. Эта точка берётся впереди по направлению движения накопленного градиента:


v(t) = \alpha v(t-1) + \eta \nabla_\theta J( \theta(t) - \alpha v(t-1)),

\theta(t+1) = \theta(t) - v(t).


На картинке изображены различия этих двух методов.

image
источник

Красным вектором на первой части изображено направление градиента в текущей точке пространства параметров, такой градиент используется в стандартном SGD. На второй части красный вектор задает градиент сдвинутый на накопленное среднее. Зелеными векторами на обеих частях выделены импульсы, накопленные градиенты.

Заключение



В этой статье мы детально рассмотрели начала градиентного спуска с точки зрения оптимизации нейронных сетей. Поговорили о трёх разновидностях спуска с точки зрения используемых данных, и о двух модификациях SGD, использующих импульс, для достижения лучшего качества оптимизации невыпуклых и негладких функционалов ошибки. Дальнейшее изучение темы предполагает разбор адаптивных к частоте встречающихся признаков подходов таких как Adagrad, RMSProp, ADAM.

Данная статья была написана в преддверии старта курса Математика для Data Science от OTUS.

Приглашаю всех желающих записаться на demo day курса, в рамках которого вы сможете подробно узнать о курсе и процессе обучения, а также познакомиться с экспертами OTUS

ЗАПИСАТЬСЯ НА DEMO DAY
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru