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

Машинное обучение еще интереснее! Итак, можем ли мы объединить эти две вещи для еще большего удовольствия? Я знаю, что теперь вы думаете о сверхразумном ИИ, который превратит вас в легенду при просмотре YouTube ... ну, этого не произойдет, по крайней мере, не здесь. И, честно говоря, кто хочет все время играть (и, возможно, проигрывать) машине? Так что давайте займемся чем-нибудь, что по-прежнему весело, но не обидит вас (особенно Blizzard). Теперь, когда до выхода нового дополнения осталось всего несколько дней (помимо его создания), мы должны подготовиться к совершенно новому балансу на арене. Итак, вопрос: зная существующие карточки и их оценки, можем ли мы предсказать новые результаты? Давайте разберемся.

Данные

Машинное обучение всегда начинается с данных. И мы тоже. Чтобы продолжить наш поиск, нам потребуются данные, желательно в удобном для восприятия формате, например JSON. Благодаря команде HSReplay мы можем легко загружать данные всех карт, используя их публичный API. Поскольку здесь мы работаем с ареной, мы можем ограничиться использованием только набора коллекционных карт. Что касается уровней, мы используем rembound's Arena-Helper на github:

with urllib.request.urlopen("https://raw.githubusercontent.com/rembound/Arena-Helper/master/data/cardtier.json") as url:
    arena_data = json.load(url)
    print("There are %d cards with tier score" % len(arena_data))
    # Change key to ID
    arena_data = {x['id']:x for x in arena_data}
with urllib.request.urlopen(urllib.request.Request("https://api.hearthstonejson.com/v1/latest/enUS/cards.collectible.json", headers={'User-Agent' : "Magic Browser"})) as url:
    card_data = json.load(url)
    # Change key to ID
    card_data = {x['id']:x for x in card_data if x['id'] in arena_data}

Преобразование функции

Итак, у нас есть карточки со всеми атрибутами (= функциями) и соответствующими им уровнями (= ярлыками), чтобы мы могли попытаться провести некоторую форму обучения с учителем. Таким образом, мы предполагаем, что атрибуты карты предсказывают свой уровень независимо от других карт в нашей колоде. Мы все знаем, что это неправда: сама игра в значительной степени построена на синергии карт и комбинациях (хотя на арене она гораздо менее доминирующая), но для простоты мы пока проигнорируем это.

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

Логика очень проста: каждый скалярный атрибут будет нормализован до диапазона [0,1], а каждый категориальный атрибут (например, тип или класс) будет преобразован в горячий закодированный вектор. Это нулевые векторы до тех пор, пока существует много категорий, где каждому элементу, соответствующему атрибуту на карточке, присваивается 1. Наша функция будет выглядеть примерно так:

def card2vec(card):
    # vectorize class
    class_idx = card_classes.index(card['cardClass'])
    class_vec = mx.nd.one_hot(mx.nd.array([class_idx]), len(card_classes)).squeeze()
    
    # vectorize type
    type_idx = card_types.index(card['type'])
    type_vec = mx.nd.one_hot(mx.nd.array([type_idx]), len(card_types)).squeeze()
    
    # vectorize attack
    attack_vec = mx.nd.array([card.get('attack', 0) / max_attack])
    
    # vectorize health
    health_vec = mx.nd.array([card.get('health', 0) / max_health])
    
    # vectorize rarity
    rarity = card['rarity']
    if rarity == 'FREE':  # Free gets the same ID as common as there is no difference in occurence probabilty
        rarity = 'COMMON'
    rarity_idx = card_rarities.index(rarity)
    rarity_vec = mx.nd.one_hot(mx.nd.array([rarity_idx]), len(card_rarities)).squeeze()
    
    # vectorize race
    race_vec = mx.nd.zeros(len(card_races))
    if 'race' in card:
        race_vec[card_races.index(card['race'])] = 1
    
    # vectorize mechanics
    mechanics_vec = mx.nd.zeros(len(card_mechanics))
    if 'mechanics' in card:
        for m in card['mechanics']:
            mechanics_vec[card_mechanics.index(m)] = 1
    
    # Concatenate vectors
    sum_vec = mx.nd.concat(class_vec, type_vec, attack_vec, health_vec, rarity_vec, race_vec, mechanics_vec, dim=0)
    
    return sum_vec

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

def get_label(card_id, hero_idx):
    tier_score = arena_data[card_id]['value'][hero_idx]
    if tier_score != '':
        # remove all non-numeric characters
        tier_score = re.sub(r"\D", "", tier_score)
        return float(tier_score) / maximum_tier_score
    else:
        return None

Если вы присмотритесь, то заметите две вещи:

  • Мы используем Apache MXNet для наших векторов
  • Мы не используем текст на карточках

Apache MXNet - это мой выбор для выполнения матричных операций, не стесняйтесь использовать вместо этого Numpy, он должен быть очень похож. Причина, по которой мы не используем текст, заключается в том, что мы полагаемся на атрибут Mechanics карты, который скрыт от пользователя, но используется игрой. Механика может быть такой простой, как «DEATHRATTLE», или сложной «RECEIVES_DOUBLE_SPELLDAMAGE_BONUS». Как вы увидите позже, он не полностью заменяет текст, но дает хорошее начало нашим экспериментам.

Измерение успеха

Современное машинное обучение - это хорошо, но не идеально. Таким образом, нам нужно иметь возможность измерить, насколько хороша наша модель. Простая метрика - это увидеть, насколько наши прогнозы далеки от реального значения. Это хорошо, но мы хотели бы подчеркнуть большие отклонения от правильного значения, поэтому я рекомендую вместо этого использовать среднеквадратичную ошибку. Также, чтобы не вводить себя в заблуждение, я разделил наши пары данных на обучающий (80%) и проверочный (20%) набор.

Результаты обучения на арене

Похоже, мы готовы обучить наш компьютер, так что давайте просто углубимся в него ... о, не так быстро! Глубокое обучение - это очень модно и действительно мощно, однако люди часто забывают, что для этого также требуется чертовски много данных! У нас меньше 4000 пар данных, так почему бы нам сначала не попробовать что-нибудь более классическое?

Давайте сначала попробуем линейную регрессию.

MSE - 653,65, это ужасно. А что насчет байесовского? 0,01303. Хорошо, это намного лучше, у нас примерно 20 баллов по каждому прогнозу. Можем попробовать опорные векторные машины разной сложности (линейные, квадратичные, полиномиальные)

Никаких улучшений, все еще около 0,013… похоже, нам действительно нужно попробовать глубокое обучение.

Модель глубокого обучения

Опять же, у нас очень мало данных для этого эксперимента, поэтому нам нужно сделать нашу модель как можно более компактной. Вроде бы однослойного перцептрона было бы достаточно. В MXNet Gluon нашей модели хотелось бы примерно такого:

def create_model(input_size):
    print('Model input size is %d' % input_size)

    model = nn.HybridSequential()
    model.add(nn.Dense(input_size, activation='relu'))
    model.add(nn.Dropout(0.5))
    model.add(nn.Dense(1))
    model.add(nn.Activation('sigmoid'))

    model.initialize()
    model.hybridize()
    return model

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

И спустя 500 эпох (и несколько минут) мы получаем 0,0056, что намного лучше, мы ошибаемся всего на ± 15 пунктов. Не потрясающе, но хорошее начало.

Наконец-то оценки

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

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