Привет, Хабр! ??

Меня зовут Олег Булыгин, я data scientist, аналитик, автор и спикер IT-курсов.

Я готовлю разный полезный контент, туториалы и руководства по Python, которыми бы хотел делиться с вами :)

Давайте рассмотрим, как использовать линейную алгебру и тензорные операции, чтобы создать всем известную игру в 12 строк.

И у вас сразу точно возникает несколько вопросов:

  1. Насколько длинные эти 12 строк?

    Не волнуйтесь, все они соответствуют стандарту PEP8.

  2. Зачем это вообще делать?

    Иногда надо писать код просто ради фана. Кроме того, это отличный способ познакомиться с PyTorch и возможностями, которые предоставляют тензоры.

  3. Но этом же нет никакой практической пользы?

    Напротив. Методы, используемые в этой материале, на самом деле являются фундаментальными. И они лежат в основе модуля TensorSnake, который может эмулировать параллельно 100 миллионов игр "Змейка" на карте NVIDIA A6000 с задержкой 20 миллисекунд.

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

Будем использовать PyTorch и NumPy. Можно было использовать даже какую-то одну из библиотек, но у PyTorch прекрасное Tensor API, а в NumPy есть хорошая функция под названием unravel_index, которую мы и будем использовать.

И договоримся, что в подсчёт строк не будут входить импорты и строка с определением функции ;)

Вопросы закрыли, зафиксировали договорённости, поехали!

Кодировка

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

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

Поэтому всю игру мы представим в виде матрицы целых чисел, где каждая пустая ячейка в игре будет иметь значение 0, хвост змеи будет 1, и по мере приближения хвоста к голове значение клеток будет увеличиваться на ещё одну единицу. Места с едой (целью) определяются значением -1. Итак, для змеи размера N клеток хвост будет равен 1, а голова — N.

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

Реализация

Ну и наконец, пишем код.

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

Получение текущей позиции

Первое, что нужно сделать — это получить текущее и предыдущее положение головы змеюки. Мы можем сделать это с помощью topk(2), так как голова всегда является самым большим целым числом, а предыдущая её позиция — второе по величине число. Единственная проблема, с которой мы сталкиваемся, заключается в том, что метод topk делает расчёт только по одному измерению. Поэтому нам нужно сначала разгладить тензор с помощью метода flatten(), получить максимальные k элементов, а затем использовать вышеупомянутый unravel_index, чтобы преобразовать его обратно в двухмерное состояние. И нам надо полученные два индекса в тензоры, чтобы мы могли выполнять математические вычисления и с ними.

Вычисление следующей позиции

Чтобы вычислить следующую позицию, мы сделаем pos_cur - pos_prev. Эта операция вернёт вектор, указывающий на текущее направление движения змеи. Далее мы хотим повернуть его, но насколько?

Мы хотим повернуть его на 270 + 90 * action градусов. Таким образом, когда мы будем передавать 0, то мы поворачиваем налево, 1 — мы двигаемся прямо, а 2 — поворачиваем направо.

Для получения результата мы применяем матрицу вращения. Если матрица применяется к самой себе, это даёт нам матрицу, которая эквивалентна двойному применению преобразования. Следовательно, мы можем взять вектор направления и применить матрицу вращения на 90 градусов против часовой стрелки T([[0, -1], [1, 0]]), возведённую в степень 3 + action.

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

Как умереть

Ах, извечный вопрос. Но пока мы о змейке.

Поскольку теперь у нас есть следующая позиция, становится довольно просто определить, должна ли змейка умереть или нет. Нам просто нужно проверить, является ли snake[tuple(pos_next)] > 0, так как единственными клетками со значениями больше 0 являются те, в которых в данный момент находится змея.

Если змейка умирает, мы хотим вернуть счёт текущей игры. Это также довольно просто, поскольку счёт в игре равен длине змейки минус 2 (предполагая, что мы начинаем игру при длине змеи 2). Чтобы получить длину, нам просто нужно получить значение pos_cur, так как это текущая голова змеи. Это означает, что текущий счет равен snake[tuple(pos_cur)] — 2.

Как кушать

Время собраться с духом, следующие 3 строчки — самые сложные в игре.

Чтобы проверить, поймала ли змейка еду, мы сравниваемsnake[pos_next] с -1. Если они равны, то нам нужно найти все позиции на доске, которые в данный момент равны 0. Это пустые ячейки, куда мы потенциально можем положить следующую цель.

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

Чтобы найти все места, которые в данный момент равны 0, мы просто используем snake == 0 (это возвращает логический тензор). Далее мы делаем .multinomial(1) для того, чтобы выбрать одну из позиций наугад. Функция multinominal(n) выбирает n случайных индексов из тензора с вероятностью, основанной на значении элемента.

Однако multinomial работает только с одной размерностью (как и topk), а также принимает только значения с плавающей точкой. Следовательно, нам нужно сначала использовать методы flatten() и .to(t.float). Таким образом, каждый индекс, значение которого равно 0, имеет одинаковую вероятность выбора, а каждый индекс, значение которого не равно 0, имеет нулевую вероятность выбора.

Как только это будет сделано, нам нужно снова использовать unravel, чтобы вернуть всё к двухмерному состоянию и обновить тензор snake.

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

Однако, мы хотим уменьшить змею только в том случае, если змейка не поймала цель. Если же поймала, то мы хотим увеличить её размер на 1 ячейку.

Поэтому мы добавляем блок else к ветке if на случай уменьшения. Поскольку каждая ячейка змейки пронумерована, у хвоста значение 1, мы можем вычесть 1 из значения каждой ячейки, размер которой больше 0 (так как только ячейки самой змейки вообще больше нуля). Вот мы и подрезали змейку на 1 клетку.

Теперь нам нужно добавить ей голову на новую позицию, т.е. установить значение следующей позиции, как значение предыдущей +1.

Заключение

А вот и всё. Вы написали "Змейку" в 12 строк кода.

def do(snake: t.Tensor, action: int):
    positions = snake.flatten().topk(2)[1]
    [pos_cur, pos_prev] = [T(unravel(x, snake.shape)) for x in positions]
    rotation = T([[0, -1], [1, 0]]).matrix_power(3 + action)
    pos_next = (pos_cur + (pos_cur - pos_prev) @ rotation) % T(snake.shape)
    
    if (snake[tuple(pos_next)] > 0).any():
        return (snake[tuple(pos_cur)] - 2).item() 
    
    if snake[tuple(pos_next)] == -1:
        pos_food = (snake == 0).flatten().to(t.float).multinomial(1)[0]
        snake[unravel(pos_food, snake.shape)] = -1
    else:
        snake[snake > 0] -= 1  
        
    snake[tuple(pos_next)] = snake[tuple(pos_cur)] + 1

Интерфейс

А, дак вы и поиграть в неё еще хотите?

Создание простенького графического интерфейса будет стоить нам ещё 15 строк, держите:

snake = t.zeros((32, 32), dtype=t.int)
snake[0, :3] = T([1, 2, -1]) 

fig, ax = plt.subplots(1, 1)
img = ax.imshow(snake)
action = {'val': 1}
action_dict = {'a': 0, 'd': 2}

fig.canvas.mpl_connect('key_press_event',
                       lambda e: action.__setitem__('val', action_dict[e.key]))

score = None
while score is None: 
    img.set_data(snake)
    fig.canvas.draw_idle()
    plt.pause(0.1) 
    score = do(snake, action['val']) 
    action['val'] = 1 
    
print('Score:', score)

Теперь можете играть сколько душе угодно :)

?Если тебе интересны и другие полезные материалы по Python и IT, то подписывайся на мой канал в tg: PythonTalk ?

Комментарии (21)


  1. GennPen
    24.04.2024 07:49
    +8

    Будем использовать PyTorch и NumPy. Можно было использовать даже какую-то одну из библиотек, но у PyTorch прекрасное Tensor API, а в NumPy есть хорошая функция под названием unravel_index, которую мы и будем использовать.

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


  1. Riot91
    24.04.2024 07:49

    А что будет если она себя укусит?


    1. obulygin Автор
      24.04.2024 07:49

      Игра прервётся, пройгрышь :)


      1. Jaffarr
        24.04.2024 07:49
        +3

        Йгра тогда уж :)


      1. Zara6502
        24.04.2024 07:49

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

        Ну и конечно вот правильный вариант "Игра прервётся, проигрыш :)"


        1. obulygin Автор
          24.04.2024 07:49

          У меня в мобильном браузере нет правки орфографии :(

          Поторопился, написал криво, бывает) Ничего особо страшного в этом нет. Не в ваших же комментах теперь ошибки править)

          Кстати, оставлять доброжелательную критику без примеси токсичности - отличный навык, рекомендую ^_^


          1. Zara6502
            24.04.2024 07:49

            У меня в мобильном браузере нет правки орфографии :(

            А, ну ок, только отмазку принять всё равно не смогу, так как в телефоне при наборе движением телефон сам подставляет слова из словаря, так что чтобы написать неправильно вы должны сознательно нажать на "й" и "ь", то есть результат ошибки это не опечатка.

            Ничего особо страшного в этом нет

            Воспитание и уважение к собеседникам вы называете простым словом "ничего". Ну в общем-то ожидаемо от современных людей.

            Не в ваших же комментах теперь ошибки править

            Ну если бы вы написали "выигрышь", то я бы вполне стерпел бы такое, ну подумалось вам что там мягкий звук. Но вставленная "й" расставляет все точки над "i".

            Кстати, оставлять доброжелательную критику без примеси токсичности

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

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


            1. obulygin Автор
              24.04.2024 07:49

              Вас понял, спасибо за критику :) Отлично, что лучше менять знаете, как именно я набирал текст)
              У вас примерно миллион запятых в комментах пропущено, ошибок на предложение ещё больше, но далеко идущих выводов об уважении и безграмотности, конечно, на этом основании никто делать не будет)

              Ничего, будем вместе повышать грамотность тогда ^_^


              1. Zara6502
                24.04.2024 07:49

                У вас примерно миллион запятых в комментах пропущено

                Не миллион, а ровно столько, сколько не влияет на понимание написанного.

                ошибок на предложение ещё больше

                Буду рад услышать конструктивные указания на ошибки, конечно с цитатами.

                Например это "А я не верю в перевоспитанию взрослых" - опечатка, а не ошибка. Надеюсь вам не нужно читать лекцию о разнице в опечатках и ошибках?

                но далеко идущих выводов об уважении и безграмотности

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

                Отлично, что лучше менять знаете, как именно я набирал текст)

                Методом исключения. ПК мы исключили, а на мобилке вы или тыкаете по буквам или водите линиями, так как при вождении линиями телефон вставляет правильные слова, то вы набирали по буквам, а значит и сами принимали решение на какую именно букву нажать. Буквы "и" и "й" находятся в разных частях клавиатуры, а "ь" вы либо знаете где ставить либо не знаете. Вы не знаете. Не нужно быть Холмсом чтобы сделать выводы.

                Ничего, будем вместе повышать грамотность тогда

                Книжки читайте побольше, а не залихватский твиттер и будет у вас "насмотренность" на грамотное написание слов, это самый простой способ, даже правила учить не нужно.


          1. Zara6502
            24.04.2024 07:49

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


  1. Dominux
    24.04.2024 07:49
    +1

    Интересный метод


  1. yri066
    24.04.2024 07:49
    +1

    Надеюсь что в пайтоне можно убрать все переносы и получится код максимально в одну строку


    1. Crd5
      24.04.2024 07:49

      Большинство новых строк (\n) можно заменить точкой с запятой (;), но это не соответствует PEP8


  1. zxcrstl
    24.04.2024 07:49

    Класс, только начинаю учить питона, было интересно почитать, буду дома попробую сам потыкать кнопки


  1. vaa_msu
    24.04.2024 07:49

    А не проще ли было написать: rotation = T([[0, -1], [1, 0]])**(3 + action)


    1. obulygin Автор
      24.04.2024 07:49

      Нет, ** - это поэлементное возведение в степень, а не матричное умножение. Так змейка сойдёт с ума :(


      1. vaa_msu
        24.04.2024 07:49
        +1

        Да, Вы правы. Никак не привыкну (после Julia) к поэлементности некоторых операций Python с объектами. Попытался запустить игру на своём "зоопарке" микрокомпьютеров -- после установки (в разные версии систем) python3-torch -- и обнаружил, что .matrix_power() работает так, как написано в коде, далеко не везде (иногда не запускается из-за неправильного типа параметра).

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

        if action == 1:
            rotation = T([[1, 0], [0, 1]])
        else:
            rotation = T([[0, -1], [1, 0]]) if action else T([[0, 1], [-1, 0]])
        


  1. Classic_Fungus
    24.04.2024 07:49
    +2

    мне кажется, что все статьи типа "как сделать %app_name% на python" выглядят как:

    -import библиотека1, 2,3

    библиотека 1,2,3 doSomething()

    ура, у нас получилось %app_name%


    1. Zara6502
      24.04.2024 07:49

      В своё время для меня Delphi стал языком, в котором, кажется, написали за меня всё что можно, а потом через 20 лет я познакомился с C# и за последние 7-8 лет там такого прикрутили что уууухххх.


  1. Zara6502
    24.04.2024 07:49

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


  1. Zara6502
    24.04.2024 07:49
    +1

    Создание простенького графического интерфейса будет стоить нам ещё 15 строк

    беспощадный кликбейт