Равномерное распределение и его преобразования

Базовый кирпичик всей случайности в программировании — равномерное число из полуинтервала [0, 1). Из этого скромного «сырья» строят броски кубиков, перемешивание колоды, выбор случайного элемента и даже выборки из сложнейших распределений. Понять равномерность — значит получить ключ ко всему остальному.

Равномерное распределение на [0, 1) — распределение, при котором любое число из полуинтервала имеет одинаковую «плотность» появления: ни одна область не предпочтительнее другой, и в среднем точки ложатся ровным ковром по всему отрезку.

В Python источник равномерности — функция random.random(). Она возвращает дробное число u такое, что 0 ≤ u < 1. «Равномерное» означает, что вероятность попасть в любой подотрезок одинаковой длины одна и та же: в [0.0, 0.1) точка окажется так же часто, как в [0.5, 0.6). Никаких предпочтений, никаких сгущений — именно эта простота делает равномерность идеальным фундаментом.

Почему именно равномерность — фундамент

Может показаться странным, что всё начинается с самого «скучного» распределения. Но в этом и сила. Равномерное число — это чистая, ничем не искажённая случайная «единица». Любое другое распределение — нормальное, экспоненциальное, биномиальное — можно получить, если правильно преобразовать равномерные числа. Генератор умеет выдавать только равномерность; всё остальное — это математические надстройки над ней. Поэтому, освоив преобразования равномерного распределения, вы получаете универсальный инструмент для моделирования чего угодно.

Масштабирование на произвольный отрезок [a, b]

Первое и самое частое преобразование — растянуть [0, 1) на нужный отрезок [a, b]. Формула предельно проста: x = a + (b − a) * u, где u — равномерное число из [0, 1).

Идея геометрическая. Множитель (b − a) растягивает единичный отрезок до нужной ширины, а слагаемое a сдвигает его в нужное место на числовой оси. Проверим граничные случаи: при u = 0 получаем x = a (левый край), при u, близком к 1, значение подходит к b, но не достигает его — ровно как и сам полуинтервал [0, 1). Несколько конкретных значений для отрезка [10, 20] (где a = 10, b − a = 10):

  u      x = 10 + 10*u
-----    -------------
0.00         10.0
0.25         12.5
0.50         15.0
0.75         17.5
0.90         19.0

Видно, что середине единичного отрезка соответствует середина целевого, а равные шаги по u дают равные шаги по x. Это и есть линейное масштабирование: форма распределения (равномерность) сохраняется, меняются только границы. Кстати, готовая функция random.uniform(a, b) внутри делает ровно это же.

Дискретная случайность: выбор и кубик

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

Случайный выбор: random.choice

Функция random.choice(seq) возвращает случайный элемент последовательности, причём каждый элемент равновероятен. Под капотом она берёт равномерное число, умножает на длину списка и округляет до индекса — то есть превращает непрерывную равномерность в дискретную. Если в списке три элемента, отрезок [0, 1) делится на три равные трети: [0, 1/3) → индекс 0, [1/3, 2/3) → индекс 1, [2/3, 1) → индекс 2. Каждая треть равна по длине, значит, каждый вариант выпадает с одинаковой вероятностью.

Бросок кубика: randint

Для целых чисел из заданного диапазона есть random.randint(a, b) — она возвращает целое от a до b включительно (в отличие от многих функций Python, верхняя граница тоже входит). Идеально для кубика: random.randint(1, 6) моделирует честную шестигранную кость. Проверим это на большом числе бросков:

import random
random.seed(7)
rolls = [random.randint(1, 6) for _ in range(6000)]
print("Бросков:", len(rolls))
print("Все значения в диапазоне 1..6:", min(rolls) >= 1 and max(rolls) <= 6)
print("Сколько разных граней выпало:", len(set(rolls)))

Вывод:

Бросков: 6000
Все значения в диапазоне 1..6: True
Сколько разных граней выпало: 6

Шесть тысяч бросков, и все они честно укладываются в диапазон от 1 до 6, а в выборке встретились все шесть граней — ровно то, чего ждёшь от настоящего кубика. Мы не проверяем здесь точные частоты каждой грани (они слегка «гуляют» от прогона к прогону из-за случайности), но структурные свойства — диапазон и полнота набора граней — гарантированы и устойчивы. На большой выборке частоты, конечно, выровняются примерно по 1000 на грань — это прямое следствие равномерности.

Как работает под капотом

Всё богатство дискретной и масштабированной случайности вырастает из единственного примитива — random.random(), дающего равномерное u из [0, 1). Дальше идут чистые арифметические преобразования:

  • Отрезок [a, b]: линейное преобразование a + (b − a)*u — сдвиг и растяжение, сохраняющие равномерность.
  • Индекс списка: int(u * n) отображает [0, 1) на целые 0…n−1, нарезая отрезок на n равных долей.
  • Целое из [a, b]: a + int(u * (b − a + 1)) — то же нарезание, но со сдвигом на a и шириной b − a + 1 (поэтому b входит включительно).

Ключевая мысль: генератор «умеет» лишь одно — равномерность. Все функции вроде uniform, choice, randint — это тонкие обёртки, которые той или иной арифметикой превращают равномерное число в нужную форму. Именно поэтому равномерное распределение называют фундаментом: на нём, как на бетонной плите, стоит всё здание случайности.

Частые ошибки

  • Забывать, что random.random() не включает 1. Полуинтервал [0, 1) означает, что ровно 1.0 не выпадет никогда. При масштабировании a + (b − a)*u верхняя граница b тоже недостижима — это иногда важно для граничных проверок.
  • Считать, что randint(a, b) не включает b. В отличие от range и срезов, randint включает обе границы: randint(1, 6) может вернуть и 1, и 6. Путаница приводит к «потерянной грани» кубика.
  • Ждать ровных частот на маленькой выборке. Равномерность проявляется лишь «в среднем». На 60 бросках кубика грани могут разойтись очень заметно; ровный расклад по 1000 виден только на тысячах бросков. Это нормальное свойство случайности, а не ошибка.
  • Делать собственный выбор через int(u*n) и забывать про крайний случай. Если по недосмотру использовать round вместо int или неверную длину, крайние элементы получат искажённую вероятность. Безопаснее звать готовый random.choice.
  • Путать «равномерное» с «нормальным». Равномерное распределение — это ровный ковёр без пиков; у нормального («колокол») центр гораздо вероятнее краёв. Это совершенно разные формы, и масштабирование одного не превращает его в другое.

Итоги

  • random.random() даёт равномерное число u из полуинтервала [0, 1) — базовый источник случайности, на котором держится всё остальное.
  • Перенос на отрезок [a, b] — линейное преобразование x = a + (b − a)*u: (b − a) растягивает, a сдвигает.
  • random.choice равновероятно выбирает элемент списка, нарезая [0, 1) на равные доли; random.randint(a, b) даёт целое из диапазона включая обе границы.
  • Все эти функции — арифметические обёртки над единственным примитивом-равномерностью.
  • Равномерность проявляется лишь в среднем: ровные частоты видны на больших выборках, а не на десятках значений.
Проверьте себя
1. Какое преобразование переносит равномерное число u из [0, 1) на произвольный отрезок [a, b]?
Ax = a * u + b
Bx = a + (b − a) * u
Cx = (a + b) * u
Dx = u / (b − a) + a
2. Сколько различных значений может вернуть random.randint(1, 6)?
A5, потому что верхняя граница не включается
B6, потому что включаются обе границы — от 1 до 6
C7, потому что включается ещё и ноль
DЗависит от seed
3. Почему равномерное распределение называют фундаментом для генерации других распределений?
AПотому что оно единственное, которое умеет выдавать генератор, а все прочие получают преобразованием равномерных чисел
BПотому что оно самое сложное для вычисления
CПотому что только из него можно получить целые числа
DПотому что оно совпадает с нормальным распределением
4. Если бросить честный шестигранный кубик всего 60 раз, чего разумно ожидать от частот граней?
AКаждая грань выпадет ровно по 10 раз
BВыпадет только одна-две грани
CЧастоты будут заметно «гулять» вокруг 10, и точного равенства не будет
DКубик выдаст числа вне диапазона 1..6