Всем привет. Я любитель Python и совсем недолго осваиваю язык всеми доступными способами. Моя цель - понять принципы машинного обучения и его взаимосвязь с нейросетью. Никакого опыта в IT не имел, тем не менее постараюсь излагать общепринятой терминологией, не судите строго. Моя основная профессия (оперирующий травматолог, кандидат наук) не менее сложная, далека от IT, но для упрощения работы в нее все больше внедряются AI и ML. Мною движет лишь интерес к современным технологиям, программированию.

В первой части покажу только основные этапы создания игры, где пользователь выбирает роль (Х или О), играя с компьютером. Поиск в сети Python аналогов дал только несколько вариантов игры с рандомным ответом компьютера. Мой целью в этой части стало самостоятельно научиться оценивать текущую позицию на поле "Крестики-Нолики" и подбирать оптимальный вариант следующего хода компьютера. К слову, уже перед окончанием статьи нашел готовую web-игру в google, где уже реализован такой подход. Тем интереснее было проверить себя и поделиться "изобретением колеса, но по-своему".

Во второй части попробую прикрутить к игровой логике другой подход - машинное обучение на основе большого числа сыгранных партий компьютером с самим собой.

Кому будет полезен материал: любителям Python, логики, алгоритмов. В финальном коде все переменные, функции и действия прокомментированы на английском.

Содержание статьи:

  1. Зачем Крестикам‑Ноликам машинное обучение?

  2. Представление поля 3х3 в виде одномерного числового списка.

  3. Коротко о функциях.

  4. Карта лучших ходов и алгоритм для компьютера.

  5. Функция для Игрока.

  6. Недостаток алгоритма?

  7. Финальный код.

  8. Анонс второй части статьи

Зачем Крестикам‑Ноликам машинное обучение?

Ответ простой: шахматы и переводчики на основе ИИ. Эти популярные в жизни приложения по своей сути - сложная игра. Как мне казалось, все ходы как и переводы просчитать невероятно сложно. Однако движок на CHESS.com поражает, как и выходные результаты переведенного текста в DEEP-L. Оказывается, все профессиональные шахматные партии с XIX века внесли в базу ИИ (благо записывают их в виде шахматных формул). Затем разными алгоритмами организовали поиск оптимального хода. Выяснили, что оптимальный ход может устаревать, ведь шахматисты растут да и играет уже весь мир. А чем больше партий, тем достовернее их анализ (хоть и дольше). Придумали непрерывно пополнять шахматную базу в ИИ, снова обновлять лучшие варианты хода, расширяя критерии поиска. Так я решил, что надо подобрать какую-то простую известную игру, где машину можно обучить до идеала по этой схеме. Выбор пал Tic Tac Toe с полем 3х3. Но для начала мне нужно было еще ее создать с нуля. Обозначил себе этапы Machine Learning (тема второй части):

  • пополнить ИИ уже известными результатами игр,

  • придумать критерии и алгоритм для отбора оптимального хода.

Представление поля 3х3 в виде одномерного числового списка

Я решил уйти от многомерных списков list, которые здесь напрашиваются для изображения поля. Игрока "Х" обозначил как "-1", "О" как "1". Незаполненное поле останется "0". Это числовой тип данных. Наша игра - это одномерный список с 9ю аргументами на позициях от 0 до 8, значит начало: TTT=[0, 0, 0, 0, 0, 0, 0, 0, 0] (рис. 1 и 2):

Рис. 1. Для удобства позиция в списке равна клетке на поле 3х3. Начало игры: TTT=[0, 0, 0, 0, 0, 0, 0, 0, 0]
Рис. 1. Для удобства позиция в списке равна клетке на поле 3х3. Начало игры: TTT=[0, 0, 0, 0, 0, 0, 0, 0, 0]
Рис. 2. Пример списка, описывающего текущее поле TTT=[-1, 0, 1, 0, -1, 0, 0, 0, 1].
Рис. 2. Пример списка, описывающего текущее поле TTT=[-1, 0, 1, 0, -1, 0, 0, 0, 1].

Коротко о функциях

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

pygame: Выбрана простая графика и события. Библиотека осваивается за полчаса с гугл.

import pygame as pg, sys
from pygame.locals import *
import time, random

width = 400
height = 400
white = (255, 255, 255)
RED = (255, 0, 0)
BLACK = (0, 0, 0)
line_color = (10, 10, 10)

# initializing pygame window
pg.init()
fps = 30
CLOCK = pg.time.Clock()
screen = pg.display.set_mode((width, height + 100), 0, 32)
pg.display.set_caption("Tic Tac Toe")

# loading the images
opening = pg.image.load('tic tac opening.png')
x_img = pg.image.load('x.png')
o_img = pg.image.load('o.png')

# resizing images
x_img = pg.transform.scale(x_img, (80, 80))
o_img = pg.transform.scale(o_img, (80, 80))
opening = pg.transform.scale(opening, (width, height + 100))

user_click(): Выбор мышкой нужного поля без изысков: координаты внутри сектора определяют его номер как на рис.1., что заложено в числовую переменную move.

def user_click(): # mouse click
    global move
    move = None
    # get coordinates of mouse click
    x, y = pg.mouse.get_pos()
    # get x,y of mouse click (cell 0-8)
    if (y < height / 3) and (x < width / 3):
        move = 0
    elif (y < height / 3) and (x < width / 3 * 2):
        move = 1
    elif (y < height / 3) and (x < width):
        move = 2

    elif (y < height / 3 * 2) and (x < width / 3):
        move = 3
    elif (y < height / 3 * 2) and (x < width / 3 * 2):
        move = 4
    elif (y < height / 3 * 2) and (x < width):
        move = 5

    elif (y < height) and (x < width / 3):
        move = 6
    elif (y < height) and (x < width / 3 * 2):
        move = 7
    elif (y < height) and (x < width):
        move = 8

Переменные: XO - это очередь Х (-1) или О (1) оказаться в списке TTT=[ ]. Перменные ХО и move по умолчанию None, глобальные, числовые, поскольку будут использованы в разных функциях. Говорящие за себя winner и draw - победитель и ничья.

XO = None  # -1 is X-player, 1 is O-player
move = None # numbers from 0 to 8
winner = None
draw = False

    # TicTacToe 3x3 board
TTT= [0,0,0,0,0,0,0,0,0]
    # game field is shared on 9 cells with determination of each one from left to right in upper,middle & lower row:
    # 0,1,2 - upper row
    # 3,4,5 - middle row
    # 6,7,8 - lower row
    # totaly = 3x3 field = 9 numbers (from 0 to 8 considering that list [TTT] starts with 0 position)

сheck_win(): Перебором комбинаций текущего поля ТТТ получаем три -1 или 1 в ряду (но и не ноль!), вертикали или диагонали. Программа рисует линию через победную комбинацию и присваивает переменной winner значение -1 (Х) или 1 (О).

Рис.3. Два примера диагональных побед и схематичный вид списка ТТТ при этом.
Рис.3. Два примера диагональных побед и схематичный вид списка ТТТ при этом.
def check_win(): # check winner and drawing the appropriate lines
    global TTT, winner, draw

    # check for winning rows
    for row in range(0, 7, 3):  # jump through 3 in TTT list
        if ((TTT[row] == TTT[row + 1] == TTT[row + 2]) and (TTT[row] != 0)):
            # this row won
            winner = TTT[row]
            pg.draw.line(screen, (250, 0, 0), (0, (row/3 + 1) * height / 3 - height / 6), \
                         (width, (row/3 + 1) * height / 3 - height / 6), 6)
            break

    # check for winning columns
    for col in range(0, 3, 1):  # jump through 1 in TTT list
        if (TTT[col] == TTT[col + 3] == TTT[col + 6]) and (TTT[col] != 0):
            # this column won
            winner = TTT[col]
            # draw winning line
            pg.draw.line(screen, (250, 0, 0), ((col + 1) * width / 3 - width / 6, 0), \
                         ((col + 1) * width / 3 - width / 6, height), 6)
            break

    # check for diagonal winners
    if (TTT[0] == TTT[4] == TTT[8]) and (TTT[0] != 0):
        # game won diagonally left to right
        winner = TTT[0]
        pg.draw.line(screen, (250, 70, 70), (50, 50), (350, 350), 6)

    if (TTT[2] == TTT[4] == TTT[6]) and (TTT[2] != 0):
        # game won diagonally right to left
        winner = TTT[2]
        pg.draw.line(screen, (250, 70, 70), (350, 50), (50, 350), 6)

    if TTT.count(0) == 0 and winner is None:  # all cells are filled in
        draw = True

    game_status()

Карта лучших ходов и алгоритм для компьютера

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

  1. Представим самый простой вариант (Рис.4.), когда на 7м ходу Х (ХО=-1) нужно поставить именно туда, где уже есть ХХ рядом согласно правилам (диагональ Лево-Право):

Рис.4. В желтую клетку просится Х для победы (список ТТТ[8]). Для этого в списке mov_map[8] появляется красный -1 (собственно желаемый будущий ход именно Х).
Рис.4. В желтую клетку просится Х для победы (список ТТТ[8]). Для этого в списке mov_map[8] появляется красный -1 (собственно желаемый будущий ход именно Х).
  1. Однако если, например, обороняется игрок О (ХО=1), а критическое победное поле Х уже создал (Рис. 5)? Тогда приоритет для хода О как раз та же желтая клетка (mov_map[8]=-1). Результат хода это предотвращение победы Х и ничья для игрока О:

Рис.5. Для игрока О только оборона в желтую клетку, поскольку mov_map[8]=-1 (иначе следующий ход Х сюда же приведет к его победе).
Рис.5. Для игрока О только оборона в желтую клетку, поскольку mov_map[8]=-1 (иначе следующий ход Х сюда же приведет к его победе).
  1. Следующий вариант, если в одну клетку сходятся две победные комбинации игрока Х, при этом ход игрока О (ХО=1):

Рис.6. Редкое совпадение, когда значение mov_map[8]=-2.
Рис.6. Редкое совпадение, когда значение mov_map[8]=-2.
  1. Комбинация, когда в одну клетку сходится и победа Х и победа О. Для того, чтобы избежать ноль после суммирования -1 и 1 лучше складывать модули значений, а затем умножать его на их знаки (проще говоря, если есть хоть один минус, то значение возвращется в список отрицательным во избежании путаницы в логике).

def check_moves(): # finding the best cell for the next computer's move
    global TTT, move
    mov_map = [0, 0, 0, 0, 0, 0, 0, 0, 0] # map of the moves before each computer's move in current situation
    move = None
    # check for winning rows
    # the sum of the moduli of the current value and the winning cell of the checked player, and then multiply them by the sign of the checked player
    # in the most cases: zero + 1 or -1 (current player), but if the cell has two or three winners simultaneously, the module of the value must be 2 or 3 (-2 or -3)
    for row in range(0, 7, 3):  # jump through 1 in TTT list
        r=TTT[row] + TTT[row + 1] + TTT[row + 2]
        if abs(r) == 2:
            if TTT[row] == 0:
                mov = row
                mov_map[mov] = (abs(mov_map[mov])+abs(int((r) / 2)))*int((r) / 2) #the sum of winning's module both of players & multiple on the current player's sign
            elif TTT[row + 1] == 0:
                mov = row + 1
                mov_map[mov] = (abs(mov_map[mov])+abs(int((r) / 2)))*int((r) / 2)
            elif TTT[row + 2] == 0:
                mov = row + 2
                mov_map[mov] = (abs(mov_map[mov])+abs(int((r) / 2)))*int((r) / 2)

    # check for winning columns
    for col in range(0, 3, 1):  # jump through 1 in TTT list
        c=TTT[col] + TTT[col + 3] + TTT[col + 6]
        if abs(c) == 2:
            if TTT[col] == 0:
                mov = col
                mov_map[mov] = (abs(mov_map[mov])+abs(int((c) / 2)))*int((c) / 2)
            elif TTT[col + 3] == 0:
                mov = col + 3
                mov_map[mov] = (abs(mov_map[mov])+abs(int((c) / 2)))*int((c) / 2)
            elif TTT[col + 6] == 0:
                mov = col + 6
                mov_map[mov] = (abs(mov_map[mov]) + abs(int((c) / 2))) * int((c) / 2)

    # check for diagonal winners: L>R
    d_Lr=TTT[0] + TTT[4] + TTT[8]
    if abs(d_Lr) == 2:
        if TTT[0] == 0:
            mov = 0
            mov_map[mov] = (abs(mov_map[mov])+abs(int((d_Lr) / 2)))*int((d_Lr) / 2)
        elif TTT[4] == 0:
            mov = 4
            mov_map[mov] = (abs(mov_map[mov])+abs(int((d_Lr) / 2)))*int((d_Lr) / 2)
        elif TTT[8] == 0:
            mov = 8
            mov_map[mov] = (abs(mov_map[mov])+abs(int((d_Lr) / 2)))*int((d_Lr) / 2)

        # check for diagonal winners: R>L
    d_Rl=TTT[2] + TTT[4] + TTT[6]
    if abs(d_Rl) == 2:
        if TTT[2] == 0:
            mov = 2
            mov_map[mov] = (abs(mov_map[mov])+abs(int((d_Rl) / 2)))*int((d_Rl) / 2)
        elif TTT[4] == 0:
            mov = 4
            mov_map[mov] = (abs(mov_map[mov])+abs(int((d_Rl) / 2)))*int((d_Rl) / 2)
        elif TTT[6] == 0:
            mov = 6
            mov_map[mov] = (abs(mov_map[mov])+abs(int((d_Rl) / 2)))*int((d_Rl) / 2)

Выбор хода после заполнения списка mov_map=[ ]:

Знак перед модулем значений в списке mov_map создает чувствительность для конкретного игрока. Разберем на примере, когда наступает ход Х (т.е. ХО=-1):

  1. если в списке mov_map хотя бы единожды встречается -1, то это победный выбор.

  2. если в списке mov_map -1 нет, а присутствует 1, то для Х (напомню, ХО=-1) это ход для спасения от поражения.

  3. если в списке mov_map присутствуют и -1 и 1, то выбирается модуль со своим знаком для победы (т.е. -1 для ХО=-1).

# if one winner in one cell
    if mov_map.count(XO) > 0 and mov_map.count(-1*XO) == 0: #current player must choose his own square if the opponent hasn't a winning cell
        move = mov_map.index(XO)
    if mov_map.count(-1*XO) > 0 and mov_map.count(XO) == 0: #current player must choose opponent's square if the there isn't his own winning cell
        move = mov_map.index(-1*XO)
    if mov_map.count(XO) > 0 and mov_map.count(-1*XO) > 0: # current player must choose his own square if the opponent has a winning cell as well
        move = mov_map.index(XO)
  1. Вне зависимости какой игрок (Х или О): любое значение с модулем 2 (или 3 - такое тоже возможно!) требует хода именно туда. Там сходятся или две победы одного игрока, или победа у каждого.

# if two or three winners are in one cell - the always preference goes to current player
    if mov_map.count(2) > 0:
        move = mov_map.index(2)
    if mov_map.count(-2) > 0:
        move = mov_map.index(-2)
    if mov_map.count(3) > 0:
        move = mov_map.index(3)
    if mov_map.count(-3) > 0:
        move = mov_map.index(-3)

Функция для Игрока

Раздельный способ запуска игры для Х- или О- пользователя показался более оптимальным на отладке во избежании путаницы и усложнения логики.

X_player(). Запускает главный цикл while True и обработку события выхода из игры (стандартный цикл for event... в pygame).

def X_player(): # X-player
    global TTT, XO, move, winner, draw
    while (True):  # run the game loop forever
        for event in pg.event.get():
            if event.type == QUIT:
                pg.quit()
                sys.exit()

Один ход на каждого игрока - это две ветки:

  1. Игрок Х - пользователь - (ХО==-1) выбирает мышкой желаемую клетку на поле функцией user_click(), которая должна возвратить значение от 0 до 8 переменной move (согласно схеме на рис.1). Если move=None, значит ожидается повторный клик. Удобно обходиться без обработки ошибок если пользователь промахивается мимо поля, просто разместив это условие в цикле на первом месте с повтором (continue).

  2. Если все же пользователь указал пустую клетку TTT[move] == 0, то запускается функция прорисовки фигуры на поле DrawXO() (см. ниже).

            if XO == -1: # X's move
                if event.type == MOUSEBUTTONDOWN:
                    user_click()  # click mouse button for Х's move
                    if move == None:
                        continue
                    else:
                        if (TTT[move] == 0):
                            DrawXO()
  1. Игрок О - компьютер - (ХО==1):

    • Проверка наличия оптимальных ходов: check_moves(). Если их нет, то запускается локальный цикл while True:

    • Попытка поставить "О" в центр TTT[4] == 0 ("защита от дурака", т.к. это ослабит большое преимущество у Х после первого хода).

    • При исключении - случайный ход "О", но только в пустое место: random.

    • Прорисовка, переход хода и проверка в DrawXO() (см. ниже).

            if XO == 1 and draw is False and winner is None: # O's move
                check_moves()  # check for XX, X_X, OO, O_O
                if move is None:
                    while True:
                        if TTT[4] == 0:  # protection from the fool (when a rival makes typical triangle of "X")
                            move = 4
                            break
                        else:  # a move for good luck, gives a chance to play fair without algorithm
                            move = random.randint(0, 8)
                            if TTT[move] == 0:
                                break
                DrawXO()
  1. В случае чьей-то победы или ничьи - перезагрузка reset_game().

  2. В главном цикле обновляется картинка на экране pg.display.update().

        if (winner or draw):
            reset_game()
        pg.display.update()
        CLOCK.tick(fps)

DrawXO(). Запускается только при имеющемся готовом значении хода move.

  • Сначала вносит в список новый ход: TTT[move] = XO.

  • Затем прорисовывает Х (или О) в своем поле.

  • После этого XO = -1*XO дает тригер для перехода хода (от Х к О или наоборот), меняя свой знак на обратный.

  • В финале проверяет все текущее поле ТТТ на победу check_win().

def DrawXO(): # drawing of X or O, and after a sign will be reversed (XO => - XO) for player changing
    global TTT, XO, move
    TTT[move] = XO
    if move == 0:
        posx = 30
        posy = 30
    if move == 1:
        posx = width / 3 + 30
        posy = 30
    if move == 2:
        posx = width / 3 * 2 + 30
        posy = 30

    if move == 3:
        posx = 30
        posy = height / 3 + 30
    if move == 4:
        posx = width / 3 + 30
        posy = height / 3 + 30
    if move == 5:
        posx = width / 3 * 2 + 30
        posy = height / 3 + 30

    if move == 6:
        posx = 30
        posy = height / 3 * 2 + 30
    if move == 7:
        posx = width / 3 + 30
        posy = height / 3 * 2 + 30
    if move == 8:
        posx = width / 3 * 2 + 30
        posy = height / 3 * 2 + 30

    if XO == -1:
        screen.blit(x_img, (posx, posy))
    else:
        screen.blit(o_img, (posx, posy))
    XO = -1*XO
    pg.display.update()
    check_win()

Один полноценный главный цикл X_player() это:

  1. ход Х (пользователь), прорисовка и смена игрока, проверка на победу.

  2. алгоритм поиска хода для О (компьютер), прорисовка и смена игрока, проверка на победу.

O_player(). Устроена аналогично, но зеркально наоборот.

Недостаток алгоритма?

С точки зрения пользователя - недостатки алгоритма компьютерного хода - это увеличенные шансы для победы человека. На поле 3х3 важны первые три хода, когда О-игрок мешает Х сделать вилку, т.е. одновременно появляются две клетки для безоговорочной победы Х (Рис.7).

Рис.7. Игрок Х сделал вилку. Ход О-игрока в красную или желтую клетку приведет к победе Х.
Рис.7. Игрок Х сделал вилку. Ход О-игрока в красную или желтую клетку приведет к победе Х.

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

Финальный код

Начальное меню и выбор пользователем роли в статье не рассматривалось, там все предельно просто и понятно.

Проект финального кода и три картинки для скачивания находятся в папке или в архивном файле здесь.

Анонс второй части статьи

  1. Запуск игр компьютера против самого себя.

  2. Функции для записи всех игр пошагово в CSV-файл.

  3. Анализ и выбор нужного хода из базы данных: какой способ оптимален?

  4. Результаты: это ли машинное обучение?

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


  1. insecto
    17.07.2023 19:04

    Крестики-нолики это решённая игра :(


    1. Dr_Mike Автор
      17.07.2023 19:04
      +3

      Согласен! Но я в решение не подглядывал, по-честному прокачиваю собственную логику. Уверен, кому-то будет полезно на старте изучения Python.


  1. Harpagon
    17.07.2023 19:04
    +1

    Мы в ВУЗе решали так: после каждого хода игрока строиш дерево со всеми вариантами ответного хода и для каждого варианта ещё поддеревья со всеми вариантами следующего хода игрока и выбирали наиболее выгодный. Вложенность деревьев можно было менять, тем самым настраивая уровень "тупости" ИИ (на сколько ходов вперёд ИИ может подсматривать). В крестиках-ноликах комбинаций не так уж и много, так что не сложно сгенерировать сразу все возможные ходы кожаного и ответные, которые приведут к победе либо к патовой ситуации.


    1. Dr_Mike Автор
      17.07.2023 19:04

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


  1. sunsexsurf
    17.07.2023 19:04
    +1

    Кажется, что все ваши функции можно уложить в класс. Почитайте об ООП. Для ML - один фиг, но для читаемости и дальнейшей разработки может быть полезно (напр, что, если придется играть в крестики-нолики без границ? ваши списки "лягут" (( )


    1. Dr_Mike Автор
      17.07.2023 19:04

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