Машинное обучение — это область компьютерных наук, занимающаяся обучением машин делать «умные» вещи, например писать рассказы, понимать картинки или торговать на фондовом рынке. В машинном обучении есть разные дисциплины, которые сосредоточены на разных способах обучения машины. Одним из них является обучение с подкреплением (RL), которое фокусируется на том, как машины могут учиться на опыте.

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

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

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

Среда, действия и награды

Как мышь в лабиринте, алгоритмы в RL учатся в окружающей среде. В общем, окружение может быть почти любым. Например, для продвинутого робота окружающей средой может быть буквально весь мир. Самый простой способ понять среды в RL — поиграть с ними — для этого мы воспользуемся одной из готовых сред, представленных в Gym OpenAI.

Чтобы установить Gym, просто запустите pip install gym в своей системе. Теперь в Jupyter Notebook вы можете запустить свою первую среду: Mountain Car, известную игрушечную задачку в RL:

import gym
# make a mountain car environment
env = gym.make('MountainCar-v0')
# reset the environment to its default starting position
env.reset() 
# visualize the environment
env.render()

Если все прошло хорошо, вы должны увидеть визуализацию мультяшной машины у подножия покатого холма. Это MountainCar: маленькая игра, которую на удивление сложно решить компьютеру.

Цель MountainCar — довести машину до флага на вершине крайнего правого холма. Чтобы управлять автомобилем, вам придется совершать действия в окружающей среде. В MountainCar всего три действия: ехать налево, ничего не делать или ехать направо. В нашей среде тренажерного зала Python эти действия соответствуют 0, 1 и 2 соответственно.

Чтобы выполнить действие, вы можете использовать функцию step среды. Пошаговая функция говорит среде перейти от текущего state к следующему состоянию — думайте об этом как об одном кадре анимации. Давайте попробуем сказать машине ехать прямо направо (2).

Для этого мы просто передаем действие 2 функции step среды MountainCar. Каждый раз, когда мы вызываем step, среда обрабатывает наше действие и вычисляет следующее состояние мира. Давайте вызовем функцию step для 100 временных шагов.

# reset the environment to its starting position
obs = env.reset()
for _ in range(100): # loop for 100 time steps
    # drive the car to the right
    next_obs, reward, done, info = env.step(2)
    env.render()

Во-первых, вы должны понимать, что это не лучшая стратегия для MountainCar — автомобиль не может сам по себе генерировать достаточный импульс, чтобы добраться до вершины холма. Вы также увидите, что каждый вызов step возвращает кучу важных значений.

  1. Первым из них является next_obs -- следующее наблюдение. Наблюдение — это то, что агент видит в окружающей среде. В случае горной машины next_obs – это просто список чисел, указывающий, где находится агент и как быстро он движется после выполнения действия, переданного step.
  2. reward — это единственное число, которое показывает, насколько «хорошо» положение агента в среде. Это сыр для мыши и единственный сигнал, сообщающий агенту, насколько хорошо он играет в игре. Положительное вознаграждение — это «хорошо», а отрицательное вознаграждение — «плохо». Цель RL — изучить стратегию, которая максимизирует вознаграждение, получаемое от окружающей среды.
  3. Переменная done сообщает, закончена ли игра. То есть done будет ложным до тех пор, пока машина не достигнет вершины горы, или пока игра не достигнет ограничения по времени (которое условно составляет около 1000 временных шагов).
  4. Наконец, переменная info предоставляет любую дополнительную информацию о мире, которая может быть полезна для вычислений, но может быть недоступна для нашего алгоритма игры. Мы пока проигнорируем это.

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

next_obs = env.reset()
for _ in range(200):  
  
    # decide what our next action should be,
    # based on our current observation of the environment
    if next_obs[1] > 0:
        action = 2
    else:
        action = 0
    
    next_obs, reward, done, info = env.step(action)
    
    # if we've won, stop playing
    if done:
        break
        
    env.render()

Вы понимаете, почему эта стратегия работает? В каждом наблюдении есть два числа — что они обозначают? Можете ли вы найти другую стратегию, которая работает? Каковы награды за каждый шаг (так RL научится играть в игру)?

Политики и функции ценности

Простой блок if-else в предыдущем примере кода — это наша первая политика. В RL политика — это функция, которая отображает состояния в действия. Убедитесь, что это так, для приведенного выше простого примера: на основе текущего состояния (наблюдения) среды мы выбираем действие.

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

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

import numpy as np
# choose number of gridpoints to use
n_grid = 10 
# calculate grids to discretize observations
gridspace = {}
for ii, s in enumerate(zip(env.observation_space.low, env.observation_space.high)):
    gridspace[ii] = np.linspace(s[0], s[1], n_grid)

Словарь gridspace предоставляет 10-точечные сетки для каждого измерения нашего наблюдения за окружающей средой. Так как мы использовали диапазон low и high пространства наблюдения среды, любое наблюдение попадет в какую-то точку нашей сетки. Давайте определим функцию, которая позволяет легко найти, в какие точки сетки попадает наблюдение:

# define a function that makes it easy to get our discretized indices from an obervation
def get_discretized_observation(observation):
    index = np.array([np.argmin(np.abs(grid-observation[ii]))
                          for (ii, grid) in gridspace.items()])
    return index

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

value_function = np.zeros([n_grid]*2)
print(f'Our value function has {len(value_function.flatten())} values, one for each state.')

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

def run_episode():
    ''' Collect data from a single episode. '''
    data = {}
    next_obs = env.reset()
    for t in range(1000):
        if next_obs[1] > 0:
            action = 2
        else:
            action = 0
        next_obs, reward, done, info = env.step(action)
        data[t] = {'observation': next_obs, 'reward': reward,
                   'done': done, 'info': info, 'action': action}
        if done:
            break
    return data

# prepare our value function, intializing each state to be infinitely unrewarding
value_function = -np.infty*np.ones([n_grid]*2)
# let's keep a counter of each time we saw a given (discretized) observation
state_counts = np.zeros([n_grid]*2)
n_episodes = 1000
for ep in range(n_episodes):
    data = run_episode()
    
    times = np.array([t for t in data.keys()])
    
    total_episode_reward = 0
    
    # loop backward in time, since the 
    # value of each state is its *future* reward
    for t in range(times[-1], -1, -1):
        total_episode_reward += data[t]['reward']
        ii = get_discretized_observation(data[t]['observation'])
        
        if value_function[ii[0], ii[1]] == -np.infty:
            value_function[ii[0], ii[1]] = 0.0
        
        state_counts[ii[0], ii[1]] += 1 # state counter
        value_function[ii[0], ii[1]] += total_episode_reward
        
        
# Now, we just compute the averages for our value function
value_function[state_counts>0] = value_function[state_counts>0] / state_counts[state_counts>0]

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

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

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

Функция «действие-значение»

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

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

# prepare our value function, intializing each state to be infinitely unrewarding
action_value_function = -np.infty*np.ones([n_grid]*2 + [3])
print(f'Our action-value function has {len(action_value_function.flatten())} entries.')

Теперь давайте посчитаем функцию «действие-ценность»:

def run_episode():
    ''' Collect data from a single episode. '''
    data = {}
    next_obs = env.reset()
    for t in range(1000):
        if next_obs[1] > 0:
            action = 2
        else:
            action = 0
        next_obs, reward, done, info = env.step(action)
        data[t] = {'observation': next_obs, 'reward': reward,
                   'done': done, 'info': info, 'action': action}
        if done:
            break
    return data

# prepare our value function, intializing each state to be infinitely unrewarding
action_value_function = -np.infty*np.ones([n_grid]*2 + [3])
# let's keep a counter of each time we saw a given (discretized) observation
state_counts = np.zeros([n_grid]*2 + [3])
n_episodes = 1000
for ep in range(n_episodes):
    data = run_episode()
    
    times = np.array([t for t in data.keys()])
    
    total_episode_reward = 0
    
    # loop backward in time, since the value
    # of each state is its *future* value
    for t in range(times[-1], -1, -1):
        total_episode_reward += data[t]['reward']
        ii = get_discretized_observation(data[t]['observation'])
        
        if action_value_function[ii[0], ii[1], data[t]['action']] == -np.infty:
            action_value_function[ii[0], ii[1], data[t]['action']] = 0.0
        
        state_counts[ii[0], ii[1], data[t]['action']] += 1 # state counter
        action_value_function[ii[0], ii[1], data[t]['action']] += total_episode_reward
        
        
# Now, we just compute the averages for our value function
action_value_function[state_counts>0] = action_value_function[state_counts>0] / state_counts[state_counts>0]

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

obs = env.reset()
for _ in range(1000): 
    
    # decide what our action will be given this observation
    ii = get_discretized_observation(obs)
    
    action = np.argmax( action_value_function[ii[0], ii[1]])
    
    obs, reward, done, info = env.step(action)
    env.render()
    if done:
        break

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

Это означает, что если бы мы могли найти способ изучить функцию «действие-ценность» с нуля (без нашей жестко запрограммированной стратегии), у нас был бы полностью автоматизированный метод RL.

Обучение с подкреплением

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

Мы будем использовать подход, называемый Q-learning. «Q» в Q-обучении относится к функции «действие-ценность», которую часто называют Q-функцией. Другими словами, Q-обучение — это всего лишь метод изучения оптимальной функции «действие-ценность».

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

alpha = .15
# our action-value function. we call it Q now, just to follow convention
Q = np.zeros([n_grid]*2 + [3])
n_episodes = 2000
for ep in range(n_episodes):
    obs = env.reset()
    ii = get_discretized_observation(obs)
    action = np.random.choice([0,1,2]) 
    
    for t in range(1000):
        next_obs, reward, done, info = env.step(action)
        next_ii = get_discretized_observation(next_obs)
        next_action = np.argmax(Q[next_ii[0], next_ii[1]])
        
        if done:
            if np.mod(ep, 100) == 0:
                print('Episode ended after %d steps' % t)
            if t < 199:
                # we'll help along learning 
                # by adding an extra reward when the agent succeeds
                Q[ii[0], ii[1], action] += alpha * (1 - Q[ii[0], ii[1], action])
            break
        else:
            Q[ii[0], ii[1], action] += alpha * (reward + np.max(Q[next_ii[0], next_ii[1]]) - Q[ii[0], ii[1], action])
        action = next_action
        obs = next_obs
        ii = next_ii

Самая большая разница между этим кодом и нашим предыдущим кодом заключается в том, что есть только два цикла: мы изучаем функцию «действие-ценность» во время игры, а не запускаем весь эпизод с фиксированной функцией «действие-ценность». Большая часть работы выполняется в одной строке: Q[ii[0], ii[1], action] += alpha * (reward + np.max(Q[next_ii[0], next_ii[1]]) - Q[ii[0], ii[1], action]). Эта строка обновляет значение действия предыдущего состояния с указанием вознаграждения и значения действия следующего состояния. Со временем это обновление будет сходиться к оптимальной функции «действие-ценность». Помните, что функция «действие-ценность» — это всего лишь мера того, насколько хороша (вознаграждает) каждая пара состояния и действия на самом деле.

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

obs = env.reset()
for _ in range(1000): 
    
    # decide what our action will be given this observation
    ii = get_discretized_observation(obs)
    
    action = np.argmax( Q[ii[0], ii[1]])
    
    obs, reward, done, info = env.step(action)
    env.render()
    if done:
        break

Вы должны увидеть, что ваши выученные Q могут быстро решить проблему с горным автомобилем. Если нет, попробуйте повторить или увеличить количество эпизодов (n_episodes), используемых для обучения.

Чем эта выученная Q отличается от функции действие-ценность, которую мы изучили ранее? Выполняет ли он другую политику?

Выводы

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

В будущих руководствах мы углубимся в Q-обучение, чтобы понять, как оно работает, и изучим несколько других алгоритмов в RL. Как только мы разберемся с основными алгоритмами, мы научимся масштабировать их с помощью нейронных сетей, чтобы мы могли решать проблемы, гораздо более масштабные, чем MountainCar.