сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · pandas и numpy

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, остальные разбираются в тренажёре.

  1. #pandas_numpy1 / 5
    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. Тип нуля и наличие колонки ни при чём.

  2. #pandas_numpy2 / 5
    После 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 сам по себе строки не плодит — плодят дубликаты ключа.

  3. #pandas_numpy3 / 5
    Как сократить потребление памяти pandas-датафрейма без потери самих данных?
    A)Перевести все числовые колонки в текстовый строковый тип, поскольку строки якобы хранятся заметно компактнее чисел
    B)Даункастить числовые dtypes (int64→int32) и перевести строки малой кардинальности в category
    C)Просто вызвать сборщик мусора gc.collect(), после чего датафрейм автоматически станет занимать вдвое меньше памяти
    D)Продублировать датафрейм в новую переменную, а старую удалить — это освободит примерно половину занятой памяти
    показать ответ и разбор
    +B)Даункастить числовые dtypes (int64→int32) и перевести строки малой кардинальности в category

    // разбор: Память режут, сжимая типы: числовые колонки даункастят до минимально достаточных (int64→int32/int16, float64→float32), а строковые с малым числом уникальных значений переводят в category — pandas хранит словарь категорий и коды вместо повторяющихся строк. Строки не компактнее чисел, а gc.collect и копирование памяти не экономят.

  4. #pandas_numpy4 / 5
    Чем .loc отличается от .iloc при выборке строк датафрейма?
    A)Адресует по меткам индекса, тогда как .iloc — по целочисленным позициям 0..n-1
    B)Работает со строками, а.iloc — со столбцами таблицы
    C)Возвращает копию данных, а.iloc меняет исходный кадр на месте
    D)Ничем: это два взаимозаменяемых псевдонима одного способа выборки
    показать ответ и разбор
    +A)Адресует по меткам индекса, тогда как .iloc — по целочисленным позициям 0..n-1

    // разбор: loc выбирает по меткам оси (значениям индекса, именам колонок), iloc — по целочисленной позиции от нуля. На дефолтном RangeIndex они совпадают и потому путаются, но стоит индексу стать нестандартным (даты, строки, перемешанный) — поведение расходится. Отдельная грабля: срез df.loc[2:5] включает правую метку, а df.iloc[2:5] — нет, как обычный питон-срез.

  5. #pandas_numpy5 / 5
    Что делает параметр how='left' у pandas.merge?
    A)Оставляет строки, у которых ключ нашёлся сразу в обеих таблицах
    B)Берёт объединение ключей обеих таблиц, заполняя недостающие значения нулями
    C)Оставляет все строки левой таблицы; где в правой ключа нет — ставит NaN
    D)Случайно берёт половину строк из левой и половину из правой таблицы
    показать ответ и разбор
    +C)Оставляет все строки левой таблицы; где в правой ключа нет — ставит NaN

    // разбор: how='left' сохраняет все ключи левой таблицы и подтягивает совпадения из правой; где справа совпадения нет, её колонки заполняются NaN. 'inner' оставляет только общие ключи, 'outer' — объединение с NaN с обеих сторон, 'right' — зеркально left. Частая ошибка — ждать inner-поведения от left и удивляться NaN и «лишним» строкам при дублях ключа.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.