Привет, Хабр! Наверное, каждый питонист или дата-аналитик рано или поздно плотно знакомится с Pandas. Это настоящий швейцарский нож для работы с табличными данными: пара строк кода, и вот вы уже отфильтровали, сгруппировали и агрегировали сотни тысяч записей. Удобно? Безумно. Быстро? Ну… смотря чьими руками написано.

Часто бывает так: изящный скрипт, который отлично работал на тестовом датасете в пару тысяч строк, на реальных объемах данных внезапно превращается в прожорливого, тормозящего монстра. Аналитика висит, кулер ноутбука воет, а в консоли вылетает грустное MemoryError.

Главная беда в том, что мы часто приходим в Pandas с прочным багажом привычек из классического Python. Мы любим циклы for, генераторы списков и привычные конструкции. И когда нам нужно пройтись по строкам таблицы, руки сами тянутся написать что-то вроде итератора. Проблема в том, что то, что отлично работает в «чистом» питоне, идет вразрез с архитектурой Pandas. Библиотека построена поверх массивов NumPy и ждет от нас совершенно иного, векторизованного подхода.

В этой статье мы разберем 5 самых частых и, к сожалению, очень популярных ошибок при работе с Pandas. Мы посмотрим, как безобидные на первый взгляд решения убивают производительность, сжирают оперативную память и плодят неочевидные баги. Ну и главное — разберем на практических примерах, как переписать этот код так, чтобы всё работало быстро, элегантно и по-пандасовски.

Кстати, если вы только начинаете свой путь в анализе данных или хотите структурировать знания, чтобы сразу писать чистый и оптимизированный код, заглядывайте на мой бесплатный курс — Pandas для анализа данных: Полный курс на Stepik.

А теперь перейдем к нашим любимым антипаттернам. Погнали!

Ошибка 1: Итерация по строкам

Признавайтесь, кто из нас не писал for index, row in df.iterrows():? Когда мы только переходим к анализу данных из чистого питона, цикл for кажется самым логичным и естественным решением. Нужно пройтись по таблице и обновить значение в зависимости от условия? Ну так давайте просто переберем каждую строчку!

Почему это плохо:
В чистом Python списки и циклы работают отлично, но архитектура Pandas устроена иначе. Под капотом у него лежат массивы NumPy, которые используют непрерывные блоки памяти и оптимизированы на уровне языка C. Вся их магия заключается в векторизации — способности применять одну математическую операцию сразу ко всему столбцу данных целиком, без явных циклов.

Когда вы используете iterrows() (или itertuples(), или apply с построчной логикой), вы ломаете этот механизм. Вы заставляете Pandas на каждой итерации распаковывать строку, создавать из нее тяжелый объект Series, передавать его в медленную среду интерпретатора Python для вычислений, а затем записывать результат обратно. На датасете в миллион строк скрипт, который мог бы отработать за миллисекунды, будет выполняться минутами.

Антипаттерн (как делать не надо):
Допустим, у нас есть база товаров, и нам нужно применить скидку 20% ко всем позициям дороже 1000 рублей.

import pandas as pd

# ОЧЕНЬ медленный подход: ручная итерация
for index, row in df.iterrows():
    if row['price'] > 1000:
        df.at[index, 'discounted_price'] = row['price'] * 0.8
    else:
        df.at[index, 'discounted_price'] = row['price']

**Правильное решение: Векторизация и np.where()**
Главное правило: мыслите колонками, а не строками. Если вам нужно применить логику if/else ко всему столбцу, идеальный и самый быстрый инструмент — функция where() из библиотеки NumPy.

import numpy as np

# Быстрый и элегантный подход: векторизация
df['discounted_price'] = np.where(
    df['price'] > 1000,       # Условие
    df['price'] * 0.8,        # Что сделать, если True
    df['price']               # Что сделать, если False
)

Векторизованный код не только в три раза короче и проще читается. Он выполнится практически мгновенно, так как вся математика пройдет «под капотом» на низком уровне, полностью минуя накладные расходы Python.

Ошибка 2: Злоупотребление apply() (или синдром «золотого молотка»)

Метод apply() — это абсолютный любимец многих разработчиков. Он позволяет взять любую кастомную функцию, даже самую навороченную lambda, и применить ее к колонке. Визуально это выглядит как элегантное функциональное программирование: коротко, понятно, в одну строку. Кажется, что это идеальный инструмент для всего.

Почему это плохо:
Иллюзия в том, что раз apply() — это родной метод Pandas, значит, он невероятно быстрый и оптимизированный. К сожалению, суровая правда такова: под капотом apply() — это, по сути, всё тот же замаскированный цикл for с небольшими косметическими оптимизациями.

Он берет вашу функцию и честно, шаг за шагом, дергает ее для каждой отдельной ячейки в колонке. Вы снова добровольно отказываетесь от магии массивов NumPy и заставляете интерпретатор Python потеть над каждой строчкой, выполняя медленные проверки типов и вызовы функций на уровне чистого языка.

Антипаттерн (как делать не надо):
Самый частая ошибка — использование apply() для простых операций со строками, датами или базовой математики. Допустим, нам нужно привести все имена пользователей в таблице к нижнему регистру:

import pandas as pd

# Медленно и ресурсозатратно: лямбда-функция дергается для каждой строки
df['username'] = df['username'].apply(lambda x: x.lower() if isinstance(x, str) else x)

Или, что еще хуже, попытка считать математику:

import numpy as np

# Зачем-то заставляем apply() работать с NumPy
df['log_price'] = df['price'].apply(lambda x: np.log(x))

Правильное решение: Встроенные векторизованные методы
Разработчики Pandas уже написали для нас быстрые, оптимизированные на уровне C инструменты (аксессоры) для работы со строками (.str) и датами (.dt). Именно их и нужно использовать.

# Быстро, лаконично и векторизованно
df['username'] = df['username'].str.lower()

А для математики просто передавайте всю колонку напрямую в NumPy:

# NumPy сам поймет, что ему передали массив, и посчитает всё махом
df['log_price'] = np.log(df['price'])

Золотое правило: apply() — это инструмент последней надежды. Используйте его только тогда, когда логика обработки настолько сложна и специфична (например, парсинг сложных вложенных JSON словарей с API), что для неё физически не существует готового векторизованного решения.

Ошибка 3: Игнорирование SettingWithCopyWarning (или ловушка цепочного присваивания)

Положа руку на сердце: сколько раз вы видели этот огромный красный блок текста в Jupyter Notebook, вздыхали и просто писали pd.options.mode.chained_assignment = None, чтобы он не мозолил глаза?

Это классика. SettingWithCopyWarning — пожалуй, самое известное и самое игнорируемое предупреждение в Pandas. Но прятать его — всё равно что заклеивать изолентой лампочку «Check Engine» на приборной панели. Рано или поздно двигатель заглохнет.

Почему это плохо:
Суть проблемы кроется в том, как Pandas работает с памятью. Когда вы делаете срез или фильтруете данные, библиотека может вернуть вам либо представление (view) — ссылку на те же самые данные в оперативной памяти, либо независимую копию (copy) исходного DataFrame. И самое коварное здесь то, что заранее вы никогда точно не знаете, что именно вернул Pandas.

Если вы попытаетесь изменить данные с помощью цепочного присваивания (сначала отфильтровали, потом тут же обратились к столбцу и присвоили значение), Pandas выполнит две отдельные операции. Сначала он создаст промежуточный объект, а затем попытается обновить его. Если этот промежуточный объект оказался копией, то ваши изменения улетят в пустоту, а исходный DataFrame останется нетронутым. В лучшем случае вы просто получите ворнинг. В худшем — данные исказятся, а вы об этом даже не узнаете, пока не начнете искать причину странных результатов в финальном отчете.

Антипаттерн (как делать не надо):
Допустим, мы хотим всем сотрудникам старше 30 лет присвоить статус «senior».

import pandas as pd

# Цепочное присваивание: сначала фильтруем [...], потом обращаемся к столбцу [...]
# Именно здесь Pandas ругнется SettingWithCopyWarning
df[df['age'] > 30]['status'] = 'senior'

**Правильное решение: Использование .loc[]**
Чтобы избежать двусмысленности и гарантированно изменить исходный DataFrame, нужно делать фильтрацию и выбор столбца за одну операцию. Для этого придуман метод .loc[].

# Элегантно, безопасно и за один проход под капотом
df.loc[df['age'] > 30, 'status'] = 'senior'

Первый аргумент в .loc отвечает за строки (наше условие), второй — за столбцы. Никаких промежуточных объектов, никаких копий, никаких красных предупреждений. Код читается однозначно, а исходные данные обновляются ровно так, как мы и задумывали.

Ошибка 4: Слепое доверие типам данных по умолчанию (или как съесть всю оперативку)

Pandas по своей природе — очень осторожный и невероятно жадный до памяти парень. Когда вы просите его прочитать файл, он пытается угадать типы данных и всегда перестраховывается, выделяя под них максимально возможный объем памяти.

Почему это плохо:
Если Pandas видит числа с плавающей точкой, он не задумываясь влепит им тип float64. Видит целые числа — держите int64. Видит текст — присвоит тип object.

Тип object — это вообще главный враг оперативной памяти. Под капотом это не просто массив строк, а массив указателей на строковые объекты языка Python, разбросанные по всей памяти. Из-за этого безобидный CSV-файл весом в 100 мегабайт при загрузке в DataFrame может легко раздуться до гигабайта и намертво повесить ваш ноутбук или сервер. Вы получаете колоссальный перерасход памяти (иногда в 2-5 раз!) и замедление всех операций, так как процессору приходится гонять огромные массивы данных.

Антипаттерн (как делать не надо):
Самый популярный сценарий — просто загрузить данные и сразу пойти строить сводные таблицы, даже не заглянув в .info().

import pandas as pd

# Загрузили и погнали работать «как есть»
df = pd.read_csv('huge_dataset.csv')

# Если тут сделать df.info(memory_usage='deep'), 
# можно сильно расстроиться от цифр

Правильное решение: Умное приведение типов (Downcasting и Category)
Ваша задача — объяснить Pandas, что ему не нужны «бездонные» типы данных там, где хватит крошечных.

  1. Используйте тип category: Если у вас есть текстовый столбец, в котором значения часто повторяются (например, пол, страна, статус заказа, уровень образования), переводите его из object в category. Под капотом Pandas создаст компактный словарь уникальных значений, а в самой таблице будет хранить только их легковесные числовые коды. Экономия памяти на таких столбцах может достигать 90%!

  2. Понижайте разрядность чисел (downcasting): Если в столбце «возраст» лежат числа от 0 до 100, вам абсолютно не нужен int64 (который вмещает числа до квинтиллионов). С этой задачей отлично справится int8.

# Элегантный подход: оптимизируем память сразу при загрузке или после

# 1. Переводим повторяющиеся строки в категории
df['status'] = df['status'].astype('category')
df['country'] = df['country'].astype('category')

# 2. Понижаем разрядность чисел (Pandas сам подберет оптимальный минимальный тип)
df['age'] = pd.to_numeric(df['age'], downcast='integer')
df['price'] = pd.to_numeric(df['price'], downcast='float')

Всего пара дополнительных строк кода, и ваш скрипт начинает летать, а MemoryError уходит в прошлое.

Ошибка 5: Загрузка огромных датасетов «в лоб» (или хроники зависшего ноутбука)

Знакомая ситуация: вам скинули выгрузку из базы данных на 5–10 гигабайт. Вы по привычке пишете pd.read_csv(), нажимаете Shift+Enter и идете заваривать чай. Возвращаетесь, а ноутбук гудит как турбина самолета, система не реагирует на мышь, а в Jupyter Notebook красуется предательское MemoryError (или ядро просто тихо умирает).

Почему это плохо:
Pandas спроектирован для работы с данными в оперативной памяти (in-memory). По умолчанию он пытается загрузить весь файл целиком. Если файл весит 5 ГБ, то в памяти DataFrame может развернуться на все 15–20 ГБ (вспоминаем предыдущую ошибку с типами данных).

Когда оперативная память заканчивается, операционная система начинает использовать swap-файл — сбрасывать часть данных на жесткий диск. Скорость чтения/записи падает в тысячи раз, и ваш скрипт замедляется до состояния клинической смерти.

Антипаттерн (как делать не надо):
Самая банальная ошибка — тянуть в память весь файл на 50 столбцов, когда для отчета вам нужны только ID пользователя, дата и сумма покупки.

import pandas as pd

# Верный способ повесить систему, если файл весит больше пары гигабайт
df = pd.read_csv('massive_transactions.csv')

**Правильное решение: usecols и chunksize**

Если вы не можете увеличить объем оперативной памяти, вам нужно изменить подход к чтению данных.

1. Фильтруйте столбцы на входе (usecols)
Зачем парсить и загружать в память текстовые отзывы и гео-координаты, если вы собираетесь считать только выручку? Аргумент usecols позволяет читать строго определенные колонки. Файл прочитается в разы быстрее, а памяти потребуется минимум.

# Читаем только 3 нужных столбца из 50
df = pd.read_csv('massive_transactions.csv', usecols=['user_id', 'date', 'price'])

2. Ешьте слона по частям (chunksize)
Если даже нужные столбцы не влезают в память, читайте файл порциями (чанками). Pandas будет подгружать заданное количество строк, вы будете их обрабатывать (например, агрегировать данные), а затем старый кусок будет удаляться из памяти, уступая место новому.

# Читаем файл кусками по 100 000 строк
chunk_iter = pd.read_csv('massive_transactions.csv', usecols=['price'], chunksize=100000)

total_revenue = 0
# Агрегируем результаты на лету
for chunk in chunk_iter:
    total_revenue += chunk['price'].sum()
    
print(f"Общая выручка: {total_revenue}")

Бонус: Перестаньте использовать CSV для больших данных
CSV — это прекрасный формат для обмена небольшими файлами, но он ужасен для аналитики. Это неструктурированный, несжатый текст, который нужно парсить при каждом чтении.

Если вы регулярно работаете с тяжелыми датасетами, конвертируйте их в колоночные форматы — Parquet или Feather. Они сохраняют типы данных (вам не придется заново делать downcasting), сжимают объем файла на диске в несколько раз и читаются буквально за секунды.

# Читается в 10-50 раз быстрее, чем аналогичный CSV
df = pd.read_parquet('massive_transactions.parquet')

Заключение: Чек-лист здорового дата-инженера

Давайте подведем краткий итог. Pandas — это не просто обертка над таблицами, это мощный аналитический движок, который требует понимания того, как он работает с памятью и процессором.

Чтобы ваши скрипты летали, а не падали с нехваткой памяти, держите в голове этот простой чек-лист:

  • Забудьте про циклы и iterrows: Мыслите колонками и используйте векторизацию (встроенные методы или np.where).

  • Не злоупотребляйте apply(): Это замаскированный цикл. Для строк есть .str, для дат — .dt, для математики — NumPy.

  • Никакого цепочного присваивания: Обновляйте отфильтрованные данные только через .loc[].

  • Оптимизируйте типы данных: Переводите повторяющийся текст в category и делайте downcasting для чисел. Это спасет вашу оперативку.

  • Не читайте тяжелые файлы целиком: Используйте usecols для выбора нужных столбцов, chunksize для чтения по частям и переходите с медленного CSV на быстрый Parquet.

Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.