Трансформация данных в pandas ч.2 / pd 12

Манипуляция строками

Python — это популярный язык, который благодаря своей простоте часто используется для обработки строк и текста. Большая часть операций может быть выполнена даже с помощью встроенных функций. А для более сложных используются регулярные выражения.

Встроенные методы для работы со строками

В большинстве случаев имеются сложные строки, которые желательно разделять на части и присваивать их правильным переменным. Функция split() позволяет разбить тексты на части, используя разделитель в качестве ориентира. Им может быть, например, запятая.

>>> text = '16 Bolton Avenue , Boston'
>>> text.split(',')
['16 Bolton Avenue ', 'Boston']

По первому элементу видно, что в конце у него остается пробел. Чтобы решить эту проблему, вместе со split() нужно также использовать функцию strip(), которая обрезает пустое пространство (включая символы новой строки)

>>> tokens = [s.strip() for s in text.split(',')]
>>> tokens
['16 Bolton Avenue', 'Boston']

Результат — массив строк. Если элементов не много, то можно выполнить присваивание вот так:

>>> address, city = [s.strip() for s in text.split(',')]
>>> address
'16 Bolton Avenue'
>>> city
'Boston'

Помимо разбития текста на части часто требуется сделать обратное — конкатенировать разные строки, получив в результате текст большого объема. Самый простой способ — использовать оператор +.

>>> address + ',' + city
'16 Bolton Avenue, Boston'

Но это сработает только в том случае, если строк не больше двух-трех. Если же их больше, то есть метод join(). Его нужно применять к желаемому разделителю, передав в качестве аргумента список строк.

>>> strings = ['A+','A','A-','B','BB','BBB','C+']
>>> ';'.join(strings)
'A+;A;A-;B;BB;BBB;C+'

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

>>> 'Boston' in text
True

Но имеются и две функции, которые выполняют ту же задачу: index() и find().

>>> text.index('Boston')
19
>>> text.find('Boston')
19

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

>>> text.index('New York')
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ValueError: substring not found
>>> text.find('New York')
-1

Если index() вернет сообщение с ошибкой, то find()-1. Также можно посчитать, как часто символ или комбинация из нескольких (подстрока) встречаются в тексте. За это отвечает функция count().

>>> text.count('e')
2
>>> text.count('Avenue')
1

Еще одна доступная операция — замена или удаление подстроки (или отдельного символа). В обоих случаях применяется функция replace(), где удаление подстроки — это то же самое, что и замена ее на пустой символ.

Регулярные выражения

Регулярные выражения предоставляют гибкий способ поиска совпадающих паттернов в тексте. Выражение regex — это строка, написанная с помощью языка регулярных выражений. В Python есть встроенный модуль re, который отвечает за работу с регулярными выражениями.

В первую очередь его нужно импортировать:

>>> import re

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

  • Поиск совпадающих паттернов
  • Замена
  • Разбиение

Теперь разберем на примерах. Регулярное выражение для поиска одного или последовательности пробельных символов\s+. В прошлом разделе вы видели, как для разделения текста на части с помощью split() используется символ разделения. В модуле re есть такая же функция. Она выполняет аналогичную задачу, но в качестве аргумента условия разделения принимает паттерн с регулярным выражением, что делает ее более гибкой.

>>> text = "This is an\t odd \n text!"
>>> re.split('\s+', text)
['This', 'is', 'an', 'odd', 'text!']

Разберемся чуть подробнее с принципом работы модуля re. При вызове функции re.split() сперва компилируется регулярное выражение, а только потом вызывается split() с готовым текстовым аргументом. Можно скомпилировать функцию регулярного выражения с помощью re.compile() и получить объект, который будет использоваться повторно, сэкономив таким образом циклы CPU.

Это особенно важно для операций последовательного поиска подстроки во множестве или массиве строк.

>>> regex = re.compile('\s+')

Создав объект regex с помощью функции compile(), вы сможете прямо использовать split() следующим образом.

>>> regex.split(text)
['This', 'is', 'an', 'odd', 'text!']

Для поиска совпадений паттерна с другими подстроками в тексте используется функция findall(). Она возвращает список всех подстрок, которые соответствуют условиям.

Например, если нужно найти в строке все слова, начинающиеся с латинской «A» в верхнем регистре, или, например, с «a» в любом регистре, необходимо ввести следующее:

>>> text = 'This is my address: 16 Bolton Avenue, Boston'
>>> re.findall('A\w+',text)
['Avenue']
>>> re.findall('[A,a]\w+',text)
['address', 'Avenue']

Есть еще две функции, которые связаны с findall():match() и search(). И если findall() возвращает все совпадения в списке, то search() — только первое. Более того, он является конкретным объектом.

>>> re.search('[A,a]\w+',text)
<_sre.SRE_Match object; span=(11, 18), match='address'>

Этот объект не содержит значение подстроки, соответствующей паттерну, а всего лишь индексы начала и окончания.

>>> search = re.search('[A,a]\w+',text)
>>> search.start()
11
>>> search.end()
18
>>> text[search.start():search.end()]
'address'

Функция match() ищет совпадение в начале строке; если его нет для первого символа, то двигается дальше и ищет в самой строке. Если совпадений не найдено вовсе, то она ничего не вернет.

>>> re.match('[A,a]\w+',text)

В случае успеха же она возвращает то же, что и функция search().

>>> re.match('T\w+',text)
<_sre.SRE_Match object; span=(0, 4), match='This'>
>>> match = re.match('T\w+',text)
>>> text[match.start():match.end()]
'This'

Агрегация данных

Последний этап работы с данными — агрегация. Он включает в себя преобразование, в результате которого из массива получается целое число. На самом деле, ранее упоминаемые функции sum(), mean() и count() — это тоже агрегация. Они работают с наборами данных и выполняют вычисления, результатом которых всегда является одно значение. Однако более формальный способ, дающий больше контроля над агрегацией, включает категоризацию наборов данных.

Категоризация набора, необходимая для группировки, — это важный этап в процессе обработки данных. Это тоже процесс преобразования, ведь после разделения на группы, применяется функция, которая конвертирует или преобразовывает данные определенным образом в зависимости от того, к какой группе они принадлежат. Часто фазы группировки и применения функции происходит в один шаг.

Также для этого этапа анализа данных pandas предоставляет гибкий и производительный инструмент — GroupBy.

Как и в случае с join те, кто знаком с реляционными базами данных и языком SQL, увидят знакомые вещи. Однако языки, такие как SQL, довольно ограничены, когда их применяют к группам. А вот гибкость таких языков, как Python, со всеми доступными библиотеками, особенно pandas, дает возможность выполнять очень сложные операции.

GroupBy

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

  • Разделение — разделение на группы датасетов
  • Применение — применение функции к каждой группе
  • Комбинирование — комбинирование результатов разных групп

Рассмотрите процесс подробно на следующей схеме. На первом этапе, разделении, данные из структуры (Dataframe или Series) разделяются на несколько групп в соответствии с заданными критериями: индексами или значениями в колонках. На жаргоне SQL значения в этой колонке называются ключами. Если же вы работаете с двухмерными объектами, такими как Dataframe, критерий группировки может быть применен и к строке (axis = 0), и колонке (axis = 1).


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

Последний этап собирает результаты каждой группы и комбинирует их в один объект.

Практический пример

Теперь вы знаете, что процесс агрегации данных в pandas разделен на несколько этапов: разделение-применение-комбинирование. И пусть в библиотеке они не выражены явно конкретными функциями, функция groupby() генерирует объект GroupBy, который является ядром целого процесса.

Для лучшего понимания этого механизма стоит обратиться к реальному примеру. Сперва создадим Dataframe с разными числовыми и текстовыми значениями.

>>> frame = pd.DataFrame({ 'color': ['white','red','green','red','green'],
... 'object': ['pen','pencil','pencil','ashtray','pen'],
... 'price1' : [5.56,4.20,1.30,0.56,2.75],
... 'price2' : [4.75,4.12,1.60,0.75,3.15]})
>>> frame

|   | color | object  | price1 | price2 |
|---|-------|---------|--------|--------|
| 0 | white | pen     | 5.56   | 4.75   |
| 1 | red   | pencil  | 4.20   | 4.12   |
| 2 | green | pencil  | 1.30   | 1.60   |
| 3 | red   | ashtray | 0.56   | 0.75   |
| 4 | green | pen     | 2.75   | 3.15   |

Предположим, нужно посчитать среднюю стоимость в колонке price1 с помощью меток из колонки color. Есть несколько способов, как этого можно добиться. Например, можно получить доступ к колонке price1 и затем вызвать groupby(), где колонка color будет выступать аргументом.

>>> group = frame['price1'].groupby(frame['color'])
>>> group
<pandas.core.groupby.SeriesGroupBy object at 0x00000000098A2A20>

Результат — объект GroupBy. Однако в этой операции не было никаких вычислений; пока что была лишь собрана информация, которая необходима для вычисления среднего значения. Теперь у нас есть group, где все строки с одинаковым значением цвета сгруппированы в один объект.

Чтобы понять, как произошло такое разделение на группы, вызовите атрибут groups для объекта GroupBy.

>>> group.groups
{'green': Int64Index([2, 4], dtype='int64'),
 'red': Int64Index([1, 3], dtype='int64'),
 'white': Int64Index([0], dtype='int64')}

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

>>> group.mean()
color
green 2.025
red   2.380
white 5.560
Name: price1, dtype: float64
>>> group.sum()
color
green 4.05
red   4.76
white 5.56
Name: price1, dtype: float64

Группировка по иерархии

В прошлом разделе данные были сгруппированы по значениям колонки-ключа. Тот же подход можно использовать и для нескольких колонок, сделав группировку нескольких ключей иерархической.

>>> ggroup = frame['price1'].groupby([frame['color'],frame['object']])
>>> ggroup.groups
{('green', 'pen'): Int64Index([4], dtype='int64'),
 ('green', 'pencil'): Int64Index([2], dtype='int64'),
 ('red', 'ashtray'): Int64Index([3], dtype='int64'),
 ('red', 'pencil'): Int64Index([1], dtype='int64'),
 ('white', 'pen'): Int64Index([0], dtype='int64')}
>>> ggroup.sum()
color  object 
green  pen        2.75
       pencil     1.30
red    ashtray    0.56
       pencil     4.20
white  pen        5.56
Name: price1, dtype: float64

Группировка может работать не только с одной колонкой, но и с несколькими или целым Dataframe. Также если объект GroupBy не потребуется использовать несколько раз, просто удобно выполнять группировки и расчеты за раз, без объявления дополнительных переменных.

>>> frame[['price1','price2']].groupby(frame['color']).mean()

|       | price1 | price2 |
|-------|--------|--------|
| color |        |        |
| green | 2.025  | 2.375  |
| red   | 2.380  | 2.435  |
| white | 5.560  | 4.750  |

>>> frame.groupby(frame['color']).mean()

|       | price1 | price2 |
|-------|--------|--------|
| color |        |        |
| green | 2.025  | 2.375  |
| red   | 2.380  | 2.435  |
| white | 5.560  | 4.750  |

Итерация с группировкой

Объект GroupBy поддерживает операцию итерации для генерации последовательности из двух кортежей, содержащих названия групп и их данных.

>>> for name, group in frame.groupby('color'):
...     print(name)
...     print(group)

green
   color  object  price1  price2
2  green  pencil    1.30    1.60
4  green     pen    2.75    3.15
red
  color   object  price1  price2
1   red   pencil    4.20    4.12
3   red  ashtray    0.56    0.75
white
   color object  price1  price2
0  white    pen    5.56    4.75

В последнем примере для иллюстрации был применен вывод переменных. Но операцию вывода на экран можно заменить на функцию, которую требуется применить.

Цепочка преобразований

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

>>> result1 = frame['price1'].groupby(frame['color']).mean()
>>> type(result1)
<class 'pandas.core.series.Series'>
>>> result2 = frame.groupby(frame['color']).mean()
>>> type(result2)
<class 'pandas.core.frame.DataFrame'>

Таким образом становится возможным выбрать одну колонку на разных этапах процесса. Дальше три примера выбора одной колонки на трех разных этапах. Они иллюстрируют гибкость такой системы группировки в pandas.

>>> frame['price1'].groupby(frame['color']).mean()
color
green 2.025
red   2.380
white 5.560
Name: price1, dtype: float64
>>> frame.groupby(frame['color'])['price1'].mean()
color
green 2.025
red   2.380
white 5.560
Name: price1, dtype: float64
>>> (frame.groupby(frame['color']).mean())['price1']
color
green 2.025
red   2.380
white 5.560
Name: price1, dtype: float64

Но также после операции агрегации имена некоторых колонок могут не нести нужное значение. Поэтому часто оказывается полезным добавлять префикс, объясняющий бизнес-логику такого объединения. Добавление префикса (вместо использования полностью нового имени) помогает отслеживать источник данных. Это важно в случае применения процесса цепочки преобразований (когда Series или Dataframe генерируются друг из друга), где важно отслеживать исходные данные.

>>> means = frame.groupby('color').mean().add_prefix('mean_')
>>> means

|       | mean_price1 | mean_price2 |
|-------|-------------|-------------|
| color |             |             |
| green | 2.025       | 2.375       |
| red   | 2.380       | 2.435       |
| white | 5.560       | 4.750       |

Функции для групп

Хотя многие методы не были реализованы специально для GroupBy, они корректно работают с Series. В прошлых примерах было видно, насколько просто получить Series на основе объекта GroupBy, указав имя колонки и применив метод для вычислений. Например, можно использование вычисление квантилей с помощью функции quantiles().

>>> group = frame.groupby('color')
>>> group['price1'].quantile(0.6)
color
green 2.170
red   2.744
white 5.560
Name: price1, dtype: float64

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


>>> def range(series):
... return series.max() - series.min()
...
>>> group['price1'].agg(range)
color
green 1.45
red   3.64
white 0.00
Name: price1, dtype: float64

Функция agg() позволяет использовать функции агрегации для всего объекта Dataframe.

>>> group.agg(range)

|       | price1 | price2 |
|-------|--------|--------|
| color |        |        |
| green | 1.45   | 1.55   |
| red   | 3.64   | 3.37   |
| white | 0.00   | 0.00   |

Также можно использовать больше функций агрегации одновременно с помощью mark(), передав массив со списком операций для выполнения. Они станут новыми колонками.

>>> group['price1'].agg(['mean','std',range])

|       | mean  | std      | range |
|-------|-------|----------|-------|
| color |       |          |       |
| green | 2.025 | 1.025305 | 1.45  |
| red   | 2.380 | 2.573869 | 3.64  |
| white | 5.560 | NaN      | 0.00  |

Продвинутая агрегация данных

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

Предположим, что в одном Dataframe нужно получить следующее: оригинальный объект (с данными) и полученный с помощью вычисления агрегации, например, сложения.

>>> frame = pd.DataFrame({ 'color':['white','red','green','red','green'],
...     'price1':[5.56,4.20,1.30,0.56,2.75],
...     'price2':[4.75,4.12,1.60,0.75,3.15]})
>>> frame

|   | color | price1 | price2 |
|---|-------|--------|--------|
| 0 | white | 5.56   | 4.75   |
| 1 | red   | 4.20   | 4.12   |
| 2 | green | 1.30   | 1.60   |
| 3 | red   | 0.56   | 0.75   |
| 4 | green | 2.75   | 3.15   |

>>> sums = frame.groupby('color').sum().add_prefix('tot_')
>>> sums

|       | tot_price1 | tot_price2 | price2 |
|-------|------------|------------|--------|
| color |            |            | 4.75   |
| green | 4.05       | 4.75       | 4.12   |
| red   | 4.76       | 4.87       | 1.60   |
| white | 5.56       | 4.75       | 0.75   |

>>> merge(frame,sums,left_on='color',right_index=True)

|   | color | price1 | price2 | tot_price1 | tot_price2 |
|---|-------|--------|--------|------------|------------|
| 0 | white | 5.56   | 4.75   | 5.56       | 4.75       |
| 1 | red   | 4.20   | 4.12   | 4.76       | 4.87       |
| 3 | red   | 0.56   | 0.75   | 4.76       | 4.87       |
| 2 | green | 1.30   | 1.60   | 4.05       | 4.75       |
| 4 | green | 2.75   | 3.15   | 4.05       | 4.75       |

Благодаря merge() можно сложить результаты агрегации в каждой строке. Но есть и другой способ, работающий за счет transform(). Эта функция выполняет агрегацию, но в то же время показывает значения, сделанные с помощью вычислений на основе ключевого значения в каждой строке Dataframe.

>>> frame.groupby('color').transform(np.sum).add_prefix('tot_')

|   | tot_price1 | tot_price2 |
|---|------------|------------|
| 0 | 5.56       | 4.75       |
| 1 | 4.76       | 4.87       |
| 2 | 4.05       | 4.75       |
| 3 | 4.76       | 4.87       |
| 4 | 4.05       | 4.75       |

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

Метод для более привычных GroupBy — это apply(). Он в полной мере реализует схему разделение-применение-комбинирование. Функция разделяет объект на части для преобразования, вызывает функцию для каждой из частей и затем пытается связать их между собой.

>>> frame = pd.DataFrame( { 'color':['white','black','white','white','black','black'],
... 'status':['up','up','down','down','down','up'],
... 'value1':[12.33,14.55,22.34,27.84,23.40,18.33],
... 'value2':[11.23,31.80,29.99,31.18,18.25,22.44]})
>>> frame

|   | color | price1 | price2 | status |
|---|-------|--------|--------|--------|
| 0 | white | 12.33  | 11.23  | up     |
| 1 | black | 14.55  | 31.80  | up     |
| 2 | white | 22.34  | 29.99  | down   |
| 3 | white | 27.84  | 31.18  | down   |
| 4 | black | 23.40  | 18.25  | down   |
| 5 | black | 18.33  | 22.44  | up     |

>>> frame.groupby(['color','status']).apply( lambda x: x.max())

|       |        | color | price1 | price2 | status |
|-------|--------|-------|--------|--------|--------|
| color | status |       |        |        |        |
| black | down   | black | 23.40  | 18.25  | down   |
|       | up     | black | 18.33  | 31.80  | up     |
| white | down   | white | 27.84  | 31.18  | down   |
|       | up     | white | 12.33  | 11.23  | up     |

>>> frame.rename(index=reindex, columns=recolumn)

|        | color | price1 | price2 | status |
|--------|-------|--------|--------|--------|
| first  | white | 12.33  | 11.23  | up     |
| second | black | 14.55  | 31.80  | up     |
| third  | white | 22.34  | 29.99  | down   |
| fourth | white | 27.84  | 31.18  | down   |
| fifth  | black | 23.40  | 18.25  | down   |
| 5      | black | 18.33  | 22.44  | up     |

>>> temp = pd.date_range('1/1/2015', periods=10, freq= 'H')
>>> temp

DatetimeIndex(['2015-01-01 00:00:00', '2015-01-01 01:00:00',
               '2015-01-01 02:00:00', '2015-01-01 03:00:00',
               '2015-01-01 04:00:00', '2015-01-01 05:00:00',
               '2015-01-01 06:00:00', '2015-01-01 07:00:00',
               '2015-01-01 08:00:00', '2015-01-01 09:00:00'],
              dtype='datetime64[ns]', freq='H')

>>> timeseries = pd.Series(np.random.rand(10), index=temp)
>>> timeseries

2015-01-01 00:00:00    0.317051
2015-01-01 01:00:00    0.628468
2015-01-01 02:00:00    0.829405
2015-01-01 03:00:00    0.792059
2015-01-01 04:00:00    0.486475
2015-01-01 05:00:00    0.707027
2015-01-01 06:00:00    0.293156
2015-01-01 07:00:00    0.091072
2015-01-01 08:00:00    0.146105
2015-01-01 09:00:00    0.500388
Freq: H, dtype: float64

>>> timetable = pd.DataFrame( {'date': temp, 'value1' : np.random.rand(10),
... 'value2' : np.random.rand(10)})
>>> timetable

|   | date                | value1   | value2   |
|---|---------------------|----------|----------|
| 0 | 2015-01-01 00:00:00 | 0.125229 | 0.995517 |
| 1 | 2015-01-01 01:00:00 | 0.597289 | 0.160828 |
| 2 | 2015-01-01 02:00:00 | 0.231104 | 0.076982 |
| 3 | 2015-01-01 03:00:00 | 0.862940 | 0.270581 |
| 4 | 2015-01-01 04:00:00 | 0.534056 | 0.306486 |
| 5 | 2015-01-01 05:00:00 | 0.162040 | 0.979835 |
| 6 | 2015-01-01 06:00:00 | 0.400413 | 0.486397 |
| 7 | 2015-01-01 07:00:00 | 0.157052 | 0.246959 |
| 8 | 2015-01-01 08:00:00 | 0.835632 | 0.572664 |
| 9 | 2015-01-01 09:00:00 | 0.812283 | 0.388435 |

Затем Dataframe добавляется колонка с набором текстовых значений, которые будут выступать ключевыми значениями.

>>> timetable['cat'] = ['up','down','left','left','up','up','down','right',
'right','up']
>>> timetable

|   | date                | value1   | value2   | cat   |
|---|---------------------|----------|----------|-------|
| 0 | 2015-01-01 00:00:00 | 0.125229 | 0.995517 | up    |
| 1 | 2015-01-01 01:00:00 | 0.597289 | 0.160828 | down  |
| 2 | 2015-01-01 02:00:00 | 0.231104 | 0.076982 | left  |
| 3 | 2015-01-01 03:00:00 | 0.862940 | 0.270581 | left  |
| 4 | 2015-01-01 04:00:00 | 0.534056 | 0.306486 | up    |
| 5 | 2015-01-01 05:00:00 | 0.162040 | 0.979835 | up    |
| 6 | 2015-01-01 06:00:00 | 0.400413 | 0.486397 | down  |
| 7 | 2015-01-01 07:00:00 | 0.157052 | 0.246959 | right |
| 8 | 2015-01-01 08:00:00 | 0.835632 | 0.572664 | right |
| 9 | 2015-01-01 09:00:00 | 0.812283 | 0.388435 | up    |

Но в этом примере все равно есть повторяющиеся ключи.

Максим
Я создал этот блог в 2018 году, чтобы распространять полезные учебные материалы, документации и уроки на русском. На сайте опубликовано множество статей по основам python и библиотекам, уроков для начинающих и примеров написания программ.
Мои контакты: Почта
admin@pythonru.comAlex Zabrodin2018-10-26OnlinePython, Programming, HTML, CSS, JavaScript