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