Часть 4. Ищем матчи в Dota 2 по названиям роликов на YouTube с помощью BERT и OpenDota
- суббота, 20 августа 2022 г. в 00:39:42
Представьте, что с одной стороны у вас есть видео на YouTube с интересными моментами из матча по Dota 2. А с другой стороны база данных всех матчей по Dota 2. Как для видео найти соответствующую запись в БД? Этой задачей мы сегодня и займемся.
В данном цикле статей мы реализовываем систему автоматического поиска хайлайтов в матчах Dota 2. Для ее создания нам требуется размеченный датасет с тайм-кодами. На YouTube есть множество каналов, где люди выкладывают нарезки с интересными моментами из профессиональных матчей по Dota 2.
В предыдущих частях:
В первой части мы распарсили реплей одного матча по Dota 2 и нашли хайлайты с помощью кластеризации.
Во второй части мы написали сервис для параллельного парсинга реплеев на Celery и Flask.
В третьей части мы научились скачивать видео с нарезками хайлайтов с YouTube, семплировать кадры и распознавать на них время.
Получаем заголовок и время публикации видео
Находим матчи с помощью API OpenDota
Краткое отступление про BERT
Строим эмбединги
Поиск матча по заголовку видео
Заключение
Все ссылки на код и использованные материалы вы найдете в конце статьи.
Напишем простую функцию с помощью уже знакомой нам по предыдущей части библиотеки yt_dlp
, которая на вход принимает URL видео, а на выходе возвращает метаданные.
...
import yt_dlp
@lru_cache
def get_video_metadata(url: str, save: bool = False) -> Tuple[str | int, Dict]:
options = dict()
with yt_dlp.YoutubeDL(options) as ydl:
metadata = ydl.extract_info(url, download=False)
video_id = metadata.get('id')
if save:
metadata_file = f'{video_id}.json'
video_metadata_path = VIDEO_DIR / metadata_file
with open(video_metadata_path, 'w') as fout:
json.dump(metadata, fout, indent=4)
return video_id, metadata
Словарь metadata
содержит в себе заголовок видео, время загрузки, ссылку на канал и многое другое. Возьмем в качестве примера матч Team Spirit против Team Secret.
url = 'https://www.youtube.com/watch?v=cXA5Hw2boLA&ab_channel=DotADigest'
video_id, metadata = get_video_metadata(url)
print(metadata)
> {
"id": "cXA5Hw2boLA",
"title": "SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 Dota 2 Highlights",
"thumbnail": "https://i.ytimg.com/vi/cXA5Hw2boLA/hqdefault.jpg",
"description": "DOTA 2 TS SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 by Gamers8 Dota 2 Highlights 2022 Tournament - Semi Final Playoff #dota2 #dpc \nWatch Live Riyadh Masters Dota 2: https://www.twitch.tv/gamers8gg - Commentary by Gareth & Lacoste",
"uploader": "DotA Digest",
"upload_date": "20220724",
"uploader_id": "RUDota2TV",
"uploader_url": "http://www.youtube.com/user/RUDota2TV",
"channel_id": "UCUqLL4VcEy4mXcQL0O_H_bg",
"channel_url": "https://www.youtube.com/channel/UCUqLL4VcEy4mXcQL0O_H_bg",
"duration": 1648,
"view_count": 50716,
"average_rating": null,
"age_limit": 0,
"webpage_url": "https://www.youtube.com/watch?v=cXA5Hw2boLA",
...
Для простоты ограничимся анализом канала DotA Digest. Обычно автор указывает в заголовке название команд и турнир. Также мы знаем дату загрузки видео. Попробуем на основе этой информации найти Replay
.
Идея простая. Видео с нарезками хайлайтов выходит после того, как матч уже сыгран. При этом авторы YouTube-каналов стремятся выпустить видео как можно раньше, чтобы собрать побольше просмотров. Значит мы можем взять все профессиональные матчи за несколько дней до даты публикации видео и скорее всего один из них окажется искомым (запечатлённым на видео).
Ссылку на реплей можно получить, зная match_id
— уникальный идентификатор матча, который генерирует сама игра. Для этого воспользуемся API OpenDota.
Напишем простой wrapper для /explorer
endpoint, который позволяет делать запросы к базе данных OpenDota.
import requests
from tenacity import retry, stop_after_attempt, wait_fixed
@retry(stop=stop_after_attempt(7), wait=wait_fixed(10))
def query_opendota(sql: str, **kwargs: dict) -> List[Dict]:
query = sql.format(**kwargs)
logger.debug(query)
r = requests.get(
'https://api.opendota.com/api/explorer',
params=dict(sql=query)
)
r.raise_for_status()
result = r.json()
rows = result['rows']
return rows
Напишем функцию, которая находит профессиональные матчи за предыдущие два дня относительно выбранной даты.
from datetime import datetime, timedelta
def get_nearest_matches(date: datetime) -> List[Dict]:
end_time = date + timedelta(days=1)
start_time = date - timedelta(days=2)
query = '''
SELECT
match_id,
start_time,
matches.leagueid,
leagues.name as league,
radiant_team_id,
radiant_team.name as radiant_name,
radiant_team.tag as radiant_tag,
dire_team_id,
dire_team.name as dire_name,
dire_team.tag as dire_tag
FROM
matches
join teams as dire_team on matches.dire_team_id = dire_team.team_id
join teams as radiant_team on
matches.radiant_team_id = radiant_team.team_id
join leagues on matches.leagueid = leagues.leagueid
WHERE
start_time >= extract(epoch from timestamp '{start_time}')
and start_time < extract(epoch from timestamp '{end_time}')
ORDER BY
start_time desc
LIMIT
500
'''
matches = query_opendota(
query,
start_time=datetime.strftime(start_time, '%m-%d-%Y'),
end_time=datetime.strftime(end_time, '%m-%d-%Y')
)
return matches
Проверим на все том же матче Team Spirit против Team Secret. Для этого распарсим дату публикации видео из метаданных. Найдем все профессиональные матчи, которые были сыграны в день публикации и в предыдущие два дня.
url = 'https://www.youtube.com/watch?v=cXA5Hw2boLA&ab_channel=DotADigest'
video_id, metadata = get_video_metadata(url)
upload_date = metadata['upload_date']
upload_date = datetime.strptime(upload_date, '%Y%m%d')
matches = get_nearest_matches(upload_date)
print(len(matches))
> 288
Всего матчей 288. Среди них есть 12 матчей с участием команды Team Spirit, 3 за Radiant и 9 за Dire.
print([m for m in matches if m['radiant_name'] == 'Team Spirit'])
> [
{
"match_id": 6676488286,
"start_time": 1658692615,
"leagueid": 14391,
"league": "Riyadh Masters by Gamers8",
"radiant_team_id": 7119388,
"radiant_name": "Team Spirit",
"radiant_tag": "TSpirit",
"dire_team_id": 15,
"dire_name": "PSG.LGD",
"dire_tag": "PSG.LGD"
},
{
"match_id": 6676051545,
"start_time": 1658673115,
"leagueid": 14391,
"league": "Riyadh Masters by Gamers8",
"radiant_team_id": 7119388,
"radiant_name": "Team Spirit",
"radiant_tag": "TSpirit",
"dire_team_id": 1838315,
"dire_name": "Team Secret",
"dire_tag": "Secret"
},
{
"match_id": 6672690521,
"start_time": 1658491214,
"leagueid": 14391,
"league": "Riyadh Masters by Gamers8",
"radiant_team_id": 7119388,
"radiant_name": "Team Spirit",
"radiant_tag": "TSpirit",
"dire_team_id": 8291895,
"dire_name": "Tundra Esports",
"dire_tag": "Tundra"
}
]
Нюанс в том, что Spirit играли не только с Team Secret, но и с PSG.LGD, и с Tundra Esports. При этом из заголовка видео понятно, что оно относится только к матчу Secret - Spirit.
print(metadata['fulltitle'])
> "SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 Dota 2 Highlights"
Как же отфильтровать матчи в БД, подходящие под заголовок видео?
Можно было бы воспользоваться регулярными выражениями. Но заголовок видео пишет человек, поэтому в названиях команд и турниров могут быть опечатки и сокращения.
Можно было бы пойти дальше и использовать расстояние Левинштейна, чтобы обойти проблему опечаток и сокращений. Но в названии видео также могут содержаться комментарии и кликбейтные словосочетания.
Но я решил стрельнуть из пушки по воробьям. Благо в наше время все сильнее набирает популярность новое, модное, молодежное явление — нейросети.
Для решения данной задачи я решил использовать нейросетьDistillBERT
, которая по сути своей является оптимизированной по скорости и памяти версией BERT
. Она позволяет отображать текст в "хорошие" числовые векторы, которые потом можно между собой сравнивать.
BERT — модель, обученная предсказывать пропущенные слова (на самом деле токены), а также наличие взаимосвязи между предложениями.
Сосредоточимся на пропущенных токенах. Сами по себе они являются словами или кусочками слов. Для их получения используется алгоритм BPE (Byte Pair Encoding). Всего в словаре модели 30 000 токенов.
В оригинальной статье в качестве датасета авторы использовали тексты из книг и википедии, суммарно более 3 млрд слов. Причем 15% токенов заменялись на служебный — [MASK]. Задача модели была предсказать, какое слово скрывается под маской. Для этого она могла использовать контекст некоторой длины как слева, так и справа. Рассмотрим пример.
[CLS] the man went to [MASK] store [SEP] he bought a gallon [MASK] milk [SEP]
Можно было бы предположить, что оригинальное предложение выглядит так
The man went to asdf store. He bought a gallon dinosaur's milk.
Но более правдоподобным выглядит вариант
The men went to the store. He bought a gallon of milk.
Примерно этим нейросеть и занималась на этапе обучения. Сначала могла сказать про галлон молока динозавра. Но у динозавров нет молока, у них здоровенные яйца. Поэтому модель получала от лосса по весам. И потихоньку сходилась к более правдопобному варианту. И так для миллионов текстов.
Схема модели:
Берем текст некоторой длины
Разбиваем на токены
Каждому токену ставим в соответствие некоторый числовой emdedding — 768-мерный вектор
Добавляем Positional Encoding, чтобы учесть порядок слов (подробнее можете почитать в статье)
Прогоняем через Transformer-блоки
Получаем обновленные embedding'и исходных токенов, которые учитывают слова в тексте и взаимосвязи между ними
Делаем классификацию слов под маской
Основная ценность модели для нас в п.6 — хороших эмбедингах токенов.
Давайте поставим в соответствие заголовку видео некоторый числовой вектор. Аналогичным способом получим числовые векторы для записей в базе данных. Измерим расстояние от вектора-заголовка до всех векторов матчей и выберем ближайший. Это и будет нашим алгоритмом поиска. А для создания числовых векторов по тексту будем использовать DistillBERT
.
Напишем функцию для загрузки предобученной модели с HuggingFace.
import transformers
from transformers import DistilBertTokenizer, DistilBertModel
@lru_cache
def load_text_model() -> tuple:
model_version = 'distilbert-base-uncased'
logger.info(f'Loading model: {model_version}')
transformers.logging.set_verbosity_error()
tokenizer = DistilBertTokenizer.from_pretrained("distilbert-base-uncased")
model = DistilBertModel.from_pretrained("distilbert-base-uncased")
return tokenizer, model
И функцию для получения выхода с последнего слоя блока трансформера.
def get_distilbert_hidden_state(batch: List[str]) -> torch.Tensor:
"""
Hidden State from the last TransformerBlock of DistilBert.
Returns (B, T, H) Tensor, where
- B - Batch Size,
- T - Number of tokens in the longest string
- H - Hidden Vector Dim (768 for DistilBert)
"""
tokenizer, model = load_text_model()
inputs = tokenizer(batch, return_tensors='pt', padding=True)
outputs = model(**inputs)
hidden_state = outputs.last_hidden_state
return hidden_state
Разберемся на примерах. Попробуем получить выход с последнего слоя для заголовка видео "SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 Dota 2 Highlights".
hidden_states = get_distilbert_hidden_state([
'SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 Dota 2 Highlights',
])
type(hidden_state)
> torch.Tensor
hidden_states.shape
> torch.Size([1, 22, 768])
Имеем PyTorch Tensor с размерностью:
1 — размер батча. В данном случае мы передали список из одной строки
22 — число токенов, которое было получено с помощью BPE tokenizer
768 — размерность скрытого слоя. Была выбрана на этапе обучения сети, в данный момент мы на неё не влияем.
Батч может содержать и несколько строк разной длины.
hidden_states = get_distilbert_hidden_state([
'SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 Dota 2 Highlights',
'SECRET vs SPIRIT - RIYADH MASTERS 2022s',
])
hidden_states.shape
> torch.Size([2, 22, 768])
Теперь в нашем примере каждая текстовая строка закодирована матрицей чисел . Но матрицы не так удобно между собой сравнивать. Поэтому без страха, без уважения усредним по первой компоненте тензора, т.е. по всем токенам.
def get_text_embeddings(batch: List[str]) -> torch.Tensor:
hidden_state = get_distilbert_hidden_state(batch)
embeddings = torch.mean(hidden_state, dim=1)
return embeddings
Т.е. мы представляем текст, как нечто среднее от слов (токенов) в нем. Таким образом мы научились ставить в соответствие текстовой строке некоторый вектор из 768 чисел.
embedding = get_text_embeddings([
'Hello, World!',
])
embedding.shape
> torch.Size([1, 768])
print(embedding)
> tensor([[ 1.5433e-03, -3.2035e-01, 4.5550e-01, 6.7697e-03, -4.9975e-03,
-3.3267e-01, 5.5809e-01, -6.2899e-02, 4.8135e-03, -6.1892e-02,
-5.6701e-02, -3.2326e-01, -1.0381e-02, 4.1199e-01, 7.4125e-02,
7.1323e-02, -1.0036e-03, 9.4666e-03, 1.4607e-01, 3.1909e-01,
...
Как померить близость векторов? Например, можно взять косинус угла между ними. Из курса линейной алгебры вспомним про скалярное произведение.
Напишем функцию поиска. На вход она принимает текст поискового запроса, а также корпус текстов (документы), внутри которого мы будем искать. Возвращает Top-N ближайших документов к поисковому запросу на основе косинусной меры близости.
def search(text: str, corpus: List[str], top: int = 3) -> List[Tuple[int, str]]:
batch = [text] + corpus
with torch.no_grad():
embeddings = get_text_embeddings(batch)
similarities = F.cosine_similarity(embeddings[0:1], embeddings[1:], dim=1)
top_ids = torch.topk(similarities, k=top).indices
result = [
(int(idx), corpus[idx])
for idx in top_ids
]
return result
Проверим на практике.
search(
'SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 Dota 2 Highlights',
[
'NAVI vs Aliance - The International 2013',
'LGD Pushat',
'SECRET vs SPIRIT - RIYADH MASTERS 2022',
'Net drug, ya ne opravdivaus',
],
top=4
)
> [
(2, 'SECRET vs SPIRIT - RIYADH MASTERS 2022'),
(0, 'NAVI vs Aliance - The International 2013'),
(1, 'LGD Pushat'),
(3, 'Net drug, ya ne opravdivaus')]
Работает. Также для удобства напишем функцию, которая возвращает самый близкий результат.
def search_top1(text: str, corpus: List[str]) -> str:
_, result = search(text, corpus, top=1)[0]
return result
Теперь вспомним, что в начале статьи мы получили список matches
— всех профессиональных матчей, которые были сыграны за несколько дней до публикации видео. Одна запись содержит названия команд, теги команд и название турнира.
record = {
"match_id": 6676488286,
"start_time": 1658692615,
"leagueid": 14391,
"league": "Riyadh Masters by Gamers8",
"radiant_team_id": 7119388,
"radiant_name": "Team Spirit",
"radiant_tag": "TSpirit",
"dire_team_id": 15,
"dire_name": "PSG.LGD",
"dire_tag": "PSG.LGD"
}
Напишем функцию, которая создает текстовые описания для матчей. Будем использовать не только имена, но и вариации с тегами команд, потому что мы заранее не знаем, что будет лучше работать.
def generate_team_pairs(matches: List[Dict]) -> Dict:
pairs = dict()
for m in matches:
match_id = m['match_id']
radiant_tag = m['radiant_tag']
dire_tag = m['dire_tag']
radiant_name = m['radiant_name']
dire_name = m['dire_name']
name_pair_1 = f'{radiant_name} vs {dire_name}'
name_pair_2 = f'{dire_name} vs {radiant_name}'
tag_pair_1 = f'{radiant_tag} vs {dire_tag}'
tag_pair_2 = f'{dire_tag} vs {radiant_tag}'
for pair in (name_pair_1, name_pair_2, tag_pair_1, tag_pair_2):
pairs[pair] = match_id
return pairs
Применяя к записи из БД, получаем
generate_team_pairs([record])
> {
"Team Spirit vs PSG.LGD": 6676488286,
"PSG.LGD vs Team Spirit": 6676488286,
"TSpirit vs PSG.LGD": 6676488286,
"PSG.LGD vs TSpirit": 6676488286
}
Где 6676488286
— идентификатор матча в БД.
Не забудем и про названия турниров.
def generate_team_tournaments(matches: List[Dict]) -> Dict:
team_tournaments = dict()
pairs = generate_team_pairs(matches)
for m in matches:
match_id = m['match_id']
league = m['league']
for pair, pair_match_id in pairs.items():
if match_id == pair_match_id:
team_tournament = f'{pair} | {league}'
team_tournaments[team_tournament] = match_id
return team_tournaments
generate_team_tournaments([record])
> {
"Team Spirit vs PSG.LGD | Riyadh Masters by Gamers8": 6676488286,
"PSG.LGD vs Team Spirit | Riyadh Masters by Gamers8": 6676488286,
"TSpirit vs PSG.LGD | Riyadh Masters by Gamers8": 6676488286,
"PSG.LGD vs TSpirit | Riyadh Masters by Gamers8": 6676488286
}
В качестве вишенки на торте воспользуемся наблюдением, что на канале DotA Digest, рассмотрением которого мы ограничились в данной статье, заголовки видео формируются в формате
{Team1} vs {Team2} - {Clickbait comment} - {Tournament} Dota 2 Highlights
Поэтому к заголовкам видео можно применять простое преобразование.
title = 'SECRET vs TEAM SPIRIT - RAMPAGE! SEMI FINAL - RIYADH MASTERS 2022 Dota 2 Highlights'
teams_pair, _, tournament = [t.strip() for t in title.split('-')]
tournament = tournament.replace('Dota 2 Highlights', '')
title = f'{teams_pair} | {tournament}'
print(title)
> 'SECRET vs TEAM SPIRIT | RIYADH MASTERS 2022 '
И вот она — функция для поиска матча, соответствующего заголовку ролика на YouTube.
def search_team_tournament_pairs(video_title: str, matches: List[Dict]) -> int:
teams_pair, _, tournament = [t.strip() for t in video_title.split('-')]
tournament = tournament.replace('Dota 2 Highlights', '')
team_tournament = f'{teams_pair} | {tournament}'
team_tournaments = generate_team_tournaments(matches)
candidate = search_top1(team_tournament, list(team_tournaments))
match_id = team_tournaments[candidate]
return match_id
Принимает на вход название видео и список матчей из БД. Приводит заголовок к "православному" виду. Генерирует строки с описанием матчей из БД. Строит эмбединги (векторные представления) заголовка и описаний. Сравнивает их между собой по косинусной мере и находит ближайшего кандидата. Возвращает match_id, по которому получить реплей.
Выведем результат поиска.
match_id = search_team_tournament_pairs(teams_pair, tournament, matches)
[m for m in matches if m['match_id'] == match_id]
> [
{
"match_id": 6673204572,
"start_time": 1658513208,
"leagueid": 14391,
"league": "Riyadh Masters by Gamers8",
"radiant_team_id": 1838315,
"radiant_name": "Team Secret",
"radiant_tag": "Secret",
"dire_team_id": 7119388,
"dire_name": "Team Spirit",
"dire_tag": "TSpirit"
}
]
Мы научились искать записи в БД по заголовкам видео на YouTube. Поигрались с нейросетями. И вообще замечательно провели время.
В следующей части мы подготовим датасет и улучшим алгоритм поиска хайлайтов из первой части.
* Качество поиска можно уточнить, если разными способами генерировать текстовые описания для записей в БД. Реализация есть в репозитории на GitHub.
** Внимательный читатель заметит, что видео могут содержать не один матч, а серию, потому что команды зачастую играют 2 или 3 карты. Мне было лень возиться, но в перспективе это важно учесть.