Изучение основ библиотеки обучения с подкреплением

Начать работать с глубоким обучением с подкреплением непросто.

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

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

Вот где на помощь приходит Ray. Ray существует с 2017 года, он разработан UC Berkeley’s RISE Lab, он предназначен для предоставления масштабируемого, распараллеливаемого обучения с подкреплением для практиков и исследователей без необходимости самостоятельно реализовывать модели.

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

TL;DR

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

Первое распараллеливание с помощью Ray

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

Обычно программы, которые вы пишете на Python, выполняются последовательно, то есть один шаг за другим. Во многих приложениях это нормально. Однако, учитывая, что современные машины - будь то ваш ноутбук или сервер AWS - имеют несколько процессоров, вы можете воспользоваться этим, устраняя проблемы, чтобы их можно было запускать одновременно, параллельно, чтобы получить огромное ускорение!

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

Распараллеливание таймера

Для начала нам нужно установить Ray с помощью pip install ray. Следует отметить, что на момент написания этой статьи Ray будет работать только на машинах Linux и MacOs и совместим только с Python 3.5–3.7 (проверьте документацию на наличие обновлений). Если ваш компьютер не соответствует этим требованиям, вы можете перейти в Google Colab и получить бесплатный доступ к записной книжке, где вы сможете запустить этот код.

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

import time
import numpy as np
import ray

Мы определим функцию таймера, которая принимает аргумент x, ждет 1 секунду, а затем возвращает x. Это совершенно бесполезно, но будет иллюстрировать последовательное и параллельное питание, которое у нас есть.

def timer(x):
    time.sleep(1)
    return x

Теперь, приурочивая его:

t0 = time.time()
values = [timer(x) for x in range(4)]
print('Time Elapsed:\t{:.4f}'.format(time.time() - t0))
print(values)
Time Elapsed:	4.0043
[0, 1, 2, 3]

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

Перед распараллеливанием нам нужно инициализировать Ray с помощью ray.init(), где мы можем установить количество имеющихся у нас ЦП. Если вы не знаете, что у вас есть, просто запустите следующее:

ray.init()
ray.available_resources()['CPU']
8.0

Как вы видите выше, на моей машине 8 процессоров, которые я могу использовать для параллельного выполнения моего процесса. Если я не передаю конкретное значение при вызове ray.init, он будет использовать все 8. Я собираюсь повторно инициализировать ray, имея в распоряжении только 4 ЦП, чтобы каждый ЦП обрабатывал один из вызовов нашей функции таймера независимо. Обратите внимание: если вы повторно инициализируете луч из IDE, например Jupyter, вы должны передать аргумент ignore_reinit_error=True, иначе вы получите сообщение об ошибке или вам придется перезапустить ядро.

ray.init(num_cpus=4, ignore_reinit_error=True)

Чтобы распараллелить нашу функцию с Ray, нам просто нужно украсить ее remote.

@ray.remote
def timer_ray(x):
    time.sleep(1)
    return x

Запустив тот же код, что и выше, мы имеем:

t0 = time.time()
values = [timer_ray.remote(x) for x in range(4)]
print('Time Elapsed:\t{:.4f}'.format(time.time() - t0))
print(values)
Time Elapsed:	0.0025
[ObjectID(7dec8564195ad979ffffffff010000c801000000), 
ObjectID(0bead116322a6c2bffffffff010000c801000000), 
ObjectID(b944ee5bb38dd1a5ffffffff010000c801000000), 
ObjectID(2a124e2070438a75ffffffff010000c801000000)]

Надеюсь, это вам покажется странным. Во-первых, затраченное время не соответствует ожидаемой 1 секунде, а во-вторых, результаты выглядят как полная чушь. Рэй здесь измеряет время, необходимое для создания идентификаторов объектов для запуска, а не время, необходимое для запуска самого кода. Это то, что мы видим, когда печатаем values список: список идентификаторов объектов, которые указывают на эти задачи. Чтобы Рэй действительно оценил эти функции, нам нужно вызвать ray.get().

ray.get(values)
[0, 1, 2, 3]

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

t0 = time.time()
values = ray.get([timer_ray.remote(x) for x in range(4)])
print('Time Elapsed:\t{:.4f}'.format(time.time() - t0))
print(values)
Time Elapsed:	1.0106
[0, 1, 2, 3]

Теперь мы получили ожидаемый результат!

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

Распараллеливание скользящих средних

Существует множество финансовых показателей и стратегий, требующих вычисления скользящих средних. Иногда вам нужна простая 90-дневная скользящая средняя, ​​иногда - 10-дневная или какое-то другое значение. Это не так уж и плохо, если вам нужно иметь дело только с несколькими временными рядами. Но, как это часто бывает, вам может потребоваться регулярно рассчитывать простые скользящие средние для тысяч различных ценных бумаг. Если это так, мы можем воспользоваться преимуществом распараллеливания и добиться больших успехов.

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

data = np.random.normal(size=(1000, 1000))

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

Функция ниже даст нам желаемый результат.

def calc_moving_average(data, window=10):
    ma_data = np.zeros(data.shape)
    for i, row in enumerate(data):
        ma_data[i] = np.array(
            [np.mean(row[j-window:j+1]) 
             if j > window else np.mean(row[:j+1]) 
             for j, _ in enumerate(row)])        
    return ma_data

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

Будем рассчитывать время, как в приведенных выше примерах.

t0 = time.time()
ma_data = calc_moving_average(data)
seq_time = time.time() - t0
print('Time Elapsed:\t{:.4f}'.format(seq_time))
Time Elapsed:	7.9067

На это уходит около 8 секунд. Посмотрим, сможем ли мы добиться большего, используя наш @ray.remote декоратор для функции.

@ray.remote
def calc_moving_average_ray(data, window=10):
    ma_data = np.zeros(data.shape)
    for i, row in enumerate(data):
        ma_data[i] = np.array(
            [np.mean(row[j-window:j+1]) 
             if j > window else np.mean(row[:j+1]) 
             for j, _ in enumerate(row)])        
    return ma_data
t0 = time.time()
ma_data = ray.get(calc_moving_average_ray.remote(data))
par_time = time.time() - t0
print('Time Elapsed:\t{:.4f}'.format(par_time))
print('Speed up:\t{:.1f}X'.format(seq_time / par_time))
print("Results match:\t{}".format(np.allclose(ma_data, ma_data_ray)))
Time Elapsed:	7.6218
Speed up:	1.0X
Results match:	True

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

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

@ray.remote
def calc_moving_average_ray(row, window=10):
    return np.array([np.mean(row[j-window:j+1]) 
             if j > window else np.mean(row[:j+1]) 
             for j, _ in enumerate(row)])
t0 = time.time()
ma_data_ray = np.array(ray.get(
    [calc_moving_average_ray.remote(row) 
    for row in data]
    ))
par_time = time.time() - t0
print('Time Elapsed:\t{:.4f}'.format(par_time))
print('Speed up:\t{:.1f}X'.format(seq_time/par_time))
print("Results match:\t{}".format(np.allclose(ma_data, ma_data_ray)))
Time Elapsed:	2.2801
Speed up:	3.5X
Results match:	True

Теперь у нас есть ускорение в 3,5 раза, чего мы и ожидали теперь, когда мы распараллелили наш процесс на 4 процессорах, а не на одном процессоре. Все, что нам нужно было изменить, - это способ ввода данных в функцию. Передав каждую строку данных в функцию, мы распараллелили этот алгоритм на более низком и более значимом уровне.

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

Луч для RL

У Рэя есть две другие библиотеки, построенные на его основе, RLLIB и Tune, обе из которых невероятно мощны для реализации алгоритмов обучения с подкреплением. Они используют преимущества распараллеливания, о котором мы говорили здесь, и я расскажу об этих библиотеках и ключевых функциях в следующих статьях.