Все курсы > Анализ и обработка данных > Занятие 2
Продолжим работу по изучению библиотеки Pandas. Сегодня мы поговорим про возможности изменения и соединения датафреймов, а также про способы группировки данных.
Хотя сами по себе эти навыки являются скорее вспомогательными, овладеть ими совершенно необходимо, если вы хотите быстро и эффективно строить модели ML.
Откроем ноутбук к этому занятию⧉
Изменение датафрейма
Вернемся к тому датафрейму, с которым мы работали на прошлом занятии.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# создадим несколько списков и массивов Numpy с информацией о семи странах мира country = np.array(['China', 'Vietnam', 'United Kingdom', 'Russia', 'Argentina', 'Bolivia', 'South Africa']) capital = ['Beijing', 'Hanoi', 'London', 'Moscow', 'Buenos Aires', 'Sucre', 'Pretoria'] population = [1400, 97, 67, 144, 45, 12, 59] # млн. человек area = [9.6, 0.3, 0.2, 17.1, 2.8, 1.1, 1.2] # млн. кв. км. sea = [1] * 5 + [0, 1] # выход к морю (в этом списке его нет только у Боливии) # кроме того создадим список кодов стран, которые станут индексом датафрейма custom_index = ['CN', 'VN', 'GB', 'RU', 'AR', 'BO', 'ZA'] # создадим пустой словарь countries_dict = {} # превратим эти списки в значения словаря, # одновременно снабдив необходимыми ключами countries_dict['country'] = country countries_dict['capital'] = capital countries_dict['population'] = population countries_dict['area'] = area countries_dict['sea'] = sea # создадим датафрейм countries = pd.DataFrame(countries_dict, index = custom_index) countries |

Посмотрим, как мы можем преобразовать этот датафрейм.
Копирование датафрейма
Метод .copy()
В первую очередь поговорим про важную особенность при копировании датафрейма. Вначале создадим копию датафрейма с помощью простого присвоения этого объекта новой переменной.
1 |
countries_new = countries |
Теперь удалим строку с данными про Аргентину, а после этого выведем исходный датафрейм.
1 2 |
countries_new.drop(labels = 'AR', axis = 0, inplace = True) countries |

Как вы видите, изменения коснулись и его. По этой причине для создания полноценной копии лучше использовать метод .copy().
1 2 3 4 5 6 7 8 9 10 11 |
# в первую очередь вернем Аргентину в исходный датафрейм countries countries = pd.DataFrame(countries_dict, index = custom_index) # создадим копию, на этот раз с помощью метода .copy() countries_new = countries.copy() # вновь удалим запись про Аргентину countries_new.drop(labels = 'AR', axis = 0, inplace = True) # выведем исходный датафрейм countries |

Такой тип копирования (без изменения исходного датафрейма) может быть полезен, например, когда мы хотим взять датафрейм, протестировать какие-то гипотезы, построить одну или несколько моделей, но при этом не трогать исходные данные.
Про параметр inplace
Создадим несложный датафрейм из вложенных списков.
1 2 3 4 5 6 |
df = pd.DataFrame([[1, 1, 1], [2, 2, 2], [3, 3, 3]], columns = ['A', 'B', 'C']) df |

Как понять, сохраняется ли изменение после применение определенного метода или нет? Если метод выдает датафрейм, изменение не сохраняется.
1 2 |
# попробуем удалить столбец A df.drop(labels = ['A'], axis = 1) |

1 2 |
# проверим, сохранилось ли изменение df |

При этом если метод выдает None, изменение постоянно.
1 2 |
# изменим параметр inplace на True print(df.drop(labels = ['A'], axis = 1, inplace = True)) |
1 |
None |
1 2 |
# проверим df |

По этой причине нельзя использовать inplace = True и записывать результат в переменную одновременно.
1 2 |
df = df.drop(labels = ['B'], axis = 1, inplace = True) print(df) |
1 |
None |
В этом случае мы записываем None в переменную df.
Столбцы датафрейма
Именование столбцов при создании датафрейма
Создадим список с названиями столбцов на кириллице и транспонированный массив Numpy с данными о странах.
1 2 3 |
custom_columns = ['страна', 'столица', 'население', 'площадь', 'море'] arr = np.array([country, capital, population, area, sea]).T arr |
1 2 3 4 5 6 7 |
array([['China', 'Beijing', '1400', '9.6', '1'], ['Vietnam', 'Hanoi', '97', '0.3', '1'], ['United Kingdom', 'London', '67', '0.2', '1'], ['Russia', 'Moscow', '144', '17.1', '1'], ['Argentina', 'Buenos Aires', '45', '2.8', '1'], ['Bolivia', 'Sucre', '12', '1.1', '0'], ['South Africa', 'Pretoria', '59', '1.2', '1']], dtype='<U32') |
После этого создадим датафрейм с помощью функции pd.DataFrame() с параметром columns, в который передадим названия столбцов.
1 2 3 4 5 |
countries = pd.DataFrame(data = arr, index = custom_index, columns = custom_columns) countries |

Вернем прежние названия столбцов.
1 |
countries.columns = ['country', 'capital', 'population', 'area', 'sea'] |
Переименование столбцов
Для того чтобы переименовать отдельные столбцы, можно воспользоваться методом .rename(). Внутри этого метода в параметр columns мы передаем словарь, где ключами будут текущие названия столбцов, а значениями соответствующие им новые названия.
1 2 3 |
# переименуем столбец capital на city countries.rename(columns = {'capital': 'city'}, inplace = True) countries |

Тип данных в столбце
Все значения одного столбца датафрейма всегда имеют один и тот же тип данных. Посмотреть тип данных каждого из столбцов можно с помощью атрубута .dtypes.
1 |
countries.dtypes |
1 2 3 4 5 6 |
country object city object population object area object sea object dtype: object |
Изменение типа данных
Преобразовать тип данных столбца можно с помощью метода .astype(). Этот метод можно применить к конкретному столбцу.
1 2 |
# преобразуем тип данных столбца population в int countries.population = countries.population.astype('int') |
Кроме того, мы можем пробразовать тип данных сразу в нескольких столбцах. Для этого применим метод .astype() ко всему датафрейму. Самому методу мы передадим словарь, где ключами будут названия столбцов, а значениями — соответсвующий им желаемый тип данных.
1 2 |
# изменим тип данных в столбцах area и sea countries = countries.astype({'area': 'float', 'sea' : 'category'}) |
Посмотрим на результат.
1 |
countries.dtypes |
1 2 3 4 5 6 |
country object city object population int64 area float64 sea category dtype: object |
Тип данных category
Обратите внимание на новый для нас тип данных category. Во многом он похож на факторную переменную в R.
1 2 |
# в category содержится информация об имеющихся в столбце категориях countries.sea |
1 2 3 4 5 6 7 8 9 |
CN 1 VN 1 GB 1 RU 1 AR 1 BO 0 ZA 1 Name: sea, dtype: category Categories (2, object): ['0', '1'] |
Тип category мы рассмотрим более подробно на занятии по кодированию категориальных переменных.
Помимо упомянутых типов данных, нам также знаком объект datetime, который используется для работы с временными рядами. Мы снова обратимся к нему на занятии по очистке данных.
Фильтр столбцов по типу данных
Выбрать столбцы в зависимости от типа содержащихся в них данных можно с помощью метода .select_dtypes(). Включить определенные типы данных можно с помощью параметра include.
1 2 |
# выберем только типы данных int и float countries.select_dtypes(include = ['int64', 'float64']) |

Исключить определенные типы данных можно через exclude.
1 2 |
# выберем все типы данных, кроме object и category countries.select_dtypes(exclude = ['object', 'category']) |

Добавление строк и столбцов
Добавление строк
Метод .append() + словарь
Для добавления строк в первую очередь используется метод .append(). С его помощью строку можно добавить из питоновского словаря.
1 2 3 4 5 6 |
# создадим словарь с данными Канады и добавим его в датафрейм dict = {'country': 'Canada', 'city': 'Ottawa', 'population': 38, 'area': 10, 'sea' : '1'} # словарь можно добавлять только если ignore_index = True countries = countries.append(dict, ignore_index = True) countries |

Метод .append() + Series
Мы также можем добавить строки в виде объекта Series.
1 2 3 4 5 6 7 |
# причем, если передать список из Series, можно добавить сразу несколько строк list_of_series = [pd.Series(['Spain', 'Madrid', 47, 0.5, 1], index = countries.columns), pd.Series(['Netherlands', 'Amsterdam', 17, 0.04, 1], index = countries.columns)] # нам по-прежнему необходим параметр ignore_index = True countries.append(list_of_series, ignore_index = True) countries |

Метод .append() + другой датафрейм
Новая строка может также содержаться в другом датафрейме.
1 2 3 4 5 6 7 8 |
# новая строка может также содержаться в другом датафрейме # обратите внимание, что числовые значения мы помещаем в списки peru = pd.DataFrame({'country' : 'Peru', 'city' : 'Lima', 'population': [33], 'area' : [1.3], 'sea' : [1]}) peru |

1 2 |
# перед добавлением выберем первую строку с помощью метода .iloc[] countries.append(peru.iloc[0], ignore_index = True) |

Использование .iloc[]
Если вновь вывести наш датафрейм countries, мы увидим, что данные об Испании, Нидерландах и Перу не сохранились.
1 2 3 |
# для этого нам надо было либо перезаписать результат метода .append() в переменную countries, # либо использовать параметр inplace = True. countries |

Добавим данные об этих странах на постоянной основе с помощью метода .iloc[] и посмотрим на результат.
1 2 3 4 5 |
countries.iloc[[5, 6, 7]] = [['Spain', 'Madrid', 47, 0.5, 1], ['Netherlands', 'Amsterdam', 17, 0.04, 1], ['Peru', 'Lima', 33, 1.3, 1]] countries |
![использование метода .iloc[] для добавления строк](https://www.dmitrymakarov.ru/wp-content/uploads/2022/07/append_row_iloc.png)
Обратите внимание, что строки добавились строго на те индексы, которые были указаны в методе .iloc[] (т.е. 5, 6 и 7) и заменили данные, ранее находившиеся на этих индексах (Боливия, Южная Африка и Канада).
Добавление столбцов
Объявление нового столбца
Новый столбец датафрейма можно просто объявить и сразу добавить в него необходимые данные.
1 2 3 |
# например, добавим данные о плотности населения countries['pop_density'] = [153, 49, 281, 9, 17, 94, 508, 26] countries |

Метод .insert()
Добавим столбец с кодами стран с помощью метода .insert().
1 2 3 |
countries.insert(loc = 1, # это будет второй по счету столбец column = 'code', # название столбца value = ['CN', 'VN', 'GB', 'RU', 'AR', 'ES', 'NL', 'PE']) # значения столбца |
Обратите внимание, метод .insert() по умолчанию (без перезаписи в переменную или параметра inplace = True) сохраняет результат.
1 2 |
# посмотрим на результат countries |

Теперь рассмотрим несколько способов добавления столбца с рассчитанным значением.
Метод .assign()
Создадим столбец area_miles, в который поместим площадь в милях. Вначале используем метод .assign().
1 2 |
countries = countries.assign(area_miles = countries.area / 2.59).round(2) countries |

Удалим этот столбец, чтобы рассмотреть другие методы.
1 |
countries.drop(labels = 'area_miles', axis = 1, inplace = True) |
Можно сложнее
Мы можем усложнить код и добиться такого же результата, применив методы .iterrows() и .iloc[].
1 2 3 4 5 6 7 8 |
# выведем индекс и содержание строк for index, row in countries.iterrows(): # запишем для каждой строки (index) в новый столбец area_miles # округленное значение площади row.area в милях countries.loc[index, 'area_miles'] = np.round(row.area / 2.59, 2) # посмотрим на результат countries |
![методы .iterrows() и .iloc[] для добавления нового столбца](https://www.dmitrymakarov.ru/wp-content/uploads/2022/08/new_column_iterrows_iloc.png)
1 2 |
# снова удалим этот столбец countries.drop(labels = 'area_miles', axis = 1, inplace = True) |
Можно проще
При этом конечно есть гораздо более простой способ добавления нового столбца.
1 2 3 |
# мы можем объявить столбец и присвоить ему нужно нам значение countries['area_miles'] = (countries.area / 2.59).round(2) countries |

Удаление строк и столбцов
Удаление строк
Для удаления строк можно использовать метод .drop() с параметрами labels (индекс удаляемых строк) и axis = 0.
1 2 |
# удалим строки с индексом 0 и 1 countries.drop(labels = [0, 1], axis = 0) |

Кроме того, можно использовать метод .drop() с единственным параметром index.
1 2 |
# удалим строки с индексом 5 и 7 countries.drop(index = [5, 7]) |

Мы также можем в параметр index передать индекс датафрейма через атрибут index.
1 2 |
# удалим четвертую строку countries.drop(index = countries.index[4]) |

С атрибутом датафрейма index мы можем делать срезы.
1 2 |
# удалим каждую вторую строку, начиная с четвертой с конца countries.drop(index = countries.index[-4::2]) |

Удаление столбцов
Параметры labels (номера столбцов) и axis = 1 метода .drop() позволяют удалять столбцы.
1 2 |
# удалим столбцы area_miles и code countries.drop(labels = ['area_miles', 'code'], axis = 1) |

Такой же результат можно получить, передав список удаляемых столбцов в параметр columns.
1 2 |
# снова удалим столбцы area_miles и code countries.drop(columns = ['area_miles', 'code']) |

Через атрибут датафрейма columns мы можем передать номера удаляемых столбцов.
1 2 |
# удалим последний столбец (area_miles) countries.drop(columns = countries.columns[-1]) |

Наконец удалим пятую строку и несколько столбцов разобранными выше способами и сохраним изменения.
1 2 3 |
countries.drop(index = 4, inplace = True) countries.drop(columns = ['code', 'pop_density', 'area_miles'], inplace = True) countries |

Удаление по многоуровнему индексу
Давайте посмотрим, как удалять строки и столбцы в датафрейме с многоуровневым (иерархическим) индексом. Вначале вновь создадим соответствующий датафрейм.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
# подготовим данные для многоуровневого индекса строк rows = [('Asia', 'CN'), ('Asia', 'VN'), ('Europe', 'GB'), ('Europe', 'RU'), ('Europe', 'ES'), ('Europe', 'NL'), ('S. America', 'PE')] # и столбцов cols = [('names', 'country'), ('names', 'city'), ('data', 'population'), ('data', 'area'), ('data', 'sea')] # создадим многоуровневый (иерархический) индекс # для индекса строк добавим названия столбцов индекса через параметр names custom_multindex = pd.MultiIndex.from_tuples(rows, names = ['region', 'code']) custom_multicols = pd.MultiIndex.from_tuples(cols) # поместим индексы в атрибуты index и columns датафрейма countries.index = custom_multindex countries.columns = custom_multicols # посмотрим на результат countries |

Удаление строк
Вначале обратимся к строкам и удалим азиатский регион. Для этого воспользуемся методом .drop(), которому передадим параметр labels = ‘Asia’. Кроме того, укажем, что удаляем именно строки (axis = 0) и что Азия находится в индексе под названием region (т.е. level = 0).
1 |
countries.drop(labels = 'Asia', axis = 0, level = 0) |

Кроме того, строки можно удалять с помощью параметра index и указанием через level, по какому столбцу индекса мы будем искать удаляемую строку.
1 2 |
# удалим запись о России по ее индексу в столбце code (т.е. level = 1) countries.drop(index = 'RU', level = 1) |

Удаление столбцов
Удаление столбцов датафрейма с многоуровневым индексом происходит аналогично строкам. Передадим методу .drop() параметры labels, level и axis = 1 для удаления столбца по его наименованию (labels) на нужном нам уровне (level) индекса.
1 2 |
# удалим все столбцы в разделе names на нулевом уровне индекса столбцов countries.drop(labels = 'names', level = 0, axis = 1) |

Для удаления столбцов можно использовать параметр columns с указанием соответствующего уровня индекса (level) столбцов.
1 2 |
# например, удалим столбцы city и area на втором уровне (level = 1) индекса countries.drop(columns = ['city', 'area'], level = 1) |

Обратите внимание, что удалению столбцов не помешал тот факт, что они находятся в разных индексах первого (level = 0) уровня, а именно city находится в names, а population — в data.
Применение функций
Библиотека Pandas позволяет использовать функции для изменения данных в датафрейме.
1 2 3 4 5 6 7 8 9 |
# создадим новый датафрейм с данными нескольких человек people = pd.DataFrame({'name' : ['Алексей', 'Иван', 'Анна', 'Ольга', 'Николай'], 'gender' : [1, 1, 0, 2, 1], 'age' : [35, 20, 13, 28, 16], 'height' : [180.46, 182.26, 165.12, 168.04, 178.68], 'weight' : [73.61, 75.34, 50.22, 52.14, 69.72] }) people |

Метод .map()
Предположим, что мы хотим явно прописать мужской (male) и женский (female) пол в наших данных. В этому случае можно воспользоваться методом .map(). Вначале создадим карту (map) того, как преобразовать существующие значения в новые.
1 2 3 |
# такая карта представляет собой питоновский словарь, # где ключи - это старые данные, а значения - новые gender_map = {0: 'female', 1 : 'male'} |
Применим эту карту к нужному нам столбцу датафрейма.
1 2 |
people['gender'] = people['gender'].map(gender_map) people |

Те значения, которые в карте не указаны (в четвертой строке была ошибка, пол был помечен цифрой два), превращаются в NaN (not a number), пропущенное значение.
Обратите внимание, что словарь, в данном случае, по сути является функцией, которую мы применяем к значениям определенного столбца.
Кроме того замечу, что в большинстве случаев мы будем проводить обратное преобразование, превращая строковые категориальные значения в числовые.
В метод .map() мы можем передать и lambda-функцию.
1 2 3 |
# например, для того, чтобы выявить совершеннолетних и несовершеннолетних людей people['age_group'] = people['age'].map(lambda x: 'adult' if x >= 18 else 'minor') people |

Удалим только что созданный столбец age_group.
1 |
people.drop(labels = 'age_group', axis = 1, inplace = True) |
Вместо lambda-функции, например, для более сложных преобразований, можно испольвовать обычную собственную функцию.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# обратите внимание, такая функция не допускает дополнительных параметров, # только те данные, которые нужно преобразовать (age) def get_age_group(age): # например, мы не можем сделать threshold произвольным параметром threshold = 18 if age >= threshold: age_group = 'adult' else: age_group = 'minor' return age_group |
Применим эту функцию к столбцу age.
1 2 |
people['age_group'] = people['age'].map(get_age_group) people |

1 2 |
# снова удалим созданный столбец people.drop(labels = 'age_group', axis = 1, inplace = True) |
Функция np.where()
Точно такой же результат мы можем получить, применив функцию np.where() библиотеки Numpy к нужному нам столбцу.
1 2 3 4 |
# внутри функции np.where() три параметра: (1) условие, # (2) значение, если условие выдает True, (3) и значение, если условие выдает False people['age_group'] = np.where(people['age'] >= 18, 'adult', 'minor') people |

Замечу, что такой способ превращения количественных данных в категориальные называется binning или bucketing. О нем мы подробно поговорим на занятии по преобразованию данных.
1 2 |
# удалим созданный столбец people.drop(labels = 'age_group', axis = 1, inplace = True) |
Метод .where()
Метод .where() библиотеки Pandas действует немного иначе. В нем мы прописываем условие, которое хотим применить к отдельному столбцу или всему датафрейму.
- Если условие выполняется (т.е. оценивается как True), мы сохраняем текущее значение датафрейма.
- Если условие не выполняется (False), то значение заменяется на новое, указанное в методе .where().
Рассмотрим применение этого метода на примерах.
1 2 |
# заменим возраст тех, кому меньше 18, на NaN people.age.where(people.age >= 18, other = np.nan) |
1 2 3 4 5 6 |
0 35.0 1 20.0 2 NaN 3 28.0 4 NaN Name: age, dtype: float64 |
В примере выше возраст тех, кто не моложе 18 (True), остался без изменений. Для остальных (False) значение изменилось на пропущенное (параметр other). Обратите внимание, что тип данных этого столбца превратился во float. Это связано с тем, что в столбце появились пропущенные значения.
1 2 3 4 5 6 7 8 9 |
# создадим матрицу из вложенных списков nums_matrix = [[-13, 7, 1], [4, -2, 25], [45, -3, 8]] # преобразуем в датафрейм # (матрица не обязательно должна быть массивом Numpy (!)) nums = pd.DataFrame(nums_matrix) nums |

1 2 3 |
# если число положительное (nums < 0 == True), оставим его без изменений # если отрицательное (False), заменим на обратное (т.е. сделаем положительным) nums.where(nums > 0, other = -nums) |

Здесь можно отметить два интересных момента:
- Мы применили метод .where() ко всему датафрейму
- В качестве значения, на которое нужно заменить текущее при False, мы использовали сами значения датафрейма, но со знаком минус ( -nums)
Перейдем к методу .apply().
Метод .apply()
Применение функции с аргументами
В отличие от .map(), метод .apply() позволяет передавать именованные аргументы в применяемую функцию.
1 2 3 4 5 6 7 8 9 10 |
# объявим функцию, которой можно передать не только значение возраста, но и порог, # при котором мы будем считать человека совершеннолетним def get_age_group(age, threshold): if age >= int(threshold): age_group = 'adult' else: age_group = 'minor' return age_group |
1 2 3 4 5 |
# применим эту функцию к столбцу age, выбрав в качестве порогового значения 21 год people['age_group'] = people['age'].apply(get_age_group, threshold = 21) # посмотрим на результат people |

Применение к столбцам
В метод .apply() можно передать уже имеющуюся в Питоне функцию, например, из библиотеки Numpy.
1 2 3 4 |
# заменим значения в столбцах height и weight на медиану по столбцам people[['height', 'weight']] = people[['height', 'weight']].apply(np.median, axis = 0) people |

Применение к строкам
Теперь поработаем со строками. Создадим функцию, которая считает индекс массы тела (body mass index, BMI) на основе веса и роста человека.
1 2 3 4 |
# внутри функции разделим вес на квадрат роста def get_bmi(x): bmi = x['weight'] / (x['height'] / 100) ** 2 return bmi |
Теперь применим эту функцию к каждой строке и сохраним результат в новом столбце.
1 2 3 |
# для применения функции к строке используется параметр axis = 1 people['bmi'] = people.apply(get_bmi, axis = 1).round(2) people |

Метод .applymap()
Метод .applymap() позволяет применять функции с именованными аргументами ко всему датафрейму (метод .apply() применяется только к строкам или столбцам). Рассмотрим несложный пример.
1 2 3 4 5 6 7 |
# создадим датафрейм из чисел nums_matrix = [[13, 7, 1], [4, 2, 25], [45, 3, 8]] nums = pd.DataFrame(nums_matrix) nums |

1 2 3 4 |
# объявим функцию, которая на входе принимает число x и # прибавляет к нему другое число, указанное в параметре number def add_number(x, number): return x + number |
1 2 3 |
# передадим методу .applymap() функцию add_number и # прибавим единицу к каждому элементу датафрейма nums.applymap(add_number, number = 1) |

Метод .pipe()
Метод .pipe(), как следует из его названия, позволяет создать pipeline и последовательно применить несколько функций к датафрейму. Вновь создадим исходный датафрейм с параметрами нескольких людей.
1 2 3 4 5 6 7 8 |
people = pd.DataFrame({'name' : ['Алексей', 'Иван', 'Анна', 'Ольга', 'Николай'], 'gender' : [1, 1, 0, 2, 1], 'age' : [35, 20, 13, 28, 16], 'height' : [180.46, 182.26, 165.12, 168.04, 178.68], 'weight' : [73.61, 75.34, 50.22, 52.14, 69.72] }) people |

Объявим несколько функций, которые мы могли бы применить к датафрейму.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# в первую очередь скопируем датафрейм def copy_df(df): return df.copy() # заменим значения столбца на новые с помощью метода .map() def map_column(df, column, label1, label2): labels_map = {0: label1, 1 : label2} df[column] = df[column].map(labels_map) return df # кроме этого, создадим функцию для превращения количественной переменной # в бинарную категориальную def to_categorical(df, newcol, condcol, thres, cat1, cat2): df[newcol] = np.where(df[condcol] >= thres, cat1, cat2) return df |
Последовательно применим эти функции с помощью нескольких методов .pipe().
1 2 3 4 |
people_processed = (people. pipe(copy_df). # copy_df() применится ко всему датафрейму pipe(map_column, 'gender', 'female', 'male'). # map_column() к столбцу gender pipe(to_categorical, 'age_group', 'age', 18, 'adult', 'minor')) # to_categorical() к age_group |
Посмотрим на результат и кроме того убедимся, что исходный датафрейм не изменился.
1 |
people_processed |

1 |
people |

Перейдем к следующему разделу, который посвящен соединению датафреймов.
Соединение датафреймов
Рассмотрим, как мы можем соединить два датафрейма с помощью функций/методов pd.concat(), pd.merge() и .join(). Начнем с функции pd.concat().
pd.concat()
В качестве примера возьмем информацию о стоимости канцелярских товаров в двух магазинах.
1 2 3 4 5 6 7 8 9 |
s1 = pd.DataFrame({ 'item': ['карандаш', 'ручка', 'папка', 'степлер'], 'price': [220, 340, 200, 500] }) s2 = pd.DataFrame({ 'item': ['клей', 'корректор', 'скрепка', 'бумага'], 'price': [200, 240, 100, 300] }) |
Выведем результат.
1 |
s1 |

1 |
s2 |

Соединение «один на другой»
В первую очередь мы можем совместить два датафрейма, поставив их «один на другой».
1 2 3 |
# передадим в функцию pd.concat() список из соединяемых датафреймов, # укажем параметр axis = 0 pd.concat([s1, s2], axis = 0) |

Как вы видите, индекс не обновился.
1 2 |
# обновим индекс через параметр ignore_index = True pd.concat([s1, s2], axis = 0, ignore_index = True) |

При соединении датафреймов мы можем создать многоуровневый (иерархический) индекс. Например, создадим отдельную группу для товаров первого магазина (s1) и товаров второго (s2).
1 2 3 4 |
# передадим в параметр keys названия групп индекса, # параметр names получим названия уровней индекса by_shop = pd.concat([s1, s2], axis = 0, keys = ['s1', 's2'], names = ['s', 'id']) by_shop |

Посмотрим на созданный индекс.
1 |
by_shop.index |
1 2 3 4 5 6 7 8 9 |
MultiIndex([('s1', 0), ('s1', 1), ('s1', 2), ('s1', 3), ('s2', 0), ('s2', 1), ('s2', 2), ('s2', 3)], names=['s', 'id']) |
Выведем первую запись в первой группе.
1 |
by_shop.loc[('s1', 0)] |
1 2 3 |
item карандаш price 220 Name: (s1, 0), dtype: object |
Соединение «рядом друг с другом»
Датафреймы можно расположить «рядом друг с другом».
1 2 3 |
# для этого сразу используем параметр axis = 1 # одновременно сразу создадим группы для многоуровневого индекса столбцов pd.concat([s1, s2], axis = 1, keys = ['s1', 's2']) |

Выберем вторую группу (второй магазин) с помощью метода .iloc[].
1 |
pd.concat([s1, s2], axis = 1, keys = ['s1', 's2']).loc[:,'s2'] |
![pd.concat() + .iloc[]](https://www.dmitrymakarov.ru/wp-content/uploads/2022/08/pandas_concat_axis1_iloc.png)
Полученный через соединение результат и в целом любой датафрейм можно транспонировать.
1 2 |
# для транспонирования датафрейма используется метод .T или .transpose() pd.concat([s1, s2], axis = 1, keys = ['s1', 's2']).T |

Итак, pd.concat() выполняет простое «склеивание» датафреймов по вертикали или по горизонтали. Теперь посмотрим, что делать, если датафреймы нужно соединить по определенному столбцу.
pd.merge() и .join()
Возьмем три несложных датафрейма.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
math_dict = { 'name': ['Андрей', 'Елена', 'Антон', 'Татьяна'], 'math_score' : [83, 84, 78, 80] } math_degree_dict = { 'degree' : ['B', 'M', 'B', 'M'] } cs_dict = { 'name': ['Андрей', 'Ольга', 'Евгений', 'Татьяна'], 'cs_score' : [87, 82, 77, 81] } math = pd.DataFrame(math_dict) cs = pd.DataFrame(cs_dict) math_degree = pd.DataFrame(math_degree_dict) |
В первом содержатся оценки студентов ВУЗа по математике (по 100-бальной шкале).
1 |
math |

Во втором указано, по какой программе (бакалавр или магистр) учатся студенты.
1 |
math_degree |

В третьем содержатся данные об оценках по информатике.
1 2 |
# имена некоторых студентов повторяются, других - нет cs |

Left join
Предположим, что мы хотим объединить данные об оценках студентов по математике с информацией о том, по какой программе они учатся. Для этого можно воспользоваться соединением слева (left join) через функцию pd.merge().
1 2 3 |
pd.merge(math, math_degree, # выполним соединение двух датафреймов how = 'left', # способом left join left_index = True, right_index = True) # по индексам левого и правого датафрейма |

Точно такой же результат можно получить, применив метод .join(). По умолчанию, он как раз выполняет соединение слева (left join) по индексу (index-on-index).
1 2 |
# можно сказать, что .join() "заточен" под left join по индексу math.join(math_degree) |

Здесь важно отметить, что главным является датафрейм слева. Другими словами, если в столбцах, по которым мы соединяем датафреймы есть расхождения, библиотека Pandas сохранит нетронутым левый датафрейм, а в правом — либо заполнит недостающие значения пропусками, либо удалит лишние строки.
Например, соединим датафреймы math и cs по столбцу name для того, чтобы увидеть оценки студентов и по математике, и по информатике. В группе по математике не учатся Ольга и Евгений (они изучают только информатику), а в группе по информатике не учатся Елена и Антон (им ближе математика). Посмотрим, что произойдет после left join.
1 2 |
# выполним left join по столбцу name pd.merge(math, cs, how = 'left', on = 'name') |

Как вы видите, Pandas сохранил всех математиков (левый датафрейм), поставив NaN для тех из них, кто не изучается информатику. В правом датафрейме (информатика) сохранились только те студенты, которые также изучают и математику.
Концепцию соединения по определенному столбцу можно также объяснить через теорию множеств. На диаграмме Эйлера left join будет выглядеть следующим образом.

Опять же мы включаем в финальный датасет всех математиков и при этом оставляем только тех студентов информатики, которых также интересуют математические дисциплины.
Left excluding join
Левое исключающее соединение (left excluding join, left anti-join) предполагает, что мы оставляем только те наблюдения, которые есть в левом датафрейме и исключаем записи только правого датафрейма, а также записи, которые содержатся в обоих.

На Питоне вначале выполним левое соединение и с помощью параметра indicator = True создадим служебный столбец _merge, в котором указано, в каком из датафреймов присутствует каждая строка.
1 |
pd.merge(math, cs, how = 'left', on = 'name', indicator = True) |

Как мы видим, Елена и Антон присутствуют только в левом датафрейме, Андрей и Татьяна — в обоих. Теперь с помощью метода .query() выберем только записи из левого датафрейма и удалим столбец _merge.
1 2 3 |
# все это можно сделать, применив несколько методов подряд pd.merge(math, cs, how = 'left', on = 'name', indicator = True) \ .query('_merge == "left_only"').drop(columns = '_merge') |

Ожидаемо, в столбце cs_score появились NaN (у тех, кто учит только математику, не может быть оценок по информатике).
Также обратите внимание на обратный слэш (\), который позволяет перенести код на следующую строку.
Right join
Правое соединение (right join) — зеркальная операция по отношению к левому соединению. Мы выбираем те записи, которые содержатся только в правом или в обоих датафреймах.

1 |
pd.merge(math, cs, how = 'right', on = 'name') |

В результате остались те, кто учит информатику, а также одновременно и математику и информатику.
Right excluding join
Правое исключающее соединение (right excluding join, right anti-join) соответственно зеркально операции с левой стороны. Мы берем наблюдения, которые есть только в правом датафрейме.

На Питоне мы вначале выполним правое соединение с параметром indicator = True и посмотрим, в каком из датафреймов содержится каждое наблюдение.
1 |
pd.merge(math, cs, how = 'right', on = 'name', indicator = True) |

Теперь воспользуемся методом .query() и оставим записи, которые есть только в правом датафрейме. После этого удалим столбец _merge.
1 2 |
pd.merge(math, cs, how = 'right', on = 'name', indicator = True) \ .query('_merge == "right_only"').drop(columns = '_merge') |

Outer join
Внешнее соединение (outer join) сохраняет все строки обоих датафреймов.

Сразу посмотрим на код на Питоне.
1 |
pd.merge(math, cs, how = 'outer', on = 'name') |

Как мы видим, при таком соединении мы получили данные по всем студентам по обоим предметам. Одновременно, ожидаемо, для студентов только математики или только информатики не изучаемый ими предмет заполнен пропусками.
Full excluding join
Полное исключающее соединение (full excluding join, full anti-join) сохраняет только те наблюдения, которые есть в левом либо правом датасетах, но не в обоих.

Воспользуемся параметрами how = ‘outer’ и indicator = True. Так мы найдем те строки, которые есть только в левом датафрейме (left_only), только в правом датафрейме (right_only) и в обоих (both).
1 |
pd.merge(math, cs, on = 'name', how = 'outer', indicator = True) |

Оставим только те записи, которых нет в обоих датафреймах.
1 2 3 |
pd.merge(math, cs, on = 'name', how = 'outer', indicator = True) \ .query('_merge != "both"') \ .drop(columns = '_merge') |

Inner join
Внутреннее соединение (inner join) сохраняет только те наблюдения, которые есть в обоих датафреймах.

В нашем случае это те студенты, которые учатся и на курсе по математике, и на курсе по информатике.
1 2 |
# для внутреннего соединения используется параметр how = 'inner' pd.merge(math, cs, how = 'inner', on = 'name') |

Обращу ваше внимание на то, что по умолчанию pd.merge() выполняет именно внутренне соединение.
1 |
pd.merge(math, cs) |

Здесь нужно быть аккуратным, потому что если левый датафрейм был главным (его записи должны быть сохранены в обязательном порядке), использование pd.merge() с параметром по умолчанию (how = ‘inner’) вызовет потерю данных (лучше использовать left join).
Соединение датафреймов и дубликаты
Также замечу, что если в ваших данных есть дубликаты (повторяющиеся значения), нужно быть особенно внимательным при выборе типа соединения.
Первый пример
Создадим два датайфрейма: один с наименованием товара, другой — с ценой.
1 2 |
product_data = pd.DataFrame([[1, 'холодильник'], [2, 'телевизор']], columns = ['code', 'product']) price_data = pd.DataFrame([[1, 40000], [1, 60000]], columns = ['code', 'price']) |
1 |
product_data |

1 2 3 |
# в этом датафрейме есть две цены для холодильника (код 1) # и ни одной цены телевизора price_data |

Выполним left join по столбцу code (код товара).
1 |
pd.merge(product_data, price_data, how = 'left', on = 'code') |

В результате мы получили данные о наименовании и цене товаров и так как у нас есть две цены для холодильника (и мы не знаем какая из них верная), оба этих значения вошли в финальный датафрейм. Вместо цены телевизора ожидаемо появился NaN.
Нам лишь останется разобраться с повторяющимся значением, однако этим мы займемся на занятии по очистке данных.
Если бы мы выбрали, например, правое соединение, то часть данных была бы потеряна.
1 |
pd.merge(product_data, price_data, how = 'right', on = 'code') |

Второй пример
Теперь возьмем случай, когда повтор встречается в левом, а не правом датафрейме.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# создадим два датафрейма exams_dict = { 'professor': ['Погорельцев', 'Преображенский', 'Архенгельский', 'Дятлов', 'Иванов'], 'student' : [101, 102, 103, 104, 101], 'score' : [83, 84, 78, 80, 82] } students_dict = { 'student_id' : [101, 102, 103, 104], 'student': ['Андрей', 'Елена', 'Антон', 'Татьяна'] } exams = pd.DataFrame(exams_dict) students = pd.DataFrame(students_dict) |
В первом датафрейме содержится информация о результатах экзамена с фамилией экзаменатора, идентификатором студента и оценкой.
1 |
exams |

Во втором, идентификатор студента и его или ее имя.
1 |
students |

Как вы видите, студент с идентификатором 101 (Андрей) сдавал экзамен двум преподавателям, Погорельцеву и Иванову. В данном случае дубликат сохранится при любом типе соединения left join, right join, outer join, inner join.
1 2 3 |
# применим параметр по умолчанию how = 'inner join' pd.merge(exams, students, left_on = 'student', right_on = 'student_id') |

Вы можете самостоятельно убедиться, что другие типы соединения дадут такой же результат.
Кроме того замечу, что маркеры _x и _y, которые автоматически выставляются, если в датафреймах есть столбцы с одинаковыми названиями, можно изменить с помощью параметра suffixes = ('_x', '_y').
Cross join
Перекрестное соединение (cross join) по своей сути представляет собой декартово произведение. В этом случае каждому элементу первого датафрейма ставится в соответствие каждый элемент второго.
1 2 3 |
# создадим датафрейм со столбцом xy и двумя значениями (x и y) df_xy = pd.DataFrame({ 'xy' : ['x', 'y']}) df_xy |

1 2 3 |
# создадим еще один датафрейм со столбцом 123 и тремя значениями (1, 2 и 3) df_123 = pd.DataFrame({ '123' : [1, 2, 3]}) df_123 |

С помощью параметра how = ‘cross’ мы можем поставить как значению x, так и значению y первого датафрейма каждый из элементов второго.
1 |
pd.merge(df_xy, df_123, how = 'cross') |

Для сравнения соединим датафреймы с помощью right join.
1 2 |
pd.merge(df_xy, df_123, how = 'right', left_index = True, right_index = True) |

pd.merge_asof()
В некоторых случаях бывает нужно соединить датафреймы не по точному значению столбцов, а по ближайшему. Для этого существует функция pd.merge_asof(). Рассмотрим пример, который приведен в документации⧉ к этой функции.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# создадим два датафрейма trades = pd.DataFrame({ 'time': pd.to_datetime(['20160525 13:30:00.023', '20160525 13:30:00.038', '20160525 13:30:00.048', '20160525 13:30:00.048', '20160525 13:30:00.048']), 'ticker': ['MSFT', 'MSFT','GOOG', 'GOOG', 'AAPL'], 'price': [51.95, 51.95,720.77, 720.92, 98.00], 'quantity': [75, 155,100, 100, 100]}, columns = ['time', 'ticker', 'price', 'quantity']) quotes = pd.DataFrame({ 'time': pd.to_datetime(['20160525 13:30:00.023', '20160525 13:30:00.023', '20160525 13:30:00.030', '20160525 13:30:00.041', '20160525 13:30:00.048', '20160525 13:30:00.049', '20160525 13:30:00.072', '20160525 13:30:00.075']), 'ticker': ['GOOG', 'MSFT', 'MSFT','MSFT', 'GOOG', 'AAPL', 'GOOG','MSFT'], 'bid': [720.50, 51.95, 51.97, 51.99,720.50, 97.99, 720.50, 52.01], 'ask': [720.93, 51.96, 51.98, 52.00,720.93, 98.01, 720.88, 52.03]}, columns = ['time', 'ticker', 'bid', 'ask']) |
В первом будет содержаться информация о сделках, совершенных с ценными бумагами.
1 2 |
# время сделки, тикер эмитента, цена и количество бумаг trades |

Во втором, котировки ценных бумаг в определенный момент времени.
1 |
quotes |

Функция pd.merge_asof() выполняет левое соединение, а это значит, что мы будем ориентироваться на левый датафрейм trades. Вначале посмотрим на код.
1 2 3 4 5 6 7 8 |
# выполним левое соединение merge_asof pd.merge_asof(trades, quotes, # по столбцу времени on = 'time', # но так, чтобы совпадало значение столбца ticker by = 'ticker', # совпадение по времени должно составлять менее 10 миллисекунд tolerance = pd.Timedelta('10ms')) |

Разберем приведенный код более подробно.
- Строкам 0 и 1 левого датафрейма trades (тикер MSFT, Microsoft) мы можем поставить в соответствие строки 1 и 2 датафрейма quotes. В первом случае, у нас будет полное совпадение по времени (30 миллисекунд), во втором, разница будет находится в пределах допустимого интервала в 10 миллисекунд (38 — 30 = 8 миллисекунд).
- Хотя время в строке 3 датафрейма quotes ближе к строке 1 в trades (41 и 38 миллисекунд соответственно), алгоритм соединения по умолчанию ищет совпадения в предыдущих, а не будущих наблюдениях.
- Строкам 2 и 3 левого датафрейма соответствует строка 4 правого (полное совпадение).
- Строке 4 левого датафрейма не нашлось совпадения в правом датафрейме (хотя тикер AAPL, Apple, в нем есть и время отличается всего на одну миллисекунду) опять же, потому что алгоритм искал совпадения в предыдущих, а не будущих периодах.
Изменим некоторые параметры фунции pd.merge_asof().
- Во-первых, уменьшим интервал до пяти миллисекунд.
- Во-вторых, разрешим алгоритму искать совпадения как в предыдущих, так и в будущих периодах.
1 2 3 4 5 6 7 8 |
# еще раз выполним соединение merge_asof pd.merge_asof(trades, quotes, on = 'time', by = 'ticker', # уменьшим интервал до пяти миллисекунд tolerance = pd.Timedelta('10ms'), # разрешив искать в предыдущих и будущих периодах direction = 'nearest') |

Что изменилось?
- Строке 1 левого датафрейма теперь соответствует строка 3 правого (она ближе по времени)
- Для тикера AAPL (строка 4 левого датафрейма) теперь нашлось соответствие (строка 5 правого)
Перейдем к разделу, посвященному группировке показателей.
Группировка
В библиотеке Pandas можно группировать данные, а затем, например, рассчитывать какие-либо статистические показатели внутри каждой группы.
Метод .groupby()
Вначале рассмотрим метод .groupby(), который используется для группировки данных. Вновь в качестве примера используем датасет «Титаник».
1 2 3 4 5 6 7 8 9 |
# подгрузим данные из файла train.csv titanic = pd.read_csv('/content/train.csv') # оставим только столбцы PassengerId, Name, Ticket и Cabin titanic.drop(columns = ['PassengerId', 'Name', 'Ticket', 'Cabin'], inplace = True) # посмотрим на результат titanic.head() |

1 2 |
# посмотрим на размерность titanic.shape |
1 |
(891, 8) |
Сам по себе метод .groupby() создает объект DataFrameGroupBy.
1 2 |
# выполним группировку по столбцу Sex titanic.groupby('Sex') |
1 |
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x7fec3ffbb850> |
Как и у любого объекта в Питоне, в DataFrameGroupBy есть атрибуты и методы. Например, воспользуемтся атрибутом ngroups, чтобы узнать на сколько групп были разбиты данные по столбцу Sex.
1 |
titanic.groupby('Sex').ngroups |
1 |
2 |
Атрибут groups выдает питоновский словарь, в котором ключами будут названия групп, а значениями — индексы наблюдений, входящих в соответствующую группу.
1 2 3 |
# выберем группу female (по ключу словаря) и # выведем первые пять индексов (через срез списка), относящихся к этой группе titanic.groupby('Sex').groups['female'][:5] |
1 |
Int64Index([1, 2, 3, 8, 9], dtype='int64') |
Метод .size() выдает количество элементов в каждой группе.
1 |
titanic.groupby('Sex').size() |
1 2 3 4 |
Sex female 314 male 577 dtype: int64 |
Метод .first() выдает первые встречающиеся наблюдения в каждой из групп.
1 2 |
# можно использовать .last() для получения последних записей titanic.groupby('Sex').first() |

Обратите внимание, результатом этого метода будет уже датафрейм. Использование метода .get_group() позволяет выбрать наблюдения только одной группы.
1 2 |
# выберем наблюдения группы male и выведем первые пять строк датафрейма titanic.groupby('Sex').get_group('male').head() |

Агрегирование данных
Ценность группировки или агрегирования данных заключается конечно в том, что мы можем выполнить определенные действия не со всем датафреймом, а с каждой группой наблюдений по отдельности.
Статистика по столбцам
Например, мы можем рассчитать статистические показатели для каждой группы по каждому столбцу. Рассмотрим на примерах.
Вначале выведем статистику по одному столбцу.
1 2 |
# посчитаем медианный возраст мужчин и женщин titanic.groupby('Sex').Age.median().round(1) |
1 2 3 4 |
Sex female 27.0 male 29.0 Name: Age, dtype: float64 |
В данном случае мы последовательно применили несколько методов: сформировали объект DataFrameGroupBy, выбрали столбец Age, нашли медиану в каждой группе по этому столбцу и округлили результат. Рассчитаем статистику по нескольким столбцам.
1 2 |
# рассчитаем среднее арифметическое по столбцам Age и Fare для каждого из классов titanic.groupby('Pclass')[['Age', 'Fare']].mean().round(1) |

Если не указывать конкретные столбцы, статистический показатель будет рассчитан для всего датафрейма.
1 2 |
# очевидно, среднее арифметическое не будет иметь большого смысла для категориальных признаков titanic.groupby('Pclass').mean().round(1) |

Мы также можем группировать по нескольким признакам.
1 2 3 |
# выполним группировку по двум признакам (Pclass и Sex) # с расчетом количества наблюдений в каждой подгруппе по каждому столбцу titanic.groupby(['Pclass', 'Sex']).count() |

Обратите внимание, что количество наблюдений в столбце Age меньше, чем в остальных столбцах. Это пропущенные значения, которые, как правило, игнорируются при расчете статистистических показателей.
Значение атрибута ngroups библиотека Pandas считает по подгруппам.
1 |
titanic.groupby(['Pclass', 'Sex']).ngroups |
1 |
6 |
Метод .agg()
Метод .agg() позволяет рассчитать сразу несколько статистических показателей. Применим этот метод к одному столбцу (Sex) и найдем максимальное и минимальное значения, количество наблюдений, а также среднее арифметическое и медиану.
1 |
titanic.groupby('Sex').Age.agg(['max', 'min', 'count', 'median', 'mean']).round(1) |

Для удобства при группировке и расчете показателей столбцы можно переименовать.
1 |
titanic.groupby('Sex').Age.agg(sex_max = ('max'), sex_min = ('min')) |

Метод .agg() можно применить к нескольким столбцам.
1 2 |
# рассчитаем среднее арифметическое и медиану для столбцов Age и Fare titanic.groupby('Sex')[['Age', 'Fare']].agg(['mean', 'median']).round(1) |

Кроме того, мы можем применить метод .agg() ко всем столбцам одновременно.
1 2 |
# для каждого рассчитаем среднее арифметическое и медиану titanic.groupby('Sex').agg(['mean', 'median']).round(1) |
В качестве параметра метод .agg() можем принимать и объявленную нами функцию.
1 2 3 4 5 6 7 8 |
# объявим функцию, которая выдаст True, если средний возраст меньше 29 лет # и False в остальных случаях def below29(x): m = x.mean() return True if m < 29 else False # применим эту функцию к группам female и male через метод .agg() titanic.groupby('Sex').Age.agg(['max', 'mean', below29]) |

Преобразование данных
Мы уже научились применять функции для преобразования данных в датафрейме. Агрегирование позволяет выполнять такие преобразования отдельно внутри каждой группы. Вначале объявим несложную lambda-функцию, которая стандартизирует данные.
1 |
standartize = lambda x: (x - x.mean()) / x.std() |
При стандартизации (standartization) из каждого значения мы вычитаем среднее арифметическое и делим на среднее квадратическое отклонение. В результате среднее значение становится равно нулю, а СКО — единице.
Воспользуемся методом .apply() для применения lambda-функции к сгруппированному столбцу Age.
1 2 |
# сгруппируем данные о возрасте по полу пассажиров и применим функцию стандартизации titanic.groupby('Sex').Age.apply(standartize) |
1 2 3 4 5 6 7 8 9 10 11 12 |
0 -0.594531 1 0.714684 2 -0.135768 3 0.502071 4 0.291136 ... 886 -0.253890 887 -0.631865 888 NaN 889 -0.322018 890 0.086751 Name: Age, Length: 891, dtype: float64 |
Мы также можем найти агрегированную статистику по группе (например, среднее арифметическое).
1 2 3 |
# сгруппируем данные по Pclass и найдем среднее в столбцах Age и Fare # метод .apply() выдаст только агрегированные данные titanic.groupby('Pclass')[['Age', 'Fare']].apply(np.mean).round(1) |

Фильтрация
Сгруппированные данные можно фильтровать. Например, найдем среднее арифметическое возраста внутри каждого из классов каюты.
1 |
titanic.groupby('Pclass')[['Age']].mean() |

Теперь предположим, что мы хотим оставить только те классы кают, в которых средний возраст пассажиров составляет не менее 26 лет (то есть первый и второй классы). Для этого выполним следующие действия:
- Применим метод .filter() к сгруппированным по классу данным
- Этому методу передадим lambda-функцию, которая отберет те строки, в которых среднегрупповое значение возраста составляет не менее 26 лет.
1 |
titanic.groupby('Pclass').filter(lambda x: x['Age'].mean() >= 26).head() |

Убедимся, что у нас действительно осталось только два класса.
1 2 3 4 |
# для этого из предыдущего результата # возьмем столбец Pclass и применим метод .unique() titanic.groupby('Pclass').filter(lambda x: x['Age'].mean() >= 26) \ .Pclass.unique() |
1 |
array([1, 2]) |
Сводные таблицы
Сводные таблицы (pivot tables) — это еще один способ группировки данных. Принцип сводных таблиц проще всего продемонстрировать на примере.
Подготовка данных
Однако вначале скачаем и подгрузим в сессионное хранилище необходимые данные.
Взглянем на содержание датасета.
1 2 3 4 5 6 7 8 |
# импортируем данные cars = pd.read_csv('/content/cars.csv') # удалим столбцы, которые нам не понадобятся cars.drop(columns = ['Unnamed: 0', 'vin', 'lot', 'condition'], inplace = True) # и посмотрим на результат cars.head() |

В этом разделе мы будем использовать данные о предлагаемых к продаже автомобилях в США и Канаде. Для каждого автомобиля мы можем посмотреть цену (price), марку (brand), модель (model), год выпуска (year), требует ли автомобиль ремонта (title_status), пробег (mileage), цвет кузова (color), штат (state) и страну (country) продажи.
Группировка по строкам
Теперь предположим, что нас интересуют не отдельные автомобили, а статистика по маркам. Тогда в индексе (строках) мы разместим названия марок, а в столбцах будем рассчитывать какие-либо агрегированные показатели. Воспользуемся функцией pd.pivot_table().
1 2 3 |
# для создания сводной таблицы достаточно указать данные и индекс # по умолчанию будет рассчитано среднее арифметическое внутри каждой из групп pd.pivot_table(cars, index = 'brand').round(2).head(10) |

Как вы видите средние показатели по марке рассчитаны только для количественных признаков. Продолжим изучать функцию pd.pivot_table(). Добавим параметры values (по каким столбцам считать статистику группы) и aggfunc (какие функции использовать при агрегации).
1 2 3 4 5 6 7 |
pd.pivot_table(cars, # сгруппируем по марке index = 'brand', # считать статистику будем по цене и пробегу values = ['price', 'mileage'], # для каждой группы найдем медиану и выведем первые 10 марок aggfunc = 'median').round(2).head(10) |

Для каждого столбца (например, цены) можно рассчитать несколько статистических показателей. Кроме того, мы можем использовать не только готовые, но и собственные функции.
1 2 3 |
# в качестве несложного примера пропишем функцию, которая возвращает среднее арифметическое def custom_mean(x): return sum(x) / len(x) |
1 2 3 4 5 |
# применим как встроенную, так и собственную функцию к столбцу price pd.pivot_table(cars, index = 'brand', values = 'price', aggfunc = ['mean', custom_mean]).round(2).head(10) |

Возможна группировка по нескольким признакам.
1 2 3 4 5 6 |
# сгруппируем данные по марке, а затем по цвету кузова # для каждой подгруппы рассчитаем медиану и количество наблюдений (count) pd.pivot_table(cars, index = ['brand', 'color'], values = 'price', aggfunc = ['median', 'count']).round(2).head(11) |

Группировка по строкам и столбцам
В отличие от метода .groupby() сводные таблицы позволяют выполнять группировку по двум измерениям. Часть признаков группируется по строкам, часть — по столбцам.
Например, предположим, что с одной стороны мы хотим сгруппировать по марке, а с другой по title_status. В таком случае,
- в параметр index мы передадим значение brand
- в columns — title_status
- кроме того, нам нужно указать, откуда мы будем брать значения (values) для заполнения таблицы, в нашем случае это цена (price)
1 2 3 4 5 6 |
# найдем медианную цену для каждой марки с разбивкой по категориям title_status pd.pivot_table(cars, index = 'brand', columns = 'title_status', values = 'price', aggfunc = 'median').round(2).head() |

Пояснение про title_status. Говоря упрощенно, если автомобиль относится к категории clean vehicle, он не был в серьеных ДТП и его можно застраховать. Если же это salvage vehicle, то автомобиль поврежден настолько, что его нельзя эксплуатировать на дорогах общего пользования и на него нельзя купить страховку.
При желании мы можем добавить еще один показатель и транспонировать датафрейм.
1 2 3 4 5 6 7 |
# добавим метрику count для подсчета количества наблюдений и # применим метод .transpose(), чтобы поменять строки и столбцы местами pd.pivot_table(cars, index = 'brand', columns = 'title_status', values = 'price', aggfunc = ['median', 'count']).round().head().transpose() |

Рассмотрим некоторые дополнительные возможности.
Дополнительные возможности
Используя метод .style.background_gradient() мы можем маркировать данные с помощью цвета.
1 2 3 4 |
pd.pivot_table(cars, index = ['brand', 'color'], values = 'price', aggfunc = ['median', 'count']).round(2).head(11).style.background_gradient() |

В частности, мы видим, что наиболее высокая медианная цена и наибольшее количество наблюдений в группе маркируются более темными оттенками, более низкие показатели — светлыми.
Если же в получившейся сводной таблице имеются пропущенные значения, мы также можем выделить их с помощью, например, желтого цвета через метод .style.highlight_null().
1 2 3 4 5 6 |
# цвет выбирается через параметр null_color pd.pivot_table(cars, index = 'brand', columns = 'title_status', values = 'price', aggfunc = 'median').round(2).head(11).style.highlight_null(null_color = 'yellow') |

Данные сводных таблиц можно выводить не только с помощью чисел, но и графически. Например, посчитаем количество автомобилей (aggfunc = ‘count’) со статусом clean и salvage (columns = ‘title_status’), сгруппированных по маркам (index = ‘brand’). Затем выведем первые три марки и применим метод .plot.barh().
1 2 3 4 5 6 |
pd.pivot_table(cars, index = 'brand', columns = 'title_status', values = 'price', aggfunc = 'count').round(2).head(3).plot.barh(figsize = (10,7), title = 'Clean vs. Salvage Counts'); |

Подробнее про построение графиков мы поговорим уже в следующем разделе, посвященном исследовательскому анализу данных.
Если применить к сводной таблице метод .unstack(), то мы как бы лишаем ее второго измерения, группировки по столбцам. Остается только группировка по строкам (как в методе .groupby).
1 2 3 4 5 6 |
# сравните результат исполнения этого кода с позапрошлым примером pd.pivot_table(cars, index = 'brand', columns = 'title_status', values = 'price', aggfunc = 'median').round(2).head().unstack() |
1 2 3 4 5 6 7 8 9 10 11 12 |
title_status brand clean vehicle acura 10400.0 audi 27950.0 bmw 31600.0 buick 20802.5 cadillac 24500.0 salvage insurance acura 1000.0 audi 12.5 bmw 1825.0 buick 0.0 cadillac 0.0 dtype: float64 |
Для последующих примеров выберем исключительно автомобили марки «БМВ».
1 2 3 4 5 6 7 8 |
# создадим маску для автомобилей "БМВ" и сделаем копию датафрейма bmw = cars[cars['brand'] == 'bmw'].copy() # установим новый индекс, удалив при этом старый bmw.reset_index(drop = True, inplace = True) # удалим столбец brand, так как у нас осталась только одна марка bmw.drop(columns = 'brand', inplace = True) # посмотрим на результат bmw.head() |

Сгруппируем данные по двум признакам, но только по строкам.
1 2 3 4 5 6 |
# сгруппируем данные по штату и году выпуска, передав их в параметр index # и найдем медианну цену pd.pivot_table(bmw, index = ['state', 'year'], values = 'price', aggfunc = 'median').round(2) |

1 2 3 |
# когда группировка выполняется только по строкам, # мы можем получить аналогичный результат с помощью метода .groupby() bmw.groupby(by = ['state', 'year'])[['price']].agg('median') |

Метод .query() позволяет отфильтровать данные.
1 2 3 4 5 |
# выберем те штаты и годы выпуска, в которых медианная цена была выше 20000 pd.pivot_table(bmw, index = ['state', 'year'], values = 'price', aggfunc = 'median').round(2).query('price > 20000') |

Наконец, применим метод .style.bar() и создадим встроенную горизонтальную диаграмму.
1 2 3 4 5 |
# цвет в параметр color можно, в частности, передавать в hex-формате pd.pivot_table(bmw, index = ['state', 'year'], values = 'price', aggfunc = 'median').round(2).style.bar(color = '#d65f5f') |

Подведем итог
На сегодняшнем занятии мы в деталях изучили способы преобразования и соединения датафреймов, а также способы группировки данных.
Вопросы для закрепления
Вопрос. Для чего нужен метод .copy()?
Посмотреть правильный ответ
Ответ: метод .copy() позволяет скопировать датафрейм таким образом, чтобы изменения внесенные в копию не отражались на исходном объекте
Вопрос. Какой риск существует при соединении датафреймов с помощью pd.merge() и .join()?
Посмотреть правильный ответ
Ответ: при соединении датафреймов есть риск потерять часть информации; чтобы этого не произошло важно использовать наиболее подходящий в конкретном случае тип соединения (left join, right join, outer join и т.д.)
Вопрос. Чем метод .groupby() отличается от функции pd.pivot_table()?
Посмотреть правильный ответ
Ответ: метод .groupby() позволяет группировать по нескольким признакам, но только в одном измерении (по строкам), функция pd.pivot_table() позволяет создавать группировки как по строкам, так и по столбцам
На следующем занятии мы начнем изучать процесс исследовательского анализа данных. В частности, мы познакомимся с классификацией данных и задачами EDA.