Оптимизация памяти в 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') # заглавная IInt64 — отдельный тип, который поддерживает 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 или другие инструменты.