Массивы, формы, индексация
Массивы NumPy — таблицы чисел, на которых держатся все нейросети: почему они в десятки и сотни раз быстрее списков Python, что такое форма массива и как достать из него нужные числа срезами, условиями-масками и списками номеров.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Нейросеть работает не с отдельными числами, а с целыми таблицами чисел — массивами. Чёрно-белая картинка 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
Заголовок раздела «Почему не списки Python»Посчитаем сумму квадратов миллиона чисел тремя способами: циклом Python, как в модуле 0.2, и двумя записями NumPy.
import numpy as np подключает библиотеку под коротким именем np — так пишут все. np.arange(n) делает массив
чисел 0, 1, …, n−1 (как range), x * x возводит в квадрат каждый элемент сразу, .sum() складывает всё,
а x @ x — «перемножить попарно и сложить» одной операцией (подробно о @ — в следующем уроке).
Код замера времени копировать не обязательно — смотрите на результат:
import timeitimport numpy as np
n = 1_000_000data = [float(i) for i in range(n)] # список Pythonx = 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)) / 10t_dot = min(timeit.repeat(lambda: x @ x, number=10, repeat=5)) / 10print(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 мс — ускорение ×22x @ 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, без проверок. Подробнее:
| Список Python | np.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 байт"]
Сами числа лежат в памяти одной лентой. Чтобы читать её как таблицу,
массив хранит 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.]], float64np.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 96ndim— число осей (измерений). Здесь 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: точность и память
Заголовок раздела «dtype: точность и память»| dtype | Байт | Где встречается |
|---|---|---|
float64 | 8 | по умолчанию в NumPy; научные расчёты |
float32 | 4 | долгое время — стандарт обучения нейросетей |
float16, bfloat16 | 2 | обучение и инференс LLM (bfloat16 есть в PyTorch, в NumPy — нет) |
| FP8 | 1 | обучение DeepSeek-V3 (ступень 5) |
int64 | 8 | id токенов, индексы |
int8 | 1 | квантованные веса (ступень 7) |
bool | 1 | маски (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 занимает
байт, около 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 байте помещается разных значений (урок 0.0.2), и тип int8 отдаёт их числам
от −128 до 127. Число 128 в этот диапазон не влезает, и счёт «перескакивает» на начало, как пробег на старом
счётчике автомобиля. NumPy при этом не выдаёт ошибки — это и опасно.
Форма: reshape, транспонирование, новые оси
Заголовок раздела «Форма: reshape, транспонирование, новые оси»reshape меняет форму, не трогая данные. Элементы читаются построчно (последняя ось меняется быстрее всех):
x = np.arange(12)print(x.reshape(3, 4)) # 3 строки по 4print(x.reshape(2, 2, 3)) # 2 блока по 2 строки по 3x.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) вектор размерности режут на головы по числа, а потом переставляют оси, чтобы головы стали отдельным «батчем»:
B, T, d, H = 2, 5, 8, 2h = 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] — вся строка 0M[:, 1] # [1 5 9] — весь столбец 1M[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 > 0print(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 != 0print(real.sum(axis=1)) # [5 2] — длины последовательностей(Про axis подробно — в следующем уроке.)
Fancy indexing: индексация массивом индексов
Заголовок раздела «Fancy indexing: индексация массивом индексов»Если вместо числа или среза передать массив индексов, 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. Пусть словарь — восемь токенов, каждому соответствует строка матрицы
(таблицы) из чисел: строка 0 — токен <pad>, строка 1 — «карп» и так далее. Тогда эмбеддинги фразы
«карп прыгает через врата дракона» — это просто E[ids]: «достань строки с этими номерами». Сами числа в здесь случайные (о случайных
числах — урок 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 делает ровно это, только умеет ещё и обучать .
Вероятность правильного токена. Модель выдала распределение по словарю на каждой из позиций — матрицу
probs формы : в строке лежат вероятности всех токенов словаря оказаться на позиции .
Для обучения нужен 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 на позиции 2p = 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— таблица эмбеддингов и вероятности правильных токенов из этого урока.
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))
Проверь себя
Заголовок раздела «Проверь себя»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.