반복량 감소를 통한 성능 향상

Intro

프로그래밍에서 반복문은 기본적이면서도 자주 사용되는 문법 중 하나이다. 그러나 같은 반복문이라도, 연산량을 어떻게 줄이느냐에 따라 프로그램의 성능에 큰 차이가 발생할 수 있다. 이번 포스팅에서는 pandas 의 Dataframe 을 다루면서, 반복문의 반복 횟수를 줄임으로써 얻을 수 있는 성능상 이득을 살펴보고자 한다.

라이브러리

1
2
3
import pandas as pd
import random
import time

실험 데이터셋

  • df_a : user_id, some_data 두 컬럼으로 이루어져 있다.
  • df_a의 some_data 는 약 20% 가량이 NaN 값을 가진다.
  • df_b : user_id, alt_data 두 컬럼으로 이루어져 있다.
  • df_a와 df_b의 user_id 집합은 동일하다.
  • df_b 의 alt_data 에는 결측치가 없다.
1
2
3
4
5
6
7
8
9
10
qnt = 10000 # 데이터 수
df_a_origin = pd.DataFrame({
    "user_id" : [str(x) for x in range(1, qnt+1)],
    "some_data" : [random.randint(0, qnt) if random.randint(0, qnt) < qnt*4/5 else None
                   for _ in range(qnt)]
})
df_b = pd.DataFrame({
    "user_id" : [str(x) for x in range(1, qnt+1)],
    "alt_data" : [random.randint(0, qnt) for _ in range(qnt)]
})

실험의 목표

  • df_a 의 NaN 인 some_data 값을 찾아, df_b 의 동일 user_id 의 alt_data 로 대체한다.

대조군

  • 작동 방식 : df_a 의 각 row 를 순회하며 NaN값 여부를 확인하고, alt_data로 대체한다.
순서 작동 시간복잡도
1 df_a의 각 row를 순회하며, some_data가 NaN인지 확인한다. O(N_a)
2 NaN값인 경우, df_b에서 동일한 user_id 에 해당하는 alt_data를 찾는다. O(N_b)
3 row의 some_data 를 찾은 alt_data로 대체한다. O(1)

전체 시간복잡도 : O(N_a * N_b)
또한 매 반복마다 boolean mask 를 생성하므로 메모리 비용이 발생

1
2
3
4
5
6
df_a = df_a_origin.copy()
start = time.time()
for i, row in df_a.iterrows(): #-------- (1)
    if pd.isna(row["some_data"]): #----- (1)
        df_a.at[i, "some_data"] = df_b.loc[df_b["user_id"] == row["user_id"], "alt_data"].iloc[0] #----(2)(3)
print(f"time check 1 ::: {time.time() - start}")
1
time check 1 ::: 0.7818460464477539

실험군

  • 작동 방식 : some_data 가 NaN인 row만 반복하여, 반복 횟수를 줄인다.
순서 작동 시간복잡도
1 df_a 에서 some_data가 NaN인 인덱스들을 미리 뽑는다. O(N_a)
2 미리 뽑은 인덱스에 해당하는 row 들만 순환한다. O(K), K≈0.2*N_a
3 각 루프마다 df_b에서 동일한 user_id 에 해당하는 alt_data를 찾는다. O(N_b)
4 row의 some_data 를 찾은 alt_data로 대체한다. O(1)

전체 시간복잡도 : O(N_a) + O(K * N_b)

1
2
3
4
5
6
df_a = df_a_origin.copy()
start = time.time()
target_idx = df_a[df_a["some_data"].isna()].index #---- (1)
for i, row in df_a.loc[target_idx].iterrows(): #---- (2)
    df_a.at[i, "some_data"] = df_b.loc[df_b["user_id"] == row["user_id"], "alt_data"].iloc[0] #---- (3)(4)
print(f"time check 2 ::: {time.time() - start}")
1
time check 2 ::: 0.6828429698944092

결과 리뷰

  • 성능이 소폭 상승 (약 0.1초) 상승했다.
  • 하지만 드라마틱한 성능 향상은 볼 수 없었으며, 그 이유는 가장 큰 병목현상은 df_b 에서 user_id 를 찾는 연산 부분이기 때문이다.

원리

  • 반복문의 반복 횟수를 줄였다. (20% 수준으로)

참고 - boolean mask

  • boolean mask 란 **데이터와 같은 길이를 가지는 True/False 값으로 이루어진 배열이나 Series
  • 예를 들어 df[“some_data”].isna() 의 결과는 True/False 값으로 이루어져있다.
  • 대조군과 실험군 모두에서 df_b 에서 user_id 를 찾을 때 매번 boolean mask 를 생성하므로, 비효율적인 연산이 수행된다.

Comments