Простые и практичные советы панд: как уменьшить использование памяти на 90%

искусственный интеллект Python NumPy pandas
Простые и практичные советы панд: как уменьшить использование памяти на 90%
Статья выбрана из DATAQUEST, автор: Джош Девлин, составлена ​​сердцем машины,Оригинальная ссылка, нажмите здесь, чтобы перейти.
pandas — это программная библиотека Python, которую можно использовать для обработки и анализа данных. Блог по науке о данных Dataquest.io опубликовал руководство о том, как оптимизировать объем памяти панд: простое преобразование типа данных может уменьшить объем памяти, занимаемый набором данных бейсбольной игры, почти на 90%.Введена компиляция.
Производительность, как правило, не является проблемой при работе с небольшими данными (менее 100 МБ) с пандами. При работе с большими объемами данных (от 100 МБ до нескольких ГБ) проблемы с производительностью могут привести к увеличению времени выполнения, а выполнение полностью завершится сбоем из-за нехватки памяти.
Хотя такие инструменты, как Spark, могут обрабатывать большие наборы данных (от 100 ГБ до нескольких терабайт), для полного использования их мощности часто требуется более дорогое оборудование. И в отличие от панд, им не хватает богатого набора функций для высококачественной очистки, исследования и анализа данных. Для данных среднего размера нам лучше использовать панды более полно, чем переключаться на другой инструмент.
В этом посте мы узнаем об использовании памяти пандами и о том, как вы можете уменьшить объем памяти вашего фрейма данных почти на 90%, просто выбрав правильный тип данных для своих столбцов.

Обработка журналов бейсбольных матчей

Мы будем иметь дело с игровыми данными Высшей лиги бейсбола (MLB) за 130 лет из Retrosheet: http://www.retrosheet.org/gamelogs/index.html.
Первоначально данные были разделены на 127 различных файлов CSV, но мы объединили данные с помощью csvkit и добавили имена столбцов в первую строку. Если вы хотите загрузить эту версию данных, используемых в этой статье, посетите: https://data.world/dataquest/mlb-game-logs.
Давайте сначала импортируем данные и посмотрим на первые пять строк:
import pandas as pd

gl = pd.read_csv('game_logs.csv')
gl.head()
Ниже мы суммируем некоторые важные столбцы, но если вы хотите узнать обо всех них, мы также создали словарь данных для всего набора данных: https://data.world/dataquest/mlb-game-logs/workspace/data-dictionary. .
  • 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 — название стадиона, на котором проходила игра
  • посещаемость - соответствует аудитории
Мы можем использовать метод DataFrame.info(), чтобы предоставить нам высокоуровневую информацию о фрейме данных, включая его размер, информацию о типе данных и использовании памяти.
По умолчанию pandas приблизительно оценивает использование памяти фреймом данных для экономии времени. Поскольку мы также заботимся о точности, мы устанавливаем для параметра memory_usage значение «глубоко», чтобы получить точные числа.
gl.info(memory_usage='deep')

<class 'pandas.core.frame.DataFrame'>
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 столбцов — объектными. объект относится к случаю наличия строк или смешанных типов данных.
Чтобы лучше понять, как уменьшить использование памяти, давайте посмотрим, как pandas хранит данные в памяти.

внутреннее представление кадра данных

Внутри панд столбцы одного типа данных организованы в одни и те же блоки значений. Вот пример того, как pandas хранит первые 12 столбцов нашего фрейма данных.
Вы можете видеть, что эти блоки не сохраняют исходные имена столбцов. Это связано с тем, что эти блоки оптимизированы для хранения фактических значений в кадре данных. Класс BlockManager для pandas отвечает за поддержание отношения сопоставления между индексами строк и столбцов и фактическими блоками. Он доступен в виде API, обеспечивающего доступ к базовым данным. Всякий раз, когда мы выбираем, редактируем или удаляем эти значения, интерфейсы класса dataframe и класса BlockManager переводят наши запросы в вызовы функций и методов.
В модуле pandas.core.internals для каждого типа есть отдельный класс. pandas использует класс ObjectBlock для представления блоков, содержащих строковые столбцы, и класс FloatBlock для представления блоков, содержащих столбцы с плавающей запятой. Для блоков, представляющих целые числа и числа с плавающей запятой, pandas будет объединять столбцы и сохранять их как NumPy ndarray. NumPy ndarrays построены вокруг массивов языка 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: {:03.2f} 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 внутренне представляет числовые значения в виде NumPy ndarrays и сохраняет их в смежных блоках памяти. Этот режим хранения занимает меньше места, а также позволяет нам быстро получить доступ к этим значениям. Поскольку pandas использует одинаковое количество байтов для представления каждого значения одного и того же типа, а NumPy ndarrays может хранить количество значений, pandas может быстро и точно возвращать количество байтов, используемых числовым столбцом.
Многие типы в pandas имеют несколько подтипов, которые могут использовать меньше байтов для представления каждого значения. Например, тип float включает подтипы float16, float32 и float64. Число в имени типа представляет количество битов (битов), в которых тип представляет значение. Например, только что перечисленные подтипы используют 2, 4, 8 и 16 байт соответственно. В следующей таблице приведены подтипы наиболее часто используемых типов в pandas:
Значение типа int8 использует 1 байт дискового пространства и может представлять 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 (целое со знаком). Оба типа имеют одинаковую емкость памяти, но один из них содержит только 0 и положительные числа. Целые числа без знака позволяют более эффективно обрабатывать столбцы только с положительными значениями.

Используйте подтипы для оптимизации числовых столбцов

Мы можем использовать функцию pd.to_numeric() для понижения наших числовых типов. Мы будем использовать DataFrame.select_dtypes для выбора целочисленных столбцов, затем оптимизируем их типы данных и сравним использование памяти.
# We're going to be calculating memory usage a lot,
# so we'll create a function to save us some time!

def mem_usage(pandas_obj):
    if isinstance(pandas_obj,pd.DataFrame):
        usage_b = pandas_obj.memory_usage(deep=True).sum()
    else: # we assume if not a df it's a series
        usage_b = pandas_obj.memory_usage(deep=True)
    usage_mb = usage_b / 1024 ** 2 # convert bytes to megabytes
    return "{:03.2f} 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)

7.87 MB
1.48 MB
Мы видим, что использование памяти снизилось с 7,9 МБ до 1,5 МБ, т. е. более чем на 80%. Но это мало влияет на наш исходный фрейм данных, в котором очень мало целочисленных столбцов.
Давайте сделаем то же самое для столбца с плавающей запятой в нем.
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
Мы видим, что тип данных столбца с плавающей запятой изменен с float64 на float32, что снижает использование памяти на 50%.
Давайте создадим копию исходного фрейма данных и заменим исходные столбцы этими оптимизированными столбцами и теперь посмотрим на наше общее использование памяти.
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
Хотя мы значительно сократили использование памяти для числовых столбцов, общее использование памяти сократилось только на 7%. Большая часть нашего выигрыша будет получена за счет оптимизации типа объекта.
Прежде чем мы начнем, давайте посмотрим, как строки хранятся в pandas по сравнению с тем, как хранятся числовые типы.

Сравнение числового хранения и хранения строк

Тип объекта представляет значения с использованием строковых объектов Python, отчасти потому, что NumPy не поддерживает отсутствующие строковые типы. Поскольку Python — интерпретируемый язык высокого уровня, он не имеет детального контроля над значениями, хранящимися в памяти.
Это ограничение приводит к тому, что строки хранятся фрагментированным образом, что требует больше памяти и замедляет доступ. Каждый элемент в столбце объектов на самом деле является указателем, содержащим «адрес» местоположения фактического значения в памяти.
На следующем рисунке показано, как хранить числовые данные в типах данных NumPy и строковые данные с использованием встроенных типов Python.
Источник изображения: https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/
В предыдущей таблице вы, возможно, заметили, что использование памяти типом объекта является переменным. Хотя каждый указатель занимает всего 1 байт памяти, если бы каждая строка хранилась в Python отдельно, она занимала бы столько же места, сколько и сама строка. Мы можем продемонстрировать это с помощью функции sys.getsizeof(), сначала просматривая отдельные строки, а затем просматривая элементы в серии 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))

60
65
74
74

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
Вы можете видеть, что размер строки при сохранении в серии pandas такой же, как размер строки, хранящейся отдельно в Python.

Оптимизация типов объектов с помощью Categoricals

pandas представила категории в версии 0.15. Тип категории использует целые значения под капотом для представления значений в столбце, а не примитивных значений. pandas использует отдельный словарь сопоставления для сопоставления этих целочисленных значений с примитивными значениями. Этот подход полезен, когда столбец содержит конечный набор значений. Когда мы преобразуем столбец в категорию dtype, pandas использует наиболее компактный подтип int для представления всех различных значений в этом столбце.
Чтобы понять, почему мы можем использовать этот тип для уменьшения использования памяти, давайте посмотрим на количество различных значений каждого типа в нашем типе объекта.
gl_obj = gl.select_dtypes(include=['object']).copy()
gl_obj.describe()
Смотрите исходный текст для полного изображения выше
На первый взгляд, для всего нашего набора данных из 172 000 игр количество уникальных значений, возможно, очень мало.
Чтобы понять, что именно происходит, когда мы преобразуем его в категориальный тип, давайте взглянем на столбец объекта. Мы будем использовать второй столбец набора данных 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 для возврата целочисленного значения категории типа, которое представляет каждое значение.
dow_cat.head().cat.codes

0    4
1    0
2    2
3    1
4    5
dtype: int8
Вы можете видеть, что каждому отдельному значению присваивается целочисленное значение, а базовым типом данных столбца теперь является int8. В этом столбце нет отсутствующих значений, но даже если они есть, подтип категории может их обработать, просто установите для него значение -1.
Наконец, давайте посмотрим, как сравнивается использование памяти до и после преобразования этого столбца в категорию.
print(mem_usage(dow))
print(mem_usage(dow_cat))

9.84 MB
0.16 MB
Использование памяти с 9,8 МБ сократилось до 0,16 МБ, т. е. на 98%! Обратите внимание, что этот конкретный столбец, вероятно, представляет один из наших лучших сценариев — около 172 000 элементов только с 7 различными значениями.
Хотя преобразование всех столбцов в этот тип может показаться заманчивым, также важно понимать компромиссы. Самым большим недостатком является невозможность выполнения численных расчетов. Без предварительного преобразования его в числовой тип dtype мы не можем выполнять арифметические действия в столбце категории, что означает, что мы не можем использовать такие методы, как Series.min() и Series.max() .
Мы должны придерживаться в основном использования типа категории для столбцов объектов, где количество различных значений составляет менее 50% от общего количества значений. Если все значения в столбце различны, тип категории будет использовать больше памяти. Потому что в этом столбце хранятся не только все необработанные строковые значения, но и дополнительно хранятся коды их целочисленных значений. Вы можете прочитать об ограничениях типа категории в документации pandas: http://pandas.pydata.org/pandas-docs/stable/categorical.html.
Мы напишем функцию цикла, чтобы итеративно проверять, меньше ли количество уникальных значений в каждом столбце объекта 50%; если да, преобразуем его в тип категории.
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
В этом случае все столбцы объектов преобразуются в тип категории, но не все наборы данных, поэтому для проверки следует использовать описанный выше процесс.
Использование памяти для столбца объектов было уменьшено с 752 МБ до 52 МБ, т. е. на 93 %. Давайте объединим это с остальной частью нашего фрейма данных и посмотрим, какой прогресс был достигнут по сравнению с исходными 861 МБ.
optimized_gl[converted_obj.columns] = converted_obj

mem_usage(optimized_gl)

'103.64 MB'
Вау, прогресс действительно хорош! Есть еще одна оптимизация, которую мы можем выполнить — если вы помните приведенную ранее таблицу типов данных, вы знаете, что существует также тип даты и времени. Первый столбец этого набора данных может использовать этот тип.
date = optimized_gl.date
print(mem_usage(date))
date.head()

0.66 MB

0    18710504
1    18710505
2    18710506
3    18710508
4    18710509
Name: date, dtype: uint32
Вы, возможно, помните, что этот столбец начинался как целое число, а теперь оптимизирован, чтобы иметь тип unint32. Таким образом, преобразование его в дату и время фактически удвоит использование памяти, поскольку дата и время являются 64-разрядными. Преобразование его в тип даты и времени ценно, поскольку позволяет нам лучше анализировать временные ряды.
Функция pandas.to_datetime() может сделать это преобразование за нас, используя свой параметр формата для хранения наших данных о дате в формате ГГГГ-ММ-ДД.
optimized_gl['date'] = pd.to_datetime(date,format='%Y%m%d')

print(mem_usage(optimized_gl))
optimized_gl.date.head()

104.29 MB

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]

Выберите тип при чтении данных

Теперь мы изучили способы уменьшения объема памяти существующего фрейма данных. Сначала прочитав кадр данных, а затем повторив процесс, чтобы уменьшить объем памяти, мы узнали, сколько памяти может сэкономить каждый метод оптимизации. Но, как мы упоминали ранее, нам часто не хватает памяти для представления всех значений в наборе данных. Если мы не можем даже создать фрейм данных, как мы можем применять методы экономии памяти?
К счастью, мы можем указать оптимальный тип столбца при чтении данных. Функция 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))

# rather than print all 161 items, we'll
# sample 10 key/value pairs from the dict
# and print it nicely using prettyprint

preview = first2pairs = {key:value for key,value in list(column_types.items())[:10]}
import pprint
pp = pp = pprint.PrettyPrinter(indent=4)
pp.pprint(preview)

{   'acquisition_info': 'category',
    'h_caught_stealing': 'float32',
    'h_player_1_name': 'category',
    'h_player_9_name': 'category',
    'v_assists': 'float32',
    'v_first_catcher_interference': 'float32',
    'v_grounded_into_double': 'float32',
    'v_player_1_id': 'category',
    'v_player_3_id': 'category',
    'v_player_5_id': 'category'}
Теперь мы можем использовать этот словарь и еще несколько параметров для чтения даты как правильного типа, и это займет всего несколько строк кода:
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()

104.28 MB
Смотрите исходный текст для полного изображения выше
Оптимизировав эти столбцы, нам удалось уменьшить объем памяти панд с 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 лет.
Давайте еще раз посмотрим, как изменилась продолжительность игры:
game_lengths = optimized_gl.pivot_table(index='year', values='length_minutes')
game_lengths.reset_index().plot.scatter('year','length_minutes')
plt.show()
С 1940-х годов бейсбольные игры длятся все дольше и дольше.

Резюме и следующие шаги

Мы видели, как pandas использует разные типы данных, а затем использовали эти знания, чтобы сократить использование памяти кадром данных pandas почти на 90%, используя всего несколько простых методов:
  • Приведение числовых столбцов к более эффективным типам
  • Преобразование строкового столбца в категориальный тип
Если вы также хотите использовать pandas для обработки данных большего масштаба, вы можете пройти этот интерактивный курс: https://www.dataquest.io/m/163/optimizing-dataframe-memory-footprint/16/next-steps.