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

Наш код машинного обучения живет в этом репозитории. Для этой статьи вам нужно взглянуть на ветку randomize-moves. Посмотрите здесь оригинальный код игры. Вам понадобится ветка q-learning в основном репо.

В этой части серии используются Haskell и Tensor Flow. Чтобы узнать больше об их совместном использовании, загрузите наше Руководство по Haskell Tensor Flow Guide!

Неконтролируемое машинное обучение

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

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

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

Но q-обучение — это неконтролируемый алгоритм. Мы заставляем наш ИИ исследовать мир и учиться самостоятельно. Но прямо сейчас он делает только те ходы, которые считает «оптимальными». Но со случайным набором весов «оптимальные» ходы совсем не оптимальны! Частью хорошего плана «исследования» является возможность время от времени выбирать ходы, которые не кажутся оптимальными.

Добавление случайного выбора

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

Однако в ходе обучения мы хотим уменьшить этот случайный шанс. Теоретически наш ИИ должен улучшаться по мере обучения сети. Поэтому по мере приближения к концу обучения нам нужно будет принимать меньше случайных решений и больше «наилучших» решений. Мы постараемся начать с этого параметра как 1 из 5 и уменьшить его до 1 из 50 по мере продолжения обучения. Итак, как мы это реализуем?

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

-- Third "Float" parameter is the random chance
runAllIterations :: Model -> World
  -> StateT ([Float, Int, Float) Session ()
...
trainGame :: World -> Session (Vector Float)
trainGame w = do
  model <- buildModel
  let initialRandomChance = 0.2
  (finalReward, finalWinCount, _) <- execStateT
    (runAllIterations model w)
    ([], 0, initialRandomChance)
  run (readValue $ weightsT model)

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

runAllIterations :: Model -> World
  -> StateT ([Float, Int, Float) Session ()
runAllIterations model initialWorld = do
  let numIterations = 2000
  forM [1..numIterations] $ \i -> do
    gen <- liftIO getStdGen
    (wonGame, (_, finalReward, _)) <- runStateT
      (runWorldIteration model)
      (initialWorld, 0.0, gen)
    (prevRewards, prevWinCount, randomChance) <- get
    let modifiedRandomChance = 1.0 / ((fromIntegral i / 40.0) + 5)
    put (newRewards, newWinCount, modifiedRandomChance)
  return ()

Делать случайные ходы

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

runWorldIteration :: Model -> StateT (World, Float, StdGen)
  (StateT ([Float], Int, Float) Session) Bool
runWorldIteration model = do
  (prevWorld, prevReward, gen) <- get
  (_, _, randomChance) <- lift get
  ...

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

moveFromIndex :: Int -> PlayerMove
moveFromIndex bestMoveIndex =
  PlayerMove moveDirection useStun moveDirection
  where
    moveDirection = case bestMoveIndex `mod` 5 of
      0 -> DirectionUp
      1 -> DirectionRight
      2 -> DirectionDown
      3 -> DirectionLeft
      4 -> DirectionNone

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

chooseMoveWithRandomChance ::
  PlayerMove -> StdGen -> Float -> (PlayerMove, StdGen)
chooseMoveWithRandomChance bestMove gen randomChance =
  let (randVal, gen') = randomR (0.0, 1.0) gen
      (randomIndex, gen'') = randomR (0, 1) gen'
      randomMove = moveFromIndex randomIndex
  in  if randVal < randomChance
        then (randomMove, gen'')
        else (bestMove, gen')

Теперь осталось просто применить эту функцию, и все готово!

runWorldIteration :: Model -> StateT (World, Float StdGen)
  (StateT ([Float], Int, Float) Session) Bool
runWorldIteration model = do
  (prevWorld, prevReward, gen) <- get
  (_, _, randomChance) <- lift get
  ...
  let bestMove = ...
  let (newMove, newGen) = chooseMoveWithRandomChance
                            bestMove gen randomChance
  …
  put (nextWorld, prevReward + newReward, newGen)
  continuationAction

Вывод

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

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

Если вы никогда раньше не программировали на Haskell, вам может быть немного сложно перейти к машинному обучению. Ознакомьтесь с нашим Контрольным списком для начинающих и нашей Серией подъемов, чтобы начать!