Vòng lặp for ở gấu trúc có thực sự tồi tệ không? Khi nào tôi nên quan tâm?


105

Các forvòng lặp có thực sự "xấu"? Nếu không, trong (những) tình huống nào họ sẽ tốt hơn là sử dụng cách tiếp cận "vectơ hóa" thông thường hơn? 1

Tôi đã quen với khái niệm "vectơ hóa" và cách gấu trúc sử dụng các kỹ thuật vectơ hóa để tăng tốc độ tính toán. Các chức năng được vector hóa truyền phát các hoạt động trên toàn bộ chuỗi hoặc DataFrame để đạt được tốc độ lớn hơn nhiều so với việc lặp lại dữ liệu thông thường.

Tuy nhiên, tôi khá ngạc nhiên khi thấy rất nhiều mã (bao gồm từ các câu trả lời trên Stack Overflow) cung cấp giải pháp cho các vấn đề liên quan đến việc lặp qua dữ liệu bằng cách sử dụng forvòng lặp và danh sách hiểu. Tài liệu và API nói rằng các vòng lặp là "không hợp lệ" và vòng lặp "không bao giờ" lặp lại trên các mảng, chuỗi hoặc DataFrame. Vì vậy, sao đôi khi tôi thấy người dùng đề xuất các giải pháp dựa trên vòng lặp?


1 - Mặc dù đúng là câu hỏi nghe có vẻ hơi rộng, nhưng sự thật là có những tình huống rất cụ thể khi forcác vòng lặp thường tốt hơn so với việc lặp thông thường trên dữ liệu. Bài đăng này nhằm ghi lại điều này cho hậu thế.

Câu trả lời:


142

TLDR; Không, forvòng lặp không phải là "xấu", ít nhất, không phải lúc nào cũng vậy. Có lẽ chính xác hơn khi nói rằng một số hoạt động được vector hóa chậm hơn so với lặp lại , so với việc nói rằng lặp lại nhanh hơn một số hoạt động được vector hóa. Biết khi nào và tại sao là chìa khóa để đạt được hiệu suất cao nhất từ ​​mã của bạn. Tóm lại, đây là những tình huống đáng xem xét một giải pháp thay thế cho các chức năng của gấu trúc vectơ:

  1. Khi dữ liệu của bạn nhỏ (... tùy thuộc vào việc bạn đang làm),
  2. Khi xử lý object/ hỗn hợp các loại
  3. Khi sử dụng các strchức năng của trình truy cập / regex

Hãy xem xét những tình huống này riêng lẻ.


Lặp lại v / s Vectơ hóa trên dữ liệu nhỏ

Pandas tuân theo cách tiếp cận "Quy ước qua cấu hình" trong thiết kế API của nó. Điều này có nghĩa là cùng một API đã được trang bị để phục vụ cho nhiều loại dữ liệu và trường hợp sử dụng.

Khi một hàm pandas được gọi, những điều sau (trong số những thứ khác) phải được hàm xử lý nội bộ, để đảm bảo hoạt động

  1. Căn chỉnh chỉ mục / trục
  2. Xử lý các kiểu dữ liệu hỗn hợp
  3. Xử lý dữ liệu bị thiếu

Hầu hết mọi chức năng sẽ phải xử lý những điều này ở các mức độ khác nhau và điều này thể hiện một khoản chi phí . Chi phí ít hơn cho các hàm số (ví dụ, Series.add), trong khi nó rõ ràng hơn cho các hàm chuỗi (ví dụ, Series.str.replace).

forMặt khác, các vòng lặp nhanh hơn bạn nghĩ. Điều tốt hơn nữa là khả năng hiểu danh sách (tạo danh sách thông qua forcác vòng lặp) thậm chí còn nhanh hơn vì chúng là cơ chế lặp lại được tối ưu hóa để tạo danh sách.

Danh sách hiểu theo mẫu

[f(x) for x in seq]

Trong trường hợp seqlà một loạt gấu trúc hoặc cột DataFrame. Hoặc, khi thao tác trên nhiều cột,

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

Cột ở đâu seq1và ở đâu seq2.

So sánh số
Hãy xem xét một thao tác lập chỉ mục boolean đơn giản. Phương thức hiểu danh sách đã được tính thời gian đối với Series.ne( !=) và query. Đây là các chức năng:

# 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

Để đơn giản, tôi đã sử dụng perfplotgói để chạy tất cả các bài kiểm tra thời gian trong bài đăng này. Dưới đây là thời gian cho các hoạt động trên:

nhập mô tả hình ảnh ở đây

Khả năng hiểu danh sách hoạt động tốt hơn queryđối với N có kích thước vừa phải, và thậm chí còn tốt hơn so với so sánh không bằng vectơ đối với N. Thật không may, khả năng hiểu danh sách quy mô tuyến tính, vì vậy nó không mang lại nhiều hiệu suất cho N lớn hơn.

Lưu ý
Điều đáng nói là phần lớn lợi ích của việc hiểu danh sách đến từ việc không phải lo lắng về căn chỉnh chỉ mục, nhưng điều này có nghĩa là nếu mã của bạn phụ thuộc vào căn chỉnh lập chỉ mục, điều này sẽ bị hỏng. Trong một số trường hợp, các hoạt động vectorised trên các mảng NumPy cơ bản có thể được coi là mang lại "điều tốt nhất của cả hai thế giới", cho phép vectorisation mà không cần tất cả các chức năng không cần thiết của các hàm gấu trúc. Điều này có nghĩa là bạn có thể viết lại thao tác trên dưới dạng

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

Điều nào vượt trội hơn cả gấu trúc và tương đương về khả năng hiểu danh sách:

Vectơ hóa NumPy nằm ngoài phạm vi của bài đăng này, nhưng nó chắc chắn đáng xem xét, nếu hiệu suất quan trọng.

Đếm giá trị
Lấy một ví dụ khác - lần này, với một cấu trúc python vani khác nhanh hơn vòng lặp for - collections.Counter. Một yêu cầu phổ biến là tính toán số lượng giá trị và trả về kết quả dưới dạng từ điển. Này được thực hiện với value_counts, np.uniqueCounter:

# 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

nhập mô tả hình ảnh ở đây

Kết quả rõ ràng hơn, Countervượt trội hơn cả hai phương pháp vector hóa cho phạm vi lớn hơn của N nhỏ (~ 3500).

Lưu ý
Câu đố khác (nhã nhặn @ user2357112). Các Counterđược thực hiện với một tốc C , vì vậy trong khi nó vẫn phải làm việc với các đối tượng python thay cho kiểu dữ liệu C cơ bản, nó vẫn là nhanh hơn so với một forvòng lặp. Sức mạnh Python!

Tất nhiên, điều cần lưu ý ở đây là hiệu suất phụ thuộc vào dữ liệu và trường hợp sử dụng của bạn. Mục đích của những ví dụ này là thuyết phục bạn không loại trừ những giải pháp này là những lựa chọn hợp pháp. Nếu những điều này vẫn không mang lại cho bạn hiệu suất bạn cần, luôn có cythonnumba . Hãy thêm bài kiểm tra này vào hỗn hợp.

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

nhập mô tả hình ảnh ở đây

Numba cung cấp biên dịch JIT của mã python lặp lại thành mã được vector hóa rất mạnh mẽ. Hiểu cách làm cho numba hoạt động liên quan đến một đường cong học tập.


Hoạt động với hỗn hợp / objectloại

So sánh dựa trên chuỗi
Xem lại ví dụ lọc từ phần đầu tiên, điều gì sẽ xảy ra nếu các cột được so sánh là chuỗi? Hãy xem xét 3 hàm tương tự ở trên, nhưng với DataFrame đầu vào được ép kiểu thành chuỗi.

# 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

nhập mô tả hình ảnh ở đây

Vì vậy, những gì đã thay đổi? Điều cần lưu ý ở đây là các hoạt động chuỗi vốn đã rất khó để vectơ hóa. Pandas coi các chuỗi như các đối tượng và tất cả các hoạt động trên các đối tượng trở về trạng thái triển khai chậm, lặp lại.

Bây giờ, bởi vì việc triển khai lặp lại này được bao quanh bởi tất cả chi phí được đề cập ở trên, có sự khác biệt về độ lớn không đổi giữa các giải pháp này, mặc dù chúng có quy mô như nhau.

Khi nói đến các hoạt động trên các đối tượng có thể thay đổi / phức tạp, không có sự so sánh. Khả năng hiểu danh sách vượt trội hơn tất cả các hoạt động liên quan đến số và danh sách.

Truy cập (các) Giá trị Từ điển bằng Khóa
Đây là thời gian cho hai thao tác trích xuất một giá trị từ một cột từ điển: mapvà đọc hiểu danh sách. Thiết lập nằm trong Phụ lục, dưới tiêu đề "Đoạn mã".

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

nhập mô tả hình ảnh ở đây

Vị trí Danh sách Indexing
Thời gian cho 3 hoạt động mà trích xuất các yếu tố 0 từ một danh sách các cột (xử lý ngoại lệ), map, str.getaccessor phương pháp , và hiểu danh sách:

# 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

Lưu ý
Nếu chỉ mục quan trọng, bạn sẽ muốn thực hiện:

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

Khi dựng lại bộ truyện.

nhập mô tả hình ảnh ở đây

Làm phẳng danh sách
Một ví dụ cuối cùng là làm phẳng danh sách. Đây là một vấn đề phổ biến khác và chứng minh rằng python thuần túy mạnh đến mức nào ở đây.

# 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

nhập mô tả hình ảnh ở đây

Cả hai itertools.chain.from_iterablevà khả năng hiểu danh sách lồng nhau đều là cấu trúc python thuần túy và quy mô tốt hơn nhiều so với stackgiải pháp.

Các mốc thời gian này là một dấu hiệu mạnh mẽ cho thấy rằng gấu trúc không được trang bị để làm việc với các loại hỗn hợp và có lẽ bạn nên hạn chế sử dụng nó để làm như vậy. Bất cứ khi nào có thể, dữ liệu phải được hiển thị dưới dạng giá trị vô hướng (ints / float / string) trong các cột riêng biệt.

Cuối cùng, khả năng áp dụng của các giải pháp này phụ thuộc nhiều vào dữ liệu của bạn. Vì vậy, điều tốt nhất nên làm là kiểm tra các hoạt động này trên dữ liệu của bạn trước khi quyết định điều gì sẽ xảy ra. Lưu ý rằng tôi đã không tính thời gian applycho các giải pháp này, vì nó sẽ làm sai lệch đồ thị (vâng, nó chậm như vậy).


Thao tác Regex và .strPhương thức truy cập

Pandas có thể áp dụng các hoạt động regex như str.contains, str.extractstr.extractall, cũng như các hoạt động chuỗi " str.splitvectơ hóa" khác (chẳng hạn như , str.find ,str.translate`, v.v.) trên các cột chuỗi. Các chức năng này chậm hơn so với khả năng hiểu danh sách và có nghĩa là các chức năng tiện lợi hơn bất kỳ chức năng nào khác.

Việc biên dịch trước một mẫu regex và lặp qua dữ liệu của bạn với nó thường nhanh hơn nhiều re.compile(xem thêm Liệu có đáng sử dụng biên dịch lại của Python không? ). Danh sách comp tương đương str.containstrông giống như sau:

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

Hoặc là,

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

Nếu bạn cần xử lý NaN, bạn có thể làm điều gì đó như

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

Danh sách comp tương đương với str.extract(không có nhóm) sẽ giống như sau:

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

Nếu bạn cần xử lý không khớp và NaN, bạn có thể sử dụng chức năng tùy chỉnh (vẫn nhanh hơn!):

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']]

Các matcherchức năng là rất mở rộng. Nó có thể được trang bị để trả về danh sách cho mỗi nhóm chụp, nếu cần. Chỉ cần trích xuất truy vấn grouphoặc groupsthuộc tính của đối tượng so khớp.

Đối với str.extractall, thay đổi p.searchthành p.findall.

Trích xuất chuỗi
Hãy xem xét một hoạt động lọc đơn giản. Ý tưởng là trích xuất 4 chữ số nếu nó đứng trước một chữ cái viết hoa.

# 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

nhập mô tả hình ảnh ở đây

Các ví dụ khác
Tiết lộ đầy đủ - Tôi là tác giả (một phần hoặc toàn bộ) của những bài đăng này được liệt kê bên dưới.


Phần kết luận

Như được hiển thị từ các ví dụ trên, lặp lại tỏa sáng khi làm việc với các hàng nhỏ của DataFrames, kiểu dữ liệu hỗn hợp và biểu thức chính quy.

Việc tăng tốc bạn nhận được tùy thuộc vào dữ liệu và vấn đề của bạn, vì vậy quãng đường của bạn có thể khác nhau. Điều tốt nhất cần làm là cẩn thận chạy các bài kiểm tra và xem liệu khoản thanh toán có xứng đáng với nỗ lực không.

Các chức năng "vectơ hóa" thể hiện sự đơn giản và dễ đọc của chúng, vì vậy nếu hiệu suất không quan trọng, bạn chắc chắn nên thích những chức năng đó.

Một lưu ý phụ khác, các hoạt động chuỗi nhất định giải quyết các ràng buộc có lợi cho việc sử dụng NumPy. Dưới đây là hai ví dụ trong đó vectơ hóa NumPy cẩn thận tốt hơn python:

Ngoài ra, đôi khi chỉ hoạt động trên các mảng bên dưới .valueschứ không phải trên Series hoặc DataFrames có thể cung cấp tốc độ đủ mạnh cho hầu hết các trường hợp thông thường (xem Lưu ý trong phần So sánh số ở trên). Vì vậy, ví dụ df[df.A.values != df.B.values]sẽ hiển thị hiệu suất tăng nhanh hơn df[df.A != df.B]. Việc sử dụng .valuescó thể không phù hợp trong mọi tình huống, nhưng đó là một thủ thuật hữu ích cần biết.

Như đã đề cập ở trên, bạn quyết định xem liệu những giải pháp này có đáng để bạn gặp khó khăn khi thực hiện hay không.


Phụ lục: Đoạn mã

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.Seriespd.DataFramehiện hỗ trợ xây dựng từ các mục lặp. Điều đó có nghĩa là người ta có thể chỉ cần chuyển một trình tạo Python cho các hàm khởi tạo thay vì cần phải xây dựng một danh sách trước (sử dụng khả năng hiểu danh sách), điều này có thể chậm hơn trong nhiều trường hợp. Tuy nhiên, kích thước của đầu ra máy phát điện không thể được xác định trước. Tôi không chắc điều đó sẽ gây ra bao nhiêu thời gian / bộ nhớ.
GZ0

@ GZ0 IIRC, chấp nhận trình vòng lặp là một bổ sung gần đây hơn cho API. Đối với "điều đó có nghĩa là người ta có thể chỉ cần chuyển một trình tạo Python cho các hàm khởi tạo hơn là cần phải xây dựng một danh sách trước", tôi không đồng ý. Bộ nhớ có thể, hiệu suất không. Theo kinh nghiệm của tôi, hầu như luôn luôn nhanh hơn để tạo danh sách và chuyển nó. Danh sách bao gồm FTW.
cs95

@ cs95 Tại sao bạn không nghĩ rằng máy phát điện có thể dẫn đến tăng hiệu suất? Hay bạn đã thực hiện bất kỳ bài kiểm tra nào về điều đó chưa?
GZ0

@ GZ0 Tôi không nói máy phát điện không dẫn đến tăng hiệu suất, tôi đang nói rằng bạn không đạt được nhiều như bạn sẽ sử dụng tính năng hiểu danh sách. Có các chi phí liên quan đến việc khởi tạo genexps, duy trì trạng thái, v.v., mà danh sách comps không có. Có rất nhiều ví dụ ở đây với khả năng hiểu danh sách mà bạn có thể dựa vào mức độ hiểu của trình tạo để so sánh. Đây là một ví dụ bạn có thể chạy ngay bây giờ 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 Có lẽ bạn đúng. Tôi vừa mới xem nhanh mã phương thức khởi tạo của pd.Seriesvà hóa ra là nếu không xác định được kích thước của đầu ra có thể lặp lại thì dù sao thì danh sách cũng sẽ được xây dựng từ có thể lặp lại. Vì vậy, trong hầu hết các trường hợp sẽ không có lợi khi sử dụng máy phát điện. Tuy nhiên, khi tôi chạy các ví dụ bạn đưa ra, phiên bản trình tạo thực sự chậm hơn. Tôi không chắc sự khác biệt đến từ đâu.
GZ0

1

Nói ngắn gọn

  • vòng lặp for + iterrowscực kỳ chậm. Chi phí không đáng kể trên ~ 1 nghìn hàng, nhưng đáng chú ý trên hơn 10 nghìn hàng.
  • vòng lặp for + itertuplesnhanh hơn nhiều so với iterrowshoặc apply.
  • vector hóa thường nhanh hơn nhiều so với itertuples

Điểm chuẩn nhập mô tả hình ảnh ở đây

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.