Собесов

Playrix — анализ A/B-теста кривой сложности в Match-3 игре

Кейсы и метрикиA/B-тесты в gamedevСложнаяSenior

Условие

В Match-3 игре каждый уровень имеет сложность. Кривая сложности — динамика изменения сложности в последовательности уровней. Команда хочет проверить две гипотезы:

  1. Снижение сложности первых уровней уменьшит отток игроков, но снизит монетизацию на этих уровнях (юзеры не будут «застревать» и покупать буст).
  2. Постепенное усложнение дальних уровней компенсирует просадку в монетизации.

Провели A/B-тест на новых игроках (join_date == install_date). Длительность — 60 дней.

Данные:

  • ab_users.csv: event_user, abgroup, join_date.
  • ab_data.csv: event_user, date, revenue, attempts (дневное количество сыгранных уровней).

Задания:

  1. Интерпретировать результаты теста и дать рекомендацию.
  2. Какие данные/метрики ещё хотели бы посмотреть.

Playrix Match-3 — описание задачи

Решение

Подход

Тест с двумя «дорожками изменений» (упростили начало + усложнили дальше). Это не два независимых эксперимента, а один, чтобы увидеть нетто-эффект.

Гипотезы можно увидеть в данных через:

  • Retention: должен вырасти в test (меньше «застряли» в начале).
  • ARPU: на ранних днях, скорее всего, упадёт (меньше «фрустрированных» покупок); на поздних — должен компенсировать.
  • Attempts (попытки = engagement): рост — хороший сигнал.
  • LTV-60 (decision metric) — нетто-эффект.

Реализация — план анализа

import pandas as pd
import numpy as np
from scipy import stats
import matplotlib.pyplot as plt
 
ab = pd.read_csv("ab_users.csv", parse_dates=["join_date"])
data = pd.read_csv("ab_data.csv", parse_dates=["date"])
 
df = data.merge(ab, on="event_user")
df["day_after_install"] = (df["date"] - df["join_date"]).dt.days
 
# 1. Retention curve по группе
def retention_curve(df, group, days=range(0, 61)):
    sub = df[df["abgroup"] == group]
    n_total = sub["event_user"].nunique()
    out = []
    for d in days:
        active = sub[sub["day_after_install"] == d]["event_user"].nunique()
        out.append({"day": d, "retention": active / n_total})
    return pd.DataFrame(out)

Метрика 1. Retention

ret_a = retention_curve(df, "A")
ret_b = retention_curve(df, "B")
 
plt.plot(ret_a["day"], ret_a["retention"], label="A control")
plt.plot(ret_b["day"], ret_b["retention"], label="B test")
plt.xlabel("Day after install"); plt.ylabel("DAU/installs"); plt.legend()

Если retention в B выше → гипотеза 1 «уменьшаем отток на старте» подтверждается.

Метрика 2. ARPU и LTV

# Cumulative LTV
df["cumrev_user"] = df.sort_values(["event_user", "date"]).groupby("event_user")["revenue"].cumsum()
 
ltv = (df.groupby(["abgroup", "day_after_install"])["revenue"].sum()
         .div(df.groupby("abgroup")["event_user"].nunique(), level="abgroup")
         .reset_index(name="ltv_per_day"))
# Кумулятивный LTV(60)
ltv["cum_ltv"] = ltv.groupby("abgroup")["ltv_per_day"].cumsum()

Если в B cum_ltv@60 ≥ cum_ltv@60_A → нетто-эффект положительный или нейтральный, гипотеза 2 подтверждается.

Метрика 3. Attempts (engagement)

attempts = df.groupby(["abgroup", "day_after_install"])["attempts"].mean().reset_index()

Если в B больше attempts на каждый день → engagement выше.

Стат-критерии

  • Retention(D7), Retention(D30): пропорции → z-test.
  • LTV-60 per user: непрерывная, heavy-tailed → bootstrap (нельзя t-test без сильных предположений).
  • Attempts per day: можно усреднять до уровня юзера и t-test, или mixed-effects.
# LTV-60 per user
ltv60 = df.groupby(["event_user", "abgroup"])["revenue"].sum().reset_index()
 
# Bootstrap CI на разность средних
def bootstrap_diff(a, b, n_iter=10000, seed=42):
    rng = np.random.default_rng(seed)
    diffs = []
    for _ in range(n_iter):
        a_s = rng.choice(a, len(a), replace=True)
        b_s = rng.choice(b, len(b), replace=True)
        diffs.append(b_s.mean() - a_s.mean())
    return np.array(diffs)
 
a = ltv60[ltv60["abgroup"] == "A"]["revenue"].values
b = ltv60[ltv60["abgroup"] == "B"]["revenue"].values
diffs = bootstrap_diff(a, b)
print(f"Mean diff: {diffs.mean():.4f}")
print(f"95% CI: [{np.percentile(diffs, 2.5):.4f}, {np.percentile(diffs, 97.5):.4f}]")
print(f"P(B > A) = {(diffs > 0).mean():.3f}")

Анализ / интерпретация

Возможные сценарии и рекомендации:

Retention LTV-60 Решение
B выше B выше Раскатывать B. Win-win.
B выше B = A Раскатывать B (long-term retention важнее).
B выше B ниже Сложно. Нужен LTV-180/365 — может быть, retention сдвинет монетизацию дальше. Если бизнесу важна окупаемость — может потребоваться доп. эксперимент.
B = A B ниже Не раскатывать. Гипотеза не сработала.
B ниже B ниже Не раскатывать.

Фактически, наиболее вероятный исход — B даёт лучший retention и сравнимый/лучший LTV-60, потому что начальные уровни при сильной фрустрации больше теряют, чем приносят (большинство «застрявших» уходят, а не покупают).

Дополнительные данные / метрики

  1. % юзеров, прошедших уровень N для всех N. Покажет, где именно «бутылочное горлышко» в обеих кривых.
  2. Time on level N — сколько раз пробуют, сколько времени тратят. Связь с фрустрацией.
  3. Spent currency per level — на каких уровнях покупают буст.
  4. Конверсия в первую покупку (CR to first IAP) — главный leading indicator монетизации.
  5. Сегмент платящих vs нет: эффект может быть только на платящих или только на не-платящих.
  6. UA-источник: качество трафика влияет, иногда эффект только на organic.
  7. D90, D180 retention (если можно ждать): 60 дней мало для match-3, у которой LTV-кривая длинная.
  8. Distribution of revenue per user: 60-day LTV — это среднее, но в gamedev доход распределён по power law. Median revenue — отличный sanity-check.
  9. Cancellation после первого buy — если в B «сделал одну покупку и больше не возвращается», это плохой сигнал.

Подводные камни

  1. Только конверсия первой покупки. «B даёт больше первых покупок» — не значит больше LTV-60. Считайте LTV.
  2. Среднее vs медиана. В gamedev 1% китов даёт 50% дохода. Среднее по 1000 юзеров может полностью определяться 1 китом. Используйте bootstrap или trimmed mean.
  3. Sample Ratio Mismatch. Особенно если test — это разная игровая экономика; различные клиенты могут по-разному обновляться.
  4. Sample selection. Тест на новых юзерах — но «новые» через 60 дней могут отличаться (изменился UA, сезон). Сравните только когорты в одном временнóм окне.
  5. Незрелые юзеры в выборке: те, кто установился в последний день, имеют только 1 день истории. Анализ LTV-60 — только для тех, у кого есть 60 дней.
  6. Игнорирование «нулевых» юзеров. Игрок, не открывший игру после установки, обычно не попадает в ab_data.csv. Если в ab_users.csv они есть — это 0% retention для них; учитывайте.
  7. Day 0 vs Day 1. «retention D1» — кто вернулся следующий календарный день, а не «через 24 часа». Tz важен.
  8. «Установка = попадание в тест». Если разнесено — нужно фильтровать тех, у кого join_date != install_date (но в задаче это совпадает).

Эталонный ответ

Структура отчёта:

  1. Sanity check групп (размер, distribution UA-каналов, гео).
  2. Retention curve A vs B → ключевой график.
  3. Cumulative LTV A vs B → decision-метрика.
  4. Attempts per day A vs B → engagement.
  5. Bootstrap CI на разность средних LTV-60.
  6. Рекомендация с условиями.

Дополнительно хотим посмотреть: % прошедших уровень N, time on level, conversion to first IAP, D90/D180 retention, медианный revenue, segmentation по UA.

Главное: A/B с двумя одновременными изменениями (уменьшили старт + усложнили дальше) — нельзя вычленить эффект каждого по отдельности. Нужен либо 4-арм тест (A, A+easy_start, A+hard_late, A+both), либо последовательные A/B.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти