Время прочтения: 4 мин.

Нередко я сталкиваюсь с большими объемами данных, которые требуют дополнительной обработки с помощью известной всем библиотеки Pandas. Однако, загружая или сохраняя огромные датасеты, неприятно столкнуться с ошибкой Memory error. В таких ситуациях применение таких методов, как .drop_duplicates() (удаление дубликатов) или .dropna() (удаление пустых строк) слабо влияет на сокращение занимаемого объема памяти.

Существует несколько способов эффективного решения проблем с памятью.

Изменение типов данных

float64 →  float16  или int64 →  int16

Как показывает практика, далеко не для всех задач требуется высокая точность при работе c числовыми значениями. Таким образом, достаточно воспользоваться типом float16 (float32) вместо float64. Аналогичную замену можно сделать и для целочисленных значений.

object  → datetime64

Часто при считывании данных, оказывается, что они хранятся в строковом формате. Перевод  данных в формат даты datetime64 резервирует существенно меньше памяти в отличие от типа object .

object  → category

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

Разберём, замену типов данных на примере загрузки csv файла объемом (833 МБ, 8.1 млн строк), объем не большой, однако, на нем наглядно можно показать значительное сокращение резервируемой памяти.

Загружаем датасет со следующим набором полей:

  • ‘ID’  –  уникальный номер события
  • ‘EVENT_DATE_TIME’   –  дата и время события
  • ‘EVENT_CODE’   –  код операции (значение из ограниченного набора)
  • ‘USER_LOGIN’  –  логин пользователя
  • ‘EMPLOYEE_LOGIN’   –  логин сотрудника, подтверждающего операцию
  • ‘OPERATION_NAME’  –   наименование операции (значение из ограниченного набора)

Видим, что он занимает 2.9 Гб.

In [1]: df =  pd.read_csv("dataset_1.csv", sep="~", engine='python')
   ...: print(df.info(memory_usage='deep'))

Out [1]: <class 'pandas.core.frame.DataFrame'>
RangeIndex: 8115951 entries, 0 to 8115950
Data columns (total 6 columns):
ID                 int64
EVENT_DATE_TIME    object
EVENT_CODE         object
USER_LOGIN         int64
EMPLOYEE_LOGIN     object
OPERATION_NAME     object
dtypes: int64(2), object(4)
memory usage: 2.9 GB

Поскольку, поле ‘ID’ – это уникальный идентификационный номер события и имеет неповторяющиеся значения высокого порядка, целесообразно оставить эти значения в с типом int64. Поле ‘EVENT_DATE_TIME’ содержит дату и время в виде «гггг-мм-дд чч:мм:сс», разумно конвертировать в тип данных datetime64. В рамках рассматриваемого набора данных можно считать оставшиеся поля категориальными, производя замену object  → category для всех полей кроме ‘ID’ и ‘EVENT_DATE_TIME’ получаем итоговую экономию памяти c 2,9 Гб до 0,4 Гб

In [2]: df['EVENT_DATE_TIME'] = pd.to_datetime(df['EVENT_DATE_TIME'])
   ...: for col in df.iloc[:, 2:].columns:
   ...: 	df[col] = df[col].astype('category')
   ...: print(df.info(memory_usage='deep'))
  
Out [2]: <class 'pandas.core.frame.DataFrame'>
RangeIndex: 8115951 entries, 0 to 8115950
Data columns (total 6 columns):
ID                 int64
EVENT_DATE_TIME    datetime64[ns]
EVENT_CODE         category
USER_LOGIN         category
EMPLOYEE_LOGIN     category
OPERATION_NAME     category
dtypes: category(4), datetime64[ns](1), int64(1)
memory usage: 396.2 MB

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

In [3]: df = pd.read_csv(r"dataset_1.csv",
    ...:                   usecols=['ID',
    ...:                            'EVENT_DATE_TIME',
    ...:                            'EVENT_CODE',
    ...:                            'USER_LOGIN',
    ...:                            'EMPLOYEE_LOGIN',
    ...:                            'OPERATION_NAME'],
    ...:                   dtype={
    ...:                           'ID':'int64',
    ...:                           'EVENT_DATE_TIME':'datetime64[ns]',
    ...:                           'EVENT_CODE':'category',
    ...:                           'USER_LOGIN': 'category',
    ...:                           'EMPLOYEE_LOGIN':'category',
    ...:                           'OPERATION_NAME': 'category'
    ...:                           },
    ...:                   sep="~", engine='python')
    ...: print(df.info(memory_usage='deep'))
Out [3]: <class 'pandas.core.frame.DataFrame'>
memory usage: 602.9 MB

Обработка файла фрагментами (in chanks)

В тех случаях, когда объем данных слишком велик и не удается загрузить весь файл целиком, следует прочитать его по частям, разбивая его параметром chunksize. Загрузим ранее описанный датасет, при этом я буду заменять тип данных при чтении, как в предыдущем примере и методом append() будем добавлять преобразованные в итоговый Dataframe. Однако, поля с категориальными данными не корректно считывать сразу с типом категории, так как такой формат подразумевает хранение ссылок на уникальные значения. Будет лучше преобразовывать датафрейм после применения метода append().

In [4]: n_ch = 1
    ...: df = pd.DataFrame()
    ...: for chunk in pd.read_csv("dataset_1.csv",
    ...:                          sep='~', 
    ...:                          chunksize = 10**6, 
    ...:                          dtype={
    ...:                           'ID':'int64',
    ...:                           'EVENT_DATE_TIME':'datetime64[ns]',
    ...:                           'EVENT_CODE':'object',
    ...:                           'USER_LOGIN': 'object',
    ...:                           'EMPLOYEE_LOGIN':'object',
    ...:                           'OPERATION_NAME': 'object'
    ...:                           },
    ...:                          engine='python'):
    ...:     
    ...:     df = df.append(chunk)
    ...:     print('part: ', n_ch )
    ...:     for col in df.iloc[:, 2:].columns:
    ...:         df[col] = df[col].astype('category')
    ...:     n_ch += 1
    ...: print(df.info(memory_usage='deep'))

В результате выполнения занимаемый объем памяти составляет 664 Мб.

Если вы знаете, почему в следствии преобразования типов данных различными способами изменяется итоговый объем занимаемой памяти, напишите об этом в комментариях, возможно в понимании этих процессов можно будет добиться наилучших результатов!

Сжатие данных при сохранении результата

Работая над несколькими проектами в области Data Analysis и Data Science, я часто сталкиваюсь с проблемой ограничения места на локальном диске.

 Самым очевидным решением оказывается архивирование результата с помощью метода .to_csv() с параметром compression='gzip'. К примеру, csv файл размером 560 Мб можно сохранить через следующую команду

df.to_csv(r"dataset.gz", compression='gzip', index=False)

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

df.read_csv(r"dataset.gz")

Перечисленные приемы и методы будут особенно полезны при работе с несколькими датафреймами в рамках одного проекта.  В частности, сокращение объёма используемой памяти будет наиболее эффективным с применением таких методов, как .concat(), .append(), merge(), join() и т.п.