Pandas를 사용하다보면 특정 함수를 여러 행에 적용하는 경우가 굉장히 많습니다. 일반적인 for 루프나 Pandas의 iterrows 이터레이터 등을 활용할 수 있지만 행이 굉장히 많은 경우에는 많이 느리게 됩니다. 어떤 방법이 제일 효율적일까요?
통신사의 예를 들어봅시다. 통신사에는 고객별로 일별 통화 사용량 정보가 있고 각 고객이 통화를 점점 더 많이 혹은 더 적게 하는지 알고 싶다고 합시다. 그렇다면 일반 최소 제곱 (OLS, Ordinary, Least Square) 방법을 적용해서 고객별 일별 통화 사용량 데이터에 직선을 맞추고 그 직선의 기울기를 통해 판단할 수 있겠죠. (고객별로 일차 추세선 함수를 만들면 됩니다) 이제 실험에 사용할 데이터를 만듭니다.
푸아송 분포를 이용해 데이터 만들기
하루에 고객이 사용하는 전화량을 어떻게 모델링할 수 있을까요? 예를 들어 하루에 매 분마다 전화를 사용할 확률을 $p$라 하면 하루 1440분에 대해 이항분포를 적용하면 구할 수 있겠지만 팩토리얼로 인해 계산이 불가능합니다. 이 때는 $n$이 너무 크고 $p$가 너무 작은 경우에 이항분포의 극한값을 이용해 이항분포의 값을 근사적으로 구할 수 있는 푸아송 분포를 이용하면 됩니다. 푸아송 분포는 단위 시간/공간에서 어떤 사건이 발생하는 횟수에 대한 이산 확률 분포로 우리가 사용할 푸아송 분포는 고객별 하루 전화 평균 사용량이 $np=\lambda=60$ 분이라고 가정합니다.
numpy.random.poisson 함수를 이용하면 푸아송 확률 분포에 따른 난수를 뽑아낼 수 있습니다. (원래 poisson 함수의 결과값은 푸아송 분포에 따른 사건 횟수를 (정수) 나타냅니다) 이 값에 60을 나눠 분을 시 단위로 바꿉니다. 당연히 이 값들은 아무 물리적인 의미는 없습니다.
import numpy as np
import pandas as pd
NBR_DAYS = 14
NBR_PEOPLE = 100_000
lam = 60
np.random.seed(0) # fix the seed
hours_per_day_per_person = np.random.poisson(lam=lam, size=(NBR_PEOPLE, NBR_DAYS))
hours_per_day_per_person = hours_per_day_per_person / 60
df = pd.DataFrame(hours_per_day_per_person).astype(np.float32)
OLS 구현
이번에는 OLS에 대해 사이킷런 구현과 넘파이를 사용한 선형 대수 구현을 비교해 보겠습니다. 여기서 $m$은 기울기고 $c$는 직선의 절편이 됩니다.
from sklearn.linear_model import LinearRegression
def ols_sklearn(row):
"""Solve OLS using scikit-learn's LinearRegression"""
est = LinearRegression()
X = np.arange(row.shape[0]).reshape(-1, 1) # shape (14, 1)
# note that the intercept is built inside LinearRegression
est.fit(X, row.values)
m = est.coef_[0] # note c is in est.intercept_
return m
def ols_lstsq(row):
"""Solve OLS using numpy.linalg.lstsq"""
# build X values for [0, 13]
X = np.arange(row.shape[0]) # shape (14,)
ones = np.ones(row.shape[0]) # constant used to build intercept
A = np.vstack((X, ones)).T # shape(14, 2)
# lstsq returns the coefficient and intercept as the first result
# followed by the residuals and other items
m, c = np.linalg.lstsq(A, row.values, rcond=-1)[0]
return m
def ols_lstsq_raw(row):
"""Variant of `ols_lstsq` where row is a numpy array (not a Series)"""
X = np.arange(row.shape[0])
ones = np.ones(row.shape[0])
A = np.vstack((X, ones)).T
m, c = np.linalg.lstsq(A, row, rcond=-1)[0]
return m
- $x$는 0부터 14까지의 정수로 합니다.
- $y=mx+c$를 구해야 하므로 이를 $A=[[x 1]], p=[[m],[c]]$로 바꾼 행렬 연산 $y=Ap$으로 계산합니다. (https://numpy.org/doc/stable/reference/generated/numpy.linalg.lstsq.html)
각 함수를 timeit 모듈로 분석해보면 ols_sklearn 함수가 ols_lstsq에 비해 3~4배 가량 느립니다. 왜 그럴까요? 사이킷런의 함수는 실제 함수 동작 전 데이터의 무결성을 검사하는 로직이 기본으로 추가되어 있기 때문입니다. Line profiler를 통해 분석해보면 사이킷런의 fit 함수는 최종 linalg.lstsq 함수를 호출하기 전에 배열의 모양, NaN 포함 여부, 입력 배열의 오프셋 변경 등의 검사를 실행하게 됩니다. 이런 검사로 에러를 미연에 방지한 경험이 다들 있으시겠지만 이런 메서드의 안정성은 최적화를 방해하는 요소로 생산성을 낮추게 됩니다. (당연히 그냥 내비두는 것이 좋겠죠!)
est = LinearRegression()
row = df.iloc[0]
X = np.arange(row.shape[0]).reshape(-1, 1).astype(np.float32)
lp = LineProfiler(est.fit)
print("Run on a single row")
lp.run("est.fit(X, row.values)")
lp.print_stats()
OLS 적용하기
먼저 가장 기본적인 방법으로 DataFrame의 색인을 매번 iloc를 사용해 얻어 얻은 행에 대해 OLS를 계산해 보겠습니다. 16초 정도가 소요되네요.
t1 = time.time()
ms = []
for row_idx in range(df.shape[0]):
row = df.iloc[row_idx]
m = ols_lstsq(row)
ms.append(m)
results = pd.Series(ms)
t2 = time.time()
다음으로는 Pandas의 iterrows를 사용하는 방법이 있습니다. iterrows는 행을 이터레이션하면서 iloc과 달리 매번 행을 참조하지 않고도 행 사이를 오갈 수 있습니다. 데랙 12.8초 정도가 소요됩니다.
t1 = time.time()
ms = []
for row_idx, row in df.iterrows():
m = ols_lstsq(row)
ms.append(m)
results = pd.Series(ms)
t2 = time.time()
위의 두 방법은 매 반복마다 새로운 Pandas.Series 객체를 명시적으로 만들면서 ($row$) 중간 결과를 저장하기 위한 참조를 ($m$) 든다는 공통점이 있습니다. 다음과 같이 Pandas의 apply 메소드를 이용하면 파이썬 중간 참조를 만들지 않고 ols_lstsq를 데이터 행에 직접 넘기게 됩니다. (물론 이 경우에도 내부적으로는 행마다 새로운 Series가 만들어집니다) 속도도 7.6초 정도로 매우 빨라졌습니다.
t1 = time.time()
ms = df.apply(ols_lstsq, axis=1)
results = pd.Series(ms)
t2 = time.time()
마지막으로는 같은 apply 호출을 사용하되 raw=True를 인자로 넘기는 방법이 있습니다. raw=True를 인자로 넘기면 중간 Series 객체가 만들어지지 않고 바로 numpy 배열을 함수로 넘기게 되어 이때는 위에서 구현한 ols_lstsq_raw 함수를 사용해야합니다. 6.7초로 더 빨라졌습니다.
t1 = time.time()
ms = df.apply(ols_lstsq_raw, axis=1, raw=True)
results = pd.Series(ms)
t2 = time.time()
결과적으로 중간 객체를 생성하거나 값을 역참조하는 iterrows 등의 반복 루프보다 apply 메소드 사용이 더 바람직하다고 볼 수 있습니다. 여기에서는 10만 행이라 속도 차이가 견딜만 하지만 데이터가 백만, 천만 행 단위로 늘어난다면 apply 메소드와 다른 방법과의 속도 차이가 매우 크게 날 것입니다.
'Computer > Pandas' 카테고리의 다른 글
Pandas 문자열 찾기 (0) | 2022.10.13 |
---|---|
Pandas Series, DataFrame 이어 붙이기 (0) | 2022.10.13 |
Pandas groupby (3) (0) | 2021.08.14 |
Pandas - SettingWithCopyWarning (0) | 2021.08.11 |
Pandas groupby (2) (0) | 2021.08.07 |