-
판다스 series.apply()와 for문 속도 비교 (번외 스파크)카테고리 없음 2025. 7. 1. 01:44
판다스를 사용한다면 for문보다 series.apply()를 사용하는 것이 좋다고 하는데 성능에 대해 확인을 해봅니다.
숫자 비교
import pandas as pd import numpy as np import time # 1. 테스트용 데이터 생성 data_size = 1_000_000 # 백만 개의 데이터 포인트 s = pd.Series(np.random.rand(data_size), name="test_series") print(f"데이터 크기: {data_size}개") print("-" * 30) # 2. 적용할 함수 정의 def complex_logic(x): """간단한 조건부 연산을 수행하는 함수""" if x > 0.5: return x * 2 else: return x / 2 # ---------------------------------------------------------------------- # 방법 1: 순수 Python for 루프 # ---------------------------------------------------------------------- print("[방법 1] 순수 Python for 루프 테스트 시작...") start_time = time.time() # 결과를 저장할 리스트 초기화 results_for_loop = [] for item in s: results_for_loop.append(complex_logic(item)) # 리스트를 다시 Pandas Series로 변환 (공정한 비교를 위해) series_from_for_loop = pd.Series(results_for_loop) end_time = time.time() time_for_loop = end_time - start_time print(f"-> for 루프 소요 시간: {time_for_loop:.4f}초") print("-" * 30) # ---------------------------------------------------------------------- # 방법 2: series.apply() # ---------------------------------------------------------------------- print("[방법 2] series.apply() 테스트 시작...") start_time = time.time() # apply 메서드 사용 series_from_apply = s.apply(complex_logic) end_time = time.time() time_apply = end_time - start_time print(f"-> apply() 소요 시간: {time_apply:.4f}초") print("-" * 30) # ---------------------------------------------------------------------- # 방법 3: 벡터화(Vectorization) 연산 # ---------------------------------------------------------------------- print("[방법 3] 벡터화 연산 (np.where) 테스트 시작...") start_time = time.time() # np.where를 사용한 조건부 벡터화 연산 # 조건 (s > 0.5)이 참이면 s * 2를, 거짓이면 s / 2를 적용 series_from_vectorization = pd.Series(np.where(s > 0.5, s * 2, s / 2)) end_time = time.time() time_vectorization = end_time - start_time print(f"-> 벡터화 연산 소요 시간: {time_vectorization:.4f}초") print("-" * 30) # ---------------------------------------------------------------------- # 최종 결과 비교 # ---------------------------------------------------------------------- print("\n[최종 속도 비교]") print(f"for 루프 : {time_for_loop:.4f}초") print(f"series.apply() : {time_apply:.4f}초 (for 루프 대비 약 {time_for_loop/time_apply:.2f}배 빠름)") print(f"벡터화 연산 : {time_vectorization:.4f}초 (apply 대비 약 {time_apply/time_vectorization:.2f}배 빠름)")결과
[최종 속도 비교] for 루프 : 0.2155초 series.apply() : 0.0881초 (for 루프 대비 약 2.45배 빠름) 벡터화 연산 : 0.0051초 (apply 대비 약 17.27배 빠름)실행 결과는 환경에 따라 달라질 수 있지만 for문보다 series.apply()가 더 빠른 것을 확인할 수 있습니다.
각 테스트의 동작은 다음과 같습니다.
for: 파이썬 인터프리터가 각 원소를 하나씩 순회하며 함수를 호출하므로 오버헤드가 매우 큼. 데이터가 커질수록 속도가 가장 느림series.apply(): 내부적으로 최적화된 C 코드를 통해 반복을 수행하여 순수 파이썬for루프보다는 빠름. 하지만 여전히 각 원소에 대해 파이썬 함수(complex_logic)를 호출해야 하는 오버헤드가 존재- 벡터화 연산: 전체 데이터 배열에 대해 단일 연산을 수행하는 것처럼 동작. 모든 계산이 최적화된 C 또는 포트란 코드로 실행되므로 오버헤드가 거의 없음
문자열 순회 비교
import pandas as pd import numpy as np import time # 1. 테스트용 문자열 데이터 생성 data_size = 1_000_000 words = ['apple', 'banana', 'cherry', 'dragonfruit', 'elderberry', 'fig', 'grape'] # words 리스트에서 무작위로 샘플링하여 백만 개의 데이터 생성 s = pd.Series(np.random.choice(words, data_size), name="test_series") print(f"문자열 데이터 크기: {data_size}개") print("-" * 40) # 2. 적용할 문자열 처리 함수 정의 def process_string(text): """문자열 길이에 따라 다른 처리를 하는 함수""" if len(text) > 6: return text[:4].upper() # 긴 단어는 앞 4글자를 대문자로 else: return text + "_fruit" # 짧은 단어는 '_fruit' 접미사 추가 # ---------------------------------------------------------------------- # 방법 1: 순수 Python for 루프 # ---------------------------------------------------------------------- print("[Pandas 방법 1] 순수 Python for 루프 테스트 시작...") start_time = time.time() results_for_loop = [process_string(item) for item in s] series_from_for_loop = pd.Series(results_for_loop) end_time = time.time() time_for_loop = end_time - start_time print(f"-> for 루프 소요 시간: {time_for_loop:.4f}초") print("-" * 40) # ---------------------------------------------------------------------- # 방법 2: series.apply() # ---------------------------------------------------------------------- print("[Pandas 방법 2] series.apply() 테스트 시작...") start_time = time.time() series_from_apply = s.apply(process_string) end_time = time.time() time_apply = end_time - start_time print(f"-> apply() 소요 시간: {time_apply:.4f}초") print("-" * 40) # ---------------------------------------------------------------------- # 방법 3: 벡터화(Vectorization) 연산 (.str accessor + np.where) # ---------------------------------------------------------------------- print("[Pandas 방법 3] 벡터화 연산 테스트 시작...") start_time = time.time() # 조건 생성 condition = s.str.len() > 6 # 조건이 참일 때와 거짓일 때의 연산을 각각 벡터화하여 수행 result_if_true = s.str[:4].str.upper() result_if_false = s + "_fruit" # 문자열 덧셈도 벡터화 연산입니다. # np.where를 사용해 조건에 따라 결과를 합침 series_from_vectorization = pd.Series(np.where(condition, result_if_true, result_if_false)) end_time = time.time() time_vectorization = end_time - start_time print(f"-> 벡터화 연산 소요 시간: {time_vectorization:.4f}초") print("-" * 40) # ---------------------------------------------------------------------- # 최종 결과 비교 (Pandas) # ---------------------------------------------------------------------- print("\n[Pandas 최종 속도 비교]") print(f"for 루프 : {time_for_loop:.4f}초") print(f"series.apply() : {time_apply:.4f}초 (for 루프 대비 약 {time_for_loop/time_apply:.2f}배 빠름)") print(f"벡터화 연산 : {time_vectorization:.4f}초 (apply 대비 약 {time_apply/time_vectorization:.2f}배 빠름)")결과
[Pandas 최종 속도 비교] for 루프 : 0.4881초 series.apply() : 0.4187초 (for 루프 대비 약 1.17배 빠름) 벡터화 연산 : 0.7900초 (apply 대비 약 0.53배 빠름)숫자만큼은 아니지만 series.apply()가 for문보다 빠른 것을 확인할 수 있습니다.
[번외] 스파크 비교
from pyspark.sql import SparkSession from pyspark.sql.functions import col, udf, when, length, upper, substring, lit from pyspark.sql.types import StringType import pandas as pd import numpy as np # SparkSession 생성 spark = SparkSession.builder.appName("StringProcessingComparison").getOrCreate() # -- Pandas와 동일한 데이터로 Spark DataFrame 생성 -- data_size = 1_000_000 words = ['apple', 'banana', 'cherry', 'dragonfruit', 'elderberry', 'fig', 'grape'] pandas_df = pd.DataFrame(np.random.choice(words, data_size), columns=['text']) df = spark.createDataFrame(pandas_df) print(f"Spark DataFrame 레코드 수: {df.count()}개") print("-" * 40) # -- 적용할 Python 함수 (Pandas와 동일) -- def process_string(text): if len(text) > 6: return text[:4].upper() else: return text + "_fruit" # ---------------------------------------------------------------------- # Spark 방법 1: UDF (User-Defined Function) 사용 # ---------------------------------------------------------------------- print("[Spark 방법 1] UDF 테스트 시작...") start_time = time.time() # Python 함수를 Spark UDF로 등록 (반환 타입 지정 필수) process_string_udf = udf(process_string, StringType()) # UDF를 'text' 컬럼에 적용 df_from_udf = df.withColumn('processed_text', process_string_udf(col('text'))) # .show()나 .count() 같은 액션(Action)을 실행해야 실제 연산이 수행됨 df_from_udf.count() # 실제 연산을 트리거하기 위해 count() 실행 end_time = time.time() print(f"UDF 소요 시간: {end_time - start_time:.4f}초") print("-" * 40) # ---------------------------------------------------------------------- # Spark 방법 2: 내장 함수(Native Functions) 사용 # ---------------------------------------------------------------------- print("[Spark 방법 2] 내장 함수 테스트 시작...") start_time = time.time() # Spark 내장 함수로 동일한 로직 구현 # 참고: Spark의 substring은 1부터 인덱싱 (substring(str, pos, len)) df_from_native = df.withColumn('processed_text', when(length(col('text')) > 6, upper(substring(col('text'), 1, 4))) .otherwise(col('text') + '_fruit') # 혹은 concat(col('text'), lit('_fruit')) ) df_from_native.count() # 실제 연산을 트리거 end_time = time.time() print(f"내장 함수 소요 시간: {end_time - start_time:.4f}초") print("-" * 40) spark.stop()결과
UDF 소요 시간 : 1.0567초 내장 함수 소요 시간: 1.3442초결과를 설명하기에 앞서 스파크 UDF와 스파크 내장 함수는 다음과 같습니다.
- 스파크 UDF: 판다스의 apply()와 개념적으로 유사. 사용자가 정의한 파이썬 함수를 스파크의 각 데이터 row에 적용할 수 있게 해줌. 다른 점은 스파크가 병렬로 처리할 수 있는 형태
- 스파크는 JVM(자바 가상 머신) 위에서 동작. 파이썬 UDF를 사용하면 각 로우의 데이터를 JVM → 파이썬 프로세스로 직렬화하여 보내고 파이썬 함수를 실행한 뒤 그 결과를 다시 파이썬 프로세스 → JVM으로 직렬화해서 가져오는데 이 과정의 오버헤드가 매우 커서 성능 저하의 주된 원인이 됨
- 스파크 내장 함수: 판다스의 벡터화 연산과 같음. 스파크가 제공하는 고성능 내장 함수(예:
length,substring,when)를 조합하여 사용하는 방식- 스파크의 모든 내장 함수는 JVM 위에서 직접 실행되도록 설계됨. 데이터가 JVM을 벗어날 필요가 없으며 스파크의 Catalyst 옵티마이저가 전체 쿼리 계획을 분석하고 최적화할 수 있어 성능이 좋음
스파크가 더 빠르다고 예상했지만 for문보다 느린 것을 확인할 수 있습니다. 이 테스트는 백만 건 정도의 작은 데이터로 단일 머신의 메모리에 충분히 들어가는 크기입니다. 스파크는 이런 경우에 사용하도록 설계된 도구가 아닙니다. 또한 스파크는 분산 처리를 위해 작업을 계획하고(DAG 생성) 여러 노드(Executor)에 작업을 분배하고 결과를 취합하는 등 상당한 초기 오버헤드가 발생합니다. 데이터가 작을 때는 이 오버헤드가 실제 연산 시간보다 더 커서 판다스보다 느리게 보입니다.
요약
개념 (Concept) Pandas Spark 성능 (Performance) 설명 로우별 순회 for loop (안티패턴) collect() 후 loop 매우 느림 단일 프로세스/드라이버에서 순차 처리하여 병렬성의 이점 없음. 사용자 함수 적용 Series.apply() UDF (User-Defined Function) 중간 사용하기 편리하지만, 파이썬 함수 호출 오버헤드 존재. 네이티브/벡터화 .str 접근자 + np.where 내장 함수 + when() 매우 빠름 내부적으로 최적화된 코드로 실행되어 성능이 가장 뛰어남.