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=True object-столбцы показывают только размер указателей (~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.

Sources

Related topics