Apakah loop-for pada panda benar-benar buruk? Kapan saya harus peduli?

106

Apakah forloop benar-benar "buruk"? Jika tidak, dalam situasi apa mereka lebih baik daripada menggunakan pendekatan "vektorisasi" yang lebih konvensional? 1

Saya akrab dengan konsep "vektorisasi", dan bagaimana panda menggunakan teknik vektor untuk mempercepat komputasi. Fungsi vektorisasi menyiarkan operasi ke seluruh rangkaian atau DataFrame untuk mencapai percepatan yang jauh lebih besar daripada pengulangan data secara konvensional.

Namun, saya cukup terkejut melihat banyak kode (termasuk dari jawaban di Stack Overflow) yang menawarkan solusi untuk masalah yang melibatkan perulangan melalui data menggunakan forloop dan pemahaman daftar. Dokumentasi dan API mengatakan bahwa loop itu "buruk", dan loop itu "tidak pernah" harus mengulang-ulang array, seri, atau DataFrame. Jadi, kenapa terkadang saya melihat pengguna menyarankan solusi berbasis loop?


1 - Meskipun benar bahwa pertanyaannya terdengar agak luas, kenyataannya adalah bahwa ada situasi yang sangat spesifik ketika forloop biasanya lebih baik daripada mengulang data secara konvensional. Posting ini bertujuan untuk menangkap ini untuk anak cucu.

cs95
sumber

Jawaban:

144

TLDR; Tidak, forloop bukanlah selimut yang "buruk", setidaknya tidak selalu. Mungkin lebih akurat untuk mengatakan bahwa beberapa operasi vektorisasi lebih lambat daripada iterasi , dibandingkan dengan mengatakan bahwa iterasi lebih cepat daripada beberapa operasi vektor. Mengetahui kapan dan mengapa adalah kunci untuk mendapatkan performa maksimal dari kode Anda. Singkatnya, ini adalah situasi di mana perlu mempertimbangkan alternatif untuk fungsi panda vektor:

  1. Ketika data Anda kecil (... tergantung pada apa yang Anda lakukan),
  2. Saat berhadapan dengan object/ tipe campuran
  3. Saat menggunakan strfungsi pengakses / regex

Mari kita periksa situasi ini satu per satu.


Iterasi v / s Vektorisasi pada Data Kecil

Pandas mengikuti pendekatan "Konvensi Selama Konfigurasi" dalam desain API-nya. Artinya, API yang sama telah dipasang untuk melayani berbagai data dan kasus penggunaan.

Ketika fungsi pandas dipanggil, hal-hal berikut (antara lain) harus ditangani secara internal oleh fungsi tersebut, untuk memastikan berfungsi

  1. Perataan indeks / sumbu
  2. Menangani tipe data campuran
  3. Menangani data yang hilang

Hampir setiap fungsi harus menangani hal ini hingga tingkat yang berbeda-beda, dan ini menimbulkan biaya tambahan . Overhead lebih sedikit untuk fungsi numerik (misalnya, Series.add), sementara itu lebih diucapkan untuk fungsi string (misalnya, Series.str.replace).

forloop, di sisi lain, lebih cepat dari yang Anda pikirkan. Apa yang lebih baik adalah pemahaman daftar (yang membuat daftar melalui forloop) bahkan lebih cepat karena mekanisme iteratif dioptimalkan untuk pembuatan daftar.

Pemahaman daftar mengikuti polanya

[f(x) for x in seq]

Di mana seqderetan panda atau kolom DataFrame. Atau, saat mengoperasikan beberapa kolom,

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

Dimana seq1dan seq2merupakan kolom.

Perbandingan Numerik
Pertimbangkan operasi pengindeksan boolean sederhana. Metode pemahaman daftar telah disesuaikan waktunya dengan Series.ne( !=) dan query. Berikut fungsinya:

# 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

Untuk kesederhanaan, saya telah menggunakan perfplotpaket untuk menjalankan semua tes timeit di posting ini. Pengaturan waktu untuk operasi di atas adalah di bawah ini:

masukkan deskripsi gambar di sini

Pemahaman daftar berkinerja lebih baik queryuntuk N berukuran sedang, dan bahkan mengungguli vektorized tidak sama dengan perbandingan untuk N kecil Sayangnya, pemahaman daftar berskala linier, jadi tidak menawarkan banyak peningkatan kinerja untuk N. yang lebih besar.

Catatan
Perlu disebutkan bahwa sebagian besar manfaat pemahaman daftar berasal dari tidak perlu khawatir tentang penyelarasan indeks, tetapi ini berarti bahwa jika kode Anda bergantung pada penyelarasan indeks, ini akan rusak. Dalam beberapa kasus, operasi vektorisasi pada array NumPy yang mendasari dapat dianggap membawa "yang terbaik dari kedua dunia", memungkinkan untuk vektorisasi tanpa semua overhead yang tidak diperlukan dari fungsi panda. Ini berarti Anda dapat menulis ulang operasi di atas sebagai

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

Yang mengungguli kedua pandas dan pemahaman daftar yang setara:

Vektorisasi NumPy berada di luar cakupan posting ini, tetapi pasti layak dipertimbangkan, jika kinerja penting.

Nilai Hitungan
Mengambil contoh lain - kali ini, dengan konstruksi python vanilla lain yang lebih cepat daripada for loop - collections.Counter. Persyaratan umum adalah menghitung jumlah nilai dan mengembalikan hasilnya sebagai kamus. Hal ini dilakukan dengan value_counts, np.unique, danCounter :

# 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

masukkan deskripsi gambar di sini

Hasilnya lebih jelas, Countermenang atas kedua metode vektorisasi untuk rentang yang lebih besar dari N kecil (~ 3500).

Catatan
Lebih banyak hal sepele (courtesy @ user2357112). Ini Counterdiimplementasikan dengan a akselerator C , jadi meskipun masih harus bekerja dengan objek python alih-alih tipe data C yang mendasarinya, ini masih lebih cepat daripada forloop. Kekuatan Python!

Tentu saja, yang dapat diambil dari sini adalah bahwa kinerjanya bergantung pada data dan kasus penggunaan Anda. Inti dari contoh-contoh ini adalah untuk meyakinkan Anda agar tidak mengesampingkan solusi ini sebagai opsi yang sah. Jika ini masih tidak memberikan kinerja yang Anda butuhkan, selalu ada cython dan numba . Mari tambahkan tes ini ke dalam campuran.

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

masukkan deskripsi gambar di sini

Numba menawarkan kompilasi JIT kode python loopy menjadi kode vektorisasi yang sangat kuat. Memahami cara membuat numba berhasil melibatkan kurva pembelajaran.


Operasi dengan Campuran / objecttipe

Perbandingan Berbasis String
Melihat kembali contoh pemfilteran dari bagian pertama, bagaimana jika kolom yang dibandingkan adalah string? Pertimbangkan 3 fungsi yang sama di atas, tetapi dengan input DataFrame dilemparkan ke string.

# 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

masukkan deskripsi gambar di sini

Jadi, apa yang berubah? Hal yang perlu diperhatikan di sini adalah bahwa operasi string pada dasarnya sulit untuk melakukan vektorisasi. Pandas memperlakukan string sebagai objek, dan semua operasi pada objek kembali ke implementasi yang lambat dan gila-gilaan.

Sekarang, karena implementasi gila ini dikelilingi oleh semua overhead yang disebutkan di atas, ada perbedaan besaran yang konstan di antara solusi ini, meskipun skalanya sama.

Ketika datang ke operasi pada objek yang bisa berubah / kompleks, tidak ada perbandingan. Pemahaman daftar mengungguli semua operasi yang melibatkan dicts dan daftar.

Mengakses Nilai Kamus dengan Kunci
Berikut adalah pengaturan waktu untuk dua operasi yang mengekstrak nilai dari kolom kamus: mapdan pemahaman daftar. Setup ada di Appendix, di bawah judul "Code Snippets".

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

masukkan deskripsi gambar di sini

Posisi Daftar Indexing
Timing untuk 3 operasi yang mengekstrak elemen 0 dari daftar kolom (penanganan pengecualian), map, str.getaccessor metode , dan daftar pemahaman:

# 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

Catatan
Jika indeks penting, Anda ingin melakukan:

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

Saat merekonstruksi seri.

masukkan deskripsi gambar di sini

List Flattening
Contoh terakhir adalah meratakan daftar. Ini adalah masalah umum lainnya, dan menunjukkan betapa kuatnya python murni di sini.

# 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

masukkan deskripsi gambar di sini

Keduanya itertools.chain.from_iterabledan pemahaman daftar bersarang adalah konstruksi python murni, dan skalanya jauh lebih baik daripada stacksolusi.

Pengaturan waktu ini merupakan indikasi kuat dari fakta bahwa panda tidak dilengkapi peralatan untuk bekerja dengan tipe-tipe campuran, dan Anda mungkin sebaiknya menahan diri untuk tidak menggunakannya. Jika memungkinkan, data harus disajikan sebagai nilai skalar (ints / floats / string) di kolom terpisah.

Terakhir, penerapan solusi ini sangat bergantung pada data Anda. Jadi, hal terbaik yang harus dilakukan adalah menguji operasi ini pada data Anda sebelum memutuskan apa yang akan digunakan. Perhatikan bagaimana saya tidak applymenentukan waktu pada solusi ini, karena itu akan membuat grafik miring (ya, selambat itu).


Operasi Regex, dan .strMetode Accessor

Panda dapat menerapkan operasi regex seperti str.contains, str.extract, dan str.extractall, serta "Vectorized" operasi string lainnya (seperti str.split, str.find, str.translate`, dan sebagainya) pada kolom string. Fungsi-fungsi ini lebih lambat daripada pemahaman daftar, dan dimaksudkan sebagai fungsi yang lebih memudahkan daripada yang lainnya.

Biasanya jauh lebih cepat untuk melakukan pra-kompilasi pola regex dan melakukan iterasi pada data Anda dengan re.compile(juga lihat Apakah layak menggunakan kompilasi ulang Python? ). Daftar comp setara dengan str.containsterlihat seperti ini:

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

Atau,

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

Jika Anda perlu menangani NaN, Anda dapat melakukan sesuatu seperti

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

Daftar comp yang setara dengan str.extract(tanpa grup) akan terlihat seperti ini:

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

Jika Anda perlu menangani no-match dan NaN, Anda bisa menggunakan fungsi custom (masih lebih cepat!):

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

The matcherFungsi ini sangat extensible. Ini dapat dipasang untuk mengembalikan daftar untuk setiap grup tangkapan, sesuai kebutuhan. Cukup ekstrak kueri groupataugroups atribut dari objek matcher.

Untuk str.extractall, ganti p.searchke p.findall.

Ekstraksi String
Pertimbangkan operasi pemfilteran sederhana. Idenya adalah untuk mengekstrak 4 digit jika diawali dengan huruf besar.

# 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

masukkan deskripsi gambar di sini

Lebih Banyak Contoh
Pengungkapan penuh - Saya adalah penulis (sebagian atau keseluruhan) posting yang tercantum di bawah ini.


Kesimpulan

Seperti yang ditunjukkan dari contoh di atas, iterasi bersinar saat bekerja dengan baris kecil DataFrame, tipe data campuran, dan ekspresi reguler.

Percepatan yang Anda dapatkan bergantung pada data dan masalah Anda, jadi jarak tempuh Anda mungkin berbeda. Hal terbaik yang harus dilakukan adalah menjalankan pengujian dengan hati-hati dan melihat apakah pembayarannya sepadan.

Fungsi "vektorisasi" bersinar dalam kesederhanaan dan keterbacaannya, jadi jika kinerja tidak kritis, Anda harus lebih memilihnya.

Catatan samping lain, operasi string tertentu menangani kendala yang mendukung penggunaan NumPy. Berikut adalah dua contoh di mana vektorisasi NumPy yang cermat mengungguli python:

Selain itu, kadang-kadang hanya beroperasi pada array yang mendasari melalui .valuessebagai lawan pada Seri atau DataFrames dapat menawarkan percepatan yang cukup sehat untuk sebagian besar skenario (lihat Catatan di bagian Perbandingan Numerik di atas). Jadi, misalnya df[df.A.values != df.B.values]akan menunjukkan peningkatan kinerja instan df[df.A != df.B]. Menggunakan.values mungkin tidak selalu sesuai di setiap situasi, tetapi ini adalah peretasan yang berguna untuk diketahui.

Seperti disebutkan di atas, terserah Anda untuk memutuskan apakah solusi ini sepadan dengan penerapannya.


Lampiran: Cuplikan Kode

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
)
cs95
sumber
pd.Seriesdan pd.DataFramesekarang mendukung konstruksi dari iterable. Itu berarti seseorang dapat dengan mudah meneruskan generator Python ke fungsi konstruktor daripada perlu membuat daftar terlebih dahulu (menggunakan pemahaman daftar), yang bisa lebih lambat dalam banyak kasus. Namun besarnya keluaran genset tidak dapat ditentukan sebelumnya. Saya tidak yakin berapa banyak waktu / memori overhead yang akan terjadi.
GZ0
@ GZ0 IIRC, menerima iterator adalah tambahan yang lebih baru untuk API. Adapun "itu berarti seseorang dapat dengan mudah meneruskan generator Python ke fungsi konstruktor daripada perlu membuat daftar terlebih dahulu", saya tidak setuju. Memori mungkin, performa no. Menurut pengalaman saya, biasanya hampir selalu lebih cepat untuk menghasilkan daftar dan menyebarkannya. Daftar comps FTW.
cs95
@ cs95 Mengapa menurut Anda generator tidak dapat menghasilkan peningkatan kinerja? Atau apakah Anda pernah melakukan tes untuk itu?
GZ0
@ GZ0 Saya tidak mengatakan generator tidak mengarah pada peningkatan kinerja, saya katakan Anda tidak mendapatkan sebanyak yang Anda inginkan dengan menggunakan pemahaman daftar. Ada overhead yang terkait dengan menginisialisasi genexps, mempertahankan status, dll, yang tidak dimiliki daftar comps. Ada banyak contoh di sini dengan pemahaman daftar yang dapat Anda bandingkan dengan pemahaman generator. Berikut adalah contoh yang dapat Anda jalankan sekarang 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 Anda mungkin benar. Saya baru saja melihat sekilas kode konstruktor pd.Seriesdan ternyata jika ukuran output yang dapat diulang tidak dapat ditentukan, daftar akan dibuat dari iterable. Jadi dalam banyak kasus tidak akan ada keuntungan menggunakan generator. Namun ketika saya menjalankan contoh yang Anda berikan, versi generator sebenarnya lebih lambat. Saya tidak yakin dari mana perbedaan itu berasal.
GZ0
1

Pendeknya

  • for loop + iterrowssangat lambat. Overhead tidak signifikan pada ~ 1k baris, tetapi terlihat pada 10k + baris.
  • untuk loop + itertuplesjauh lebih cepat dari iterrowsatau apply.
  • vektorisasi biasanya jauh lebih cepat daripada itertuples

Tolok ukur masukkan deskripsi gambar di sini

artoby
sumber