Перейти к содержимому
Ступень 00.3 NumPy — язык тензоров

Случайные числа и графики

Случайные числа, которые можно повторить (сид), среднее, медиана и разброс, гистограммы и первые графики в matplotlib — и почему честная оценка LLM требует многих прогонов.

УРОКstage-0/0-3-numpy/03-random-plots.mdx1 ч 40 минУрок 3 из 3
После урока вы сможете
  • Получать воспроизводимые случайные данные через np.random.default_rng(seed) и объяснить, зачем нужен сид
  • Считать базовые статистики и строить гистограммы в NumPy и matplotlib
  • Рисовать линию, scatter и гистограмму с подписями, заголовком и легендой и проверять данные «глазами»
  • Оценить шум сэмплирования в результатах бенчмарка и понять, сколько прогонов нужно

Случайность в ML повсюду. Веса сети перед обучением заполняются случайными числами. Примеры перемешиваются перед каждой эпохойэпохаEN epochОдин полный проход по обучающим данным. LLM на претрейне часто не делают и одной эпохи. — полным проходом по всем данным. Dropoutдропаут (dropout)EN dropoutНа обучении каждая активация обнуляется с вероятностью p, остальные умножаются на 1/(1 − p); на инференсе слой ничего не делает. В претрейне современных LLM обычно p = 0. — приём против переобучения (ступень 3) — на каждом шаге случайно выключает часть нейронов. Языковая модель выбирает следующий токен, бросая «кубик» по своему распределению. GRPO, алгоритм обучения DeepSeek-R1, генерирует на каждый вопрос группу случайных ответов и сравнивает их между собой.

Отсюда два навыка. Первый — контролировать случайность: повторный запуск эксперимента должен дать тот же результат, иначе невозможно понять, помогло ли ваше изменение. Второй — измерять её: отличать настоящее улучшение от шума. Третий навык идёт бонусом: смотреть на данные глазами. Андрей Карпатый в своём «рецепте» обучения нейросетей ставит этот шаг первым («Become one with the data»): гистограмма или график за минуту находят ошибки, которые не видны в таблицах чисел.

Компьютер не бросает монету. Он запускает детерминированный алгоритм — генератор псевдослучайных чисел, — который из начального числа, сидасид генератора случайных чиселEN random seedНачальное значение генератора. Один и тот же сид даёт ту же последовательность «случайных» чисел., выдаёт длинную последовательность чисел, неотличимую от случайной по статистическим тестам. Один и тот же сид всегда даёт одну и ту же последовательность.

В NumPy генератор создаётся так:

import numpy as np
rng = np.random.default_rng(42) # генератор с сидом 42
print(rng.random(3)) # 3 числа, равномерно из [0, 1)
print(rng.integers(0, 10, size=5)) # 5 целых из [0, 10)
print(rng.normal(loc=0.0, scale=1.0, size=3)) # нормальное распределение N(0, 1)
print(rng.choice(["карп", "прыгает", "через", "врата", "дракона"], size=4)) # выбор с возвращением
print(rng.permutation(6)) # случайная перестановка 0..5

Вывод в NumPy 2.4:

[0.77395605 0.43887844 0.85859792]
[0 6 2 0 5]
[ 0.1278404 -0.31624259 -0.01680116]
['дракона' 'дракона' 'через' 'через']
[0 5 4 2 1 3]

Запустите этот код ещё раз — числа будут те же самые. Поменяйте сид на 43 — получите другую последовательность. Без сида (np.random.default_rng()) генератор берёт начальное значение из источника случайности операционной системы, и результат каждый раз новый. В игре «угадай число» из урока 0.2.6 вы пользовались модулем random из стандартной библиотеки — идея сида там та же. Для массивов чисел в ML берут генератор NumPy.

Три выражения из комментариев к коду:

  • Равномерно из [0,1)[0, 1) — любое число от 0 до 1 выпадает одинаково часто, это равномерное распределениеравномерное распределениеEN uniform distributionВсе значения на отрезке (или все K исходов) равновероятны. U[0, 1) — то, что возвращает random().. Квадратная скобка значит, что 0 может выпасть, круглая — что 1 не выпадет никогда.
  • Нормальное распределение N(0,1)N(0, 1) — «колокол»нормальное (гауссовское) распределениеEN normal (Gaussian) distribution«Колокол» N(μ, σ²). Правило 68–95–99.7 — доля значений в пределах 1, 2 и 3 σ от среднего.: чаще всего выпадают числа около среднего loc (здесь 0), и чем дальше от него, тем реже. scale — ширина колокола, это std из прошлого урока (0.3.2). Примерно 68 % значений лежат не дальше одного scale от среднего и 95 % — не дальше двух. Как выглядит колокол, увидите ниже, в разделе о гистограммах.
  • Выбор с возвращением — один и тот же элемент может выпасть несколько раз, как 'дракона' в выводе выше.
МетодЧто делаетГде в ML
rng.random(size)равномерно из [0,1)[0, 1)dropout-маски, метод Монте-Карло (пример — в практике)
rng.integers(lo, hi, size)целые из [lo,hi)[lo, hi)случайные индексы, id токенов
rng.normal(loc, scale, size)нормальное распределениеинициализация весов
rng.choice(a, size, p=...)выбор из a с вероятностями pсэмплирование токенов
rng.permutation(n)перестановка 0..n−10..n-1перемешивание данных, разбиение на обучающую и проверочную части (train/val)

воспроизводимостьвоспроизводимостьEN reproducibilityВозможность получить тот же результат, повторно запустив тот же код на тех же данных. — это возможность получить тот же результат, повторив тот же код на тех же данных. В обучении нейросети случайность приходит из нескольких мест, и зафиксировать нужно все:

import random
import numpy as np
import torch
SEED = 42
random.seed(SEED) # стандартный модуль random
rng = np.random.default_rng(SEED) # NumPy: передавайте rng явно
torch.manual_seed(SEED) # PyTorch: инициализация весов, dropout (ступень 3)
rng_data, rng_init = np.random.default_rng(SEED).spawn(2) # независимые потоки под разные задачи

Зачем отдельные генераторы? Если данные и инициализация берут числа из одного потока, то добавление одного лишнего вызова при загрузке данных изменит все веса. С двумя независимыми генераторами эксперименты не мешают друг другу.

Две оговорки:

  • Сид фиксирует последовательность для данной версии библиотеки. После обновления NumPy или PyTorch числа могут стать другими.
  • На GPU часть операций недетерминирована: порядок сложения в параллельных потоках меняется, и результаты расходятся в последних знаках. В PyTorch есть строгий режим torch.use_deterministic_algorithms(True), но он медленнее.

Сэмплирование с заданными вероятностями — это то, что делает языковая модель при генерации:

rng = np.random.default_rng(0)
vocab = np.array(["карп", "дракон", "врата", "река"])
p = np.array([0.5, 0.3, 0.15, 0.05]) # «распределение модели»
s = rng.choice(len(vocab), size=10_000, p=p) # 10 000 сэмплов: номера слов 0..3
# bincount считает, сколько раз выпал каждый номер 0, 1, 2, 3; делим на число сэмплов — получаем доли
print(np.bincount(s, minlength=4) / len(s)) # [0.499 0.2989 0.1523 0.0498]
print(vocab[s[:8]])

Частоты почти совпали с вероятностями. Как работает choice внутри, вы реализуете сами в уроке 1.3.1, а температуру и top-p разберёте в уроке 4.1.3. Сейчас важно другое: насколько частота отличается от вероятности при данном числе сэмплов. Бросим нечестную монету, которая падает орлом с вероятностью p=0.7p = 0.7, то есть в 70 % бросков:

rng = np.random.default_rng(1)
for n in (10, 100, 1000, 10_000, 100_000):
# равномерное число из [0, 1) меньше 0.7 ровно в 70 % случаев — это и есть бросок такой монеты;
# .mean() у массива True/False — доля орлов
freq = (rng.random(n) < 0.7).mean()
print(n, freq, round(abs(freq - 0.7), 4), round(np.sqrt(0.7 * 0.3 / n), 4))
nn бросковЧастота орловОшибка — отличие от 0.7Теория p(1−p)/n\sqrt{p(1-p)/n}
100.70.00.1449
1000.670.030.0458
1 0000.6890.0110.0145
10 0000.69870.00130.0046
100 0000.699180.00080.0014

Ошибка при n=10n = 10 равна нулю чисто случайно. Типичный разброс там ±0.145. Это закон больших чисел: частота приближается к вероятности, а стандартное отклонение оценки среднего падает как

σxˉ=σn.\sigma_{\bar{x}} = \frac{\sigma}{\sqrt{n}}.

Что здесь написано. σ\sigma (греческая «сигма») — стандартное отклонение одного броска, std из прошлого урока. xˉ\bar{x} («икс с чертой») — среднее по nn броскам, то есть наша частота, а σxˉ\sigma_{\bar{x}} — её типичная ошибка: насколько частота обычно отличается от настоящей вероятности. Формула говорит: ошибка среднего равна разбросу одного броска, делённому на корень из числа бросков.

Для монеты σ=p(1−p)\sigma = \sqrt{p(1-p)}. При p=0.7p = 0.7 это 0.7⋅0.3=0.21≈0.458\sqrt{0.7 \cdot 0.3} = \sqrt{0.21} \approx 0.458. При n=100n = 100 ошибка равна 0.458/100=0.458/10≈0.0460.458 / \sqrt{100} = 0.458 / 10 \approx 0.046 — это число из столбца «Теория». При n=400n = 400 корень равен 20, и ошибка вдвое меньше, около 0.023. Практический вывод: чтобы уменьшить шум вдвое, нужно в четыре раза больше данных. Это правило ещё встретится нам в китайском углу этого урока.

Статистики: что посчитать в первую очередь

Заголовок раздела «Статистики: что посчитать в первую очередь»
x = np.array([1.0, 2.0, 3.0, 4.0, 100.0])
print(x.mean(), np.median(x)) # 22.0 3.0
print(x.std(), x.min(), x.max()) # 39.01… 1.0 100.0
print(np.percentile(x, [5, 50, 95])) # [ 1.2 3. 80.8] — 5-й, 50-й (медиана) и 95-й перцентили

Медиана — число посередине отсортированного списка: у [1, 2, 3, 4, 100] это 3. Перцентиль обобщает эту идею: 95-й перцентиль — число, меньше которого 95 % значений, а 50-й перцентиль — та же медиана. Между соседними значениями NumPy достраивает промежуточные числа, поэтому 95-й перцентиль здесь — 80.8, хотя такого числа в массиве нет.

Один выброс (100) утащил среднее до 22, хотя типичное значение около 3. Медиана устойчива к выбросам, среднее — нет. Поэтому, например, длины текстов в датасете описывают медианой и перцентилями: «медиана 300 токенов, 95 % короче 2 000», а не средним.

Ещё две проверки, которые спасают часы отладки: np.isnan(x).sum() и np.isinf(x).sum(). Если loss стал nan, где-то в данных или в вычислениях появилось недопустимое число.

гистограммагистограммаEN histogramДиапазон значений делят на корзины (bins) и считают, сколько значений попало в каждую. делит диапазон значений на корзины (bins) и считает, сколько значений попало в каждую. Это самый быстрый способ увидеть форму распределения.

rng = np.random.default_rng(0)
x = rng.normal(size=10_000)
counts, edges = np.histogram(x, bins=12, range=(-3, 3))
for c, lo, hi in zip(counts, edges[:-1], edges[1:]):
print(f"{lo:5.1f}..{hi:4.1f} | {'#' * (c // 40)} {c}")
-3.0..-2.5 | # 67
-2.5..-2.0 | ### 152
-2.0..-1.5 | ########## 413
-1.5..-1.0 | ####################### 922
-1.0..-0.5 | ###################################### 1520
-0.5.. 0.0 | ################################################ 1939
0.0.. 0.5 | ############################################# 1808
0.5.. 1.0 | ###################################### 1556
1.0.. 1.5 | ####################### 950
1.5.. 2.0 | ########## 437
2.0.. 2.5 | #### 172
2.5.. 3.0 | # 41

Такая текстовая гистограмма работает даже в терминале сервера без графики. Те же числа в виде столбцов:

xychart-beta
title "10 000 сэмплов N(0, 1): гистограмма"
x-axis ["-2.75", "-2.25", "-1.75", "-1.25", "-0.75", "-0.25", "0.25", "0.75", "1.25", "1.75", "2.25", "2.75"]
y-axis "Количество" 0 --> 2000
bar [67, 152, 413, 922, 1520, 1939, 1808, 1556, 950, 437, 172, 41]
Знакомый «колокол»: 23 значения из 10 000 вышли за пределы ±3

Симметричный колокол с центром в нуле — так и должно выглядеть нормальное распределение. Если бы гистограмма весов вашей сети выглядела как один столбик в нуле или имела хвосты до ±1000, это был бы повод остановить обучение. Для данных с длинным хвостом, например частот слов (закон Ципфа, ступень 4), берите логарифмическую шкалу.

matplotlib — стандартная библиотека графиков в Python. В Jupyter графики появляются прямо под ячейкой. Нарисуем три главных типа графиков за один раз:

import numpy as np
import matplotlib.pyplot as plt
rng = np.random.default_rng(0)
# 1) «кривая обучения»: шумный loss и его сглаживание
# Формула ниже только рисует правдоподобную кривую: старт около 10.4, спад к 2 и случайный шум.
# Экспоненту np.exp и логарифм np.log разберём в уроке 1.0.2.
steps = np.arange(2000)
loss = 2.0 + (np.log(32_000) - 2.0) * np.exp(-steps / 300) + rng.normal(0, 0.3, size=steps.size)
w = 50
smooth = np.convolve(loss, np.ones(w) / w, mode="valid") # скользящее среднее по 50 шагам
# 2) два «облака» точек — так выглядят эмбеддинги слов, если нарисовать их на плоскости
ru = rng.normal(loc=[-2.0, 0.0], scale=0.8, size=(200, 2))
zh = rng.normal(loc=[2.0, 1.0], scale=0.8, size=(200, 2))
# 3) данные для гистограммы
x = rng.normal(size=10_000)
fig, axes = plt.subplots(1, 3, figsize=(15, 4)) # одна фигура, три области (Axes)
axes[0].plot(steps, loss, alpha=0.3, label="loss на шаге")
axes[0].plot(steps[w - 1:], smooth, label=f"скользящее среднее ({w})")
axes[0].set_xlabel("шаг обучения")
axes[0].set_ylabel("loss")
axes[0].set_title("Кривая обучения")
axes[0].legend()
axes[1].scatter(ru[:, 0], ru[:, 1], s=8, label="русские слова")
axes[1].scatter(zh[:, 0], zh[:, 1], s=8, label="китайские слова")
axes[1].set_title("Scatter: два кластера")
axes[1].legend()
axes[2].hist(x, bins=40)
axes[2].set_title("Гистограмма N(0, 1)")
axes[2].set_xlabel("значение")
axes[2].set_ylabel("сколько раз")
fig.tight_layout()
fig.savefig("first_plots.png", dpi=90) # сохранить в файл
plt.show() # показать (в Jupyter можно не писать)

Результат:

Три графика: шумная кривая обучения со сглаживанием, два кластера точек на scatter и колоколообразная гистограмма

Разберём, что здесь важно:

  • Figure и Axes. plt.subplots(1, 3) создаёт фигуру (Figure) — весь рисунок — и массив из трёх областей (Axes), по одной на график. Рисуют методами области: ax.plot — линия, ax.scatter — точечная диаграмма (каждая пара чисел — точка на плоскости; кучки близких точек называют кластерами), ax.hist — гистограмма.
  • Подписи обязательны. График без подписей осей через неделю не поймёте даже вы. set_xlabel, set_ylabel, set_title, а при нескольких линиях — label=... и legend().
  • Сглаживание. Loss на отдельных шагах шумит, потому что каждый батч — новая случайная выборка. Скользящее среднее np.convolve(loss, np.ones(w) / w, mode="valid") показывает тренд: каждая точка сглаженной линии — среднее 50 соседних значений loss. Обратите внимание: свёртка с окном 50 делает массив на 49 точек короче, поэтому ось x сдвинута (steps[w - 1:]).
  • Проверка здравого смысла. Синтетическую кривую мы специально начали с ln⁡32 000≈10.4\ln 32\,000 \approx 10.4 (натуральный логарифм, урок 1.0.2): именно такой loss у только что созданной модели со словарём в 32 тысячи токенов, которая угадывает токены наугад (урок 1.3.2). Если ваша настоящая кривая стартует намного выше, не ждите конца обучения: сначала ищите ошибку в коде.

Чек-лист на первые пять минут с любым новым массивом, будь то датасет, веса или логи:

  1. x.shape, x.dtype — то ли вообще пришло?
  2. x.min(), x.max(), x.mean(), np.median(x) — правдоподобный ли масштаб? Пиксели в [0,255][0, 255] или в [0,1][0, 1]?
  3. np.isnan(x).sum(), np.isinf(x).sum() — есть ли битые значения?
  4. Гистограмма — какой формы распределение, есть ли выбросы и «столбик в нуле»?
  5. Несколько случайных примеров (rng.choice(len(data), 5, replace=False)), а не первые пять. Первые часто нетипичны.
  6. Для обучения — кривая loss со сглаживанием, при большом разбросе — в логарифмической шкале (ax.set_yscale("log")).

Четыре функции на случайность и статистику:

  • train_val_split — воспроизводимо разбить номера примеров на обучающую часть (на ней модель учится) и проверочную, валидационную (на ней честно измеряют качество, ступень 2). Перемешайте номера через rng.permutation: первые n_val пойдут в проверку, остальные — в обучение.
  • describe — сводка по данным из раздела «Статистики».
  • estimate_pi — оценка числа π\pi методом Монте-Карло, то есть с помощью множества случайных бросков. Бросаем точки в квадрат 1×1. Точка (x,y)(x, y) лежит внутри четверти круга радиуса 1, если её расстояние до угла (0,0)(0, 0) меньше 1, то есть x2+y2<1x^2 + y^2 < 1 (теорема Пифагора из прошлого урока). Площадь четверти круга — π/4≈0.785\pi / 4 \approx 0.785, площадь квадрата — 1, поэтому внутрь попадёт примерно 78.5 % точек. Если из 10 000 точек внутри оказалось 7 854, то π≈4⋅0.7854=3.1416\pi \approx 4 \cdot 0.7854 = 3.1416.
  • pass_at_1 — усреднение точности по нескольким прогонам бенчмарка (набора задач для оценки модели), как в техотчётах LLM. В таблице results нули и единицы: строка — вопрос, столбец — прогон, 1 — модель решила. Точность прогона — среднее его столбца; дальше — среднее и std этих точностей. Зачем так делать, объясняет «Китайский угол» ниже.
Сэмплирование и статистикиnumpy · нужен интернет6 тестов
import numpy as np


def train_val_split(n, val_frac, seed):
    """Воспроизводимо разбить индексы 0..n-1 на обучающую и валидационную части.

    1) создайте генератор rng = np.random.default_rng(seed);
    2) перемешайте индексы: rng.permutation(n);
    3) первые n_val = int(n * val_frac) индексов — валидация, остальные — обучение.
    Вернуть кортеж (train_idx, val_idx) из двух np.ndarray.
    """
    # TODO
    ...


def describe(x):
    """Сводка по массиву: словарь с ключами
    "mean", "std", "min", "max", "median", "p95" (95-й перцентиль). Значения — float.

    median — число посередине отсортированного массива; p95 — число, меньше которого 95 % значений.
    describe(np.array([1.0, 2.0, 3.0, 4.0, 100.0])) -> {"mean": 22.0, "std": 39.01..., "min": 1.0,
                                                     "max": 100.0, "median": 3.0, "p95": 80.8}
    """
    # TODO: словарь из шести пар: np.mean, np.std, np.min, np.max, np.median, np.percentile(x, 95);
    #       каждое значение оберните в float(...) — так число NumPy станет обычным числом Python.
    ...


def estimate_pi(n, seed):
    """Оценка числа π методом Монте-Карло.

    Бросьте n случайных точек (x, y) в квадрат [0, 1) × [0, 1) генератором default_rng(seed).
    Доля точек с x² + y² < 1 примерно равна площади четверти круга, π/4.
    Вернуть 4 × долю (float). Без циклов.
    Пример: если из 10 000 точек внутри оказалось 7 854, ответ 4 * 0.7854 = 3.1416.
    """
    # TODO: 1) rng = np.random.default_rng(seed); точки — таблица (n, 2): xy = rng.random((n, 2));
    #       2) маска «внутри»: сумма квадратов по строке (axis=1) меньше 1;
    #       3) доля True — это .mean() маски; верните 4 * долю.
    ...


def pass_at_1(results):
    """results — массив 0/1 формы (вопросы, прогоны): решила ли модель вопрос i в прогоне j.

    Вернуть кортеж (mean_acc, std_acc):
      mean_acc — средняя точность по всем вопросам и прогонам (float);
      std_acc  — стандартное отклонение точности МЕЖДУ прогонами (float): обычный np.std, по умолчанию ddof=0.
    """
    # TODO: точность каждого прогона — среднее по оси вопросов
    ...


# Проверьте себя кнопкой «Запустить»:
print(train_val_split(10, 0.2, seed=0))
print(describe(np.array([1.0, 2.0, 3.0, 4.0, 100.0])))
print(estimate_pi(100_000, seed=0))
Проверь себя6 вопросов
1 Скрипт с rng = np.random.default_rng(42) запустили дважды на одной машине. Что с числами?
2 Что нужно зафиксировать, чтобы обучение нейросети было воспроизводимым?Можно выбрать несколько ответов
3 Честную монету (p=0.5p = 0.5) бросили 10 000 раз. Чему равно стандартное отклонение оценки доли орлов?
4 Во сколько раз нужно увеличить число прогонов бенчмарка, чтобы шум оценки уменьшился вдвое?
5 Длины текстов в датасете: 280, 300, 310, 320, 50 000 токенов. Какая статистика лучше описывает «типичный» текст?
6 Модель A набрала 70 %, модель B — 63 % на бенчмарке из 30 задач, по одному прогону каждая. Какой вывод корректен?
  • Псевдослучайный генератор детерминирован: np.random.default_rng(seed) даёт одну и ту же последовательность. Для разных задач берите отдельные генераторы (spawn).
  • Для воспроизводимости фиксируйте все источники случайности: данные, инициализацию, dropout, сэмплирование. Отчитывайтесь по нескольким сидам.
  • Шум среднего падает как σ/n\sigma/\sqrt{n}: вдвое точнее — вчетверо больше данных. Поэтому LLM оценивают по многим сэмплам на вопрос.
  • Медиана устойчива к выбросам. Гистограмма за секунды показывает форму распределения, выбросы и битые значения.
  • matplotlib: fig, ax = plt.subplots(), затем plot, scatter, hist, подписи осей, заголовок, легенда. Шумный loss сглаживайте скользящим средним.

Дальше: ступень 1 — математика для ML. Начнём с освежения школьной математики: функции и их графики. matplotlib из этого урока пригодится там сразу.