Для А/Б-тестов в вебе показаны случайный выбор групп, хэширование, логика на бэкэнде и фронтэнде, логирование событий, одновременные эксперименты и админка.
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
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)— группа записывается в куки.
Для просмотра другого варианта нужно либо открыть страницу в новом окне инкогнито, либо очистить куки и перезагрузить страницу.
Скрипт 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 Commonsstatic/mars.jpg: NASA/JPL, Public domain, via Wikimedia Commons
ссылка на оригинал статьи https://habr.com/ru/articles/940118/
Добавить комментарий