Агрегации: sum, mean, std с axis и keepdims

Урок объясняет агрегации — свёртку массива в одно число или вдоль выбранной оси — и важнейший для них параметр axis.

Агрегация — операция, сворачивающая множество значений в одно (сумма, среднее, минимум); параметр axis задаёт, вдоль какой оси выполнять свёртку.

Агрегация всего массива

Без указания оси агрегирующая функция сворачивает весь массив в скаляр. a.sum() — сумма всех элементов, a.mean() — среднее, a.max() — максимум, и так далее. Эти методы есть и как методы массива (a.sum()), и как функции (np.sum(a)) — они эквивалентны.

import numpy as np
a = np.array([[1, 2, 3],
              [4, 5, 6]])

print(a.sum())     # 21 — всё
print(a.mean())    # 3.5
print(a.min())     # 1
print(a.max())     # 6
print(a.std())     # стандартное отклонение всех элементов

Вывод:

21
3.5
1
6
1.707825127659933

Параметр axis: сворачиваем вдоль оси

Самое важное и поначалу контринтуитивное. axis указывает, какую ось убрать (свернуть). Для матрицы (строки × столбцы):

  • axis=0 сворачивает строки — операция идёт вниз по столбцам, результат имеет длину числа столбцов. «Сумма по столбцам».
  • axis=1 сворачивает столбцы — операция идёт вдоль строк, результат имеет длину числа строк. «Сумма по строкам».

Запомнить помогает мысль: «axis=0 убирает ось 0 (строки), значит результат — по столбцам». Размер свёрнутой оси исчезает из формы результата.

import numpy as np
a = np.array([[1, 2, 3],
              [4, 5, 6]])     # форма (2, 3)

print(a.sum(axis=0))    # вниз по столбцам: [1+4, 2+5, 3+6] = [5, 7, 9]
print(a.sum(axis=1))    # вдоль строк: [1+2+3, 4+5+6] = [6, 15]
print(a.sum(axis=0).shape)   # (3,) — ушла ось 0
print(a.sum(axis=1).shape)   # (2,) — ушла ось 1

Вывод:

[5 7 9]
[ 6 15]
(3,)
(2,)

Прочувствуем axis на чистом Python — это просто выбор направления суммирования:

a = [[1, 2, 3],
     [4, 5, 6]]

# axis=0: суммируем по столбцам (вниз)
by_cols = [sum(a[r][c] for r in range(2)) for c in range(3)]
# axis=1: суммируем по строкам (вдоль)
by_rows = [sum(row) for row in a]

print("axis=0 (по столбцам):", by_cols)
print("axis=1 (по строкам): ", by_rows)

Вывод:

axis=0 (по столбцам): [5, 7, 9]
axis=1 (по строкам):  [6, 15]

Методы массива против функций модуля

Почти у каждой агрегации есть две формы: метод массива (a.sum()) и функция модуля (np.sum(a)). Для базового применения они эквивалентны и взаимозаменяемы — выбирайте по вкусу и стилю кода. Но между ними есть и различия, которые иногда важны. Функции модуля чаще «терпимее» к входу: np.sum примет и список Python, и кортеж, неявно превратив их в массив, тогда как метод существует только у настоящих ndarray. Кроме того, некоторые операции (например, np.median, np.unique) есть только как функции модуля, без метода. Общий совет: для повседневной агрегации массива удобнее цепочка методов (data.mean(), data.std()) — она читается слева направо как «у данных взять среднее». Для функций, у которых нет метода, и для обработки разнородных входов берите форму np.функция(...). Зная, что это по сути одно и то же, вы не запутаетесь, встретив в чужом коде оба стиля.

keepdims: сохранить размерность для broadcasting

После агрегации по оси эта ось исчезает: (2, 3) с axis=1 даёт (2,). Но часто результат нужно использовать обратно с исходным массивом — например, вычесть среднее каждой строки. Тогда формы (2, 3) и (2,) не сложатся по правилам broadcasting (выравнивание справа: 3 и 2). Параметр keepdims=True оставляет свёрнутую ось длиной 1: (2, 3) → (2, 1), и тогда broadcasting срабатывает.

import numpy as np
a = np.array([[1, 2, 3],
              [4, 5, 6]])

row_mean = a.mean(axis=1)                 # форма (2,)
row_mean_kd = a.mean(axis=1, keepdims=True)  # форма (2, 1)

print(row_mean.shape, row_mean_kd.shape)
# Центрирование строк: вычесть среднее каждой строки
print(a - row_mean_kd)    # работает за счёт keepdims

Вывод:

(2,) (2, 1)
[[-1.  0.  1.]
 [-1.  0.  1.]]

Без keepdims=True выражение a - a.mean(axis=1) упало бы с ошибкой broadcasting. Это один из самых частых практических поводов использовать keepdims: нормировка и центрирование по строкам или столбцам.

axis в трёх и более измерениях

В 2D правило «axis=0 убирает строки, axis=1 убирает столбцы» запоминается быстро. В N измерениях держитесь общего принципа: axis=k сворачивает k-ю ось, и именно она исчезает из формы результата. Для трёхмерного массива формы (партии, строки, столбцы) сумма с axis=0 даст (строки, столбцы) — сложатся соответствующие элементы всех партий; axis=2 даст (партии, строки) — свернутся столбцы внутри каждой строки. Можно сворачивать и несколько осей сразу, передав кортеж: a.sum(axis=(0, 1)) уберёт обе первые оси. Универсальная проверка себя: возьмите форму, мысленно вычеркните из неё оси, перечисленные в axis — то, что осталось, и есть форма результата (с keepdims=True вычеркнутые оси превращаются в единицы, а не исчезают). Этот приём избавляет от угадывания и работает в любом числе измерений.

argmin и argmax: позиция, а не значение

Иногда нужно не само минимальное значение, а его позиция. Это дают argmin и argmax. По умолчанию они работают с «расплющенным» массивом и возвращают плоский индекс; с axis — индексы вдоль оси.

import numpy as np
a = np.array([[3, 7, 1],
              [9, 2, 5]])

print(a.argmax())          # 3 — плоский индекс максимума (значение 9)
print(a.argmax(axis=0))    # индекс макс. в каждом столбце: [1, 0, 1]
print(a.argmin(axis=1))    # индекс мин. в каждой строке: [2, 1]

Вывод:

3
[1 0 1]
[2 1]

argmax/argmin незаменимы, когда по массиву значений нужно найти «где», а не «сколько» — например, индекс лучшего варианта, метку класса с максимальной вероятностью и т. п.

cumsum и накопительные агрегации

Помимо «схлопывающих» агрегаций (sum, mean), которые дают одно число на ось, есть накопительные: cumsum (нарастающая сумма) и cumprod (нарастающее произведение). Они не уменьшают размер массива, а возвращают массив той же длины, где каждый элемент — это агрегат всех предыдущих. Нарастающая сумma [1, 2, 3, 4] даёт [1, 3, 6, 10]. Это незаменимо для временных рядов: накопленная выручка по дням, текущий баланс после каждой транзакции, пройденный путь по скоростям. На первый взгляд такая задача выглядит «последовательной» (каждый шаг зависит от предыдущего), и хочется написать цикл, — но NumPy предоставляет векторную форму, которая считается в скомпилированном коде. Это важный пример того, что даже кажущиеся последовательными вычисления часто имеют готовое векторное решение. К теме переписывания циклов мы вернёмся в финальном разделе, но cumsum/cumprod стоит запомнить уже сейчас как частый инструмент.

std и var: смещённая или несмещённая оценка

Стандартное отклонение (std) и дисперсия (var) по умолчанию считаются с делением на N (параметр ddof=0). Если вам нужна несмещённая выборочная оценка с делением на N−1 (как в статистике для выборки), задайте ddof=1. Это тонкость, на которой часто спотыкаются: NumPy по умолчанию даёт «популяционную» дисперсию, а многие ожидают «выборочную».

import numpy as np
a = np.array([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])

print(a.var())          # ddof=0: деление на N
print(a.var(ddof=1))    # ddof=1: деление на N-1 (выборочная)
print(a.std())          # корень из var(ddof=0)

Вывод:

4.0
4.571428571428571
2.0

Зачем агрегации так важны в анализе данных

Агрегации — это мост от «сырых данных» к «выводам». Любой отчёт, любая сводка, любая метрика модели — это в основе своей агрегация: средний чек, максимальная нагрузка, доля ошибок, суммарная выручка по регионам. Умение свернуть массив вдоль нужной оси — это умение задать данным правильный вопрос. Причём axis здесь несёт смысл: для таблицы «дни × товары» сумма по axis=0 — это «итог по каждому товару за все дни», а по axis=1 — «итог по каждому дню по всем товарам». Один и тот же массив, два разных бизнес-вопроса, и различает их только выбор оси. Поэтому ошибка в axis — это не просто неверное число, а ответ не на тот вопрос. Привыкайте проговаривать смысл оси, по которой сворачиваете: «я суммирую через дни, получая итог по товарам». Эта дисциплина почти исключает путаницу и делает код самодокументируемым.

Таблица агрегаций

ФункцияЧто считает
sum, prodсумма, произведение
mean, medianсреднее, медиана
std, varстд. отклонение, дисперсия
min, maxминимум, максимум значения
argmin, argmaxпозиция минимума/максимума
cumsum, cumprodнакопленные сумма/произведение

Подводные камни

  • Путать смысл axis. axis=0 сворачивает строки (результат по столбцам). Многие думают наоборот.
  • Забыть keepdims при нормировке. a - a.mean(axis=1) упадёт; нужно keepdims=True.
  • Ожидать выборочную дисперсию по умолчанию. NumPy делит на N (ddof=0), для N−1 ставьте ddof=1.
  • nan в данных. Обычный sum/mean с nan даст nan. Для пропусков есть np.nansum, np.nanmean.

Пропуски, переполнение и стабильность агрегаций

Несколько практических предостережений, без которых агрегации иногда дают неверные числа. Первое — пропуски: если в данных есть nan, обычные sum/mean/max вернут nan, потому что любая операция с nan даёт nan. Для данных с пропусками существуют устойчивые версии — np.nansum, np.nanmean, np.nanmax, — которые игнорируют nan. Второе — переполнение: сумма большого массива узких целых (int8, int16) может переполнить тип. NumPy для некоторых агрегаций повышает внутренний тип накопления, но не всегда; при риске указывайте dtype аккумулятора явно, например a.sum(dtype=np.int64). Третье — точность float: суммирование миллионов чисел в float32 накапливает ошибку округления, поэтому средние и суммы по большим массивам надёжнее считать в float64 (многие функции NumPy так и делают по умолчанию). Эти три момента — пропуски, переполнение, точность — стоит держать в голове всякий раз, когда агрегация даёт подозрительный результат.

Лучшие практики

  • Всегда явно указывайте axis, когда сворачиваете часть многомерного массива.
  • Для последующей нормировки/центрирования используйте keepdims=True.
  • Решите осознанно, нужна ли выборочная дисперсия (ddof=1) или популяционная (по умолчанию).
  • При наличии пропусков берите nan-версии агрегаций.

Итог

  • Без axis агрегация сворачивает весь массив в скаляр.
  • axis=0 сворачивает строки (результат по столбцам), axis=1 — столбцы (по строкам).
  • keepdims=True сохраняет свёрнутую ось длиной 1 для дальнейшего broadcasting.
  • argmin/argmax дают позицию, а ddof=1 переключает std/var на выборочную оценку.
Проверьте себя
1. Что вернёт a.sum(axis=0) для матрицы a формы (2, 3)?
AСумму всех элементов (одно число)
BСуммы по строкам — массив длины 2
CСуммы по столбцам — массив длины 3 (ось 0 сворачивается)
DОшибку, axis должен быть 1
2. Зачем нужен keepdims=True при вычислении a.mean(axis=1)?
AЧтобы ускорить вычисление среднего
BЧтобы сохранить свёрнутую ось длиной 1, и результат можно было использовать в broadcasting с исходным массивом
CЧтобы получить медиану вместо среднего
DЧтобы среднее считалось по столбцам, а не по строкам
3. Чем отличается a.var() от a.var(ddof=1) в NumPy?
AНичем, это синонимы
Bvar() делит на N (популяционная дисперсия), а var(ddof=1) делит на N−1 (несмещённая выборочная оценка)
Cvar() возвращает стандартное отклонение, а var(ddof=1) — дисперсию
Dvar(ddof=1) игнорирует значения nan
Поддержать проект