팬더의 for 루프가 정말 나쁜가요? 언제 신경을 써야합니까?


106

인가 for루프는 정말 "나쁜"? 그렇지 않다면 기존의 "벡터화 된"접근 방식을 사용하는 것보다 어떤 상황에서 더 나을까요? 1

저는 "벡터화"의 개념과 팬더가 계산 속도를 높이기 위해 벡터화 기술을 사용하는 방법에 대해 잘 알고 있습니다. 벡터화 된 함수는 전체 시리즈 또는 DataFrame에 대한 작업을 브로드 캐스트하여 기존의 데이터 반복보다 훨씬 더 빠른 속도를 달성합니다.

그러나 for루프 및 목록 이해를 사용하여 데이터를 반복하는 문제에 대한 솔루션을 제공하는 많은 코드 (Stack Overflow의 답변 포함)를보고 놀랐습니다 . 문서와 API는 루프가 "나쁘다"고, 배열, 시리즈 또는 데이터 프레임을 "절대"반복해서는 안된다고 말합니다. 그렇다면 때때로 사용자가 루프 기반 솔루션을 제안하는 것을 보는 이유는 무엇입니까?


1-질문이 다소 광범위하게 들리는 것은 사실이지만 for루프가 일반적으로 데이터를 반복하는 것보다 일반적으로 더 나은 매우 특정한 상황이 있다는 것 입니다. 이 포스트는 이것을 후세를 위해 포착하는 것을 목표로합니다.

답변:


143

TLDR; 아니요, for루프는 적어도 항상 그런 것은 아닙니다. 일부 벡터화 된 작업이 반복보다 느리다고 말하는 것이, 일부 벡터화 된 작업보다 반복이 더 빠르다고 말하는 것이 더 정확할 것입니다 . 코드에서 최고의 성능을 얻으려면 언제, 왜 그런지 아는 것이 중요합니다. 요컨대, 다음은 벡터화 된 팬더 함수의 대안을 고려할 가치가있는 상황입니다.

  1. 데이터가 작을 때 (...하고있는 작업에 따라 다름),
  2. object/ mixed dtypes를 다룰 때
  3. str/ regex 접근 자 함수를 사용하는 경우

이러한 상황을 개별적으로 살펴 보겠습니다.


작은 데이터에 대한 반복 대 벡터화

Pandas 는 API 디자인에서 "컨벤션 오버 컨벤션" 접근 방식을 따릅니다 . 이는 동일한 API가 광범위한 데이터 및 사용 사례에 적합하다는 것을 의미합니다.

pandas 함수가 호출 될 때, 작동을 보장하기 위해 함수가 다음과 같은 사항을 내부적으로 처리해야합니다.

  1. 색인 / 축 정렬
  2. 혼합 데이터 유형 처리
  3. 누락 된 데이터 처리

거의 모든 함수는 이러한 것들을 다양한 범위로 처리해야하며 이는 오버 헤드를 제공합니다 . 숫자 함수 (예 :)의 경우 오버 헤드가 적고 Series.add문자열 함수 (예 :)의 경우 더 두드러 Series.str.replace집니다.

for반면에 루프는 생각보다 빠릅니다. 더 좋은 점은 목록 생성을위한 최적화 된 반복 메커니즘이기 때문에 목록 이해 ( for루프를 통해 목록을 생성하는 )가 훨씬 빠르다는 것입니다.

목록 이해는 패턴을 따릅니다.

[f(x) for x in seq]

seqPandas 시리즈 또는 DataFrame 열은 어디에 있습니까 ? 또는 여러 열에 대해 작업 할 때

[f(x, y) for x, y in zip(seq1, seq2)]

어디 seq1seq2열입니다.

숫자 비교
간단한 부울 인덱싱 작업을 고려하십시오. 목록 이해 방법은 Series.ne( !=) 및 query. 기능은 다음과 같습니다.

# Boolean indexing with Numeric value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

단순화 perfplot를 위해이 게시물의 모든 timeit 테스트를 실행 하기 위해 패키지를 사용 했습니다. 위의 작업 시간은 다음과 같습니다.

여기에 이미지 설명 입력

목록 이해력 query은 중간 크기의 N보다 성능이 뛰어나고 벡터화 된 같지 않음 작은 N에 대한 비교보다 성능이 뛰어납니다. 불행히도 목록 이해력은 선형 적으로 확장되므로 큰 N에 대한 성능 향상을 많이 제공하지 않습니다.

참고
목록 이해의 이점 중 상당 부분은 인덱스 정렬에 대해 걱정할 필요가 없다는 점에서 비롯되지만 이는 코드가 인덱싱 정렬에 의존하는 경우 중단된다는 것을 의미합니다. 어떤 경우에는 기본 NumPy 배열에 대한 벡터화 된 작업이 "양쪽 세계의 최고"를 가져 오는 것으로 간주 될 수 있으므로 팬더 함수의 불필요한 오버 헤드 없이 벡터화가 가능 합니다. 이것은 위의 작업을 다음과 같이 다시 작성할 수 있음을 의미합니다.

df[df.A.values != df.B.values]

pandas와 list comprehension 등가물을 능가하는 것 :

NumPy 벡터화는이 게시물의 범위를 벗어 났지만 성능이 중요하다면 확실히 고려할 가치가 있습니다.

값 카운트
다른 예를 들면, 이번에 는 for 루프보다 빠른 또 다른 바닐라 파이썬 구조를 사용합니다 collections.Counter. 일반적인 요구 사항은 값 개수를 계산하고 결과를 사전으로 반환하는 것입니다. 이 작업은 이루어집니다 value_counts, np.unique그리고 Counter:

# Value Counts comparison.
ser.value_counts(sort=False).to_dict()           # value_counts
dict(zip(*np.unique(ser, return_counts=True)))   # np.unique
Counter(ser)                                     # Counter

여기에 이미지 설명 입력

결과는 더 Counter뚜렷하고 더 큰 범위의 작은 N (~ 3500)에 대해 두 벡터화 방법을 능가합니다.


더 많은 퀴즈를 참고하십시오 (예의 @ user2357112). 는 Counter로 구현 C 촉진제 가 빠르게보다는 여전히 파이썬 대신 기본 C 데이터 유형 객체 여전히 작동을 가져서 동시에 for루프. 파이썬 파워!

물론 여기서 빼면 성능은 데이터와 사용 사례에 따라 달라집니다. 이 예의 요점은 이러한 솔루션을 합법적 인 옵션으로 배제하지 않도록 설득하는 것입니다. 그래도 필요한 성능을 제공하지 못하는 경우 항상 cythonnumba가 있습니다. 이 테스트를 믹스에 추가해 보겠습니다.

from numba import njit, prange

@njit(parallel=True)
def get_mask(x, y):
    result = [False] * len(x)
    for i in prange(len(x)):
        result[i] = x[i] != y[i]

    return np.array(result)

df[get_mask(df.A.values, df.B.values)] # numba

여기에 이미지 설명 입력

Numba는 루피 파이썬 코드의 JIT 컴파일을 매우 강력한 벡터 코드로 제공합니다. numba를 작동시키는 방법을 이해하려면 학습 곡선이 필요합니다.


혼합 / objectdtypes 작업

문자열 기반 비교
첫 번째 섹션의 필터링 예제를 다시 살펴보면 비교 중인 열이 문자열이면 어떻게됩니까? 위의 동일한 세 가지 함수를 고려하지만 입력 DataFrame은 문자열로 캐스트됩니다.

# Boolean indexing with string value comparison.
df[df.A != df.B]                            # vectorized !=
df.query('A != B')                          # query (numexpr)
df[[x != y for x, y in zip(df.A, df.B)]]    # list comp

여기에 이미지 설명 입력

그래서 무엇이 바뀌 었습니까? 여기서 주목할 점은 문자열 연산이 본질적으로 벡터화하기 어렵다는 것입니다. Pandas는 문자열을 객체로 취급하고 객체에 대한 모든 작업은 느리고 반복적 인 구현으로 돌아갑니다.

이제이 반복적 인 구현은 위에서 언급 한 모든 오버 헤드로 둘러싸여 있기 때문에 확장이 동일하더라도 이러한 솔루션 간에는 일정한 크기 차이가 있습니다.

변경 가능 / 복잡한 개체에 대한 작업에 관해서는 비교가 없습니다. 목록 이해는 사전 및 목록과 관련된 모든 작업을 능가합니다.

키로 사전 값 액세스
다음은 사전 열에서 값을 추출하는 두 가지 작업 map, 즉 목록 이해에 대한 타이밍입니다 . 설정은 "코드 조각"제목 아래의 부록에 있습니다.

# Dictionary value extraction.
ser.map(operator.itemgetter('value'))     # map
pd.Series([x.get('value') for x in ser])  # list comprehension

여기에 이미지 설명 입력


열 목록 (예외 처리) map, str.get접근 자 메서드 및 목록 이해력 에서 0 번째 요소를 추출하는 3 개의 작업에 대한 위치 목록 인덱싱 타이밍 :

# List positional indexing. 
def get_0th(lst):
    try:
        return lst[0]
    # Handle empty lists and NaNs gracefully.
    except (IndexError, TypeError):
        return np.nan

ser.map(get_0th)                                          # map
ser.str[0]                                                # str accessor
pd.Series([x[0] if len(x) > 0 else np.nan for x in ser])  # list comp
pd.Series([get_0th(x) for x in ser])                      # list comp safe

참고
색인이 중요한 경우 다음을 수행 할 수 있습니다.

pd.Series([...], index=ser.index)

시리즈를 재구성 할 때.

여기에 이미지 설명 입력

목록 병합
마지막 예는 목록 병합 입니다. 이것은 또 다른 일반적인 문제이며 순수한 파이썬이 여기에 얼마나 강력한 지 보여줍니다.

# Nested list flattening.
pd.DataFrame(ser.tolist()).stack().reset_index(drop=True)  # stack
pd.Series(list(chain.from_iterable(ser.tolist())))         # itertools.chain
pd.Series([y for x in ser for y in x])                     # nested list comp

여기에 이미지 설명 입력

모두 itertools.chain.from_iterable와 중첩 된 목록의 이해는 훨씬 더보다는 순수 파이썬 구조, 규모입니다 stack솔루션입니다.

이러한 타이밍은 pandas가 혼합 dtypes와 함께 작동하도록 준비되어 있지 않다는 사실을 보여주는 강력한 표시이며 그렇게하는 데 사용하지 않아야 할 것입니다. 가능한 경우 데이터는 별도의 열에 스칼라 값 (ints / floats / strings)으로 표시되어야합니다.

마지막으로 이러한 솔루션의 적용 가능성은 데이터에 따라 크게 달라집니다. 따라서 가장 좋은 방법은 무엇을 사용할지 결정하기 전에 데이터에서 이러한 작업을 테스트하는 것입니다. apply그래프를 왜곡 할 수 있기 때문에 이러한 솔루션에 대해 시간을 설정 하지 않았는지 확인 하십시오 (예, 그렇게 느립니다).


정규식 작업 및 접근 자 .str방법

팬더는 다음과 같은 정규식 작업을 적용 할 수 있습니다 str.contains, str.extract그리고 str.extractall뿐만 아니라 (같은 다른 "벡터화"문자열 연산 str.splitstr.find, ,문자열 컬럼에 등 str.translate`). 이러한 함수는 목록 이해보다 느리며 다른 어떤 것보다 더 편리한 함수입니다.

일반적으로 정규식 패턴을 사전 컴파일하고 데이터를 반복하는 것이 훨씬 빠릅니다 re.compile(또한 Python의 re.compile을 사용할 가치가 있습니까? 참조 ). 에 해당하는 목록 구성 str.contains은 다음과 같습니다.

p = re.compile(...)
ser2 = pd.Series([x for x in ser if p.search(x)])

또는,

ser2 = ser[[bool(p.search(x)) for x in ser]]

NaN을 처리해야하는 경우 다음과 같이 할 수 있습니다.

ser[[bool(p.search(x)) if pd.notnull(x) else False for x in ser]]

str.extract(그룹 없음)에 해당하는 목록 구성 은 다음과 같습니다.

df['col2'] = [p.search(x).group(0) for x in df['col']]

불일치 및 NaN을 처리해야하는 경우 사용자 지정 함수를 사용할 수 있습니다 (여전히 더 빠릅니다!).

def matcher(x):
    m = p.search(str(x))
    if m:
        return m.group(0)
    return np.nan

df['col2'] = [matcher(x) for x in df['col']]

matcher기능은 매우 확장 가능합니다. 필요에 따라 각 캡처 그룹에 대한 목록을 반환하도록 조정할 수 있습니다. matcher 개체 의 group또는 groups특성 쿼리를 추출하기 만하면 됩니다.

의 경우 str.extractall로 변경 p.search합니다 p.findall.

문자열 추출
간단한 필터링 작업을 고려하십시오. 아이디어는 대문자가 앞에 오는 경우 4 자리 숫자를 추출하는 것입니다.

# Extracting strings.
p = re.compile(r'(?<=[A-Z])(\d{4})')
def matcher(x):
    m = p.search(x)
    if m:
        return m.group(0)
    return np.nan

ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False)   #  str.extract
pd.Series([matcher(x) for x in ser])                  #  list comprehension

여기에 이미지 설명 입력

추가 예
전체 공개-본인은 아래 나열된 게시물의 일부 또는 전체의 작성자입니다.


결론

위의 예에서 볼 수 있듯이 반복은 작은 행의 DataFrame, 혼합 데이터 유형 및 정규식으로 작업 할 때 빛납니다.

속도 향상은 데이터와 문제에 따라 다르므로 마일리지가 다를 수 있습니다. 가장 좋은 방법은 신중하게 테스트를 실행하고 지불금이 노력할만한 가치가 있는지 확인하는 것입니다.

"벡터화 된"함수는 단순성과 가독성에서 빛을 발합니다. 따라서 성능이 중요하지 않다면 확실히 선호해야합니다.

또 다른 참고 사항은 특정 문자열 작업이 NumPy 사용을 선호하는 제약 조건을 처리한다는 것입니다. 다음은 신중한 NumPy 벡터화가 Python을 능가하는 두 가지 예입니다.

또한 때로는 .values시리즈 또는 데이터 프레임과 달리 기본 어레이에서 작동하는 것만으로도 대부분의 일반적인 시나리오에 대해 충분한 속도 향상을 제공 할 수 있습니다 (위 의 숫자 비교 섹션 의 참고 사항 참조 ). 따라서, 예를 들어 이상 인스턴트 성능 부스트를 보여줄 것입니다 . 모든 상황에서 사용 이 적절하지는 않지만 알아두면 유용한 해킹입니다.df[df.A.values != df.B.values]df[df.A != df.B].values

위에서 언급했듯이 이러한 솔루션을 구현할 가치가 있는지 여부를 결정하는 것은 귀하에게 달려 있습니다.


부록 : 코드 조각

import perfplot  
import operator 
import pandas as pd
import numpy as np
import re

from collections import Counter
from itertools import chain

# Boolean indexing with Numeric value comparison.
perfplot.show(
    setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B']),
    kernels=[
        lambda df: df[df.A != df.B],
        lambda df: df.query('A != B'),
        lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
        lambda df: df[get_mask(df.A.values, df.B.values)]
    ],
    labels=['vectorized !=', 'query (numexpr)', 'list comp', 'numba'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N'
)

# Value Counts comparison.
perfplot.show(
    setup=lambda n: pd.Series(np.random.choice(1000, n)),
    kernels=[
        lambda ser: ser.value_counts(sort=False).to_dict(),
        lambda ser: dict(zip(*np.unique(ser, return_counts=True))),
        lambda ser: Counter(ser),
    ],
    labels=['value_counts', 'np.unique', 'Counter'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=lambda x, y: dict(x) == dict(y)
)

# Boolean indexing with string value comparison.
perfplot.show(
    setup=lambda n: pd.DataFrame(np.random.choice(1000, (n, 2)), columns=['A','B'], dtype=str),
    kernels=[
        lambda df: df[df.A != df.B],
        lambda df: df.query('A != B'),
        lambda df: df[[x != y for x, y in zip(df.A, df.B)]],
    ],
    labels=['vectorized !=', 'query (numexpr)', 'list comp'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

# Dictionary value extraction.
ser1 = pd.Series([{'key': 'abc', 'value': 123}, {'key': 'xyz', 'value': 456}])
perfplot.show(
    setup=lambda n: pd.concat([ser1] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.map(operator.itemgetter('value')),
        lambda ser: pd.Series([x.get('value') for x in ser]),
    ],
    labels=['map', 'list comprehension'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

# List positional indexing. 
ser2 = pd.Series([['a', 'b', 'c'], [1, 2], []])        
perfplot.show(
    setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.map(get_0th),
        lambda ser: ser.str[0],
        lambda ser: pd.Series([x[0] if len(x) > 0 else np.nan for x in ser]),
        lambda ser: pd.Series([get_0th(x) for x in ser]),
    ],
    labels=['map', 'str accessor', 'list comprehension', 'list comp safe'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

# Nested list flattening.
ser3 = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']])
perfplot.show(
    setup=lambda n: pd.concat([ser2] * n, ignore_index=True),
    kernels=[
        lambda ser: pd.DataFrame(ser.tolist()).stack().reset_index(drop=True),
        lambda ser: pd.Series(list(chain.from_iterable(ser.tolist()))),
        lambda ser: pd.Series([y for x in ser for y in x]),
    ],
    labels=['stack', 'itertools.chain', 'nested list comp'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',    
    equality_check=None

)

# Extracting strings.
ser4 = pd.Series(['foo xyz', 'test A1234', 'D3345 xtz'])
perfplot.show(
    setup=lambda n: pd.concat([ser4] * n, ignore_index=True),
    kernels=[
        lambda ser: ser.str.extract(r'(?<=[A-Z])(\d{4})', expand=False),
        lambda ser: pd.Series([matcher(x) for x in ser])
    ],
    labels=['str.extract', 'list comprehension'],
    n_range=[2**k for k in range(0, 15)],
    xlabel='N',
    equality_check=None
)

pd.Series그리고 pd.DataFrame지금은 반복 가능 객체에서 건설을 지원합니다. 즉, 목록을 먼저 생성 할 필요없이 (목록 이해를 사용하여) Python 생성기를 생성자 함수에 간단히 전달할 수 있으며, 이는 많은 경우에 더 느릴 수 있습니다. 그러나 발전기 출력의 크기는 미리 결정할 수 없습니다. 얼마나 많은 시간 / 메모리 오버 헤드가 발생하는지 잘 모르겠습니다.
GZ0

@ GZ0 IIRC, 반복자를 수락하는 것은 API에 대한 최근 추가 사항입니다. "즉, 목록을 먼저 생성 할 필요없이 Python 생성기를 생성자 함수에 간단히 전달할 수 있음을 의미합니다"에 대해서는 동의하지 않습니다. 아마도 메모리, 성능 아니. 내 경험상 거의 항상 목록을 생성하고 전달하는 것이 더 빠릅니다. 목록 comps FTW.
cs95

@ cs95 생성기가 성능 향상으로 이어질 수 있다고 생각하지 않는 이유는 무엇입니까? 아니면 그것에 대해 어떤 테스트를 했습니까?
GZ0

@ GZ0 나는 발전기가 성능 향상으로 이어지지 않는다고 말하지 않았고, 목록 이해력을 사용하는 것만 큼 많이 얻지 못한다고 말합니다. genexp 초기화, 상태 유지 등과 관련된 오버 헤드가 있습니다. 이러한 오버 헤드는 목록 구성 요소에 없습니다. 여기에는 비교하기 위해 생성기 이해와 시간을 맞출 수있는 목록 이해가 포함 된 많은 예제가 있습니다. 지금 바로 실행할 수있는 예는 다음과 같습니다. ser = pd.Series([['a', 'b', 'c'], ['d', 'e'], ['f', 'g']] * 10000); %timeit pd.Series(y for x in ser for y in x); %timeit pd.Series([y for x in ser for y in x])
cs95

@ cs95 아마 맞습니다. 방금 생성자 코드를 pd.Series간략히 살펴 보았는데 반복 가능한 출력의 크기를 결정할 수없는 경우 어쨌든 반복 가능한 항목에서 목록이 생성되는 것으로 나타났습니다. 따라서 대부분의 경우 제너레이터를 사용하면 이득이 없습니다. 그러나 예제를 실행했을 때 생성기 버전이 실제로 느립니다. 차이점이 어디에서 오는지 잘 모르겠습니다.
GZ0

1

간단히 말해서

  • for 루프 + iterrows는 매우 느립니다. 오버 헤드는 ~ 1,000 개 행에서 중요하지 않지만 10,000 개 이상의 행에서 두드러집니다.
  • for 루프 + itertuplesiterrows또는 보다 훨씬 빠릅니다 apply.
  • 벡터화는 일반적으로 itertuples

기준 여기에 이미지 설명 입력

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.