Обзор основных функций

В этом разделе мы рассмотрим основные функции Pandas, которые могут быть полезны при повседневной работе с данной библиотекой, но сначала как обычно выполним все необходимые импорты:

import numpy as np
import pandas as pd

Бинарные операции

Датафреймы и серии имеют множество методов для выполнения бинарных операций (add, sub, div и т.д.), которые позволяют определить, то как должны транслироваться одномерные объекты (например серии): по столбцам или по строкам датафреймов. Давайте сначала создадим датафрейм:

idx = list('abcde')
cols = ['col_' + str(i) for i in range(4)]
df = pd.DataFrame(np.arange(20).reshape(5, 4),
                  index=idx,
                  columns=cols)
df
col_0 col_1 col_2 col_3
a 0 1 2 3
b 4 5 6 7
c 8 9 10 11
d 12 13 14 15
e 16 17 18 19

А теперь создадим два одномерных массива:

a = np.arange(12, 16)
b = np.arange(2, 19, 4)
a, b
(array([12, 13, 14, 15]), array([ 2,  6, 10, 14, 18]))

По умолчанию, транслирование выполняется вдоль столбцов, при этом длина одномерного массива должна быть равна количеству столбцов датафрейма:

df.sub(a)   # эквивалентно команде df.sub(a, axis='columns')
            # или df.sub(a, axis=1)
col_0 col_1 col_2 col_3
a -12 -12 -12 -12
b -8 -8 -8 -8
c -4 -4 -4 -4
d 0 0 0 0
e 4 4 4 4

Если длина не совпадает с количеством столбцов, то мы увидим ошибку:

df.sub(b)
ValueError: Unable to coerce to Series, length must be 4: given 5

Что бы выполнить транслирование вдоль строк, необходимо что бы длина массива была равна количеству строк датафрейма:

df.sub(b, axis='index')    # эквивалентно df.sub(b, axis=0)
col_0 col_1 col_2 col_3
a -2 -1 0 1
b -2 -1 0 1
c -2 -1 0 1
d -2 -1 0 1
e -2 -1 0 1

То же самое произойдет, если вместо одномерных массивов, мы будем использовать серии:

row = pd.Series(range(12, 16),
                index=cols)
row
col_0    12
col_1    13
col_2    14
col_3    15
dtype: int64
# транслирование серии по столбцам:
df.sub(row)
col_0 col_1 col_2 col_3
a -12 -12 -12 -12
b -8 -8 -8 -8
c -4 -4 -4 -4
d 0 0 0 0
e 4 4 4 4
column = pd.Series(range(2, 19, 4),
                   index=idx)
column
a     2
b     6
c    10
d    14
e    18
dtype: int64
# транслирование серии по строкам:
df.sub(column, axis=0)
col_0 col_1 col_2 col_3
a -2 -1 0 1
b -2 -1 0 1
c -2 -1 0 1
d -2 -1 0 1
e -2 -1 0 1

Но серии в отличие от массивов, вовсе не обязаны соответствовать правилам совместимости принятым в NumPy, т.е. длины серий не обязаны совпадать ни с количеством строк ни с количеством столбцов в датафреймах.

row = pd.Series([12, 15, 100], 
                index=['col_0', 'col_3', 'col_4'])
row
col_0     12
col_3     15
col_4    100
dtype: int64
df.sub(row)
col_0 col_1 col_2 col_3 col_4
a -12.0 NaN NaN -12.0 NaN
b -8.0 NaN NaN -8.0 NaN
c -4.0 NaN NaN -4.0 NaN
d 0.0 NaN NaN 0.0 NaN
e 4.0 NaN NaN 4.0 NaN

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

column = pd.Series(range(1, 34, 8),
                   index=list('aceXY'))
column
a     1
c     9
e    17
X    25
Y    33
dtype: int64
df.sub(column, axis='index')
col_0 col_1 col_2 col_3
X NaN NaN NaN NaN
Y NaN NaN NaN NaN
a -1.0 0.0 1.0 2.0
b NaN NaN NaN NaN
c -1.0 0.0 1.0 2.0
d NaN NaN NaN NaN
e -1.0 0.0 1.0 2.0

Серии и индексы (объекты Index) поддерживают встроенную в Python функцию divmod, которая возвращает результат целочисленного деления и остатки от деления:

s = pd.Series(range(7))
s
0    0
1    1
2    2
3    3
4    4
5    5
6    6
dtype: int64
d, m = divmod(s, 2)
d
0    0
1    0
2    1
3    1
4    2
5    2
6    3
dtype: int64
m
0    0
1    1
2    0
3    1
4    0
5    1
6    0
dtype: int64
idx = pd.Index(range(7))
idx
RangeIndex(start=0, stop=7, step=1)
d, m = divmod(idx, 2)
d, m
(Int64Index([0, 0, 1, 1, 2, 2, 3], dtype='int64'),
 Int64Index([0, 1, 0, 1, 0, 1, 0], dtype='int64'))
m
Int64Index([0, 1, 0, 1, 0, 1, 0], dtype='int64')

Причем, возвращаемый результат всегда имеет тот же тип, что и первый входной аргумент divmod(). При этом есть возможность поэлементного выполнения данной функции в том случае, если второй аргумент, является последовательностью чисел той же длины:

d, m = divmod(idx, [2, 2, 2, 2, 4, 4, 4])
d, m
(Int64Index([0, 0, 1, 1, 1, 1, 1], dtype='int64'),
 Int64Index([0, 1, 0, 1, 0, 1, 2], dtype='int64'))

Бинарные операции с отсутствующими данными

Все методы серий и датафреймов для выполнения бинарных арифметических операций содержат аргумент fill_value, который позволяет заменять отсутствующие элементы на указанные значения. Но нужно иметь ввиду, что NaN-ы заменяются только в том случае, если соответствующие элементы не равны NaN одновременно:

a = np.random.choice([11, 33, np.nan], size=(5, 4))
b = np.random.choice([22, 44, np.nan], size=(5, 4))
cols = ['col_' + str(i) for i in range(1, 5)]

df_1 = pd.DataFrame(a, columns=cols)
df_1
col_1 col_2 col_3 col_4
0 11.0 NaN 11.0 11.0
1 NaN 11.0 11.0 NaN
2 11.0 33.0 NaN 33.0
3 NaN 33.0 11.0 11.0
4 NaN NaN 33.0 NaN
df_2 = pd.DataFrame(b, columns=cols)
df_2
col_1 col_2 col_3 col_4
0 NaN 44.0 22.0 22.0
1 22.0 NaN NaN 22.0
2 22.0 44.0 NaN NaN
3 44.0 44.0 44.0 44.0
4 44.0 NaN 44.0 22.0
df_1.add(df_2, fill_value=9999)
col_1 col_2 col_3 col_4
0 10010.0 10043.0 33.0 33.0
1 10021.0 10010.0 10010.0 10021.0
2 33.0 77.0 NaN 10032.0
3 10043.0 77.0 55.0 55.0
4 10043.0 NaN 77.0 10021.0

Дальнейшее заменение NaN-ов можно выполнить с помощью метода fillna(), например так:

s = df_1.add(df_2, fill_value=9999)
s.fillna(-111)
col_1 col_2 col_3 col_4
0 10010.0 10043.0 33.0 33.0
1 10021.0 10010.0 10010.0 10021.0
2 33.0 77.0 -111.0 10032.0
3 10043.0 77.0 55.0 55.0
4 10043.0 -111.0 77.0 10021.0

Сравнение значений

Еще один тип полезных бинарных операций - это операции сравнения, для них есть следующие методы:

  • eq() - equal (равно) ==;
  • ne() - not equal (не равно) !=;
  • le() - less than or equal (меньше или равно) <=;
  • lt() - less than (меньше чем) <;
  • ge() - greater than or equal (больше или равно) >=;
  • gt() - greater than (больше) >;
df_1 = pd.DataFrame(np.random.randint(0, 5, (5, 5)))
df_1
0 1 2 3 4
0 3 0 0 1 2
1 2 4 2 4 0
2 2 0 4 3 0
3 1 2 2 4 1
4 0 0 4 0 4
df_2 = pd.DataFrame(np.random.randint(2, 7, (5, 5)))
df_2
0 1 2 3 4
0 4 4 6 4 4
1 5 6 5 4 6
2 5 6 5 5 3
3 6 4 2 3 3
4 4 2 6 6 2
# какие элементы датафреймов имеют 
# одинаковые значения?
df_1.eq(df_2)
0 1 2 3 4
0 False False False False False
1 False False False True False
2 False False False False False
3 False False True False False
4 False False False False False
# какие элементы df_1 меньше чем
# соответствующие элементы df_2?
df_1.lt(df_2)
0 1 2 3 4
0 True True True True True
1 True True True False True
2 True True True True True
3 True True False False True
4 True True True True False

Конечно, вместо данных методов всегда можно воспользоваться операторами сравнения:

df_1 < df_2
0 1 2 3 4
0 True True True True True
1 True True True False True
2 True True True True True
3 True True False False True
4 True True True True False

Но не забывайте, что данные методы поддерживают механизм транслирования, как по строкам так и по столбцам:

s = pd.Series([3, 4, 10, 4, 3])
s
0     3
1     4
2    10
3     4
4     3
dtype: int64
# транслирование сравнения по строкам:
df_1.lt(s, axis=0)
0 1 2 3 4
0 False True True True True
1 True False True False True
2 True True True True True
3 True True True False True
4 True True False True False
# транслирование сравнения по столбцам:
df_1.lt(s, axis=1)    # эквивалентно df_1 < s
0 1 2 3 4
0 False True True True True
1 True False True False True
2 True True True True True
3 True True True False True
4 True True True True False

Возвращаемые объекты могут использоваться для булевой индексации (как логические маски), благодаря чему в результирующий массив попадают только те элементы, которым в булевом массиве соответствует значение True, а те которым соответствует False, заменяются на NaN:

df_2[df_1 < s]
0 1 2 3 4
0 NaN 4.0 6 4.0 4.0
1 5.0 NaN 5 NaN 6.0
2 5.0 6.0 5 5.0 3.0
3 6.0 4.0 2 NaN 3.0
4 4.0 2.0 6 6.0 NaN

Методы any() и all() позволяют выполнять логические операции ИЛИ и И вдоль столбцов датафреймов с булевыми значениями:

df_1.index = list('abcde')
df_1.columns = ['col_' + str(i) for i in range(5)]
df_1
col_0 col_1 col_2 col_3 col_4
a 3 0 0 1 2
b 2 4 2 4 0
c 2 0 4 3 0
d 1 2 2 4 1
e 0 0 4 0 4
s.index = ['col_' + str(i) for i in range(5)]
(df_1 < s).all()
col_0    False
col_1    False
col_2     True
col_3    False
col_4    False
dtype: bool
(df_1 < 1).any()
col_0    True
col_1    True
col_2    True
col_3    True
col_4    True
dtype: bool

Применительно к сериям методы any() и all() работают точно так же как и аналогичные функции в Python:

(s < 5).any()
True

Именно поэтому, они могут применяться последовательно к датафреймам:

(df_1 < s).all().all()
False

Что бы убедиться в том, что датафрейм или серия не является пустой, можно воспользоваться атрибутом empty:

# Пустая серия:
s = pd.Series()
s
Series([], dtype: float64)
s.empty
True
# Пустой датафрейм:
df = pd.DataFrame(columns=list('abc'))
df
a b c
df.empty
True

Оценить одноэлементные объекты с логическими значениями, можно воспользоваться методом bool():

pd.Series([True]).bool()
True
pd.DataFrame([[False]]).bool()
False

Датафреймы и серии с логическими значениями не могут учавствовать в логических опреациях:

s1 = pd.Series([False, True, False])
s2 = pd.Series([False, True, True])
# например вот эта команда вернет ошибку:
s1 and s2
ValueError: The truth value of a Series is ambiguous.
Use a.empty, a.bool(), a.item(), a.any() or a.all().

После нижеприведенной конструкции интерпретатор тоже выдаст ошибку:

if s1:
    pass
ValueError: The truth value of a Series is ambiguous.
Use a.empty, a.bool(), a.item(), a.any() or a.all().

Однако, подобные операции очень необходимы. Здесь главное вспомнить что False интерпретируется как 0, а True как 1, а это значит что логические операции могут быть заменены на побитовые. Звучит странно, но это работает:

s1 & s2   # логическое and
0    False
1     True
2    False
dtype: bool
~s1       # логическое not
0     True
1    False
2     True
dtype: bool
# не забывайте ставить скобки
(s1 | s2) & (s1 | s2)     # "|" - логическое or
0    False
1     True
2     True
dtype: bool

А конструкции типа if s1: или while s1: можно заменить, например, на if s1.all():, while s2.empty() или while s1.any():.


Проверка на эквивалентность

Иногда нужно убедиться в том, что две серии или два датафрейма состоят из элементов с одинаковыми значениями. Например, у нас есть вот такой датафрейм:

df = pd.DataFrame(np.arange(16, dtype=np.float).reshape(4, 4),
                  index=list('abcd'),
                  columns=['col_' + str(i) for i in range(1, 5)])

df
col_1 col_2 col_3 col_4
a 0.0 1.0 2.0 3.0
b 4.0 5.0 6.0 7.0
c 8.0 9.0 10.0 11.0
d 12.0 13.0 14.0 15.0

Разумеется результаты выражений 3*df и df + df + df вернут абсолютно одинаковые датафреймы, в чем легко убедиться:

3*df == df + df + df
col_1 col_2 col_3 col_4
a True True True True
b True True True True
c True True True True
d True True True True

Но если в исходных датафреймах (или сериях) есть хоть один элемент с значением NaN, то результат будет неожиданным:

df['col_4'][0] = np.nan
df
col_1 col_2 col_3 col_4
a 0.0 1.0 2.0 NaN
b 4.0 5.0 6.0 7.0
c 8.0 9.0 10.0 11.0
d 12.0 13.0 14.0 15.0
3*df == df + df + df
col_1 col_2 col_3 col_4
a True True True False
b True True True True
c True True True True
d True True True True

Дело в том, что NaN не равно NaN:

np.nan == np.nan
False

Но мы ведь знаем что датафреймы равны! Что бы выполнить, абсолютно корректное с нашей точки зрения сравнение, можно воспользоваться методом equals(), который так же как и мы воспринимает значения NaN на одинаковых позициях, как равные:

(3*df).equals(df + df + df)
True

Но имейте в виду, что индексы должны иметь одинаковый порядок, так как их автоматическое выравнивание не выполняется:

df_2 = df[::-1].copy()
df_2
col_1 col_2 col_3 col_4
d 12.0 13.0 14.0 15.0
c 8.0 9.0 10.0 11.0
b 4.0 5.0 6.0 7.0
a 0.0 1.0 2.0 NaN
# мы думаем, что индексы будут выровнены,
# но нет:
(df).equals(df_2)
False
# а вот если их выровнять вручную,
# то все получится:
(df).equals(df_2.sort_index())
True

Сравнение массиво-подобных объектов

Выполнить сравнение объекта Pandas с каким-нибудь скалярным значением - это проще простого:

s = pd.Series(np.random.randint(0, 2, 10))
s
0    0
1    0
2    1
3    1
4    1
5    1
6    1
7    0
8    0
9    0
dtype: int32
s == 0
0     True
1     True
2    False
3    False
4    False
5    False
6    False
7     True
8     True
9     True
dtype: bool
idx = pd.Index(list('abaabba'))
idx
Index(['a', 'b', 'a', 'a', 'b', 'b', 'a'], dtype='object')
idx == 'b'
array([False,  True, False, False,  True,  True, False])

Точно так же выполняется сравнение между массиво-подобными объектами одинаковой длины:

pd.Series(list('ababaa')) == pd.Index(list('aabbaa'))
0     True
1    False
2    False
3     True
4     True
5     True
dtype: bool
pd.Series(list('ababaa')) == np.array(list('abaaaa'))
0     True
1     True
2     True
3    False
4     True
5     True
dtype: bool

Но если попробовать сравнить два объекта разной длины, то мы увидим ошибку:

pd.Series(list('ababaa')) == pd.Series(['a'])
ValueError: Can only compare identically-labeled Series objects
pd.Series(list('ababaa')) == pd.Index(['a', 'b', 'a'])
ValueError: ('Lengths must match to compare', (6,), (3,))

Это очень сильно отличается от поведения массивов NumPy в котором операция сравнения может транслироваться:

np.array([2, 3, 5, 2]) < np.array([3])
array([ True, False, False,  True])

Или просто вернуть False, если транслирование не может быть выполнено:

np.array([2, 3, 5, 2]) == np.array([3, 5])
DeprecationWarning: elementwise comparison failed; this will raise an error in the future.
                    
False

Объединение перекрывающихся наборов данных

Иногда появляется необходимость комбинировать несколько наборов данных, при котором имеющиеся значения одного набора могли бы заменять соответствующие отсутствующие значения другого набора. Например у нас есть две серии:

s1 = pd.Series([0, np.nan, 2, 3, np.nan])
s2 = pd.Series([np.nan, 1, 2, 3, np.nan])

Мы видим, что некоторые пропущенные значения в одной серии, могут быть заменены на имеющиеся значения из другой, а значит было бы круто объединить эти эначения вместе. Это можно сделать с помощью метода combine_first():

s1.combine_first(s2)
0    0.0
1    1.0
2    2.0
3    3.0
4    NaN
dtype: float64

В итоге осталось всего одно значение NaN, которое отсутствует в обеих сериях. Однако, данный метод несколько интереснее чем может показаться. Допустим, в одной серии хранятся свежеполученные данные, но этих данных мало и некоторые из них являются пропущенными, а в другой серии, хранятся не совсем актуальные, но все же полезные данные. Мы конечно можем заменять NaN-ы на среднее или медианное значение, но иногда гораздо целесообразнее заменять их на соответствующие значения из предыдущих выборок. Итак, допустим первый набор данных выглядит так:

data_1 = pd.Series([0, 1, np.nan, 3, 4, np.nan])

А другой, вот так:

data_2 = pd.Series([1, np.nan, 3, np.nan, 9, 5, 4, 1])

Тогда объединить два набора можно так:

data_1.combine_first(data_2)
0    0.0
1    1.0
2    3.0
3    3.0
4    4.0
5    5.0
6    4.0
7    1.0
dtype: float64

Заметьте, что в первом наборе данных NaN-ы были заменены на соответствующие значения из второго набора, а так же к нему в "хвост" так же добавились данные из второго набора. Иногда такое добавление данных в конец является нежелательным и поэтому в результат можно включить только срез необходимой длины:

data_1.combine_first(data_2)[:len(data_1)]
0    0.0
1    1.0
2    3.0
3    3.0
4    4.0
5    5.0
dtype: float64

Данный метод, так же подходит и для датафреймов, но на самом деле данный метод вызывает более общий - DataFrame.combine(), который позволяет задавать собственные функции, определяющие, то как должно выполняться объединение.


Методы описательной статистики

В Pandas существует множества функций для вычисления описательных статистик данных, хранящихся в сериях и датафреймах. Многие из этих функций являются агрегирующими и выдают результат в виде серий и датафреймов меньших размеров (например sum(), mean()), другие (например cumsum() и cumprod()) и выдают результат того же размера.

Все эти методы принимают аргумент axis который позволяет задать ось вдоль которой должна выполняться функция. Для серий данный аргумент не имеет никакого смысла, так как они являются одномерными, а для датафреймов он может быть равен одному из следующих значений:

  • 'index' (т.е. axis = 'index' или что тоже самое axis = 0) - что соответствует вычислениям по столбцам (данное значение установлено по умолчанию);
  • 'columns' (т.е. axis = 'columns' или что тоже самое axis = 1)- соответствует вычислениям вдоль строк.
s = pd.Series(range(10))
s.sum(), s.mean()
(45, 4.5)
df = pd.DataFrame(np.arange(16, dtype=np.float).reshape(4, 4),
                  index=list('abcd'),
                  columns=['col_' + str(i) for i in range(4)])
df
col_0 col_1 col_2 col_3
a 0.0 1.0 2.0 3.0
b 4.0 5.0 6.0 7.0
c 8.0 9.0 10.0 11.0
d 12.0 13.0 14.0 15.0
# сумма по столбцам:
df.sum()   # axis='index' по умолчанию
col_0    24
col_1    28
col_2    32
col_3    36
dtype: int64
# сумма по строкам:
df.sum(axis='columns')
a     6
b    22
c    38
d    54
dtype: int64

У всех этих методов есть аргумент skipna позволяющий исключать отсутствующие значения:

df['col_3'][0] = np.nan
df
col_0 col_1 col_2 col_3
a 0.0 1.0 2.0 NaN
b 4.0 5.0 6.0 7.0
c 8.0 9.0 10.0 11.0
d 12.0 13.0 14.0 15.0
df.sum(axis=1)
a     3.0
b    22.0
c    38.0
d    54.0
dtype: float64
df.sum(axis=1, skipna=False)
a     NaN
b    22.0
c    38.0
d    54.0
dtype: float64

Учитывая, что арифметические операции транслируются, то данные методы могут быть использованы в математических выражениях:

((df - df.mean()) / df.std()).std()
col_0    1.0
col_1    1.0
col_2    1.0
col_3    1.0
dtype: float64

Аккумулирующие функции сохраняют размеры датафреймов и серий, но если в них содержатся NaN-ы то их расположение так же сохраняется:

pd.Series([1, 2, np.nan, 4, 5, np.nan, 6]).cumsum()
0     1.0
1     3.0
2     NaN
3     7.0
4    12.0
5     NaN
6    18.0
dtype: float64
df.cumsum()
col_0 col_1 col_2 col_3
a 0.0 1.0 2.0 NaN
b 4.0 6.0 8.0 7.0
c 12.0 15.0 18.0 18.0
d 24.0 28.0 32.0 33.0

Так же обратите внимание на то, что некоторые аналогичные методы NumPy могут игнорировать значения NaN в сериях:

np.sum(df['col_3'])
33.0
np.sum(df['col_3'].to_numpy())
nan
np.nansum(df['col_3'].to_numpy())
33.0

Каждый такой метод принимает аргумент level, который нужен при работе с иерархическими индексами, но подробнее этот нюанс будет рассмотрен на странице по мультииндексу.

Вот краткая информация по основным функция описательной статистики:

  • count - количество значений не равных NaN;;
  • sum - сумма значений элементов;
  • mean - среднее значение элементов;;
  • mad - среднее абсолютных значений элементов;;
  • median - медиана значений элементов (половина значений меньше медианы, другая половина больше);;
  • min - минимальное значение;;
  • max - максимальное значение;;
  • mode - наиболее часто-встречающееся (типичное) значение;
  • abs - абсолютное значение элементов;;
  • prod - произведение значений;
  • std - стандартное отклонение с поправкой Бесселя (используется n - 1 вместо n);
  • var - несмещенная (исправленная) дисперсия;
  • sem - стандартная ошибка среднего;
  • skew - асимметрия распределения выборки (третий момент);
  • kurt - куртозис (коэфициент эксцеса) распределения выборки, характеризует остроту вершин распределения выборки и тяжесть (толщину) хвостов;
  • quantile - квантиль значений, т.е. значение которое не будет превышено с заданной вероятностью (т.е. просто процент количества значений, которые являются меньше чем указанное значение);
  • cumsum - кумулятивная сумма;
  • cumprod - кумулятивное произведение;
  • cummax - кумулятивный максимум;
  • cummin - кумулятивный минимум;

Краткая статистическая сводка

Иногда бывает полезно быстро узнать какие-нибудь сводные статистики о данных в серии или каждого столбца в датафрейме. Для этого существует метод describe() который вычисляет эти статистики (без учета значений NaN):

s = pd.Series(np.random.randn(1000))
s[::3] = np.nan
s
0           NaN
1      0.694777
2     -0.260088
3           NaN
4     -0.067500
         ...   
995   -0.058663
996         NaN
997    0.995671
998    0.298379
999         NaN
Length: 1000, dtype: float64
s.describe()
count    666.000000
mean      -0.063356
std        0.980980
min       -3.485490
25%       -0.695968
50%       -0.058447
75%        0.598769
max        2.737608
dtype: float64

Глядя на данный вывод мы можем заметить, что среднее значение близко к 0, а стандартное значение близко к 1. К тому же 50-й процентиль (он же второй квантиль, он же медиана) тоже очень близок к среднему значению, так что перед нами, судя по всему, действительно, нормально распределенная случайная величина.

Для датафреймов подобные статистики вычисляются для каждого столбца в отдельности:

data = pd.DataFrame(np.random.randn(2000, 4),
                    columns = ['col_' + str(i) for i in range(4)])
data.iloc[::3] = np.nan
data
col_0 col_1 col_2 col_3
0 NaN NaN NaN NaN
1 0.673580 0.469629 2.033159 -1.201297
2 1.347816 -0.652112 0.238076 0.692468
3 NaN NaN NaN NaN
4 1.637430 -0.292464 0.160859 -0.463325
... ... ... ... ...
1995 NaN NaN NaN NaN
1996 0.975141 -0.754659 0.769076 1.164425
1997 -0.644748 1.060235 0.949261 -0.667111
1998 NaN NaN NaN NaN
1999 -2.609587 -1.201645 -1.526409 0.419282
data.describe()
col_0 col_1 col_2 col_3
count 1333.000000 1333.000000 1333.000000 1333.000000
mean -0.007838 -0.054281 0.007605 0.005540
std 0.992171 1.020226 0.981725 1.020494
min -3.729444 -3.570644 -3.632059 -2.835269
25% -0.652890 -0.778349 -0.677309 -0.665894
50% 0.028582 -0.055274 0.014708 0.042450
75% 0.673580 0.643916 0.690836 0.674617
max 3.290922 3.803188 3.008416 3.390709

Можно указать дополнительное количество процентилей:

data.describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95])
col_0 col_1 col_2 col_3
count 1333.000000 1333.000000 1333.000000 1333.000000
mean -0.007838 -0.054281 0.007605 0.005540
std 0.992171 1.020226 0.981725 1.020494
min -3.729444 -3.570644 -3.632059 -2.835269
5% -1.688970 -1.710802 -1.574646 -1.720128
25% -0.652890 -0.778349 -0.677309 -0.665894
50% 0.028582 -0.055274 0.014708 0.042450
75% 0.673580 0.643916 0.690836 0.674617
95% 1.596295 1.632087 1.587718 1.642854
max 3.290922 3.803188 3.008416 3.390709

Для серий состоящих из нечисловых значений метод describe() возвращает:

  • count - количество не равных NaN элементов;
  • unique - количество уникальных элементов;
  • top - наиболее часто встречающееся значение;
  • freq - число появлений top-значения в данных.
s = pd.Series(list('ababaaacbabbaa'))
s[[1, 3, 5]] = np.nan
s
0       a
1     NaN
2       a
3     NaN
4       a
5     NaN
6       a
7       c
8       b
9       a
10      b
11      b
12      a
13      a
dtype: object
s.describe()
count     11
unique     3
top        a
freq       7
dtype: object

Для датафреймов, состоящих из числовых и нечисловых столбцов, по умолчанию, всегда выводится сводка только по числовым столбцам:

data = pd.DataFrame({'cat': list('a'*3 + 'b'*7),
                     'num': np.random.rand(10)})
data
cat num
0 a 0.233899
1 a 0.704844
2 a 0.277895
3 b 0.388364
4 b 0.379752
5 b 0.431376
6 b 0.944682
7 b 0.804738
8 b 0.595195
9 b 0.088990
data.describe()
num
count 10.000000
mean 0.484973
std 0.270816
min 0.088990
25% 0.303359
50% 0.409870
75% 0.677431
max 0.944682

Указать, какие именно столбцы должны попасть в сводку можно с помощью параметра include:

data.describe(include=['object'])
cat
count 10
unique 2
top b
freq 7
data.describe(include=['number'])
num
count 10.000000
mean 0.484973
std 0.270816
min 0.088990
25% 0.303359
50% 0.409870
75% 0.677431
max 0.944682
data.describe(include='all')
cat num
count 10 10.000000
unique 2 NaN
top b NaN
freq 7 NaN
mean NaN 0.484973
std NaN 0.270816
min NaN 0.088990
25% NaN 0.303359
50% NaN 0.409870
75% NaN 0.677431
max NaN 0.944682

Метод describe() позволяет так же указывать какго типа столбцы должны попасть в сводку, но обычно это очень редко используется на практике. Однако, если практика на то и практика что бы там могло понадобиться все что угодно, так что не забывайте обращаться к официальной документации.

Очень часто бывает полезным подсчитать количество уникальных значений в серии (кроме NaN, естественно), для этого подойдет метод nunique():

s = pd.Series([1, 1, np.nan, 2, 2, 2])
s.nunique()
2
s = pd.Series(np.random.rand(10))
s[-3:] = np.nan
s[:5] = 0.5
s
0    0.500000
1    0.500000
2    0.500000
3    0.500000
4    0.500000
5    0.063941
6    0.618402
7         NaN
8         NaN
9         NaN
dtype: float64
s.nunique()
3

Индексы минимальных и максимальных значений

Что бы узнать индекс максимального или минимального элемента в серии можно воспользоваться методом idxmin() или idxmax() (если таких значений несколько то будет возвращен индекс первого встретившегося):

s = pd.Series([1, 1, 2, 2, 3, 3])
s.idxmin(), s.idxmax()
(0, 4)
s = pd.Series(np.random.randint(100, 1000, 1000))
# индекс элемента с максимальным значением
s.idxmax()
980
# сам элемент с максимальным значением
s[s.idxmax()]
999

Если серия снабжена дополнительным индексом, то возвращены будут метки из данного индекса:

s = pd.Series([1, 2, 0, 4],
              index=list('abcd'))
s
a    1
b    2
c    0
d    4
dtype: int64
s.idxmin()
'c'

Для датафреймов, данные методы позволяют указывать направление оси вдоль которой выполняется поиск:

df = pd.DataFrame(np.random.randint(0, 20, (5, 5)),
                  index=list('abcde'),
                  columns=['C_' + str(i) for i in range(5)])
df
C_0 C_1 C_2 C_3 C_4
a 3 6 6 19 17
b 5 7 19 0 0
c 4 10 12 14 13
d 16 10 18 8 19
e 15 7 18 12 0
# метка строки с максимальным элементом
# по каждому столбцу:

df.idxmax()    # равносильно df.idxmin(axis=0)
               # или df.idxmin(axis='index')
C_0    d
C_1    c
C_2    b
C_3    a
C_4    d
dtype: object
# метка столбца с максимальным элементом
# по каждой строке:

df.idxmax(axis='columns')    # равносильно df.idxmin(axis=1)
a    C_3
b    C_2
c    C_3
d    C_4
e    C_2
dtype: object
df.idxmin(axis=1)
a    C_0
b    C_3
c    C_0
d    C_3
e    C_4
dtype: object

Подсчет значений и мода значений

Иногда данные являются категориальными или состоят из небольшого множества значений. В таких ситуациях часто возникает необходимость, подсчета количества каждого из значений. Сделать это можно с помощью метода value_counts():

s = pd.Series(list('ababccaabccab'))
s
0     a
1     b
2     a
3     b
4     c
5     c
6     a
7     a
8     b
9     c
10    c
11    a
12    b
dtype: object
s.value_counts()
a    5
b    4
c    4
dtype: int64
s = pd.Series(np.random.randint(0, 10, 2000))
s.value_counts()
8    222
0    214
3    213
4    209
7    206
9    204
5    196
1    192
2    179
6    165
dtype: int64

Данный метод так же доступен в виде функции и может применяться к обычным одномерным массивам:

data = np.random.randint(0, 10, 2000)

pd.value_counts(data)
7    214
6    212
5    207
2    207
8    202
0    200
1    193
4    191
9    187
3    187
dtype: int64

Как видите, подсчет возвращается в неотсортированном виде, что бы исправить это можно воспользоваться методом sort_index() (или воспользоваться дополнительными параметра данного метода):

pd.value_counts(data).sort_index()
0    200
1    193
2    207
3    187
4    191
5    207
6    212
7    214
8    202
9    187
dtype: int64

Метод value_counts() может использоваться и для подсчета уникальных комбинаций в столбцах датафреймов:

data = np.random.randint(0, 2, 20).reshape(10, 2)
df = pd.DataFrame(data, columns=['a', 'b'])
df
a b
0 0 1
1 0 1
2 1 1
3 0 1
4 0 1
5 0 1
6 0 0
7 0 1
8 1 0
9 0 0
df.value_counts()
a  b
0  1    6
   0    2
1  1    1
   0    1
dtype: int64

Получить наиболее часто встречающееся значение (или значения, если их несколько) можно с помощью метода mode():

s = pd.Series([1, 2, 3, 3, 3, 4, 5])
s.mode()
0    3
dtype: int64
s = pd.Series([1, 1, 1, 2, 3, 3, 3])
s.mode()
0    1
1    3
dtype: int64
data_1 = np.random.randint(0, 5, 100)
data_2 = np.random.randint(5, 10, 100)
df = pd.DataFrame({'a': data_1,
                   'b': data_2})

df.mode()
a b
0 2.0 6
1 NaN 8

Данный вывод означает что в столбце a чаще всего встречается значение 2, а в столбце b чаще всего встречаются значения 6 и 8 которых поровну в чем очень дегко убедиться:

df['a'].value_counts()
2    26
4    24
0    19
1    16
3    15
Name: a, dtype: int64
df['b'].value_counts()
8    24
6    24
7    22
9    16
5    14
Name: b, dtype: int64

Дискретизация непрерывных значений

Некоторые величины, например такие как возраст или количество выпавших осадков, являются непрерывными. Тем не менее иногда приходится раскладывать наблюдения таких величин по дискретным "ящикам" - интервалам значений. Для возраста, мы можем выделить интервалы (18, 35], (35, 75], (75, 110] и назвать их "молодой", "зрелый" и "пожилой" соответственно. Выполнить подобное разбиение непрерывной величины в Pandas можно с помощью метода cut():

s = pd.Series([0.11, 0.19, 0.23, 0.27, 0.33, 0.39])

hist_s = pd.cut(s, 3)
hist_s
0     (0.11, 0.203]
1     (0.11, 0.203]
2    (0.203, 0.297]
3    (0.203, 0.297]
4     (0.297, 0.39]
5     (0.297, 0.39]
dtype: category
Categories (3, interval[float64]): [(0.11, 0.203] < (0.203, 0.297] < (0.297, 0.39]]

В данном случае, мы разбили значения [0.11, 0.19, 0.23, 0.27, 0.33, 0.39] на три непересекающихся интервала одинаковой длины, причем в каждом оказалось по два значения, в чем легко убедиться:

pd.value_counts(hist_s)
(0.297, 0.39]     2
(0.203, 0.297]    2
(0.11, 0.203]     2
dtype: int64

Интервалы вовсе не обязательно должны быть одинаковыми и вместо их количества мы можем задавать их границы:

data = 10*np.random.randn(300)

hist_data = pd.cut(data, [-30, -5, 0, 5, 30])

hist_data
[(0, 5], (-30, -5], (5, 30], (-5, 0], (0, 5], ..., (-30, -5], (5, 30], (-30, -5], (-30, -5], (5, 30]]
Length: 300
Categories (4, interval[int64]): [(-30, -5] < (-5, 0] < (0, 5] < (5, 30]]
pd.value_counts(hist_data)
(-30, -5]    94
(5, 30]      88
(-5, 0]      65
(0, 5]       51
dtype: int64

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

data = np.random.randint(0, 10, 300)
hist_data = pd.cut(data, [0, 5, 10])
hist_data
[(0, 5], (5, 10], (0, 5], (5, 10], (5, 10], ..., NaN, (5.0, 10.0], (0.0, 5.0], (0.0, 5.0], (0.0, 5.0]]
Length: 300
Categories (2, interval[int64]): [(0, 5] < (5, 10]]

В примере выше мы указали границы, как [0, 5, 10] что соответствует двум интервалам (0, 5] и (5, 10], но 0 не входит в интервал (0, 5], а значит не учитывается при дискретизации, в чем тоже очень легко убедиться:

pd.value_counts(hist_data).sum()
272

При указывании границ интервалов можно использовать значения np.inf, т.е. по сути можно просто разбить данные пополам относительно некоторого значения:

data = np.random.randn(300)
hist_data = pd.cut(data, [-np.inf, 0, np.inf])
pd.value_counts(hist_data)
(-inf, 0.0]    155
(0.0, inf]     145
dtype: int64

Еще один способ дискретизации заключается в том что бы указывать не количество интервалов или их границы, а квантили. Сделать это можно с помощью метода qcut():

data = np.random.randn(50)

hist_data = pd.qcut(data, [0, 0.25, 0.75, 1])

hist_data
[(0.411, 1.765], (-0.715, 0.411], (-2.1229999999999998, -0.715], (0.411, 1.765], (-0.715, 0.411], ..., (0.411, 1.765], (-0.715, 0.411], (-0.715, 0.411], (-0.715, 0.411], (-0.715, 0.411]]
Length: 50
Categories (3, interval[float64]): [(-2.1229999999999998, -0.715] < (-0.715, 0.411] < (0.411, 1.765]]
pd.value_counts(hist_data)
(-0.715, 0.411]                  24
(0.411, 1.765]                   13
(-2.1229999999999998, -0.715]    13
dtype: int64

Метод qcut() полезен когда мы работаем с асемметричным распределением:

data = np.random.beta(2, 3, 150)

hist_data = pd.qcut(data, [0, 0.25, 0.75, 1])

hist_data
[(0.535, 0.919], (0.0114, 0.24], (0.535, 0.919], (0.24, 0.535], (0.0114, 0.24], ..., (0.24, 0.535], (0.0114, 0.24], (0.24, 0.535], (0.0114, 0.24], (0.24, 0.535]]
Length: 150
Categories (3, interval[float64]): [(0.0114, 0.24] < (0.24, 0.535] < (0.535, 0.919]]
pd.value_counts(hist_data)
(0.24, 0.535]     74
(0.535, 0.919]    38
(0.0114, 0.24]    38
dtype: int64

В данном случае мы просто получаем интервалы такой ширины, что значения выборки могут попасть в них только с указанной межквантильной вероятностью. Например, для интервала границы которого определяют 0.25 и 0.75 квантили, вероятность попадания значений в него равна 0.5. А для остальных двух интервалов вероятность равна 0.25.

Ну и конечно же удобен такой метод тем, что значения всегда можно очень легко разделить на две части используя медиану (0.5-й квантиль) в качестве разделителя:

data = np.random.beta(2, 3, 150)

hist_data = pd.qcut(data, [0, 0.5, 1])

hist_data
[(0.395, 0.863], (0.00799, 0.395], (0.00799, 0.395], (0.00799, 0.395], (0.00799, 0.395], ..., (0.395, 0.863], (0.00799, 0.395], (0.00799, 0.395], (0.395, 0.863], (0.00799, 0.395]]
Length: 150
Categories (2, interval[float64]): [(0.00799, 0.395] < (0.395, 0.863]]
pd.value_counts(hist_data)
(0.395, 0.863]      75
(0.00799, 0.395]    75
dtype: int64

Применение функций

Для того что бы обрабатывать данные в датафреймах и сериях нужно использовать собственные функции или функции сторонних библиотек. Для этого нужно четко понимать над чем собирается работать функция: над серией или над датафреймом, будет ли она выполняться построчно или по столбцам, а может она будет работать поэлементно? Со всем этим мы и попробуем разобраться в данном разделе.

В Pandas есть следующие методы, которые облегчают работу с функциями и их применение:

  • pipe() - для применения табличных функций;
  • apply() - для применения функций к строкам и столбцам;
  • agg() и transform() - для функций выполняющих агрегацию;
  • applymap() - для применения функций к каждому элементу.

Применение табличных функций

Датафреймы и серии могут быть аргументами функций и для их применения может пригодиться метод pipe(). Давайте придумаем какой-нибудь простой пример. Допустим у нас есть вот такой датафрейм:

data = pd.DataFrame(np.random.randint(1, 10, (5, 3)),
                  columns=list('XYZ'))
data
X Y Z
0 8 1 1
1 8 9 2
2 3 3 8
3 1 6 4
4 6 2 9

Допустим, нам нужно вычислить среднее геометрическое значений каждой строки, для чего мы создали вот таку функцию:

def geom(df, n):
    df['geom'] = (df['X'] * df['Y'] * df['Z'])**n
    return df

Мы можем просто воспользоваться этой функцией и получить необходимый результат:

geom(data, 1/3)
X Y Z geom
0 8 1 1 2.000000
1 8 9 2 5.241483
2 3 3 8 4.160168
3 1 6 4 2.884499
4 6 2 9 4.762203

Но метод pipe() позволяет использовать данную функцию вот так:

data.pipe(geom, n=1/3)
X Y Z geom
0 8 1 1 2.000000
1 8 9 2 5.241483
2 3 3 8 4.160168
3 1 6 4 2.884499
4 6 2 9 4.762203

Казалось бы? что в таких ухищрениях нет никакой надобности, но очень часто к одному датафрейму (или серии) применяется несколько функций. Допустим у нас появилась еще одна функция, вычисляющая среднее гармоническое элементов каждой строки:

def harm(df, n):
    df['harm'] = n / (1/df['X'] + 1/df['Y'] + 1/df['Z'])
    return df

Без метода pipe() мы попытались бы выполнить вот такой код и он сработает:

harm(geom(data, 1/3), 3)
X Y Z geom harm
0 8 1 1 2.000000 1.411765
1 8 9 2 5.241483 4.075472
2 3 3 8 4.160168 3.789474
3 1 6 4 2.884499 2.117647
4 6 2 9 4.762203 3.857143

Но если таких функций не две, а больше двух? В этом случае нам придется сначала проследить глазами за цепочкой функций, а только потом понять над чем они выполняются. Метод pipe() позволяет сначала обращать внимание на данные, а только потом следить за тем что с ними делается, т.е. какие функции и скакими параметрами используются:

data.pipe(harm, 3).pipe(geom, 1/3)
X Y Z geom harm
0 8 1 1 2.000000 1.411765
1 8 9 2 5.241483 4.075472
2 3 3 8 4.160168 3.789474
3 1 6 4 2.884499 2.117647
4 6 2 9 4.762203 3.857143

Если функций слишком много, то всю команду можно обернуть в скобки и расставить переносы с отступами:

(data.pipe(harm, 3)
     .pipe(geom, 1/3))
X Y Z geom harm
0 8 1 1 2.000000 1.411765
1 8 9 2 5.241483 4.075472
2 3 3 8 4.160168 3.789474
3 1 6 4 2.884499 2.117647
4 6 2 9 4.762203 3.857143

Сейчас мы рассмотрели только те функции, которые принимают данные в качестве первого аргумента, но некоторые функции могут принимать в качестве первого аргумента что-то другое, а данные в качестве ключевого аргумента. И ключевого означает, что данные передаются в функцию по ключу, а не в том смысле, что данные являются главным аргументом функции. Давайте приведем пример такой функции:

def poly(n, XYZ=None, a=1, b=1, c=1):
    data['poly'] = a*data['X']**n + b*data['Y']**(n - 1) + \
                   c*data['Z']**(n - 2)
    return data

Данная функция принимает данные в качестве второго ключевого аргумента по имени 'XYZ', что бы она заработала в методе pipe(), ее нужно указать в кортеже вместе с именем аргумента, принимающим данные:

data.pipe((poly, 'XYZ'), 3, a=3, b=4, c=2)
X Y Z geom harm poly
0 8 1 1 2.000000 1.411765 1542
1 8 9 2 5.241483 4.075472 1864
2 3 3 8 4.160168 3.789474 133
3 1 6 4 2.884499 2.117647 155
4 6 2 9 4.762203 3.857143 682

А цепочка из всех трех функций может выглядеть следующим образом:

(data.pipe(harm, 3)
     .pipe(geom, 1/3)
     .pipe((poly, 'XYZ'), 3, a=3, b=4, c=2))
X Y Z geom harm poly
0 8 1 1 2.000000 1.411765 1542
1 8 9 2 5.241483 4.075472 1864
2 3 3 8 4.160168 3.789474 133
3 1 6 4 2.884499 2.117647 155
4 6 2 9 4.762203 3.857143 682

Выполнение функций по столбцам и строкам

Многие методы датафреймов и серий, например, методы описательной статистики, могут принимать необезательный аргумент axis который позволяет указать вдоль чего должны выполняться эти методы: строк или столбцов. Метод apply() позволяет использовать произвольные функции точно так же, указывая в каком направлении (вдоль какой оси) они должны выполняться. Допустим у нас есть вот такой датафрейм:

df = pd.DataFrame(np.random.randint(1, 10, (5, 5)),
                  index=list('abcde'),
                  columns=['col_' + str(i) for i in range(5)])
df
col_0 col_1 col_2 col_3 col_4
a 6 5 5 9 9
b 7 9 4 8 7
c 2 3 5 7 9
d 3 9 8 2 1
e 2 3 2 8 6

Допустим мы хотим вычислить среднее арифметическое значений каждого столбца, для чего можем воспользоваться методом mean():

df.mean()    # df.mean(axis='index') или df.mean(axis=0)
col_0    4.0
col_1    5.8
col_2    4.8
col_3    6.8
col_4    6.4
dtype: float64

Если нам нужно вычислить среднее арифметическое, вдоль строк, то мы просто указываем что axis=1 (или axis='columns'):

df.mean(axis=1)
a    6.8
b    7.0
c    5.2
d    4.6
e    4.2
dtype: float64

Но тот же самый метод взятый из библиотеки NumPy может быть использован с помощью метода apply():

df.apply(np.mean)
col_0    4.0
col_1    5.8
col_2    4.8
col_3    6.8
col_4    6.4
dtype: float64
df.apply(np.mean, axis=1)
a    6.8
b    7.0
c    5.2
d    4.6
e    4.2
dtype: float64

А благодаря тому, что apply() позволяет использовать лямбда-функции мы можем вычислить среднее арифметическое даже вот так:

df.apply(lambda vec: np.sum(vec) / len(vec), axis=1)
a    6.8
b    7.0
c    5.2
d    4.6
e    4.2
dtype: float64

Лямбда функции, как раз очень удобны тем, что позволяют быстро выполнить какие-то действия, которые не предусмотрены используемыми библиотеками:

df.apply(lambda x: x**2 + 1)
col_0 col_1 col_2 col_3 col_4
a 37 26 26 82 82
b 50 82 17 65 50
c 5 10 26 50 82
d 10 82 65 5 2
e 5 10 5 65 37

Как вы заметили, функции, передаваемые методу apply() могут выполняться не только вдоль строк или столбцов, но и поэлементно:

df.apply(np.sqrt)
col_0 col_1 col_2 col_3 col_4
a 2.449490 2.236068 2.236068 3.000000 3.000000
b 2.645751 3.000000 2.000000 2.828427 2.645751
c 1.414214 1.732051 2.236068 2.645751 3.000000
d 1.732051 3.000000 2.828427 1.414214 1.000000
e 1.414214 1.732051 1.414214 2.828427 2.449490

Если необходимая функция находится в пространстве имен Pandas, то ее имя можно передать в виде строки:

df.apply('mean', axis=1)
a    6.8
b    7.0
c    5.2
d    4.6
e    4.2
dtype: float64

Учитывая что, лямбда функции могут возвращать последовательности, то мы можем вычислять сразу несколько функций вдоль строк или столбцов:

df.apply(lambda x: [np.var(x), np.std(x)], axis=1)
a    [3.3600000000000003, 1.8330302779823362]
b                   [2.8, 1.6733200530681511]
c    [6.5600000000000005, 2.5612496949731396]
d                 [10.64, 3.2619012860600183]
e                                 [5.76, 2.4]
dtype: object

Полученные последовательности мы можем распаковать с помощью параметра result_type установленного в значение 'expand':

df.apply(lambda x: [np.var(x), np.std(x)], axis=1, result_type='expand')
0 1
a 3.36 1.833030
b 2.80 1.673320
c 6.56 2.561250
d 10.64 3.261901
e 5.76 2.400000

А если нам хочется получить в результате датафрейм с осмысленными столбцами, то можем использовать серии, указав необходимые названия индексов, которые в итоге станут метками столбцов (или строк):

df.apply(lambda x: pd.Series([np.var(x), np.std(x)], 
                              index=['var', 'std']),
         axis=1)
var std
a 3.36 1.833030
b 2.80 1.673320
c 6.56 2.561250
d 10.64 3.261901
e 5.76 2.400000
df.apply(lambda x: pd.Series([np.var(x), np.std(x)], 
                              index=['var', 'std']),
         axis=0)
col_0 col_1 col_2 col_3 col_4
var 4.400000 7.360000 3.760000 6.160000 8.640000
std 2.097618 2.712932 1.939072 2.481935 2.939388

В результате работы функций вдоль очей или столбцов мы получаем серию:

df.apply(lambda x: np.sum(x), axis=1)
a    34
b    35
c    26
d    23
e    21
dtype: int64

Но иногда, бывает нужно транслировать полученный результат на все столбцы или строки:

df.apply(lambda x: np.sum(x), axis=1, result_type='broadcast')
col_0 col_1 col_2 col_3 col_4
a 34 34 34 34 34
b 35 35 35 35 35
c 26 26 26 26 26
d 23 23 23 23 23
e 21 21 21 21 21

Но если функция возвращает последовательность чисел длина которой несовместима с исходным датафреймом, то это приведет к ошибке:

# вот это сработает:
df.apply(lambda x: [2, 4, 6, 8, 0], axis=0, result_type='broadcast')

# а это выдаст ошибку:
df.apply(lambda x: [2, 4, 0], axis=0, result_type='broadcast')

Метод apply() хорош тем, что он позволяет очень быстро ответить на многие вопросы о данных, например, очень часто бывает необходимо выяснить, в какие даты наблюдались максимальные и минимальные значения той или инной величины:

data = pd.DataFrame(np.random.randint(100, 300, (2000, 3)),
                    index=pd.date_range('1/1/2010', periods=2000),
                    columns=['a', 'b', 'c'])
data
a b c
2010-01-01 115 182 221
2010-01-02 100 121 252
2010-01-03 146 161 251
2010-01-04 143 277 145
2010-01-05 249 225 170
... ... ... ...
2015-06-19 233 132 209
2015-06-20 212 211 257
2015-06-21 133 133 160
2015-06-22 162 141 135
2015-06-23 263 125 150
data.apply(lambda x: pd.Series([x.idxmax(), x.idxmin()], 
                              index=['max', 'min']))
a b c
max 2010-04-12 2010-02-13 2011-03-17
min 2010-01-02 2010-07-24 2010-06-06

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

df = pd.DataFrame(np.arange(16).reshape(4, 4),
                  columns=list('ABCD'))
df
A B C D
0 0 1 2 3
1 4 5 6 7
2 8 9 10 11
3 12 13 14 15

И нам нужно использовать вот такую функцию:

def prod_and_sum(x, term, factor=1):
    return factor*(x + term)

Позиционные аргументы могут быть переданы в apply() виде кортежа и присвоены специальному параметру args, а ключевые аргументы просто перечисляются с указанием их значений:

df.apply(prod_and_sum, args=(2,), factor=2)
A B C D
0 4 6 8 10
1 12 14 16 18
2 20 22 24 26
3 28 30 32 34

Еще одна особенность метода apply() заключается в том, что ему можно передать любой метод объектов Series, в результате чего указанный метод, может быть применен, как к строкам, так и столбцам датафрейма:

df.apply(pd.Series.sum)
A    24
B    28
C    32
D    36
dtype: int64
df.apply(pd.Series.sum, axis=1)
0     6
1    22
2    38
3    54
dtype: int64

Поддержка агрегации

Для того что бы применять агрегирующие функции существует метод aggregate() и его псевдоним agg(). Метод agg() может быть применен как к серии так и датафрейму, но лучше всего продемонстрировать его использование можно на датафрейме:

df = pd.DataFrame(np.arange(12).reshape(4, 3),
                  index=list('abcd'),
                  columns=list('XYZ'))
df
X Y Z
a 0 1 2
b 3 4 5
c 6 7 8
d 9 10 11
df.agg(np.mean)
X    4.5
Y    5.5
Z    6.5
dtype: float64
df.agg('mean')
X    4.5
Y    5.5
Z    6.5
dtype: float64

Можем использовать данный метод для серий:

df.loc['d'].agg('mean')
10.0

Вы можете снова спросить зачем нужен это метод, ведь мы всегда можем воспользоваться методами этих объектов, которые напрямую вычисляют то что нам нужно, например вот так:

df.mean()
X    4.5
Y    5.5
Z    6.5
dtype: float64
df.loc['d'].mean()
10.0

Но метод agg() хорош тем, что позволяет использовать несколько агрегирующих функций одновременно, просто перечислив их имена в списке:

df.agg(['sum', np.mean, 'std'])
X Y Z
sum 18.000000 22.000000 26.000000
mean 4.500000 5.500000 6.500000
std 3.872983 3.872983 3.872983

Обратите внимание на то, что если используются методы из пространства имен Pandas, то мы просто указываем эти методы в виде строк, а методы сторонних библиотек указываются по имени и без круглых скобок.

Применить несколько агрегирующих методов можно и к серии:

df.loc['d'].agg(['sum', 'mean', 'std'])
sum     30.0
mean    10.0
std      1.0
Name: d, dtype: float64

Наряду с обычными функциями, мы можем использовать и лямбда функции:

df.agg(['sum', lambda x: (x**2).sum()])
X Y Z
sum 18 22 26
<lambda> 126 166 214

Однако, если вы будете использовать несколько лямбда-функций, то скорее всего запутаетесь, так как все результирующие строки будут иметь одинаковую метку <lambda>:

df.agg([lambda x: x.sum(), lambda x: x.mean()])
X Y Z
<lambda> 18.0 22.0 26.0
<lambda> 4.5 5.5 6.5

В этом случае лучше всего создавать обычные функции и использовать их имена:

def sq_mean(x):
    return (x**2).mean()

def cb_mean(x):
    return (x**3).mean()
df.agg([sq_mean, cb_mean])
X Y Z
sq_mean 31.5 41.5 53.5
cb_mean 243.0 352.0 494.0

Другой интересной особенностью данного метода является то, что ему можно передавать словари с ключами - именами столбцов и значениями - именами функций:

df.agg({'X': 'sum', 'Z': sq_mean,
        'Y': lambda x: (1/x).sum()})
X    18.000000
Z    53.500000
Y     1.492857
dtype: float64

Это, конечно, может быть удобно, но старайтесь не запутаться, так как в выводе не указывается какая функция и к какому столбцу применялась. Однако, данный способ позволяет применять к столбцам несколько, причем, несколько разных методов:

df.agg({'X': ['min', 'max'],
        'Y': ['sum', 'mean'],
        'Z': ['min', 'max', 'sum', 'mean']})
X Y Z
max 9.0 NaN 11.0
mean NaN 5.5 6.5
min 0.0 NaN 2.0
sum NaN 22.0 26.0

Значения NaN в данном случае означают что функция к столбце не применялась.

Если датафрейм содержит данные разного типа, то будут выполняться только вычислимые агрегирующие функции.

data = pd.DataFrame({'A': np.arange(5),
                     'B': np.arange(5, dtype=np.float),
                     'C': ['id_' + str(i) for i in range(5)],
                     'D': pd.date_range('2020-11-24', periods=5)})
data
A B C D
0 0 0.0 id_0 2020-11-24
1 1 1.0 id_1 2020-11-25
2 2 2.0 id_2 2020-11-26
3 3 3.0 id_3 2020-11-27
4 4 4.0 id_4 2020-11-28
data.agg(['min', 'max', 'sum', 'mean'])
A B C D
min 0.0 0.0 id_0 2020-11-24
max 4.0 4.0 id_4 2020-11-28
sum 10.0 10.0 id_0id_1id_2id_3id_4 NaT
mean 2.0 2.0 NaN 2020-11-26

Еще один метод, который поддерживает агрегацию по столбцам, это метод transform(). Этот метод очень похож на метод agg(), но не агрегирует результат, а работает поэлементно. Давайте сначала взглянем на датафрейм, что бы было проще понять работу метода:

df
X Y Z
a 0 1 2
b 3 4 5
c 6 7 8
d 9 10 11

а теперь взглянем на пример:

df.transform([np.sqrt, lambda x: np.log(x + 1)])
X Y Z
sqrt <lambda> sqrt <lambda> sqrt <lambda>
a 0.000000 0.000000 1.000000 0.693147 1.414214 1.098612
b 1.732051 1.386294 2.000000 1.609438 2.236068 1.791759
c 2.449490 1.945910 2.645751 2.079442 2.828427 2.197225
d 3.000000 2.302585 3.162278 2.397895 3.316625 2.484907

Как видите, функция действительно работает поэлементно, но результат работы кажой функции для отдельных столбцов группируются вместе. Во всем остальном, метод transform() очень похож на метод agg().


Поэлементные функции

Векторизованные функции применяются сразу ко всему массиву:

a = np.arange(10)
a
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
a**2 + 1
array([ 1,  2,  5, 10, 17, 26, 37, 50, 65, 82], dtype=int32)

Согласитесь - это очень удобно. Но не все функции могут быть векторизованы и зачастую они принимают и возвращают только один аргумент. Как раз для таких функций и существуют методы applymap() - для датафреймов, и, map() - для серий, которые избавляют нас от необходимости писать циклы. Допустим у нас есть следующий датафрейм:

df = pd.DataFrame(np.arange(10, 22).reshape(4, 3),
                  index=list('abcd'),
                  columns=list('XYZ'))
df
X Y Z
a 10 11 12
b 13 14 15
c 16 17 18
d 19 20 21

И представим, что нам необходимо выполнить следующую функцию к его каждому элементу:

def inverse(x):
    return int(str(x)[::-1])

Тогда выполнение этой функции можно сделать так:

df.applymap(inverse)
X Y Z
a 1 11 21
b 31 41 51
c 61 71 81
d 91 2 12

А применить туже функцию, но к серии так:

df['Z'].map(inverse)
a    21
b    51
c    81
d    12
Name: Z, dtype: int64

Метод map() может быть использован для связывания или (сопоставления) значений одной серии с значениями из другой:

s_1 = pd.Series(['cat', 'cat', 'dog', 'cat', 'dog'],
              index=list('abcde'))
s_1
a    cat
b    cat
c    dog
d    cat
e    dog
dtype: object
s_2 = pd.Series([100, 500], index=['cat', 'dog'])
s_2
cat    100
dog    500
dtype: int64
s_1.map(s_2)
a    100
b    100
c    500
d    100
e    500
dtype: int64