Привет, Хабр! ??
Меня зовут Олег Булыгин, я data scientist, аналитик, автор и спикер IT-курсов.
Я готовлю разный полезный контент, туториалы и руководства по Python, которыми бы хотел делиться с вами :)
Давайте рассмотрим, как использовать линейную алгебру и тензорные операции, чтобы создать всем известную игру в 12 строк.
И у вас сразу точно возникает несколько вопросов:
-
Насколько длинные эти 12 строк?
Не волнуйтесь, все они соответствуют стандарту PEP8.
-
Зачем это вообще делать?
Иногда надо писать код просто ради фана. Кроме того, это отличный способ познакомиться с PyTorch и возможностями, которые предоставляют тензоры.
-
Но этом же нет никакой практической пользы?
Напротив. Методы, используемые в этой материале, на самом деле являются фундаментальными. И они лежат в основе модуля 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)
Riot91
24.04.2024 07:49А что будет если она себя укусит?
obulygin Автор
24.04.2024 07:49Игра прервётся, пройгрышь :)
Zara6502
24.04.2024 07:49Мне вот одно не ясно, вы и дата-сайнтист и аналитик и автор и питона вон знаете как своего, но отчего вы не в состоянии в браузере увидеть красную волнистую линию под словом и ради вежливости к читателям (хотя бы так) не нажать правую кнопку мышки и не исправить ошибки?
Ну и конечно вот правильный вариант "Игра прервётся, проигрыш :)"
obulygin Автор
24.04.2024 07:49У меня в мобильном браузере нет правки орфографии :(
Поторопился, написал криво, бывает) Ничего особо страшного в этом нет. Не в ваших же комментах теперь ошибки править)
Кстати, оставлять доброжелательную критику без примеси токсичности - отличный навык, рекомендую ^_^
Zara6502
24.04.2024 07:49У меня в мобильном браузере нет правки орфографии :(
А, ну ок, только отмазку принять всё равно не смогу, так как в телефоне при наборе движением телефон сам подставляет слова из словаря, так что чтобы написать неправильно вы должны сознательно нажать на "й" и "ь", то есть результат ошибки это не опечатка.
Ничего особо страшного в этом нет
Воспитание и уважение к собеседникам вы называете простым словом "ничего". Ну в общем-то ожидаемо от современных людей.
Не в ваших же комментах теперь ошибки править
Ну если бы вы написали "выигрышь", то я бы вполне стерпел бы такое, ну подумалось вам что там мягкий звук. Но вставленная "й" расставляет все точки над "i".
Кстати, оставлять доброжелательную критику без примеси токсичности
А я не верю в перевоспитанию взрослых, только токсичность может заставить измениться, так как доброжелательность не будет мотиватором.
И смотрите как у нас вышло, вы безграмотный, но доброжелательный, а я токсичный и недоброжелательный - как вы лихо знаки поменяли и я уже тут плохой, обижаю почём зря людей.
obulygin Автор
24.04.2024 07:49Вас понял, спасибо за критику :) Отлично, что лучше менять знаете, как именно я набирал текст)
У вас примерно миллион запятых в комментах пропущено, ошибок на предложение ещё больше, но далеко идущих выводов об уважении и безграмотности, конечно, на этом основании никто делать не будет)Ничего, будем вместе повышать грамотность тогда ^_^
Zara6502
24.04.2024 07:49У вас примерно миллион запятых в комментах пропущено
Не миллион, а ровно столько, сколько не влияет на понимание написанного.
ошибок на предложение ещё больше
Буду рад услышать конструктивные указания на ошибки, конечно с цитатами.
Например это "А я не верю в перевоспитанию взрослых" - опечатка, а не ошибка. Надеюсь вам не нужно читать лекцию о разнице в опечатках и ошибках?
но далеко идущих выводов об уважении и безграмотности
Ну выводы делаются не просто на факте самих ваших ошибок, а и на отношении к ним. Если вы уж такой позитивный балагур, то вполне могли написать "каюсь, невнимательно, по безграмотности, тыкал на кнопки, не проверил набранное и отправил. Сути это не изменит, но хотя бы даст понять что вы осознаёте происходящее и понимаете отрицательный эффект сделанного вами.
Отлично, что лучше менять знаете, как именно я набирал текст)
Методом исключения. ПК мы исключили, а на мобилке вы или тыкаете по буквам или водите линиями, так как при вождении линиями телефон вставляет правильные слова, то вы набирали по буквам, а значит и сами принимали решение на какую именно букву нажать. Буквы "и" и "й" находятся в разных частях клавиатуры, а "ь" вы либо знаете где ставить либо не знаете. Вы не знаете. Не нужно быть Холмсом чтобы сделать выводы.
Ничего, будем вместе повышать грамотность тогда
Книжки читайте побольше, а не залихватский твиттер и будет у вас "насмотренность" на грамотное написание слов, это самый простой способ, даже правила учить не нужно.
Zara6502
24.04.2024 07:49Да, кстати, а представьте я бы написал статью, где у меня был бы код на питоне (как принято выражаться "говнокод"), поверьте, сожрали бы с потрохами за каждый символ и разговор про токсичность не помог бы. А тут, фи, подумаешь, какой-там русский язык... явно не питон.
zxcrstl
24.04.2024 07:49Класс, только начинаю учить питона, было интересно почитать, буду дома попробую сам потыкать кнопки
vaa_msu
24.04.2024 07:49А не проще ли было написать: rotation = T([[0, -1], [1, 0]])**(3 + action)
obulygin Автор
24.04.2024 07:49Нет, ** - это поэлементное возведение в степень, а не матричное умножение. Так змейка сойдёт с ума :(
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]])
Classic_Fungus
24.04.2024 07:49+2мне кажется, что все статьи типа "как сделать %app_name% на python" выглядят как:
-import библиотека1, 2,3
библиотека 1,2,3 doSomething()
ура, у нас получилось %app_name%
Zara6502
24.04.2024 07:49В своё время для меня Delphi стал языком, в котором, кажется, написали за меня всё что можно, а потом через 20 лет я познакомился с C# и за последние 7-8 лет там такого прикрутили что уууухххх.
Zara6502
24.04.2024 07:49Кстати во многих реализация змейки (игры) если пойти в самого себя то погибаешь как и при укусе своего тела, но многие варианты просто не позволяют так ходить.
Zara6502
24.04.2024 07:49+1Создание простенького графического интерфейса будет стоить нам ещё 15 строк
беспощадный кликбейт
GennPen
С таким же успехом можно было бы написать свою библиотеку для работы змейки и вызывать ее одной строкой.