Появление в библиотеке pandas режима Copy‑on‑Write (CoW, копирование при записи) — это изменение, нарушающее обратную совместимость, которое окажет некоторое воздействие на существующий код, использующий pandas. Мы разберёмся с тем, как адаптировать код к новым реалиям, сделать так, чтобы он работал бы без ошибок тогда, когда режим CoW будет включён по умолчанию. Сейчас сделать это планируется в версии pandas 3.0, выход которой ожидается в апреле 2024 года. В первом материале из этой серии мы разбирались с особенностями поведения CoW, во втором — говорили об оптимизации производительности, имеющей отношение к новому режиму работы pandas.

Мы планируем добавить в систему «тревожный режим», в котором она будет выдавать предупреждения при выполнении любой операции, поведение которой меняется при включении CoW. Эти предупреждения будут привлекать к себе очень много внимания пользователей, поэтому к возможности их появления стоит относиться с осторожностью. В этом материале рассматриваются некоторые типичные проблемы кода и то, как его можно адаптировать для того чтобы его поведение не изменилось бы после включения CoW.
Цепное присваивание
Цепное присваивание — это такое действие, когда состояние объекта изменяется в ходе выполнения двух последовательных операций.
import pandas as pd
df = pd.DataFrame({"x": [1, 2, 3]})
df["x"][df["x"] > 1] = 100
Первая операция выбирает столбец «x», а вторая ограничивает количество строк. Существует множество различных комбинаций этих операций (например — в комбинации с loc или iloc). При использовании CoW ни одна из этих комбинаций работать не будет. Попытка их применения приведёт не к молчаливому бездействию системы, к выдаче исключения ChainedAssignmentError, направленного на то, чтобы соответствующие паттерны были бы удалены из кода.
Обычно вместо подобных конструкций можно использовать loc:
df.loc[df["x"] > 1, "x"] = 100
Первое измерение loc всегда соответствует row-indexer. Это означает, что у программиста имеется возможность выбрать подмножество строк. Второе измерение соответствует column-indexer, что позволяет выбрать подмножество строк.
Применение loc обычно позволяет ускорить код в случае, когда нужно задать значения подмножеству строк. Поэтому это позволит сделать код чище и даст улучшение производительности.
Это — очевидный пример ситуации, в которой CoW оказывает влияние на код. Кроме того, CoW влияет и на цепные непосредственные операции с объектом:
df["x"].replace(1, 100)
Тут прослеживается тот же паттерн, что и в предыдущем примере. Первая операция — это выбор столбца. Метод replace пытается работать с временным объектом, в результате чего обновить состояние исходного объекта этому методу не удастся. От подобных паттернов тоже довольно легко избавиться. Делается это путём указания столбцов, с которыми нужно работать.
df = df.replace({"x": 1}, {"x": 100})
Антипаттерны
В предыдущем материале речь шла о том, как работают механизмы CoW, и о том, как объекты DataFrame совместно используют данные, на которых они основаны. При непосредственной модификации одного из объектов, использующих общие данные, будет выполнено защитное копирование.
df2 = df.reset_index()
df2.iloc[0, 0] = 100
Операция reset_index создаст срез данных, на которых основан объект df. Результат выполнения этой операции присваивается новой переменной — df2. Это означает, что два этих объекта совместно используют одни и те же данные. Это остаётся в силе до тех пор, пока объект df не будет уничтожен в ходе сборки мусора. Операция setitem, в результате, вызовет копирование данных. В этом совершенно нет необходимости в том случае, если программисту больше не нужен исходный объект df. Обычная перезапись значения одной переменной просто сделает недействительной ссылку, которая удерживается объектом.
df = df.reset_index()
df.iloc[0, 0] = 100
Подводя итог, можно сказать, что создание множества ссылок в одном и том же методе приводит к поддержанию в рабочем состоянии ненужных сущностей.
При этом временные ссылки, создаваемые при объединении нескольких методов в цепочку — это вполне нормально.
df = df.reset_index().drop(...)
При таком подходе в рабочем состоянии останется лишь одна ссылка.
Обращение к массиву NumPy, на котором основан объект DataFrame
Сейчас библиотека pandas позволяет обращаться к NumPy‑массивам, на которых основаны датафреймы, пользуясь to_numpy или .values. Возвращённый массив — это копия данных в том случае, если соответствующий DataFrame состоит из данных разных типов. Например:
df = pd.DataFrame({"a": [1, 2], "b": [1.5, 2.5]})
df.to_numpy()
[[1. 1.5]
[2. 2.5]]
Этот объект DataFrame основан на двух массивах, которые необходимо объединить в один. Это вызывает копирование данных.
Другой случай — это когда в основе DataFrame лежит лишь один массив NumPy:
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
df.to_numpy()
[[1 3]
[2 4]]
Тут можно напрямую обратиться к массиву и получить не копию, а срез. Это — гораздо быстрее, чем копирование всех данных. Теперь можно работать с массивом NumPy и, в принципе, можно непосредственно модифицировать его элементы, что приведёт и к изменению и исходного объекта DataFrame, и тех объектов, которые используют те же данные, что и этот объект. Всё становится гораздо сложнее при применении CoW, так как это означает отсутствие множества защитных копий данных, существовавших ранее. Гораздо больше объектов DataFrame теперь будут совместно использовать одни и те же области памяти.
Из-за этого команды to_numpy и .values будут возвращать массивы, предназначенные только для чтения. Это значит, что в получившиеся массивы нельзя будет записывать данные.
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
arr = df.to_numpy()
arr[0, 0] = 1
Эта конструкция вызовет исключение ValueError:
ValueError: assignment destination is read-only
Избежать этой проблемы можно двумя способами:
Вручную инициировать копирование в том случае, если нужно избежать изменения объектов
DataFrame, которые совместно используют память, хранящую массив.Сделать массив пригодным для записи. Это решение отличается лучшей производительностью, но оно обходит правила CoW, поэтому им следует пользоваться с осторожностью.
arr.flags.writeable = True
В некоторых ситуациях это невозможно. Один из типичных случаев — это когда обращаются к отдельному столбцу, который основа на PyArrow:
ser = pd.Series([1, 2], dtype="int64[pyarrow]")
arr = ser.to_numpy()
arr.flags.writeable = True
В результате выполнения такого кода будет выдано исключение ValueError:
ValueError: cannot set WRITEABLE flag to True of this array
Массивы Arrow иммутабельны, в результате тут нельзя сделать так, чтобы в массив NumPy можно было бы записывать данные. В данном случае переход от Arrow к NumPy — это пример операции, где копирования данных не происходит.
Итоги
Мы рассмотрели наиболее серьёзные изменения pandas, связанные с режимом Copy‑on‑Write. Этот режим станет стандартным в pandas 3.0. Мы, кроме того, поговорили о том, как можно адаптировать код к особенностям CoW, сделать так, чтобы включение этого режима не нарушило бы работу программ. Если вы сможете избежать антипаттернов, описанных в этом материале, это значит, что вы без проблем обновитесь до версии pandas, в которой режим копирования при записи будет включён по умолчанию.
О, а приходите к нам работать? ???? ????
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.