Аналитикам: большая шпаргалка по Pandas
Привет. Я задумывал эту заметку для студентов курса Digital Rockstar, на котором мы учим маркетологов автоматизировать свою работу с помощью программирования, но решил поделиться шпаргалкой по Pandas со всеми. Я ожидаю, что читатель умеет писать код на Python хотя бы на минимальном уровне, знает, что такое списки, словари, циклы и функции.
- Что такое Pandas и зачем он нужен
- Структуры данных: серии и датафреймы
- Создаем датафреймы и загружаем в них данные
- Исследуем загруженные данные
- Получаем данные из датафреймов
- Считаем производные метрики
- Объединяем несколько датафреймов
- Решаем задачу
Что такое Pandas и зачем он нужен
Pandas — это библиотека для работы с данными на Python. Она упрощает жизнь аналитикам: где раньше использовалось 10 строк кода теперь хватит одной.
Например, чтобы прочитать данные из csv, в стандартном Python надо сначала решить, как хранить данные, затем открыть файл, прочитать его построчно, отделить значения друг от друга и очистить данные от специальных символов.
> with open('file.csv') as f: . content = f.readlines() . content = [x.split(',').replace('\n','') for x in content]
В Pandas всё проще. Во-первых, не нужно думать, как будут храниться данные — они лежат в датафрейме. Во-вторых, достаточно написать одну команду:
> data = pd.read_csv('file.csv')
Pandas добавляет в Python новые структуры данных — серии и датафреймы. Расскажу, что это такое.
Структуры данных: серии и датафреймы
Серии — одномерные массивы данных. Они очень похожи на списки, но отличаются по поведению — например, операции применяются к списку целиком, а в сериях — поэлементно.
То есть, если список умножить на 2, получите тот же список, повторенный 2 раза.
> vector = [1, 2, 3] > vector * 2 [1, 2, 3, 1, 2, 3]
А если умножить серию, ее длина не изменится, а вот элементы удвоятся.
> import pandas as pd > series = pd.Series([1, 2, 3]) > series * 2 0 2 1 4 2 6 dtype: int64
Обратите внимание на первый столбик вывода. Это индекс, в котором хранятся адреса каждого элемента серии. Каждый элемент потом можно получать, обратившись по нужному адресу.
> series = pd.Series(['foo', 'bar']) > series[0] 'foo'
Еще одно отличие серий от списков — в качестве индексов можно использовать произвольные значения, это делает данные нагляднее. Представим, что мы анализируем помесячные продажи. Используем в качестве индексов названия месяцев, значениями будет выручка:
> months = ['jan', 'feb', 'mar', 'apr'] > sales = [100, 200, 300, 400] > data = pd.Series(data=sales, index=months) > data jan 100 feb 200 mar 300 apr 400 dtype: int64
Теперь можем получать значения каждого месяца:
> data['feb'] 200
Так как серии — одномерный массив данных, в них удобно хранить измерения по одному. На практике удобнее группировать данные вместе. Например, если мы анализируем помесячные продажи, полезно видеть не только выручку, но и количество проданных товаров, количество новых клиентов и средний чек. Для этого отлично подходят датафреймы.
Датафреймы — это таблицы. У их есть строки, колонки и ячейки.
Технически, колонки датафреймов — это серии. Поскольку в колонках обычно описывают одни и те же объекты, то все колонки делят один и тот же индекс:
> months = ['jan', 'feb', 'mar', 'apr'] > sales = < . 'revenue': [100, 200, 300, 400], . 'items_sold': [23, 43, 55, 65], . 'new_clients': [10, 20, 30, 40] . >> sales_df = pd.DataFrame(data=sales, index=months) > sales_df revenue items_sold new_clients jan 100 23 10 feb 200 43 20 mar 300 55 30 apr 400 65 40
Объясню, как создавать датафреймы и загружать в них данные.
Создаем датафреймы и загружаем данные
Бывает, что мы не знаем, что собой представляют данные, и не можем задать структуру заранее. Тогда удобно создать пустой датафрейм и позже наполнить его данными.
> df = pd.DataFrame()
А иногда данные уже есть, но хранятся в переменной из стандартного Python, например, в словаре. Чтобы получить датафрейм, эту переменную передаем в ту же команду:
> df = pd.DataFrame(data=sales, index=months))
Случается, что в некоторых записях не хватает данных. Например, посмотрите на список goods_sold — в нём продажи, разбитые по товарным категориям. За первый месяц мы продали машины, компьютеры и программное обеспечение. Во втором машин нет, зато появились велосипеды, а в третьем снова появились машины, но велосипеды исчезли:
> goods_sold = [ . , . , . . ]
Если загрузить данные в датафрейм, Pandas создаст колонки для всех товарных категорий и, где это возможно, заполнит их данными:
> pd.DataFrame(goods_sold) bicycles cars computers soft 0 NaN 1.0 10 3 1 1.0 NaN 4 5 2 NaN 2.0 6 3
Обратите внимание, продажи велосипедов в первом и третьем месяце равны NaN — расшифровывается как Not a Number. Так Pandas помечает отсутствующие значения.
Теперь разберем, как загружать данные из файлов. Чаще всего данные хранятся в экселевских таблицах или csv-, tsv- файлах.
Экселевские таблицы читаются с помощью команды pd.read_excel() . Параметрами нужно передать адрес файла на компьютере и название листа, который нужно прочитать. Команда работает как с xls, так и с xlsx:
> pd.read_excel('file.xlsx', sheet_name='Sheet1')
Файлы формата csv и tsv — это текстовые файлы, в которых данные отделены друг от друга запятыми или табуляцией:
# CSV month,customers,sales feb,10,200 # TSV month\tcustomers\tsales feb\t10\t200
Оба читаются с помощью команды .read_csv() , символ табуляции передается параметром sep (от англ. separator — разделитель):
> pd.read_csv('file.csv') > pd.read_csv('file.tsv', sep='\t')
При загрузке можно назначить столбец, который будет индексом. Представьте, что мы загружаем таблицу с заказами. У каждого заказа есть свой уникальный номер, Если назначим этот номер индексом, сможем выгружать данные командой df[order_id] . Иначе придется писать фильтр df[df[‘id’] == order_id ] .
О том, как получать данные из датафреймов, я расскажу в одном из следующих разделов. Чтобы назначить колонку индексом, добавим в команду read_csv() параметр index_col , равный названию нужной колонки:
> pd.read_csv('file.csv', index_col='id')
После загрузки данных в датафрейм, хорошо бы их исследовать — особенно, если они вам незнакомы.
Исследуем загруженные данные
Представим, что мы анализируем продажи американского интернет-магазина. У нас есть данные о заказах и клиентах. Загрузим файл с продажами интернет-магазина в переменную orders . Раз загружаем заказы, укажем, что колонка id пойдет в индекс:
> orders = pd.read_csv('orders.csv', index_col='id')
Расскажу о четырех атрибутах, которые есть у любого датафрейма: .shape , .columns , .index и .dtypes .
.shape показывает, сколько в датафрейме строк и колонок. Он возвращает пару значений (n_rows, n_columns) . Сначала идут строки, потом колонки.
> orders.shape (5009, 5)
В датафрейме 5009 строк и 5 колонок.
Окей, масштаб оценили. Теперь посмотрим, какая информация содержится в каждой колонке. С помощью .columns узнаем названия колонок:
> orders.columns Index(['order_date', 'ship_mode', 'customer_id', 'sales'], dtype='object')
Теперь видим, что в таблице есть дата заказа, метод доставки, номер клиента и выручка.
С помощью .dtypes узнаем типы данных, находящихся в каждой колонке и поймем, надо ли их обрабатывать. Бывает, что числа загружаются в виде текста. Если мы попробуем сложить две текстовых значения ‘1’ + ‘1’ , то получим не число 2, а строку ’11’ :
> orders.dtypes order_date object ship_mode object customer_id object sales float64 dtype: object
Тип object — это текст, float64 — это дробное число типа 3,14.
C помощью атрибута .index посмотрим, как называются строки:
> orders.index Int64Index([100006, 100090, 100293, 100328, 100363, 100391, 100678, 100706, 100762, 100860, . 167570, 167920, 168116, 168613, 168690, 168802, 169320, 169488, 169502, 169551], dtype='int64', name='id', length=5009)
Ожидаемо, в индексе датафрейма номера заказов: 100762, 100860 и так далее.
В колонке sales хранится стоимость каждого проданного товара. Чтобы узнать разброс значений, среднюю стоимость и медиану, используем метод .describe() :
> orders.describe() sales count 5009.0 mean 458.6 std 954.7 min 0.6 25% 37.6 50% 152.0 75% 512.1 max 23661.2
Наконец, чтобы посмотреть на несколько примеров записей датафрейма, используем команды .head() и .sample() . Первая возвращает 6 записей из начала датафрейма. Вторая — 6 случайных записей:
> orders.head() order_date ship_mode customer_id sales id 100006 2014-09-07 Standard DK-13375 377.970 100090 2014-07-08 Standard EB-13705 699.192 100293 2014-03-14 Standard NF-18475 91.056 100328 2014-01-28 Standard JC-15340 3.928 100363 2014-04-08 Standard JM-15655 21.376
Получив первое представление о датафреймах, теперь обсудим, как доставать из него данные.
Получаем данные из датафреймов
Данные из датафреймов можно получать по-разному: указав номера колонок и строк, использовав условные операторы или язык запросов. Расскажу подробнее о каждом способе.
Указываем нужные строки и колонки
Продолжаем анализировать продажи интернет-магазина, которые загрузили в предыдущем разделе. Допустим, я хочу вывести столбец sales . Для этого название столбца нужно заключить в квадратные скобки и поставить после них названия датафрейма: orders[‘sales’] :
> orders['sales'] id 100006 377.970 100090 699.192 100293 91.056 100328 3.928 100363 21.376 100391 14.620 100678 697.074 100706 129.440 .
Обратите внимание, результат команды — новый датафрейм с таким же индексом.
Если нужно вывести несколько столбцов, в квадратные скобки нужно вставить список с их названиями: orders[[‘customer_id’, ‘sales’]] . Будьте внимательны: квадратные скобки стали двойными. Первые — от датафрейма, вторые — от списка:
> orders[['customer_id', 'sales']] customer_id sales id 100006 DK-13375 377.970 100090 EB-13705 699.192 100293 NF-18475 91.056 100328 JC-15340 3.928 100363 JM-15655 21.376 100391 BW-11065 14.620 100363 KM-16720 697.074 100706 LE-16810 129.440 .
Перейдем к строкам. Их можно фильтровать по индексу и по порядку. Например, мы хотим вывести только заказы 100363, 100391 и 100706, для этого есть команда .loc[] :
> show_these_orders = ['100363', '100363', '100706'] > orders.loc[show_these_orders] order_date ship_mode customer_id sales id 100363 2014-04-08 Standard JM-15655 21.376 100363 2014-04-08 Standard JM-15655 21.376 100706 2014-12-16 Second LE-16810 129.440
А в другой раз бывает нужно достать просто заказы с 1 по 3 по порядку, вне зависимости от их номеров в таблицемы. Тогда используют команду .iloc[] :
> show_these_orders = [1, 2, 3] > orders.iloc[show_these_orders] order_date ship_mode customer_id sales id 100090 2014-04-08 Standard JM-15655 21.376 100293 2014-04-08 Standard JM-15655 21.376 100328 2014-12-16 Second LE-16810 129.440
Можно фильтровать датафреймы по колонкам и столбцам одновременно:
> columns = ['customer_id', 'sales'] > rows = ['100363', '100363', '100706'] > orders.loc[rows][columns] customer_id sales id 100363 JM-15655 21.376 100363 JM-15655 21.376 100706 LE-16810 129.440 .
Часто вы не знаете заранее номеров заказов, которые вам нужны. Например, если задача — получить заказы, стоимостью более 1000 рублей. Эту задачу удобно решать с помощью условных операторов.
Если — то. Условные операторы
Задача: нужно узнать, откуда приходят самые большие заказы. Начнем с того, что достанем все покупки стоимостью более 1000 долларов:
> filter_large = orders['sales'] > 1000 > orders.loc[filter_slarge] order_date ship_mode customer_id sales id 101931 2014-10-28 First TS-21370 1252.602 102673 2014-11-01 Standard KH-16630 1044.440 102988 2014-04-05 Second GM-14695 4251.920 103100 2014-12-20 First AB-10105 1107.660 103310 2014-05-10 Standard GM-14680 1769.784 .
Помните, в начале статьи я упоминал, что в сериях все операции применяются по-элементно? Так вот, операция orders[‘sales’] > 1000 идет по каждому элементу серии и, если условие выполняется, возвращает True . Если не выполняется — False . Получившуюся серию мы сохраняем в переменную filter_large .
Вторая команда фильтрует строки датафрейма с помощью серии. Если элемент filter_large равен True , заказ отобразится, если False — нет. Результат — датафрейм с заказами, стоимостью более 1000 долларов.
Интересно, сколько дорогих заказов было доставлено первым классом? Добавим в фильтр ещё одно условие:
> filter_large = df['sales'] > 1000 > filter_first_class = orders['ship_mode'] == 'First' > orders.loc[filter_large & filter_first_class] order_date ship_mode customer_id sales id 101931 2014-10-28 First TS-21370 1252.602 103100 2014-12-20 First AB-10105 1107.660 106726 2014-12-06 First RS-19765 1261.330 112158 2014-12-02 First DP-13165 1050.600 116666 2014-05-08 First KT-16480 1799.970 .
Логика не изменилась. В переменную filter_large сохранили серию, удовлетворяющую условию orders[‘sales’] > 1000 . В filter_first_class — серию, удовлетворяющую orders[‘ship_mode’] == ‘First’ .
Затем объединили обе серии с помощью логического ‘И’: filter_first_class & filter_first_class . Получили новую серию той же длины, в элементах которой True только у заказов, стоимостью больше 1000, доставленных первым классом. Таких условий может быть сколько угодно.
Язык запросов
Еще один способ решить предыдущую задачу — использовать язык запросов. Все условия пишем одной строкой ‘sales > 1000 & ship_mode == ‘First’ и передаем ее в метод .query() . Запрос получается компактнее.
> orders.query('sales > 1000 & ship_mode == First') order_date ship_mode customer_id sales id 101931 2014-10-28 First TS-21370 1252.602 103100 2014-12-20 First AB-10105 1107.660 106726 2014-12-06 First RS-19765 1261.330 112158 2014-12-02 First DP-13165 1050.600 116666 2014-05-08 First KT-16480 1799.970 .
Отдельный кайф: значения для фильтров можно сохранить в переменной, а в запросе сослаться на нее с помощью символа @: sales > @sales_filter .
> sales_filter = 1000 > ship_mode_filter = 'First' > orders.query('sales > @sales_filter & ship_mode > @ship_mode_filter') order_date ship_mode customer_id sales id 101931 2014-10-28 First TS-21370 1252.602 103100 2014-12-20 First AB-10105 1107.660 106726 2014-12-06 First RS-19765 1261.330 112158 2014-12-02 First DP-13165 1050.600 116666 2014-05-08 First KT-16480 1799.970 .
Разобравшись, как получать куски данных из датафрейма, перейдем к тому, как считать агрегированные метрики: количество заказов, суммарную выручку, средний чек, конверсию.
Считаем производные метрики
Задача: посчитаем, сколько денег магазин заработал с помощью каждого класса доставки. Начнем с простого — просуммируем выручку со всех заказов. Для этого используем метод .sum() :
> orders['sales'].sum() 2297200.8603000003
Добавим класс доставки. Перед суммированием сгруппируем данные с помощью метода .groupby() :
> orders.groupby('ship_mode')['sales'].sum() ship_mode First 3.514284e+05 Same Day 1.283631e+05 Second 4.591936e+05 Standard 1.358216e+06
3.514284e+05 — научный формат вывода чисел. Означает 3.51 * 10 5 . Нам такая точность не нужна, поэтому можем сказать Pandas, чтобы округлял значения до сотых:
> pd.options.display.float_format = ''.format > orders.groupby('ship_mode')['sales'].sum() ship_mode First 351,428.4 Same Day 128,363.1 Second 459,193.6 Standard 1,358,215.7
Другое дело. Теперь видим сумму выручки по каждому классу доставки. По суммарной выручке неясно, становится лучше или хуже. Добавим разбивку по датам заказа:
> orders.groupby(['ship_mode', 'order_date'])['sales'].sum() ship_mode order_date First 2014-01-06 12.8 2014-01-11 9.9 2014-01-14 62.0 2014-01-15 149.9 2014-01-19 378.6 2014-01-26 152.6 .
Видно, что выручка прыгает ото дня ко дню: иногда 10 долларов, а иногда 378. Интересно, это меняется количество заказов или средний чек? Добавим к выборке количество заказов. Для этого вместо .sum() используем метод .agg() , в который передадим список с названиями нужных функций.
> orders.groupby(['ship_mode', 'order_date'])['sales'].agg(['sum', 'count']) sum count ship_mode order_date First 2014-01-06 12.8 1 2014-01-11 9.9 1 2014-01-14 62.0 1 2014-01-15 149.9 1 2014-01-19 378.6 1 2014-01-26 152.6 1 .
Ого, получается, что это так прыгает средний чек. Интересно, а какой был самый удачный день? Чтобы узнать, отсортируем получившийся датафрейм: выведем 10 самых денежных дней по выручке:
> orders.groupby(['ship_mode', 'order_date'])['sales'].agg(['sum']).sort_values(by='sum', ascending=False).head(10) sum ship_mode order_date Standard 2014-03-18 26,908.4 2016-10-02 18,398.2 First 2017-03-23 14,299.1 Standard 2014-09-08 14,060.4 First 2017-10-22 13,716.5 Standard 2016-12-17 12,185.1 2017-11-17 12,112.5 2015-09-17 11,467.6 2016-05-23 10,561.0 2014-09-23 10,478.6
Команда разрослась, и её теперь неудобно читать. Чтобы упростить, можно разбить её на несколько строк. В конце каждой строки ставим обратный слеш \ :
> orders \ . .groupby(['ship_mode', 'order_date'])['sales'] \ . .agg(['sum']) \ . .sort_values(by='sum', ascending=False) \ . .head(10) sum ship_mode order_date Standard 2014-03-18 26,908.4 2016-10-02 18,398.2 First 2017-03-23 14,299.1 Standard 2014-09-08 14,060.4 First 2017-10-22 13,716.5 Standard 2016-12-17 12,185.1 2017-11-17 12,112.5 2015-09-17 11,467.6 2016-05-23 10,561.0 2014-09-23 10,478.6
В самый удачный день — 18 марта 2014 года — магазин заработал 27 тысяч долларов с помощью стандартного класса доставки. Интересно, откуда были клиенты, сделавшие эти заказы? Чтобы узнать, надо объединить данные о заказах с данными о клиентах.
Объединяем несколько датафреймов
До сих пор мы смотрели только на таблицу с заказами. Но ведь у нас есть еще данные о клиентах интернет-магазина. Загрузим их в переменную customers и посмотрим, что они собой представляют:
> customers = pd.read_csv('customers.csv', index='id') > customers.head() name segment state city id CG-12520 Claire Gute Consumer Kentucky Henderson DV-13045 Darrin Van Huff Corporate California Los Angeles SO-20335 Sean O'Donnell Consumer Florida Fort Lauderdale BH-11710 Brosina Hoffman Consumer California Los Angeles AA-10480 Andrew Allen Consumer North Carolina Concord
Мы знаем тип клиента, место его проживания, его имя и имя контактного лица. У каждого клиента есть уникальный номер id . Этот же номер лежит в колонке customer_id таблицы orders . Значит мы можем найти, какие заказы сделал каждый клиент. Например, посмотрим, заказы пользователя CG-12520 :
> cust_filter = 'CG-12520' > orders.query('customer_id == @cust_filter') order_date ship_mode customer_id sales id CA-2016-152156 2016-11-08 Second CG-12520 993.90 CA-2017-164098 2017-01-26 First CG-12520 18.16 US-2015-123918 2015-10-15 Same Day CG-12520 136.72
Вернемся к задаче из предыдущего раздела: узнать, что за клиенты, которые сделали 18 марта заказы со стандартной доставкой. Для этого объединим таблицы с клиентами и заказами. Датафреймы объединяют с помощью методов .concat() , .merge() и .join() . Все они делают одно и то же, но отличаются синтаксисом — на практике достаточно уметь пользоваться одним из них.
Покажу на примере .merge() :
> new_df = pd.merge(orders, customers, how='inner', left_on='customer_id', right_index=True) > new_df.columns Index(['order_date', 'ship_mode', 'customer_id', 'sales', 'name', 'segment', 'state', 'city'], dtype='object')
В .merge() я сначала указал названия датафреймов, которые хочу объединить. Затем уточнил, как именно их объединить и какие колонки использовать в качестве ключа.
Ключ — это колонка, связывающая оба датафрейма. В нашем случае — номер клиента. В таблице с заказами он в колонке customer_id , а таблице с клиентами — в индексе. Поэтому в команде мы пишем: left_on=’customer_id’, right_index=True .
Решаем задачу
Закрепим полученный материал, решив задачу. Найдем 5 городов, принесших самую большую выручку в 2016 году.
Для начала отфильтруем заказы из 2016 года:
> orders_2016 = orders.query("order_date >= '2016-01-01' & order_date orders_2016.head() order_date ship_mode customer_id sales id 100041 2016-11-20 Standard BF-10975 328.5 100083 2016-11-24 Standard CD-11980 24.8 100153 2016-12-13 Standard KH-16630 63.9 100244 2016-09-20 Standard GM-14695 475.7 100300 2016-06-24 Second MJ-17740 4,823.1
Город — это атрибут пользователей, а не заказов. Добавим информацию о пользователях:
> with_customers_2016 = pd.merge(customers, orders_2016, how='inner', left_index=True, right_on='customer_id')
Cруппируем получившийся датафрейм по городам и посчитаем выручку:
> grouped_2016 = with_customers_2016.groupby('city')['sales'].sum() > grouped_2016.head() city Akron 1,763.0 Albuquerque 692.9 Amarillo 197.2 Arlington 5,672.1 Arlington Heights 14.1 Name: sales, dtype: float64
Отсортируем по убыванию продаж и оставим топ-5:
> top5 = grouped_2016.sort_values(ascending=False).head(5) > print(top5) city New York City 53,094.1 Philadelphia 39,895.5 Seattle 33,955.5 Los Angeles 33,611.1 San Francisco 27,990.0 Name: sales, dtype: float64
Возьмите данные о заказах и покупателях и посчитайте:
- Сколько заказов, отправлено первым классом за последние 5 лет?
- Сколько в базе клиентов из Калифорнии?
- Сколько заказов они сделали?
- Постройте сводную таблицу средних чеков по всем штатам за каждый год.
Через некоторое время выложу ответы в Телеграме. Подписывайтесь, чтобы не пропустить ответы и новые статьи.
Кстати, большое спасибо Александру Марфицину за то, что помог отредактировать статью.
Библиотека Pandas для работы с данными
Pandas — программная библиотека на языке Python для обработки и анализа данных. Работа pandas с данными строится поверх библиотеки NumPy, являющейся инструментом более низкого уровня. Предоставляет специальные структуры данных и операции для манипулирования числовыми таблицами и временны́ми рядами.
Главный элемент пандаса — DataFrame (датафрейм, df), с которым можно производить необходимые преобразования. df — “таблица”, состоящая из строк и столбцов. По умолчанию, строчки таблицы — это объекты, а столбцы — признаки (фичи) объектов.
import pandas as pd import numpy as np
Создание DataFrame
d = 'feature1': [4,3,2,1,0], 'feature2': ['x', 'z', 'y', 'x', 'z'], 'feature3': [2,3,4,1,0]> df = pd.DataFrame(d) df
feature1 | feature2 | feature3 | |
---|---|---|---|
0 | 4 | x | 2 |
1 | 3 | z | 3 |
2 | 2 | y | 4 |
3 | 1 | x | 1 |
4 | 0 | z | 0 |
data = [['tom', 10], ['nick', 15], ['juli', 14]] df = pd.DataFrame(data, columns = ['Name', 'Age']) df
Name | Age | |
---|---|---|
0 | tom | 10 |
1 | nick | 15 |
2 | juli | 14 |
data = 'Name':['Tom', 'Jack', 'nick', 'juli'], 'marks':[99, 98, 95, 90]> df = pd.DataFrame(data, index =['rank1', 'rank2', 'rank3', 'rank4']) df
Name | marks | |
---|---|---|
rank1 | Tom | 99 |
rank2 | Jack | 98 |
rank3 | nick | 95 |
rank4 | juli | 90 |
data = ['a': 1, 'b': 2, 'c':3>, 'a':10, 'b': 20>] df = pd.DataFrame(data) df
a | b | c | |
---|---|---|---|
0 | 1 | 2 | 3.0 |
1 | 10 | 20 | NaN |
d = 'one' : pd.Series([10, 20, 30, 40], index =['a', 'b', 'c', 'd']), 'two' : pd.Series([10, 20, 30, 40], index =['a', 'b', 'c', 'd'])> df = pd.DataFrame(d) df
one | two | |
---|---|---|
a | 10 | 10 |
b | 20 | 20 |
c | 30 | 30 |
d | 40 | 40 |
Первичный анализ данных с Pandas
Pandas — это библиотека Python, предоставляющая широкие возможности для анализа данных. С ее помощью очень удобно загружать, обрабатывать и анализировать табличные данные с помощью SQL-подобных запросов. В связке с библиотеками Matplotlib и Seaborn появляется возможность удобного визуального анализа табличных данных.
Данные, с которыми работают датсаентисты и аналитики, обычно хранятся в виде табличек — например, в форматах .csv, .tsv или .xlsx. Для того, чтобы считать нужные данные из такого файла, отлично подходит библиотека Pandas.
Основными структурами данных в Pandas являются классы Series и DataFrame. Первый из них представляет собой одномерный индексированный массив данных некоторого фиксированного типа. Второй — это двухмерная структура данных, представляющая собой таблицу, каждый столбец которой содержит данные одного типа. Можно представлять её как словарь объектов типа Series. Структура DataFrame отлично подходит для представления реальных данных: строки соответствуют признаковым описаниям отдельных объектов, а столбцы соответствуют признакам.
pd.read_csv('beauty.csv', nrows=2)
wage;exper;union;goodhlth;black;female;married;service;educ;looks | |
---|---|
0 | 5.73;30;0;1;0;1;1;1;14;4 |
1 | 4.28;28;0;1;0;1;1;0;12;3 |
#help(pd.read_csv) path_to_file = 'beauty.csv' data = pd.read_csv(path_to_file, sep=';') print(data.shape) #df.tail() data.head()
(1260, 10)
wage | exper | union | goodhlth | black | female | married | service | educ | looks | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 5.73 | 30 | 0 | 1 | 0 | 1 | 1 | 1 | 14 | 4 |
1 | 4.28 | 28 | 0 | 1 | 0 | 1 | 1 | 0 | 12 | 3 |
2 | 7.96 | 35 | 0 | 1 | 0 | 1 | 0 | 0 | 10 | 4 |
3 | 11.57 | 38 | 0 | 1 | 0 | 0 | 1 | 1 | 16 | 3 |
4 | 11.42 | 27 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 3 |
Мы считали данные по модельному бизнесу 80-90е года в США
type(data)
pandas.core.frame.DataFrame
#data.shape len(data)
1260
Чтобы посмотреть общую информацию по датафрейму и всем признакам, воспользуемся методом info:
data.info()
RangeIndex: 1260 entries, 0 to 1259 Data columns (total 10 columns): wage 1260 non-null float64 exper 1260 non-null int64 union 1260 non-null int64 goodhlth 1260 non-null int64 black 1260 non-null int64 female 1260 non-null int64 married 1260 non-null int64 service 1260 non-null int64 educ 1260 non-null int64 looks 1260 non-null int64 dtypes: float64(1), int64(9) memory usage: 98.6 KB
int64 и float64 — это типы признаков. Видим, что 1 признак — float64 и 9 признаков имеют тип int64.
Метод describe показывает основные статистические характеристики данных по каждому числовому признаку (типы int64 и float64): число непропущенных значений, среднее, стандартное отклонение, диапазон, медиану, 0.25 и 0.75 квартили.
data.describe()
wage | exper | union | goodhlth | black | female | married | service | educ | looks | |
---|---|---|---|---|---|---|---|---|---|---|
count | 1260.000000 | 1260.000000 | 1260.000000 | 1260.000000 | 1260.000000 | 1260.000000 | 1260.000000 | 1260.000000 | 1260.000000 | 1260.000000 |
mean | 6.306690 | 18.206349 | 0.272222 | 0.933333 | 0.073810 | 0.346032 | 0.691270 | 0.273810 | 12.563492 | 3.185714 |
std | 4.660639 | 11.963485 | 0.445280 | 0.249543 | 0.261564 | 0.475892 | 0.462153 | 0.446089 | 2.624489 | 0.684877 |
min | 1.020000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 5.000000 | 1.000000 |
25% | 3.707500 | 8.000000 | 0.000000 | 1.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 12.000000 | 3.000000 |
50% | 5.300000 | 15.000000 | 0.000000 | 1.000000 | 0.000000 | 0.000000 | 1.000000 | 0.000000 | 12.000000 | 3.000000 |
75% | 7.695000 | 27.000000 | 1.000000 | 1.000000 | 0.000000 | 1.000000 | 1.000000 | 1.000000 | 13.000000 | 4.000000 |
max | 77.720000 | 48.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 1.000000 | 17.000000 | 5.000000 |
Посмотрим на признак “exper” — рабочий стаж
data['exper'].head() #data.exper.head() # 2-ой вариант
0 30 1 28 2 35 3 38 4 27 Name: exper, dtype: int64
Как описывалось ранее — тип данных в колонке является Series, что по сути является проиндексированным массивом
type(data['exper'])
pandas.core.series.Series
loc и iloc
С помощью loc и iloc — можно из начального датафрейма зафиксировать определённые интервал строк и интересующих столбцов и работать/смотреть только их
#data.loc[1:5, ['wage']] data.wage.loc[1:5]
1 4.28 2 7.96 3 11.57 4 11.42 5 3.91 Name: wage, dtype: float64
#data.iloc[0,1] # первое число - номер столбца (начинается с 0). Второе - индекс строчки data['wage'].iloc[1:5]
1 4.28 2 7.96 3 11.57 4 11.42 Name: wage, dtype: float64
Условия
Посмотрим на наш датафрейм, на соответствие какому-то условию
(data['exper'] >= 15)
0 True 1 True 2 True 3 True 4 True . 1255 True 1256 False 1257 True 1258 True 1259 True Name: exper, Length: 1260, dtype: bool
Посмотрим только те строки, в датафрейме, которые удовлетворяют определённому условию, и выведем первые 5 из них
data[(data['female'] == 1) & (data['black'] == 1)].head(10)
wage | exper | union | goodhlth | black | female | married | service | educ | looks | |
---|---|---|---|---|---|---|---|---|---|---|
44 | 4.95 | 20 | 0 | 1 | 1 | 1 | 0 | 1 | 14 | 3 |
85 | 10.12 | 40 | 0 | 1 | 1 | 1 | 0 | 1 | 10 | 3 |
110 | 3.37 | 36 | 0 | 1 | 1 | 1 | 0 | 1 | 13 | 3 |
148 | 7.21 | 20 | 1 | 0 | 1 | 1 | 1 | 1 | 17 | 3 |
167 | 2.81 | 14 | 0 | 1 | 1 | 1 | 1 | 0 | 13 | 3 |
211 | 2.88 | 7 | 0 | 1 | 1 | 1 | 0 | 1 | 13 | 4 |
497 | 7.07 | 8 | 1 | 1 | 1 | 1 | 0 | 0 | 13 | 3 |
499 | 3.89 | 4 | 0 | 1 | 1 | 1 | 0 | 0 | 16 | 4 |
504 | 6.54 | 8 | 0 | 1 | 1 | 1 | 0 | 0 | 13 | 3 |
507 | 7.69 | 16 | 0 | 1 | 1 | 1 | 1 | 0 | 13 | 3 |
Посмотрим только те строки, которые удовлетворяют условию и выведем значение определённого столбца
data[data['female'] == 1]['wage'].head(10)
0 5.73 1 4.28 2 7.96 5 3.91 8 5.00 9 3.89 10 3.45 18 10.44 19 7.69 44 4.95 Name: wage, dtype: float64
data[(data['female'] == 0) & (data['married'] == 1)].head(10)
wage | exper | union | goodhlth | black | female | married | service | educ | looks | |
---|---|---|---|---|---|---|---|---|---|---|
3 | 11.57 | 38 | 0 | 1 | 0 | 0 | 1 | 1 | 16 | 3 |
4 | 11.42 | 27 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 3 |
6 | 8.76 | 12 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 3 |
11 | 4.03 | 6 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 4 |
12 | 5.14 | 19 | 0 | 1 | 0 | 0 | 1 | 1 | 17 | 2 |
14 | 7.99 | 12 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 4 |
15 | 6.01 | 17 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 4 |
16 | 5.16 | 7 | 0 | 1 | 0 | 0 | 1 | 0 | 17 | 3 |
17 | 11.54 | 12 | 0 | 1 | 0 | 0 | 1 | 1 | 17 | 4 |
21 | 6.79 | 19 | 0 | 1 | 0 | 0 | 1 | 1 | 14 | 3 |
# Метод describe для сложного условия data[(data['female'] == 0) & (data['married'] == 1)].describe()
wage | exper | union | goodhlth | black | female | married | service | educ | looks | |
---|---|---|---|---|---|---|---|---|---|---|
count | 658.000000 | 658.000000 | 658.000000 | 658.000000 | 658.000000 | 658.0 | 658.0 | 658.000000 | 658.000000 | 658.000000 |
mean | 7.716778 | 22.136778 | 0.308511 | 0.937690 | 0.037994 | 0.0 | 1.0 | 0.194529 | 12.495441 | 3.164134 |
std | 4.798763 | 11.714753 | 0.462230 | 0.241902 | 0.191327 | 0.0 | 0.0 | 0.396139 | 2.716007 | 0.655469 |
min | 1.050000 | 1.000000 | 0.000000 | 0.000000 | 0.000000 | 0.0 | 1.0 | 0.000000 | 5.000000 | 1.000000 |
25% | 4.810000 | 12.000000 | 0.000000 | 1.000000 | 0.000000 | 0.0 | 1.0 | 0.000000 | 12.000000 | 3.000000 |
50% | 6.710000 | 20.500000 | 0.000000 | 1.000000 | 0.000000 | 0.0 | 1.0 | 0.000000 | 12.000000 | 3.000000 |
75% | 8.890000 | 32.000000 | 1.000000 | 1.000000 | 0.000000 | 0.0 | 1.0 | 0.000000 | 13.000000 | 4.000000 |
max | 41.670000 | 48.000000 | 1.000000 | 1.000000 | 1.000000 | 0.0 | 1.0 | 1.000000 | 17.000000 | 5.000000 |
Посчитаем средние значения из тех данных, что удовлетворяют условию
data[data['female'] == 1]['wage'].mean(), data[data['female'] == 0]['wage'].mean() # .std, .min, .max, .count
(4.299357798165136, 7.3688228155339734)
Вывод медианного значения, для данных, удовлетворяющих сложному условию
data[(data['female'] == 0) & (data['married'] == 1)]['wage'].median(), \ data[(data['female'] == 0) & (data['married'] == 0)]['wage'].median()
(6.710000000000001, 5.0649999999999995)
data['wage'].nunique()
Ниже приводятся примеры использования метода groupby для отображения информации по сгруппированному признаку
data.groupby('looks').wage.count()
looks 1 13 2 142 3 722 4 364 5 19 Name: wage, dtype: int64
for look, sub_df in data.drop(['goodhlth'],axis=1).groupby('looks'): print(look) print(sub_df.head()) print()
1 wage exper union black female married service educ looks 28 8.35 41 0 0 0 1 1 16 1 200 3.75 36 0 0 0 0 0 12 1 248 10.99 40 0 0 0 1 0 12 1 327 1.65 24 0 0 1 0 1 13 1 751 7.93 39 1 0 0 1 0 12 1 2 wage exper union black female married service educ looks 12 5.14 19 0 0 0 1 1 17 2 33 8.17 18 0 0 0 1 0 16 2 35 9.62 37 0 0 0 1 0 13 2 37 7.69 10 1 0 0 1 0 13 2 57 6.56 17 0 0 0 1 0 13 2 3 wage exper union black female married service educ looks 1 4.28 28 0 0 1 1 0 12 3 3 11.57 38 0 0 0 1 1 16 3 4 11.42 27 0 0 0 1 0 16 3 5 3.91 20 0 0 1 1 0 12 3 6 8.76 12 0 0 0 1 0 16 3 4 wage exper union black female married service educ looks 0 5.73 30 0 0 1 1 1 14 4 2 7.96 35 0 0 1 0 0 10 4 7 7.69 5 1 0 0 0 0 16 4 10 3.45 3 0 0 1 0 0 12 4 11 4.03 6 0 0 0 1 0 16 4 5 wage exper union black female married service educ looks 26 14.84 29 0 0 0 0 1 13 5 27 19.08 17 0 0 0 0 0 17 5 76 23.32 15 0 0 0 1 1 17 5 112 6.11 7 0 0 1 1 0 12 5 316 3.92 12 0 0 0 1 1 12 5
for look, sub_df in data.groupby('looks'): print(look) print(sub_df['wage'].median()) print()
1 3.46 2 4.595000000000001 3 5.635 4 5.24 5 4.81
for look, sub_df in data.groupby('looks'): print(look) print(round(sub_df['female'].mean(), 3)) print()
1 0.385 2 0.38 3 0.323 4 0.374 5 0.421
for look, sub_df in data.groupby(['looks', 'female']): print(look) print(sub_df['goodhlth'].mean()) print()
(1, 0) 0.75 (1, 1) 1.0 (2, 0) 0.9431818181818182 (2, 1) 0.9259259259259259 (3, 0) 0.9304703476482618 (3, 1) 0.9012875536480687 (4, 0) 0.9649122807017544 (4, 1) 0.9411764705882353 (5, 0) 1.0 (5, 1) 1.0
С помощью .agg метод groupby может применять различные функции к данным, что он получает
data.groupby('looks')[['wage', 'exper']].max()
wage | exper | |
---|---|---|
looks | ||
1 | 10.99 | 41 |
2 | 26.24 | 45 |
3 | 38.86 | 48 |
4 | 77.72 | 47 |
5 | 23.32 | 32 |
Декартово произведение признаков из столбцов и их отображение
pd.crosstab(data['female'], data['married'])
married | 0 | 1 |
---|---|---|
female | ||
0 | 166 | 658 |
1 | 223 | 213 |
pd.crosstab(data['female'], data['looks'])
looks | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|
female | |||||
0 | 8 | 88 | 489 | 228 | 11 |
1 | 5 | 54 | 233 | 136 | 8 |
Создание нового признака из наложения дополнительных условий на основе старых данных
data['exp'] = (data['exper'] >=15).astype(int) data.head(10)
wage | exper | union | goodhlth | black | female | married | service | educ | looks | exp | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 5.73 | 30 | 0 | 1 | 0 | 1 | 1 | 1 | 14 | 4 | 1 |
1 | 4.28 | 28 | 0 | 1 | 0 | 1 | 1 | 0 | 12 | 3 | 1 |
2 | 7.96 | 35 | 0 | 1 | 0 | 1 | 0 | 0 | 10 | 4 | 1 |
3 | 11.57 | 38 | 0 | 1 | 0 | 0 | 1 | 1 | 16 | 3 | 1 |
4 | 11.42 | 27 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 3 | 1 |
5 | 3.91 | 20 | 0 | 0 | 0 | 1 | 1 | 0 | 12 | 3 | 1 |
6 | 8.76 | 12 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 3 | 0 |
7 | 7.69 | 5 | 1 | 1 | 0 | 0 | 0 | 0 | 16 | 4 | 0 |
8 | 5.00 | 5 | 0 | 1 | 0 | 1 | 0 | 0 | 16 | 3 | 0 |
9 | 3.89 | 12 | 0 | 1 | 0 | 1 | 0 | 0 | 12 | 3 | 0 |
new = data[data['female'] == 1] new.to_csv('new.csv', index=False) new.head()
wage | exper | union | goodhlth | black | female | married | service | educ | looks | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 5.73 | 30 | 0 | 1 | 0 | 1 | 1 | 1 | 14 | 4 |
1 | 4.28 | 28 | 0 | 1 | 0 | 1 | 1 | 0 | 12 | 3 |
2 | 7.96 | 35 | 0 | 1 | 0 | 1 | 0 | 0 | 10 | 4 |
5 | 3.91 | 20 | 0 | 0 | 0 | 1 | 1 | 0 | 12 | 3 |
8 | 5.00 | 5 | 0 | 1 | 0 | 1 | 0 | 0 | 16 | 3 |
data['wage'].sort_values(ascending=False).head(3)
602 77.72 269 41.67 415 38.86 Name: wage, dtype: float64
data['is_rich'] = (data['wage'] > data['wage'].quantile(.75)).astype('int64')
data['wage'].quantile(.75)
7.695
data.head()
wage | exper | union | goodhlth | black | female | married | service | educ | looks | exp | is_rich | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 5.73 | 30 | 0 | 1 | 0 | 1 | 1 | 1 | 14 | 4 | 1 | 0 |
1 | 4.28 | 28 | 0 | 1 | 0 | 1 | 1 | 0 | 12 | 3 | 1 | 0 |
2 | 7.96 | 35 | 0 | 1 | 0 | 1 | 0 | 0 | 10 | 4 | 1 | 1 |
3 | 11.57 | 38 | 0 | 1 | 0 | 0 | 1 | 1 | 16 | 3 | 1 | 1 |
4 | 11.42 | 27 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 3 | 1 | 1 |
data['rubbish'] = .56 * data['wage'] + 0.32 * data['exper'] data.head()
wage | exper | union | goodhlth | black | female | married | service | educ | looks | exp | is_rich | rubbish | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 5.73 | 30 | 0 | 1 | 0 | 1 | 1 | 1 | 14 | 4 | 1 | 0 | 12.8088 |
1 | 4.28 | 28 | 0 | 1 | 0 | 1 | 1 | 0 | 12 | 3 | 1 | 0 | 11.3568 |
2 | 7.96 | 35 | 0 | 1 | 0 | 1 | 0 | 0 | 10 | 4 | 1 | 1 | 15.6576 |
3 | 11.57 | 38 | 0 | 1 | 0 | 0 | 1 | 1 | 16 | 3 | 1 | 1 | 18.6392 |
4 | 11.42 | 27 | 0 | 1 | 0 | 0 | 1 | 0 | 16 | 3 | 1 | 1 | 15.0352 |
Контест для проверки понимания ссылка.
Домашнее задание будет во 2ой части
Сайт построен с использованием Pelican. За основу оформления взята тема от Smashing Magazine. Исходные тексты программ, приведённые на этом сайте, распространяются под лицензией GPLv3, все остальные материалы сайта распространяются под лицензией CC-BY.
Руководство по использованию pandas для анализа больших наборов данных
При использовании библиотеки pandas для анализа маленьких наборов данных, размер которых не превышает 100 мегабайт, производительность редко становится проблемой. Но когда речь идёт об исследовании наборов данных, размеры которых могут достигать нескольких гигабайт, проблемы с производительностью могут приводить к значительному увеличению длительности анализа данных и даже могут становиться причиной невозможности проведения анализа из-за нехватки памяти.
В то время как инструменты наподобие Spark могут эффективно обрабатывать большие наборы данных (от сотен гигабайт до нескольких терабайт), для того чтобы полноценно пользоваться их возможностями обычно нужно достаточно мощное и дорогое аппаратное обеспечение. И, в сравнении с pandas, они не отличаются богатыми наборами средств для качественного проведения очистки, исследования и анализа данных. Для наборов данных средних размеров лучше всего попытаться более эффективно использовать pandas, а не переходить на другие инструменты.
В материале, перевод которого мы публикуем сегодня, мы поговорим об особенностях работы с памятью при использовании pandas, и о том, как, просто подбирая подходящие типы данных, хранящихся в столбцах табличных структур данных DataFrame , снизить потребление памяти почти на 90%.
Работа с данными о бейсбольных матчах
Мы будем работать с данными по бейсбольным играм Главной лиги, собранными за 130 лет и взятыми с Retrosheet.
Изначально эти данные были представлены в виде 127 CSV-файлов, но мы объединили их в один набор данных с помощью csvkit и добавили, в качестве первой строки получившейся таблицы, строку с названиями столбцов. Если хотите, можете загрузить нашу версию этих данных и экспериментировать с ними, читая статью.
Начнём с импорта набора данных и взглянем на его первые пять строк. Их вы можете найти в этой таблице, на листе Фрагмент исходного набора данных .
import pandas as pd gl = pd.read_csv('game_logs.csv') gl.head()
Ниже приведены сведения о наиболее важных столбцах таблицы с этими данными. Если вы хотите почитать пояснения по всем столбцам — здесь вы можете найти словарь данных для всего набора данных.
- date — Дата проведения игры.
- v_name — Название команды гостей.
- v_league — Лига команды гостей.
- h_name — Название команды хозяев.
- h_league — Лига команды хозяев.
- v_score — Очки команды гостей.
- h_score — Очки команды хозяев.
- v_line_score — Сводка по очкам команды гостей, например — 010000(10)00 .
- h_line_score — Сводка по очкам команды хозяев, например — 010000(10)0X .
- park_id — Идентификатор поля, на котором проводилась игра.
- attendance — Количество зрителей.
По умолчанию pandas, ради экономии времени, указывает приблизительные сведения об использовании памяти объектом DataFrame . Нас интересуют точные сведения, поэтому мы установим параметр memory_usage в значение ‘deep’ .
gl.info(memory_usage='deep')
Вот какие сведения нам удалось получить:
RangeIndex: 171907 entries, 0 to 171906 Columns: 161 entries, date to acquisition_info dtypes: float64(77), int64(6), object(78) memory usage: 861.6 MB
Как оказалось, у нас имеется 171,907 строк и 161 столбец. Библиотека pandas автоматически выяснила типы данных. Здесь присутствует 83 столбца с числовыми данными и 78 столбцов с объектами. Объектные столбцы используются для хранения строковых данных, и в тех случаях, когда столбец содержит данные разных типов.
Теперь, для того, чтобы лучше понять то, как можно оптимизировать использование памяти этим объектом DataFrame , давайте поговорим о том, как pandas хранит данные в памяти.
Внутреннее представление объекта DataFrame
Внутри pandas столбцы данных группируются в блоки со значениями одинакового типа. Вот пример того, как в pandas хранятся первые 12 столбцов объекта DataFrame .
Внутреннее представление данных разных типов в pandas
Можно заметить, что блоки не хранят сведения об именах столбцов. Происходит это из-за того, что блоки оптимизированы для хранения значений, имеющихся в ячейках таблицы объекта DataFrame . За хранение сведений о соответствии между индексами строк и столбцов набора данных и того, что хранится в блоках однотипных данных, отвечает класс BlockManager . Он играет роль API, который предоставляет доступ к базовым данным. Когда мы читаем, редактируем или удаляем значения, класс DataFrame взаимодействует с классом BlockManager для преобразования наших запросов в вызовы функций и методов.
Каждый тип данных имеет специализированный класс в модуле pandas.core.internals . Например, pandas использует класс ObjectBlock для представления блоков, содержащих строковые столбцы, и класс FloatBlock для представления блоков, содержащих столбцы, хранящие числа с плавающей точкой. Для блоков, представляющих числовые значения, выглядящие как целые числа или числа с плавающей точкой, pandas комбинирует столбцы и хранит их в виде структуры данных ndarray библиотеки NumPy. Эта структура данных построена на основе массива C, значения хранятся в непрерывном блоке памяти. Благодаря такой схеме хранения данных доступ к фрагментам данных осуществляется очень быстро.
Так как данные разных типов хранятся раздельно, мы исследуем использование памяти разными типами данных. Начнём со среднего показателя использования памяти по разным типам данных.
for dtype in ['float','int','object']: selected_dtype = gl.select_dtypes(include=[dtype]) mean_usage_b = selected_dtype.memory_usage(deep=True).mean() mean_usage_mb = mean_usage_b / 1024 ** 2 print("Average memory usage for <> columns: MB".format(dtype,mean_usage_mb))
В результате оказывается, что средние показатели по использованию памяти для данных разных типов выглядят так:
Average memory usage for float columns: 1.29 MB Average memory usage for int columns: 1.12 MB Average memory usage for object columns: 9.53 MB
Эти сведения дают нам понять то, что большая часть памяти уходит на 78 столбцов, хранящих объектные значения. Мы ещё поговорим об этом позже, а сейчас давайте подумаем о том, можем ли мы улучшить использование памяти столбцами, хранящими числовые данные.
Подтипы
Как мы уже говорили, pandas представляет числовые значения в виде структур данных ndarray NumPy и хранит их в непрерывных блоках памяти. Эта модель хранения данных позволяет экономно расходовать память и быстро получать доступ к значениям. Так как pandas представляет каждое значение одного и того же типа, используя одинаковое число байт, и структуры ndarray хранят сведения о числе значений, pandas может быстро и точно выдать сведения об объёме памяти, потребляемых столбцами, хранящими числовые значения.
У многих типов данных в pandas есть множество подтипов, которые могут использовать меньшее число байт для представления каждого значения. Например тип float имеет подтипы float16 , float32 и float64 . Число в имени типа указывает на количество бит, которые подтип использует для представления значений. Например, в только что перечисленных подтипах для хранения данных используется, соответственно, 2, 4, 8 и 16 байт. В следующей таблице представлены подтипы наиболее часто используемых в pandas типов данных.
Использование памяти, байт | Число с плавающей точкой | Целое число | Беззнаковое целое число | Дата и время | Логическое значение | Объект |
1 | int8 | uint8 | bool | |||
2 | float16 | int16 | uint16 | |||
4 | float32 | int32 | uint32 | |||
8 | float64 | int64 | uint64 | datetime64 | ||
Переменный объём памяти | object |
Значение типа int8 использует 1 байт (8 бит) для хранения числа и может представлять 256 двоичных значений (2 в 8 степени). Это означает, что этот подтип можно использовать для хранения значений в диапазоне от -128 до 127 (включая 0).
Для проверки минимального и максимального значения, подходящего для хранения с использованием каждого целочисленного подтипа, можно воспользоваться методом numpy.iinfo() . Рассмотрим пример:
import numpy as np int_types = ["uint8", "int8", "int16"] for it in int_types: print(np.iinfo(it))
Выполнив этот код, мы получаем следующие данные:
Machine parameters for uint8 --------------------------------------------------------------- min = 0 max = 255 --------------------------------------------------------------- Machine parameters for int8 --------------------------------------------------------------- min = -128 max = 127 --------------------------------------------------------------- Machine parameters for int16 --------------------------------------------------------------- min = -32768 max = 32767 ---------------------------------------------------------------
Тут можно обратить внимание на различие между типами uint (беззнаковое целое) и int (целое число со знаком). Оба типа имеют одинаковую ёмкость, но, при хранении в столбцах только положительных значений, беззнаковые типы позволяют эффективнее расходовать память.
Оптимизация хранения числовых данных с использованием подтипов
Функцию pd.to_numeric() можно использовать для нисходящего преобразования числовых типов. Для выбора целочисленных столбцов воспользуемся методом DataFrame.select_dtypes() , затем оптимизируем их и сравним использование памяти до и после оптимизации.
# Мы будем часто выяснять то, сколько памяти используется, # поэтому создадим функцию, которая поможет нам сэкономить немного времени. def mem_usage(pandas_obj): if isinstance(pandas_obj,pd.DataFrame): usage_b = pandas_obj.memory_usage(deep=True).sum() else: # исходим из предположения о том, что если это не DataFrame, то это Series usage_b = pandas_obj.memory_usage(deep=True) usage_mb = usage_b / 1024 ** 2 # преобразуем байты в мегабайты return " MB".format(usage_mb) gl_int = gl.select_dtypes(include=['int']) converted_int = gl_int.apply(pd.to_numeric,downcast='unsigned') print(mem_usage(gl_int)) print(mem_usage(converted_int)) compare_ints = pd.concat([gl_int.dtypes,converted_int.dtypes],axis=1) compare_ints.columns = ['before','after'] compare_ints.apply(pd.Series.value_counts)
Вот что получается в результате исследования потребления памяти:
До | После | |
uint8 | NaN | 5.0 |
uint32 | NaN | 1.0 |
int64 | 6.0 | NaN |
В результате можно видеть падение использования памяти с 7.9 до 1.5 мегабайт, то есть — мы снизили потребление памяти больше, чем на 80%. Общее воздействие этой оптимизации на исходный объект DataFrame , однако, не является особенно сильным, так как в нём очень мало целочисленных столбцов.
Сделаем то же самое со столбцами, содержащими числа с плавающей точкой.
gl_float = gl.select_dtypes(include=['float']) converted_float = gl_float.apply(pd.to_numeric,downcast='float') print(mem_usage(gl_float)) print(mem_usage(converted_float)) compare_floats = pd.concat([gl_float.dtypes,converted_float.dtypes],axis=1) compare_floats.columns = ['before','after'] compare_floats.apply(pd.Series.value_counts)
В результате получается следующее:
100.99 MB
50.49 MB
До | После | |
float32 | NaN | 77.0 |
float64 | 77.0 | NaN |
В результате все столбцы, хранившие числа с плавающей точкой с типом данных float64 , теперь хранят числа типа float32 , что дало нам 50% уменьшение использования памяти.
Создадим копию исходного объекта DataFrame , используем эти оптимизированные числовые столбцы вместо тех, что присутствовали в нём изначально, и посмотрим на общий показатель использования памяти после оптимизации.
optimized_gl = gl.copy() optimized_gl[converted_int.columns] = converted_int optimized_gl[converted_float.columns] = converted_float print(mem_usage(gl)) print(mem_usage(optimized_gl))
Вот что у нас получилось:
861.57 MB
804.69 MB
Хотя мы значительно уменьшили потребление памяти столбцами, хранящими числовые данные, в целом, по всему объекту DataFrame , потребление памяти снизилось лишь на 7%. Источником куда более серьёзного улучшения ситуации может стать оптимизация хранения объектных типов.
Прежде чем мы займёмся такой оптимизацией, поближе познакомимся с тем, как в pandas хранятся строки, и сравним это с тем, как здесь хранятся числа.
Сравнение механизмов хранения чисел и строк
Тип object представляет значения с использованием строковых объектов Python. Отчасти это так от того, что NumPy не поддерживает представление отсутствующих строковых значений. Так как Python — это высокоуровневый интерпретируемый язык, он не даёт программисту инструментов для тонкого управления тем, как данные хранятся в памяти.
Это ограничение ведёт к тому, что строки хранятся не в непрерывных фрагментах памяти, их представление в памяти фрагментировано. Это ведёт к увеличению потребления памяти и к замедлению скорости работы со строковыми значениями. Каждый элемент в столбце, хранящем объектный тип данных, на самом деле, представляет собой указатель, который содержит «адрес», по которому настоящее значение расположено в памяти.
Ниже показана схема, созданная на основе этого материала, на которой сравнивается хранение числовых данных с использованием типов данных NumPy и хранение строк с применением встроенных типов данных Python.
Хранение числовых и строковых данных
Тут вы можете вспомнить о том, что выше, в одной из таблиц, было показано, что для хранения данных объектных типов используется переменный объём памяти. Хотя каждый указатель занимает 1 байт памяти, каждое конкретное строковое значение занимает тот же объём памяти, который использовался бы для хранения отдельно взятой строки в Python. Для того чтобы это подтвердить, воспользуемся методом sys.getsizeof() . Сначала взглянем на отдельные строки, а затем на объект Series pandas, хранящий строковые данные.
Итак, сначала исследуем обычные строки:
from sys import getsizeof s1 = 'working out' s2 = 'memory usage for' s3 = 'strings in python is fun!' s4 = 'strings in python is fun!' for s in [s1, s2, s3, s4]: print(getsizeof(s))
Здесь данные по использованию памяти выглядят так:
Теперь посмотрим на то, как выглядит использование строк в объекте Series :
obj_series = pd.Series(['working out', 'memory usage for', 'strings in python is fun!', 'strings in python is fun!']) obj_series.apply(getsizeof)
Здесь мы получаем следующее:
0 60 1 65 2 74 3 74 dtype: int64
Тут можно видеть, что размеры строк, хранящихся в объектах Series pandas, аналогичны их размерам при работе с ними в Python и при представлении их в виде самостоятельных сущностей.
Оптимизация хранения данных объектных типов с использованием категориальных переменных
Категориальные переменные появились в pandas версии 0.15. Соответствующий тип, category , использует в своих внутренних механизмах, вместо исходных значений, хранящихся в столбцах таблицы, целочисленные значения. Pandas использует отдельный словарь, устанавливающий соответствия целочисленных и исходных значений. Такой подход полезен в тех случаях, когда столбцы содержат значения из ограниченного набора. Когда данные, хранящиеся в столбце, конвертируют в тип category , pandas использует подтип int , который позволяет эффективнее всего распорядиться памятью и способен представить все уникальные значения, встречающиеся в столбце.
Исходные данные и категориальные данные, использующие подтип int8
Для того чтобы понять, где именно мы сможем воспользоваться категориальными данными для снижения потребления памяти, выясним количество уникальных значений в столбцах, хранящих значения объектных типов:
gl_obj = gl.select_dtypes(include=['object']).copy() gl_obj.describe()
То, что у нас получилось, вы может найти в этой таблице, на листе Количество уникальных значений в столбцах .
Например, в столбце day_of_week , представляющем собой день недели, в который проводилась игра, имеется 171907 значений. Среди них всего 7 уникальных. В целом же, одного взгляда на этот отчёт достаточно для того, чтобы понять, что во многих столбцах для представления данных примерно 172000 игр используется довольно-таки мало уникальных значений.
Прежде чем мы займёмся полномасштабной оптимизацией, давайте выберем какой-нибудь один столбец, хранящий объектные данные, да хотя бы day_of_week , и посмотрим, что происходит внутри программы при преобразовании его в категориальный тип.
Как уже было сказано, в этом столбце содержится всего 7 уникальных значений. Для преобразования его в категориальный тип воспользуемся методом .astype() .
dow = gl_obj.day_of_week print(dow.head()) dow_cat = dow.astype('category') print(dow_cat.head())
Вот что у нас получилось:
0 Thu 1 Fri 2 Sat 3 Mon 4 Tue Name: day_of_week, dtype: object 0 Thu 1 Fri 2 Sat 3 Mon 4 Tue Name: day_of_week, dtype: category Categories (7, object): [Fri, Mon, Sat, Sun, Thu, Tue, Wed]
Как видите, хотя тип столбца изменился, данные, хранящиеся в нём, выглядят так же, как и раньше. Посмотрим теперь на то, что происходит внутри программы.
В следующем коде мы используем атрибут Series.cat.codes для того, чтобы выяснить то, какие целочисленные значения тип category использует для представления каждого из дней недели:
dow_cat.head().cat.codes
Нам удаётся выяснить следующее:
0 4 1 0 2 2 3 1 4 5 dtype: int8
Тут можно заметить то, что каждому уникальному значению назначено целочисленное значение, и то, что столбец теперь имеет тип int8 . Здесь нет отсутствующих значений, но если бы это было так, для указания таких значений использовалось бы число -1.
Теперь давайте сравним потребление памяти до и после преобразования столбца day_of_week к типу category .
print(mem_usage(dow)) print(mem_usage(dow_cat))
Вот что тут получается:
Как видно, сначала потреблялось 9.84 мегабайт памяти, а после оптимизации — лишь 0.16 мегабайт, что означает 98% улучшение этого показателя. Обратите внимание на то, что работа с этим столбцом, вероятно, демонстрирует один из наиболее выгодных сценариев оптимизации, когда в столбце, содержащем примерно 172000 элементов, используется лишь 7 уникальных значений.
Хотя идея преобразования всех столбцов к этому типу данных выглядит привлекательно, прежде чем это делать, стоит учитывать негативные побочные эффекты такого преобразования. Так, наиболее серьёзный минус этого преобразования заключается в невозможности выполнения арифметических операций над категориальными данными. Это касается и обычных арифметических операций, и использования методов наподобие Series.min() и Series.max() без предварительного преобразования данных к настоящему числовому типу.
Нам стоит ограничить использование типа category , в основном, столбцами, хранящими данные типа object , в которых уникальными являются менее 50% значений. Если все значения в столбце уникальны, то использование типа category приведёт к повышению уровня использования памяти. Это происходит из-за того, что в памяти приходится хранить, в дополнение к числовым кодам категорий, ещё и исходные строковые значения. Подробности об ограничениях типа category можно почитать в документации к pandas.
Создадим цикл, который перебирает все столбцы, хранящие данные типа object , выясняет, не превышает ли число уникальных значений в столбцах 50%, и если это так, преобразует их в тип category .
converted_obj = pd.DataFrame() for col in gl_obj.columns: num_unique_values = len(gl_obj[col].unique()) num_total_values = len(gl_obj[col]) if num_unique_values / num_total_values < 0.5: converted_obj.loc[:,col] = gl_obj[col].astype('category') else: converted_obj.loc[:,col] = gl_obj[col]
Теперь сравним то, что получилось после оптимизации, с тем, что было раньше:
print(mem_usage(gl_obj)) print(mem_usage(converted_obj)) compare_obj = pd.concat([gl_obj.dtypes,converted_obj.dtypes],axis=1) compare_obj.columns = ['before','after'] compare_obj.apply(pd.Series.value_counts)
752.72 MB
51.67 MB
До | После | |
object | 78.0 | NaN |
category | NaN | 78.0 |
В нашем случае все обрабатываемые столбцы были преобразованы к типу category , однако нельзя говорить о том, что то же самое произойдёт при обработке любого набора данных, поэтому, обрабатывая по этой методике свои данные, не забывайте о сравнениях того, что было до оптимизации, с тем, что получилось после её выполнения.
Как видно, объём памяти, необходимый для работы со столбцами, хранящими данные типа object , снизился с 752 мегабайт до 52 мегабайт, то есть на 93%. Теперь давайте посмотрим на то, как нам удалось оптимизировать потребление памяти по всему набору данных. Проанализируем то, на какой уровень использования памяти мы вышли, если сравнить то, что получилось, с исходным показателем в 891 мегабайт.
optimized_gl[converted_obj.columns] = converted_obj mem_usage(optimized_gl)
Вот что у нас получилось:
Результат впечатляет. Но мы ещё можем кое-что улучшить. Как было показано выше, в нашей таблице имеются данные типа datetime , столбец, хранящий которые, можно использовать в качестве первого столбца набора данных.
date = optimized_gl.date print(mem_usage(date)) date.head()
В плане использования памяти здесь получается следующее:
Вот сводка по данным:
0 18710504 1 18710505 2 18710506 3 18710508 4 18710509 Name: date, dtype: uint32
Можно вспомнить, что исходные данные были представлены в целочисленном виде и уже оптимизированы с использованием типа uint32 . Из-за этого преобразование этих данных в тип datetime приведёт к удвоению потребления памяти, так как этот тип использует для хранения данных 64 бита. Однако в преобразовании данных к типу datetime , всё равно, есть смысл, так как это позволит нам легче выполнять анализ временных рядов.
Преобразование выполняется с использованием функции to_datetime() , параметр format которой указывает на то, что данные хранятся в формате YYYY-MM-DD .
optimized_gl['date'] = pd.to_datetime(date,format='%Y%m%d') print(mem_usage(optimized_gl)) optimized_gl.date.head()
В результате получается следующее:
Данные теперь выглядят так:
0 1871-05-04 1 1871-05-05 2 1871-05-06 3 1871-05-08 4 1871-05-09 Name: date, dtype: datetime64[ns]
Выбор типов при загрузке данных
До сих пор мы исследовали способы уменьшения потребления памяти существующим объектом DataFrame . Мы сначала считывали данные в их исходном виде, затем, пошагово, занимались их оптимизацией, сравнивая то, что получилось, с тем, что было. Это позволило как следует разобраться с тем, чего можно ожидать от тех или иных оптимизаций. Как уже было сказано, часто для представления всех значений, входящих в некий набор данных, может попросту не хватить памяти. В связи с этим возникает вопрос о том, как применить методики экономии памяти в том случае, если нельзя даже создать объект DataFrame , который предполагается оптимизировать.
К счастью, оптимальные типы данных для отдельных столбцов можно указать ещё до фактической загрузки данных. Функция pandas.read_csv() имеет несколько параметров, позволяющих это сделать. Так, параметр dtype принимает словарь, в котором присутствуют, в виде ключей, строковые имена столбцов, и в виде значений — типы NumPy.
Для того чтобы воспользоваться этой методикой, мы сохраним итоговые типы всех столбцов в словаре с ключами, представленными именами столбцов. Но для начала уберём столбец с датой проведения игры, так как его нужно обрабатывать отдельно.
dtypes = optimized_gl.drop('date',axis=1).dtypes dtypes_col = dtypes.index dtypes_type = [i.name for i in dtypes.values] column_types = dict(zip(dtypes_col, dtypes_type)) # вместо вывода всех 161 элементов, мы # возьмём 10 пар ключ/значение из словаря # и аккуратно их выведем preview = first2pairs = import pprint pp = pp = pprint.PrettyPrinter(indent=4) pp.pprint(preview) Вот что у нас получится:
Теперь мы сможем воспользоваться этим словарём вместе с несколькими параметрами, касающимися данных о датах проведения игр, в ходе загрузки данных.
Соответствующий код получается довольно-таки компактным:
read_and_optimized = pd.read_csv('game_logs.csv',dtype=column_types,parse_dates=['date'],infer_datetime_format=True) print(mem_usage(read_and_optimized)) read_and_optimized.head()
В результате объём использования памяти выглядит так:
Данные теперь выглядят так, как показано на листе Фрагмент оптимизированного набора данных в этой таблице.
Внешне таблицы, приведённые на листах Фрагмент оптимизированного набора данных и Фрагмент исходного набора данных , за исключением столбца с датами, выглядят одинаково, но это касается лишь их внешнего вида. Благодаря оптимизации использования памяти в pandas нам удалось снизить потребление памяти с 861.6 Мбайт до 104.28 Мбайт, получив впечатляющий результат экономии 88% памяти.
Анализ бейсбольных матчей
Теперь, после того, как мы оптимизировали данные, мы можем заняться их анализом. Взглянем на распределение игровых дней.
optimized_gl['year'] = optimized_gl.date.dt.year games_per_day = optimized_gl.pivot_table(index='year',columns='day_of_week',values='date',aggfunc=len) games_per_day = games_per_day.divide(games_per_day.sum(axis=1),axis=0) ax = games_per_day.plot(kind='area',stacked='true') ax.legend(loc='upper right') ax.set_ylim(0,1) plt.show()
Дни, в которые проводились игры
Как видно, до 1920-х годов игры редко проводились по воскресеньям, после чего, примерно в течение 50 лет, игры в этот день постепенно проводились всё чаще.
Кроме того, можно заметить, что распределение дней недели, в которые проводились игры последние 50 лет, является практически неизменным.
Теперь взглянем на то, как со временем менялась длительность игр.
game_lengths = optimized_gl.pivot_table(index='year', values='length_minutes') game_lengths.reset_index().plot.scatter('year','length_minutes') plt.show()
Длительность игр
Возникает такое ощущение, что с 1940-х годов по настоящее время матчи становятся всё более длительными.
Итоги
В этом материале мы обсудили особенности хранения данных разных типов в pandas, после чего воспользовались полученными знаниями для уменьшения объёма памяти, необходимого для хранения объекта DataFrame , почти на 90%. Для этого мы применили две простые методики:
- Мы произвели нисходящее преобразование типов числовых данных, хранящихся в столбцах, выбрав более эффективные, в плане использования памяти, типы.
- Мы преобразовали строковые данные к категориальному типу данных.
Уважаемые читатели! Перевести эту статью нам порекомендовал наш читатель eugene_bb. Если вам известны какие-нибудь интересные материалы, которые стоит перевести — расскажите нам о них.
- Блог компании RUVDS.com
- Анализ и проектирование систем
- Big Data
- Хранение данных
Введение в анализ данных с помощью Pandas
Сегодня речь пойдет о пакете Pandas. Данный пакет делает Python мощным инструментом для анализа данных. Пакет дает возможность строить сводные таблицы, выполнять группировки, предоставляет удобный доступ к табличным данным, а при наличии пакета matplotlib дает возможность рисовать графики на полученных наборах данных. Далее будут показаны основы работы с пакетом, такие как загрузка данных, обращение к полям, фильтрация и построение сводных.
Основные структуры данных и их загрузка
- SQL
- Текстовые файлы
- Excel файлы
- HTML
from pandas import read_csv df1 = read_csv("df1.txt") df2 = read_csv("df2.txt",";") #второй аргумент задает разделитель
Теперь у нас есть 2 набора данных df1, содержащий магазины и количество отгрузок:
shop | qty |
---|---|
427 | 3 |
707 | 4 |
957 | 2 |
437 | 1 |
И df2, содержащий магазин и его город:
shop | name |
---|---|
347 | Киев |
427 | Самара |
707 | Минск |
957 | Иркутск |
437 | Москва |
Базовые операции с наборами данных
- в набор с городами магазинов добавим поле `country` и заполним соответствующими странами
- выберем украинский магазин и поменяем его номер
- добавим магазин, полученный на предыдущем шаге, к общему списку
- добавим количество из df1 к набору df2
- построим сводную таблицу по странам и количеству отгрузок
country = [u'Украина',u'РФ',u'Беларусь',u'РФ',u'РФ'] df2.insert(1,'country',country)
- номер позиции, куда будет вставлен новый столбец
- имя нового столбца
- массив значений столбца (в нашем случае, это обычный список list)
shop | country | name |
---|---|---|
347 | Украина | Киев |
427 | РФ | Самара |
707 | Беларусь | Минск |
957 | РФ | Иркутск |
437 | РФ | Москва |
- через точку — НаборДанных.ИмяПоля
- в квадратных скобках – НаборДанных[‘ИмяПоля’]
t = df2[df2.country == u'Украина'] t.shop = 345
Результатом выполнения данного кода, будет новый промежуточный набор данных t, содержащий одну запись:
shop | country | name |
---|---|---|
345 | Украина | Киев |
Для того чтобы добавить полученную на предыдущем шаге запись, нужно выполнить функцию append(), в качестве аргумента которой передается набор данных, который нужно добавить к исходному:
df2 = df2.append(t)
Агрегация данных
Теперь к нашему основному списку магазинов df2, можно подтянуть количество из набора данных df1. Сделать это можно с помощью функции merge(), которая соединяет два набора данных (аналог join в SQL):
res = df2.merge(df1, 'left', on='shop')
- набор данных (который будет присоединен к исходному)
- тип соединения
- поле, по которому происходит соединение
shop | country | name | qty |
---|---|---|---|
347 | Украина | Киев | NaN |
427 | РФ | Самара | 3 |
707 | Беларусь | Минск | 4 |
957 | РФ | Иркутск | 2 |
437 | РФ | Москва | 1 |
345 | Украина | Киев | NaN |
- список столбцов, по которым будет считаться агрегированные значение
- список столбцов, которые будут строками итоговой таблицы
- функция, которая используется для агрегации
- параметр для замены пустых значений на 0
res.pivot_table(['qty'],['country'], aggfunc='sum', fill_value = 0)
Итоговая таблица будет выглядеть так:
country | qty |
---|---|
Беларусь | 4 |
РФ | 6 |
Украина | 0 |
Заключение
В качестве заключения хотелось бы сказать, Pandas является неплохой альтернативой Excel при работе с большими объемами данных. Показанные функции это только верхушка айсберга под название Pandas. В дальнейшем, я планирую написать серию статей в которых будет показана вся мощь данного пакета.