Контринтуиция в статистике на примере игры Мир Танков/WOT

от автора

Контринтуитивные события окружают нас повсюду. Взять, например, парадокс дней рождений: каждый раз, когда оказываешься в небольшой группе и узнаёшь, что у двух участников группы праздник в один и тот же день, то думаешь: какое удивительное совпадение, ведь кажется, что событие редкое. Но вот уже при детальном рассмотрении оказывается, что вероятность такого события достаточно высокая (например, если группа 23 человека, то вероятность составит 50%, для группы в 57 человек — 99%). Примеров контринтуитивных событий много и они удивительны тем, что их разоблачение позволяет нам менять своё мировосприятие. 

Вот и мы с друзьями затеяли спор об одном таком событии, связанным с игрой Мир танков. Суть спора: двое из нас считают один сервер «несчастливым», потому что при нашей средней статистике в 50%, именно на этом сервере происходят частые поражения. Третий друг считает, что не может быть такого, чтоб сервер был «несчастливым» и тем более, невозможно, чтобы средний процент побед всех игроков на нём был меньше 50%. 

Его аргументы: исход боя либо победа, либо поражение (ничью, которая является поражением для обеих команд — в расчёт не берём). Вот мы берём 30к игроков, они играют между собой (команды набираются по 15 игроков), допустим, 30 боёв, статистику каждого записываем и получаем средний процент побед игроков в 50% (кто‑то выиграл 10 из 30, кто‑то 20 из 30, тем самым, получаем среднее). И вроде всё так. Но в такой модели мы говорим: каждый обязан провести ровно 30 игр. В жизни так не бывает, существует очень много параметров, всех их не рассмотреть. Но я уверен, что даже один параметр уже будет смещать среднее вправо или влево от теоретических 50%. Самый главный параметр — количество игр. Все играют по‑разному. У кого‑то времени больше, у кого‑то меньше. И сначала я захотел провести эксперименты именно с этим параметром.

Логика эксперимента

Для начала опишу логику: что и как мы будем смотреть, проводить, измерять. Определимся с количеством игроков. Могу по опыту сказать, что в вечернее время на сервере играет около 30к игроков. То есть количество игроков онлайн 30 000. Это константа, одни игроки уходят, другие заходят, но онлайн в эксперименте всегда ровно 30 000. Сколько будет длиться эксперимент — количество игр/итераций равно 30. Исходя из опыта, 30 игр это примерно 4–5 часов реального времени, онлайн в 30к примерно столько и держится. Ожидание и подбор соперников из эксперимента убраны.

Как происходит эксперимент: играют все 30к одновременно, то есть сначала набираются матчи (в матче 30 игроков), получается 1000 матчей. В каждом матче игроки случайно формируют команды (15 в одной команде, оставшиеся 15 в другой). Проводится матч, игрокам, которые победили, записывается 1 победа — так ведётся статистика каждого игрока, сколько матчей он сыграл и сколько матчей он выиграл. После одного матча все игроки перемешиваются по матчам, потом по командам и опять проводится игра. Также, каждому игроку случайным образом присваивается параметр — сколько матчей он сыграет. Например, игрок с id 1 должен сыграть 10 матчей. После 10-го матча он «уходит» из игры (но его статистика сохраняется), на его место приходит игрок, который должен сыграть 5 матчей и так далее. Если осталась последняя игра (всего 30 игр), а пришёл игрок у которого параметр 10 игр — он сыграет всего 1 игру, и его статистика запишется. Параметр «количество игр одного игрока» будет описываться различными распределениями, о которых поговорим далее.

Нормальное распределение

Нормальное распределение — это непрерывное распределение вероятностей с пиком в центре и симметричными боковыми сторонами. Формула выглядит следующим образом:

f(x) = \frac{1}{\sigma \sqrt{2\pi}} \exp\left(-\frac{(x-\mu)^2}{2\sigma^2}\right)

Это распределение берут, когда есть какое‑то среднее, и мы считаем что 99,99% всех значений лежит в 3-х среднеквадратичных отклонениях вправо и влево. Вот и я в этом эксперименте решил использовать это распределение как параметр количества игр у игрока. 

Предполагаем, что в среднем игрок проводит 10 игр, значит, что дисперсия будет 3,5427, а среднее квадратичное отклонение 1,88. Ниже я оставлю код, чтобы вы могли воспроизвести этот эксперимент у себя. Следующие эксперименты будут отличаться только распределениями и их параметрами.

import randomimport pandas as pdimport mathfrom scipy.stats import norm# ========== КОНСТАНТЫ ==========PRE_TOTAL_PLAYERS = 30000      # начальное количество активных игроковTEAM_SIZE = 15MATCHES_PER_ROUND = PRE_TOTAL_PLAYERS // (TEAM_SIZE * 2)  # 1000TOTAL_ROUNDS = 30              # максимальное количество раундов (т.к. максимум 30)# Параметры нормального распределения для max_gamesMEAN_MAX_GAMES = 10.0STD_MAX_GAMES = 1.88MIN_MAX_GAMES = 3MAX_MAX_GAMES = 30# ========== ФУНКЦИЯ ГЕНЕРАЦИИ ЛИМИТА ==========def generate_max_games():    """Генерирует целое число боёв для нового игрока из усечённого нормального распределения."""    # Генерируем значение из нормального распределения    raw = norm.rvs(loc=MEAN_MAX_GAMES, scale=STD_MAX_GAMES)    # Обрезаем до допустимого диапазона    clamped = max(MIN_MAX_GAMES, min(MAX_MAX_GAMES, raw))    # Округляем вверх до целого    return math.ceil(clamped)# ========== ИНИЦИАЛИЗАЦИЯ ДАННЫХ ==========# Создаём начальных игроков с нормально распределёнными лимитамиactive_players = []max_games_dict = {}stats = {}print("=== Инициализация ===")print(f"Генерация {PRE_TOTAL_PLAYERS} начальных игроков...")for i in range(PRE_TOTAL_PLAYERS):    player_id = i    max_g = generate_max_games()    active_players.append(player_id)    max_games_dict[player_id] = max_g    stats[player_id] = {'wins': 0, 'games': 0}next_id = PRE_TOTAL_PLAYERSprint(f"Сгенерировано {len(active_players)} игроков. Следующий ID: {next_id}")# Посчитаем распределение лимитов для справкиfrom collections import Counterinit_counts = Counter(max_games_dict.values())print("Распределение max_games среди начальных игроков (первые 10 значений):")for g in sorted(init_counts.keys())[:10]:    print(f"  {g}: {init_counts[g]} игроков")print("...")# ========== ФУНКЦИЯ ПРОВЕДЕНИЯ ОДНОГО РАУНДА ==========def run_round(active_players, stats):    """Проводит один раунд (MATCHES_PER_ROUND матчей) среди активных игроков."""    shuffled = active_players.copy()    random.shuffle(shuffled)    for match_idx in range(MATCHES_PER_ROUND):        start = match_idx * (TEAM_SIZE * 2)        match_players = shuffled[start:start + (TEAM_SIZE * 2)]        team1 = match_players[:TEAM_SIZE]        team2 = match_players[TEAM_SIZE:]        winner = random.choice([1, 2])        winning_team = team1 if winner == 1 else team2        all_players = team1 + team2        for pid in winning_team:            stats[pid]['wins'] += 1        for pid in all_players:            stats[pid]['games'] += 1    return stats# ========== ОСНОВНОЙ ЦИКЛ СИМУЛЯЦИИ ==========if __name__ == '__main__':    print("\nНачинаем симуляцию на {} раундов...".format(TOTAL_ROUNDS))    for round_num in range(1, TOTAL_ROUNDS + 1):        stats = run_round(active_players, stats)        print(f"Раунд {round_num} завершён.")        # Если это не последний раунд, выполняем замену завершивших игроков        if round_num < TOTAL_ROUNDS:            # Находим игроков, достигших своего max_games            players_to_remove = [                pid for pid in active_players                if stats[pid]['games'] == max_games_dict[pid]            ]            # Удаляем их из активного списка            for pid in players_to_remove:                active_players.remove(pid)            # Создаём новых игроков с новыми лимитами (по одному на каждого удалённого)            for _ in players_to_remove:                new_id = next_id                next_id += 1                new_max = generate_max_games()                max_games_dict[new_id] = new_max                stats[new_id] = {'wins': 0, 'games': 0}                active_players.append(new_id)            print(f"  Заменено {len(players_to_remove)} игроков. Активных: {len(active_players)}")    print("\n=== Симуляция завершена ===")    print(f"Всего уникальных игроков создано: {next_id}")    # ========== ПРОВЕРКИ ==========    total_games = sum(record['games'] for record in stats.values())    total_wins = sum(record['wins'] for record in stats.values())    print(f"Общее количество сыгранных матчей (игр) на всех игроков: {total_games}")    print(f"Общее количество побед: {total_wins}")    # Проверка, что все игроки не превысили свой лимит    violations = 0    for pid, record in stats.items():        if record['games'] > max_games_dict[pid]:            violations += 1    print(f"Игроков, превысивших лимит: {violations}")    # Количество игроков, которые завершили свою карьеру (games == max_games)    finished = sum(1 for pid in stats if stats[pid]['games'] == max_games_dict[pid] and stats[pid]['games'] > 0)    print(f"Игроков, завершивших карьеру (сыграли ровно свой лимит): {finished}")    # ========== СОХРАНЕНИЕ В EXCEL ==========    try:        data = []        for pid, record in stats.items():            games = record['games']            wins = record['wins']            win_rate = (wins / games * 100) if games > 0 else 0.0            data.append({                'player_id': pid,                'max_games': max_games_dict[pid],                'games': games,                'wins': wins,                'win_rate': win_rate            })        df = pd.DataFrame(data)        df.sort_values('player_id', inplace=True)        filename = 'statistics_normal_distribution.xlsx'        df.to_excel(filename, index=False)        print(f"\nСтатистика сохранена в файл {filename}")    except Exception as e:        print(f"Ошибка при сохранении: {e}")    # ========== ДОПОЛНИТЕЛЬНАЯ СВОДКА ПО ВСЕМ ИГРОКАМ ==========    avg_win_rate_all = sum(stats[pid]['wins'] / max(1, stats[pid]['games']) * 100 for pid in stats) / len(stats)    print(f"\nСредний процент побед по всем игрокам (включая активных): {avg_win_rate_all:.2f}%")    # Сводка только по завершившим (для сравнения)    if finished > 0:        finished_players = [pid for pid in stats if stats[pid]['games'] == max_games_dict[pid] and stats[pid]['games'] > 0]        avg_win_rate_finished = sum(stats[pid]['wins'] / stats[pid]['games'] * 100 for pid in finished_players) / len(finished_players)        print(f"Средний процент побед только среди завершивших карьеру: {avg_win_rate_finished:.2f}%")    # Пример для первого игрока    if 0 in stats:        print(f"\nПример: игрок 0 (max_games={max_games_dict[0]}) "              f"сыграл {stats[0]['games']} игр, побед {stats[0]['wins']} "              f"(процент {stats[0]['wins']/max(1, stats[0]['games'])*100:.2f}%)")

Сразу хочется сказать — очевидно, что один результат хоть и покажет то, что мы ожидаем, но для чистоты эксперимента я провёл 100 симуляций, чтобы видеть более общую картинку. Итоги эксперимента графически:

Результаты среднего процента побед игроков в 100 симуляциях

Результаты среднего процента побед игроков в 100 симуляциях

Здесь мы видим, что хоть и средний процент побед игроков в каждой симуляции стремился к 50%, но почти ни в одной не был 50%. Разброс хоть и небольшой, в пределах 0,07 в каждую сторону, но даже так мы получаем ответ — если учитывать такой параметр, что каждый игрок играет определённое количество игр, то средний процент игроков будет смещаться либо вправо, либо влево.

Посмотрим каким образом выбирался параметр игроков:

Частотность по лимитам боёв

Частотность по лимитам боёв

Теперь посмотрим распределение игроков по проценту побед:

В каждой симуляции генерируется по итогу 100-110к игроков

В каждой симуляции генерируется по итогу 100–110к игроков

Мы видим, что график первой симуляции несимметричен. Асимметрию создают игроки, которые проводят случайное количество матчей. Например, очевидно, что игроки у которых 0% и 100% побед играли совсем мало матчей, может, выиграли 5 из 5 или проиграли 3 из 3. 

Ради интереса я решил проверить — является ли распределение процента побед игроков нормальным. Для этого я использовал график qq‑plot.

Видно, что в середине графика идёт совпадение с нормальными значениями (красная линия — нормальное распределение, синий график — распределение процента побед по игрокам), но хвосты всё портят

Видно, что в середине графика идёт совпадение с нормальными значениями (красная линия — нормальное распределение, синий график — распределение процента побед по игрокам), но хвосты всё портят

Ну и на всякий случай, решил использовать тест Колмогорова‑Смирнова для определения нормальности в каждой симуляции.

Чтобы не растягивать статью скажу: ни в одном эксперименте, распределение процента побед игроков не было нормальным

Чтобы не растягивать статью скажу: ни в одном эксперименте, распределение процента побед игроков не было нормальным

Распределение Пуассона

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

P(X = k) = \frac{\lambda^k e^{-\lambda}}{k!}, \quad k = 0,1,2,\dots, \quad \lambda > 0

Оно будет несимметричным, поэтому я ради эксперимента его тоже взял. Вот так будет выглядеть распределение параметров, при лямбда 10 игр:

Распределение лимитов стало более широким и асимметричным

Распределение лимитов стало более широким и асимметричным

Эксперимент при 100 симуляциях дал следующие результаты:

Видим всё такое же отклонение среднего процента побед всех игроков

Видим всё такое же отклонение среднего процента побед всех игроков

Распределение игроков по проценту побед:

Тут мы видим более жирные хвосты тех у кого 0% и 100% побед, как раз за счёт  асимметрии распределения Пуассона, то есть распределение стало давать больше вероятности, что зайдёт игрок, который играет мало матчей

Тут мы видим более жирные хвосты тех у кого 0% и 100% побед, как раз за счёт асимметрии распределения Пуассона, то есть распределение стало давать больше вероятности, что зайдёт игрок, который играет мало матчей

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

Распределение Парето

Распределение Парето — это степенное математическое распределение непрерывной случайной величины, которое описывает дисбаланс и неравномерность в экономике, природе и обществе.

F(x) = P(X \le x) =  \begin{cases} 1 - \left(\dfrac{x_m}{x}\right)^\alpha, & x \ge x_m, \\[6pt] 0, & x < x_m, \end{cases} \quad \text{где } x_m > 0,\; \alpha > 0.

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

То есть большинство игроков сыграло только 4 игры, уже гораздо меньше 5 и так далее

То есть большинство игроков сыграло только 4 игры, уже гораздо меньше 5 и так далее

С одной стороны такое распределение даст нам хорошую симметрию, за счёт того, что сыграть 4 победы из 4 боёв или проиграть все из 4-х, шанс не такой уж и большой, что мы и увидим на следующих графиках:

Распределение 100 симуляций отличается от предыдущих минимальным разбросом

Распределение 100 симуляций отличается от предыдущих минимальным разбросом

Распределение игроков выглядит гораздо интереснее, чем раньше:

Распределение с самыми жирными хвостами

Распределение с самыми жирными хвостами

Тут примерно 12к игроков не выиграли ни одной игры и 12к игроков выиграли все игры. Как раз из‑за того, что игр у большинства игроков было мало.

Отрицательное биномиальное распределение

Отрицательное биномиальное распределение я решил взять, так как по моему мнению, оно наиболее правдоподобно распределяет параметр игр.

P(X = k) = \binom{k+r-1}{k} p^r (1-p)^k, \quad k = 0,1,2,\dots

Распределение лимита боёв будет выглядеть следующим образом:

Большинство проводит 3 игры, а остальное распределение равномерно угасает, исключение составляют лишь «старички», которые просто хотят поиграть подольше

Большинство проводит 3 игры, а остальное распределение равномерно угасает, исключение составляют лишь «старички», которые просто хотят поиграть подольше

В таком распределении результат 100 симуляций выглядит так:

Разброс примерно 0,1 в каждую сторону

Разброс примерно 0,1 в каждую сторону

Распределение по проценту побед у игроков выглядит так:

Тут хвост получился гораздо меньше, чем у Парето, всего по 6к игроков либо все игры выиграли, либо все проиграли. Но всё ещё больше, чем у распределений Пуассона (2,5к)  и нормального (2к)

Тут хвост получился гораздо меньше, чем у Парето, всего по 6к игроков либо все игры выиграли, либо все проиграли. Но всё ещё больше, чем у распределений Пуассона (2,5к) и нормального (2к)

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

Второй и главный вывод этого эксперимента: даже если в игре всего 2 исхода, это не означает, что средний процент всех игроков будет 50%, он будет смещаться влево или вправо, в зависимости от случайности. 

Последний эксперимент

Мы получили эксперимент, который не может похвастаться большим разбросом от теоретического значения. Но мы добавили лишь один математический параметр — количество игр каждого игрока. А что если добавить ещё один параметр? Например, психологический. Далеко не каждый игрок может мириться с луз стриками. Ладно 2–3 поражения подряд. А что если мы опишем функцию «выживания» игрока. Возьмём за параметр, что в среднем игрок уйдёт после 5 поражения подряд. Опишем это также: отрицательным биномиальным распределением. Тогда будет следующая логика: игрок зашёл сыграть 10 матчей, но если произойдёт 5 поражений подряд, то он выйдет из игры (или перейдёт на другой сервер).

Тогда получается, что с таким распределением лимита боёв:

Распределение как и в предыдущем эксперименте

Распределение как и в предыдущем эксперименте

И с новым параметром выживаемости:

Распределение начинается с 2-х поражений подряд

Распределение начинается с 2-х поражений подряд

Мы получаем такое распределение среднего процента побед игроков по 100 симуляциям:

Видим сильное смещение влево за счёт добавления нового параметра

Видим сильное смещение влево за счёт добавления нового параметра

И следующее распределение процента побед игроков в первой симуляции:

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

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

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

При добавлении параметра «выживаемости» игрока, мы видим, что средний процент побед игроков сместился влево. Эксперимент можно назвать радикальным, потому что в распределении выживаемости много игроков, которые уйдут после 2-3-х поражений подряд. Но при этом, суть остаётся — при добавлении параметров, которые могут быть разными: психологическими, ситуативными, физическими, то даже в банальной игре с двумя возможными событиями — победа/поражение, средняя статистика игроков будет смещаться влево.

Чтобы не запутаться с дальнейшими данными реальных данных, повторю — изначальный спор был о том, может ли статистика игроков целого сервера в данных момент игрового времени быть меньше теоретических 50%? Ответ — да, может. Первое — за счёт того, что игроки играют разное количество матчей. Второе — за счёт психологического параметра выживаемости. И третье — малое количество игр. Так как мы рассматривали 30 игр, а это 4–5 часов реального времени.

Реальные данные

Меня всегда интересовало, как вообще распределён средний процент игроков в игре. Я нашёл сайт сессионной аналитики для игр «Мир танков» и «World of Tanks» и запросил у разработчика сайта данные. Он любезно их предоставил, чтобы можно было посмотреть средний процент игроков. Данные по RU региону, режим игры — «Случайный бой».

Тут мы видим жирные хвосты за счёт новичков, которые больше в игру не играли

Тут мы видим жирные хвосты за счёт новичков, которые больше в игру не играли

Теперь посмотрим статистику тех, кто отыграл больше 1000 игр:

На графике хорошо видна асимметрия, что меньше 40% игроков почти нет, но больше 60% есть и не мало

На графике хорошо видна асимметрия, что меньше 40% игроков почти нет, но больше 60% есть и не мало

Ну и посмотрим ветеранов игры, которые провели более 10 000 игр

Статистика говорит о том, что среднее ветеранов составляет аж 51,46%

Статистика говорит о том, что среднее ветеранов составляет аж 51,46%

Эксперимент, который я провёл, рассматривает текущую ситуацию в игре. Это можно использовать (или, скорее всего, используют) для ребаланса игры. И я имею в виду не только один показатель как средний процент побед, но ещё и жирность хвостов. Как мы помним — жирнохвостость показывает определённую имбалансность параметров (как было с параметром количества лимитов игр с распределением Парето), хоть и средний процент при этом был почти равен теоретическому.

ссылка на оригинал статьи https://habr.com/ru/articles/1081222/