Главное сразу: агрегация — это когда из массива чисел вы получаете одно число или одну строку чисел. Сумма выручки, средний чек, перцентиль латентности, накопительный итог — всё это агрегации. В numpy они делаются одним вызовом: revenue.sum(), data.mean(), latency.std(). А параметр axis решает, считать ли по всей таблице сразу, по столбцам или по строкам — это самая важная и самая путающая вещь в этом уроке, поэтому ей мы уделим максимум внимания.
Это пятая часть курса «numpy с нуля для аналитика данных». В прошлых частях вы научились создавать массивы, индексировать их и применять векторные операции. Теперь пора их сворачивать в метрики — то, ради чего аналитик вообще берёт в руки numpy. Любой дашборд, любой отчёт, любая проверка A/B-теста под капотом — это агрегации над массивами.
Разберём по порядку: базовые сводки (sum, mean, std, min, max, median), затем главную тему — axis=0 против axis=1 на матрице, потом argmin/argmax (где находится минимум/максимум), и в конце два инструмента, без которых не живёт ни один аналитик — percentile (перцентили для латентности и зарплат) и cumsum (накопительный итог). Простым языком, на примерах с реальными метриками, с тремя интерактивными песочницами в конце.
Что такое агрегация и зачем она аналитику
Представьте, что у вас есть массив выручки по дням за месяц — 30 чисел. Сами по себе они мало что говорят. Аналитику нужны выводы: сколько всего заработали, сколько в среднем в день, насколько стабильна выручка, какой был лучший день. Каждый такой вывод — это свёртка массива в одно число (или в несколько). Это и называется агрегацией.
В numpy агрегирующие функции есть в двух формах, и работают они одинаково:
import numpy as np
revenue = np.array([100, 200, 150, 300, 250])
# Форма 1: метод массива
print(revenue.sum()) # 1000
# Форма 2: функция numpy
print(np.sum(revenue)) # 1000 — то же самое
Обе формы дают одинаковый результат. Метод revenue.sum() читается короче, поэтому в коде аналитика встречается чаще. Функцию np.sum(...) удобно использовать, когда данные приходят не массивом, а обычным списком — numpy сам его превратит в массив.
Главное преимущество numpy здесь — скорость и краткость. Чтобы посчитать сумму списка циклом, нужно несколько строк. В numpy это один вызов, и он работает в десятки раз быстрее на больших данных, потому что весь цикл выполняется внутри C-кода, а не в Python. Для аналитика, который гоняет агрегации по миллионам строк, это решающе.
Базовые сводки: sum, mean, std, min, max, median
Это шесть функций, которые покрывают 90% повседневной работы. Разберём каждую на массиве чеков.
import numpy as np
checks = np.array([500, 800, 1200, 600, 900])
print(checks.sum()) # 4000 — суммарная выручка
print(checks.mean()) # 800.0 — средний чек
print(checks.min()) # 500 — минимальный чек
print(checks.max()) # 1200 — максимальный чек
print(np.median(checks)) # 800.0 — медианный чек
print(checks.std()) # стандартное отклонение
Что значит каждая:
sum— сумма всех значений. Выручка, число событий, суммарный объём.mean— среднее арифметическое (сумма делить на количество). Средний чек, средняя конверсия.min/max— минимум и максимум. Худший и лучший день, границы диапазона.median— медиана: значение, которое делит отсортированный массив пополам. Половина значений меньше, половина больше.std— стандартное отклонение: насколько сильно значения разбросаны вокруг среднего. Чем больше std, тем нестабильнее метрика.
Среднее против медианы — важная для аналитика разница. Среднее чувствительно к выбросам, медиана — нет. Посмотрите:
import numpy as np
# у одного клиента гигантский чек
checks = np.array([500, 600, 700, 800, 50000])
print(checks.mean()) # 10520.0 — среднее задрано выбросом
print(np.median(checks)) # 700.0 — медиана честно показывает «типичного» клиента
Один богатый клиент перекосил среднее в 15 раз. Поэтому для зарплат, чеков, времени ответа аналитики почти всегда смотрят медиану, а не среднее — она устойчивее.
Маленький нюанс про std. По умолчанию numpy считает *популяционное* стандартное отклонение (делит на N). В статистике для выборки часто нужно делить на N−1 — это задаётся параметром ddof=1:
import numpy as np
x = np.array([1, 2, 3, 4, 5])
print(x.std()) # 1.414... — делит на N (популяция)
print(x.std(ddof=1)) # 1.581... — делит на N-1 (выборка)
Для новичка достаточно запомнить: если делаете статвыводы по выборке (например, оцениваете разброс метрики в A/B-тесте) — добавляйте ddof=1.
Попробуй сам
Подсказка к решению — раскрыть только если застрял
Запомни главное правило: axis=0 сворачивает строки и даёт итог по каждому СТОЛБЦУ (по месяцам), axis=1 сворачивает столбцы и даёт итог по каждой СТРОКЕ (по каналам). Без axis — сумма всей матрицы в одно число. mean работает с axis так же, как sum.
Решение:
print("По месяцам:", revenue.sum(axis=0))
print("По каналам:", revenue.sum(axis=1))
print("Всего:", revenue.sum())
print("Среднее по каналам:", revenue.mean(axis=1))Главное: axis=0 по столбцам, axis=1 по строкам
До сих пор мы агрегировали одномерный массив — вектор. Но данные аналитика чаще двумерные: строки и столбцы, как таблица. И тут возникает вопрос: а в какую сторону сворачивать? По строкам? По столбцам? Или всё сразу в одно число? За это отвечает параметр axis.
Возьмём матрицу: строки — это каналы трафика, столбцы — месяцы.
import numpy as np
# 2 канала (строки) x 3 месяца (столбцы)
revenue = np.array([
[1, 2, 3], # канал A
[4, 5, 6], # канал B
])
print(revenue.shape) # (2, 3) — 2 строки, 3 столбца
Теперь три варианта суммы:
# Без axis — сворачивает ВСЁ в одно число
print(revenue.sum()) # 21
# axis=0 — сворачивает СТРОКИ, остаются столбцы (сумма по каждому месяцу)
print(revenue.sum(axis=0)) # [5 7 9]
# axis=1 — сворачивает СТОЛБЦЫ, остаются строки (сумма по каждому каналу)
print(revenue.sum(axis=1)) # [6 15]
Разберём, что произошло, потому что это сбивает с толку всех новичков.
axis=0 идёт сверху вниз — по столбцам. Он схлопывает строки. Было 2 строки и 3 столбца, осталось 3 числа — по одному на столбец. [5 7 9] это 1+4, 2+5, 3+6 — сумма каждого месяца по всем каналам. Запомните: axis=0 = «итог по каждому столбцу».
axis=1 идёт слева направо — по строкам. Он схлопывает столбцы. Осталось 2 числа — по одному на строку. [6 15] это 1+2+3, 4+5+6 — сумма каждого канала по всем месяцам. Запомните: axis=1 = «итог по каждой строке».
Правило-подсказка, которое спасает: axis — это номер оси, которая ИСЧЕЗАЕТ. В форме (2, 3) ось 0 — это строки (их 2), ось 1 — столбцы (их 3). Указали axis=0 — пропадает размерность строк, остаётся (3,). Указали axis=1 — пропадает размерность столбцов, остаётся (2,).
import numpy as np
revenue = np.array([[1, 2, 3], [4, 5, 6]])
print(revenue.sum(axis=0).shape) # (3,) — исчезла ось строк
print(revenue.sum(axis=1).shape) # (2,) — исчезла ось столбцов
Это работает не только для sum, но и для mean, std, min, max, median — у всех есть параметр axis с той же логикой:
import numpy as np
revenue = np.array([[1, 2, 3], [4, 5, 6]])
print(revenue.mean(axis=0)) # [2.5 3.5 4.5] — средний месяц по каналам
print(revenue.max(axis=1)) # [3 6] — лучший месяц каждого канала
Если запомнить только одну вещь из всего урока, пусть это будет: axis=0 — вниз по столбцам, axis=1 — вбок по строкам.
axis=0 (вниз по столбцам) vs axis=1 (вбок по строкам)
Матрица revenue, shape (2, 3): строки = каналы, столбцы = месяцы
м1 м2 м3
+-----+-----+-----+
канал A | 1 | 2 | 3 | --. axis=1 (свернуть столбцы
+-----+-----+-----+ | -> итог по СТРОКЕ)
канал B | 4 | 5 | 6 | --. sum(axis=1) = [ 6, 15]
+-----+-----+-----+ | 1+2+3=6 4+5+6=15
| | | v
v v v шкала строк ИСЧЕЗАЕТ -> shape (2,)
axis=0 (свернуть строки -> итог по СТОЛБЦУ)
sum(axis=0) = [ 5, 7, 9 ]
1+4 2+5 3+6
шкала столбцов ИСЧЕЗАЕТ -> shape (3,)
без axis: всё в одно число -> sum() = 21
ПРАВИЛО: axis = номер оси, которая ИСЧЕЗАЕТ.
axis=0 -> вниз по столбцам axis=1 -> вбок по строкам
axis=0 схлопывает строки и оставляет по одному числу на столбец ([5 7 9], сумма каждого месяца), axis=1 схлопывает столбцы и оставляет по числу на строку ([6 15], сумма каждого канала). Стрелки показывают направление свёртки; подпись «ИСЧЕЗАЕТ» — мнемоника «axis — номер оси, которая пропадает».
Где находится экстремум: argmin и argmax
max отвечает на вопрос «какое самое большое значение?». Но аналитику часто нужен другой вопрос — «где находится самое большое значение?». Какой день был лучшим, какой канал худшим, на каком шаге воронки больше всего отвалилось. На это отвечают argmax и argmin — они возвращают не само значение, а его индекс (позицию в массиве).
import numpy as np
# конверсия (%) по дням недели: пн, вт, ср, чт, пт
conv = np.array([4.0, 6.0, 3.0, 7.0, 5.0])
print(conv.max()) # 7.0 — само значение лучшей конверсии
print(conv.argmax()) # 3 — ИНДЕКС лучшего дня (четверг, считая с 0)
print(conv.min()) # 3.0 — худшая конверсия
print(conv.argmin()) # 2 — индекс худшего дня (среда)
argmax() вернул 3 — это позиция максимума, индекс четверга (отсчёт с нуля: пн=0, вт=1, ср=2, чт=3, пт=4). Дальше по этому индексу можно достать что угодно из связанных данных:
import numpy as np
days = np.array(["пн", "вт", "ср", "чт", "пт"])
conv = np.array([4.0, 6.0, 3.0, 7.0, 5.0])
best = conv.argmax()
print("Лучший день:", days[best]) # Лучший день: чт
Это частый паттерн: нашли индекс экстремума в одном массиве, применили его к другому, чтобы достать связанную информацию (название дня, id пользователя, имя канала).
Важный нюанс про ничьи. Если максимум встречается несколько раз, argmax вернёт индекс первого из них:
import numpy as np
a = np.array([10, 50, 30, 50])
print(a.argmax()) # 1 — первый из двух максимумов, не последний
И так же как у остальных функций, у argmax/argmin есть axis — можно искать позицию максимума в каждой строке или каждом столбце матрицы.
Попробуй сам
Подсказка к решению — раскрыть только если застрял
max() и min() возвращают САМО значение, а argmax() и argmin() — его ИНДЕКС (позицию, отсчёт с нуля). Здесь максимум 9 стоит на позиции 4 (пятница), минимум 3 — на позиции 0 (понедельник). Медиана считается через np.median(dau), а среднее и std — методами dau.mean() и dau.std().
Решение:
print("Максимум:", dau.max())
print("Минимум:", dau.min())
print("Индекс максимума:", dau.argmax())
print("Индекс минимума:", dau.argmin())
print("Медиана:", np.median(dau))
print("Среднее:", dau.mean())
print("Std:", dau.std())Перцентили: percentile для латентности и зарплат
Среднее и медиана — это «центр» данных. Но аналитику часто важнее хвосты: какое время ответа у 95% самых медленных запросов, какая зарплата у топ-10% сотрудников. Для этого есть перцентили. Перцентиль p — это значение, ниже которого лежит p процентов данных. Медиана — это просто 50-й перцентиль.
import numpy as np
# время ответа API (мс)
latency = np.array([90, 110, 120, 150, 200, 300, 800])
print(np.percentile(latency, 50)) # 150.0 — медиана (p50)
print(np.percentile(latency, 90)) # p90 — 90% запросов быстрее этого
print(np.percentile(latency, 95)) # p95 — классическая SLA-метрика
Почему перцентили, а не среднее? Потому что среднее время ответа врёт. Если 99 запросов отработали за 100 мс, а один завис на 10 секунд — среднее будет около 200 мс и выглядит прилично. Но p99 покажет 10 секунд — и это реальная боль ваших пользователей. Поэтому в мониторинге всегда смотрят p90/p95/p99, а не среднее.
То же самое с зарплатами и чеками — перцентили показывают распределение честнее среднего:
import numpy as np
salaries = np.array([80, 90, 100, 110, 120, 150, 400]) # тыс. руб.
print(np.percentile(salaries, 25)) # нижний квартиль
print(np.percentile(salaries, 50)) # медиана
print(np.percentile(salaries, 75)) # верхний квартиль
25-й, 50-й и 75-й перцентили называют квартилями — они делят данные на четыре равные части. Можно запросить сразу несколько перцентилей списком:
import numpy as np
latency = np.array([90, 110, 120, 150, 200, 300, 800])
print(np.percentile(latency, [50, 90, 95, 99]))
# вернёт массив из 4 значений — удобно для дашборда
Одно предупреждение про точность: между точками данных numpy интерполирует, поэтому перцентиль может оказаться дробным числом с длинным хвостом (например 499.99999999). Это нормально — для отчёта округлите через round. В песочнице ниже данные подобраны так, чтобы перцентили выходили ровными.
Накопительный итог: cumsum
Все функции выше схлопывали массив до меньшего размера. cumsum — особенная: она возвращает массив той же длины, где каждый элемент это сумма всех предыдущих плюс текущий. Это нарастающий итог — то, что строят на графиках «выручка с начала месяца», «накопленные регистрации», «прогресс к цели».
import numpy as np
# выручка по дням
daily = np.array([100, 200, 150, 300, 250])
print(np.cumsum(daily)) # [ 100 300 450 750 1000]
Разберём результат [100 300 450 750 1000]:
- день 1:
100 - день 2:
100 + 200 = 300 - день 3:
100 + 200 + 150 = 450 - день 4:
+ 300 = 750 - день 5:
+ 250 = 1000
Последний элемент 1000 равен обычной sum() — это итог за весь период. А промежуточные показывают, как сумма росла день за днём. Именно это нужно для линии «накопленная выручка»:
import numpy as np
daily = np.array([100, 200, 150, 300, 250])
cum = np.cumsum(daily)
print("Итог за период:", cum[-1]) # 1000
print("Доля от цели 2000:", cum[-1] / 2000) # 0.5 — выполнили половину плана
У cumsum тоже есть axis для матриц — можно считать нарастающий итог вдоль каждой строки или столбца. И есть родственники: np.cumprod (накопительное произведение, для сложных процентов и роста) и np.cummax (бегущий максимум, для «рекорд на текущий момент»). Логика у всех одна — массив той же длины с накопленным результатом.
На этом базовый набор агрегаций аналитика собран. Этих функций хватит, чтобы посчитать практически любую табличную метрику. Закрепим на практике.
Попробуй сам
Подсказка к решению — раскрыть только если застрял
np.percentile(latency, p) даёт значение, ниже которого лежит p% данных: p50 — это медиана, p100 — максимум. Здесь массив из 11 элементов, поэтому перцентили попадают точно на значения. np.cumsum(daily) даёт массив той же длины с нарастающей суммой; его последний элемент (индекс -1) равен общей сумме за период.
Решение:
print("Медиана (p50):", np.percentile(latency, 50))
print("p90:", np.percentile(latency, 90))
print("Максимум (p100):", np.percentile(latency, 100))
print("Нарастающий итог:", np.cumsum(daily))
print("Итого за период:", np.cumsum(daily)[-1])Главное
- Агрегация сворачивает массив в метрику: sum, mean, std, min, max, median — базовый набор аналитика
- axis=0 сворачивает строки и даёт итог по каждому СТОЛБЦУ; axis=1 сворачивает столбцы и даёт итог по каждой СТРОКЕ. Подсказка: axis — это номер оси, которая исчезает
- Без axis функция сворачивает всю матрицу в одно число
- Среднее (mean) чувствительно к выбросам, медиана (median) — нет; для зарплат, чеков и латентности аналитики смотрят медиану и перцентили
- argmax/argmin возвращают не значение, а ИНДЕКС экстремума; по этому индексу удобно доставать связанные данные (название дня, id). При ничьей берётся первый максимум
- np.percentile(x, p) — для хвостов распределения: p90/p95/p99 для латентности и SLA честнее среднего
- np.cumsum даёт нарастающий итог — массив той же длины; его последний элемент равен обычной сумме
- std по умолчанию популяционное (делит на N); для выборки в статвыводах добавляйте ddof=1
Частые вопросы
Что означают axis=0 и axis=1 в numpy?
axis — это ось, вдоль которой numpy «сворачивает» массив. Для 2D-массива axis=0 схлопывает строки и считает по каждому столбцу (результат — вектор длиной в число столбцов), а axis=1 схлопывает столбцы и считает по каждой строке. Проще запомнить так: указанная ось исчезает из формы результата. Потренироваться на живых данных можно в Python-тренажёре.
Почему np.mean без axis возвращает одно число?
Если не передать axis, агрегация идёт по всему массиву сразу и схлопывает его в скаляр. arr.mean() даст среднее по всем элементам, arr.mean(axis=0) — среднее по столбцам, arr.mean(axis=1) — по строкам. Это самая частая ошибка новичков: забыли ось и получили одно число вместо вектора.
Как найти не само максимальное значение, а его индекс?
Для значения используют max/min, а для позиции — argmax/argmin. Например, np.argmax(sales) вернёт индекс дня с пиком продаж, а np.argmin — с минимумом. С axis они работают тоже: argmax(axis=1) даст индекс максимума в каждой строке.
Чем percentile отличается от median в numpy?
Медиана — это частный случай перцентиля: np.median(x) равно np.percentile(x, 50). percentile гибче — можно взять любой уровень: np.percentile(x, 95) для 95-го перцентиля времени ответа, np.percentile(x, [25, 50, 75]) сразу для квартилей. Перцентили устойчивее к выбросам, чем среднее, поэтому их часто берут для latency и чеков.
Что делает cumsum и чем отличается от обычного sum?
sum возвращает итоговую сумму, а cumsum — накопительную: массив той же длины, где каждый элемент это сумма всех предыдущих. np.cumsum([10, 20, 30]) даст [10, 30, 60]. Удобно для нарастающего итога выручки, кумулятивного охвата когорты или построения ступенчатых графиков.