pandas를 효율적으로 사용하는 방법(2/2)

5 분 소요

지난 글에서는 pandas를 이용할 때 메모리 효율을 최적화 하기위한 방법에 대해서 알아보았다.

이번 글에서는 pandas 데이터를 어떻게 다루어야 빠르게 결과를 구할 수 있는지 알아보도록 한다.

단순 반복을 피하자

A Beginner’s Guide to Optimizing Pandas Code for Speed 글을 보면, pandas dataframe의 처리 속도를 최적화하기 위한 방법에 대해 설명하고 있다. 핵심은 비효율적인 반복을 피하고, 벡터화(vectorization)을 이용하라는 것인데, 이 글에서 설명하고 있는 방법을 차례대로 살펴보도록 하자. A Beginner’s Guide to Optimizing Pandas Code for Speed 글에서는 각 방법의 효율을 비교하기 위해 Haversine 수식을 이용했다. (Haversine 수식은 위도와 경도를 가진 두 좌표를 받아 지구의 곡률을 고려하여 두 좌표간의 직선거리를 계산하는 수식이다).

테스트를 위한 샘플 데이터 및 jupyter notebook 코드는 pycon2017-optimizing-pandas에서 확인할 수 있다.

1. forloop

첫 번째 방법은 forloop을 이용한 단순 반복이다. 단순 반복은 기존 python 객체와 동일하게 처리가능하기 때문에 쉽게 적용할 수 있지만 가장 느린 방법이다.

%%timeit

def haversine_looping(df):
    distance_list = []
    for i in range(0, len(df)):
        d = haversine(40.671, -73.985, df.iloc[i]['latitude'], df.iloc[i]['longitude'])
        distance_list.append(d)
    return distance_list
645 ms ± 31 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

단순 반복을 이용하면 위와 같은 코드처럼 처리할 수 있다. 1600개가 조금 넘는 행을 처리하는데 645ms라는 매우 느린 실행속도를 보였다.

2. iterrows method

두 번째 방법은 iterrows method를 이용하여 처리하는 방법이다. 이 방법은 generator를 이용하여 각 행을 반환하고 dataframe에 사용할 수 있도록 최적화되어 있어 단순 반복보다는 효율적인 방법이다.

%%timeit

haversine_series = []
for index, row in df.iterrows():
    haversine_series.append(haversine(40.671, -73.985, row['latitude'], row['longitude']))
df['distance'] = haversine_series
166 ms ± 2.42 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

iterrows method를 이용하면 166ms로 기존 방법보다 4배 정도 빠르게 처리된 것을 확인할 수 있다.

3. apply method

세 번째 방법은 apply method를 사용하는 것이다. apply method는 cython내 iterator를 사용하는 것과 같이 내부적인 최적화를 가져올 수 있어 기존의 방법들보다 더 효율적이다. 주로 익명의 lambda 함수를 같이 사용하며, dataframe의 특정 영역을 함수의 입력 값으로 받고 axis 옵션을 주어 행과 열 데이터 중 원하는 데이터를 처리할 수 있다. apply method는 단순 반복이나 iterrows method와 동일한 횟수를 반복처리하지만 처리 속도는 확연하게 다르다.

%%timeit

df['distance'] = df.apply(lambda row: haversine(40.671, -73.985, row['latitude'], row['longitude']), axis=1)
90.6 ms ± 7.55 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

위 실행 결과를 보면 평균 90.6ms로 두 번째 방법에 비해 2배 정도 빨라진 것을 확인할 수 있다. 기억해야 할 것은 단순 반복, iterrows method, apply method은 모두 처리 방식의 차이가 있을 뿐 반복 횟수는 동일하는 것이다. 이를 확인하기 위해 아래와 같이 lprun이라는 명령어를 통해 처리 과정을 상세하게 볼 수 있다. (lprun 명령어는 timeit과 함께 pandas 처리과정을 최적화할 때 자주 확인해봐야 할 내용이니 참고하도록 하자.)

%lprun -f haversine df.apply(lambda row: haversine(40.671, -73.985, row['latitude'], row['longitude']), axis=1)

apply method의 lprun 실행 결과

위 실행 결과를 보면, 1631번의 반복 처리를 진행한 것을 알 수 있다.

4. pandas series에 대한 벡터화 연산

pandas의 series는 인덱스와 값으로 이루어진 배열 기반의 객체이다. 이러한 배열 기반의 데이터를 효과적으로 계산할 수 있는 방법이 벡터화(vectorization)인데, 벡터화는 배열 각각의 값(스칼라)에 대하여 반복적으로 데이터를 처리하지 않고, 배열 전체를 벡터로 변경하여 벡터 연산을 할 수 있게 해준다. pandas에서는 다양한 벡터화 함수를 지원하고 있으며, 이는 우리가 손쉽게 벡터화 함수를 사용할 수 있음을 의미한다.

%%timeit

df['distance'] = haversine(40.671, -73.985, df['latitude'], df['longitude'])

위의 예에서 보듯이, haversine 함수에 series(위도, 경도 열 데이터)를 제공하여 매우 간단하게 사용할 수 있다.

1.62 ms ± 41.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

그리고 1.62ms의 실행속도로 apply method에 비해 50배 이상 빠르게 처리되었고, 아래와 같이 해당 함수는 벡터화 연산을 단 한 번만 처리한 것을 확인할 수 있다.

vectorization의 lprun 실행 결과

이는 백터화 연산이 전달받은 배열을 스칼라 단위로 반복 처리한 것이 아니라 배열 단위로 벡터화하여 단 한 번의 연산으로 결과값을 구할 수 있음을 보여주는 것이다.

5. numpy 배열에 대한 벡터화

다섯 번째 방법은 numpy 배열을 벡터화하여 처리하는 것이다. 사실 pandas series를 사용해서 벡터화하면 충분히 빠르게 요구사항을 처리할 수 있지만, 그 이상의 속도를 원한다면 numpy를 활용해야 한다. numpy는 미리 컴파일되어있는 c코드로 작업을 수행하기 때문에 더 빠르고 pandas series와 같은 배열 객체(ndarrays)를 이용한다. 그리고 pandas에서 지원하는 기능(색인이나 데이터 타입 확인 등)을 제공하지 않는 대신 더 빠르게 작업을 수행한다. 그렇기 때문에 pandas에서 지원하는 추가 기능이 필요할 때는 pandas series를 이용해야 한다.

numpy 배열은 pandas series의 values method를 이용하여 바로 제공받을 수 있고 이를 통해 테스트를 진행한 결과는 아래와 같다.

%%timeit

df['distance'] = haversine(40.671, -73.985, df['latitude'].values, df['longitude'].values)
370 µs ± 18 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

위의 결과를 보면, numpy 배열에 대한 벡터화를 이용하여 처리하면 pandas series를 이용할 때보다 약 4배 더 빠르게 결과를 얻을 수 있다. 우리는 지금까지 pandas 처리속도를 올리기 위해 반복에 대해 처리할 수 있는 5가지 방법에 대하여 알아보았고, 이 중 단순 반복이나 iterrows method는 가급적 쓰지 않는 것이 좋고 최대한 벡터화를 이용하는 것이 좋다는 사실을 알았다. 또한 최고 속도를 보였던 numpy 배열에 대한 백터화가 속도에는 가장 좋겠지만, pandas series에서 제공하는 기능을 사용해야 한다면 pandas series에 대한 벡터화만 해도 충분히 빠르다는 것을 알게 되었다.

적절한 pandas built-in 함수 활용

이제 반복에 대한 이야기를 정리하고 다음 이야기를 진행하고자 한다.

There should be one--and preferably only one--obvious way to do it. 

위 문장은 python의 철학 중 하나이다. 직역하면 그것을 할 수 있는 분명한 한 가지 방법이 있어야 한다. 그 방법이 유일하다면 더 좋다. 정도로 해석할 수 있다. python도 pandas도 처음 접할 때는 원하는 동작을 진행할 수 있는 방법이 다양하다는 것을 알 수 있다. 하지만, 올바른 python 개발자는 주어진 상황에 최적의 방법을 찾아야 하고, python에 기초한 pandas 개발자도 이와 다르지 않다.

pandas는 엄청나게 많은 method와 attribute를 제공한다. 그 중 주어진 상황에서 최고의 효율을 낼 수 있는 method와 attribute를 사용하는 것이 중요하다.

하나의 예를 들어보자. 100만개의 점수가 들어있는 dataframe이 있다고 가정하고 이 중 가장 큰 값을 가진 5개만 추출하는 방법에 대해 개발해야 한다면 어떨까? 어떤 개발자는 sort_values와 head method를 이용하여 개발할 수 있고, 어떤 개발자는 nlargest method를 이용할지 모른다.

nlargest 실행 결과

2가지 처리 방식은 동일한 결과를 같지만 8배에 가까운 처리 속도 차이를 보여주는 것을 알 수 있다. pandas 개발자는 결국 더 빨리 결과물을 얻기 위해서 최적의 방법을 찾아야한다. 아쉽게도 원하는 요구사항과 상황이 달라 모든 정답을 가지고 있을 수 없기에 pandas-101, numpy-100과 같이 여러가지 pandas, numpy 문제에 해결책을 제시한 곳을 참고하는 것도 좋은 방법이다.

마치며

이 글에서는 pandas를 효율적으로 처리하는 방법 중 속도를 개선할 수 있는 방법에 대해 살펴보았다.

  1. 반복처리가 필요할 때는 단순 반복이나 iterrows method 보다는 벡터화를 이용하여 처리하는 것이 더 빠르다.
  2. 요구사항을 만족하더라도 속도를 더 개선시킬 수 있도록 다른 built-in method가 없는지 확인하자.