Реализация А/Б-тестов

от автора

Для А/Б-тестов в вебе показаны случайный выбор групп, хэширование, логика на бэкэнде и фронтэнде, логирование событий, одновременные эксперименты и админка.

1. Случайные группы
2. Хэширование
3. Фронтэнд
4. События
5. Конфиг
6. Два эксперимента
7. Админка
8. Веса
9. Раскатка
Заключение

Репозиторий: https://github.com/andrewbrdk/AB-Testing-from-Scratch .

Виртуальное окружение примеров:

git clone https://github.com/andrewbrdk/Web-AB-Testing-Demo cd Web-AB-Testing-Demo python -m venv pyvenv source ./pyvenv/bin/activate pip install flask aiohttp playwright playwright install chromium

A/Б-тестирование веб-сервисов оценивает влияние новых функций на ключевые метрики. Оригинальная и изменённая версия запускаются параллельно, пользователи случайно делятся между вариантами. Одновременный запуск обеспечивает равное влияние внешних факторов, случайное деление снижает дизбаланс групп. В итоге различия метрик между вариантами объясняют новой функциональностью.

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

1. Случайные группы

Экспериментальная группа пользователя определяется на бэкэнде вызовом random.choice. Группа сохраняется в куках и проверяется при каждом заходе на сайт для постоянности варианта.

python 1_rnd.py

Эксп: http://127.0.0.1:5000

Moon, Mars

Варианты эксперимента.
from flask import Flask, render_template_string, request, make_response import random  app = Flask(__name__)  TEMPLATE = ''' <!DOCTYPE html> <html> <head>     <title>A/B Test</title>     <link rel="stylesheet" href="{{ url_for('static', filename='banners.css') }}"> </head> <body>     {% if variant == 'Moon' %}         <div class="banner" style="background-image: url('{{ url_for('static', filename='./moon.jpg') }}');">             <h1>Walk on the Moon</h1>             <div class="vspacer"></div>             <p>Be one of the first tourists to set foot on the lunar surface. Your journey to another world starts here.</p>             <button onclick="console.log('Click Moon')">Reserve Your Spot</button>         </div>     {% else %}         <div class="banner" style="background-image: url('{{ url_for('static', filename='./mars.jpg') }}');">             <h1>Journey to Mars</h1>             <div class="vspacer"></div>             <p>Be among the first humans to set foot on the Red Planet. Experience the adventure of a lifetime.</p>             <button onclick="console.log('Click Mars')">Reserve Your Spot</button>         </div>     {% endif %} </body> </html> '''  @app.route('/') def index():     variant = request.cookies.get('variant')     if variant not in ['Moon', 'Mars']:         variant = random.choice(['Moon', 'Mars'])     response = make_response(render_template_string(TEMPLATE, variant=variant))     response.set_cookie('variant', variant, max_age=60*60*24*30)     return response  if __name__ == '__main__':     app.run(debug=True)
  • {% if variant == 'Moon' %} ... {% endif %} — бэкенд отдаёт соответствующий группе вариант страницы.

  • variant = request.cookies.get('variant') — считывается текущая группа в куках.

  • variant = random.choice(['Moon', 'Mars']) — если группы нет, группа выбирается случайно.

  • response.set_cookie('variant', variant, max_age=60*60*24*30) — группа записывается в куки.

Для просмотра другого варианта нужно либо открыть страницу в новом окне инкогнито, либо очистить куки и перезагрузить страницу.

Clear Cookies

Экспериментальная группа сохраняется в куках для постоянности варианта при заходе на сайт. Для просмотра другого варианта нужно либо открыть страницу в новом окне инкогнито, либо очистить куки и перезагрузить страницу.

Скрипт simulate_visits.py имитирует заходы на страницу. Распределение по группам близко ожидаемому 50/50.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 488 visits (48.80%), Exact 50.00% Moon: 512 visits (51.20%), Exact 50.00%

2. Хэширование

Каждому посетителю присваивается уникальный device_id и записывается в куки. Экспериментальная группа вычисляется как hash(device_id || experiment_name) % 2.

python 2_hash.py

Эксп: http://127.0.0.1:5000

from flask import Flask, render_template_string, request, make_response import uuid import hashlib  app = Flask(__name__)  TEMPLATE = """ <!DOCTYPE html> <html> <head>     <title>A/B Test</title>     <link rel="stylesheet" href="{{ url_for('static', filename='banners.css') }}"> </head> <body>     {% if variant == 'Moon' %}         <div class="banner" style="background-image: url('{{ url_for('static', filename='./moon.jpg') }}');">             <h1>Walk on the Moon</h1>             <div class="vspacer"></div>             <p>Be one of the first tourists to set foot on the lunar surface. Your journey to another world starts here.</p>             <button onclick="console.log('Click Moon')">Reserve Your Spot</button>         </div>     {% else %}         <div class="banner" style="background-image: url('{{ url_for('static', filename='./mars.jpg') }}');">             <h1>Journey to Mars</h1>             <div class="vspacer"></div>             <p>Be among the first humans to set foot on the Red Planet. Experience the adventure of a lifetime.</p>             <button onclick="console.log('Click Mars')">Reserve Your Spot</button>         </div>     {% endif %} </body> </html> """  EXPERIMENT_NAME = "moon_mars"  def assign_group(device_id: str, experiment: str) -> str:     key = f"{device_id}:{experiment}"     hash_bytes = hashlib.sha256(key.encode()).digest()     hash_int = int.from_bytes(hash_bytes, 'big')     return 'Moon' if hash_int % 2 == 0 else 'Mars'  @app.route('/') def index():     device_id = request.cookies.get("device_id")     if not device_id:         device_id = str(uuid.uuid4())     variant = assign_group(device_id, EXPERIMENT_NAME)     response = make_response(render_template_string(TEMPLATE, variant=variant))     response.set_cookie("device_id", device_id, max_age=60*60*24*365)     return response  if __name__ == '__main__':     app.run(debug=True)
  • device_id = str(uuid.uuid4()) — генерирует уникальный ID новым посетителям.

  • variant = assign_group(device_id, EXPERIMENT_NAME) — вычисляет группу.

  • key = f"{device_id}:{experiment}" — объединение ID пользователя и названия эксперимента для вычисления группы.

  • response.set_cookie("device_id", device_id, max_age=60*60*24*365) — записывает device_id в куки.

Распределение по группам равномерное.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 507 visits (50.70%), Exact 50.00% Moon: 493 visits (49.30%), Exact 50.00%

3. Фронтэнд

Фронтэнд получает обе версии и отображает нужный вариант. Группа вычисляется на бэкэнде и передаётся в куке "exp_group". С помощью хеширования группу можно вычислять на фронтэнде при доступном device_id.

python 3_frontend.py

Эксп: http://127.0.0.1:5000

from flask import Flask, request, make_response, render_template_string import uuid import hashlib  app = Flask(__name__)  TEMPLATE = """ <!DOCTYPE html> <html> <head>     <title>A/B Test</title>     <link rel="stylesheet" href="{{ url_for('static', filename='banners.css') }}"> </head> <body>     <div id="variant-container">Loading...</div>      <script>         function getCookie(name) {             const value = `; ${document.cookie}`;             const parts = value.split(`; ${name}=`);             if (parts.length === 2) return parts.pop().split(';').shift();         }          const expGroup = getCookie("exp_group");         const container = document.getElementById("variant-container");          if (expGroup === "Moon") {             container.innerHTML = `                 <div class="banner" style="background-image: url('{{ url_for('static', filename='./moon.jpg') }}');">                     <h1>Walk on the Moon</h1>                     <div class="vspacer"></div>                     <p>Be one of the first tourists to set foot on the lunar surface. Your journey to another world starts here.</p>                     <button onclick="console.log('Click Moon')">Reserve Your Spot</button>                 </div>             `;         } else {             container.innerHTML = `                 <div class="banner" style="background-image: url('{{ url_for('static', filename='./mars.jpg') }}');">                     <h1>Journey to Mars</h1>                     <div class="vspacer"></div>                     <p>Be among the first humans to set foot on the Red Planet. Experience the adventure of a lifetime.</p>                     <button onclick="console.log('Click Mars')">Reserve Your Spot</button>                 </div>             `;         }     </script> </body> </html> """  EXPERIMENT_NAME = "moon_mars"  def assign_group(device_id: str, experiment: str) -> str:     key = f"{device_id}:{experiment}"     hash_bytes = hashlib.sha256(key.encode()).digest()     hash_int = int.from_bytes(hash_bytes, 'big')     return 'Moon' if hash_int % 2 == 0 else 'Mars'  @app.route('/') def index():     device_id = request.cookies.get("device_id")     if not device_id:         device_id = str(uuid.uuid4())     variant = assign_group(device_id, EXPERIMENT_NAME)     response = make_response(render_template_string(TEMPLATE))     response.set_cookie("device_id", device_id, max_age=60*60*24*365)     response.set_cookie("exp_group", variant, max_age=60*60*24*365)     return response  if __name__ == '__main__':     app.run(debug=True)
  • <div id="variant-container">Loading...</div> — контейнер эксперимента.

  • const expGroup = getCookie("exp_group"); — чтение группы из кук.

  • if (expGroup === "Moon") { container.innerHTML = ... } — замена содержимого контейнера вариантом, соответствующим группе пользователя.

Деление трафика корректное.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 502 visits (50.20%), Exact 50.00% Moon: 498 visits (49.80%), Exact 50.00%

4. События

При заходе на страницу или нажатии кнопки баннера отправляются события pageview и button_click. События — это JSON’ы с device_id, event_name, моментом отправки и другой информацией. Данные отправляются на эндпоинт /events, в реальных конфигурациях часто используют отдельный сервис.

python 4_events.py

Эксп: http://127.0.0.1:5000
События: http://127.0.0.1:5000/events

События.

События.
from flask import Flask, request, make_response, render_template_string, jsonify import uuid import hashlib  app = Flask(__name__)  TEMPLATE = """ <!DOCTYPE html> <html> <head>     <title>A/B Test</title>     <link rel="stylesheet" href="{{ url_for('static', filename='banners.css') }}"> </head> <body>     <div id="variant-container">Loading...</div>      <script>         async function sendEvent(eventName, params = {}) {             let ts = new Date().toISOString();             await fetch('/events', {                 method: 'POST',                 headers: { 'Content-Type': 'application/json' },                 body: JSON.stringify({                     ts: ts,                     device_id: deviceId,                     source: 'client',                     event: eventName,                     exp_group: expGroup,                     params: params                 })             });         }          function getCookie(name) {             const value = `; ${document.cookie}`;             const parts = value.split(`; ${name}=`);             if (parts.length === 2) return parts.pop().split(';').shift();         }          const deviceId = getCookie("device_id");         const expGroup = getCookie("exp_group");         const container = document.getElementById("variant-container");          if (expGroup === "Moon") {             container.innerHTML = `                 <div class="banner" style="background-image: url('{{ url_for('static', filename='./moon.jpg') }}');">                     <h1>Walk on the Moon</h1>                     <div class="vspacer"></div>                     <p>Be one of the first tourists to set foot on the lunar surface. Your journey to another world starts here.</p>                     <button onclick="sendEvent('button_click', { btn_type: 'Moon' })">Reserve Your Spot</button>                 </div>             `;         } else {             container.innerHTML = `                 <div class="banner" style="background-image: url('{{ url_for('static', filename='./mars.jpg') }}');">                     <h1>Journey to Mars</h1>                     <div class="vspacer"></div>                     <p>Be among the first humans to set foot on the Red Planet. Experience the adventure of a lifetime.</p>                     <button onclick="sendEvent('button_click', { btn_type: 'Mars' })">Reserve Your Spot</button>                 </div>             `;         }          sendEvent("pageview", {});     </script> </body> </html> """  EXPERIMENT_NAME = "moon_mars"  def assign_group(device_id: str, experiment: str) -> str:     key = f"{device_id}:{experiment}"     hash_bytes = hashlib.sha256(key.encode()).digest()     hash_int = int.from_bytes(hash_bytes, 'big')     return 'Moon' if hash_int % 2 == 0 else 'Mars'  @app.route('/') def index():     device_id = request.cookies.get("device_id")     if not device_id:         device_id = str(uuid.uuid4())     variant = assign_group(device_id, EXPERIMENT_NAME)     response = make_response(render_template_string(TEMPLATE))     response.set_cookie("device_id", device_id, max_age=60*60*24*365)     response.set_cookie("exp_group", variant, max_age=60*60*24*365)     return response  EVENTS = []  @app.route('/events', methods=['GET', 'POST']) def events():     if request.method == 'POST':         data = request.json         EVENTS.append(data)         return jsonify({"status": "ok"})     else:         return jsonify(EVENTS)  if __name__ == '__main__':     app.run(debug=True)
  • async function sendEvent(eventName, params = {}) — отправляет аналитические события. Поле params содержит специфическую для каждого типа событий информацию.

  • sendEvent("button_click", ...) — отправляет событие button_click.

  • sendEvent("pageview", {}); — отправляет событие pageview.

  • EVENTS = [] — на сервере события хранятся в списке EVENTS .

  • @app.route('/events', methods=['GET', 'POST']) — эндпоинт для сбора событий.

В simulate_visits.py имитируются заходы на страницу и нажатия кнопок. Вероятность нажатия кнопки в группах отличается CLICK_PROBS = {'Moon': 0.1, 'Mars': 0.2}. При заходе на страницу или нажатии кнопки отправляются аналитические события. Конверсии по этим событиям близки CLICK_PROBS.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 490 visits (49.00%), Exact 50.00% Moon: 510 visits (51.00%), Exact 50.00%  Moon/Mars Exp events: Mars: 490 visits, 95 clicks, Conv=19.39 +- 3.57%, Exact: 20.00% Moon: 510 visits, 51 clicks, Conv=10.00 +- 2.66%, Exact: 10.00%

5. Конфиг

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

python 5_config.py

Эксп: http://127.0.0.1:5000
Собыитя: http://127.0.0.1:5000/events
Эксперименты: http://127.0.0.1:5000/api/experiments
Группы: http://127.0.0.1:5000/api/expgroups

# ...  INDEX_TEMPLATE = """ // ... <body>     <div id="variant-container">Loading...</div>      <script>         // ...          async function getExpGroups(deviceId) {             const res = await fetch(`/api/expgroups?device_id=${deviceId}`);             return await res.json();         }          async function renderPage() {             const experiments = await getExpGroups(deviceId);             const exp = experiments["moon_mars"];             const container = document.getElementById("variant-container");             if (exp.group === "Moon") {                 container.innerHTML = `                     <div class="banner" style="background-image: url('{{ url_for('static', filename='./moon.jpg') }}');">                         <h1>Walk on the Moon</h1>                         <div class="vspacer"></div>                         <p>Be one of the first tourists to set foot on the lunar surface. Your journey to another world starts here.</p>                         <button onclick="sendEvent('button_click', { btn_type: 'Moon' })">Reserve Your Spot</button>                     </div>                 `;             } else {                 container.innerHTML = `                     <div class="banner" style="background-image: url('{{ url_for('static', filename='./mars.jpg') }}');">                         <h1>Journey to Mars</h1>                         <div class="vspacer"></div>                         <p>Be among the first humans to set foot on the Red Planet. Experience the adventure of a lifetime.</p>                         <button onclick="sendEvent('button_click', { btn_type: 'Mars' })">Reserve Your Spot</button>                     </div>                 `;             }         }          const deviceId = getCookie("device_id");         sendEvent("pageview", {});         renderPage();     </script> </body> </html> """  @app.route('/') def index():     device_id = request.cookies.get("device_id")     if not device_id:         device_id = str(uuid.uuid4())     response = make_response(render_template_string(INDEX_TEMPLATE))     response.set_cookie("device_id", device_id, max_age=60*60*24*365)     return response  # ...  EXPERIMENTS = {     "moon_mars": {         "groups": {'Moon': 50, 'Mars': 50},         "fallback": "Moon",         "state": "active",     } }  @app.route('/api/experiments') def api_experiments():     return jsonify(EXPERIMENTS)  @app.route('/api/expgroups') def api_expgroups():     device_id = request.args.get("device_id")     result = {}     for exp_name, info in EXPERIMENTS.items():         group = assign_group(device_id, exp_name) if device_id else ""         result[exp_name] = {             "state": info["state"],             "fallback": info["fallback"],             "group": group         }     if device_id:         post_event("exp_groups", device_id, result)     return jsonify(result)  def assign_group(device_id: str, experiment: str) -> str:     groups = EXPERIMENTS[experiment]["groups"]     total_parts = sum(groups.values())     key = f"{device_id}:{experiment}"     hash_bytes = hashlib.sha256(key.encode()).digest()     hash_int = int.from_bytes(hash_bytes, 'big')     hash_mod = hash_int % total_parts     c = 0     chosen = EXPERIMENTS[experiment]["fallback"]     for group_name, weight in sorted(groups.items()):         c += weight         if hash_mod < c:             chosen = group_name             break     return chosen  def post_event(event_name: str, device_id: str, params: dict):     payload = {         "ts": datetime.utcnow().isoformat(),         "deviceId": device_id,         "source": 'backend',         "event": event_name,         "params": params     }     with app.test_request_context("/events", method="POST", json=payload):         return events()  if __name__ == '__main__':     app.run(debug=True)
  • async function getExpGroups(deviceId) — получает группы клиента с сервера.

  • if (exp.group === "Moon") { ... } — отображает нужный вариант.

  • def index() — больше не выставляет куку «exp_group».

  • EXPERIMENTS — параметры экспериментов.

  • @app.route('/api/experiments') — возвращает информацию об экспериментах.

  • @app.route('/api/expgroups') — возвращает группы заданного device_id.

  • hash_mod = hash_int % total_parts — поддерживаются эксперименты с произвольным количеством групп и делением трафика.

  • post_event("exp_groups", device_id, result) — бэкенд отправляет аналитическое событие при присвоении групп.

Деление трафика и конверсии корректны.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 492 visits (49.20%), Exact 50.00% Moon: 508 visits (50.80%), Exact 50.00%  Moon/Mars Exp events: Mars: 492 visits, 111 clicks, Conv=22.56 +- 3.77%, Exact: 20.00% Moon: 508 visits, 55 clicks, Conv=10.83 +- 2.76%, Exact: 10.00%

6. Два эксперимента

Добавлен второй эксперимент с двумя группами, что дает четыре варианта страницы. Деление по группам в экспериментах независимо т.к. в хэшировании при определении группы hash(device_id || experiment_name) % n_groups используется уникальныйexperiment_name. Оба эндпоинта api/experiments и api/expgroups поддерживают несколько экспериментов.

python 6_multiexps.py

Эксп: http://127.0.0.1:5000
События: http://127.0.0.1:5000/events
Эксперименты: http://127.0.0.1:5000/api/experiments
Группы: http://127.0.0.1:5000/api/expgroups

Четыре варианта страницы - два эксперимента по две группы в каждом.

Четыре варианта страницы — два эксперимента по две группы в каждом.
# ...  INDEX_TEMPLATE = """ // ... <body>     <div id="variant-container">Loading...</div>      <script>         // ...          async function getExpGroups(deviceId) {             const res = await fetch(`/api/expgroups?device_id=${deviceId}`);             return await res.json();         }          async function renderPage() {             const experiments = await getExpGroups(deviceId);             let exp = experiments["moon_mars"];             let moon_mars_group = exp.group;             exp = experiments["white_gold_btn"];             let white_gold_group = exp.group;             const container = document.getElementById("variant-container");             let btn_cls = white_gold_group === "White" ? 'class="white"' : 'class="gold"';             if (moon_mars_group === "Moon") {                 container.innerHTML = `                     <div class="banner" style="background-image: url('{{ url_for('static', filename='./moon.jpg') }}');">                         <h1>Walk on the Moon</h1>                         <div class="vspacer"></div>                         <p>Be one of the first tourists to set foot on the lunar surface. Your journey to another world starts here.</p>                         <button ${btn_cls} onclick="sendEvent('button_click', { btn_type: 'Moon' })">Reserve Your Spot</button>                     </div>                 `;             } else {                 container.innerHTML = `                     <div class="banner" style="background-image: url('{{ url_for('static', filename='./mars.jpg') }}');">                         <h1>Journey to Mars</h1>                         <div class="vspacer"></div>                         <p>Be among the first humans to set foot on the Red Planet. Experience the adventure of a lifetime.</p>                         <button ${btn_cls} onclick="sendEvent('button_click', { btn_type: 'Mars' })">Reserve Your Spot</button>                     </div>                 `;             }         }          // ...     </script> </body> </html> """  # ...  EXPERIMENTS = {     "moon_mars": {         "groups": {'Moon': 50, 'Mars': 50},         "fallback": "Moon",         "state": "active"     },     "white_gold_btn": {         "groups": {'White': 50, 'Gold': 50},         "fallback": "White",         "state": "active"     } }  @app.route('/api/expgroups') def api_expgroups():     device_id = request.args.get("device_id")     result = {}     for exp_name, info in EXPERIMENTS.items():         group = assign_group(device_id, exp_name) if device_id else ""         result[exp_name] = {             "state": info["state"],             "fallback": info["fallback"],             "group": group         }     if device_id:         post_event("exp_groups", device_id, result)     return jsonify(result)  def assign_group(device_id: str, experiment: str) -> str:     groups = EXPERIMENTS[experiment]["groups"]     total_parts = sum(groups.values())     key = f"{device_id}:{experiment}"     hash_bytes = hashlib.sha256(key.encode()).digest()     hash_int = int.from_bytes(hash_bytes, 'big')     hash_mod = hash_int % total_parts     c = 0     chosen = EXPERIMENTS[experiment]["fallback"]     for group_name, weight in sorted(groups.items()):         c += weight         if hash_mod < c:             chosen = group_name             break     return chosen  # ...
  • async function getExpGroups(deviceId) — получает группы обоих экспериментов.

  • let btn_cls = white_gold_group === "White" ? 'class="white"' : 'class="gold"'; — определяет класс кнопки во втором эксперименте.

  • <button ${btn_cls} onclick=... </button> — класс в атрибутах.

  • EXPERIMENTS = {..., "white_gold_btn": {..., "groups": {'White': 50, 'Gold': 50}, ...} — параметры второго эксперимента.

При заходе на страницу пользователь попадает в оба эксперимента. В заходах simulate_visits.py соотношение между группами близко ожидаемому. Вероятность клика зависит только от первого эксперимента: CLICK_PROBS = {'Moon': 0.1, 'Mars': 0.2}, второй эксперимент на нее не влияет. Конверсии второго эксперимента в обеих группах должны быть CLICK_PROBS['Moon']*share_Moon + CLICK_PROBS['Mars']*share_Mars, значения близки к этому. Независимость деления трафика в экспериментах P((exp1, group_i) and (exp2, group_j)) = P(exp1, group_i) * P(exp2, group_j) подтверждается.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 502 visits (50.20%), Exact 50.00% Moon: 498 visits (49.80%), Exact 50.00%  White/Gold Exp Split: Gold: 478 visits (47.80%), Exact 50.00% White: 522 visits (52.20%), Exact 50.00%  Moon/Mars Exp events: Mars: 502 visits, 103 clicks, Conv=20.52 +- 3.60%, Exact: 20.00% Moon: 498 visits, 44 clicks, Conv=8.84 +- 2.54%, Exact: 10.00%  White/Gold Exp events: Gold: 478 visits, 71 clicks, Conv=14.85 +- 3.25%, Exact: 15.00% White: 522 visits, 76 clicks, Conv=14.56 +- 3.09%, Exact: 15.00%  Split Independence moon_mars/white_gold_btn: ('Mars', 'Gold'): 24.00%, independence 25.00% ('Mars', 'White'): 26.20%, independence 25.00% ('Moon', 'Gold'): 23.80%, independence 25.00% ('Moon', 'White'): 26.00%, independence 25.00%

7. Админка

Добавлена админка экспериментов, изменение параметров реализовано в следующих разделах. В реальных конфигурациях для управления экспериментами часто используют отдельный сервис.

python 7_admin.py

Эксп: http://127.0.0.1:5000
События: http://127.0.0.1:5000/events
Эксперименты: http://127.0.0.1:5000/api/experiments
Группы: http://127.0.0.1:5000/api/expgroups
Админка: http://127.0.0.1:5000/experiments

Админка экспериментов.

Админка экспериментов.
# ...  EXPERIMENTS = {     "moon_mars": {         "title": "Moon/Mars",         "groups": {'Moon': 50, 'Mars': 50},         "fallback": "Moon",         "state": "active"     },     "white_gold_btn": {         "title": "White/Gold",         "groups": {'White': 50, 'Gold': 50},         "fallback": "White",         "state": "active"     } }  EXPERIMENTS_TEMPLATE = """ <!DOCTYPE html> <html> <head>     <title>Experiments</title>     <link rel="stylesheet" href="{{ url_for('static', filename='admin.css') }}"> </head> <body>     <h1>Experiments</h1>     <table>         <thead>             <tr>                 <th>Experiment</th>                 <th>Key</th>                 <th>Group: Weight</th>                 <th>Fallback</th>                 <th>State</th>             </tr>         </thead>         <tbody>         {% for name, exp in experiments.items() %}             <tr>                 <td>{{ exp.title }}</td>                 <td>{{ name }}</td>                 <td>                     {% for g, w in exp.groups.items() %}                         {{ g }}: {{ w }} <br>                     {% endfor %}                 </td>                 <td>{{ exp.fallback }}</td>                 <td>{{ exp.state }}</td>             </tr>         {% endfor %}         </tbody>     </table> </body> </html> </body> </html> """  @app.route('/experiments', methods=['GET']) def experiments_page():     return render_template_string(EXPERIMENTS_TEMPLATE, experiments=EXPERIMENTS)  # ...
  • EXPERIMENTS_TEMPLATE – шаблон страницы экспериментов.

  • @app.route('/experiments', methods=['GET']) – отдаёт страницу экспериментов.

Изменения не влияют на эксперименты.

8. Веса

Изменение весов во время эксперимента может привести к переключениям групп пользователей. Хотя hash(user_id || exp_name) % n_groups постоянный, группа зависит от весов. Нужно записывать выдаваемые группы и возвращать их при повторном обращении пользователей. В примере группы сохраняются на бэкэнде в переменной ASSIGNEDGROUPS.

python 8_weights.py

Эксп: http://127.0.0.1:5000
События: http://127.0.0.1:5000/events
Эксперименты: http://127.0.0.1:5000/api/experiments
Группы: http://127.0.0.1:5000/api/expgroups
Админка: http://127.0.0.1:5000/experiments

Изменение весов групп.

Изменение весов групп.
# ...  EXPERIMENTS_TEMPLATE = """     // ...     fetchExperiments().then(renderExperiments);     // ... """  ASSIGNEDGROUPS = {}  def assign_group(device_id: str, experiment: str) -> str:     if (device_id, experiment) in ASSIGNEDGROUPS:         gr, ts = ASSIGNEDGROUPS[(device_id, experiment)]         return gr     groups = EXPERIMENTS[experiment]["groups"]     total_parts = sum(groups.values())     key = f"{device_id}:{experiment}"     hash_bytes = hashlib.sha256(key.encode()).digest()     hash_int = int.from_bytes(hash_bytes, 'big')     hash_mod = hash_int % total_parts     c = 0     chosen = EXPERIMENTS[experiment]["fallback"]     for group_name, weight in sorted(groups.items()):         c += weight         if hash_mod < c:             chosen = group_name             break     ASSIGNEDGROUPS[(device_id, experiment)] = (chosen, datetime.now().isoformat())     return chosen  @app.route('/api/experiments/update', methods=['POST']) def update_experiment():     data = request.json     name = data.get("name")     if not name or name not in EXPERIMENTS:         return jsonify({"error": "Experiment not found"}), 404     old_groups = set(EXPERIMENTS[name]["groups"].keys())     new_groups = set(data.get("groups", {}).keys())     if old_groups != new_groups:         jsonify({"error": f"Can't change {name} group weights"}), 400     for g, w in data["groups"].items():         try:             w_int = int(w)         except Exception as e:             return jsonify({"error": f"Invalid weight for group '{g}': must be an integer"}), 400         if w_int <= 0:             return jsonify({"error": f"Invalid weight for group '{g}': must be > 0"}), 400         data["groups"][g] = w_int     for g in old_groups:         EXPERIMENTS[name]["groups"][g] = data["groups"][g]     return jsonify({"success": True, "experiment": EXPERIMENTS[name]})  #...
  • EXPERIMENTS_TEMPLATE – реализовано обновление весов.

  • ASSIGNEDGROUPS = {} – хранит выданные группы.

  • if (device_id, experiment) in ASSIGNEDGROUPS: ... – возвращает ранее выданную группу, если она есть.

  • ASSIGNEDGROUPS[(device_id, experiment)] = (chosen, datetime.now().isoformat()) – сохраняет группу и время присвоения.

  • @app.route('/api/experiments/update', methods=['POST']) – обновляет веса групп.

    Деление трафика соответствует админке.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 238 visits (23.80%), Exact 25.00% Moon: 762 visits (76.20%), Exact 75.00%  White/Gold Exp Split: Gold: 505 visits (50.50%), Exact 50.00% White: 495 visits (49.50%), Exact 50.00%  Moon/Mars Exp events: Mars: 238 visits, 52 clicks, Conv=21.85 +- 5.36%, Exact: 20.00% Moon: 762 visits, 81 clicks, Conv=10.63 +- 2.23%, Exact: 10.00%  White/Gold Exp events: Gold: 505 visits, 68 clicks, Conv=13.47 +- 3.04%, Exact: 12.50% White: 495 visits, 65 clicks, Conv=13.13 +- 3.04%, Exact: 12.50%  Split Independence moon_mars/white_gold_btn: ('Mars', 'Gold'): 11.60%, independence 12.50% ('Mars', 'White'): 12.20%, independence 12.50% ('Moon', 'Gold'): 38.90%, independence 37.50% ('Moon', 'White'): 37.30%, independence 37.50%

9. Раскатка

У экспериментов три состояния: «неактивный», «активный», «раскатан». В неактивном эксперименте пользователи получают дефолтный вариант, группы не записываются. В активном пользователи распределяются по группам, выданные группы сохраняются в ASSIGNEDGROUPS. После раскатки все пользователи видят выбранную раскатанную группу, выданные ранее группы игнорируются.

При запуске эксперимента (переключение «неактивный»-«активный») записывается время запуска. Для раскатки активного эксперимента необходимо выбрать группу раскатки. При переходе состояния в «раскатан» фиксируется время окончания эксперимента. Остановка эксперимента (переключение «активный»-«неактивный») может использоваться для исправления ошибок: все пользователи переводятся на дефолтный вариант. Выданные ранее группы остаются и после повторной активации пользователи получат старые варианты. Пользователей, у которых менялись группы, необходимо исключать из статистики. Группы поменяются у пользователей с недефолтным вариантом, поэтому их исключение нарушит случайное деление между группами и приведет к дизбалансу сегментов. Надёжнее полностью игнорировать все предыдущие данные и учитывать только пользователей, впервые попавших в эксперимент после повторной активации. Переход из раскатанного состояния в активное трактуется как перезапуск: попавших ранее в эксперимент пользователей нужно исключать из анализа аналогично остановке эксперимента. Прямой переход между состояниями «неактивен» и «раскатан» отключен.

python 9_rollout.py

Эксп: http://127.0.0.1:5000
События: http://127.0.0.1:5000/events
Эксперименты: http://127.0.0.1:5000/api/experiments
Группы: http://127.0.0.1:5000/api/expgroups
Админка: http://127.0.0.1:5000/experiments

Раскатка.

Раскатка.
# ...  EXPERIMENTS = {     "moon_mars": {         "title": "Moon/Mars",         "groups": {'Moon': 50, 'Mars': 50},         "fallback": "Moon",         "state": "active",         "rollout_group": None,         "start": datetime.now().isoformat(),         "end": None     },     "white_gold_btn": {         "title": "White/Gold",         "groups": {'White': 50, 'Gold': 50},         "fallback": "White",         "state": "inactive",         "rollout_group": None,         "start": None,         "end": None     } }  def assign_group(device_id: str, experiment: str) -> str:     if EXPERIMENTS[experiment]["state"] == "rollout":         return EXPERIMENTS[experiment]["rollout_group"]     elif EXPERIMENTS[experiment]["state"] == "inactive":         return EXPERIMENTS[experiment]["fallback"]     if (device_id, experiment) in ASSIGNEDGROUPS:         gr, ts = ASSIGNEDGROUPS[(device_id, experiment)]         return gr     groups = EXPERIMENTS[experiment]["groups"]     total_parts = sum(groups.values())     key = f"{device_id}:{experiment}"     hash_bytes = hashlib.sha256(key.encode()).digest()     hash_int = int.from_bytes(hash_bytes, 'big')     hash_mod = hash_int % total_parts     c = 0     chosen = EXPERIMENTS[experiment]["fallback"]     for group_name, weight in sorted(groups.items()):         c += weight         if hash_mod < c:             chosen = group_name             break     ASSIGNEDGROUPS[(device_id, experiment)] = (chosen, datetime.now().isoformat())     return chosen  @app.route('/api/experiments/update', methods=['POST']) def update_experiment():     data = request.json     name = data.get("name")     if not name or name not in EXPERIMENTS:         return jsonify({"error": "Experiment not found"}), 404     current_state = EXPERIMENTS[name]["state"]     new_state = data.get("state", current_state)     allowed_transitions = [("inactive", "inactive"),                            ("inactive", "active"),                            ("active", "inactive"),                            ("active", "active"),                            ("active", "rollout"),                            ("rollout", "rollout"),                            ("rollout", "active")]     if not (current_state, new_state) in allowed_transitions:         return jsonify({"error": f"Can't change state from {current_state} to {new_state}"}), 400     rollout_group = data.get("rollout_group")     if new_state == "rollout" and rollout_group not in EXPERIMENTS[name]["groups"]:         return jsonify({"error": "Invalid rollout group"}), 400     EXPERIMENTS[name]["state"] = new_state     if current_state == "inactive" and new_state == "active":         EXPERIMENTS[name]["start"] = datetime.now().isoformat()         EXPERIMENTS[name]["end"] = None     elif current_state == "active" and new_state == "inactive":         EXPERIMENTS[name]["end"] = datetime.now().isoformat()     elif current_state == "active" and new_state == "rollout":         EXPERIMENTS[name]["rollout_group"] = rollout_group         EXPERIMENTS[name]["end"] = datetime.now().isoformat()     elif current_state == "rollout" and new_state == "rollout":         EXPERIMENTS[name]["rollout_group"] = rollout_group     elif current_state == "rollout" and new_state == "active":         EXPERIMENTS[name]["rollout_group"] = None         EXPERIMENTS[name]["start"] = datetime.now().isoformat()         EXPERIMENTS[name]["end"] = None     if new_state != "rollout":         old_groups = set(EXPERIMENTS[name]["groups"].keys())         new_groups = set(data.get("groups", {}).keys())         if old_groups != new_groups:             jsonify({"error": f"Can't change {name} group weights"}), 400         for g, w in data["groups"].items():             try:                 w_int = int(w)             except Exception as e:                 return jsonify({"error": f"Invalid weight for group '{g}': must be an integer"}), 400             if w_int <= 0:                 return jsonify({"error": f"Invalid weight for group '{g}': must be > 0"}), 400             data["groups"][g] = w_int         for g in old_groups:             EXPERIMENTS[name]["groups"][g] = data["groups"][g]     return jsonify({"success": True, "experiment": EXPERIMENTS[name]})  # ...
  • EXPERIMENTS = {... {..."state": "active",...}...} — хранит состояние эксперимента, раскатанную группу, время начала и завершения эксперимента.

  • def assign_group(...) — возвращает дефолтную группу для неактивных экспериментов, выбранную группу раскатки для раскатанных экспериентов. Для активных отдает ранее выданную группу или назначает новую.

  • def update_experiment() — обновляет состояние эксперимента и веса групп из админки.

Эксперимент «Moon/Mars» активен с делением по группам 50/50. Эксперимент «White/Gold» раскатан и отдаёт всем пользователям только раскатанную группу.

> python simulate_visits.py -n 1000  Moon/Mars Exp Split: Mars: 472 visits (47.20%), Exact 50.00% Moon: 528 visits (52.80%), Exact 50.00%  White/Gold Exp Split: White: 1000 visits (100.00%), Exact 100.00%  Moon/Mars Exp events: Mars: 472 visits, 109 clicks, Conv=23.09 +- 3.88%, Exact: 20.00% Moon: 528 visits, 54 clicks, Conv=10.23 +- 2.64%, Exact: 10.00%  White/Gold Exp events: White: 1000 visits, 163 clicks, Conv=16.30 +- 2.34%, Exact: 15.00%  Split Independence moon_mars/white_gold_btn: ('Mars', 'White'): 47.20%, independence 50.00% ('Moon', 'White'): 52.80%, independence 50.00%

Заключение

Для A/Б‑тестов в вебе показаны назначение групп, определение логики на бэкэнде и фронтэнде, отправка аналитических событий и управление экспериментами. Примеры демонстрируют реализацию А/Б-тестов и устройство платформ экспериментов.

Фото:
static/moon.jpg: NASA, Public domain, via Wikimedia Commons
static/mars.jpg: NASA/JPL, Public domain, via Wikimedia Commons


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *