ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 판다스 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() 매우 빠름 내부적으로 최적화된 코드로 실행되어 성능이 가장 뛰어남.

    댓글