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

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

Комментарии (0)