Pandas и NumPy: основы
pandas - рабочий станок аналитика, и вопросы про него почти никогда не про API. Спрашивают про грабли: почему код считается двадцать минут, почему правка «не сохранилась», почему после джойна выручка выросла в полтора раза. Каждый такой вопрос - чей-то бывший продовый инцидент.
Типовые формулировки: «как ускоришь pandas-код на миллионах строк?», «что не так с df[df.a > 0].b = 1?», «чем transform отличается от agg?».
// Слово «векторизация» в первом же предложении ответа про скорость - почти всегда плюс балл. Оно показывает, что ты знаешь настоящую причину, а не собираешься советовать «взять сервер помощнее».
Векторизация: откуда берётся разрыв в тысячи раз
Миллион строк, две колонки, надо перемножить. Через df.apply(lambda row: row.a * row.b, axis=1) это 17.7 секунды. Через df.a * df.b - 0.005 секунды. Разница почти в четыре тысячи раз, и код при этом короче.
Причина не в магии. apply с axis=1 на каждой строке собирает отдельный объект Series, вызывает питоновскую функцию и интерпретирует её байткод - миллион раз подряд. Векторная операция уходит одним вызовом в скомпилированный код, который проходит по сплошному куску памяти и не отвлекается ни на что.
Отсюда простой детектор в чужом коде: ищи всё, что выполняется построчно. iterrows, itertuples, apply(axis=1), обычный for по индексу - это питон-цикл, как его ни назови. Заменяется арифметикой столбцов, np.where вместо if, масками вместо фильтрации в цикле, merge или map вместо поиска по словарю на каждой строке.
// Вторая быстрая победа - память. Колонка на миллион строк с тремя уникальными городами весит 77 мегабайт как строки и 1 мегабайт после astype('category'): вместо миллиона строковых объектов хранится словарь из трёх значений и миллион маленьких кодов. groupby по такой колонке тоже становится заметно быстрее.
- векторизация
- выразить вычисление как операцию над всем столбцом сразу, чтобы цикл выполнялся в скомпилированном коде, а не в питоне
loc, iloc и правка, которая исчезает
loc обращается по меткам индекса, iloc - по позициям. Различие, которое спрашивают почти всегда: срез loc включает правую границу, срез iloc - нет. loc['a':'c'] вернёт три элемента a, b и c, а iloc[0:3] вернёт элементы с номерами 0, 1 и 2. Смешивать метки и позиции в одном выражении нельзя.
Дальше - самая известная ловушка pandas. Код df[df.a > 1].b = 99 выглядит логично: отфильтровали строки, присвоили колонке значение. На деле df[df.a > 1] создаёт новый объект, присваивание правит его, объект тут же уничтожается, а исходный датафрейм остаётся нетронутым.
Раньше pandas хотя бы ругался предупреждением SettingWithCopyWarning. В свежих версиях с копированием при записи операция просто молча не делает ничего: ни исключения, ни предупреждения, данные не изменились. То есть баг стал тише, а не безопаснее.
// Правило гигиены закрывает вопрос навсегда: любая правка подмножества делается одним выражением через .loc - df.loc[df.a > 1, 'b'] = 99. Тогда вопрос «это была копия или окно в оригинал» тебя просто не касается.
- цепочное индексирование
- две операции подряд через квадратные скобки: сначала отфильтровали, потом присвоили. Между ними успевает родиться временный объект, в который и уходит правка
merge, groupby и заразный пропуск
merge - это SQL-джойн со всеми его ловушками, и главная называется размножением строк. Три строки слева, три справа, ключ повторяется в обеих таблицах - на выходе получается пять строк, а сумма колонки вырастает с 60 до 90. Ошибки нет, есть просто метрика, которая теперь врёт в полтора раза.
Страховка встроена в саму функцию: validate='m:1' роняет джойн с MergeError, если ключ справа не уникален. Уронить пайплайн на месте на порядок дешевле, чем через неделю выяснять, почему выручка в отчёте выше, чем в кассе.
У groupby три разных инструмента, и путать их дорого. agg даёт одно число на группу: из значений 1 и 3 в группе «a» и 10 в группе «b» получится [2, 10]. transform возвращает результат той же длины, что исходник: [2, 2, 10] - среднее группы, размноженное обратно в каждую её строку. Это готовая идиома для фичей вида «отклонение от среднего по категории». apply - последний резерв, он гибкий и медленный.
// NaN - это число с плавающей точкой, и он заразен: добавь один пропуск в колонку целых чисел, и вся колонка станет float. Плюс NaN не равен ничему, включая самого себя: сравнение df.col == np.nan всегда даёт False, пропуски ищут только через isna().
- кардинальность джойна
- сколько строк одной таблицы соответствует одной строке другой: один к одному, многие к одному, многие ко многим. Последнее и размножает строки
Как отвечать: «pandas-код тормозит на миллионах строк. Как ускоришь?»
Первым делом ищу скрытые питон-циклы: iterrows и apply с axis=1 - это цикл, как бы он ни выглядел. Переписываю на операции над столбцами, условную логику на np.where и маски, поиск по справочнику на merge. Обычно этого хватает: разрыв там в сотни и тысячи раз, а не в проценты. Дальше смотрю память - строковые колонки с малым числом уникальных значений перевожу в category, это кратно меньше памяти и быстрее groupby. Потом проверяю джойны на validate, потому что «медленно» часто означает «после merge данных стало в десять раз больше, чем должно быть». Если после всего этого всё ещё тесно, беру чанки или переезжаю на polars либо duckdb, но до этого доходит редко.
Порядок разбора: алгоритмическая причина, потом память, потом объём данных, и только последней - смена инструмента. Так рассуждает человек, который чинил чужие ноутбуки, а не читал бенчмарки.
На чём валят
- −iterrows и apply(axis=1) на миллионах строк - секунды превращаются в десятки минут на ровном месте.
- −Джойн с дублями в правом ключе: суммы «выросли», ошибки нет, validate поймал бы это сразу.
- −df[df.a > 0].b = 1 - правка временного объекта. В свежем pandas это происходит вообще без предупреждения.
- −df.col == np.nan всегда False: пропуски ищутся только через isna().
- −Забыть, что срез loc включает правую границу, и получить лишнюю строку в выборке.
- −Взять apply вместо transform и получить агрегат вместо колонки нужной длины.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 40, остальные разбираются в тренажёре.
- df[df.a>0]['b'] = 0 не доходит до df: в старых pandas лишь печатает SettingWithCopyWarning, а в pandas 3.0 (Copy-on-Write) бросает ChainedAssignmentError. Почему?A)Потому что колонки 'b' на самом деле практически нет в этом датафрейме, и именно отсюда берётся предупреждениеB)Потому что pandas запрещает присваивание в колонку после применения булевой маски к строкамC)Цепочечная индексация пишет во временную копию, а не в исходный df; нужен единый df.loc[маска, 'b']D)Потому что число 0 имеет неверный тип и его обязательно нужно приводить к float перед этим присваиванием
показать ответ и разбор
+C)Цепочечная индексация пишет во временную копию, а не в исходный df; нужен единый df.loc[маска, 'b']// разбор: Цепочечная индексация df[df.a>0] строит промежуточный объект (часто копию), и присваивание ['b']=0 меняет его, а не исходный df. До pandas 3.0 это молча терялось с предупреждением SettingWithCopyWarning; в pandas 3.0 модель Copy-on-Write сделала правило строгим — chained assignment для записи бросает ChainedAssignmentError. Лечение в обеих версиях одно: единый индексатор df.loc[df.a>0, 'b'] = 0. Тип нуля и наличие колонки ни при чём.
- После merge число строк неожиданно выросло против обеих исходных таблиц. Частая причина?A)Дубликаты ключа на одной из сторон дают many-to-many; проверь уникальность и параметр validate=B)Merge в pandas физически удваивает каждую строку левой таблицы вне зависимости от ключейC)Строки размножились потому, что в ключевой колонке присутствовали значения разного числового типа данныхD)Это штатное поведение inner join, при котором на выходе строк больше, чем в входе
показать ответ и разбор
+A)Дубликаты ключа на одной из сторон дают many-to-many; проверь уникальность и параметр validate=// разбор: Если ключ не уникален на обеих сторонах, join даёт декартово произведение совпадений: одна строка слева, встретив три совпадения справа, порождает три строки — размножение (fan-out). Лечится проверкой уникальности ключа и параметром validate='one_to_many'/'one_to_one', который уронит merge при нарушении ожидания. Inner join сам по себе строки не плодит — плодят дубликаты ключа.
- Как сократить потребление памяти pandas-датафрейма без потери самих данных?A)Перевести все числовые колонки в текстовый строковый тип, поскольку строки якобы хранятся заметно компактнее чиселB)Даункастить числовые dtypes (int64→int32) и перевести строки малой кардинальности в categoryC)Просто вызвать сборщик мусора gc.collect(), после чего датафрейм автоматически станет занимать вдвое меньше памятиD)Продублировать датафрейм в новую переменную, а старую удалить — это освободит примерно половину занятой памяти
показать ответ и разбор
+B)Даункастить числовые dtypes (int64→int32) и перевести строки малой кардинальности в category// разбор: Память режут, сжимая типы: числовые колонки даункастят до минимально достаточных (int64→int32/int16, float64→float32), а строковые с малым числом уникальных значений переводят в category — pandas хранит словарь категорий и коды вместо повторяющихся строк. Строки не компактнее чисел, а gc.collect и копирование памяти не экономят.
- Чем .loc отличается от .iloc при выборке строк датафрейма?A)Адресует по меткам индекса, тогда как .iloc — по целочисленным позициям 0..n-1B)Работает со строками, а.iloc — со столбцами таблицыC)Возвращает копию данных, а.iloc меняет исходный кадр на местеD)Ничем: это два взаимозаменяемых псевдонима одного способа выборки
показать ответ и разбор
+A)Адресует по меткам индекса, тогда как .iloc — по целочисленным позициям 0..n-1// разбор: loc выбирает по меткам оси (значениям индекса, именам колонок), iloc — по целочисленной позиции от нуля. На дефолтном RangeIndex они совпадают и потому путаются, но стоит индексу стать нестандартным (даты, строки, перемешанный) — поведение расходится. Отдельная грабля: срез df.loc[2:5] включает правую метку, а df.iloc[2:5] — нет, как обычный питон-срез.
- Что делает параметр how='left' у pandas.merge?A)Оставляет строки, у которых ключ нашёлся сразу в обеих таблицахB)Берёт объединение ключей обеих таблиц, заполняя недостающие значения нулямиC)Оставляет все строки левой таблицы; где в правой ключа нет — ставит NaND)Случайно берёт половину строк из левой и половину из правой таблицы
показать ответ и разбор
+C)Оставляет все строки левой таблицы; где в правой ключа нет — ставит NaN// разбор: how='left' сохраняет все ключи левой таблицы и подтягивает совпадения из правой; где справа совпадения нет, её колонки заполняются NaN. 'inner' оставляет только общие ключи, 'outer' — объединение с NaN с обеих сторон, 'right' — зеркально left. Частая ошибка — ждать inner-поведения от left и удивляться NaN и «лишним» строкам при дублях ключа.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.