Векторизация в NumPy
numpy лежит под pandas, sklearn и почти всем стеком данных, поэтому собес проверяет, думаешь ли ты массивами, а не циклами. Вопросы предметные: чем массив отличается от списка, что такое broadcasting, почему правка среза изменила исходный массив.
Типовые формулировки: «почему цикл по массиву медленный?», «что вернёт a[1:4] - копию или окно?», «объясни axis=0 и axis=1».
// Это не про экзотику. Цикл по строкам вместо векторной операции - самая частая причина, по которой отчёт считается минуты вместо секунд, и умение его переписать экономит настоящее время каждую неделю.
Массив против списка: откуда скорость
Список Python - это набор указателей на объекты, разбросанные по памяти. Каждое число в нём отдельный объект со своим типом и счётчиком ссылок. Массив numpy - сплошной кусок памяти, где все элементы одного типа и одного размера лежат подряд.
Из одного этого следует всё остальное. Цикл по списку на каждом шаге разыменовывает указатель, проверяет тип и интерпретирует байткод. numpy отдаёт весь блок в скомпилированный код, который идёт по нему одним проходом с фиксированным шагом и попутно пользуется SIMD (single instruction, multiple data) - командами процессора, обрабатывающими несколько чисел за такт.
Цифры для ощущения масштаба: суммирование миллиона чисел питоновским циклом заняло 0.23 секунды, np.sum на тех же данных - 0.0014 секунды. Разница в 170 раз на самой простой операции; на составных выражениях она растёт.
// Поэтому dtype - не формальность и не про экономию памяти. Именно единый тип даёт фиксированный шаг по памяти и позволяет не проверять тип на каждом элементе. Массив разнородных объектов был бы обычным списком с другим фасадом.
- dtype
- тип элементов массива, один на весь массив: int64, float32, bool. Задаёт, сколько байт занимает элемент и как его интерпретировать
Broadcasting и оси
Broadcasting - правило, по которому numpy считает операции над массивами разной формы, ничего при этом не копируя. Оси сопоставляются справа налево, ось размером 1 растягивается до нужного размера, а если оси не равны и ни одна из них не единица - numpy падает с ошибкой формы, а не выравнивает молча.
Живой пример: матрица 1000×3 (тысяча объектов, три признака) и вектор из трёх средних. Выражение m - means вычитает среднее из каждой строки. Ни цикла, ни копии матрицы средних в памяти.
Классическая путаница - форма (n,) против (n,1). Первое вектор, второе колонка. Сложи их между собой, и broadcasting добросовестно построит матрицу n×n вместо вектора длины n. Ошибки не будет, будет неверный результат и странное потребление памяти - это источник половины тихих багов с размерностями.
// axis задаёт направление свёртки, и мнемоника простая: axis - это та ось, которая исчезает. У матрицы 2×2 сумма с axis=0 схлопывает строки и даёт результат по столбцам, с axis=1 схлопывает столбцы и даёт по строкам.
m = np.array([[1, 2], [3, 4]])
m.sum(axis=0) # [4, 6] - схлопнули строки, ответ по столбцам
m.sum(axis=1) # [3, 7] - схлопнули столбцы, ответ по строкам
np.where(m > 2, 1, 0) # векторный if: [[0, 0], [1, 1]]- форма (shape)
- кортеж размеров по каждой оси: (1000, 3) это тысяча строк по три числа. Половина ошибок в numpy лечится тем, что ты просто напечатал shape
Окно, копия и молчаливое переполнение
Обычный срез numpy возвращает не копию, а окно в тот же буфер памяти. Возьми a = np.arange(6), сделай v = a[1:4] и запиши v[0] = 99 - девяносто девять окажется в самом a. Это не баг, а способ не копировать гигабайты почём зря.
Индексация списком номеров и булевой маской ведёт себя иначе: она всегда возвращает копию. Тот же трюк через a[[1, 2, 3]] оригинал не тронет. Нужна гарантия независимости - ставь .copy() явно, это дешевле, чем потом искать, кто испортил исходные данные.
Вторая мина - переполнение. Целые числа в numpy фиксированной ширины, и при выходе за границу они заворачиваются молча: np.array([2**62], dtype=np.int64) * 4 даёт ровно ноль, без исключения и без предупреждения. Обычный питоновский int на том же выражении честно вернёт 18446744073709551616, потому что он неограниченный. Следи за dtype там, где перемножаются большие счётчики.
// И плавающая точка. 0.1 * 3 == 0.3 даёт False: 0.1 в двоичной записи не представима точно, как 1/3 не представима в десятичной. Числа с плавающей точкой сравнивают через np.isclose. Отдельно np.nan не равен даже самому себе - пропуски ищут через np.isnan, а агрегируют через nan-версии функций вроде np.nanmean.
- буфер
- непрерывный участок памяти с данными массива. Несколько массивов могут смотреть в один буфер - тогда правка через один виден через все остальные
Как отвечать: «Почему цикл по массиву медленный и как его ускорить?»
Питоновский цикл на каждом элементе платит за интерпретацию байткода, разыменование объекта и проверку типа, и на миллионах элементов именно эти накладные расходы всё и определяют, а не само вычисление. Векторная операция numpy передаёт весь массив в скомпилированный код, где элементы одного типа лежат подряд и обрабатываются сплошным проходом, часто по несколько чисел за такт процессора. Разрыв получается в сотни раз: суммирование миллиона чисел циклом это две десятых секунды, через np.sum - полторы миллисекунды. Практически я переписываю цикл в операции над массивами целиком, условия на np.where, а выравнивание форм отдаю broadcasting вместо ручных reshape. В цикле остаётся только то, что в векторные примитивы честно не сворачивается.
Названа настоящая причина медленности - накладные расходы на элемент, а не абстрактное «numpy быстрее», - и рядом стоят конкретные инструменты замены. Так отвечает тот, кто правда ускорял расчёты.
На чём валят
- −Писать цикл по numpy-массиву и терять весь смысл numpy.
- −Изменить срез в расчёте, что это копия, и молча испортить исходный массив.
- −Перепутать axis=0 и axis=1 и посчитать агрегат не по тому измерению.
- −Сложить (n,) с (n,1) и получить матрицу n×n вместо вектора - без всякой ошибки.
- −Переполнить int64 в произведении больших счётчиков: результат неверный, предупреждения нет.
- −Сравнивать с np.nan через == и всегда получать False - для пропусков есть np.isnan.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 16, остальные разбираются в тренажёре.
- В чём разница между .ravel(), .flatten() и .reshape() у numpy с точки зрения копирования?A)Все три возвращают копию, поэтому исходный массив после них неизмененB)Все три возвращают view, поэтому запись в результат меняет оригиналC)reshape и ravel вернут view, если память позволяет (contiguous); flatten копирует всегдаD)Разница только в форме результата на выходе; само копирование данных у всех трёх методов устроено совершенно одинаково
показать ответ и разбор
+C)reshape и ravel вернут view, если память позволяет (contiguous); flatten копирует всегда// разбор: reshape и ravel стараются вернуть view на ту же память и копируют лишь когда иначе нельзя (например, неконтигуозный порядок после транспонирования); flatten возвращает копию всегда. Практический эффект: запись в результат ravel/reshape может задеть оригинал, а flatten — нет, но flatten дороже по памяти. Для гарантированной независимости берут .copy(), для непрерывности — np.ascontiguousarray.
- Сумма миллионов float64 чуть расходится с ожидаемой в младших разрядах. Почему и что делать?A)Это баг numpy в подсчёте суммы; помогает пересчёт обычным питон-циклом forB)Float64 хранит слишком мало знаков; надо держать в int и делить в концеC)Копится округление float; сравнивать через np.isclose, а точную сумму брать math.fsumD)Расхождение однозначно означает, что в данных затесались NaN; после dropna сумма станет точной до последнего бита
показать ответ и разбор
+C)Копится округление float; сравнивать через np.isclose, а точную сумму брать math.fsum// разбор: float64 представляет числа приближённо (0.1+0.2≠0.3), и при сложении миллионов значений ошибки округления накапливаются, а порядок слагаемых влияет на итог. Поэтому «==» для float ненадёжен — берут np.isclose/allclose с допуском. Для более точной суммы есть попарное суммирование (его использует np.sum) и math.fsum (точная сумма). Наивный питон-цикл, наоборот, накапливает ошибку сильнее.
- Чем numpy-массив отличается от обычного списка Python?A)Однородный тип и непрерывная память: отсюда операции без циклаB)Массив хранит числа, а список — объекты, в остальном они одинаковыC)Массив изменяемый, а список — неизменяемыйD)Массив дёшево меняет длину на лету, а у списка она фиксирована
показать ответ и разбор
+A)Однородный тип и непрерывная память: отсюда операции без цикла// разбор: Список — массив указателей на разнородные объекты. numpy-массив хранит элементы одного типа подряд в памяти, поэтому операции выполняет скомпилированный код над всем блоком сразу (векторизация), а не интерпретатор в цикле. Плата: тип фиксирован, а изменение длины требует пересоздания массива.
- Почему операции над numpy-массивом обычно быстрее, чем цикл по списку Python?A)numpy запускает вычисления сразу на всех ядрах процессора по умолчаниюB)Данные лежат сплошным блоком одного типа, а операции идут в C поэлементноC)numpy кэширует результаты предыдущих вычислений между вызовамиD)Список Python вообще не умеет хранить числа, только строки
показать ответ и разбор
+B)Данные лежат сплошным блоком одного типа, а операции идут в C поэлементно// разбор: Массив numpy — непрерывный блок памяти элементов одного dtype, и операция над ним выполняется скомпилированным кодом на C единым проходом (векторизация), без интерпретации на каждом шаге. Список Python хранит указатели на разнородные объекты, и цикл платит за интерпретацию и разыменование на каждом элементе. Многопоточность по умолчанию тут ни при чём.
- Что значит «векторизовать» вычисление в numpy?A)Записать значения в вектор-столбец вместо вектор-строкиB)Разбить массив на части и обработать их в разных потокахC)Выразить операцию над всем массивом сразу вместо цикла по элементамD)Перевести числа в формат с плавающей точкой перед расчётом
показать ответ и разбор
+C)Выразить операцию над всем массивом сразу вместо цикла по элементам// разбор: Векторизация — выражение вычисления как операции над целым массивом (a + b, a * 2, np.exp(a)), которую numpy выполняет единым проходом на C. Это замена явного питоновского цикла по элементам на одну массовую операцию — отсюда и скорость, и читаемость. Ориентация вектора и тип чисел к смыслу термина отношения не имеют.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.