PandasSeniorSystem design
Как профилировать использование памяти DataFrame в Pandas и уменьшить его?
Для профилирования памяти DataFrame используют df.memory_usage(deep=True), memory_profiler и tracemalloc; снижают потребление через downcast dtype, category, nullable-типы и замену object на pd.StringDtype().
Инструменты профилирования
Первый шаг — понять, сколько занимает DataFrame и какие столбцы виноваты. Pandas предоставляет несколько встроенных инструментов, а для глубокого профилирования используют стандартную библиотеку и сторонние пакеты.
1. memory_usage(deep=True)
import pandas as pd
import numpy as np
df = pd.read_parquet("events.parquet") # например 5 млн строк
# deep=True учитывает реальный размер object-столбцов (Python heap)
usage = df.memory_usage(deep=True)
print(usage)
print(f"Total: {usage.sum() / 1024**2:.1f} MB")
# Топ-5 самых тяжёлых столбцов
print(usage.sort_values(ascending=False).head())
2. Сводная таблица dtype и памяти
def memory_report(df: pd.DataFrame) -> pd.DataFrame:
"""Показывает dtype, количество уникальных значений и размер каждого столбца."""
rows = []
for col in df.columns:
rows.append({
"column": col,
"dtype": str(df[col].dtype),
"nulls": df[col].isna().sum(),
"nunique": df[col].nunique(),
"mem_mb": df[col].memory_usage(deep=True) / 1024**2,
})
return pd.DataFrame(rows).sort_values("mem_mb", ascending=False)
print(memory_report(df).to_string())
3. memory_profiler для построчного профилирования
# pip install memory-profiler
from memory_profiler import profile
@profile
def load_and_process(path: str) -> pd.DataFrame:
df = pd.read_csv(path) # пик 1
df = downcast_dtypes(df) # пик 2 (временно 2x)
df = df.dropna(subset=["id"]) # пик 3
return df
result = load_and_process("data.csv")
# Вывод покажет потребление памяти после каждой строки
4. tracemalloc для точного отслеживания аллокаций
import tracemalloc
tracemalloc.start()
df_merged = pd.merge(left, right, on="user_id", how="left")
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"Current: {current / 1024**2:.1f} MB, Peak: {peak / 1024**2:.1f} MB")
Стратегии снижения памяти
Downcast числовых типов
def downcast_dtypes(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
for col in df.select_dtypes("integer").columns:
df[col] = pd.to_numeric(df[col], downcast="unsigned")
for col in df.select_dtypes("float").columns:
df[col] = pd.to_numeric(df[col], downcast="float")
return df
# int64 (8 байт) -> uint8 (1 байт) при значениях 0-255: экономия 87%
# float64 (8 байт) -> float32 (4 байта): экономия 50%
Categorical для строк с низкой кардинальностью
# object-столбец "country" с 200 уникальными значениями из 5 млн строк
df["country"] = df["country"].astype("category")
# object: ~50 байт/строку = 250 MB; category: ~1-2 байта/строку = 5-10 MB
# Автоматически для всех строковых столбцов с кардинальностью < 50%
for col in df.select_dtypes("object").columns:
if df[col].nunique() / len(df) < 0.5:
df[col] = df[col].astype("category")
Nullable-типы и pd.StringDtype
# pandas 1.0+: Int64 вместо int64 поддерживает NaN без float-конвертации
df["age"] = df["age"].astype("Int32") # Nullable Integer, 4 байта
df["name"] = df["name"].astype("string") # pd.StringDtype — меньше overhead чем object
Чтение только нужных столбцов
# Parquet поддерживает column pruning на уровне файла
df = pd.read_parquet("data.parquet", columns=["user_id", "event", "ts"])
# CSV: usecols с типами сразу при чтении
df = pd.read_csv(
"data.csv",
usecols=["user_id", "event", "ts"],
dtype={"user_id": "int32", "event": "category"},
parse_dates=["ts"],
)
Подводные камни
- memory_usage(deep=False) по умолчанию — без
deep=Trueobject-столбцы показывают только размер указателей (~8 байт/строку), а не реальный размер строк на heap; цифры занижены в 10-50 раз. - downcast float64 -> float32 теряет точность — float32 имеет ~7 значимых цифр; для финансовых расчётов или координат это критично.
- category замедляет операции с высокой кардинальностью — если столбец почти уникальный (user_id), category добавляет overhead без экономии памяти.
- assign/copy создаёт полные копии —
df["new"] = df["old"] * 2не создаёт copy, ноdf.assign(new=lambda x: x["old"] * 2)возвращает новый DataFrame; в цепочке трансформаций пиковое потребление удваивается. - merge строит хеш-таблицу в памяти — промежуточная структура может занять 3x от меньшего DataFrame; downcast ключей перед merge критически важен.
- concat с ignore_index=False — при конкатенации многих чанков индекс может дублироваться;
ignore_index=Trueсоздаёт новый RangeIndex и экономит память на индексном массиве. - del df без gc.collect() — CPython не сразу освобождает память назад ОС; для скриптов с большими циклами явно вызывайте
import gc; gc.collect(). - Переход на Dask/Polars — если DataFrame не помещается в RAM, Pandas — неправильный инструмент; Polars использует Arrow memory format и lazy evaluation, Dask — параллельные чанки с spill-to-disk.
Common mistakes
- Объяснять
memory profilingтолько синтаксисом без shape, dtype, состояния или режима выполнения. - Игнорировать leakage, воспроизводимость, пустые входы и скрытые копии данных.
- Не проверять production-симптомы: latency, память, ретраи, дрейф качества и несовпадение версий.
What the interviewer is testing
- Может ли связать
memory profilingс реальным контрактом входов и выходов. - Упоминает ли тесты, метрики, reproducibility и диагностику ошибок.
- Видит ли различие между demo-кодом в ноутбуке и production-пайплайном.
- Предлагает ли observability, rollback, ограничения стоимости и стратегию incident replay.