A3C (Asynchronous Advantage Actor-Critic)

A3C – теория и план

Здравствуйте и вновь добро пожаловать на занятия по теме «Глубокое обучение с подкреплением: глубокое обучение на языке Python, часть 7».

В этой и следующей части курса мы будем изучать ещё один знаменитый алгоритм глубокого обучения с подкреплением, известный под названием A3C. A3C означает Asynchronous Advantage Actor-Criticасинхронная модель «субъект-критик». Как видите, в английском названии есть три «А» и одно «С», отсюда и название – А3С. Весьма радует то, что мы уже разобрали большую часть основ этого алгоритма.

Действительно, с точки зрения теории здесь нет ничего нового, что требовало бы обсуждения. В частности, мы уже охватили всё, что необходимо знать, в части, посвящённой градиентам стратегии. Как вы, вероятно, помните, окончательная форма алгоритма градиента стратегии, которую мы использовали, называлась модель «субъект-критик», где субъектом была нейронная сеть, которая параметризовала стратегию, а критиком была другая нейронная сеть, которая параметризовала функцию ценности. Также, как вы, должно быть, помните, преимущество – это просто термин, который мы используем для измерения разницы между действительной отдачей в состоянии s и ценностью в состоянии s:

Преимущество = GV(s).

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

Но вначале подробнее рассмотрим, как работают градиенты стратегии. Как вы помните, у нас должны быть две нейронные сети: сеть стратегии и сеть ценности. Можно представить, что сеть стратегии на выходе выдаёт π – стратегию, а сеть ценности – V – ценность. Эти сети имеют весовые коэффициенты θp и θv соответственно:

π(a|s, θp) = Нейросеть(вход: s, весы: θp),

V(s, θv) = Нейросеть(вход: s, весы: θv).

Целевая функция стратегии выводится из своего рода работы в обратном направлении от собственно фактического градиента стратегии или, другими словами, взятием интеграла от градиента. Это просто отдача G минус V(s), умноженный на логарифм π:

Обратите внимание, что здесь я представил это в виде функции потерь или, другими словами, то, что нужно минимизировать, а не максимизировать, потому что именно в таком виде она будет использоваться в Tensorflow.

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

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

while not done:

    a = pi.sample(s)

    s’, r, done = env.step(a)

    G = r + gamma * V(s’)

    Lp = -(G – V(s)) * log(pi(s))

    Lv = (G – V(s))^2

    θp = θp – коэффициент_обучения * d Lp / d θp

    θv = θv – коэффициент_обучения * d Lv / d θv

Итак, псевдокод работает следующим образом: пока не закончено, мы выбираем действие из сети стратегии и получаем a. Затем мы выполняем действие a в среде, получая s и r. Далее устанавливаем G = r + γ*V(s) и вычисляем потери стратегии и ценности. И наконец мы выполняем градиентный спуск, чтобы обновить θp и θv.

Одно небольшое отличие между этим алгоритмом и А3С заключается в том, что мы делаем N шагов за раз, что значит, что мы можем использовать отдачу методом N шагов, а не методом временных разниц. Таким образом, здесь мы видим ещё одну возможность для объединения разных методов, прежде изученных в этом курсе.

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

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

while not done:

    a = pi.sample(s)

    s’, r, done = env.step(a)

    G = r + gamma * V(s’)

    Lp = -(G – V(s)) * log(pi(s))

    Lv = (G – V(s))^2

    θp = θp – коэффициент_обучения * d Lp / d θp

    θv = θv – коэффициент_обучения * d Lv / d θv

Итак, псевдокод работает следующим образом: пока не закончено, мы выбираем действие из сети стратегии и получаем a. Затем мы выполняем действие a в среде, получая s и r. Далее устанавливаем G = r + γ*V(s) и вычисляем потери стратегии и ценности. И наконец мы выполняем градиентный спуск, чтобы обновить θp и θv.

Одно небольшое отличие между этим алгоритмом и А3С заключается в том, что мы делаем N шагов за раз, что значит, что мы можем использовать отдачу методом N шагов, а не методом временных разниц. Таким образом, здесь мы видим ещё одну возможность для объединения разных методов, прежде изученных в этом курсе.

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

В нашем случае k представляет собой лишь разные действия, а πk – просто вероятность данного действия на выходе сети стратегии.

В конце мы берём обычные потери градиента стратегии и добавляем энтропию, умноженную на константу регуляризации, которая управляет силой члена регуляризации:

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

Другим большим улучшением данного алгоритма является то, что теперь он способен включать нейронные сети как приближения функций; в частности, мы будем использовать свёрточные нейронные сети, как это было в глубоком Q-обучении для обработки кадров экрана игры Atari в качестве входных данных. Следует отметить, что, судя по всему, для простейшего градиента стратегии такой подход не срабатывает, но, как вы увидите, с А3С мы в состоянии использовать нейронные сети, и позже в этой лекции мы поясним, почему так происходит.

Что любопытно в алгоритме А3С – что он асинхронный. Другими словами, тут используется прежний наш алгоритм, но таким образом, чтобы он был асинхронным. В оставшейся части данной лекции я объясню, что именно это означает.

Прежде всего отметим, что в современных вычислениях нам часто необходимо запускать параллельные процессы. К примеру, предположим, что у нас есть цикл for для миллиона файлов, которые нужно обработать. В коде это очень легко: мы можем просто перебрать весь миллион файлов и запускать функцию обработки для каждого из них. Однако это медленный процесс. Если бы у нас было два миллиона файлов, то время, необходимое для выполнения такого кода просто удвоится. Что же делать?

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

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

Итак, каков же основной алгоритм А3С?

Концептуально нам нужен один набор мастер-сетей, один – для стратегии, и один для функции ценности. Их можно рассматривать как набор глобальных сетей. Тогда время от времени глобальная сеть будет отправлять свои весовые коэффициенты набору рабочих машин с копиями сетей стратегии и ценности, после чего каждая из этих рабочих машин сыграет несколько эпизодов в среде, используя свои текущие весовые коэффициенты сети. Исходя из собственного опыта, каждый из этих «работников» может также вычислить как обновления градиента стратегии, так и обновления функции ценности. Разумеется, надо иметь в виду, что каждый из «работников» знает лишь о собственных обновлениях. В конце «работники» отправляют свои градиенты глобальным сетям, чтобы глобальная сеть могла обновить свои параметры. Опять же, время от времени глобальная сеть передаёт новые обновлённые параметры обратно рабочим машинам. Таким образом, рабочие сети всегда работают с относительно свежей копией глобальной сети.

Итак, чем же это хорошо? Ну, прежде всего глобальная мастер-сеть остаётся без работы: она не принимает участия в действительной игре в среде, только работники. Таким образом работники – отсюда и название – выполняют всю работу: отыгрывают эпизод, вычисляют ошибки и находят обновление градиентов. Глобальная же мастер-сеть является «выгодоприобретателем» этих обновлений и, конечно же, поскольку глобальная мастер-сеть имеет множество работников, отыгрывающих эпизоды, она может параллельно собрать гораздо больше опыта, чем могла бы, играя в среде самостоятельно. Короче говоря, мы экономим время.

Но в таком способе действий есть и другое большое преимущество. Не забывайте, что в каждой игре всегда присутствует определённый элемент случайности – каждая игра начинается по-разному, да и каждое действие, выбранное из вероятностной стратегии, будет отличаться. Таким образом, каждая игра, в которую играют работники, будет отличаться от других. Если помните, иногда в обучении с подкреплением производительность нашего агента может резко упасть из-за неудачного обновления. Имея множество работников, вносящих свой вклад в глобальную мастер-сеть, мы можем уменьшить дисперсию наших обновлений и получить более стабильную траекторию обучения. По-другому говоря, что мы получим, имея множество разных работников, отыгрывающих разные эпизоды с одними и теми же параметрами? Мы получим большую стабильность! Это как вместо того, чтобы выполнять стохастический градиентный спуск, где рассматривается один пример за раз, мы можем выполнить пакетный градиентный спуск, где рассматривается за раз несколько примеров. Как вы, должно быть, помните, это значительно сглаживает величину потерь на каждой итерации.

Это же может напомнить вам часть, посвящённую глубоким Q-сетям, поскольку там мы также добавляли различные функции, помогавшие нам стабилизировать обучение, в частности, «замораживание» целевой сети и использование буфера воспроизведения опыта. Последнее позволяло нам рассматривать несколько примеров на каждом этапе обучения, что несколько напоминает пакетный градиентный спуск, и давало интуитивное понимание, каким образом это помогает нам стабилизировать обучение. Короче говоря, стабилизированное обучение – это хорошо, и мы можем достичь его, рассматривая несколько примеров одновременно. Именно это и получается в случае А3С путём наличия нескольких параллельных копий играющих агентов. Это же позволяет нам использовать нейронные сети в качестве приближённого отображения функций, хотя они плохо работают с обычными градиентами стратегии без распараллеливания. Другими словами, и глубокие Q-сети, и А3С пытаются решить одну и ту же задачу: как использовать нейронные сети в классических алгоритмах обучения с подкреплением, просто делают они это по-разному.

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

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

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

И наконец, третий файл – nets.py. Он будет содержать определение сетей стратегии и ценности.

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

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

Базовая структура второго файла – worker.py – состоит в следующем. Мы напишем функцию run, которая будет запускаться каждой цепочкой. Эта функция лишь запускает простенький цикл: вначале копируются параметры из глобальной сети в нашу локальную копию сети, затем запускается N этапов игры. В этот момент сохраняются все данные, которые необходимо передать в наши нейронные сети, такие как состояния, в которых мы очутились, и вознаграждения. Затем обновляются параметры глобальной сети при помощи градиентов из последних N локальных этапов. Чтобы получить представление о том, как это работает, рассмотрим всё с точки зрения следующих уравнений:

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

Другим большим улучшением данного алгоритма является то, что теперь он способен включать нейронные сети как приближения функций; в частности, мы будем использовать свёрточные нейронные сети, как это было в глубоком Q-обучении для обработки кадров экрана игры Atari в качестве входных данных. Следует отметить, что, судя по всему, для простейшего градиента стратегии такой подход не срабатывает, но, как вы увидите, с А3С мы в состоянии использовать нейронные сети, и позже в этой лекции мы поясним, почему так происходит.

Что любопытно в алгоритме А3С – что он асинхронный. Другими словами, тут используется прежний наш алгоритм, но таким образом, чтобы он был асинхронным. В оставшейся части данной лекции я объясню, что именно это означает.

Прежде всего отметим, что в современных вычислениях нам часто необходимо запускать параллельные процессы. К примеру, предположим, что у нас есть цикл for для миллиона файлов, которые нужно обработать. В коде это очень легко: мы можем просто перебрать весь миллион файлов и запускать функцию обработки для каждого из них. Однако это медленный процесс. Если бы у нас было два миллиона файлов, то время, необходимое для выполнения такого кода просто удвоится. Что же делать?

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

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

Итак, каков же основной алгоритм А3С?

Концептуально нам нужен один набор мастер-сетей, один – для стратегии, и один для функции ценности. Их можно рассматривать как набор глобальных сетей. Тогда время от времени глобальная сеть будет отправлять свои весовые коэффициенты набору рабочих машин с копиями сетей стратегии и ценности, после чего каждая из этих рабочих машин сыграет несколько эпизодов в среде, используя свои текущие весовые коэффициенты сети. Исходя из собственного опыта, каждый из этих «работников» может также вычислить как обновления градиента стратегии, так и обновления функции ценности. Разумеется, надо иметь в виду, что каждый из «работников» знает лишь о собственных обновлениях. В конце «работники» отправляют свои градиенты глобальным сетям, чтобы глобальная сеть могла обновить свои параметры. Опять же, время от времени глобальная сеть передаёт новые обновлённые параметры обратно рабочим машинам. Таким образом, рабочие сети всегда работают с относительно свежей копией глобальной сети.

Итак, чем же это хорошо? Ну, прежде всего глобальная мастер-сеть остаётся без работы: она не принимает участия в действительной игре в среде, только работники. Таким образом работники – отсюда и название – выполняют всю работу: отыгрывают эпизод, вычисляют ошибки и находят обновление градиентов. Глобальная же мастер-сеть является «выгодоприобретателем» этих обновлений и, конечно же, поскольку глобальная мастер-сеть имеет множество работников, отыгрывающих эпизоды, она может параллельно собрать гораздо больше опыта, чем могла бы, играя в среде самостоятельно. Короче говоря, мы экономим время.

Но в таком способе действий есть и другое большое преимущество. Не забывайте, что в каждой игре всегда присутствует определённый элемент случайности – каждая игра начинается по-разному, да и каждое действие, выбранное из вероятностной стратегии, будет отличаться. Таким образом, каждая игра, в которую играют работники, будет отличаться от других. Если помните, иногда в обучении с подкреплением производительность нашего агента может резко упасть из-за неудачного обновления. Имея множество работников, вносящих свой вклад в глобальную мастер-сеть, мы можем уменьшить дисперсию наших обновлений и получить более стабильную траекторию обучения. По-другому говоря, что мы получим, имея множество разных работников, отыгрывающих разные эпизоды с одними и теми же параметрами? Мы получим большую стабильность! Это как вместо того, чтобы выполнять стохастический градиентный спуск, где рассматривается один пример за раз, мы можем выполнить пакетный градиентный спуск, где рассматривается за раз несколько примеров. Как вы, должно быть, помните, это значительно сглаживает величину потерь на каждой итерации.

Это же может напомнить вам часть, посвящённую глубоким Q-сетям, поскольку там мы также добавляли различные функции, помогавшие нам стабилизировать обучение, в частности, «замораживание» целевой сети и использование буфера воспроизведения опыта. Последнее позволяло нам рассматривать несколько примеров на каждом этапе обучения, что несколько напоминает пакетный градиентный спуск, и давало интуитивное понимание, каким образом это помогает нам стабилизировать обучение. Короче говоря, стабилизированное обучение – это хорошо, и мы можем достичь его, рассматривая несколько примеров одновременно. Именно это и получается в случае А3С путём наличия нескольких параллельных копий играющих агентов. Это же позволяет нам использовать нейронные сети в качестве приближённого отображения функций, хотя они плохо работают с обычными градиентами стратегии без распараллеливания. Другими словами, и глубокие Q-сети, и А3С пытаются решить одну и ту же задачу: как использовать нейронные сети в классических алгоритмах обучения с подкреплением, просто делают они это по-разному.

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

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

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

И наконец, третий файл – nets.py. Он будет содержать определение сетей стратегии и ценности.

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

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

Базовая структура второго файла – worker.py – состоит в следующем. Мы напишем функцию run, которая будет запускаться каждой цепочкой. Эта функция лишь запускает простенький цикл: вначале копируются параметры из глобальной сети в нашу локальную копию сети, затем запускается N этапов игры. В этот момент сохраняются все данные, которые необходимо передать в наши нейронные сети, такие как состояния, в которых мы очутились, и вознаграждения. Затем обновляются параметры глобальной сети при помощи градиентов из последних N локальных этапов. Чтобы получить представление о том, как это работает, рассмотрим всё с точки зрения следующих уравнений:

Первый этап – вычисление градиента, являющегося локальным значением потерь относительно локальных весовых коэффициентов. Второй этап – обновление глобальных весовых коэффициентов при помощи градиентного спуска с локальными градиентами. Конечно, в Tensorflow это не будет выглядеть именно так, поскольку на самом деле мы не будем выписывать обновление градиентного спуска в явном виде. Вместо этого мы используем адаптивный алгоритм обучения, такой как RMSprop, а нашей действительной целью будет выяснить, какую функцию Tensorflow использовать для реализации такого своего рода смешанного обновления, когда градиент, использующийся для обновления весовых коэффициентов одной сети, на самом деле возникает из-за потерь в другой сети. Файл worker.py, вероятно, будет самым сложным из всех, так что просто подождите, чтобы увидеть конкретные детали. Только имейте в виду, что каждый из вышеперечисленных этапов на самом деле окажется несколькими функциями.

И последний файл, который мы обсудим, – nets.py. В нём реализуются сеть стратегии и сеть ценности, хотя и тут есть несколько важных деталей, о которых стоит поговорить, прежде чем углубиться в код. Прежде всего, эти сети будут свёрточными нейронными сетями. Это значит, что они будут состоять из серии свёрток, за которыми следует ряд полносвязных слоёв. К тому же обе сети будут иметь общее «тело». Другими словами, только последний слой каждой из нейронных сетей будет отличаться, а все предыдущие будут иметь общие весовые коэффициенты. С точки зрения концепции их можно представить в виде двуглавого дракона. Я не буду сейчас вдаваться в подробности реализации, но в Tensorflow мы используем области видимости переменных и аргумент reuse, указывающий Tensorflow использовать прежние, уже созданные весовые коэффициенты.

В нескольких следующих лекциях мы рассмотрим этот код в подробностях.

A3C – код, часть 1 (разминка)

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

import itertools

import threading

import time

import multiprocessing

import numpy as np

Вначале обсудим класс Worker. Не забывайте, что с точки зрения концепции мы собираемся создать несколько работников, которые будут работать параллельно. В конструкторе у нас есть два входных параметра – учётная запись работника id_ и глобальный счётчик global_counter. В самом конструкторе мы создадим ещё и третью переменную local_counter. Идея этой очень простой программки заключается в следующем. Нам нужен глобальный счётчик, который был бы общим для каждой цепочки задач. Каждая цепочка будет работать по циклу, причём при каждом проходе цикла глобальный счётчик будет увеличиваться на единицу. Как только глобальный счётчик достигнет 20, все цепочки закончат работу. Хитрость в том, что у каждого работника будет уходить разное время между проходами цикла. Это значит, что если, скажем, у нас есть два работника, которые вместе должны досчитать до 20, то один из них мог бы увеличить счётчик на единицу 13 раз, а второй – только 7 раз, но в целом это будет 20. Это объясняет код функции run.

class Worker:

  def __init__(self, id_, global_counter):

    self.id = id_

    self.global_counter = global_counter

    self.local_counter = itertools.count()

Функция run выполняется в бесконечном цикле. Вначале мы «спим» в течение случайного промежутка времени в диапазоне от нуля до двух секунд; затем увеличиваем глобальный счётчик, а также и локальный. После этого выводим на экран учётные записи работников с их локальными счетами и, наконец, проверяем, не является ли глобальный счёт большим или равным 20, и если да, то прерываем цикл.

  def run(self):

    while True:

      time.sleep(np.random.rand()*2)

      global_step = next(self.global_counter)

      local_step = next(self.local_counter)

      print(“Worker({}): {}”.format(self.id, local_step))

      if global_step >= 20:

        break

Итак, чего стоит ожидать при запуске? Предположим, у нас есть 8 работников, а общий счёт равен 20. Тогда средний счёт равен 20/8 = 2,5. В действительности же некоторые цепочки могут досчитать лишь до единицы, тогда как другие – до 4 или 5.

Перейдём теперь к основной части программы. Прежде всего мы создаём переменную global_counter, являющуюся ориентированной на многопотоковое исполнение. Она просто будет увеличиваться – 0, 1, 2, 3 и так далее. Но поскольку она ориентирована на многопотоковость, то когда одна из цепочек увеличит счёт, все остальные цепочки будут об этом знать. Далее мы получаем количество доступных процессоров путём вызова функции multiprocessing.cpu_count и присваиваем это значение переменной NUM_WORKERS.

global_counter = itertools.count()

NUM_WORKERS = multiprocessing.cpu_count()

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

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

# create the workers

workers = []

for worker_id in range(NUM_WORKERS):

  worker = Worker(worker_id, global_counter)

  workers.append(worker)

Во втором блоке создаётся список цепочек задач работников. Мы перебираем по циклу каждого работника и запускаем лямбда-функцию из каждой функции run работников. Это связано с тем, что нам нужно получить функцию без каких-либо параметров. Далее создаётся цепочка задач и вставляется название только что созданной функции – теперь цепочка знает, как вызвать эту функцию. Создав цепочку, мы вызываем функцию start для запуска цепочки. Это будет вызывать функцию run работников, но каждая из них будет работать параллельно. И в конце мы добавляем цепочку задач к нашему списку цепочек.

# start the threads

worker_threads = []

for worker in workers:

  worker_fn = lambda: worker.run()

  t = threading.Thread(target=worker_fn)

  t.start()

  worker_threads.append(t)

И последний наш блок – блок join. Зачем же он нужен? Обычно когда у нас есть цепочка задач, с точки зрения концепции её можно рассматривать как своего рода ответвление от основной программы. Другими словами, основная программа запускает какую-либо цепочку, но её не волнует, что будет с цепочкой после этого. То есть, запустив цепочку, мы считаем свою работу сделанной и можем двигаться дальше. С точки зрения этого кода, мы в конце, когда вся работа закончена, выводим сообщение «Done!». Однако мы не можем этого сделать, поскольку запуск цепочки – это не то же самое, что и ожидание, пока цепочка не закончит работу. Следовательно, мы не можем запускать наши цепочки и выводить «Done!», пока цепочки работают. Вместо этого сообщение «Done!» должно выводиться только тогда, когда все цепочки закончат свою работу. Для этого и нужна функция join. Мы продемонстрируем этот код дважды – с функцией join и без неё.

# join the threads

for t in worker_threads:

  t.join()

print(“DONE!”)

Итак, вначале запустим программу с функцией join. Как видите, сообщение «Done!» выводится в конце работы, как и ожидалось. Обратите также внимание, что конечный счёт каждого из работников отличается от других. Это связано с тем, что все они находятся в ожидании разное количество времени, так что некоторые досчитывают до 4, тогда как другие до 3 или 2.

Теперь запустим ту же программу без функции join. Как видите, сообщение «Done!» выводится сразу в начале, что конечно, бессмысленно.

A3C – код, часть 2

Теперь, когда вы уже понимаете, как работают основы многопотоковости, в этой лекции мы начнём рассматривать настоящий код для А3С. Первый файл, который мы обсудим – main.py.

Вначале идут импорты большинства наших обычных библиотек, таких как Numpy, Matplotlib и Tensorflow. Кроме того, для многопотоковости нам нужно импортировать модули threading и multiprocessing. Из файла nets.py мы импортируем функцию create_networks, а из файла worker.py – класс Worker.

import gym

import sys

import os

import numpy as np

import tensorflow as tf

import matplotlib.pyplot as plt

import itertools

import shutil

import threading

import multiprocessing

from nets import create_networks

from worker import Worker

После этого мы определяем некоторые константы. Прежде всего это среда. А3С работает в нескольких средах игр Atari, так как же и в некоторых средах не из игр Atari; мы будем придерживаться среды игры Breakout, поскольку именно на неё уже настроили гиперпараметры. Величину MAX_GLOBAL_STEPS мы установим равной 5 миллионам – это как в нашем предыдущем примере по основам многопотоковости, где у нас было общее количество объединённых этапов работы для каждого работника. И наконец, у нас есть переменная STEPS_PER_UPDATE – это число этапов работы, которое выполняет работник перед тем, как вычислить градиент и отправить его назад глобальной сети.

ENV_NAME = “Breakout-v0”

MAX_GLOBAL_STEPS = 5e6

STEPS_PER_UPDATE = 5

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

Затем мы вручную присваиваем значение переменной NUM_ACTIONS. Не забывайте, что Breakout имеет несколько раздражающую особенность: среда содержит действия, которые на самом деле не соответствуют действительным действиям, так что тогда как действительное число действий равно четырём, она возвращает шесть. Среда для игры Pong делает то же самое, поэтому для Breakout и Pong мы устанавливаем количество действий вручную.

def Env():

  return gym.envs.make(ENV_NAME)

# Depending on the game we may have a limited action space

if ENV_NAME == “Pong-v0” or ENV_NAME == “Breakout-v0”:

  NUM_ACTIONS = 4 # env.action_space.n returns a bigger number

else:

  env = Env()

  NUM_ACTIONS = env.action_space.n

  env.close()

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

def smooth(x):

  # last 100

  n = len(x)

  y = np.zeros(n)

  for i in range(n):

    start = max(0, i – 99)

    y[i] = float(x[start:(i+1)].sum()) / (i – start + 1)

  return y

После этого мы определяем величину NUM_WORKERS – количество создаваемых работников. Для определения этой величины используется функция multiprocessing.cpu_count.

# Set the number of workers

NUM_WORKERS = multiprocessing.cpu_count()

В блоке with мы первым делом создаём переменную Tensorflow global_step. Если вы внимательнее к ней присмотритесь, то сможете уяснить, что вид у неё такой, будто мы нигде не собираемся её использовать. Однако она нужна библиотеке Tensorflow для некоторых вычислений, которые мы собираемся произвести, поэтому если вы попытаетесь убрать её из кода, тот станет неработоспособным. По-видимому, это просто плохой дизайн Tensorflow.

with tf.device(“/cpu:0”):

  # Keeps track of the number of updates we’ve performed

  # https://www.tensorflow.org/api_docs/python/tf/train/global_step

  global_step = tf.Variable(0, name=”global_step”, trainable=False)

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

  # Global policy and value nets

  with tf.variable_scope(“global”) as vs:

    policy_net, value_net = create_networks(NUM_ACTIONS)

Далее мы создаём итератор global_counter, очень схожий на тот, который мы использовали в примере по основам многопотоковости. Он гарантирует, что мы пройдём только заранее определённое количество объединённых этапов.

  # Global step iterator

  global_counter = itertools.count()

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

  # Save returns

  returns_list = []

Затем мы создаём наших работников. И вновь-таки, по сути это то же самое, что у нас было в примере по основам многопотоковости, только у этих новых работников больше аргументов. Так, сюда включены имя работника worker  – это просто строка символов worker, соединённая с номером, среда env, глобальная сеть стратегии policy_net, глобальная сеть ценности value_net, глобальный счётчик global_counter, список отдач returns_list, коэффициент обесценивания gamma, и максимальное количество глобальных этапов max_global_steps. Отсюда следует, что работник знает, когда прекращать работу, проверяя значение global_counter и сравнивая его с max_global_steps.

  # Create workers

  workers = []

  for worker_id in range(NUM_WORKERS):

    worker = Worker(

      name=”worker_{}”.format(worker_id),

      env=Env(),

      policy_net=policy_net,

      value_net=value_net,

      global_counter=global_counter,

      returns_list=returns_list,

      discount_factor = 0.99,

      max_global_steps=MAX_GLOBAL_STEPS)

    workers.append(worker)

После этого мы начинаем сессию Tensorflow и запускаем наших рабочих. Как обычно, мы должны начать с запуска инициатора глобальных переменных, а вот затем идёт кое-что новенькое – координатор объектов Tensorflow. Координатор объектов помогает нам координировать завершение работы цепочек. Как видите, это похоже на функцию join, которая использовалась в предыдущем примере с цепочками.

with tf.Session() as sess:

  sess.run(tf.global_variables_initializer())

  coord = tf.train.Coordinator()

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

  # Start worker threads

  worker_threads = []

  for worker in workers:

    worker_fn = lambda: worker.run(sess, coord, STEPS_PER_UPDATE)

    t = threading.Thread(target=worker_fn)

    t.start()

    worker_threads.append(t)

Вне цикла мы вызываем функцию join координатора для цепочек работников. Обратите внимание, что это позволяет нам установить «льготный период». В основном каждая цепочка в некоторый момент будет пытаться остановиться, и в этот момент у неё будет 5 минут, чтобы  действительно завершить работу. Если же цепочки не завершат работу, то в конце льготного периода координатор выдаст ошибку.

  # Wait for all workers to finish

  coord.join(worker_threads, stop_grace_period_secs=300)

И последнее, что мы делаем в этой программе – строим график собранных отдач. В дополнение к этому мы построим график сглаженных отдач, чтобы можно было чётче видеть улучшение во времени.

  # Plot the smoothed returns

  x = np.array(returns_list)

  y = smooth(x)

  plt.plot(x, label=’orig’)

  plt.plot(y, label=’smoothed’)

  plt.legend()

  plt.show()

В следующей лекции мы рассмотрим другие файлы этого примера – nets.py и worker.py.

Андрей Никитенко
Андрей Никитенко
Задать вопрос эксперту
Понравилась статья? Поделить с друзьями: