В данной статье будет рассмотрена работа с библиотекой gymnasium для изучения машинного обучения с подкреплением. Реализация агента, который использует метод машинного обучения q-learning для максимизации выигрыша в карточной игре blackjack и сравним средний выигрыш за 100 000 игр при различных реализациях игры blackjack.

Правила блэкджека

Подробно с правилами игры в блэкджек можно ознакомиться на википедии. Рассмотрим окружение, которое реализовано в gymnasium:

  • численные значения карт равны от 2 до 10 - для карт от двойки до десятки соответственно, и 10 - для валетов, дам и королей;

  • туз считается за 11 очков, если общая сумма карт на руке игрока не превосходит 21 (по-английски, в этом случае, говорят, что на руке есть usable ace), и за 1 очко, если превосходит 21;

  • игроку раздаются две карты, дилеру — одна в открытую и одна в закрытую;

  • игрок может совершать одно из двух действий:

    • hit — взять ещё одну карту;

    • stand — не брать больше карт;

  • если сумма очков у игрока на руках больше 21, он проигрывает (bust);

  • если игрок выбирает stand с суммой не больше 21, дилер добирает карты, пока сумма карт в его руке меньше 17;

  • после этого игрок выигрывает, если дилер либо превышает 21, либо получает сумму очков меньше, чем сумма очков у игрока; при равенстве очков объявляется ничья (ставка возвращается);

  • в исходных правилах есть ещё дополнительный бонус за natural blackjack: если игрок набирает 21 очко с раздачи двумя картами, он выигрывает не +1, а +1.5 (полторы ставки).

Формализация среды блэкджека

Если описывать правила в терминах машинного обучения с подкреплением, то доступно два действия: hit и stand.

Пространство наблюдений - это 3 дискретных состояния:

  • сумма карт игрока (2 - 32);

  • открытая карта дилера (1 - 10);

  • флаг, который показывает туз игрока, считается за 1 или за 11.

Награда: 1 - при победе, 1.5 - если сразу раздали блэджек, -1 - при проигрыше и 0 - при исходе "ничья".

В качестве baseline реализуем простую стратегию - делать stand, если у нас на руках комбинация в 19, 20 или 21 очко. Во всех остальных случаях - говорить hit.

def play_basic_strategy(env):
    observation, info = env.reset()
    player_score, deler_score, usable_ace = observation
    while player_score < 19:
        observation, reward, terminated, truncated, info = env.step(STAND)
        player_score, deler_score, usable_ace = observation
    observation, reward, terminated, truncated, info = env.step(HIT)
    return reward

В результате, получили мат. ожидание выигрыша около -0.06%.

Описание агента

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

Формула обновления Q-значений в алгоритме Q-learning:

Q(s, a) \leftarrow Q(s, a) + \alpha \cdot \left[ r + \gamma \cdot \max_{a'} Q(s', a') - Q(s, a) \right]

Где:

  • Q(s, a) - текущее значение функции полезности (Q-value) для состояния s и действия a

  • α - скорость обучения (learning rate), определяет степень влияния новой информации на существующую оценку

  • r - награда, полученная после выполнения действия a в состоянии s

  • γ - коэффициент дисконтирования (discount factor), показывает важность будущих наград

  • s′ - новое состояние, в которое переходит агент после выполнения действия

  • maxₐ′ Q(s′, a′) - максимальная оценка полезности в следующем состоянии s′ по всем возможным действиям a′

    Реализация агента, который будет максимизировать выигрыш при игре в блэкджек, используя q-learning.

class BlackJackAgent:
    
    def __init__(self, env, count_action: int, get_action_func):

        self.states = self.get_all_states_from_env(env)
        self.q_policy = dict(zip(self.states, np.zeros((len(self.states), count_action))))
        self.env = env
        self.get_action_func = get_action_func
        
    
    def get_all_states_from_env(self, env):
        states = [get_observation_range_from_discrete(obseration) for obseration in env.observation_space]
        return list(itertools.product(*states))
    
    def q_learning(self, num_episodes: int,epsilon: float, alpha: float, gamma: float):
        
        for _ in range(num_episodes):
            state, info = self.env.reset()
            terminated = False
            while not terminated:
                action = self.get_action_func(self.q_policy, state, epsilon, self.env)
                observation, reward, terminated, truncated, info = self.env.step(action)
                # Q[s,a] = Q[s,a] + alpha (r + gamma maxₐ′ Q[s′,a′] − Q[s,a]).
                self.q_policy[state][action] = self.q_policy[state][action] + alpha * (reward + gamma * max(self.q_policy[observation]) - self.q_policy[state][action])
                state = observation

Описание окружений

Сравним средний выигрыш за 100 000 игр, получаемый агентом, в различных вариациях игры блэкджек:

  • Базовое окружение из пакета gymnasium;

  • Базовое окружение из пакета gymnasium с добавленным действием dobule. В окружение добавляется ещё одно действие double — удвоить ставку. После выбора действия doubleделать другие действия нельзя. Игроку выдаётся ровно одна дополнительная карта, а выигрыш или проигрыш удваивается;

  • Базовое окружение из пакета gymnasium + double + подсчёт карт по системе половинки. Для реализации этого окружения необходимо переопределить колоду, так как в gymnasium реализована бесконечная колода. В нашем случае, будет шуз на 6 колод, и, когда будет оставаться меньше 30 карт, шуз будет заново замешиваться. Система подсчёта Половинки описана на википедии. Если коротко, каждой карте присвоен вес и, при появлении карты на столе, счёт изменяется согласно весу. Детали реализации - игрок может считать карты в диапазоне от -35 до 35 с шагом 0.5, и, если нам так "повезло", что было очень много картинок подряд, и мы получили счёт -36, то выбирается ближайшее состояние, соответствующее ближайшему счёту (в нашем примере выбирается состояние, соответствующие счёту -35);

  • Базовое окружение из пакета gymnasium + double + подсчёт карт по системе плюс-минус. Аналогично окружению, реализующему подсчёт карт по системе половинки, только веса у карт [-1, 0, 1] и шаг при подсчёте равен 1;

  • Базовое окружение из пакета gymnasium + double + подсчёт карт по системе плюс-минус + split. В случае, когда игроку пришли две одинаковые карты(либо две картинки), он может разбить руку на две, внести ещё одну ставку и продолжать играть в две руки сразу (как-будто за двоих игроков);

  • Базовое окружение из пакета gymnasium + double + подсчёт карт по системе половинки + split.

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

class BlackjackDoubleCardsCountEnv(BlackjackEnv): # наследуемся от базового окружения из gymnasium.  https://github.com/openai/gym/blob/master/gym/envs/toy_text/blackjack.py
    def __init__(self, render_mode: Optional[str] = None, natural=False, sab=False, ...):
            super().__init__(render_mode, natural, sab)
            ...
            self.action_space = spaces.Discrete(3) # переопределим пространство действий
            ...
    def step(self, action):
        ...
        elif action == 3:
            # дилер набирает карты
            # даём игроку ещё одну карту
            ...
            # домножаем награду на 2 и завершаем игру
            done = True
            reward = cmp(score(self.player), score(self.dealer)) * 2 
        return self._get_obs(), reward, done, False, {}

Для реализации подсчёта карт в конструкторе переопределим колоду, добавим аргумент для изменения значений весов mathing и аргумент для выбора диапазона, в котором считаются карты cards_count_range. Также в пространство наблюдений нужно добавить состояние, показывающее текущий счёт. А в вызове функции step, при каждом действии, считаем выданную карту. Также переопределим функцию _get_obs и функцию reset, в которой будем замешивать колоду только в том случае, если карт в колоде осталось меньше 30.

class BlackjackDoubleCardsCountEnv(BlackjackEnv):

    def __init__(self, render_mode: Optional[str] = None, natural=False, sab=False, mathing=None, cards_count_range=np.arange(-35, 35, 0.5)):

        self.standart_deck = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10] * 4
        # шуз на 6 колод
        self.deck = self.standart_deck * 6
        # 'счет' карт
        self.cards_count = 0
        # числовые значения для карт
        if mathing == None:
            self.mathing = { # веса по схеме "Половинки"
                1: -1,
                2: 0.5,
                3: 1,
                4: 1,
                5: 1.5,
                6: 1,
                7: 0.5,
                8: 0,
                9: -0.5,
                10: -1,
            }
        else: 
            self.mathing = mathing
        avilable_count_card_values = cards_count_range
        self.map_avilable_count_card_to_state = dict(zip(avilable_count_card_values, range(len(avilable_count_card_values))))
        self.observation_space = spaces.Tuple(                      
            (spaces.Discrete(32), spaces.Discrete(11), spaces.Discrete(2), spaces.Discrete(len(avilable_count_card_values)))
        )
    
    def step(self, action):
        ...
        if action == any:
            self.count(card) # при каждом действии, считаем выданную карту
            #  не забываем, что у дилера нужно считать только закрытые карты, в конце игры
        ...

    def _get_obs(self):
        # считаем, что возможности агента по счёту не бесконечны
        # и после того как счёт выходит за доступный диапазон 
        # выбираем ближайшее крайнее состояние
        if self.cards_count in self.map_avilable_count_card_to_state:
            cards_count_state = self.map_avilable_count_card_to_state[self.cards_count]
        elif self.cards_count < 0:
            cards_count_state = 0
        else:
            cards_count_state = len(self.map_avilable_count_card_to_state) - 1
            
        return (... , cards_count_state)

    def reset(self):
        if len(self.deck) < 30: # заново замешиваем колоды 
            self.deck = self.standart_deck * 6
            self.cards_count = 0
        cards = self.draw_hand(self.np_random)
        self.dealer = cards
        self.count(self.dealer[0])  
        cards = self.draw_hand(self.np_random)
        self.player = cards
        for card in cards:
            self.count(card)  
        return self._get_obs(), {}

Результаты

Агент для каждого окружения обучается на 3 500 000 играх.
После 100 000 игр будем уменьшать exploration уменьшая вероятность выбора случайного действия до 0.08.

def fit_agent(agent, env, alpha = 0.0009, epsilon = 0.77, gamma = 0.91):
    rewards = []
    for i in tqdm(range(0, COUNT_GAMES, STEP)):
        agent.q_learning(STEP, epsilon, alpha, gamma)
        mean_reward = np.mean([play(env, agent) for _ in range(0, 100_000)])
        rewards.append(mean_reward)
        if i % 100_000 == 0:
            epsilon = min(0.92, epsilon + 0.05)
    return rewards
env = gym.make('Blackjack-v1',  natural=True)
agent = BlackJackAgent(env, env.action_space.n, get_action)
rewards_default_env = fit_agent(agent, env)

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

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

Ещё больше интересного в телеграм канале.

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


  1. anonymous
    13.06.2025 03:26


  1. bazilxp
    13.06.2025 03:26

    Хотел спросить про колоды есть ли идея что используется 2-6 колод (и случайным образом укорочено) на смещение выпадение каких либо комбинаций?


    1. monkey_llm Автор
      13.06.2025 03:26

      Для построения графиков в окружениях где нужно было считать карты использовалось 6 колод, а случайным образом их не укорачивал


      1. bazilxp
        13.06.2025 03:26

        огромное спасибо за статью , в рамках материала прям практическая база к фильму 21 .

        Механика подсчета карт.. я попробую воспроизвести игру=)