Привет, Хабр! Наверное, каждый питонист или дата-аналитик рано или поздно плотно знакомится с 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, что ему не нужны «бездонные» типы данных там, где хватит крошечных.
Используйте тип
category: Если у вас есть текстовый столбец, в котором значения часто повторяются (например, пол, страна, статус заказа, уровень образования), переводите его изobjectвcategory. Под капотом Pandas создаст компактный словарь уникальных значений, а в самой таблице будет хранить только их легковесные числовые коды. Экономия памяти на таких столбцах может достигать 90%!Понижайте разрядность чисел (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-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.
