Хотите стать классным разработчиком? Работайте с UX


Введение

Методы онлайнового обучения машин (ОО) — это семейство динамических алгоритмов обучения с подкреплением, которое стоит за кулисами многих достижений во всей области ИИ за последние десять лет. Методы ОО относятся к классу обучения на сэмплах из раздела методов с подкреплением. Они позволяют определять значения состояния благодаря простым повторяющимся наблюдениям.

Подходы ОО (в отличие от своих “офлайновых” братьев) позволяют по нарастающей обновлять значения состояний и поведения внутри, так называемых, пространственных эпизодов. Еще с ними удобно наблюдать за растущим постоянно уровнем производительности.

Эволюция в УВР (учёт временной разницы) привела к возрастающим оценкам и улучшению значений состояний и поведения. О Q-обучении узнали как о базе для подходов сферы обучения с подкреплением. Оно было нужно для симуляций игровых пространств, например, на таких платформах-“спортзалах”, как OpenAI Gym. Но в этой статье не будет обсуждения теоретических аспектов.

Способы ОО подходят высокодинамичным окружениям, где значения состояний и поведения постоянно обновляются во времени и в наборах оценок из-за быстрой ответной реакции внутри эпизодов (в том числе УВР). Возможно, самое важное то, что УВР — это основа Q-обучения, более продвинутого алгоритма для тренировки агентов, который имеет дело с игровыми окружениями, которые можно увидеть в OpenAI Atari Gym.

В этом материале изучим, как можно использовать Q-обучение для агента, который играет в классический шутер от первого лица вроде Doom. Будем делать это с помощью открытой библиотеки-враппера Vizdoomgym. Мы настроим вашего первого агента, а также заложим базу для дальнейшней работы.

Выходя за пределы УВР: SARSA и Q-обучение

В УВР поведение агента циклично в пространстве в последовательности состояний (State), действий (Action) и наград (Reward). 

В процессе УВР мы можем обновить значение предыдущего состояния сразу же, как только достигаем следующей стадии. Дальше мы расширяем объем своей модели, чтобы в нее входили значения состояния-действия, согласно подходу SARSA (“состояние-действие-награда-состояние-действие”), регламентированному алгоритму управления УВР, который нужен для оценки значений поведения.

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

Давайте сравним уравнения обновлений состояния-действия и состояния-значения УВР:

Q-обучение отличается от SARSA принудительным выбором действия с текущим максимальным значением в процессе обновления. Так же происходит и в похожем подходе, который наблюдается в уравнениях оптимальности Беллмана.

Изучим алгоритм SANSA и Q-обучение на фоне метода Беллмана и его уравнений оптимальности:

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

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

Теория закончилась, переходим к реализации. 

Реализация 

Наша реализация в Colaboratory от Google написана на Python с помощью Tensorflow. Ее можно найти на GradientCrescent Github.

Реализация с таким подходом достаточно непростая, поэтому давайте обобщим порядок необходимых действий:

1. Мы определяем нашу нейросеть глубокого Q-обучения. Это РНС, которая делает скриншоты во время игры и выводит вероятности для каждого из действий или Q-значений в игровом пространстве Ms-Pacman. Чтобы получить тензор вероятностей, нам не нужно добавлять никакой функции активации в финальный слой. 

2. Так как для Q-обучения мы должны знать и текущие, и следующие состояния, нам стоит начать с генерации данных. Мы “скармливаем” предварительно обработанные входные изображения игровому пространству, предоставляем исходные состояния s нашей нейронной сети и получаем исходную вероятность распределения действий или Q-значений. До тренировки эти значения будут появляться случайно и неоптимально. 

3. С тензором вероятностей мы готовы выбрать действие с текущей высшей вероятностью при помощи функции argmax() и использовать ее для создания правил эпсилон-жадного алгоритма.

4. Используя наши правила мы выбираем действие a, и оцениваем наше решение в окружении gym — чтобы получить информацию по новому состоянию s’ , награде r и информацию о том, закончился ли эпизод. 

5. Мы сохраняем это сочетание информации в буфер в формате списка <s,a,r,s’,d> и повторяем шаги 2–4 для пресета с числами в промежутке времени, чтобы создать достаточно большой буферный датасет. 

6. Как будет закончен 5-й шаг, двигаемся к генерации целевых y-значений, R’ и A’. Они нужны для расчета ошибки. Предыдущее — это просто уменьшенное значение R. Амы получаем A`, передавая S` в сеть.

7. У нас есть все компоненты пространства и мы готовы рассчитать ошибку для тренировки сети. 

8. По окончании тренировки мы оцениваем производительность нашего агента в новом игровом эпизоде и записываем её. 

Приступим-с. Вместе с Tensorflow 2 и окружениями из Colaboratory мы конвертировали код в совместимый с TF2 формат. Нам помог новый пакет compat. 

Помните, что этот код не является нативным для TF2.

Импортируем все пакеты, без которых не обойтись, в том числе окружения OpenAI и Vizdoomgym, а также Tensorflow:

import gym import vizdoomgym !pip install tensorflow==1.15 import numpy as np import tensorflow as tf from tensorflow.contrib.layers import flatten, conv2d, fully_connected from collections import deque, Counter import random from datetime import datetime

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

from skimage.color import rgb2gray from skimage import transform #предварительно обработаем uint8-фрейм (240, 320, 3) в плавающий одномерный вектор 30x40 color = np.array([240, 320, 74]).mean() def preprocess_observation(obs): img =obs/255.0 img[img==color] = 0 img_gray = rgb2gray(img) preprocessed_frame = transform.resize(img_gray, [60,80]) return preprocessed_frame

Инициализируем окружение gym. Будем пользоваться сценарием получения здоровья Vizdoomgym. Его цель — собрать максимально возможное количество коробок здоровья, чтобы остаться в живых, пока он перемещается в квадратной комнате с опасной кислотой на полу. 

env = gym.make(‘VizdoomHealthGathering-v0’) n_outputs = env.action_space.n print(n_outputs) observation = env.reset() import tensorflow as tf import matplotlib.pyplot as plt for i in range(22): if i > 20: print(observation.shape) plt.imshow(observation) plt.show() observation, _, _, _ = env.step(1)

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

Сырые наблюдения в качестве исходных данных

Воспользуемся этим шансом для сравнения нашего оригинала и предварительно обработанных вводных изображений:

Вводные данные в виде предварительно обработанного изображения

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

Применяем компоновку фрейма так: берём два исходных фрейма и возвращаем сумму поэлементных максимумов maxframe от них двух. Затем эти скомпонованные фреймы сохраняются в двухсторонней очереди или стеке, который автоматически убирает устаревшие записи с появлением новых. 

stack_size = 4 # Всего в стеке 4 скомпонованных фрейма stacked_frames = deque([np.zeros((60,80), dtype=np.int) for i in range(stack_size)], maxlen=4) def stack_frames(stacked_frames, state, is_new_episode): # Предварительная обработка фрейма frame = preprocess_observation(state) if is_new_episode: # Очистка фреймов из стека stacked_frames stacked_frames = deque([np.zeros((60,80), dtype=np.int) for i in range(stack_size)], maxlen=4) # Так как мы в новом эпизоде, копируем тот же фрейм 4 раза, применяем поэлементный максимум maxframe = np.maximum(frame,frame) stacked_frames.append(maxframe) stacked_frames.append(maxframe) stacked_frames.append(maxframe) stacked_frames.append(maxframe) # Помещаем фреймы в стек stacked_state = np.stack(stacked_frames, axis=2) else: #Так как двусторонняя очередь добавляет t справа, то мы можем получить самый крайний элемент справа maxframe=np.maximum(stacked_frames[-1],frame) # Добавляем фрейм в двустороннюю очередь, автоматически удаляется самый старый фрейм stacked_frames.append(maxframe) # Создаем состояние стека (первое измерение определяет разные фреймы) stacked_state = np.stack(stacked_frames, axis=2) return stacked_state, stacked_frames

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

Заметьте, что здесь нет слоев активации, иначе на выходе бы было бы бинарное распределение.

tf.compat.v1.reset_default_graph() def q_network(X, name_scope): # Инициализируем слои initializer = tf.compat.v1.keras.initializers.VarianceScaling(scale=2.0) with tf.compat.v1.variable_scope(name_scope) as scope: # Инициализация сверточного слоя layer_1 = conv2d(X, num_outputs=32, kernel_size=(8,8), stride=4, padding=’SAME’, weights_initializer=initializer) tf.compat.v1.summary.histogram(‘layer_1’,layer_1) layer_2 = conv2d(layer_1, num_outputs=64, kernel_size=(4,4), stride=2, padding=’SAME’, weights_initializer=initializer) tf.compat.v1.summary.histogram(‘layer_2’,layer_2) layer_3 = conv2d(layer_2, num_outputs=64, kernel_size=(3,3), stride=1, padding=’SAME’, weights_initializer=initializer) tf.compat.v1.summary.histogram(‘layer_3’,layer_3) # Уплощенный результат слоя_3 до передачи данных полносвязному слою flat = flatten(layer_3) # Вставить полносвязный слой fc = fully_connected(flat, num_outputs=128, weights_initializer=initializer) tf.compat.v1.summary.histogram(‘fc’,fc) # Добавить результирующий слой output = fully_connected(fc, num_outputs=n_outputs, activation_fn=None, weights_initializer=initializer) tf.compat.v1.summary.histogram(‘output’,output) # Переменные будут хранить параметры сети, например, веса vars = {v.name[len(scope.name):]: v for v in tf.compat.v1.get_collection(key=tf.compat.v1.GraphKeys.TRAINABLE_VARIABLES, scope=scope.name)} # Возвращаем вместе переменные и результаты return vars, output

Теперь давайте определим гиперпараметры нашей модели и тренировочного процесса. Обратите внимание, что X_shape (None, 60, 80, 4) сейчас находится в расчете нашего стека с фреймами.

num_episodes = 1000 batch_size = 48 input_shape = (None, 60, 80, 1) learning_rate = 0.002 # Изменяется для собранных в стек фреймов X_shape = (None, 60, 80, 4) discount_factor = 0.99 global_step = 0 copy_steps = 100 steps_train = 4 start_steps = 2000

Вспомните, что Q-обучение требует, чтобы мы выбирали действия с самыми высокими значениями. Чтобы убедиться в том, что мы всё ещё смотрим каждую отдельную возможную комбинацию состояний-действий, мы сделаем так, чтобы наш агент следовал многослойным правилам эпсилон-жадной стратегии с долей обучения в 5%.

epsilon = 0.5 eps_min = 0.05 eps_max = 1.0 eps_decay_steps = 500000 def epsilon_greedy(action, step): p = np.random.random(1).squeeze() #Одномерные записи возвращаются при помощи squeeze epsilon = max(eps_min, eps_max — (eps_max-eps_min) * step/eps_decay_steps) #Разделение на большее количество шагов if p< epsilon: return np.random.randint(n_outputs) else: return action

Вспоминаем из уравнений выше, что обновление функции для Q-обучения требуют следующего:

  • текущее состояние s;
  • текущее действие a;
  • награда за текущее действие r;
  • следующее состояние s’;
  • следующее действие a’.

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

Давайте теперь создадим буфер с простой функцией сэмплирования:

buffer_len = 20000 exp_buffer = deque(maxlen=buffer_len) def sample_memories(batch_size): perm_batch = np.random.permutation(len(exp_buffer))[:batch_size] mem = np.array(exp_buffer)[perm_batch] return mem[:,0], mem[:,1], mem[:,2], mem[:,3], mem[:,4]

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

# строим сеть Q, которая принимает на входе X и генерирует Q-значения для всех действий внутри состояния mainQ, mainQ_outputs = q_network(X, ‘mainQ’) # чтобы оценить правила, мы создаём итоговую сеть Q похожим образом targetQ, targetQ_outputs = q_network(X, ‘targetQ’) copy_op = [tf.compat.v1.assign(main_name, targetQ[var_name]) for var_name, main_name in mainQ.items()] copy_target_to_main = tf.group(*copy_op)

В конце мы также определим ошибку. Это всего-навсего квадратная разница целевого (с наивысшим значением действия) и предсказанного действий. Мы будем использовать оптимизатор ADAM (адаптивная оценка момента), чтобы минимизировать ошибку в процессе тренировки:

# определяем плейсхолдер для нашего вывода, например, действия y = tf.compat.v1.placeholder(tf.float32, shape=(None,1)) # вычисляем значение ошибки - разницу между текущим и предсказанным значениями loss = tf.reduce_mean(input_tensor=tf.square(y — Q_action)) # для оптимизации ошибки пользуемся оптимизатором adam optimizer optimizer = tf.compat.v1.train.AdamOptimizer(learning_rate) training_op = optimizer.minimize(loss) init = tf.compat.v1.global_variables_initializer() loss_summary = tf.compat.v1.summary.scalar(‘LOSS’, loss) merge_summary = tf.compat.v1.summary.merge_all() file_writer = tf.compat.v1.summary.FileWriter(logdir, tf.compat.v1.get_default_graph())

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

  • На каждом этапе мы передаём стек изображений в виде вводных данных внутрь нашей сети, чтобы генерировать вероятностное распределение доступных действий. Распределения генерируются до применения правил эпсилон-жадного к следующему действию. 
  • Дальше мы вводим всё это в сеть и получаем информацию о следующем состоянии и сопутствующих наградах, сохраняя её в буфер. Обновляем стек и повторяем процесс столько раз, сколько шагов предопределено. 
  • Когда буфер станет достаточно большим, мы отдаём следующие состояния в нашу сеть, чтобы получить следующее действие, и рассчитываем следующую награду, уменьшая текущую. 
  • Мы генерируем итоговые Y-значения в процессе работы функции обновления Q-обучения и тренируем сеть. 
  • Чтобы вывести улучшенные значения состояния-поведения для следующего правила, мы обновляем весовые параметры сети, уменьшая ошибки тренировки.
with tf.compat.v1.Session() as sess: init.run() # для каждого эпизода history = [] for i in range(num_episodes): done = False obs = env.reset() epoch = 0 episodic_reward = 0 actions_counter = Counter() episodic_loss = [] # первый шаг, предварительная обработка и стек инициализации obs,stacked_frames= stack_frames(stacked_frames,obs,True) # пока состояние не переходное while not done: # генерируем данные с нетренированной сетью # отдаем игровой экран и получаем значения Q для каждого действия actions = mainQ_outputs.eval(feed_dict={X:[obs], in_training_mode:False}) # получаем действие action = np.argmax(actions, axis=-1) actions_counter[str(action)] += 1 # выбираем действие по стратегии эпсилон-жадной action = epsilon_greedy(action, global_step) # выполняем действие и переходим в следующее состояние next_obs, получаем награду next_obs, reward, done, _ = env.step(action) #в новом эпизоде обновлен стек фреймов next_obs, stacked_frames = stack_frames(stacked_frames, next_obs, False) # сохраняем этот переход как опыт в буфере повтора exp_buffer.append([obs, action, next_obs, reward, done]) #После определенных шагов тренируем нашу сеть Q с примерами из буфера повтора опыта if global_step % steps_train == 0 and global_step > start_steps: o_obs, o_act, o_next_obs, o_rew, o_done = sample_memories(batch_size) # состояния o_obs = [x for x in o_obs] # следующие состояния o_next_obs = [x for x in o_next_obs] # следующие действия next_act = mainQ_outputs.eval(feed_dict={X:o_next_obs, in_training_mode:False}) # уменьшенная награда: значения Y y_batch = o_rew + discount_factor * np.max(next_act, axis=-1) * (1-o_done) # собираем все итоги и записываем их в файл mrg_summary = merge_summary.eval(feed_dict={X:o_obs, y:np.expand_dims(y_batch, axis=-1), X_action:o_act, in_training_mode:False}) file_writer.add_summary(mrg_summary, global_step) # выполняем ранее определенные функции, о которых мы говорили при передаче данных на вход для расчета ошибки. train_loss, _ = sess.run([loss, training_op], feed_dict={X:o_obs, y:np.expand_dims(y_batch, axis=-1), X_action:o_act, in_training_mode:True}) episodic_loss.append(train_loss) # после некоторого интервала мы копируем веса нашей главной сети Q в итоговую Q-сеть if (global_step+1) % copy_steps == 0 and global_step > start_steps: copy_target_to_main.run() obs = next_obs epoch += 1 global_step += 1 episodic_reward += reward next_obs=np.zeros(obs.shape) exp_buffer.append([obs, action, next_obs, reward, done]) obs= env.reset() obs,stacked_frames= stack_frames(stacked_frames,obs,True) history.append(episodic_reward) print(‘Epochs per episode:’, epoch, ‘Episode Reward:’, episodic_reward,”Episode number:”, len(history))

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

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

img_array=[] with tf.compat.v1.Session() as sess: init.run() observation, stacked_frames = stack_frames(stacked_frames, observation, True) while True: # передаем экран игры и получаем значения Q для каждого действия actions = mainQ_outputs.eval(feed_dict={X:[observation], in_training_mode:False}) # получаем действие action = np.argmax(actions, axis=-1) actions_counter[str(action)] += 1 # выбираем действие согласно эпсилон-жадному правилу select the action using epsilon greedy policy action = epsilon_greedy(action, global_step) environment.render() new_observation, stacked_frames = stack_frames(stacked_frames, new_observation, False) observation = new_observation # теперь выполняем действие и переходим в следующее состояние next_obs, получаем награду new_observation, reward, done, _ = environment.step(action) img_array.append(new_observation) if done: #наблюдение = env.reset() break environment.close()

Наконец, мы можем взять наш список фреймов и “скормить” их библиотеке skvideo.io  —  сгенерируем выходную видео-последовательность для изучения:

from random import choice import cv2 from google.colab.patches import cv2_imshow import numpy as np import skvideo.io out_video = np.empty([len(img_array), 240, 320, 3], dtype = np.uint8) out_video = out_video.astype(np.uint8) for i in range(len(img_array)): frame = img_array[i] out_video[i] = frame # Записываем выходные последовательности изображений в видео-файл skvideo.io.vwrite(“/content/doom.mp4”, out_video)

Посмотрим на нашего агента в действии!

Обратите внимание, как агент приостанавливается, когда замечает ящик здоровья прямо перед тем, как начинает двигаться к нему. Ну и просто веселья ради мы натренировали агента по базовому сценарию, где цель — поразить монстра как можно скорее. Лучшее время пока что: около 1,3 секунды, показываем его ниже на одном из более ранних эпизодов.

Такая вот упаковка имплементации Q-обучения.

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


Перевод статьи


Поделиться статьей:


Вернуться к статьям

Комментарии

    Ничего не найдено.