Главная идея этого урока: в 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.
Попробуй сам
Подсказка к решению — раскрыть только если застрял
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 для аналитика:
np.sqrt(x)— квадратный корень. Нужен в формулах стандартного отклонения, доверительных интервалов, расчёте размера выборки для A/B.np.abs(x)— модуль, абсолютное значение. Удобно, когда важна величина отклонения, а не знак:np.abs(revenue - plan).np.round(x, n)— округление доnзнаков. Чтобы метрики читались по-человечески: проценты до 2 знаков, рубли до 0.np.log(x)иnp.exp(x)— натуральный логарифм и экспонента.
Логарифм заслуживает отдельного слова. В аналитике выручка и доходы часто распределены «с длинным хвостом»: много мелких значений и несколько огромных. Логарифмирование сжимает такой разброс и делает данные удобнее для графиков и моделей:
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: они быстрые (та же векторизация под капотом) и читаются как обычная математика. Никаких циклов, чтобы применить корень к колонке из миллиона строк.
Попробуй сам
Подсказка к решению — раскрыть только если застрял
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 с нуля.
Попробуй сам
Подсказка к решению — раскрыть только если застрял
mask = conversion >= threshold. Количество прошедших — mask.sum() (True=1). Отбор значений — conversion[mask]. Среднее по ним — conversion[mask].mean(), затем np.round(..., 4). Метки через np.where(условие, 'A', 'B').Главное
- Векторизация — это операция сразу над всем массивом без цикла: revenue - plan вместо for. Быстрее обычного Python в 10-100 раз на больших данных.
- Арифметика (+, -, *, /, **) работает поэлементно: со скаляром применяется к каждому элементу, с массивом той же длины — по парам.
- Сравнения (>, ==, >= и т.д.) возвращают булев массив-маску. (условие).sum() считает количество подходящих, (условие).mean() — их долю.
- ufunc (np.sqrt, np.abs, np.round, np.log, np.exp) применяют функцию ко всему массиву разом. log сжимает выручку с длинным хвостом.
- np.where(условие, a, b) — условная логика в одну строку: флаги, метки, замена выбросов без if-цикла.
- Маской можно индексировать: sales[sales > 100] достаёт только нужные элементы (аналог WHERE в SQL). Условия комбинируйте через & и | с обязательными скобками.
Частые вопросы
Что такое векторизация и почему она быстрее цикла?
Векторизация — это операция сразу над всем массивом (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-тренажёре.