Случайные числа: современный Generator вместо legacy

Урок про правильную работу со случайностью в современном NumPy: новый Generator, распределения и воспроизводимость экспериментов.

Generator — современный объект-генератор случайных чисел NumPy, создаваемый через np.random.default_rng(); он пришёл на смену устаревшим глобальным функциям np.random.seed/rand.

Почему старый способ устарел

Долгие годы случайные числа в NumPy получали так: np.random.seed(42) для воспроизводимости и затем np.random.rand(), np.random.randn(), np.random.randint(). Этот «legacy»-интерфейс опирается на единое глобальное состояние, спрятанное внутри модуля. Сегодня он считается устаревшим, и вот почему это плохо:

  • Глобальное состояние. Любой код (включая чужие библиотеки) может незаметно вызвать np.random.seed и сломать вашу воспроизводимость. Состояние общее на всю программу.
  • Опасно для параллельности. Несколько потоков, дёргающих общий генератор, мешают друг другу.
  • Хуже статистические свойства. Новый генератор по умолчанию (PCG64) современнее и качественнее старого Mersenne Twister.

Современный, рекомендуемый официальной документацией способ — создать явный объект-генератор через np.random.default_rng() и вызывать методы у него. Состояние локально, изолированно и предсказуемо.

Почему глобальное состояние — это плохо

Углубимся, почему именно глобальное состояние legacy-интерфейса вредит. Старые функции np.random.rand, np.random.randn и компания все дёргают один спрятанный генератор на всю программу. Это означает, что любой вызванный код — ваша функция, чужая библиотека, импортированный модуль — обращается к тому же состоянию и продвигает его. Достаточно одной библиотеке внутри вызвать np.random.seed или сгенерировать сколько-то случайных чисел, и ваша «воспроизводимая» последовательность поедет: вы получите другие числа, не изменив ни строчки своего кода. Отладить такое почти невозможно, потому что причина — в невидимом взаимодействии через общее состояние. Явный Generator решает проблему в корне: у каждого генератора своё изолированное состояние, и только тот код, которому вы передали конкретный rng, влияет на его последовательность. Это превращает случайность из глобального ресурса, за который все борются, в локальный объект, которым вы полностью управляете. Та же логика, кстати, делает новый API безопасным для параллельных вычислений: разным потокам или процессам дают независимые генераторы, и они не мешают друг другу.

Создание генератора и базовые вызовы

На практике вы создаёте генератор один раз в начале программы и затем пользуетесь его методами везде, где нужна случайность. Это и удобнее, и безопаснее разрозненных глобальных вызовов: всё «случайное» в вашем коде происходит через один управляемый объект. Передав в default_rng целое число (seed), вы получаете воспроизводимую последовательность: при том же seed — те же числа. Без seed генератор инициализируется недетерминированно (от ОС), давая каждый раз разные результаты.

import numpy as np
rng = np.random.default_rng(42)   # seed для воспроизводимости

print(rng.random(3))              # 3 числа в [0, 1)
print(rng.integers(0, 10, size=5))  # 5 целых в [0, 10)
print(rng.normal(0, 1, size=3))   # нормальное распределение

Вывод:

[0.77395605 0.43887844 0.85859792]
[0 7 6 4 4]
[ 0.09290788  0.28174615  0.76902257]

Обратите внимание на имена: rng.random() (равномерное в [0,1)), rng.integers() (целые; в отличие от legacy randint у него удобный и единообразный интерфейс), rng.normal(), rng.uniform(), rng.choice(). Всё это — методы вашего объекта, а не глобальные функции.

Воспроизводимость: тот же seed — тот же результат

Воспроизводимость критична для науки и отладки: эксперимент должен повторяться. С Generator это надёжно — два генератора с одинаковым seed выдают идентичные последовательности, и никакой посторонний код на них не влияет.

import numpy as np
rng1 = np.random.default_rng(123)
rng2 = np.random.default_rng(123)

print(rng1.random(3))
print(rng2.random(3))    # ровно те же числа
print(np.array_equal(rng1.integers(0, 100, 5),
                     rng2.integers(0, 100, 5)))  # True

Вывод:

[0.68235186 0.05382102 0.22035987]
[0.68235186 0.05382102 0.22035987]
True

Чтобы понять, что такое детерминированный псевдослучайный генератор, реализуем простейший на чистом Python — линейный конгруэнтный. Тот же seed даёт ту же цепочку:

class SimpleRNG:
    def __init__(self, seed):
        self.state = seed
    def next(self):
        # Параметры из numerical recipes
        self.state = (1664525 * self.state + 1013904223) % (2**32)
        return self.state / (2**32)   # в [0, 1)

r1 = SimpleRNG(42)
r2 = SimpleRNG(42)
print([round(r1.next(), 4) for _ in range(3)])
print([round(r2.next(), 4) for _ in range(3)])  # та же цепочка

Вывод:

[0.2523, 0.0881, 0.5773]
[0.2523, 0.0881, 0.5773]

NumPy использует куда более совершенный генератор (PCG64), но принцип тот же: «случайность» детерминирована состоянием, и одинаковый seed воспроизводит одинаковую последовательность.

Что такое seed и почему «случайность» воспроизводима

На первый взгляд звучит парадоксально: как генератор может быть и «случайным», и воспроизводимым одновременно? Разгадка в том, что это псевдослучайные числа. Генератор хранит внутреннее состояние (большое число) и по детерминированной формуле вычисляет из него следующее число, попутно обновляя состояние. Последовательность выглядит случайной — числа не имеют видимых закономерностей, проходят статистические тесты на случайность, — но полностью определяется начальным состоянием. Seed — это и есть способ задать начальное состояние. Тот же seed → то же начальное состояние → та же цепочка чисел. Это не недостаток, а ценнейшее свойство: оно позволяет воспроизвести эксперимент, отладить код с «теми же случайными данными», сравнить два алгоритма на идентичных входах. Настоящая, физическая случайность (от шума оборудования) для большинства задач не нужна и даже вредна, потому что лишает воспроизводимости. Псевдослучайность даёт лучшее из двух миров: статистически случайные числа, которые при этом можно повторить.

Распределения и выборка

Помимо одиночных чисел, генератор удобно порождает целые массивы случайных значений нужной формы — достаточно указать параметр size. Это снова векторизация: вместо цикла, генерирующего числа по одному, вы получаете сразу массив миллиона значений одним вызовом. Generator умеет генерировать из множества распределений: равномерного, нормального, биномиального, пуассоновского и десятков других. Отдельно полезны choice (случайная выборка из массива, с заменой или без) и shuffle/permutation (перемешивание).

import numpy as np
rng = np.random.default_rng(7)

print(rng.uniform(10, 20, size=3))      # равномерно в [10, 20)
print(rng.choice([1, 2, 3, 4, 5], size=3, replace=False))  # без повторов
print(rng.permutation([10, 20, 30, 40]))  # случайная перестановка

data = np.arange(5)
rng.shuffle(data)        # перемешать на месте
print(data)

Вывод:

[19.43919916 14.4633663  12.92147527]
[2 5 1]
[20 40 10 30]
[3 0 1 4 2]

Параметр replace=False у choice делает выборку без повторений (как тянуть карты из колоды), replace=True (по умолчанию) — с возвращением. Это частая развилка при семплировании данных.

Выбор распределения под задачу

Generator умеет порождать числа из множества распределений, и выбор правильного — это содержательное решение, а не техническая деталь. Равномерное (uniform, random) даёт числа, одинаково вероятные в диапазоне, — подходит для случайных координат, перемешивания, симуляций «без предпочтений». Нормальное (normal, standard_normal) — колоколообразное, концентрирующееся вокруг среднего, — моделирует естественный разброс величин (рост, погрешности измерений, шум), потому что суммы многих независимых факторов стремятся к нему. Целочисленное равномерное (integers) — для бросков кубика, случайных индексов, выбора категорий. Биномиальное и пуассоновское — для счётных событий (число успехов из n попыток, число событий за интервал). Понимание, какое распределение соответствует природе вашей величины, отличает осмысленную симуляцию от генерации бессмысленных чисел. Например, моделировать время ожидания равномерным распределением обычно неправильно — для него естественнее экспоненциальное. NumPy даёт десятки распределений; стоит потратить время, чтобы выбрать подходящее под смысл задачи, а не брать первое попавшееся.

Практика: разбиение данных на train/test

Типичная задача ML — случайно разделить данные на обучающую и тестовую части воспроизводимым образом. Приём через перемешанные индексы универсален и хорошо иллюстрирует силу fancy-индексации в связке со случайностью: вы перемешиваете не сами данные (которых может быть много и которые дороги копировать), а лёгкий массив индексов, а затем применяете порядок к данным по необходимости. С Generator это делается так:

import numpy as np
rng = np.random.default_rng(0)

n = 10
idx = rng.permutation(n)        # случайный порядок индексов
split = int(n * 0.7)            # 70% на обучение
train_idx, test_idx = idx[:split], idx[split:]
print("train:", train_idx)
print("test :", test_idx)

Вывод:

train: [5 0 8 1 4 2 6]
test : [9 3 7]

Воспроизводимость на практике: дисциплина эксперимента

Воспроизводимость — не абстрактная добродетель, а рабочая необходимость. Представьте, что вы обучаете модель, получаете точность 87%, меняете один параметр — и точность падает до 85%. Стало хуже из-за параметра или просто из-за другого случайного разбиения данных и случайной инициализации? Без фиксированного seed вы не отличите эффект изменения от случайного шума. Поэтому в серьёзных экспериментах принято фиксировать seed во всех источниках случайности: разбиение train/test, перемешивание, инициализация весов. Тогда два прогона различаются только тем, что вы намеренно изменили, и сравнение становится честным. Современный подход с явным Generator делает это надёжным: вы создаёте rng = np.random.default_rng(seed) в начале и передаёте его во все функции, которым нужна случайность. Никакой посторонний код не может незаметно сбить ваше состояние, как это бывало с глобальным np.random.seed. Эта изоляция — главная практическая причина перехода на новый API: она делает воспроизводимость управляемой и предсказуемой.

Подводные камни

  • Использовать legacy np.random.seed/rand. Устарело, опирается на глобальное состояние; предпочитайте default_rng().
  • Ждать воспроизводимости без seed. Без явного seed каждый запуск даёт другие числа.
  • Смешивать legacy и Generator. У них разное состояние; результаты не совпадут даже при одинаковом seed.
  • Делиться одним генератором между потоками. Для параллельности порождайте независимые потоки через SeedSequence/spawn.

Лучшие практики

  • Создавайте один rng = np.random.default_rng(seed) и передавайте его туда, где нужна случайность.
  • Фиксируйте seed для воспроизводимых экспериментов и отладки.
  • Используйте методы генератора (rng.normal, rng.integers), а не глобальные функции np.random.*.
  • Для выборки без повторений указывайте replace=False.

Случайность кажется простой темой, но именно небрежность в работе с ней — частая причина невоспроизводимых экспериментов и трудных багов. Современный Generator делает работу со случайностью явной, изолированной и предсказуемой. Привыкайте создавать генератор с фиксированным seed, передавать его туда, где нужна случайность, и выбирать распределение под смысл задачи — это признак аккуратного научного кода.

Итог

  • Современный способ — np.random.default_rng(seed), а не устаревшие np.random.seed/rand.
  • Generator хранит локальное состояние, безопаснее и качественнее старого глобального интерфейса.
  • Одинаковый seed даёт воспроизводимую последовательность; без seed — недетерминированную.
  • Методы генератора покрывают распределения, выборку (choice) и перемешивание (shuffle/permutation).
Проверьте себя
1. Какой способ генерации случайных чисел рекомендуется в современном NumPy?
Anp.random.seed(42) и затем np.random.rand()
BСоздать объект rng = np.random.default_rng(seed) и вызывать его методы (rng.random, rng.normal, ...)
CИспользовать модуль random из стандартной библиотеки
DКаждый раз импортировать numpy заново
2. Чем плох устаревший интерфейс np.random.seed/np.random.rand?
AОн работает только с целыми числами
BОн опирается на единое глобальное состояние, которое любой код может незаметно изменить, ломая воспроизводимость и параллельность
CОн не умеет генерировать нормальное распределение
DОн доступен только в старых версиях Python
3. Что гарантирует передача одинакового seed в два вызова np.random.default_rng(seed)?
AЧто генераторы выдадут случайные, но разные последовательности
BЧто оба генератора выдадут идентичные воспроизводимые последовательности чисел
CЧто числа будут только целыми
DЧто генераторы будут синхронизированы между потоками автоматически
Поддержать проект