Контринтуиция в статистике на примере игры Мир Танков/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 игру, и его статистика запишется. Параметр «количество игр одного игрока» будет описываться различными распределениями, о которых поговорим далее.
Нормальное распределение
Нормальное распределение — это непрерывное распределение вероятностей с пиком в центре и симметричными боковыми сторонами. Формула выглядит следующим образом:
Это распределение берут, когда есть какое‑то среднее, и мы считаем что 99,99% всех значений лежит в 3-х среднеквадратичных отклонениях вправо и влево. Вот и я в этом эксперименте решил использовать это распределение как параметр количества игр у игрока.
Предполагаем, что в среднем игрок проводит 10 игр, значит, что дисперсия будет 3,5427, а среднее квадратичное отклонение 1,88. Ниже я оставлю код, чтобы вы могли воспроизвести этот эксперимент у себя. Следующие эксперименты будут отличаться только распределениями и их параметрами.
import random
import pandas as pd
import math
from scipy.stats import norm
# ========== КОНСТАНТЫ ==========
PRE_TOTAL_PLAYERS = 30000 # начальное количество активных игроков
TEAM_SIZE = 15
MATCHES_PER_ROUND = PRE_TOTAL_PLAYERS // (TEAM_SIZE * 2) # 1000
TOTAL_ROUNDS = 30 # максимальное количество раундов (т.к. максимум 30)
# Параметры нормального распределения для max_games
MEAN_MAX_GAMES = 10.0
STD_MAX_GAMES = 1.88
MIN_MAX_GAMES = 3
MAX_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_PLAYERS
print(f"Сгенерировано {len(active_players)} игроков. Следующий ID: {next_id}")
# Посчитаем распределение лимитов для справки
from collections import Counter
init_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 симуляций, чтобы видеть более общую картинку. Итоги эксперимента графически:

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

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

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

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

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

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

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

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

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

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

Тут примерно 12к игроков не выиграли ни одной игры и 12к игроков выиграли все игры. Как раз из‑за того, что игр у большинства игроков было мало.
Отрицательное биномиальное распределение
Отрицательное биномиальное распределение я решил взять, так как по моему мнению, оно наиболее правдоподобно распределяет параметр игр.
Распределение лимита боёв будет выглядеть следующим образом:

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

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

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

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

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

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

По сути мы определили, что, как минимум, определённый сервер, в данный момент игрового времени, может быть «несчастливым». Поймав луз стрик игроки либо переходят на другой сервер, с целью выравнивания статистики, либо вообще выходят из игры.
При добавлении параметра «выживаемости» игрока, мы видим, что средний процент побед игроков сместился влево. Эксперимент можно назвать радикальным, потому что в распределении выживаемости много игроков, которые уйдут после 2-3-х поражений подряд. Но при этом, суть остаётся — при добавлении параметров, которые могут быть разными: психологическими, ситуативными, физическими, то даже в банальной игре с двумя возможными событиями — победа/поражение, средняя статистика игроков будет смещаться влево.
Чтобы не запутаться с дальнейшими данными реальных данных, повторю — изначальный спор был о том, может ли статистика игроков целого сервера в данных момент игрового времени быть меньше теоретических 50%? Ответ — да, может. Первое — за счёт того, что игроки играют разное количество матчей. Второе — за счёт психологического параметра выживаемости. И третье — малое количество игр. Так как мы рассматривали 30 игр, а это 4–5 часов реального времени.
Реальные данные
Меня всегда интересовало, как вообще распределён средний процент игроков в игре. Я нашёл сайт сессионной аналитики для игр «Мир танков» и «World of Tanks» и запросил у разработчика сайта данные. Он любезно их предоставил, чтобы можно было посмотреть средний процент игроков. Данные по RU региону, режим игры — «Случайный бой».

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

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

Эксперимент, который я провёл, рассматривает текущую ситуацию в игре. Это можно использовать (или, скорее всего, используют) для ребаланса игры. И я имею в виду не только один показатель как средний процент побед, но ещё и жирность хвостов. Как мы помним — жирнохвостость показывает определённую имбалансность параметров (как было с параметром количества лимитов игр с распределением Парето), хоть и средний процент при этом был почти равен теоретическому.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.