numpyвекторизацияufuncpython для аналитикаnumpy с нуляанализ данных

NumPy с нуля. Часть 2: векторизация и ufunc

2026-06-28 11 мин

Главная идея этого урока: в numpy для аналитика циклы почти никогда не нужны. Вместо того чтобы перебирать массив по одному элементу в for, вы пишете одну строку вроде revenue - plan, и numpy сам применяет операцию ко всем элементам сразу. Это называется векторизация, и именно она делает numpy в десятки раз быстрее обычного Python.

В первой части курса мы научились создавать массивы и понимать их форму. Теперь самое полезное на практике: как считать метрики целыми колонками. Вы возьмёте массив выручки и за одну операцию посчитаете отклонение от плана, процент выполнения, флаг «выполнил / не выполнил» — без единого цикла.

Мы разберём четыре кита векторизации: поэлементные операции (+, -, *, /), сравнения, которые возвращают булевы массивы, универсальные функции ufunc (sqrt, log, exp, abs, round) и связку np.where с масками для фильтрации и условной логики. Всё на примерах из аналитики: выручка, конверсия, A/B-тесты. Это фундамент, на котором держится вся дальнейшая работа с данными в numpy с нуля.

Что такое векторизация и почему циклы медленные

Представьте, что у вас есть выручка по четырём дням и нужно прибавить к каждому дню бонус 5. На чистом Python вы бы написали цикл:

revenue = [10, 20, 30, 40]
result = []
for x in revenue:
    result.append(x + 5)
print(result)   # [15, 25, 35, 45]

В numpy то же самое — одна строка:

import numpy as np

revenue = np.array([10, 20, 30, 40])
print(revenue + 5)   # [15 25 35 45]

Запись revenue + 5 означает «прибавь 5 к каждому элементу». Это и есть векторизация: операция применяется ко всему массиву разом, без явного цикла.

Почему циклы в Python медленные? Каждая итерация for — это работа интерпретатора Python: проверить тип, найти метод, упаковать результат. На миллионе чисел это миллион таких проверок. numpy же хранит числа плотным блоком в памяти и выполняет сложение на уровне C — одним быстрым проходом, без накладных расходов интерпретатора. На больших массивах разница доходит до 10-100 раз.

Для аналитика вывод простой: если вы пишете for по строкам массива или DataFrame, чтобы что-то посчитать — почти всегда есть векторизованный способ короче и быстрее. Циклы оставляем для логики, которую нельзя выразить операцией над массивом.

Поэлементные операции: арифметика над массивами

Все базовые арифметические операции работают поэлементно. Если справа стоит число (скаляр), оно применяется к каждому элементу:

a = np.array([10, 20, 30, 40])

print(a + 5)    # [15 25 35 45]
print(a * 2)    # [20 40 60 80]
print(a / 10)   # [1. 2. 3. 4.]
print(a ** 2)   # [ 100  400  900 1600]

Обратите внимание: a / 10 дал [1. 2. 3. 4.] с точками — деление всегда возвращает дробные числа (float), поэтому numpy печатает их с точкой.

Если оба операнда — массивы одинаковой длины, операция идёт по парам: первый с первым, второй со вторым:

a = np.array([10, 20, 30, 40])
b = np.array([1, 2, 3, 4])

print(a + b)   # [11 22 33 44]
print(a - b)   # [ 9 18 27 36]
print(a * b)   # [ 10  40  90 160]

Это прямо то, что нужно аналитику. Допустим, revenue — фактическая выручка по дням, plan — план. Отклонение от плана и процент выполнения считаются в одну строку каждый:

revenue = np.array([120, 340, 90, 510])
plan    = np.array([100, 300, 100, 500])

diff = revenue - plan          # абсолютное отклонение
ratio = revenue / plan         # доля выполнения плана

Никаких циклов: numpy сам прошёлся по всем дням. Важно, чтобы массивы были одной длины — иначе numpy выдаст ошибку формы (про автоматическое растягивание разных форм, broadcasting, поговорим в следующих частях).

Поэлементная операция: a + b = c

   a            b            c = a + b
 ┌──────┐     ┌──────┐      ┌──────┐
 │  10  │  +  │   1  │  ──► │  11  │
 ├──────┤     ├──────┤      ├──────┤
 │  20  │  +  │   2  │  ──► │  22  │
 ├──────┤     ├──────┤      ├──────┤
 │  30  │  +  │   3  │  ──► │  33  │
 ├──────┤     ├──────┤      ├──────┤
 │  40  │  +  │   4  │  ──► │  44  │
 └──────┘     └──────┘      └──────┘
     │            │             │
     └─ пара ─────┘             │
        складывается ───────────┘

        одна запись: a + b  (без цикла for)

numpy складывает массивы по парам: первый с первым, второй со вторым и т.д. Одна строка a + b обрабатывает все элементы сразу — это и есть векторизация, заменяющая ручной цикл for.

Попробуй сам

Задача · Python
Условие
Дана фактическая выручка по 5 дням и план. Посчитайте: (1) отклонение от плана diff = выручка минус план, (2) долю выполнения ratio = выручка / план, округлённую до 2 знаков, (3) массив статусов status: 'ok' если выручка не ниже плана, иначе 'fail', (4) суммарное отклонение за все дни. Выведите все четыре результата (diff, ratio, status, сумму diff) каждый с новой строки. Циклы использовать нельзя.
Подсказка к решению — раскрыть только если застрял
Спойлер! Сначала попробуй сам. diff = revenue - plan; ratio = np.round(revenue / plan, 2); status = np.where(revenue >= plan, 'ok', 'fail'). Сумму массива даёт diff.sum(). numpy печатает массив без запятых, через пробелы.

Сравнения: булевы массивы

Операторы сравнения (>, <, >=, <=, ==, !=) тоже работают поэлементно. Но возвращают они не числа, а массив из True и False — булев массив:

prices = np.array([100, 250, 99, 500])

print(prices > 200)    # [False  True False  True]
print(prices == 99)    # [False False  True False]

Каждый элемент проверяется отдельно, и на его месте остаётся ответ на вопрос «выполняется ли условие». Такой массив называют маской — он показывает, какие элементы подходят под условие.

Булев массив сам по себе очень полезен. Например, чтобы посчитать, сколько дней выручка была выше 200, достаточно просуммировать маску: True считается за 1, False за 0.

revenue = np.array([120, 340, 90, 510, 75])
mask = revenue > 200
print(mask.sum())   # 2  — два дня выше 200

А доля таких дней — это среднее по маске:

print(mask.mean())  # 0.4  — 40% дней

Это стандартный приём аналитика: «сколько объектов удовлетворяет условию» = (условие).sum(), «какая их доля» = (условие).mean(). Запомните его, он встречается постоянно — от подсчёта оттока до доли успешных платежей.

Универсальные функции ufunc: sqrt, exp, log, abs, round

Помимо арифметики numpy даёт набор готовых функций, которые тоже применяются ко всему массиву сразу. Их называют ufunc (universal functions). Вы вызываете функцию один раз, передаёте массив — и получаете массив той же длины с результатом для каждого элемента.

x = np.array([1, 4, 9, 16, 25])

print(np.sqrt(x))   # [1. 2. 3. 4. 5.]   — корень
print(np.abs(np.array([-3, 5, -7])))   # [3 5 7]   — модуль
print(np.round(np.array([1.234, 5.678, 9.111]), 1))   # [1.2 5.7 9.1]

Самые ходовые ufunc для аналитика:

Логарифм заслуживает отдельного слова. В аналитике выручка и доходы часто распределены «с длинным хвостом»: много мелких значений и несколько огромных. Логарифмирование сжимает такой разброс и делает данные удобнее для графиков и моделей:

revenue = np.array([100, 1000, 10000])
print(np.round(np.log10(revenue), 2))   # [2. 3. 4.]

Здесь np.log10 — десятичный логарифм: 100 → 2, 1000 → 3, 10000 → 4. Громадный разброс превратился в ровную шкалу.

Главное про ufunc: они быстрые (та же векторизация под капотом) и читаются как обычная математика. Никаких циклов, чтобы применить корень к колонке из миллиона строк.

Попробуй сам

Задача · Python
Условие
Дан массив значений values. Посчитайте поэлементный квадратный корень через np.sqrt и выведите его. Затем дан массив delta с положительными и отрицательными отклонениями: выведите модуль каждого элемента (np.abs), а потом отберите маской только те элементы, у которых модуль отклонения больше 4, и выведите их количество. Без циклов.
Подсказка к решению — раскрыть только если застрял
Спойлер! Сначала попробуй сам. np.sqrt(values) даёт float-массив (печатается с точками). Маска: delta[np.abs(delta) > 4]. Количество элементов массива — это .size. Скобки вокруг условия в индексации обязательны.

np.where: условие в одну строку

Очень частая задача: «если выполнено условие — одно значение, иначе — другое». В Python это if/else, но if не умеет работать сразу со всем массивом. Для этого есть np.where.

Синтаксис простой: np.where(условие, значение_если_True, значение_если_False).

sales = np.array([120, 80, 200, 50, 300])

# флаг: 1 если продажи дотянули до 100, иначе 0
print(np.where(sales >= 100, 1, 0))   # [1 0 1 0 1]

# текстовая метка вместо числа
print(np.where(sales >= 100, 'ok', 'low'))   # ['ok' 'low' 'ok' 'low' 'ok']

Второй и третий аргументы могут быть не только константами, но и массивами. Например, «обнулить всё, что меньше 100, остальное оставить как есть» — типичная чистка данных:

print(np.where(sales >= 100, sales, 0))   # [120   0 200   0 300]
np.where идеально подходит для категоризации: разметить пользователей на сегменты, проставить флаг конверсии, заменить выбросы. Всё в одну строку и векторизованно.

Когда условий больше двух, np.where можно вкладывать друг в друга, но при трёх и более уровнях читабельнее становится np.select — про него в отдельной части. Для бинарного «да / нет» np.where остаётся лучшим инструментом.

Маски как фильтр: выбираем нужные элементы

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

sales = np.array([120, 80, 200, 50, 300])

mask = sales > 100
print(mask)          # [ True False  True False  True]
print(sales[mask])   # [120 200 300]   — остались только > 100

Чаще пишут короче, прямо подставляя условие в скобки:

print(sales[sales > 100])   # [120 200 300]

Читается как «возьми из sales те элементы, где sales больше 100». Это аналог WHERE в SQL, только для массива.

Дальше к отфильтрованным элементам можно применять любые операции и агрегаты:

# средняя выручка только по успешным дням
print(sales[sales > 100].mean())   # 206.66666666666666

# сколько таких дней
print(sales[sales > 100].size)     # 3

Условия можно комбинировать через & (и) и | (или). Важно: используйте именно & / |, а не and / or, и оборачивайте каждое условие в скобки:

# дни, где выручка от 100 до 250 включительно
print(sales[(sales >= 100) & (sales <= 250)])   # [120 200]

Забудете скобки — получите ошибку приоритета операторов. Это самая частая ошибка новичка с масками, держите её в голове.

Итог: маска — это фильтр. Сравнение строит маску, индексация по маске достаёт элементы, агрегат считает по ним метрику. Эта тройка заменяет почти все циклы, которые вы могли бы написать для отбора данных в numpy с нуля.

Попробуй сам

Задача · Python
Условие
Дан массив конверсий по 6 группам A/B-теста и порог threshold = 0.10. Постройте булеву маску 'конверсия не ниже порога'. Выведите: (1) саму маску, (2) сколько групп прошли порог (сумма маски), (3) значения конверсий, прошедших порог, (4) метки через np.where ('A' если прошла порог, иначе 'B'), (5) среднюю конверсию среди прошедших групп, округлённую до 4 знаков. Без циклов.
Подсказка к решению — раскрыть только если застрял
Спойлер! Сначала попробуй сам. mask = conversion >= threshold. Количество прошедших — mask.sum() (True=1). Отбор значений — conversion[mask]. Среднее по ним — conversion[mask].mean(), затем np.round(..., 4). Метки через np.where(условие, 'A', 'B').

Главное

Частые вопросы

Что такое векторизация и почему она быстрее цикла?

Векторизация — это операция сразу над всем массивом (a + b, a * 2) вместо перебора элементов в Python-цикле. Тяжёлая работа уходит в скомпилированный C-код numpy, поэтому получается в десятки-сотни раз быстрее, а код короче. Правило numpy: если пишешь for по элементам массива — почти наверняка есть векторный способ сделать то же самое.

Что такое ufunc в numpy?

ufunc (universal function) — это функция, которая применяется поэлементно ко всему массиву: np.sqrt, np.log, np.abs, np.exp. Она работает векторно и поддерживает broadcasting, поэтому np.sqrt(arr) сразу вернёт массив корней без цикла. Обычные функции из math так не умеют — они считают только одно число за раз.

Как заменить if/else на массиве через np.where?

np.where(cond, a, b) возвращает массив, где на месте True стоит значение из a, на месте False — из b. Например, np.where(sales > 100, 'big', 'small') разметит каждую сделку. Это векторный аналог тернарного if, работающий на всём массиве разом. Для нескольких условий берут np.select.

Как отфильтровать массив по условию через булеву маску?

Сравнение массива с числом даёт маску из True/False: arr > 0. Подставив её в индекс — arr[arr > 0] — получишь только подходящие элементы. Условия объединяют через & (и), | (или), ~ (не), обязательно оборачивая каждое в скобки. Это основа фильтрации и в numpy, и в pandas — отработать можно в Python-тренажёре.

Закрепи NumPy на практике
Реши Python-задачи аналитика в тренажёре — pandas, numpy, scipy прямо в браузере. Первые 5 — без регистрации.
Открыть Python-тренажёр →