Если DataFrame не влезает в память или всё дико тормозит, в большинстве случаев дело не в объёме данных, а в том, что pandas по умолчанию хранит их максимально расточительно. Числа лежат в int64 и float64, даже когда с головой хватило бы int16, а повторяющиеся строки вроде города или статуса заказа занимают столько же места, сколько уникальные. Лечится это тремя приёмами: downcast числовых типов, перевод повторяющихся строк в category и чтение файла сразу в нужных типах через dtype, usecols и chunksize. На реальных логах это стабильно даёт экономию в 3–10 раз. Дальше — как делать руками и где не наступить на грабли.
Почему DataFrame занимает в разы больше, чем исходный CSV?
Потому что в файле данные лежат как текст, а в памяти — как типизированные массивы фиксированной ширины. Число 7 в CSV — это один байт, а в колонке int64 оно занимает 8 байт, ровно как и число 9 000 000 000. Ширина типа не зависит от значения.
Со строками ещё веселее. Колонка типа object — это на самом деле массив указателей на разбросанные по памяти Python-объекты str. Каждая короткая строка вроде "paid" тянет за собой служебные байты самого объекта плюс сам текст. В сумме одна ячейка object легко весит 50+ байт там, где в файле было 4 символа. Умножьте на 10 миллионов строк — и колонка статуса, которая в CSV занимает пару мегабайт, в памяти раздувается до сотен.
Отсюда правило: смотреть надо не на размер файла на диске, а на то, во что он разворачивается в оперативке. Обычно это в 2–5 раз больше.
Как замерить, что реально ест память?
Первым делом — memory_usage(deep=True). Без deep=True pandas для object-колонок покажет только вес указателей (8 байт на ячейку) и наврёт в разы. С deep=True он проходит по каждой строке и считает честно.
import pandas as pd
df = pd.read_csv('events.csv')
# Общий размер в мегабайтах
df.memory_usage(deep=True).sum() / 1024**2
# По колонкам, от самых тяжёлых
(df.memory_usage(deep=True) / 1024**2).sort_values(ascending=False)
Ещё удобнее df.info(memory_usage='deep') — сразу видно типы и суммарный вес. Я всегда начинаю с сортировки колонок по весу: обычно 2–3 колонки съедают 80% памяти, и оптимизировать остальные бессмысленно. Меряем до, меряем после — иначе оптимизация превращается в гадание.
Как ужать целочисленные колонки без потери данных?
Посмотреть на реальный диапазон значений и выбрать самый узкий тип, который его вмещает. Границы простые: int8 держит от −128 до 127, int16 — примерно ±32 тысячи, int32 — ±2,1 миллиарда, int64 — астрономические числа. Для неотрицательных есть беззнаковые uint8 (0–255) и старше. Полный список числовых типов и их границ удобно держать под рукой в справочнике по Python.
df['quantity'].min(), df['quantity'].max() # (1, 48) — влезает в int8
df['quantity'] = df['quantity'].astype('int8')
Не хочется считать вручную — есть автодаункаст, он сам подберёт минимальный тип:
df['quantity'] = pd.to_numeric(df['quantity'], downcast='integer')
df['user_id'] = pd.to_numeric(df['user_id'], downcast='unsigned')
Одна засада: если в колонке есть пропуски (NaN), pandas хранит её как float64, и обычный astype('int16') упадёт. Тогда берите nullable-тип с большой буквы — Int16, он умеет держать NaN:
df['age'] = df['age'].astype('Int16')
user_id в int64 на 10 млн строк — это 80 МБ. Тот же user_id в int32 (хватает до 2 млрд) — 40 МБ. Половину колонки сдули одним astype.
Когда downcast float безопасен, а когда ломает точность?
float32 безопасен, когда вам не нужна высокая точность: доли, коэффициенты, скоры моделей, координаты, проценты. Он вдвое легче float64, но хранит только около 7 значащих цифр.
df['ctr'] = pd.to_numeric(df['ctr'], downcast='float') # float64 -> float32
А вот с деньгами float32 — мина. Сумма вроде 19 999.99 при семи значащих цифрах уже впритык, а после пары арифметических операций начнёт накапливать ошибку, и отчёт по выручке разойдётся сначала на копейки, потом на рубли. Деньги я вообще стараюсь хранить в целых копейках (int), а не во float — тогда и downcast не нужен, и округлений нет. Если очень надо дробное — оставляйте float64. Память не тот случай, ради которого стоит ронять точность платежей.
Почему object → category экономит память кратно?
Потому что category не хранит строку в каждой ячейке. Он один раз складывает уникальные значения в справочник, а в самой колонке держит компактные целочисленные коды — ссылки на этот справочник. Если у вас 10 миллионов строк и всего 85 уникальных городов, то вместо 10 миллионов жирных object-строк лежат 10 миллионов однобайтовых кодов плюс крохотный словарь из 85 названий.
df['city'].nunique() # 85 уникальных на 10 млн строк
df['city'] = df['city'].astype('category')
Экономия тут не проценты, а разы: колонка city может усохнуть с 600 МБ до 10–15. category идеально ложится на низкокардинальные строковые поля: статус заказа (paid / pending / refunded), город, канал привлечения, тип устройства, платёжный провайдер. Правило на глаз: если число уникальных значений сильно меньше числа строк (условно, nunique / len < 0.5, а лучше < 0.1) — переводите в category не раздумывая.
Всегда ли category ускоряет работу?
Нет, и это главное недопонимание. category — это в первую очередь про память, а не про скорость, и на высокой кардинальности он вообще вредит.
Если значений почти столько же, сколько строк (например, email или session_id, где уникально почти всё), то category не экономит, а добавляет накладные расходы: он держит и коды, и справочник размером с исходную колонку. Память вырастет, а не упадёт.
Дальше — операции. groupby и value_counts по category обычно быстрее. А вот строковые методы через .str внутри всё равно разворачивают категорию обратно в строки, так что выигрыша нет. concat двух датафреймов с разными наборами категорий может либо расширить категории, либо свалиться обратно в object — легко получить сюрприз. И попытка записать в category-колонку значение, которого нет в справочнике, кинет ошибку, пока не добавишь его через add_categories.
Вывод простой: category ставим ради памяти и группировок на низкокардинальных полях. Всё, что критично по скорости, надо мерить, а не верить на слово. Проверить свои гипотезы на живом коде можно прямо в Python-тренажёре.
Как прочитать CSV, который не влезает в оперативку?
Не грузить его целиком в object-типах, а сразу указать pandas, что читать и как. Три рычага.
Первый — usecols: не читайте колонки, которые не нужны. Это самая дешёвая экономия, часто половина файла лишняя.
Второй — dtype и parse_dates: задайте типы прямо при чтении, чтобы pandas не разворачивал всё в int64/object, а потом обратно.
dtypes = {
'user_id': 'int32',
'city': 'category',
'status': 'category',
'quantity': 'int16',
'amount': 'float64',
}
df = pd.read_csv(
'payments.csv',
usecols=['user_id', 'city', 'status', 'quantity', 'amount', 'created_at'],
dtype=dtypes,
parse_dates=['created_at'],
)
Третий — chunksize, когда даже ужатый датафрейм не помещается. read_csv с chunksize отдаёт итератор по кускам; вы агрегируете каждый и складываете результат, ни разу не держа всё целиком:
paid_total = 0
for chunk in pd.read_csv('payments.csv', chunksize=500_000, dtype=dtypes):
paid_total += chunk.loc[chunk['status'] == 'paid', 'amount'].sum()
И финальный трюк: один раз ужали — сохраните в parquet. Он бинарный, жмётся, читается в разы быстрее CSV и, в отличие от CSV, помнит типы, включая category.
df.to_parquet('payments.parquet')
Практика: ужимаем лог платежей с 1,8 ГБ до 300 МБ
Возьмём типичный payments.csv на 15 млн строк: user_id, city, status, provider, quantity, amount, created_at. В лоб он читается примерно в 1,8 ГБ.
Сначала замер и диагноз:
df = pd.read_csv('payments.csv', parse_dates=['created_at'])
df.info(memory_usage='deep')
Видно, что city, status, provider — это object и они съедают львиную долю, а user_id/quantity сидят в int64 вхолостую. Дальше — универсальная функция, которую я держу под рукой и прогоняю на любом новом датасете:
def optimize(df):
for col in df.select_dtypes(include=['integer']).columns:
df[col] = pd.to_numeric(df[col], downcast='integer')
for col in df.select_dtypes(include=['float']).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.5:
df[col] = df[col].astype('category')
return df
df = optimize(df)
df.info(memory_usage='deep')
int64 схлопнулись в int16/int32, три строковые колонки ушли в category, и датафрейм садится примерно до 300 МБ — в шесть раз меньше. Дальше сохраняем в parquet, и в следующий раз файл читается сразу в нужных типах за секунды. Только не забудьте про деньги: если amount критичен по копейкам, уберите его из float-даункаста и держите в float64 или в целых копейках. Те же приёмы полезно прогнать руками на тестовых заданиях с реальными датасетами.
С чего начать на своём датасете?
Порядок, который экономит время: сначала df.info(memory_usage='deep'), чтобы найти 2–3 самые тяжёлые колонки. Потом usecols при чтении — выкинуть лишнее. Дальше downcast целых, category для низкокардинальных строк, аккуратный float (деньги не трогаем), даты через parse_dates. Если и так не влезает — chunksize и агрегация по кускам, а результат в parquet.
Умение ужать данные и не ронять Jupyter с MemoryError на большом логе — тот навык, который отличает джуна от мидла и заметно влияет на грейд и зарплату аналитика. В компаниях с большими данными про category и downcast регулярно спрашивают на собеседованиях, так что перед интервью не лишним будет пройтись по плану подготовки.
Тренироваться на реальных датасетах с проверкой прямо в браузере, без возни с окружением, можно в Python-тренажёре — а полный доступ к 400+ задачам с разборами открывает Pro.
Связанная тема — pandas apply медленный: векторизация вместо apply.