Pandas 연산 체이닝 장점과 한계

Pandas 연산 체이닝 장점과 한계
Cozy CodingPosted On Jul 20, 202419 min read

파이썬 프로그래밍

이미지

이 기사의 제목은 Pandas 작업을 연결하는 강점과 한계에 대해 강조하지만 사실은 재미있는 부분에 대해 쓸 거에요.

재미? 데이터 분석할 자료가 있는데 왜 중요한가요?

당신에게 잘 맞는 방법은 무엇인지는 모르지만, 저에게는 일하는 데 재미가 중요해요. 데이터 과학 분야에서 20년 이상의 경험을 토대로 코딩에서 느끼는 즐거움이 작업을 완료했을 때 더 만족스러운 결과를 낼 수 있다는 것을 알게 되었어요. 저는 작업을 완수하는 과정뿐만 아니라 작업을 추구하는 과정에서의 경헀이 중요하다고 생각해요. 물론 결과를 달성하는 것도 중요하죠. 그러나 제가 사용하는 도구를 싫어한다면, 일을 빨리 끝내고 싶어할 것이에요. 이러면 실수를 할 수 있고, 데이터의 중요한 세부사항을 간과할 수도 있어요. 이것은 피해야 할 상황이에요.

저는 R에서 Python으로 전환하면서 R로 데이터 분석하는 것이 매우 즐거웠던, dplyr 구문 덕분일 거라고 생각해요. 항상 그것을 즐기고 있었고, 아직도 그렇죠. 그러나 Python으로 전환하고 나서는 R보다 Python을 선호할 때가 많았어요. 저는 실제로 R에서 프로그래밍하는 것을 즐기지 않아요 (데이터 분석과 프로그래밍 간의 차이를 봐주세요), Python으로 프로그래밍하는 것은 정말 재미있어요. 물론 이것은 주관적일 수 있어요, 그것은 알고 있어요. 하지만 그것을 바꿀 수는 없어요.

그런데 하나의 예외가 있었어요: Python의 Pandas 대 R의 dplyr. 비교할 바 없이 dplyr이 데이터 분석의 모든 측면에서 우수했어요. 제가 Pandas를 전혀 좋아하지 않았어요.

과거형을 알아차리셨나요? "하나의 예외였다." "비교할 바 없었다." "모든 측면에서 이겼다." "Pandas를 좋아하지 않았다."

과거형을 사용한 이유는 상황이 바뀌었기 때문이에요 — 사실 나는 이제 판다스를 좋아하게 되었어요. 심지어 디플라보 dplyr보다 판다스를 더 좋아한다고 생각하기 시작했어요.

그래서 무엇이 변했을까요?

판다스의 파이프

얼마 전, 판다스에 대해 새로운 것을 배웠어요 — 제 마음을 뒤흔든 것이 계속해서 연결된 판다스 작업들을 파이프라인으로 만드는 방법입니다.

한때 코드를 보여드릴 때가 되었어요. 그게 아니면, 아무도 납득시키기 힘들 거라 생각해요. 다음과 같이 생성된 데이터프레임을 사용할 거예요:

>>> import pandas as pd
>>> def make_df():
...     return pd.DataFrame({
...         "x": [1.]*5 + [1.2]*5 + [3.]*5 + [1, 5, 2, 3, 3],
...         "y": [20.]*5 + [21.5]*5 + [35.]*5 + [41, 15, 22, 13, 13],
...         "group": ["a"]*5 + ["b"]*5 + ["c"]*5 + ["d"]*5,
...     })
>>> df = make_df()

이 함수가 있어야 후속 예제에서 원래 데이터프레임을 재사용할 수 있어요.

파이프 연산자

R의 dplyr를 강력하게 만드는 것은 파이프(|)입니다. %>% 연산자를 사용하여 명령을 연결할 수 있으며, 이런 파이프는 매우 길 수 있지만 여전히 가독성이 좋습니다. 이 가독성이 차이를 만들어냅니다. 이것이 dplyr 코드를 강력하게 만드는 이유입니다.

전형적인 Pandas 코드는 괄호와 할당을 사용합니다. 따라서 괄호 할당을 연달아 쓰게 되며, 결과 코드는 다소 서투르고 압도적으로 보일 수 있습니다. 그리고 파이프가 없습니다. 간단한 예시를 보겠습니다:

>>> df["z"] = df["x"] * df["y"]
>>> sum(df[df["group"] == "a"]["z"])
100.0

이것이 전형적인 Pandas 코드입니다. 데이터 과학자들이 작성한 수백만 개의 노트북과 스크립트에서 흔히 볼 수 있습니다.

하지만 이것은 Pandas 코드를 작성하는 한 가지 스타일에 불과합니다. 또 다른 스타일은... pipes(파이프)! dplyr은 체이닝에 %`% 연산자를 사용하는 반면, Pandas는 점 연산자 (.)를 사용합니다 - 객체의 속성에 액세스하기위한 매우 전형적인 Python 연산자입니다. 따라서 Python을 알고있는 사람은 이미 파이프에 대해 어떻게 사용해야하는지 알고 있습니다. 그냥 Pandas에서 이를 어떻게 사용하는지 알면됩니다.

체이닝을 위한 메소드

물론, 이것은 작업을 연결하는 것뿐만 아니라 체이닝할 메소드에 대한 것입니다. 먼저, head(), sum(), isna(), astype()와 같은 많은 pd.DataFrame 메소드를 연결할 수 있습니다. 그러나 일부 다른 메소드는 파이프 방식으로 자연스럽게 맞지 않습니다. 예를들어 data frame을 재구성하는 pivot()와 집계를 수행하는 groupby() 등이 있습니다.

그러나 일부 메소드는 정확히 이러한 목적으로 설계되었습니다: 체이닝할 수 있도록. 따라서 체이닝할 수 없는 코드 대신 사용할 수 있습니다. Pandas 작업 체인의 코드는 대괄호 할당의 일련의 순서보다 훨씬 가독성있게 사용될 수 있습니다. 이러한 종류의 구문을 알지 못하는 사람들에게는 아마 필요하지는 않을 것이지만 익숙해지는 데 많은 시간이 필요하지는 않습니다.

자료 대체하기를 원할 때 가장 자주 사용하는 방법은 다음과 같습니다:

  • filter() : 지정된 인덱스 레이블에 따라 데이터 프레임의 행이나 열을 부분 집합화하는 데 사용
  • drop() : 레이블 이름과 해당 축(행 또는 열)을 사용하여 행이나 열을 제거하는 데 사용; 또는 직접 인덱스나 열 이름을 지정할 수도 있음
  • query() : 문자열로 제공된 부울 표현식을 사용하여 데이터 프레임의 열을 쿼리하는 데 사용
  • assign() : 데이터 프레임에 새 열(열)을 할당(생성)하는 데 사용
  • squeeze() : 일차원 축 개체를 스칼라로 압축하는 데 사용
  • pipe() : pd.Series 또는 pd.DataFrame을 취하고 하나를 반환하는 사용자 지정 함수를 호출하는 데 사용
  • where : 조건이 거짓인 경우 값을 대체하는 데 사용합니다. 실제로 이 방법은 위에서 언급한 방법보다 이해하기 어렵고 사용하기 어려운 것으로 생각합니다.
  • apply : 데이터 프레임의 열에 대해 외부 함수를 사용하는 데 사용

판다스 작업을 연결하기 시작하기 전에, 이 목록에서 .apply() 메서드만 사용했습니다. 이러한 메서드들은 파이프라인 작업 없이도 사용할 수 있음을 유의하십시오. 한 줄씩 연이어 사용자할 수 있고, 할당을 사용하여 한 줄씩 연속적으로 사용할 수 있습니다. 그러나 pipe는 이러한 메서드를 한 명령어로 연결할 수 있도록 해줍니다.

나에게는 위의 방법들이 판다스 파이프 처리 API의 가장 중요한 부분을 형성합니다. 곧 어떻게 사용하는지 예시를 살펴보겠습니다. 이러한 방법이 나의 프로젝트에서 가장 유용하게 증명되었지만, 다른 유용한 방법들도 있습니다. 예를 들어:

  • dropna()
  • fillna()
  • rename()
  • sort_values()
  • value_counts()

이것들은 매우 유용한 메서드들이에요. 판다스 사용자라면 적어도 그 중 일부를 사용해봤을 거에요.

기초 학습

만약 Pandas가 제공하는 모든 방법을 익히고 싶다면, 너무 열심히 하지 말아요. 적게는 한 번에 하나씩 스텝바이스텝으로 해보세요.

지금은 기초만 배우세요. 아마도 — 그렇게 되길 바래요 — 이러한 구문이 얼마나 강력한지 보게 될 거예요. 기초를 배우고 이 분야가 내 것이라는 것을 깨달았다면 더 고급 도구를 배울 수 있을 거예요.

이 글에서는 기초만 보여드릴 거에요 — 하지만 이러한 기본 연쇄 작업이 전형적인 판다 코드보다 더 읽기 쉬운 코드를 만드는 데 도움이 될 거에요.

판다스 연산 연결하기

자, 제가 무슨 말을 하는지 봐야 할 때입니다. 이 코드로 돌아가볼까요:

>>> df["z"] = df["x"] * df["y"]
>>> sum(df[df["group"] == "a"]["z"])
100.0

그리고 piping 방법을 사용하여 재구현해 보겠습니다. 이번에는 두 줄의 작업이 필요합니다. 한 파이프 안에서 새 열을 만들고 그 위에서 계산을 할 수 없기 때문입니다.

>>> df = make_df()
>>> df = df.assign(z=df.x * df.y)
>>> df.query("group == 'a'").z.sum()
100.0

참고로, df["x"]를 df.x로 변경했지만 차이가 없음을 보여드리기 위함입니다. 두 가지 방법은 정확히 동일하게 작동합니다. (두 방식 간의 미묘한 차이를 알고 싶다면 이 기사를 읽어보세요.)

만약 합만을 계산하길 원한다면 아래의 하나의 체인을 사용해서 이렇게 할 수 있어요:

>>> df = make_df()
>>> df.assign(z=df.x * df.y).query("group == 'a'").z.sum()
100.0

하지만, 이 방법으로는 z 열이 임시로 생성되므로 작업이 반환되면 df 열에 들어가 있지 않을 거에요:

>>> df.columns
Index(['x', 'y', 'group'], dtype='object')

query() 메서드는 불리언 표현식을 기반으로 데이터프레임을 필터링합니다; 여기서는 group이 "a"인 행만 유지하려고 합니다. 그런 다음 z 열을 가져와 그 합계를 계산합니다.

일단 assign() 메서드로 돌아가 봅시다. 이 메서드는 새로운 열을 생성합니다. 여기서는 벡터화된 Pandas 연산을 사용했는데, 이는 빠릅니다. 그러나 때로는 열을 생성하기 위해 사용해야 하는 로직의 복잡성 때문에 작동하지 않을 수도 있습니다. 그럴 때는 일반적인 Python 리스트 내포를 사용할 수 있습니다:

>>> df.assign(z = [
...     a + b if a < 2 and b > 20 else a * b
...     for a, b in zip(df["x"], df["y"])
... ]).iloc[[0, 6, 10]]
    x     y group      z
0   1.0  20.0     a   20.0
6   1.2  21.5     b   22.7
10  3.0  35.0     c  105.0

위에서는 Pandas 연산의 짧은 체인의 간단한 예를 사용했습니다. 이 구문의 아름다움은 긴 체인에서 발휘됩니다. 다음 코드를 살펴보겠습니다:

>>> df = make_df()
>>> filtered_df = df[df["x"] > 1].copy()
>>> filtered_df["w"] = filtered_df["x"] ** 2
>>> grouped_df = filtered_df.groupby("group").agg({"y": "mean", "w": "mean"})
>>> grouped_df = grouped_df.reset_index()
>>> result = grouped_df.sort_values(by="y", ascending=False)
>>> result
  group      y      w
1     c  35.00   9.00
0     b  21.50   1.44
2     d  15.75  11.75

위 코드에서 pd.DataFrame 객체의 .copy() 메서드 사용에 주목해주세요. 이 메서드는 필요한 이유가 있습니다. 그것은 원래 DataFrame의 뷰가 아닌 별도의 사본에서 작업하게 됩니다. 뷰를 수정하면 예상치 못한 결과가 발생할 수 있기 때문에 Pandas는 뷰에서 값을 변경하려고 시도할 때 SettingWithCopyWarning 경고를 발생시킵니다. 복사본을 만들어서 우리의 작업이 원본 데이터프레임에 영향을 주지 않도록하고 이 경고를 피할 수 있습니다.

다음 작업 진행을 분석해 봅시다:

  • filtered_df = df[df["x"] > 1]: x가 1보다 큰 행만 있는 df를 필터링합니다.
  • filtered_df["w"] = filtered_df["x"] ** 2: filtered_df에 x의 제곱인 새로운 열 w를 추가합니다.
  • grouped_df = filtered_df.groupby("group").agg({"y": "mean", "w": "mean"}): filtered_df를 group 열로 그룹화하고 각 그룹에 대한 y와 w의 평균을 계산합니다.
  • grouped_df = grouped_df.reset_index(): 그룹화된 grouped_df의 인덱스를 다시 설정하여 그룹 레이블을 다시 열로 변환합니다.
  • result = grouped_df.sort_values(by="y", ascending=False): y의 평균값을 기준으로 결과 데이터프레임을 내림차순으로 정렬합니다.

다음과 같이 코드를 하나의 연결된 Pandas 작업 체인으로 리팩토링해 보겠습니다:

result_chain = (
    df[df["x"] > 1]
    .assign(w=lambda df: df["x"] ** 2)
    .groupby('group')
    .agg({"y": "mean", "w": "mean"})
    .reset_index()
    .sort_values(by="y", ascending=False)
)
result_chain

보시다시피, 동일한 결과를 얻었으며, 다음과 같이 확인할 수 있습니다:

result_chain.equals(result)
True

아래 코드를 분석해봅시다:

  • df[df["x"] ` 1]: x가 1보다 큰 행만을 포함하도록 df를 필터링합니다.
  • .assign(w=lambda df: df["x"] ** 2): x의 제곱 값을 나타내는 새로운 열 w를 추가합니다.
  • .groupby("group"): 그룹화된 데이터프레임을 group 열로 그룹화합니다.
  • .agg('"y": "mean", "w": "mean"'): 각 그룹의 y와 w의 평균을 계산합니다.
  • .reset_index(): 그룹 레이블을 다시 열로 변환하기 위해 인덱스를 재설정합니다.
  • .sort_values(by="y", ascending=False): y의 평균 값에 따라 데이터프레임을 내림차순으로 정렬합니다.

두 설명이 거의 동일한 것을 보니, 이 두 코드 블록이 동일한 작업을 하고 있음을 보여줍니다. 차이는 후자 코드에서 연산을 하나의 파이프로 연결하는 것에 있습니다.

긴 파이프 코드에서는 각 파이프 단계에서 원본 데이터프레임(df)을 수정하지 않고 새 데이터프레임을 생성합니다. 이는 각 단계에서 새로운 데이터프레임을 반환하여 기존 뷰를 수정하지 않기 때문에 SettingWithCopyWarning을 피할 수 있습니다. 즉, copy() 메서드가 필요하지 않습니다.

필터링할 때 전형적인 판다스 필터링 대신 .query() 메서드를 자주 사용해요. 속도는 느릴 수 있지만, 저한테는 더 읽기 쉬워요:

>>> (
...     df
...     .query("x > 1")
...     .assign(w=lambda df: df["x"] ** 2)
...     .groupby("group")
...     .agg({"y": "mean", "w": "mean"})
...     .reset_index()
...     .sort_values(by="y", ascending=False)
... )
  group      y      w
1     c  35.00   9.00
0     b  21.50   1.44
2     d  15.75  11.75

여기서 유일한 차이는 .query() 메서드가 사용된 부분이에요. 여기서 필터링은 매우 간단하지만, 때로는 복잡해지면 필터링 조건을 그렇게 정하는 게 단순히 더 자연스럽고 읽기 쉽기 때문에 대괄호를 사용한 필터링보다 낫습니다.

이 두 버전 중 어느 쪽이 더 읽기 쉬운가요? 저는 파이프 처리된 버전이 전형적인 단계별 접근보다 더 읽기 쉬운 것 같아요. 이게 각 단계가 순차적으로 연결되어 있어서 작업이 명확하고 부드럽게 흐르게 해줍니다. 각 단계가 연속적으로 연결되어 있어서 데이터의 작업 과정을 처음부터 끝까지 쉽게 따라갈 수 있어요. 이 방법은 중간 변수가 필요 없어져서 코드가 혼잡해지는 것을 줄여주고 이해하기 어려운 코드를 만드는 데 도움이 돼요.

파이프를 사용하면 간결하고 표현력이 풍부한 코드를 작성할 수 있습니다. 이는 가독성을 향상시키는 것뿐만 아니라 전체 작업 순서가 한눈에 보이기 때문에 유지 보수와 디버깅을 용이하게 합니다. 예를 들어 위의 파이프를 사용하지 않은 예시에서 임시 변수가 여러 개인 경우, 코드를 읽는 사람은 이후에 사용할 변수가 있는지 또는 이 특정 위치에서만 사용되는지 궁금해할 수 있습니다.

간단히 말해, 파이프 방식은 불필요한 코드를 줄이고 데이터 작업의 논리적 흐름을 향상시킵니다. 이는 코드를 더 직관적으로 만들고 파악하기 쉽게 합니다.

두 가지 구문을 함께 사용할 수 있습니다

가끔은 전형적인 Pandas 구문을 사용하는 것이 더 편할 때가 있습니다. 이 방식을 사용해 작업하는 방법을 알고 있다면 그대로 진행하세요. 파이프를 중지하고 괄호 할당을 사용한 후 결과 객체를 사용하여 새로운 파이프를 시작할 수 있습니다. 필자는 때때로 이렇게 하곤 합니다, 특히 시간이 부족해서 파이프 방식을 찾기 어려운 경우에는요. 하지만 추후 시간을 내어 파이프 방식을 찾아보고 사용해보기도 합니다.

팬더스에서는 파이프를 사용하는 것을 권장하는 방법으로 제안합니다. 파이프는 더 부드럽고 가독성이 좋지만 가끔 중간에 끊어 사용해도 문제가 되지 않습니다. 저는 급진적인 사람이 아니라는 것을 알 수 있죠.

하지만, 체인된 작업을 사용할 수 있는 경우 코드는 간단하고 가독성이 좋을 가능성이 높습니다. 왜냐하면 파이프 안에 연결된 팬더스 작업은 일반적인 팬더스 구문보다 보통 더 가독성이 좋기 때문입니다.

가독성은 모든 코드의 큰 장점입니다. 하지만, 이것이 유일한 장점은 아닙니다. 다음 섹션에서 보겠지만, 때로는 파이프로 체인된 작업이 전통적인 팬더스 코드보다 느릴 수도 있습니다.

성능

Pandas 구문의 사용을 어떻게 결정해야 하는가?

그것은 달려있다. 다른 사람들이 볼 일이 없는 분석 코드라면, 선호하는 구문을 사용하세요; 더 잘 알고 사용하는 데 편한 구문일수록 더 좋습니다. 성능이 가장 중요한 데이터 제품을 개발할 때, 심지어 약간의 이득이 차이를 만들 때 (대시보드에서 작업할 때 종종 해당됨), 빠른 접근 방식을 반드시 사용해야 합니다. 다른 상황에서는 장단점을 고려하고 주어진 상황에서 가장 잘 작동할 수 있는 방법을 선택해야 합니다.

이러한 결정을 내리기 위해서는 다양한 종류의 구문의 성능에 대한 기본 지식이 있어야 합니다. 여기서는 몇 가지 간단한 벤치마킹 실험을 수행할 것입니다만, 이는 일부 기본 시나리오에 대해서만 다룰 것입니다. 코드를 작성할 때 신중한 결정을 내려야 합니다. 이는 종종 코드를 프로파일링하고 특정 부분 (예: Pandas 작업 체인)이 병목 현상인지 확인해야 할 수도 있습니다.

정규 Pandas 코드와 해당 파이프 기반 코드의 벤치마킹을 위해 timeit 모듈을 사용할 것입니다 (여기에서 해당 내용을 확인할 수 있습니다). 벤치마크는 Windows 10 머신, 32 GB RAM, 4개 물리 및 8개 논리 코어에서 실행될 것입니다.

부록에서 벤치마킹 코드를 참조하세요. 벤치마킹할 두 조각의 코드를 담고 있는 code_regular 및 code_pipe 변수를 변경할 것입니다. 일단 부록에 있는 것을 시작해봅시다.

code_pipe = f'df = df.assign(xy = df["x"] * 3 / (1 + df["y"]*.3))'
code_regular = f'df["xy"] = (df["x"] * 3) / (1 + df["y"]*.3)'

이 코드는 pd.DataFrame.assign() 메서드를 전통적인 벡터화된 Pandas 할당과 비교합니다. 결과를 보겠습니다:

100행과 10000번 반복하는 df로 벤치마킹 중
Pipe:
평균 시간(t) = 0.0004555
최소 시간(t) = 0.0004289 
일반 코드:
평균 시간(t) = 0.0004164
최소 시간(t) = 0.0003984 

1000행과 10000번 반복하는 df로 벤치마킹 중
Pipe:
평균 시간(t) = 0.0005324
최소 시간(t) = 0.000495  
일반 코드:
평균 시간(t) = 0.0004764
최소 시간(t) = 0.0004467 

10000행과 10000번 반복하는 df로 벤치마킹 중
Pipe:
평균 시간(t) = 0.0012987
최소 시간(t) = 0.0011558 
일반 코드:
평균 시간(t) = 0.0006378
최소 시간(t) = 0.0005557 

100000행과 1000번 반복하는 df로 벤치마킹 중
Pipe:
평균 시간(t) = 0.0066507
최소 시간(t) = 0.0060729
일반 코드:
평균 시간(t) = 0.001402
최소 시간(t) = 0.0010576

1000000행과 10번 반복하는 df로 벤치마킹 중
Pipe:
평균 시간(t) = 0.0694944
최소 시간(t) = 0.0687732
일반 코드:
평균 시간(t) = 0.011838
최소 시간(t) = 0.0114591

위에서 볼 수 있듯이 .assign() 메서드는 Pandas 코드의 벡터화된 속도의 약 50-60% 정도 느립니다. 차이는 데이터프레임의 크기에 따라 달라지지 않는 것으로 보입니다.

위에서 우리는 새로운 열을 만드는 벤치마킹을 했습니다. 이제 열을 필터링하는 벤치마킹을 해보겠습니다:

code_pipe = f'df_ = df.filter(["a", "b", "c", "d", "e"])'
code_regular = f'df_ = df.loc[:, ["a", "b", "c", "d", "e"]]'

그리고 여기가 결과입니다:

100행의 df로 10000번 반복하여 벤치마킹 중
Pipe:
평균 시간(t) = 0.0002483
최소 시간(t) = 0.0002267
일반 코드:
평균 시간(t) = 0.0002808
최소 시간(t) = 0.0002459

1000행의 df로 10000번 반복하여 벤치마킹 중
Pipe:
평균 시간(t) = 0.0002972
최소 시간(t) = 0.0002681
일반 코드:
평균 시간(t) = 0.0003176
최소 시간(t) = 0.0002926

10000행의 df로 10000번 반복하여 벤치마킹 중
Pipe:
평균 시간(t) = 0.0004878
최소 시간(t) = 0.0003939
일반 코드:
평균 시간(t) = 0.0005025
최소 시간(t) = 0.0004806

100000행의 df로 1000번 반복하여 벤치마킹 중
Pipe:
평균 시간(t) = 0.0013341
최소 시간(t) = 0.0011952
일반 코드:
평균 시간(t) = 0.0013245
최소 시간(t) = 0.0011956

1000000행의 df로 100번 반복하여 벤치마킹 중
Pipe:
평균 시간(t) = 0.0094321
최소 시간(t) = 0.0090392
일반 코드:
평균 시간(t) = 0.0095467
최소 시간(t) = 0.0087992

이러한 결과들이 조금 다릅니다: 대부분의 데이터프레임에 대해 .filter() 메소드는 약간 더 느렸습니다. 100,000행의 데이터프레임의 경우에는 더 빨랐습니다.

이제 행을 필터링해보겠습니다:

code_pipe = f'''df_ = df.query("a >= @mean_a")'''
code_regular = f'df_ = df[df["a"] >= mean_a]'

여기 결과입니다:

Benchmarking with df of 100 rows and repeat = 10000 Pipe: mean_time(t) = 0.0017628 min_time(t) = 0.0016239 Regular code: mean_time(t) = 0.0001927 min_time(t) = 0.0001791

Benchmarking with df of 1000 rows and repeat = 10000 Pipe: mean_time(t) = 0.0018353 min_time(t) = 0.0017287 Regular code: mean_time(t) = 0.0002581 min_time(t) = 0.0002381

Benchmarking with df of 10000 rows and repeat = 10000 Pipe: mean_time(t) = 0.0024342 min_time(t) = 0.0021453 Regular code: mean_time(t) = 0.0007169 min_time(t) = 0.0004661

Benchmarking with df of 100000 rows and repeat = 1000 Pipe: mean_time(t) = 0.00625 min_time(t) = 0.0046697 Regular code: mean_time(t) = 0.0035322 min_time(t) = 0.0028939

Benchmarking with df of 1000000 rows and repeat = 10 Pipe: mean_time(t) = 0.0471684 min_time(t) = 0.041649 Regular code: mean_time(t) = 0.0343789 min_time(t) = 0.0331603

결과는 상당히 유사합니다: 대부분의 실험에서 .query() 메소드가 느렸으며, 성능 차이가 가장 큰것은 10만 개 행의 데이터프레임에서 관측되었습니다 (두 배로 느림).

물론 이러한 벤치마크는 여러 요소가 영향을 미치므로, 또 다른 실험 결과는 약간 다를 수 있습니다. 예를들어, 10만 행의 데이터프레임보다 100만 행일 때 성능 차이가 더 커진다고 결론 내릴 수 없으며, 다른 실험에서 결과가 달라질 수 있습니다. 하지만, 세 가지 파이프드 판다스 메소드가 일반적인 판다스 코드 보다 보통 상당히 느리다는 결론을 낼 수 있습니다. 때로는 차이가 뚜렷할 수 있지만(예: 파이프드 함수가 두 배 더 느림), 보통은 성능 차이가 그리 크지 않습니다.

가장 중요한 파이프드 판다 메소드인 .assign(), .filter(), 그리고 .query()를 중점적으로 분석했어. 실제로, 나는 판다 파이프를 생성할 때 이 세 가지 메소드를 가장 자주 사용해.

우리는 이러한 결과를 매우 신중하게 취급해야 해. 이 결과들은 일반적으로 연결된 판다 메소드가 더 느리다는 것을 보여주지만, 때로는 크게, 때로는 미미하게 느릴 수 있다는 것을 보여줄 뿐이야. 그럼에도 불구하고, 실제 프로젝트에서는 특히 성능이 중요할 때 코드를 프로파일링하고 특정 작업 집합이 어떻게 수행되는지 확인해야 해. 성능을 최적화하기 위한 일반적인 권장 사항은 다음과 같아: 일반적인 가정을 할 수는 있지만(예: 연결된 판다 작업은 대개 약간 더 느릴 것으로 보임), 항상 코드를 프로필링하여 특정 작업 집합이 병목 현상을 일으키는지 여부를 확인해야 해.

이 벤치마킹은 실행 시간에만 집중했어. 이것은 메모리 성능을 무시한다는 게 아니라, 내 실험 결과로는 두 접근 방식이 거의 동일한 메모리 풋프린트를 갖는 것으로 나타났기 때문이야. 이는 메모리 풋프린트가 항상 동일할 것이라는 뜻은 아니야 - 특히 긴 체인이나 다른 메소드의 경우에도 그렇지 않을 수 있어. 실행 시간과 마찬가지로, 메모리 사용량을 꼭 다시 확인하는 것이 좋아. 이를 위해 pd.DataFrame.memory_usage() 메소드나 파이썬 메모리 프로파일링 패키지 중 하나(psutil, memory_profiler, tracemalloc) 혹은 IPython의 ipython_memory_usage 확장을 사용할 수 있어.

결론

판다스 작업의 여러 장단점에 대해 토론했습니다. 따라서 전형적인 판다스 코드 대신에 이를 사용해야 할까요? 대부분의 경우, 답은 상황에 따라 다릅니다. 결정을 내릴 때는 다음과 같은 프로젝트의 여러 측면을 고려해야 합니다:

  • 코드 유형: 분석, 컨셉 증명 (Proof of Concept, PoC), 또는 프로덕션 용도입니까?
  • 성능 대 가독성: 성능이 가독성보다 중요한가요?
  • 편의 및 선호도: 각 구문에 얼마나 익숙한가요? 어느 쪽을 선호하시나요?
  • 대상 그룹: 누가 코드를 읽을 것인가요?

이러한 질문들은 서로 관련이 있으며, 그에 대한 답을 함께 고려해야 합니다. 여기에 몇 가지 지침이 있습니다:

  • 분석 코드: 코드를 공유하지 않을 경우에는 가장 익숙한 방식으로 작성하십시오. 공유될 경우 (노트북 사용 등), 대상 그룹을 고려하십시오 (아래 참조). 그러나 몇 가지 예외가 있습니다. 대규모 데이터를 처리할 때는 성능을 최적화해야 할 수 있습니다 (상황에 따라 시간 또는 메모리 또는 둘 다에 대한 성능).
  • 프로덕션 코드: 가독성과 성능의 균형을 유지하십시오.
  • 가독성 초점: 파이핑은 더 명확한 코드를 제공할 수 있습니다. 그러나 해당 구문에 익숙하지 않은 코드 판독자는 특정 파이프 메서드가 무엇을 하는지 및 작동하는 방식을 학습하는 데 시간을 소비해야 할 수 있습니다.
  • 성능 초점: 파이프 대신 벡터화된 작업을 선호하십시오. 그러나 성능을 향상시키고 싶다면 중대한 변경을 하기 전에 코드 프로필링을 하는 것을 잊지 마십시오.
  • 개인적인 선호도: 파이핑이 자연스럽게 느껴진다면 사용하고, 부자연스럽게 느껴진다면 피하십시오 - 여적으로 배우고 싶다면, 실제 프로젝트는 그에 딱인 기회를 제공합니다.
  • 교육용 코드: 단순성, 가독성, 교육성 사이에서 선택하는 데 프로젝트의 문맥을 고려하십시오.

프로젝트의 장단점을 따져서 파이프링을 해야 할 때인지를 결정하세요. 그러나 기억해 주세요, 파이프링은 Python에서 중요하며, Pandas는 단지 파이프 연산을 가능케 하는 유일한 프레임워크가 아닙니다. Python 객체지향 프로그래밍(OOP)을 사용할 때는 주로 도트 연산자를 사용하여 클래스 메소드를 연결합니다. 그러므로, Pandas의 파이프링을 일반적이거나 부자연스러운 것으로 여기지 마세요. 사실, 메소드를 파이프로 연결하는 것은 Python에게 일련의 연산에서 임시 변수에 값을 할당하는 것만큼이나 자연스러운 일입니다.

각주

¹ 사실, 파이프 연산자인 %>%는 tidyverse 환경의 일부인 magrittr 패키지에서 유래되었습니다.

부록

성능 평가를 위한 코드입니다. 성능을 측정할 두 개의 코드 조각은 code_regular 및 code_pipe 변수에 저장됩니다. 코드는 셸에서 실행되며 두 개의 명령 줄 변수를 사용합니다:

  • 데이터프레임의 행 수.
  • timeit.repeat에 전달되는 반복 값 (여기서 rep 변수로 유지됨).

예를 들어, 다음과 같이 호출할 수 있습니다:

> python bench.py 1000 100_000

1000개의 행으로 구성된 Pandas 데이터프레임을 생성하고 반복 값이 100,000인 벤치마크를 실행할 것입니다.

실험은 반복 횟수가 높지만 number 값은 항상 1로 유지됩니다. 이는 전형적이지 않을 수 있지만, 저는 이를 일부러 그렇게 한 것입니다. 우리는 가변 데이터프레임에서의 작업을 벤치마킹하고 있으며, 일부 작업에서는 결과를 할당해야 하는 경우가 있습니다. 따라서 일관성 있고 공정한 비교를 위해 모든 벤치마킹된 작업에서 결과를 할당하고 있습니다.

보통 이러한 작업의 결과를 동일한 변수에 할당합니다 (코드에서는 df라고 합니다). 따라서 만약 number 값이 2 이상이라면, 첫 번째 작업 실행에서의 df와 다음 작업 실행에서의 df는 다를 수 있습니다. 그러나 number를 1로 설정하고 반복 횟수를 늘린다면, 각 실행 이전에 df가 재생성됩니다: 설정 코드가 실행되고, 벤치마킹 코드가 설정 코드의 출력값과 함께 한 번만 실행됩니다. 따라서 각 실행은 동일한 출력을 가져오며, 이는 좋은 벤치마크의 요구 사항입니다.

다음은 코드입니다:

import sys
import warnings

from timeit import repeat

warnings.filterwarnings("ignore", category=DeprecationWarning)

try:
    n = int(sys.argv[1])
except IndexError:
    n = 1

try:
    number = int(sys.argv[2])
except IndexError:
    number = 1000

print(f"Benchmarking with df of {n} rows and repeat = {number}")

setup = f"""
import pandas as pd
df = pd.DataFrame({
    letter: list(range({n}))
    for letter in 'abcdefghijklmnopqrstuwxyz'
})
mean_a = df.a.mean()
"""

import pandas as pd
df = pd.DataFrame({
    letter: list(range(10))
    for letter in 'abcdefghijklmnopqrstuwxyz'
})

code_pipe = f'df = df.assign(xy = df["x"] * 3 / (1 + df["y"]*.3))'
code_regular = f'df["xy"] = (df["x"] * 3) / (1 + df["y"]*.3)'

kwargs = dict(setup=setup, number=1, repeat=number)
t_pipe = repeat(code_pipe, **kwargs)
t_regular = repeat(code_regular, **kwargs)


def report(t: list[float], comment="", dig=7) -> None:
    def mean_time(x): return round(sum(x)/len(x), dig)
    def min_time(x): return round(min(x), dig)
    print(comment)
    print(
        f"{mean_time(t) = }"
        "\n"
        f"{min_time(t) = }"
    )

report(t_pipe, "Pipe:")
report(t_regular, "Regular code:")