Работа с pandas.DataFrame может превратиться в неловкую кучу старого (не очень) доброго спагетти-кода. Я и мои коллеги часто используем эту библиотеку, и хотя мы стараемся придерживаться хороших практик программирования, таких как разделение кода на модули и модульное тестирование, иногда мы все равно мешаем друг другу, создавая запутанный код.

Я собрала несколько советов и подводных камней, которых следует избегать, чтобы сделать код на pandas чистым. Надеюсь, вам они тоже будут полезны. Также я буду ссылаться на классическую книгу Роберта Мартина «Чистый код: создание, анализ и рефакторинг».

TL;DR:

Не существует единственно правильного способа написания кода, но вот несколько советов для работы с pandas:

«Нет»

  • не изменяйте DataFrame слишком сильно внутри функций, потому что так можно потерять контроль над тем, что и где будет добавлено/удалено из него;

  • не пишите методы, которые изменяют DataFrame и не возвращают его, потому что это сбивает с толку.

«Да»

  • Создавайте новые объекты вместо того, чтобы изменять исходныйDataFrame, и не забывайте делать глубокую копию, когда это необходимо;

  • выполняйте только операции аналогичного уровня внутри одной функции;

  • разрабатывайте функции с учетом возможности переиспользования;

  • тестируйте свои функции, потому что это поможет вам создать более чистый код, защититься от ошибок и крайних случаев и документировать его.

Не надо так

Для начала рассмотрим несколько ошибочных паттернов, вдохновленных реальной жизнью. Позже мы попробуем улучшить этот код с точки зрения читабельности и контроля над происходящим.

Мутабельность

Самое главное, что тут нужно вспомнить: pandas.DataFrame — это изменяемые объекты [2, 3]. Когда вы изменяете мутабельный объект, это затрагивает тот же самый экземпляр, который вы изначально создали, и его физическое расположение в памяти остается неизменным. В отличие от этого, когда вы изменяете неизменяемый объект (например, строку), Python создает новый объект в новом месте памяти и меняет ссылку на новый объект.

Это очень важный момент: в Python объекты передаются в функцию путем присваивания [4, 5]. Посмотрите на картинку ниже: значение df было присвоено переменной in_df, когда она была передана в функцию в качестве аргумента. И исходное значение df, и in_df внутри функции указывают на одну и ту же область памяти (числовое значение в круглых скобках), даже если они имеют разные имена переменных. Во время модификации атрибутов расположение изменяемого объекта остается неизменным.

Изменение мутабельного объекта в памяти
Изменение мутабельного объекта в памяти

На самом деле, поскольку мы изменили исходный экземпляр, возвращать DataFrame и присваивать его переменной избыточно. Этот код дает точно такой же эффект:

Изменение мутабельного объекта в памяти: убрали избыточный код
Изменение мутабельного объекта в памяти: убрали избыточный код

Внимание: функция теперь возвращает None, поэтому будьте осторожны, чтобы не перезаписать df на None, если вы выполните присваивание: df = modify_df(df).

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

Работа с неизменяемым объектом в памяти
Работа с неизменяемым объектом в памяти

Дело в том, что изменениеDataFrame внутри функций имеет глобальный эффект. Если вы не будете помнить об этом, вы можете:

  • случайно изменить или удалить часть данных, думая, что действие происходит только внутри области видимости функции, а это не так;

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

Выходные аргументы

Мы исправим эту проблему позже, а сейчас — еще одно «нет», прежде чем мы перейдем к «да».

Конструкция из предыдущего раздела на самом деле является антипаттерном, называемым выходным аргументом [1 стр.45]. Как правило, входные данные функции используются для создания выходного значения. Если единственным смыслом передачи аргумента в функцию является его модификация, то есть входной аргумент меняет свое состояние, то это бросает вызов нашей интуиции. Такое поведение называется побочным эффектом (англ. side effect) [1 стр.44] функции, и оно должно быть хорошо задокументировано, а лучше — сведено к минимуму, поскольку заставляет программиста помнить о том, что происходит в фоновом режиме, а значит, повышает вероятность ошибиться.

When we read a function, we are used to the idea of information going in to the function through arguments and out through the return value. We don’t usually expect information to be going out through the arguments. [1 p.41]

Когда мы читаем функцию, мы привыкли к тому, что информация поступает в функцию через аргументы, а выходит через возвращаемое значение. Обычно мы не ожидаем, что информация будет возвращена через аргументы. [1 p.41]

Ситуация становится еще хуже, если функция несет двойную ответственность: и изменяет входные данные, и возвращает выходные. Рассмотрим эту функцию:

def find_max_name_length(df: pd.DataFrame) -> int:
    df["name_len"] = df["name"].str.len()  # side effect
    return max(df["name_len"])

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

Надо вот так!

Минимизируем модификации объектов

Чтобы устранить побочный эффект, в приведенном ниже коде мы создали новую временную переменную вместо того, чтобы модифицировать исходный DataFrame. Обозначение lengths: pd.Series указывает на тип данных переменной.

def find_max_name_length(df: pd.DataFrame) -> int:
    lengths: pd.Series = df["name"].str.len()
    return max(lengths)

Такая конструкция функции лучше тем, что она инкапсулирует промежуточное состояние, а не создает побочный эффект.

Еще одно предупреждение: пожалуйста, помните о различиях между глубоким и поверхностным копированием [6] элементов из DataFrame. В приведенном выше примере мы изменили каждый элемент исходной серии df["name"], поэтому старый DataFrame и новая переменная не имеют общих элементов. Однако если вы напрямую присвоите один из исходных столбцов новой переменной, базовые элементы по-прежнему будут иметь одинаковые ссылки в памяти. Вот примеры:

df = pd.DataFrame({"name": ["bert", "albert"]})

series = df["name"]     # поверхностная копия
series[0] = "roberta"   # <-- изначальный DataFrame изменяется

series = df["name"].copy(deep=True)
series[0] = "roberta"   # <-- изначальный DataFrame не изменяется

series = df["name"].str.title()  # в любом случае не копия
series[0] = "roberta"   # <-- изначальный DataFrame не изменяется

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

Группируем похожие операции

Возможно, по какой-то причине вы хотите сохранить результат вычисления длины. Все равно не стоит добавлять его в DataFrame внутри функции из-за возможных побочных эффектов, а также из-за накопления нескольких обязанностей в одной функции.

Мне нравится правило One Level of Abstraction per Function, которое гласит:

We need to make sure that the statements within our function are all at the same level of abstraction.

Mixing levels of abstraction within a function is always confusing. Readers may not be able to tell whether a particular expression is an essential concept or a detail. [1 p.36]

Нам нужно убедиться, что все действия внутри нашей функции находятся на одном уровне абстракции.

Смешение уровней абстракции в функции всегда приводит к путанице. Читатель может не понять, является ли конкретное выражение существенной концепцией или деталью. [1 стр.36]

Также давайте воспользуемся принципом единственной ответственности [1 стр.138] из ООП, хотя сейчас мы не сосредоточены на объектно-ориентированном коде. (И в принципе ООП даже в контексте Python — это последнее, с чем ассоциируется анализ данных с использованием pandas. (примечание автора перевода))

Почему бы не подготовить данные заранее? Давайте разделим подготовку данных и собственно вычисления на отдельные функции:

def create_name_len_col(series: pd.Series) -> pd.Series:
    return series.str.len()

def find_max_element(collection: Collection) -> int:
    return max(collection) if len(collection) else 0

df = pd.DataFrame({"name": ["bert", "albert"]})
df["name_len"] = create_name_len_col(df.name)
max_name_len = find_max_element(df.name_len)

Отдельная задача создания столбца name_len была передана другой функции. Она не изменяет исходный DataFrame и выполняет одну задачу за раз. Позже мы получим максимальный элемент, передав новый столбец другой специальной функции.

Приведем код в порядок с помощью следующих шагов:

  • Мы можем использовать функцию concat и перенести ее в отдельную функцию prepare_data, которая сгруппировала бы все шаги подготовки данных в одном месте;

  • Мы также можем воспользоваться методом apply и работать с отдельными текстами, а не с сериями текстов;

  • Не будем забывать про использование поверхностного и глубокого копирование в зависимости от того, нужно или не нужно изменять исходные данные:

def compute_length(word: str) -> int:
    return len(word)

def prepare_data(df: pd.DataFrame) -> pd.DataFrame:
    return pd.concat([
        df.copy(deep=True),  # deep copy
        df.name.apply(compute_length).rename("name_len"),
        ...
    ], axis=1)

Переиспользуем код

То, как мы разделили код, позволяет легко вернуться к скрипту позже, взять всю функцию и повторно использовать ее в другом скрипте, и это замечательно!

Есть еще одна вещь, которую можно сделать для повышения уровня повторного использования: передавать имена столбцов в качестве параметров в функции. Рефакторинга уже много, но иногда за гибкость и возможность повторного использования приходится платить.

def create_name_len_col(df: pd.DataFrame, orig_col: str, target_col: str) -> pd.Series:
    return df[orig_col].str.len().rename(target_col)

name_label, name_len_label = "name", "name_len"
pd.concat([
    df,
    create_name_len_col(df, name_label, name_len_label)
], axis=1)

Делаем код тестируемым

Вы когда-нибудь выясняли, что ваша предобработка была ошибочной, после нескольких недель экспериментов с предварительно обработанным набором данных? Нет? Повезло. На самом деле мне случалось повторять всю серию экспериментов из-за неработающих аннотаций, чего можно было бы избежать, если бы я протестировала всего пару базовых функций.

Итак, важные скрипты должны быть протестированы [1, с. 121, 7]. Даже если скрипт — всего лишь помощник, теперь я стараюсь тестировать хотя бы важнейшие, самые низкоуровневые функции. Давайте вернемся к шагам, которые мы сделали с самого начала:

  1. Здесь тестируется куча разных функций: вычисление длины имени и агрегирование результата для элемента max . А тест падает c AttributeError: Can only use .str accessor with string values!, хотя мы этого не ожидали, не так ли?

def find_max_name_length(df: pd.DataFrame) -> int:
    df["name_len"] = df["name"].str.len()  # побочный эффект
    return max(df["name_len"])


@pytest.mark.parametrize("df, result", [
    (pd.DataFrame({"name": []}), 0),  # упс, здесь тест упадет
    (pd.DataFrame({"name": ["bert"]}), 4),
    (pd.DataFrame({"name": ["bert", "roberta"]}), 7),
])
def test_find_max_name_length(df: pd.DataFrame, result: int):
    assert find_max_name_length(df) == result
  1. Уже гораздо лучше — мы сосредоточились на одной задаче, поэтому тест стал проще. Кроме того, нам не нужно зацикливаться на именах столбцов, как это было раньше. Однако мне всё ещё кажется, что формат данных мешает проверке правильности вычислений.

def create_name_len_col(series: pd.Series) -> pd.Series:
    return series.str.len()


@pytest.mark.parametrize("series1, series2", [
    (pd.Series([]), pd.Series([])),
    (pd.Series(["bert"]), pd.Series([4])),
    (pd.Series(["bert", "roberta"]), pd.Series([4, 7]))
])
def test_create_name_len_col(series1: pd.Series, series2: pd.Series):
    pd.testing.assert_series_equal(create_name_len_col(series1), series2, check_dtype=False)
  1. Здесь мы навели порядок и тестируем саму вычислительную функцию без обёртки в виде pandas. Легче придумать крайние случаи, если сосредоточиться на чем-то одном. Я поняла, что хочу проверить значения None, которые могут появиться в DataFrame, и в итоге мне пришлось усовершенствовать свою функцию, чтобы тест прошел. Баг пойман!

def compute_length(word: Optional[str]) -> int:
    return len(word) if word else 0


@pytest.mark.parametrize("word, length", [
    ("", 0),
    ("bert", 4),
    (None, 0)
])
def test_compute_length(word: str, length: int):
    assert compute_length(word) == length
  1. Нам не хватает только теста для find_max_element:

def find_max_element(collection: Collection) -> int:
    return max(collection) if len(collection) else 0


@pytest.mark.parametrize("collection, result", [
    ([], 0),
    ([4], 4),
    ([4, 7], 7),
    (pd.Series([4, 7]), 7),
])
def test_find_max_element(collection: Collection, result: int):
    assert find_max_element(collection) == result

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

Заключение

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

Буду рада фидбеку и другим комментариям. Счастливого кодинга!

Cписок источников

Иллюстрации были созданы с помощью Miro.

Комментарии (15)


  1. excoder
    06.04.2024 12:46
    +7

    У pandas вообще очень дурной api. В этом плане гораздо больше приятен dplyr из R, там всё чистенько и потоково. Но, массовая разработка, все используют Питон. Мыши плакали, кололись, и нам приходится :)


    1. NechkaP Автор
      06.04.2024 12:46
      +4

      Спасибо за комментарий! Да, соглашусь, что R для классической аналитики очень даже хорош, и его много где недооценивают)


    1. akakoychenko
      06.04.2024 12:46
      +5

      Как-то нанимал команду дата саентистов с 0 (задолго еще до последней волны хайпа). Собеседовал исключительно синиоров и лидов. Тоже был несказанно поражен тем, насколько среди них популярен пандас, и какой малый % из них способен писать SQL чуть сложнее совсем базового. Вопросы вида "а как будешь готовить обучающий датасет на 100ГБ?" в тупик ставят, многие до синиоров доросли, ни разу даже в 1ГБ не пощупав данных в проде. Кто-то вспоминает о костыльных способах масштабирования пандаса, библиотеках, совместимых с ним по сигнатурам, но раскладывающим задачи в map-reduce.

      К слову, как по мне, то, что от pandas, что от dplyr, надо совсем немного, когда умеешь работать с SQL - их задача лишь какие-то мелочи поправить, чтобы сшить result set со входом модели. Как не крути, но для абсолютного большинства задач современные SQL движки аналитических баз (или исполнителей SQL поверх файлов, как Presto) сделают подготовку входа для библиотеки ML/анализа/визуализации куда эффективнее, и, главное, управляемее и масштабированее, чем библиотеки манипулирования датасетами, запускаемые из под того же python в его же процессе


      1. NechkaP Автор
        06.04.2024 12:46
        +1

        Согласна про большие объемы данных и SQL, с ними точно стоит уметь работать.
        Хотя есть достаточно приятные и Python-специфичные инструменты визуализации (я, например, люблю библиотеку Plotly и фреймворк Dash для практически моментального написания веб-приложений), и тут в случае подготовки какого-то конкретного отчета очень даже подходит pandas


    1. economist75
      06.04.2024 12:46
      +1

      "Дурной" vs "чистенько, потоково" не может относиться к близким системам. На R полно безобразного кода, как и на Pandas. Но на Pandas в десятки тысяч раз больше кода, поэтому рекомендации из статьи, безусловно, полезны и актуальны.

      DS/DI-ники под свои UDF часто пишут не тесты, а используют личный краткий тестовый df с чрезвычайно грязными данными (смесь типов, псевдокириллица из латиницы, смесь кодировок, пять разных пробелов и чисто статловушки (смесь разных ед измерения). На нем прекрасно вылезают всё непредусмотренные случаи и ошибки. Это как сквозной пример в бухучете - в конце должен получиться баланс с круглыми цифрами (а в DS - ML модель должна сойтись и дать 97,5% Accuracy).


      1. NechkaP Автор
        06.04.2024 12:46

        Благодарю за комментарий, тоже отличная стратегия, особенно если код использует в основном только автор, вполне можно так заменить тесты)


      1. excoder
        06.04.2024 12:46
        +1

        У пандаса один плюс, всю эту кодолапшу в терминах его API теперь нам пишет ChatGPT. А потому что столько пандаса кругом, ChatGPT очень хорошо его выучил. Это радует, не приходится так расстраиваться теперь при его использовании :) С долей сарказма, но тут только доля шутки.


  1. miotema
    06.04.2024 12:46

    Ссылаться на «Чистый код» и Боба Мартина в 2024 — дурной тон. Это сборник вредных советов. Даже на момент издания код был мягко говоря безобразным.


    1. NechkaP Автор
      06.04.2024 12:46

      Я открыта к дискуссии, подскажете, пожалуйста, что из советов в данной заметке вы считаете вредным?
      (Хотя я лишь автор перевода, а не самой статьи, перевела её именно потому, что считаю написанное не необходимым, конечно, но как минимум адекватным)


  1. Dgolubetd
    06.04.2024 12:46
    +4

    Эх, если бы наш бывший дата-саентист следовал этим советам.. У меня даже отвращение к DataFrame выработалось, типа вам мало динамичности в Python - получайте ещё удар в пах.


  1. danilovmy
    06.04.2024 12:46
    +5

    Чет не торт:

    В примере:

    def compute_length(word: str) -> int:
        return len(word)
    
    def prepare_data(df: pd.DataFrame) -> pd.DataFrame:
        return pd.concat([
            ...
            df.name.apply(compute_length).rename("name_len"),
            ...
        ],...)

    Неужели только у меня екнула мылсь о тотальной ненужности compute_length как и тестов к этой функции :

    def prepare_data(df: pd.DataFrame) -> pd.DataFrame:
        return pd.concat([
            ...
            df.name.apply(len).rename("name_len"),
            ...
        ],...)

    Про падающий len(None) отписал ниже.

    Далее, в оригинале непонятно что имел ввиду автор тут:

    eries = df["name"].str.title()  # not a copy whatsoever (в любом случае не копия)

    Из документации: str.len()возвращает новую серию, содержащую длины строк исходной серии. Таким образом, исходная серия остаётся неизменной. Так что это результат с числами вместо строк, о какой копии или не копии может идти речь?

    Незнание базового Python тоже удручает:

    def find_max_name_length(df: pd.DataFrame) -> int:
        df["name_len"] = df["name"].str.len()  # побочный эффект
        return max(df["name_len"])
    
    @pytest.mark.parametrize("df, result", [
        (pd.DataFrame({"name": []}), 0),  # упс, здесь тест упадет

    Да, точно, давайте создадим Safe Max функцию:

    def find_max_element(collection: Collection) -> int:
        return max(collection) if len(collection) else 0

    Вместо того что бы почитать документацию про max тут:

    def find_max_element(collection: Collection) -> int:
        return max(collection, default=0)

    Ну и, напоследок, про "пойманный баг". В оригинале автор(ка) прежде, чем работать с данными, их не провалидировала. По тестам видно, что данные - это набор строк, который вероятно, может содержать нулевые значения. Разумеется, можно на функции просчета len возвращать 0 если передано значение Null. Это, кстати, придется делать для всех функций падающих на Null. Ещё можно функцию применять не к df.Name а к df.Name.str. Падать len уже не будет, потому как будут передаваться 'None'. Но результаты плачевные, это видно тут:

    def create_name_len_col(series: pd.Series) -> pd.Series:
        return series.str.len()

    На каждое Noneзначение в исходной серии значений получим 4, вместо 0. Но в тестах это пропущено.

    Так что... Товарищи! Мойте руки Валидируйте данные перед едой! Ну и про тесты не забывайте, а то - тут одно протестировали, там другое... не надо так.

    p.s. За перевод спасибо!


    1. NechkaP Автор
      06.04.2024 12:46

      Это ценный комментарий, благодарю!)

      Точно могу согласиться с поинтом про валидацию данных заранее, хотя на практике всякое случается)
      Что касается момента с "не-копией", я поняла его так, что там присвоили значение новому объекту Series, а потому он не имеет отношения к изменению исходного датафрейма (вполне очевидно, но это подчеркнули)


  1. Andrey_Solomatin
    06.04.2024 12:46
    +1

    Мне понравились идеи из этого видео.

    https://www.youtube.com/watch?v=zgbUk90aQ6A

    Но так как я на Pandas не пишу, я так и не применил их на практике.


  1. mvakhmenin
    06.04.2024 12:46

    (И в принципе ООП даже в контексте Python — это последнее, с чем ассоциируется анализ данных с использованием pandas. (примечание автора перевода)) - гугл-транслейт уже начал примечания писать? :о)


    1. NechkaP Автор
      06.04.2024 12:46

      Ага, гугл-транслейт так поумнел, что еще и ваш комментарий промодерировал и отвечает