Оптимизация памяти в pandas: практические приёмы

Почему pandas ест память

Pandas под капотом использует NumPy массивы. Каждый тип данных занимает определённое количество байт:

  • int64 — 8 байт на значение.
  • float64 — 8 байт.
  • object (строки, смешанные типы) — 50+ байт на строку из-за Python-объектов.
  • bool — 1 байт, но часто хранится как int8.

При чтении CSV pandas по умолчанию выбирает максимально вместительные типы: int64 для всех чисел, float64, object для строк. Это безопасно, но расточительно.

Для файла на 10 миллионов строк с 20 колонками это может означать 2-3 GB в памяти вместо 500 MB при оптимальных типах. На 100 миллионах строк ноутбук падает, а правильными типами всё бы прошло.

Диагностика

Перед оптимизацией — понять, сколько памяти съедает DataFrame:

df.info(memory_usage='deep')

deep=True считает реальный размер, включая строки в object-колонках. Без этого параметра object-колонки показываются как 8 байт на указатель, что обманчиво.

Поколоночное использование:

df.memory_usage(deep=True).sort_values(ascending=False)

Обычно топ-1-3 колонок съедают 80% памяти. Это object-колонки или большие int64 там, где хватило бы int16.

Downcasting числовых типов

Самая простая оптимизация — сузить integer/float типы.

df['count'] = df['count'].astype('int32')      # 4 байта вместо 8
df['small_int'] = df['small_int'].astype('int16')   # 2 байта
df['flag'] = df['flag'].astype('int8')         # 1 байт

df['amount'] = df['amount'].astype('float32')  # 4 байта вместо 8

Диапазоны:

  • int8: -128 до 127.
  • int16: -32768 до 32767.
  • int32: ±2 миллиарда.
  • int64: ±9 квинтильонов.

Если user_id до 10 миллионов — хватает int32. Если age до 150 — int8. Экономия в 4-8 раз.

Автоматический downcasting:

df['amount'] = pd.to_numeric(df['amount'], downcast='integer')
df['price'] = pd.to_numeric(df['price'], downcast='float')

Pandas сам выберет минимальный тип. Удобно.

Categorical для строк с малым кардинальностью

Колонки типа country, status, category часто имеют ограниченный набор значений. Хранить «Russia», «USA» как object — 50+ байт на строку. Всё это можно закодировать:

df['country'] = df['country'].astype('category')
df['status'] = df['status'].astype('category')

Category хранит уникальные значения в отдельном array, а в основной колонке — int-индексы. Для колонки с 10 уникальными значениями и 10 миллионами строк экономия — десятки MB.

Бонус: операции groupby/sort на category-колонках быстрее, потому что сравнение int быстрее сравнения string.

Когда не использовать category:

  • Высокая кардинальность (например, email — уникальный для каждого). Там overhead перевесит выигрыш.
  • Текстовые операции (нижний регистр, split). Они потребуют обратной конвертации.

Правило: если уникальных значений меньше 1% от числа строк — category.

dtype при чтении

Оптимально задавать типы сразу при чтении, а не менять потом:

dtypes = {
    'user_id': 'int32',
    'amount': 'float32',
    'category': 'category',
    'is_active': 'bool',
    'created_at': 'datetime64[ns]'
}

df = pd.read_csv('file.csv', dtype=dtypes, parse_dates=['created_at'])

Это экономит и память (pandas не будет сначала читать как int64, потом converting), и время.

Для object-колонок, которые потом станут category, можно передать так:

df = pd.read_csv('file.csv', dtype={'category': 'category'})

Pandas сам распознаёт уникальные значения и кодирует.

Nullable integer

Стандартный pandas int-тип не поддерживает NULL. Если в int-колонке есть NaN, pandas конвертирует её в float64 (потому что NaN — это float). Это увеличивает память.

С pandas 1.0 появились nullable-типы:

df['user_id'] = df['user_id'].astype('Int64')  # заглавная I

Int64 — отдельный тип, который поддерживает NA и остаётся integer. Память меньше, чем float64.

Также есть Float32, Float64, boolean — все nullable версии.

Чтение по частям (chunksize)

Если файл настолько большой, что даже с оптимальными типами не помещается в память — читайте по частям:

chunks = []
for chunk in pd.read_csv('huge_file.csv', chunksize=100_000):
    # Обработать chunk
    processed = chunk[chunk['amount'] > 100]
    chunks.append(processed)

df = pd.concat(chunks, ignore_index=True)

Pandas читает файл по 100k строк за раз. Вы обрабатываете каждый chunk, накапливаете результат. Итоговый DataFrame может быть значительно меньше исходного файла, если обработка фильтрует или агрегирует.

Для агрегаций — ещё эффективнее: не накапливать чанки, а суммировать по мере обработки:

total_revenue = 0
for chunk in pd.read_csv('huge_file.csv', chunksize=100_000):
    total_revenue += chunk['amount'].sum()

Вся память занята только одним chunk в 100k строк.

Работа с большими данными в pandas — важная часть middle-скиллов. В тренажёре Карьерник есть задачи на оптимизацию Python-кода для аналитических задач.

Parquet вместо CSV

CSV — текстовый формат, неэффективный. Parquet — колоночный, сжатый.

Сравнение для одной таблицы на 10 миллионов строк:

  • CSV: 5 GB на диске, 2 GB в памяти после чтения.
  • Parquet: 1 GB на диске, 800 MB в памяти.

И parquet читается быстрее, и сразу сохраняет dtypes (не нужно переопределять).

# Сохранение
df.to_parquet('data.parquet', compression='snappy')

# Чтение
df = pd.read_parquet('data.parquet')

Для регулярно используемых файлов — всегда parquet. CSV оставьте для обмена с сервисами, которые parquet не понимают.

Parquet также поддерживает чтение только нужных колонок:

df = pd.read_parquet('data.parquet', columns=['user_id', 'amount'])

Если в parquet 50 колонок, а вам нужны 2 — чтение в 25 раз быстрее.

Удаление ненужных колонок сразу

Частая ошибка — прочитать все 50 колонок и потом выбрать 5. Лучше читать только 5:

# Плохо
df = pd.read_csv('big.csv')
df = df[['user_id', 'amount', 'date']]

# Хорошо
df = pd.read_csv('big.csv', usecols=['user_id', 'amount', 'date'])

Это экономит и память, и время парсинга.

Удаление after use

После того как DataFrame больше не нужен:

del df
import gc
gc.collect()

Python освобождает память garbage collector'ом, но иногда это происходит с задержкой. Для длинных скриптов полезно явно.

В Jupyter notebooks ещё полезно:

df = None

Перед повторным присваиванием, чтобы не держать две копии в памяти.

Использование copy vs view

Pandas иногда создаёт view, иногда copy при slicing. Это влияет на память:

sub = df[df['amount'] > 100]  # часто view (memory-friendly)
sub = df[df['amount'] > 100].copy()  # явно copy (больше памяти, но безопаснее)

Для долгоживущих DataFrames делайте copy явно, чтобы избежать SettingWithCopyWarning. Для быстрой фильтрации и агрегации — view подходит.

Когда ничего не помогает

Если даже после всех оптимизаций данные не помещаются:

Dask — pandas-подобный API для данных, которые не помещаются в память. Автоматически chunking.

Polars — современная альтернатива, эффективнее pandas по памяти.

DuckDB — читать из файлов напрямую через SQL, без загрузки в Python.

Spark — для действительно больших объёмов на кластере.

Переход на эти инструменты — шаг серьёзный, но для task-ов 10+ GB часто оправдан.

Измерение до и после

Финальный workflow:

import pandas as pd

# Baseline
df = pd.read_csv('data.csv')
print(f'Before: {df.memory_usage(deep=True).sum() / 1024**2:.0f} MB')

# Оптимизация
for col in df.select_dtypes(include='int64').columns:
    df[col] = pd.to_numeric(df[col], downcast='integer')

for col in df.select_dtypes(include='float64').columns:
    df[col] = pd.to_numeric(df[col], downcast='float')

for col in df.select_dtypes(include='object').columns:
    if df[col].nunique() < len(df) * 0.01:
        df[col] = df[col].astype('category')

print(f'After: {df.memory_usage(deep=True).sum() / 1024**2:.0f} MB')

Обычно вижу результаты 50-80% экономии памяти. Для 2 GB файла — это 400 MB вместо 2 GB. Ноутбук выживает.

Читайте также

FAQ

Сколько памяти экономят эти оптимизации?

Обычно 50-70% для файлов с разнообразными типами. Больше — если много object-колонок с малой кардинальностью.

Работает ли с MultiIndex?

Да, к каждому уровню индекса применимо то же (можно делать categorical).

Есть ли штраф по скорости?

Для category — наоборот, ускоряет groupby и sort. Для downcasted numeric — небольшое замедление из-за менее оптимизированных numpy операций, но обычно незаметно.

Когда optimal не помогает?

Когда все данные — уникальные строки (логи, URL-ы, event IDs). Там ничего не сжать, нужно переходить на chunks или другие инструменты.