import numpy as np
rng = np.random.default_rng(42)
x = rng.random(1000000)
y = rng.random(1000000)
%timeit x + y2.21 ms ± 142 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
앞서 살펴본 것처럼 PyData 스택의 강력한 성능은 직관적인 상위 수준 구문을 바탕으로 기본 작업을 하위 수준의 컴파일된 코드로 변환해 실행하는 NumPy와 Pandas의 기능 덕분입니다. NumPy의 벡터화/브로드캐스팅 연산이나 Pandas의 그룹화 연산이 대표적인 예입니다. 이러한 추상화 방식은 일반적인 사용 사례에서 매우 효율적이지만, 임시 중간 객체를 생성하는 과정에서 계산 시간과 메모리 사용량이 늘어나는 오버헤드가 발생합니다.
이 문제를 해결하기 위해 Pandas는 NumExpr 패키지를 활용하는 eval()과 query() 메서드를 제공합니다. 이 메서드들을 사용하면 불필요한 중간 배열을 할당하지 않고도 C 언어 수준의 빠른 연산 속도를 얻을 수 있습니다. 이번 장에서는 이 메서드들의 사용법과 함께, 어떤 상황에서 사용하면 좋은지 판단할 수 있는 몇 가지 기준을 알아보겠습니다.
앞서 NumPy와 Pandas가 빠른 벡터화 연산을 지원한다는 점을 확인했습니다. 예를 들어 두 배열의 요소를 더하는 경우를 살펴보겠습니다.
import numpy as np
rng = np.random.default_rng(42)
x = rng.random(1000000)
y = rng.random(1000000)
%timeit x + y2.21 ms ± 142 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
NumPy 배열 연산: 범용 함수에서 설명했듯이, 이는 파이썬 루프나 리스트 컴프리헨션(list comprehension)을 사용하는 것보다 훨씬 빠릅니다.
%timeit np.fromiter((xi + yi for xi, yi in zip(x, y)), dtype=x.dtype, count=len(x))263 ms ± 43.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
하지만 복합 표현식을 계산할 때는 이러한 추상화 방식의 효율이 떨어질 수 있습니다. 예를 들어 다음 표현식을 살펴보겠습니다.
mask = (x > 0.5) & (y < 0.5)NumPy는 각 하위 표현식을 순차적으로 계산하므로, 이는 내부적으로 다음과 같이 처리됩니다.
tmp1 = (x > 0.5)
tmp2 = (y < 0.5)
mask = tmp1 & tmp2즉, 모든 중간 단계 결과가 메모리에 명시적으로 할당됩니다. x와 y 배열이 매우 크다면 메모리와 계산 오버헤드가 상당히 커질 수 있습니다. NumExpr 라이브러리는 모든 중간 배열을 할당하지 않고도 이러한 복합 표현식을 요소별(element-wise)로 계산하는 기능을 제공합니다. 더 자세한 내용은 NumExpr 문서에서 확인할 수 있지만, 여기서는 라이브러리가 계산할 NumPy 스타일의 표현식을 문자열 형태로 받는다는 점만 알아두면 충분합니다.
import numexpr
mask_numexpr = numexpr.evaluate('(x > 0.5) & (y < 0.5)')
np.all(mask == mask_numexpr)True
NumExpr의 장점은 가능한 한 임시 배열을 생성하지 않고 표현식을 계산하므로 NumPy보다 훨씬 더 효율적이라는 점입니다. 특히 대규모 배열을 대상으로 복잡한 연산을 수행할 때 더욱 빛을 발합니다. 이번 장에서 다룰 Pandas의 eval()과 query() 메서드도 개념적으로 이와 유사하며, 본질적으로 NumExpr 기능을 Pandas에 맞춰 감싼(wrapper) 도구입니다.
Pandas의 eval() 함수는 문자열 표현식을 사용하여 DataFrame 객체의 연산을 효율적으로 수행합니다. 예를 들어 다음과 같은 데이터를 살펴보겠습니다.
import pandas as pd
nrows, ncols = 100000, 100
df1, df2, df3, df4 = (pd.DataFrame(rng.random((nrows, ncols)))
for i in range(4))일반적인 Pandas 방식으로 4개 DataFrame의 합을 구하려면 다음과 같이 작성합니다.
%timeit df1 + df2 + df3 + df473.2 ms ± 6.72 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
연산식을 문자열로 만들어 pd.eval()에 전달하면 동일한 결과를 얻을 수 있습니다.
%timeit pd.eval('df1 + df2 + df3 + df4')34 ms ± 4.2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
eval()을 사용한 버전은 약 50% 더 빠르면서 메모리 사용량도 훨씬 적고, 결과값은 동일합니다.
np.allclose(df1 + df2 + df3 + df4,
pd.eval('df1 + df2 + df3 + df4'))True
pd.eval()은 다양한 연산을 지원합니다. 이를 확인하기 위해 다음과 같은 정수 데이터를 만들어 보겠습니다.
df1, df2, df3, df4, df5 = (pd.DataFrame(rng.integers(0, 1000, (100, 3)))
for i in range(5))pd.eval()은 모든 산술 연산자를 지원합니다. 예를 들면 다음과 같습니다.
result1 = -df1 * df2 / (df3 + df4) - df5
result2 = pd.eval('-df1 * df2 / (df3 + df4) - df5')
np.allclose(result1, result2)True
pd.eval()은 연쇄(chained) 표현식을 포함한 모든 비교 연산자를 지원합니다.
result1 = (df1 < df2) & (df2 <= df3) & (df3 != df4)
result2 = pd.eval('df1 < df2 <= df3 != df4')
np.allclose(result1, result2)True
pd.eval()은 &와 | 비트 연산자를 지원합니다.
result1 = (df1 < 0.5) & (df2 < 0.5) | (df3 < df4)
result2 = pd.eval('(df1 < 0.5) & (df2 < 0.5) | (df3 < df4)')
np.allclose(result1, result2)True
또한 불리언(boolean) 표현식에서 and와 or 키워드 사용도 지원합니다.
result3 = pd.eval('(df1 < 0.5) and (df2 < 0.5) or (df3 < df4)')
np.allclose(result1, result3)True
pd.eval()은 obj.attr 구문을 통한 객체 속성 접근과 obj[index] 구문을 통한 인덱스 접근을 지원합니다.
result1 = df2.T[0] + df3.iloc[1]
result2 = pd.eval('df2.T[0] + df3.iloc[1]')
np.allclose(result1, result2)True
함수 호출, 조건문, 루프 등 복잡한 구문은 현재 pd.eval()에서 지원하지 않습니다. 이러한 복잡한 표현식을 실행하려면 NumExpr 라이브러리를 직접 사용해야 합니다.
최상위 함수인 pd.eval()처럼 DataFrame 객체도 비슷한 기능을 수행하는 eval() 메서드를 가지고 있습니다. eval() 메서드의 장점은 열 이름을 직접 변수처럼 참조할 수 있다는 점입니다. 다음과 같은 DataFrame을 예로 들어보겠습니다.
df = pd.DataFrame(rng.random((1000, 3)), columns=['A', 'B', 'C'])
df.head()| A | B | C | |
|---|---|---|---|
| 0 | 0.850888 | 0.966709 | 0.958690 |
| 1 | 0.820126 | 0.385686 | 0.061402 |
| 2 | 0.059729 | 0.831768 | 0.652259 |
| 3 | 0.244774 | 0.140322 | 0.041711 |
| 4 | 0.818205 | 0.753384 | 0.578851 |
앞서 본 것처럼 pd.eval()을 사용하면 세 개의 열로 구성된 표현식을 다음과 같이 계산합니다.
result1 = (df['A'] + df['B']) / (df['C'] - 1)
result2 = pd.eval("(df.A + df.B) / (df.C - 1)")
np.allclose(result1, result2)True
DataFrame.eval() 메서드를 사용하면 열이 포함된 표현식을 훨씬 간결하게 계산합니다.
result3 = df.eval('(A + B) / (C - 1)')
np.allclose(result1, result3)True
표현식 안에서 열 이름을 변수처럼 다룰 수 있으며, 결과도 동일함을 확인합니다.
DataFrame.eval()은 연산뿐만 아니라 새로운 열을 할당하는 데도 사용합니다. 앞서 사용한 'A', 'B', 'C' 열이 있는 DataFrame을 다시 활용해 보겠습니다.
df.head()| A | B | C | |
|---|---|---|---|
| 0 | 0.850888 | 0.966709 | 0.958690 |
| 1 | 0.820126 | 0.385686 | 0.061402 |
| 2 | 0.059729 | 0.831768 | 0.652259 |
| 3 | 0.244774 | 0.140322 | 0.041711 |
| 4 | 0.818205 | 0.753384 | 0.578851 |
df.eval()을 사용하여 새 열 'D'를 만들고, 다른 열의 계산 결과를 할당해보겠습니다.
df.eval('D = (A + B) / C', inplace=True)
df.head()| A | B | C | D | |
|---|---|---|---|---|
| 0 | 0.850888 | 0.966709 | 0.958690 | 1.895916 |
| 1 | 0.820126 | 0.385686 | 0.061402 | 19.638139 |
| 2 | 0.059729 | 0.831768 | 0.652259 | 1.366782 |
| 3 | 0.244774 | 0.140322 | 0.041711 | 9.232370 |
| 4 | 0.818205 | 0.753384 | 0.578851 | 2.715013 |
같은 방식으로 기존 열의 값을 수정할 수도 있습니다.
df.eval('D = (A - B) / C', inplace=True)
df.head()| A | B | C | D | |
|---|---|---|---|---|
| 0 | 0.850888 | 0.966709 | 0.958690 | -0.120812 |
| 1 | 0.820126 | 0.385686 | 0.061402 | 7.075399 |
| 2 | 0.059729 | 0.831768 | 0.652259 | -1.183638 |
| 3 | 0.244774 | 0.140322 | 0.041711 | 2.504142 |
| 4 | 0.818205 | 0.753384 | 0.578851 | 0.111982 |
DataFrame.eval() 메서드는 로컬 파이썬 변수를 사용할 수 있는 특별한 구문을 지원합니다. 다음 예시를 살펴보겠습니다.
column_mean = df.mean(1)
result1 = df['A'] + column_mean
result2 = df.eval('A + @column_mean')
np.allclose(result1, result2)True
@ 기호는 열 이름이 아닌 변수 이름임을 나타냅니다. 덕분에 열의 네임스페이스와 파이썬 객체의 네임스페이스라는 두 영역을 오가며 효율적으로 연산합니다. 참고로 이 @ 구문은 DataFrame.eval() 메서드에서만 지원됩니다. pd.eval() 함수는 파이썬 네임스페이스 하나만 접근할 수 있기 때문입니다.
DataFrame에는 문자열 표현식을 바탕으로 데이터를 필터링하는 query() 메서드도 있습니다. 다음 예시를 살펴보겠습니다.
result1 = df[(df.A < 0.5) & (df.B < 0.5)]
result2 = pd.eval('df[(df.A < 0.5) & (df.B < 0.5)]')
np.allclose(result1, result2)True
DataFrame.eval()과 마찬가지로 열 이름을 사용한 표현식이지만, 필터링 연산은 DataFrame.eval() 구문으로 처리할 수 없습니다. 대신 이러한 필터링 작업에 바로 query() 메서드를 사용합니다.
result2 = df.query('A < 0.5 and B < 0.5')
np.allclose(result1, result2)True
연산이 효율적일 뿐만 아니라, 기존의 마스킹 방식보다 가독성도 훨씬 좋습니다. query() 메서드 역시 지역 변수를 참조할 때 @ 기호를 사용합니다.
Cmean = df['C'].mean()
result1 = df[(df.A < Cmean) & (df.B < Cmean)]
result2 = df.query('A < @Cmean and B < @Cmean')
np.allclose(result1, result2)True
eval()과 query()의 도입을 고려할 때는 계산 시간과 메모리 사용량이라는 두 가지 측면을 살펴봐야 합니다. 메모리 사용량은 예측하기 쉽습니다. 앞서 언급했듯이 NumPy 배열이나 Pandas DataFrame이 포함된 복합 표현식은 내부적으로 임시 배열을 생성합니다. 예를 들어 보겠습니다.
x = df[(df.A < 0.5) & (df.B < 0.5)]이 코드는 내부적으로 다음과 같이 동작합니다.
tmp1 = df.A < 0.5
tmp2 = df.B < 0.5
tmp3 = tmp1 & tmp2
x = df[tmp3]생성되는 임시 DataFrame의 크기가 시스템 메모리(보통 수 GB 단위)와 비교했을 때 무시하지 못할 수준이라면 eval()이나 query()를 사용하는 것이 좋습니다. 배열의 대략적인 메모리 크기는 다음과 같이 바이트 단위로 확인합니다.
df.values.nbytes32000
성능 면에서는 시스템 메모리가 넉넉하더라도 eval()이 더 빠를 때가 있습니다. 생성되는 임시 객체의 크기가 CPU 캐시(L1 또는 L2, 보통 수 MB 단위)보다 훨씬 크다면, eval()을 통해 메모리 캐시 간의 불필요한 데이터 이동을 줄일 수 있기 때문입니다. 사실 일반적인 방식과 eval()/query() 방식의 계산 시간 차이가 그리 크지 않은 경우도 많습니다. 오히려 데이터 크기가 작을 때는 일반적인 방식이 더 빠르기도 합니다. 따라서 eval()과 query()를 사용하는 주된 이유는 메모리 절약과 간결한 구문에 있다고 볼 수 있습니다.
지금까지 eval()과 query()의 핵심적인 내용을 살펴보았습니다. 더 자세한 정보는 Pandas 공식 문서를 참고하시기 바랍니다. 특히 쿼리 실행을 위해 다양한 파서(parser)와 엔진을 지정할 수도 있는데, 이에 대한 내용은 문서를 통해 더 깊이 있게 확인해 보실 수 있습니다.