Команда Python for Devs подготовила перевод статьи о том, почему Python — несмотря на свою популярность — не всегда идеален для Data Science. Автор показывает, как отсутствие нестандартной оценки выражений усложняет анализ данных, и сравнивает Python с R, где такие задачи решаются куда элегантнее.


Это вторая часть серии о том, какие ограничения есть у Python как у языка для Data Science. Первую часть можно найти здесь. Если вы ещё не читали её, сделайте это сначала — она даёт важный контекст.

Обычно мне скучно обсуждать, какие языки программирования подходят для тех или иных задач. Все языки, которыми мы пользуемся, тьюринг-полны, и любую задачу можно решить на любом языке. И что ещё важнее — применимость языка чаще определяется доступными библиотеками и инфраструктурой экосистемы, чем самим языком. Современные языки достаточно гибкие, и под почти любую вычислительную задачу можно написать эффективные и изящные библиотеки практически на любом из них.

 Image by Rubaitul Azad on Unsplash

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

Для примера рассмотрим оба случая. Во-первых, нестандартная оценка выражений. В Python её нет, и это реальное ограничение языка, которое приводит к громоздким интерфейсам в библиотеках вроде pandas или Polars. Во-вторых, замыкания. В Python они есть, но используются программистами относительно редко. Python-сообщество в целом скорее будет строить объекты, а не использовать замыкания, тогда как в R принято противоположное. Это приводит к разным стилям кода, которые могут быть более или менее удобны в конкретных сценариях.¹

Здесь я хочу сосредоточиться именно на реальных ограничениях языка. О соглашениях внутри сообщества я поговорю в отдельной статье. Основные проблемы, которые я вижу в Python как в языке для Data Science, — это семантика call-by-reference, отсутствие встроенного представления пропущенных значений, отсутствие встроенной векторизации и отсутствие нестандартной оценки выражений. Есть ещё вопрос синтаксиса Python, но здесь я его обсуждать не буду. Скажу лишь, что нужно обладать определённой нечувствительностью к страданиям других людей, чтобы спроектировать язык, в котором возможны баги из-за пробелов.

Семантика call-by-reference

Python использует call-by-reference для изменяемых объектов. Это означает, что если вы передаёте изменяемый объект в функцию, функция может менять его как угодно. После вызова функции вы никогда не можете быть уверены, что объект остался неизменным. А что такое изменяемые объекты? Это все нетривиальные структуры данных, которые вы почти наверняка будете использовать для хранения данных: списки, словари и любые пользовательские классы.

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

def mask_ends_and_join(x):
    x[0] = '_'
    x[-1] = '_'
    return ''.join(x)

abc = ['A', 'B', 'C']
print(mask_ends_and_join(abc))
## _B_

print(abc) # the list has unexpectedly changed
## ['_', 'B', '_']

Чтобы показать, что интерактивный скриптовый язык с динамической типизацией вовсе не обязан вести себя так, посмотрим на эквивалентный код в R:

mask_ends_and_join <- function(x) {
  x[1] <- '_'
  x[length(x)] <- '_'
  paste0(x, collapse = '')
}

abc <- c('A', 'B', 'C')
print(mask_ends_and_join(abc))
## [1] "_B_"

print(abc) # the original vector of letters is unchanged
## [1] "A" "B" "C"

Такое поведение, как в R, по-моему, куда безопаснее. Я хочу, чтобы язык программирования защищал меня от глупых ошибок вроде случайного изменения переменных в вызывающем окружении. Я не хочу, чтобы язык создавал ловушки на каждом шагу. На самом деле я считаю call-by-reference одной из крупнейших ошибок в дизайне Python. И это касается не только Data Science — обязательная передача по ссылке порождает целый класс неочевидных багов, которые трудно найти и исправить. Многие начинающие программисты на Python попадаются на эту ловушку. Они пишут функцию вроде mask_ends_and_join(), затем сталкиваются с нежелательными побочными эффектами, теряются и думают, что всё вокруг абсурд. Опытные разработчики на Python знают, что перед изменением списка нужно сделать копию, но сам язык никак не защищает от ситуации, когда вы забыли это сделать.²

На мой взгляд, одна эта особенность языка делает Python непригодным для большинства серьёзных проектов. Как вообще можно строить что-то важное на языке с такой зияющей «дырой» в безопасности? И действительно возникает вопрос: почему язык вообще ведёт себя таким образом? Я считаю это преждевременной оптимизацией. В 1990-х, когда Python создавали, компьютеры были медленными и имели мало памяти, поэтому call-by-reference для объектов был разумным подходом при разработке скриптового языка с хорошей производительностью. Но в 2025 году я бы не хотел видеть такой механизм вызова функций по умолчанию. R использует копирование при записи, и это работает прекрасно, обеспечивая гарантии корректности, которые Python просто не может дать.³

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

Отсутствие встроенного представления пропущенных значений

Пропуски — это неизбежная часть Data Science. Редко бывает, чтобы в наборе данных не было ни одного пропущенного значения. И всё же в Python работать с пропусками удивительно неудобно. В языке есть ключевое слово None, но оно совершенно не подходит для представления отсутствующих данных. Причина в том, что None имеет собственный тип, поэтому оно не может представлять ни «отсутствующее число», ни «отсутствующее булево значение», ни «отсутствующую строку». Это объект, обозначающий отсутствие значения. И что особенно важно — с None нельзя выполнять стандартные вычисления. Например, такой код приводит к ошибке:

x = [1, 2, None, 4, 5]
[i > 3 for i in x]
## Traceback (most recent call last):
##   File "<stdin>", line 1, in <module>
## TypeError: '>' not supported between instances of 'NoneType' and 'int'

По-хорошему, как мне кажется, вместо ошибки следовало бы получить такой результат:
[False, False, None, True, True].

Поскольку в Python нет единого стандарта для пропущенных значений, каждая аналитическая библиотека изобретает своё собственное. NumPy использует nan, pandas — NA, Polars — null. И поведение при вычислениях с пропусками у этих библиотек тоже различается. Вот что делает NumPy:

import numpy as np
 
x = np.array([1, 2, np.nan, 4, 5])
x > 3
## array([False, False, False,  True,  True])

Вот что делает pandas:

import pandas as pd

x = pd.Series([1, 2, pd.NA, 4, 5])
x > 3
## 0    False
## 1    False
## 2    False
## 3     True
## 4     True
## dtype: bool

А вот что делает Polars:

import polars as pl
 
x = pl.Series([1, 2, None, 4, 5])
x > 3
## shape: (5,)
## Series: '' [bool]
## [
## 	false
## 	false
## 	null
## 	true
## 	true
## ]

Из всех трёх вариантов, на мой взгляд, корректно обрабатывает пропуски только Polars. Пропущенные значения должны «заражать» последующие вычисления, чтобы вы случайно не посчитали что-то по пропущенным данным и не получили неправильный результат. Ни NumPy, ни pandas так не делают.

Но не стоит слишком надеяться на Polars. Он тоже не всегда «заражает» вычисления пропусками. Например, при вычислении суммы или среднего он просто игнорирует их — и изменить это поведение никак нельзя.

Отсутствие встроенной векторизации

Векторизация — это возможность выполнять операции сразу над целым массивом значений, а не по одному элементу за раз. Это типичная особенность ранних языков для научных вычислений, вроде Fortran или Matlab. В R векторизация — тоже основной способ обработки данных.

Сегодня векторизацию нередко считают анахронизмом. Немногие современные языки поддерживают её на уровне самого языка. Одно заметное исключение — Julia, относительно молодой язык, созданный специально для Data Science. Ирония в том, что вся современная глубокая нейросеть строится на векторизации. (Тензор — это современная версия векторизированного типа данных.)

Причина, по которой векторизацию сегодня часто не считают критически важной, в том, что её можно реализовать через библиотеки — благодаря различным механизмам расширения, которые есть в современных языках. И действительно, в Python векторизация обеспечивается библиотеками вроде NumPy, pandas или Polars. И хотя это работает, со временем я пришёл к выводу, что для языка, предназначенного для Data Science, это не лучшая стратегия. Такой подход приводит к хаотичному разнообразию реализаций векторных типов данных. В Python у нас есть, как минимум, встроенные списки (не векторизованные), а также векторизованные структуры — NumPy arrays, pandas Series и Polars Series — все со слегка разными соглашениями и API. В итоге код получается несочетаемым. Библиотеки дальше по цепочке предполагают определённую систему векторизации и, как правило, не могут работать напрямую с данными из других систем. И работающему дата-саентисту постоянно приходится конвертировать один тип данных в другой только для того, чтобы выполнить нужный анализ.

И даже если вы активно используете векторизованные библиотеки, вы почти наверняка используете и обычные python-списки, потому что всегда найдётся место в коде, где какая-то функция требует обычный список или возвращает его. В итоге вам приходится возиться с этими списками. Можно было бы преобразовать их в NumPy array, выполнить векторные операции и преобразовать обратно, но на практике так редко кто делает. Обычно вы просто пишете list comprehension. И вот вы уже вынуждены одновременно использовать два совершенно разных стиля программирования — в зависимости от того, какой тип данных используется для хранения вашего вектора.

Остановимся на list comprehension чуть подробнее. По своей природе это приём функционального программирования, но в Python его реализация больше напоминает императивный стиль. Использование ключевого слова for и акцент на итерации подталкивают думать об операции как о цикле, хотя по сути это больше похоже на map(), чем на for. Я не возражаю против list comprehension — это полезная конструкция, особенно когда нужно работать со встроенными списками, которые не поддерживают векторизацию. Но это ещё один пример того, как Python постоянно заставляет вас думать о логистике анализа данных. Если вы весь день пишете list comprehension, вы, скорее всего, будете писать и циклы for в других частях кода. И снова вы балансируете между индексами, вручную управляете деталями реализации вместо того, чтобы размышлять на более высоком уровне — о логике потока данных в вашей программе.

Отсутствие нестандартной оценки выражений

Нестандартная оценка выражений — пожалуй, самая важная возможность для data science, которой не хватает Python. Это ключевая особенность языка R и главная причина, почему код в tidyverse может быть таким элегантным и лаконичным, а также почему в R появилась удобная формульная нотация для задания статистических моделей.

Что такое нестандартная оценка выражений? Вкратце — это возможность выполнять вычисления над самим языком. Функция в R может перехватывать код R, переданный в качестве аргумента, и выполнять его позже в другом окружении. Это критически важно для анализа данных. Часто нужно проводить вычисления с различными колонками датафрейма или описывать кодом точные зависимости между переменными в статистической модели. В R такие вычисления можно описывать обычным R-кодом — например, так, будто колонки датафрейма являются обычными переменными, доступными для вычислений в текущем окружении. В сочетании с векторизацией это даёт крайне лаконичный код.

Чтобы показать нестандартную оценку выражений в действии, приведу простой пример с набором данных penguins. Допустим, мы хотим вычислить новую переменную bill_ratio, равную отношению длины к глубине клюва у пингвинов, а затем отсортировать получившийся датафрейм по имени острова по возрастанию и по отношению клюва — по убыванию. В R это выглядит так:

library(tidyverse)
library(palmerpenguins)

penguins |> 
  mutate(bill_ratio = bill_length_mm / bill_depth_mm) |>
  arrange(island, desc(bill_ratio))

Здесь есть два места, где вступает в игру нестандартная оценка выражений. Во-первых, внутри mutate() вычисление отношения размеров клюва — это обычный R-код, который выполняется внутри входного датафрейма, где колонки доступны как обычные переменные. Во-вторых, внутри arrange() мы используем desc(), которая превращает сортировку по возрастанию в сортировку по убыванию. Функция desc() немного «магическая», но для числовых колонок о ней можно думать примерно как об умножении значений на -1.

Когда мы делаем тот же анализ в Python, у нас нет нестандартной оценки выражений, и приходится пользоваться обходными путями. Пакет pandas опирается на lambda-функции:

import pandas as pd
from palmerpenguins import load_penguins

penguins = load_penguins()

(penguins
 .assign(
     bill_ratio=lambda df: df[’bill_length_mm’] / df[’bill_depth_mm’]
 )
 .sort_values(
     [’island’, ‘bill_ratio’],
     ascending=[True, False]
 )
)

Думаю, очевидно, что нестандартная оценка выражений сильно помогает сохранять код простым и читаемым.7 Теперь пойдём дальше. Допустим, я хочу отсортировать по косинусу длины клюва. Да, пример выдуманный, но это ровно тот тип вопроса, который я мог бы задать студенту — об этом говорилось в первой части этой серии. Вместо сортировки по убыванию — сортировка по значению косинуса. Насколько сложно это сделать?

С нестандартной оценкой выражений нужное изменение тривиально и абсолютно очевидно. Вместо desc() мы просто пишем cos(). Готово.

penguins |> 
  mutate(bill_ratio = bill_length_mm / bill_depth_mm) |>
  arrange(island, cos(bill_ratio))

В Python (точнее, в pandas, который я здесь использую; большинство других фреймворков требуют таких же неудобных подходов), без нестандартной оценки выражений мне приходится создавать временную колонку, потому что pandas не умеет применить косинус к столбцу bill_ratio «на лету»:

import numpy as np

(penguins
 .assign(
     bill_ratio=lambda df: df['bill_length_mm'] / df['bill_depth_mm'],
     cos_bill_ratio=lambda df: np.cos(df['bill_ratio'])
 )
 .sort_values(['island', 'cos_bill_ratio'])
 .drop(columns=['cos_bill_ratio']) # drop temporary column
)

Количество дополнительного кода, нужного для выполнения такой простой задачи, получается довольно ощутимым. Теперь нам нужно определить две lambda-функции и временный столбец. Кроме того, параметр ascending нам больше не нужен: хотя сортировка по возрастанию или убыванию встроена, сортировки «по косинусу» встроенной поддержки нет.

Честно говоря, синтаксис pandas здесь, возможно, выглядит особенно громоздким, и в других фреймворках это может выглядеть аккуратнее. Но отсутствие нестандартной оценки выражений всегда мешает в той или иной форме. Например, тот же код в Polars немного лаконичнее и не требует временной к��лонки, но постоянная необходимость писать pl.col() в коде на Polars довольно быстро начинает утомлять.

import polars as pl

penguins = pl.from_pandas(load_penguins())

(penguins
 .with_columns(
     bill_ratio=(pl.col('bill_length_mm') / pl.col('bill_depth_mm'))
 )
 .sort(['island', pl.col('bill_ratio').cos()])
)

Нестандартная оценка выражений была частью языка R с самого его появления, но в tidyverse она получила мощный импульс. Я бы сказал, что полноценное понимание того, как использовать её правильно — максимально выразительно, но без излишне запутанного кода — сформировалось относительно недавно. Важные изменения были внесены всего лишь в июне 2019 года. Если учесть, что первый релиз ggplot2 вышел в 2007 году, становится понятно, что Хэдли Викхэм и его команде понадобилось больше десяти лет, чтобы найти корректный подход к нестандартной оценке выражений. Неудивительно, что эти идеи пока ещё не получили широкого распространения за пределами языка, в котором они появились.

Ограничения языка R

Чтобы не казалось, будто я просто защищаю R и критикую Python, коротко перечислю несколько конкретных недостатков R, которые я вижу. На мой взгляд, эти недостатки мешают использовать R как универсальный язык для разработки приложений, хотя для data science они менее критичны.

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

Во-вторых, неприятно, что в R нет нормальной, встроенной в язык объектно-ориентированной модели. В итоге люди часто создают свои собственные системы, и вариантов становится слишком много. Навскидку вспоминаются S3, S4, R6, S7 и ещё несколько менее популярных. Разобраться, что выбрать, бывает сложно, и между ними далеко не идеальная совместимость.

Наконец, R использует ленивую оценку аргументов функций. Это означает, что аргументы не вычисляются в момент вызова функции — они вычисляются только тогда, когда и если сама функция запрашивает соответствующее значение. Ленивая оценка критически важна для работы механизма нестандартной оценки выражений, но она приводит к странным багам, особенно когда люди пытаются использовать R императивно, а не функционально. Из-за этого регулярно появляются ложные отчёты о багах в ggplot2 — примеры можно посмотреть здесь и здесь. Этот вопрос также часто всплывает на StackOverflow.

Я перечисляю эти ограничения языка R, чтобы подчеркнуть: любое решение в дизайне языка — это компромисс. Нестандартная оценка выражений отлично подходит для data science, но требует ленивой оценки, а это уже плохой выбор для языков, ориентированных на императивный стиль и/или стандартные задачи разработки приложений. Не существует языка, который одинаково хорошо справлялся бы со всеми возможными задачами. И, возвращаясь к теме серии моих статей, на мой вкус в Python слишком много дизайн-решений, которые мешают эффективной и надёжной работе с данными, хотя для других областей применения эти решения могут быть вполне разумными.

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

Русскоязычное сообщество про Python

Друзья! Эту статью подготовила команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!