numpyслучайностьab-тестысимуляцииpythonаналитика данных

NumPy с нуля. Часть 6: случайность для A/B и симуляций

2026-06-28 11 мин

Если ты умеешь генерировать случайные числа в numpy — ты умеешь делать две вещи, за которые на собеседовании дают плюс: разбивать пользователей на группы A/B честно и проверять метрику симуляцией, не дожидаясь реальных данных. Этот урок про модуль np.random: как зафиксировать seed, чтобы результат повторялся, как раскидать юзеров по группам с нужными весами и как сгенерировать «фейковую выручку», которая ведёт себя как настоящая.

Случайность для аналитика — это не казино. Это инструмент. Рандомизация в A/B-тесте убирает скрытые перекосы между группами. Симуляция позволяет ответить на вопрос «а что если?» до того, как ты потратишь две недели на сбор реальных цифр. И почти всегда тебе нужно, чтобы случайность была воспроизводимой: коллега запустил твой код — получил ровно те же числа. За это отвечает np.random.seed.

Мы идём дальше в серии numpy с нуля. В прошлых частях были массивы, индексация, агрегации и broadcasting. Сегодня добавляем последний кирпич базового набора — генерацию случайных данных. После этого урока ты сможешь написать мини-симуляцию A/B-теста в несколько строк, и это уже уровень, на котором задают вопросы Senior-кандидатам.

np.random.seed: почему случайность должна повторяться

Компьютер не умеет в настоящую случайность. Он генерирует псевдослучайные числа по формуле, которая стартует с некоторого начального значения — его называют seed (зерно). Зафиксируешь seed — получишь одну и ту же «случайную» последовательность каждый раз.

Зачем это аналитику? Чтобы результат был воспроизводимым. Ты показываешь коллеге симуляцию A/B-теста, он запускает твой ноутбук и видит ровно те же числа. Без фиксации seed каждый запуск дал бы новые значения, и проверить тебя было бы невозможно.

import numpy as np

np.random.seed(0)
print(np.random.rand(3))
# [0.5488135  0.71518937 0.60276338]

np.random.seed(0)   # тот же seed — та же последовательность
print(np.random.rand(3))
# [0.5488135  0.71518937 0.60276338]

Обрати внимание: первый и второй вывод одинаковые, потому что перед каждым мы сбросили seed на 0. Если бы вызвали np.random.rand(3) второй раз без сброса — получили бы следующие три числа из той же последовательности, а не повтор.

Правило простое: ставь np.random.seed(КАКОЕ-ТО ЧИСЛО) в начале любого кода, где есть случайность и тебе важна воспроизводимость. Число — любое, обычно берут 0, 42, 7. Само значение seed на «качество» случайности не влияет, это просто точка старта.

rand, randn, randint: три способа получить случайные числа

Это три рабочие лошадки. Запомни разницу между ними — на этом строится почти всё.

np.random.rand(...) — равномерное распределение на отрезке [0, 1). Каждое число от 0 до 1, все одинаково вероятны. Удобно для долей, вероятностей, флагов «попал/не попал».
np.random.seed(0)
print(np.random.rand(2, 3))
# [[0.5488135  0.71518937 0.60276338]
#  [0.54488318 0.4236548  0.64589411]]

Аргументы rand — это форма массива. rand(3) даст вектор из 3 чисел, rand(2, 3) — матрицу 2 на 3.

np.random.randn(...) — нормальное (гауссово) распределение со средним 0 и стандартным отклонением 1. Числа группируются вокруг нуля, бывают и отрицательные. Это основа для симуляции «естественных» величин: рост, время на сайте, отклонение метрики.
np.random.seed(1)
print(np.random.randn(4))
# [ 1.62434536 -0.61175641 -0.52817175 -1.07296862]
np.random.randint(low, high, size=...) — случайные целые числа от low (включительно) до high (НЕ включительно). Идеально для симуляции счётчиков: число заказов, кликов, кубик.
np.random.seed(10)
print(np.random.randint(1, 7, size=5))   # бросок кубика 5 раз
# [2 6 5 1 2]

Запомни границу: randint(1, 7) даёт числа 1, 2, 3, 4, 5, 6 — семёрка не входит. Это частая ловушка.

Попробуй сам

Задача · Python
Условие
Зафиксируй seed = 42 и перемешай объединённые конверсии двух групп через permutation. Затем со seed = 7 выбери 3 значения без возврата из conv_b. Это базовый приём рандомизации перед A/B-сплитом.
Подсказка к решению — раскрыть только если застрял
Спойлер! Сначала попробуй сам. np.concatenate([conv_a, conv_b]) склеивает массивы. np.random.permutation(arr) возвращает перемешанную копию. Для выбора без возврата: np.random.choice(conv_b, size=3, replace=False). Не забудь сбросить seed перед каждым блоком.

np.random.choice: выбор из списка, веса и возврат

np.random.choice — самая полезная функция для аналитика, потому что именно ей раскидывают пользователей по группам A/B. Она выбирает случайные элементы из массива.

Простой случай — выбрать size элементов (с возможным повторением):

np.random.seed(3)
print(np.random.choice([10, 20, 30, 40], size=3))
# [30 10 20]

По умолчанию выбор идёт С ВОЗВРАТОМ (replace=True): один и тот же элемент может выпасть несколько раз. Это правильно для бросков кубика или симуляции независимых событий.

Если нужно выбрать БЕЗ возврата (каждый элемент максимум один раз) — ставь replace=False. Так делают выборку респондентов или случайную подвыборку строк:

np.random.seed(3)
print(np.random.choice([10, 20, 30, 40], size=3, replace=False))
# [40 20 10]

Самое важное для A/B — параметр p, веса (вероятности). Им задают, какая доля юзеров пойдёт в какую группу. Сумма весов должна быть равна 1:

np.random.seed(5)
# 80% в группу A (контроль), 20% в группу B (новая фича)
print(np.random.choice(['A', 'B'], size=10, p=[0.8, 0.2]))
# ['A' 'B' 'A' 'B' 'A' 'A' 'A' 'A' 'A' 'A']

Из 10 пользователей в B попали двое — примерно 20%, как и просили. На больших числах (10000+ юзеров) доля будет почти ровно 0.2. Так делают неравномерный сплит: например, выкатывают новую фичу сначала на 10% аудитории, чтобы снизить риск.

permutation и shuffle: честное перемешивание для рандомизации

В A/B-тесте критично, чтобы разбиение на группы было случайным, без скрытого порядка. Если ты возьмёшь первые 50% пользователей в контроль, а вторые 50% в тест — может оказаться, что в начале списка сидят старые лояльные юзеры, а в конце новички. Группы получатся неравными ещё ДО эксперимента. Решение — перемешать.

np.random.shuffle(arr) перемешивает массив НА МЕСТЕ. Он меняет сам arr и ничего не возвращает (вернёт None):
np.random.seed(2)
a = np.array([1, 2, 3, 4, 5])
np.random.shuffle(a)
print(a)
# [3 5 2 4 1]
np.random.permutation(arr) делает то же самое, но НЕ трогает оригинал — возвращает новый перемешанный массив. Исходный остаётся как был. Это безопаснее, и чаще используют именно его:
np.random.seed(2)
print(np.random.permutation([1, 2, 3, 4, 5]))
# [3 5 2 4 1]

Заметь: при одинаковом seed shuffle и permutation дают одинаковый порядок — это один и тот же алгоритм.

Типичный приём рандомизации: перемешать индексы пользователей и разрезать пополам.

np.random.seed(0)
user_ids = np.arange(1000)              # id от 0 до 999
shuffled = np.random.permutation(user_ids)
group_a = shuffled[:500]                # первая половина — контроль
group_b = shuffled[500:]                # вторая — тест
print(len(group_a), len(group_b))
# 500 500

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

Попробуй сам

Задача · Python
Условие
Симулируй счётчик заказов и неравномерный сплит. Со seed = 0 сгенерируй 10 дней с числом заказов от 0 до 9, посчитай сколько дней было 5+ заказов. Потом со seed = 123 раскидай 8 пользователей по группам A/B с весами 0.7 / 0.3.
Подсказка к решению — раскрыть только если застрял
Спойлер! Сначала попробуй сам. np.random.randint(0, 10, size=10) даёт целые 0..9 (десятка не входит). orders >= 5 даёт массив True/False, np.sum по нему считает True. Для сплита: np.random.choice(['A','B'], size=8, p=[0.7, 0.3]).

Нормальная выборка: симулируем метрику до сбора данных

Главный навык урока. np.random.normal(loc, scale, size) генерирует числа из нормального распределения, где loc — среднее, scale — стандартное отклонение (разброс), size — сколько чисел. В отличие от randn, тут ты сам задаёшь среднее и разброс под свою метрику.

Допустим, ты знаешь, что средний чек в магазине около 1500 рублей с разбросом примерно 300. Можно сгенерировать 10000 «чеков» и работать с ними как с реальными:

np.random.seed(0)
checks = np.random.normal(loc=1500, scale=300, size=5)
print(np.round(checks, 2))
# [2029.22 1620.05 1793.62 2172.27 2060.27]

Построив большую выборку, можно проверять гипотезы. Например — какая доля чеков превысит 1800 рублей? Это вопрос «сколько юзеров попадут в премиум-сегмент», и симуляция отвечает на него мгновенно:

np.random.seed(0)
checks = np.random.normal(1500, 300, size=100000)
share = np.mean(checks > 1800)          # доля чеков выше 1800
print(round(float(share), 3))
# 0.158

Около 16% — это и есть ответ симуляции. Так же симулируют A/B: генерируешь две выборки метрики с разным средним (контроль 1500, тест 1560), считаешь разницу средних и смотришь, не случайна ли она. Это фундамент power-анализа и оценки sample size — тем, которые отделяют Middle от Senior на собеседовании.

Ключевая идея: симуляция = генерируем правдоподобные данные → считаем нужную метрику → повторяем много раз. numpy делает это за миллисекунды на миллионах строк, тогда как обычный Python-цикл захлебнулся бы.

Сэмплирование: выборка приближает истину

   ИСТИННОЕ РАСПРЕДЕЛЕНИЕ                ВЫБОРКА (size=N)
   средний чек ~1500, разброс 300       np.random.normal(1500,300,N)

            *  *                        ┌───────────────────────┐
          *      *          берём N     │ 2029  1620  1793 ...   │
        *          *   ───────────────► │ 2172  2060  1488 ...   │
       *            *                   │  ...   ...   ...        │
      *              *                  └───────────┬───────────┘
   ───┴──────┬───────┴───                           │ .mean()
     1200   1500   1800                             ▼
            ▲                                  ┌──────────┐
            │ истинное μ = 1500                │ ~1499    │
            └──────────── приближается ◄────── │ оценка μ │
                       чем больше N            └──────────┘

Из распределения с истинным средним μ берём случайную выборку N значений (np.random.normal). Среднее выборки .mean() приближает μ — и чем больше N, тем точнее. Так аналитик оценивает метрику симуляцией до сбора реальных данных.

Чек-лист и частые ошибки

Короткая шпаргалка, чтобы не путаться.

np.random.seed(42)              # фиксируем воспроизводимость
np.random.rand(3)               # равномерно [0, 1)
np.random.randn(3)              # нормально, среднее 0, разброс 1
np.random.randint(0, 10, 3)     # целые от 0 до 9
np.random.choice(arr, size=5)   # выбор с возвратом
np.random.choice(arr, 5, replace=False)   # без возврата
np.random.choice(['A','B'], 100, p=[0.7, 0.3])  # с весами
np.random.permutation(arr)      # новый перемешанный (оригинал цел)
np.random.shuffle(arr)          # на месте (меняет arr, вернёт None)
np.random.normal(1500, 300, 1000)  # своя метрика

Частые ошибки новичка:

И помни про назначение: rand/normal — для непрерывных величин (доли, выручка, время), randint — для счётчиков, choice — для выбора из категорий и сплита на группы, permutation/shuffle — для честной рандомизации перед разбиением.

Попробуй сам

Задача · Python
Условие
Симуляция метрики через нормальную выборку. Со seed = 42 сгенерируй 10000 средних чеков с центром 1500 и разбросом 300. Посчитай средний чек (округли до целого) и долю чеков выше 1800 в процентах (округли до 1 знака).
Подсказка к решению — раскрыть только если застрял
Спойлер! Сначала попробуй сам. np.random.normal(loc=1500, scale=300, size=10000). Среднее: checks.mean(). Доля выше порога: np.mean(checks > 1800) даёт долю от 0 до 1, умножь на 100 для процентов. round(x, 1) округляет до одного знака.

Главное

Частые вопросы

Зачем нужен np.random.seed и почему без него результаты разные?

Генератор случайных чисел на самом деле псевдослучайный: он идёт по детерминированной последовательности от стартовой точки. seed фиксирует эту точку, поэтому при каждом запуске получаются одни и те же числа. Это нужно, чтобы симуляция и A/B-разбивка были воспроизводимыми — коллега запустит код и увидит ровно ваши цифры. Без сида последовательность стартует с системного состояния и меняется от запуска к запуску.

Чем rand отличается от randn и randint?

rand даёт равномерное распределение на полуинтервале от 0 до 1, randn — стандартное нормальное (среднее 0, стандартное отклонение 1, есть отрицательные значения), а randint — целые числа в заданном диапазоне. Для нормальной выборки с нужными параметрами берут randn и масштабируют, либо сразу np.random.normal(loc, scale, size).

Как случайно разбить пользователей на группы A и B?

Три рабочих способа: np.random.choice(['A','B'], size=n) для присвоения метки каждому, np.random.shuffle или np.random.permutation чтобы перемешать индексы и разрезать пополам. Перед разбивкой поставьте seed, чтобы сплит был воспроизводимым. Логику самих A/B-метрик потом удобно закрепить на задачах из банка вопросов.

Что такое симуляция Монте-Карло для A/B-теста?

Это когда вы много тысяч раз прогоняете виртуальный эксперимент со случайными данными и смотрите на распределение исхода. Например, генерируете конверсии при известном эффекте, тысячу раз считаете, «поймал» ли тест значимость, и так оцениваете мощность. Симуляции полезны, когда аналитическая формула сложна или её нет — численный ответ всегда можно получить перебором.

Стоит ли использовать старый np.random или новый default_rng?

В современном коде рекомендуют создавать генератор через rng = np.random.default_rng(seed) и звать методы от него: rng.random(), rng.normal(), rng.choice(). Это изолированный поток случайности, он не конфликтует с чужим кодом, который тоже дёргает глобальный np.random. Старый интерфейс (np.random.seed + np.random.rand) до сих пор работает и его полно в туториалах, но для новых проектов лучше default_rng.

Закрепи NumPy на практике
Реши Python-задачи аналитика в тренажёре — pandas, numpy, scipy прямо в браузере. Первые 5 — без регистрации.
Открыть Python-тренажёр →