сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · pandas и numpy

Векторизация в 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, остальные разбираются в тренажёре.

  1. #numpy_vectorization1 / 5
    В чём разница между .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.

  2. #numpy_vectorization2 / 5
    Сумма миллионов float64 чуть расходится с ожидаемой в младших разрядах. Почему и что делать?
    A)Это баг numpy в подсчёте суммы; помогает пересчёт обычным питон-циклом for
    B)Float64 хранит слишком мало знаков; надо держать в int и делить в конце
    C)Копится округление float; сравнивать через np.isclose, а точную сумму брать math.fsum
    D)Расхождение однозначно означает, что в данных затесались NaN; после dropna сумма станет точной до последнего бита
    показать ответ и разбор
    +C)Копится округление float; сравнивать через np.isclose, а точную сумму брать math.fsum

    // разбор: float64 представляет числа приближённо (0.1+0.2≠0.3), и при сложении миллионов значений ошибки округления накапливаются, а порядок слагаемых влияет на итог. Поэтому «==» для float ненадёжен — берут np.isclose/allclose с допуском. Для более точной суммы есть попарное суммирование (его использует np.sum) и math.fsum (точная сумма). Наивный питон-цикл, наоборот, накапливает ошибку сильнее.

  3. #numpy_vectorization3 / 5
    Чем numpy-массив отличается от обычного списка Python?
    A)Однородный тип и непрерывная память: отсюда операции без цикла
    B)Массив хранит числа, а список — объекты, в остальном они одинаковы
    C)Массив изменяемый, а список — неизменяемый
    D)Массив дёшево меняет длину на лету, а у списка она фиксирована
    показать ответ и разбор
    +A)Однородный тип и непрерывная память: отсюда операции без цикла

    // разбор: Список — массив указателей на разнородные объекты. numpy-массив хранит элементы одного типа подряд в памяти, поэтому операции выполняет скомпилированный код над всем блоком сразу (векторизация), а не интерпретатор в цикле. Плата: тип фиксирован, а изменение длины требует пересоздания массива.

  4. #numpy_vectorization4 / 5
    Почему операции над numpy-массивом обычно быстрее, чем цикл по списку Python?
    A)numpy запускает вычисления сразу на всех ядрах процессора по умолчанию
    B)Данные лежат сплошным блоком одного типа, а операции идут в C поэлементно
    C)numpy кэширует результаты предыдущих вычислений между вызовами
    D)Список Python вообще не умеет хранить числа, только строки
    показать ответ и разбор
    +B)Данные лежат сплошным блоком одного типа, а операции идут в C поэлементно

    // разбор: Массив numpy — непрерывный блок памяти элементов одного dtype, и операция над ним выполняется скомпилированным кодом на C единым проходом (векторизация), без интерпретации на каждом шаге. Список Python хранит указатели на разнородные объекты, и цикл платит за интерпретацию и разыменование на каждом элементе. Многопоточность по умолчанию тут ни при чём.

  5. #numpy_vectorization5 / 5
    Что значит «векторизовать» вычисление в numpy?
    A)Записать значения в вектор-столбец вместо вектор-строки
    B)Разбить массив на части и обработать их в разных потоках
    C)Выразить операцию над всем массивом сразу вместо цикла по элементам
    D)Перевести числа в формат с плавающей точкой перед расчётом
    показать ответ и разбор
    +C)Выразить операцию над всем массивом сразу вместо цикла по элементам

    // разбор: Векторизация — выражение вычисления как операции над целым массивом (a + b, a * 2, np.exp(a)), которую numpy выполняет единым проходом на C. Это замена явного питоновского цикла по элементам на одну массовую операцию — отсюда и скорость, и читаемость. Ориентация вектора и тип чисел к смыслу термина отношения не имеют.

дальше

Теорию прочитали. Навык ставится повторением

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