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

Массивы, формы, индексация

Массивы NumPy — таблицы чисел, на которых держатся все нейросети: почему они в десятки и сотни раз быстрее списков Python, что такое форма массива и как достать из него нужные числа срезами, условиями-масками и списками номеров.

УРОКstage-0/0-3-numpy/01-arrays.mdx1 ч 30 минУрок 1 из 3
После урока вы сможете
  • Объяснить, почему векторизованный NumPy в десятки и сотни раз быстрее цикла Python
  • Создавать массивы, читать и менять их форму (shape, reshape, .T, None) и считать память по dtype
  • Выбирать данные срезами, булевыми масками и fancy indexing и помнить, где view, а где копия
  • Реализовать поиск эмбеддингов E[ids] и выбор вероятностей правильных токенов без циклов

Нейросеть работает не с отдельными числами, а с целыми таблицами чисел — массивами. Чёрно-белая картинка 28×28 пикселей — это таблица из 28 строк и 28 столбцов, в каждой клетке яркость точки. Цветная картинка — три такие таблицы (красный, зелёный, синий). А текст для языковой модели превращается в таблицу номеров токенов.

Посмотрим, как это выглядит в LLM. Модель обрабатывает тексты пачками — батчами (batch), например по 8 текстов сразу. Каждый текст — это seq_len номеров токенов (seq_len — длина последовательности). Пачка текстов — таблица формы (batch, seq_len): строка — текст, столбец — позиция токена. Затем каждый номер заменяется эмбеддингом — списком из d_model чисел, который описывает токен (модуль 4.3). Получается трёхмерный массив (batch, seq_len, d_model) — «стопка таблиц». Веса внимания (насколько каждый токен «смотрит» на каждый другой, модуль 4.4) — массив уже из четырёх измерений:

ЧтоФормаПример размеров
номера токенов пачки текстов(batch, seq_len)(8, 128) — 8 текстов по 128 токенов
эмбеддинги этих токенов(batch, seq_len, d_model)(8, 128, 768) — у каждого токена 768 чисел
веса внимания(batch, heads, seq_len, seq_len)(8, 12, 128, 128)

Такие многомерные массивы в ML называют тензорамитензорEN tensorВ ML — просто многомерный массив чисел. torch.Tensor устроен как np.ndarray, но умеет работать на GPU и считать градиенты.. Запоминать формы сейчас не нужно — мы будем возвращаться к ним весь курс. Важно увидеть главное: внутри LLM всё — массивы чисел и операции над ними.

NumPy — библиотека (урок 0.1.2), в которой многомерный массив ndarrayмассив NumPy (ndarray)EN N-dimensional array (ndarray)Непрерывный блок чисел одного типа плюс описание формы (shape) и шагов (strides). появился в Python и стал стандартом. PyTorch скопировал его интерфейс почти один в один: x.shape, x.reshape(...), x[mask], x @ w работают одинаково. Выучив NumPy, вы будете читать большую часть кода на PyTorch без словаря. В этом модуле мы освоим три вещи, без которых не прочитать ни одну реализацию трансформера: формы и индексацию (этот урок), broadcasting (следующий) и случайные числа (третий).

Посчитаем сумму квадратов миллиона чисел тремя способами: циклом Python, как в модуле 0.2, и двумя записями NumPy. import numpy as np подключает библиотеку под коротким именем np — так пишут все. np.arange(n) делает массив чисел 0, 1, …, n−1 (как range), x * x возводит в квадрат каждый элемент сразу, .sum() складывает всё, а x @ x — «перемножить попарно и сложить» одной операцией (подробно о @ — в следующем уроке). Код замера времени копировать не обязательно — смотрите на результат:

import timeit
import numpy as np
n = 1_000_000
data = [float(i) for i in range(n)] # список Python
x = np.arange(n, dtype=np.float64) # массив NumPy
def loop():
s = 0.0
for v in data:
s += v * v
return s
# timeit.repeat запускает функцию несколько раз и возвращает список времён (в секундах).
# Берём минимум: это самый «чистый» замер, без помех от других программ.
t_loop = min(timeit.repeat(loop, number=1, repeat=5))
t_np = min(timeit.repeat(lambda: (x * x).sum(), number=10, repeat=5)) / 10
t_dot = min(timeit.repeat(lambda: x @ x, number=10, repeat=5)) / 10
print(f"цикл Python: {t_loop*1000:7.1f} мс")
print(f"(x * x).sum(): {t_np*1000:7.2f} мс — ускорение ×{t_loop/t_np:.0f}")
print(f"x @ x: {t_dot*1000:7.2f} мс — ускорение ×{t_loop/t_dot:.0f}")

На машине автора (4 ядра, без нагрузки):

цикл Python: 24.7 мс
(x * x).sum(): 1.12 мс — ускорение ×22
x @ x: 0.09 мс — ускорение ×278

Перемножение матриц (таблиц чисел) 128×128 тройным циклом на Python заняло 96 мс, а A @ B — 0.27 мс, в 350 раз быстрее. Отсюда правило: векторизованный код быстрее цикла на один-два порядка (порядок — это «в 10 раз», два порядка — «в 100 раз»). Цифры зависят от машины, но порядок величины держится. (Если у вас x @ x вдруг медленнее (x * x).sum(), процессор занят другими программами: библиотека BLAS, которая выполняет @ на всех ядрах сразу (о ней — в таблице ниже), плохо переносит конкуренцию за ядра.)

Откуда такая разница? Коротко: список Python — это ящик с отдельными подписанными конвертами, в каждом по числу, и на каждом шаге цикла Python вскрывает конверт и проверяет, что внутри. Массив NumPy — лист в клетку, где числа одного типа записаны подряд, а цикл по ним выполняет быстрый код на языке C, без проверок. Подробнее:

Список Pythonnp.ndarray
Что лежит в памятисписок адресов (указателей) отдельных объектов float, каждый объект — 24 байтасплошной блок «голых» чисел, по 8 байт на float64
Миллион чисел≈ 32 МБ8 МБ
Тип элементовлюбой, проверяется на каждой операцииодин на весь массив (dtype)
Циклинтерпретатор: проверка типа, создание нового объекта на каждом шагескомпилированный код на C; специальные команды процессора (SIMD) обрабатывают по несколько чисел за раз
@ (умножение)нетBLAS — оптимизированная библиотека для умножения матриц, использует все ядра процессора

(x * x).sum() медленнее, чем x @ x, потому что сначала создаёт временный массив x * x на 8 МБ, а потом отдельно его суммирует. x @ x делает всё за один проход оптимизированной функцией BLAS.

Замену циклов операциями над целыми массивами называют векторизациейвекторизацияEN vectorizationЗамена циклов Python операциями над целыми массивами, которые выполняются в скомпилированном коде.. На GPU разрыв ещё больше: видеокарта — это тысячи ядер, которым нужно давать работу целыми массивами.

Для любопытных: как массив устроен внутри. Этот абзац можно пропустить при первом чтении.

flowchart LR
subgraph META["Метаданные ndarray"]
  D["dtype = int64"]
  S["shape = (3, 4)"]
  T["strides = (32, 8)"]
end
META --> BUF["Сплошной буфер: 0 1 2 3 4 5 6 7 8 9 10 11<br/>12 чисел × 8 байт = 96 байт"]
ndarray = буфер данных + описание того, как его читать

Сами числа лежат в памяти одной лентой. Чтобы читать её как таблицу, массив хранит strides (шаги): на сколько байт сдвинуться по ленте, чтобы перейти к следующей строке (32 байта = 4 числа по 8 байт) или к следующему столбцу (8 байт). Поэтому многие операции с формой бесплатны: reshape или транспонирование меняют только shape и strides, а данные остаются на месте.

Чтобы увидеть результат, оберните любую строку в print(...) или выполните её в Jupyter:

import numpy as np
np.array([1, 2, 3]) # из списка → [1 2 3], dtype int64 (целые)
np.zeros((2, 3)) # нули: 2 строки, 3 столбца → [[0. 0. 0.] [0. 0. 0.]], float64
np.ones((4,), dtype=np.float32) # четыре единицы, сразу float32 → [1. 1. 1. 1.]
np.full((2, 2), 7) # таблица 2×2, заполненная семёрками → [[7 7] [7 7]]
np.arange(12) # 0, 1, …, 11 (как range) → [ 0 1 2 … 11]
np.linspace(0, 1, 5) # [0. 0.25 0.5 0.75 1. ] — 5 равноотстоящих точек от 0 до 1 включительно
np.eye(3) # единичная матрица 3×3: единицы на диагонали, остальное нули

Форму передают кортежем: (2, 3) — «2 строки, 3 столбца», (4,) — «просто 4 числа в ряд» (запятая обязательна, урок 0.2.1). Точка после числа в выводе (0., 1.) значит, что число дробное.

У каждого массива есть несколько атрибутов. Смотреть на них нужно постоянно:

M = np.arange(12).reshape(3, 4)
print(M)
print(M.ndim, M.shape, M.size, M.dtype, M.itemsize, M.nbytes)
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
2 (3, 4) 12 int64 8 96
  • ndim — число осей (измерений). Здесь 2: строки и столбцы.
  • shapeформа массиваEN shapeКортеж размеров по осям, например (batch, seq_len, d_model). — размеры по осям: 3 строки, 4 столбца.
  • size — общее число элементов, произведение чисел в shape.
  • dtypeтип элементов массива (dtype)EN data type (dtype)float32, float16, int64, bool… Определяет точность и сколько байт занимает каждое число. — тип элементов; itemsize — байт на элемент; nbytes = size × itemsize.
dtypeБайтГде встречается
float648по умолчанию в NumPy; научные расчёты
float324долгое время — стандарт обучения нейросетей
float16, bfloat162обучение и инференс LLM (bfloat16 есть в PyTorch, в NumPy — нет)
FP81обучение DeepSeek-V3 (ступень 5)
int648id токенов, индексы
int81квантованные веса (ступень 7)
bool1маски (True/False, ниже в этом уроке)

Это те же форматы, что в уроке 0.0.2: float32 = FP32, float16 = FP16. Инференс — запуск уже обученной модели (например, когда вы задаёте вопрос чат-боту). Квантованные веса — веса, сжатые в целые числа из 1 байта или меньше, чтобы модель занимала меньше памяти.

NumPy сам выбирает dtype по данным: np.array([1, 2.5]) получит float64, а np.array([1, True]) — int64. Привести тип явно можно методом .astype(np.float32).

Память считается так: число элементов × байт на элемент. Массив активаций (промежуточных чисел, которые нейросеть считает по дороге) формы (1000, 768) во float32 занимает 1000⋅768⋅4=3 072 0001000 \cdot 768 \cdot 4 = 3\,072\,000 байт, около 3 МБ. Эту арифметику вы будете делать постоянно, прикидывая, влезет ли модель в видеопамять.

У меньших типов есть цена — точность и диапазон:

w = np.array([127], dtype=np.int8)
print(w + np.int8(1)) # [-128] — переполнение без ошибки!
print(np.float32(16_777_217)) # 1.6777216e+07, то есть 16777216: после 2^24 float32 не различает соседние целые
print(np.float16(0.1) + np.float16(0.2)) # 0.2998 — у float16 около 3 значащих цифр
print(np.array([1.7, -1.7]).astype(np.int64)) # [ 1 -1] — astype отбрасывает дробную часть

Почему 127 + 1 дало −128? В 1 байте помещается 28=2562^8 = 256 разных значений (урок 0.0.2), и тип int8 отдаёт их числам от −128 до 127. Число 128 в этот диапазон не влезает, и счёт «перескакивает» на начало, как пробег на старом счётчике автомобиля. NumPy при этом не выдаёт ошибки — это и опасно.

reshape меняет форму, не трогая данные. Элементы читаются построчно (последняя ось меняется быстрее всех):

x = np.arange(12)
print(x.reshape(3, 4)) # 3 строки по 4
print(x.reshape(2, 2, 3)) # 2 блока по 2 строки по 3
x.reshape(3, -1) # -1 = «вычисли сам» → (3, 4)
x.reshape(5, -1) # ValueError: cannot reshape array of size 12 into shape (5,newaxis)
[[ 0 1 2 3]
[ 4 5 6 7]
[ 8 9 10 11]]
[[[ 0 1 2]
[ 3 4 5]]
[[ 6 7 8]
[ 9 10 11]]]

Числа те же самые и в том же порядке — меняется только то, как их «разложить по полочкам». Произведение размеров должно сохраниться: 12 = 3 × 4 = 2 × 2 × 3. На 5 частей 12 чисел поровну не делятся — отсюда ошибка.

Ось — это одно из направлений массива: у таблицы ось 0 идёт вниз по строкам, ось 1 — вправо по столбцам. ТранспонированиетранспонированиеEN transposeСтроки становятся столбцами — Aᵀ. Полезное правило — (AB)ᵀ = BᵀAᵀ. В NumPy и PyTorch — A.T. M.T переставляет оси: строки становятся столбцами, (3, 4) → (4, 3). Для массивов с большим числом осей есть transpose с явным порядком. Ось длины 1 добавляют индексом None (синоним — np.newaxis):

v = np.arange(3) # shape (3,)
v[:, None].shape # (3, 1) — столбец
v[None, :].shape # (1, 3) — строка

Пригодится в следующем уроке: формы (3,), (3, 1) и (1, 3) ведут себя по-разному.

Пример из трансформера (забегаем вперёд: если сейчас непонятно, зачем это нужно, — не страшно, достаточно проследить за формами). В многоголовом внимании (урок 4.4.3) вектор размерности d=8d = 8 режут на H=2H = 2 головы по d/H=4d/H = 4 числа, а потом переставляют оси, чтобы головы стали отдельным «батчем»:

B, T, d, H = 2, 5, 8, 2
h = np.arange(B * T * d).reshape(B, T, d) # (2, 5, 8)
heads = h.reshape(B, T, H, d // H).transpose(0, 2, 1, 3) # (2, 2, 5, 4) = (B, H, T, d/H)
# transpose(0, 2, 1, 3) — новый порядок осей: ось 2 (головы) встаёт на место оси 1 (позиции)
print(heads.shape)

Ровно эта строка есть почти в каждой реализации GPT, только с torch вместо np.

M = np.arange(12).reshape(3, 4)
M[1, 2] # 6 — строка 1, столбец 2 (нумерация с нуля)
M[-1, -1] # 11 — отрицательные индексы считаются с конца
M[0] # [0 1 2 3] — вся строка 0
M[:, 1] # [1 5 9] — весь столбец 1
M[1:, ::2] # строки с 1-й, столбцы через один → [[ 4 6] [ 8 10]]
M[::-1, 0] # столбец 0 в обратном порядке → [8 4 0]

срезсрезEN slicingВыборка вида a[start:stop:step]. В NumPy срез — это view: он смотрит на те же данные, без копирования. start:stop:step работает по каждой оси так же, как срезы списков из урока 0.2.1: stop не включается, пропущенное значение означает «до края».

У трёхмерного массива индексов в квадратных скобках три — по одному на ось. Правило для формы результата простое: число по оси убирает эту ось, срез (:, 1:3, ::2) — оставляет, только, может быть, короче.

x = np.arange(24).reshape(2, 3, 4) # 2 блока по 3 строки по 4 числа
x[1].shape # (3, 4) — блок 1 целиком: первая ось убрана
x[1, 2].shape # (4,) — строка 2 блока 1: убраны две оси
x[1, 2, 3] # 23 — одно число: убраны все три оси
x[:, 0, :].shape # (2, 4) — строка 0 из каждого блока: убрана только средняя ось

Сравнение массива с числом даёт массив True/False той же формы — булева маскабулева маскаEN boolean maskМассив True/False той же формы. Выбирает или зануляет элементы; в трансформере так устроены attention- и padding-маски.. Им можно выбирать и изменять элементы:

x = np.array([3, -1, 4, -1, 5, -9, 2, 6])
mask = x > 0
print(mask) # [ True False True False True False True True]
print(x[mask]) # [3 4 5 2 6] — только положительные (копия!)
print(mask.sum(), mask.mean()) # 5 0.625 — True считается как 1: сколько и какая доля
print(x[(x > 0) & (x % 2 == 0)]) # [4 2 6] — положительные и чётные
print(np.where(x > 0, x, 0)) # [3 0 4 0 5 0 2 6] — «если, то, иначе» поэлементно

Маски объединяют операторами & (и), | (или), ~ (не). Слова and, or, not с массивами не работают.

В трансформере маски повсюду. Тексты в одном батче бывают разной длины, а таблица должна быть прямоугольной, поэтому короткие тексты дополняют до общей длины служебным токеном <pad> (padding — «набивка»), часто с номером 0. Padding-маска отмечает, где настоящие токены, а где набивка. Причинная маска запрещает токену смотреть в будущее (модуль 4.4).

batch = np.array([[1, 2, 3, 4, 5],
[6, 7, 0, 0, 0]]) # 0 = <pad>
real = batch != 0
print(real.sum(axis=1)) # [5 2] — длины последовательностей

(Про axis подробно — в следующем уроке.)

Если вместо числа или среза передать массив индексов, NumPy соберёт элементы по этим индексам. Это индексация массивом индексовиндексация массивом индексов (fancy indexing)EN fancy indexingE[ids] — выбрать строки по списку индексов. Именно так работает таблица эмбеддингов.:

M = np.arange(12).reshape(3, 4)
M[[0, 2]] # строки 0 и 2 → [[ 0 1 2 3] [ 8 9 10 11]]
M[[0, 2], [1, 3]] # пары (0,1) и (2,3) → [ 1 11]

Таблица эмбеддингов — это fancy indexing. Пусть словарь — восемь токенов, каждому соответствует строка матрицы (таблицы) EE из d=4d = 4 чисел: строка 0 — токен <pad>, строка 1 — «карп» и так далее. Тогда эмбеддинги фразы «карп прыгает через врата дракона» — это просто E[ids]: «достань строки с этими номерами». Сами числа в EE здесь случайные (о случайных числах — урок 0.3.3), нам важна только форма:

vocab = ["<pad>", "карп", "прыгает", "через", "врата", "дракона", "стал", "драконом"]
rng = np.random.default_rng(0) # генератор случайных чисел (урок 0.3.3)
E = rng.standard_normal((len(vocab), 4)) # случайная таблица (V, d) = (8, 4)
ids = np.array([1, 2, 3, 4, 5]) # карп прыгает через врата дракона
print(E[ids].shape) # (5, 4)
batch = np.array([[1, 2, 3, 4, 5], [6, 7, 0, 0, 0]])
print(E[batch].shape) # (2, 5, 4) — форма ids + (d,)

nn.Embedding в PyTorch делает ровно это, только умеет ещё и обучать EE.

Вероятность правильного токена. Модель выдала распределение по словарю на каждой из TT позиций — матрицу probs формы (T,V)(T, V): в строке tt лежат вероятности всех VV токенов словаря оказаться на позиции tt. Для обучения нужен loss — число, которое показывает, насколько модель ошиблась (урок 1.3.2). А для loss нужна вероятность правильного токена на каждой позиции. Пары индексов «номер позиции, id правильного токена» решают задачу без цикла:

probs = np.array([[0.1, 0.7, 0.2],
[0.5, 0.25, 0.25],
[0.05, 0.05, 0.9]])
targets = np.array([1, 0, 2]) # правильные токены: 1 на позиции 0, 0 на позиции 1, 2 на позиции 2
p = probs[np.arange(3), targets] # пары (0, 1), (1, 0), (2, 2)
print(p) # [0.7 0.5 0.9]
print(-np.log(p).mean()) # 0.3851… — это cross-entropy loss!

np.arange(3) — это [0, 1, 2], номера строк. Вместе с targets они образуют пары «строка, столбец», и NumPy достаёт probs[0, 1] = 0.7, probs[1, 0] = 0.5 и probs[2, 2] = 0.9. Последняя строка — забегание вперёд: логарифм (np.log) разберём в уроке 1.0.2, а cross-entropy — в уроке 1.3.2. Сейчас важно, как мы одной строкой достали p.

Пять маленьких функций, каждая — одна-две строки NumPy, без циклов. Все они встретятся вам в коде нейросетей:

  • relu — самая популярная «функция активации»: заменяет отрицательные числа нулями, [3, -1, 4] → [3, 0, 4] (подробно — в уроке 1.0.1);
  • center_crop — вырезать центр картинки. Картинка здесь — таблица чисел формы (H, W): H строк (высота) и W столбцов (ширина). Из таблицы 6×6 при k = 2 нужен квадрат 2×2 в середине — строки 2–3 и столбцы 2–3;
  • count_between — посчитать элементы в диапазоне, это маски и &;
  • embed и pick_targets — таблица эмбеддингов и вероятности правильных токенов из этого урока.
Срезы, маски и fancy indexingnumpy · нужен интернет7 тестов
import numpy as np


def relu(x):
    """ReLU: отрицательные элементы -> 0, остальные без изменений.

    Работает для массива любой формы. Входной массив НЕ должен измениться:
    верните новый массив той же формы и того же dtype.
    """
    # TODO: булева маска
    ...


def center_crop(img, k):
    """Центральный квадрат k×k из двумерного массива img формы (H, W).

    Гарантируется, что H - k и W - k чётные. Пример: из 6×6 при k=2
    берутся строки 2..3 и столбцы 2..3.
    """
    # TODO: срезы
    ...


def count_between(x, lo, hi):
    """Сколько элементов x попадает в полуинтервал lo <= x < hi. Вернуть int."""
    # TODO: две маски и оператор &
    ...


def embed(E, ids):
    """Таблица эмбеддингов: E — (V, d), ids — массив индексов токенов любой формы.

    Вернуть массив формы ids.shape + (d,): для каждого id — строку E[id].
    """
    # TODO: fancy indexing, одна строка
    ...


def pick_targets(probs, targets):
    """probs — (T, V): вероятности токенов на каждой из T позиций; targets — (T,) правильные id.

    Вернуть (T,) — вероятность правильного токена на каждой позиции:
    [probs[0, targets[0]], probs[1, targets[1]], ...]. Без цикла.
    """
    # TODO: индексация парой массивов
    ...


# Проверьте себя кнопкой «Запустить»:
x = np.array([3, -1, 4, -1, 5, -9])
print(relu(x), x)
print(center_crop(np.arange(36).reshape(6, 6), 2))
print(count_between(x, 0, 5))
Проверь себя6 вопросов
1 x = np.arange(24).reshape(2, 3, 4). Какая форма у x[1, :, ::2]?
2 Сколько байт занимает массив формы (1000,768)(1000, 768) с dtype=float32?
3 Что напечатает код: a = np.zeros(5); b = a[1:3]; b[:] = 7; print(a)?
4 Какие выражения выберут элементы x, лежащие строго между 0 и 10?Можно выбрать несколько ответов
5 Матрица эмбеддингов E имеет форму (50 000,512)(50\,000, 512), ids — (8,128)(8, 128). Какая форма у E[ids]?
6 Какой dtype получит np.array([1, 2.5])? (напишите как в NumPy)
  • ndarray — сплошной блок чисел одного dtype плюс shape и strides. Поэтому векторизованные операции быстрее цикла Python в десятки и сотни раз.
  • Память = size × itemsize. dtype решает, влезет ли модель: float32 — 4 байта, bf16 — 2, FP8 — 1.
  • reshape (с -1), .T, transpose и None меняют форму без копирования. Привыкайте предсказывать shape.
  • Срезы — view, маски и fancy indexing — копии. Условия в масках объединяйте через &, |, ~ и в скобках.
  • E[ids] — таблица эмбеддингов, probs[np.arange(T), targets] — вероятности правильных токенов для loss.

Дальше: broadcasting — как NumPy складывает массивы разных форм, агрегации по осям, @ и einsum.