Способы повышения производительности в пакете Pandas

Pandas — самый известный инструмент анализа данных в Python. Все используют Pandas при работе с наборами данных. Это быстрый, мощный и сверхгибкий инструмент. По мере увеличения размера фреймов данных некоторые способы выполнения одних операций занимают больше времени, чем другие. Поэтому важно знать и использовать более быстрые методы, особенно в больших наборах данных. Официальная страница здесь.

Генерация данных

Для этого исследования я создал фиктивный набор данных с нуля.

faker – это пакет Python, который генерирует поддельные данные. Посмотреть документацию можно здесь.

import random
from faker import Faker

fake = Faker()

car_brands = ["Audi","Bmw","Jaguar","Fiat","Mercedes","Nissan","Porsche","Toyota", None]
tv_brands = ["Beko", "Lg", "Panasonic", "Samsung", "Sony"]

def generate_record():
    """ generates a fake row
    """
    cid = fake.bothify(text='CID-###')
    name = fake.name()
    age=fake.random_number(digits=2) 
    city = fake.city()
    plate = fake.license_plate()
    job = fake.job()
    company = fake.company()
    employed = fake.boolean(chance_of_getting_true=75)
    social_security = fake.boolean(chance_of_getting_true=90)
    healthcare = fake.boolean(chance_of_getting_true=95)
    iban = fake.iban()
    salary = fake.random_int(min=0, max=99999)
    car = random.choice(car_brands)
    tv = random.choice(tv_brands)
    record = [cid, name, age, city, plate, job, company, employed, 
              social_security, healthcare, iban, salary, car, tv]
    return record
    
    
record = generate_record()
print(record)

"""
['CID-753', 'Kristy Terry', 5877566, 'North Jessicaborough', '988 XEE', 
'Engineer, control and instrumentation', 'Braun, Robinson and Shaw', 
True, True, True, 'GB57VOOS96765461230455', 27109, 'Bmw', 'Beko']
"""

Я создал фрейм данных с 1 миллионом строк, используя параллелизм.

import os
import pandas as pd
from multiprocessing import Pool

N= 1_000_000

if __name__ == '__main__':
    cpus = os.cpu_count()
    pool = Pool(cpus-1)
    async_results = []
    for _ in range(N):
        async_results.append(pool.apply_async(generate_record))
    pool.close()
    pool.join()
    data = []
    for i, async_result in enumerate(async_results):
        data.append(async_result.get())
    df = pd.DataFrame(data=data, columns=["CID", "Name", "Age", "City", "Plate", "Job", "Company",
                                     "Employed", "Social_Security", "Healthcare", "Iban", 
                                     "Salary", "Car", "Tv"])

IO

Мы можем сохранить фрейм данных в разных форматах, используя Pandas. Сравним скорости этих форматов.

#Write

%timeit df.to_csv("df.csv")
#3.77 s ± 339 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df.to_pickle("df.pickle")
#948 ms ± 13.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df.to_parquet("df")
#2.77 s ± 13 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df.to_feather("df.feather")
#368 ms ± 19.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def write_table(df):
    dtf = dt.Frame(df)
    dtf.to_csv("df_.csv")

%timeit write_table(df)
#559 ms ± 10.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

#Read

%timeit df=pd.read_csv("df.csv")
#1.89 s ± 22.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df=pd.read_pickle("df.pickle")
#402 ms ± 6.96 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df=pd.read_parquet("df")
#480 ms ± 3.62 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

%timeit df=pd.read_feather("df.feather")
#754 ms ± 8.31 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def read_table():
    dtf = dt.fread("df.csv")
    df = dtf.to_pandas()
    return df

%timeit df = read_table()
#869 ms ± 29.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Как видите, формат CSV — самый медленный. Я даже не включил формат Excel (read_excel) в это сравнение, потому что это привело бы к гораздо более медленным результатам.

В операциях, выполняемых с CSV, сначала преобразование фрейма данных pandas в объект datatable с использованием библиотеки Datatable и выполнение операций чтения и записи для этого объекта даст гораздо более быстрые результаты.

Обычно я предпочитаю формат pickle.

Типы данных

В больших наборах данных мы можем оптимизировать использование памяти путем приведения типов данных.

Например, мы можем понизить тип данных с int64 до int8, если это уместно, проверив максимальное и минимальное значения числового признака. Таким образом, он занимает в 8 раз меньше памяти.

df.info()

"""
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000000 entries, 0 to 999999
Data columns (total 14 columns):
 #   Column           Non-Null Count    Dtype 
---  ------           --------------    ----- 
 0   CID              1000000 non-null  object
 1   Name             1000000 non-null  object
 2   Age              1000000 non-null  int64 
 3   City             1000000 non-null  object
 4   Plate            1000000 non-null  object
 5   Job              1000000 non-null  object
 6   Company          1000000 non-null  object
 7   Employed         1000000 non-null  bool  
 8   Social_Security  1000000 non-null  bool  
 9   Healthcare       1000000 non-null  bool  
 10  Iban             1000000 non-null  object
 11  Salary           1000000 non-null  int64 
 12  Car              888554 non-null   object
 13  Tv               1000000 non-null  object
dtypes: bool(3), int64(2), object(9)
memory usage: 86.8+ MB
"""

Поскольку возрастной признак находится в диапазоне от 0 до 99, мы можем преобразовать его тип данных в int8.

#int

df["Age"].memory_usage(index=False, deep=False)
#8000000

#convert
df["Age"] = df["Age"].astype('int8')

df["Age"].memory_usage(index=False, deep=False)
#1000000

#float

df["Salary_After_Tax"] = df["Salary"] * 0.6
df["Salary_After_Tax"].memory_usage(index=False, deep=False)
#8000000
df["Salary_After_Tax"] = df["Salary_After_Tax"].astype('float16')
df["Salary_After_Tax"].memory_usage(index=False, deep=False)
#2000000

#categorical
df["Car"].memory_usage(index=False, deep=False)
#8000000

df["Car"] = df["Car"].astype('category')

df["Car"].memory_usage(index=False, deep=False)
#1000364

Мы также можем определить типы данных, которые будут использоваться при чтении файла.

dtypes = {
    'CID' : 'int32',
    'Name' : 'object',
    'Age' : 'int8',
    ...
}
dates=["Date Columns Here"]

df = pd.read_csv(dtype=dtypes, parse_dates=dates)

Фильтрация

Обычный метод фильтрации:

%timeit df_filtered = df[df["Car"] == "Mercedes"]
#61.8 ms ± 2.55 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Для категориальных функций мы можем использовать методы group_by и get_group панд.

%timeit df.groupby("Car").get_group("Mercedes")
#92.1 ms ± 4.38 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

df_grouped = df.groupby("Car")
%timeit df_grouped.get_group("Mercedes")
#14.8 ms ± 167 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

Как видите, процесс группировки занимает больше времени, чем обычное приложение. Однако предположим, что вы собираетесь выполнять множество фильтров по категориальным функциям, в этом случае, если мы выполним группировку с самого начала и измерим только время выполнения части get_group, мы увидим, что процесс на самом деле быстрее, чем обычный метод. То есть этот метод (groupby и get_group) может быть предпочтительным для повторяющихся операций фильтрации.

Считает

Метод value_counts быстрее, чем метод groupby и следующий за ним метод size.

%timeit df["Car"].value_counts()
#49.1 ms ± 378 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
"""
Toyota      111601
Porsche     111504
Jaguar      111313
Fiat        111239
Nissan      110960
Bmw         110906
Audi        110642
Mercedes    110389
Name: Car, dtype: int64
"""

%timeit df.groupby("Car").size()
#64.5 ms ± 37.9 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
"""
Car
Audi        110642
Bmw         110906
Fiat        111239
Jaguar      111313
Mercedes    110389
Nissan      110960
Porsche     111504
Toyota      111601
dtype: int64
"""

Итерация

Итерации занимают много времени на больших объемах данных. Поэтому необходимо выбрать самые быстрые методы в этом отношении. Возможно, вы захотите проверить мой пост об итерациях и циклах for.



Мы можем использовать методы iterrows и itertuples Pandas и давайте сравним их с обычной реализацией цикла for.

def foo_loop(df):
    total = 0
    for i in range(len(df)):
        total += df.iloc[i]['Salary']
    return total
  
%timeit foo_loop(df)
#34.6 s ± 593 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def foo_iterrows(df):
    total = 0
    for index, row in df.iterrows():
        total += row['Salary']
    return total

%timeit foo_iterrows(df)
#22.7 s ± 761 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def foo_itertuples(df):
    total = 0
    for row in df.itertuples(): 
        total += row[12]
    return total

%timeit foo_itertuples(df)
#1.22 s ± 14.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Метод iterrows быстрее, чем простой цикл, но метод itertuplesпревосходит все .

Метод apply позволяет нам выполнять любую функцию над рядом в фрейме данных.

def foo(val):
    if val > 50000:
        return "High"
    elif val <= 50000 and val > 10000:
        return "Mid Level"
    else:
        return "Low"
    
df["Salary_Category"] = df["Salary"].apply(foo)
print(df["Salary_Category"])
"""
0              High
1              High
2         Mid Level
3              High
4               Low
            ...    
999995         High
999996          Low
999997         High
999998         High
999999    Mid Level
Name: Salary_Category, Length: 1000000, dtype: object
"""

%timeit df["Salary_Category"] = df["Salary"].apply(foo)
#112 ms ± 50.6 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

def boo():
    liste = []
    for i in range(len(df)):
        val = foo(df.loc[i,"Salary"])
        liste.append(val)
    df["Salary_Category"] = liste
    
%timeit boo()
#5.73 s ± 130 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Метод map позволяет нам заменить каждое значение в серии в соответствии с заданной функцией.

print(df["Salary_Category"].map({'High': "H", "Mid Level": "M", "Low": "L"}))
"""
0         H
1         H
2         M
3         H
4         L
         ..
999995    H
999996    L
999997    H
999998    H
999999    M
Name: Salary_Category, Length: 1000000, dtype: object
"""

print(df["Salary_Category"].map("Salary Category is {}".format))
"""
0              Salary Category is High
1              Salary Category is High
2         Salary Category is Mid Level
3              Salary Category is High
4               Salary Category is Low
                      ...             
999995         Salary Category is High
999996          Salary Category is Low
999997         Salary Category is High
999998         Salary Category is High
999999    Salary Category is Mid Level
Name: Salary_Category, Length: 1000000, dtype: object
"""

df["Salary_Category"] = df["Salary"].map(foo)
print(df["Salary_Category"])
"""
0              High
1              High
2         Mid Level
3              High
4               Low
            ...    
999995         High
999996          Low
999997         High
999998         High
999999    Mid Level
Name: Salary_Category, Length: 1000000, dtype: object
"""

Давайте сравним каждый метод нормализации столбца зарплаты.

min_salary = df["Salary"].min()
max_salary = df["Salary"].max()

def normalize_for_loc(df, min_salary, max_salary):
    normalized_salary = np.zeros(len(df, ))
    for i in range(df.shape[0]):
        normalized_salary[i] = (df.loc[i, "Salary"] - min_salary) / (max_salary - min_salary)
    df["Normalized_Salary"] = normalized_salary
    return df

%timeit normalize_for_loc(df, min_salary, max_salary)
#5.45 s ± 15.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def normalize_for_iloc(df, min_salary, max_salary):
    normalized_salary = np.zeros(len(df, ))
    for i in range(df.shape[0]):
        normalized_salary[i] = (df.iloc[i, 11] - min_salary) / (max_salary - min_salary)
    df["Normalized_Salary"] = normalized_salary
    return df

%timeit normalize_for_iloc(df, min_salary, max_salary)
#13.8 s ± 29.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def normalize_for_iloc(df, min_salary, max_salary):
    normalized_salary = np.zeros(len(df, ))
    for i in range(df.shape[0]):
        normalized_salary[i] = (df.iloc[i]["Salary"] - min_salary) / (max_salary - min_salary)
    df["Normalized_Salary"] = normalized_salary
    return df

%timeit normalize_for_iloc(df, min_salary, max_salary)
#34.8 s ± 108 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def normalize_for_iterrows(df, min_salary, max_salary):
    normalized_salary = np.zeros(len(df, ))
    i = 0
    for index, row in df.iterrows():
        normalized_salary[i] = (row["Salary"] - min_salary) / (max_salary - min_salary)
        i += 1
    df["Normalized_Salary"] = normalized_salary
    return df

%timeit normalize_for_iterrows(df, min_salary, max_salary)
#21.7 s ± 53.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def normalize_for_itertuples(df, min_salary, max_salary):
    normalized_salary = list()
    for row in df.itertuples():
        normalized_salary.append((row[12] - min_salary) / (max_salary - min_salary))
    df["Normalized_Salary"] = normalized_salary
    return df

%timeit normalize_for_itertuples(df, min_salary, max_salary)
#1.34 s ± 4.29 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

def normalize_map(df, min_salary, max_salary):
    df["Normalized_Salary"] = df["Salary"].map(lambda x: (x - min_salary) / (max_salary - min_salary))
    return df

%timeit normalize_map(df, min_salary, max_salary)
#178 ms ± 970 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

def normalize_apply(df, min_salary, max_salary):
    df["Normalized_Salary"] = df["Salary"].apply(lambda x: (x - min_salary) / (max_salary - min_salary))
    return df
%timeit normalize_apply(df, min_salary, max_salary)
#182 ms ± 1.83 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

def normalize_vectorization(df, min_salary, max_salary):
    df["Normalized_Salary"] = (df["Salary"] - min_salary) / (max_salary - min_salary)
    return df

%timeit normalize_vectorization(df, min_salary, max_salary)
#1.58 ms ± 7.87 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Итак, как вы можете сделать вывод из измерений;

  • loc работает быстрее, чем iloc.
  • Если вы будете использовать iloc, лучше использовать его в формате df.iloc[i, 11].
  • itertuples лучше, чем loc, а iterrows не предлагает ничего лучше.
  • map и apply – это второй по скорости выбор.
  • Как и ожидалось, векторизация самая быстрая.

Векторизация

Определите векторизованную функцию, которая принимает вложенную последовательность объектов или массивов numpy в качестве входных данных и возвращает один массив numpy или кортеж массивов numpy. ["источник"]

def foo(val, min_salary, max_salary):
    return (val - min_salary) / (max_salary - min_salary)

foo_vectorized = np.vectorize(foo)
%timeit df["Normalized_Salary"] = foo_vectorized(df["Salary"], min_salary, max_salary)
#154 ms ± 310 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)


#conditional
%timeit df["Old"] = (df["Age"] > 80)
#140 µs ± 11.8 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

#isin
%timeit df["Old"] = df["Age"].isin(range(80,100))
#17.4 ms ± 466 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

#bins with digitize
%timeit df["Age_Bins"] = np.digitize(df["Age"].values, bins=[0, 18, 36, 54, 72, 100])
#12 ms ± 107 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
print(df["Age_Bins"])
"""
0         3
1         5
2         4
3         3
4         5
         ..
999995    4
999996    2
999997    3
999998    1
999999    1
Name: Age_Bins, Length: 1000000, dtype: int64
"""

Индексация

Использование метода .at быстрее, чем использование метода .loc.

%timeit df.loc[987987, "Name"]
#5.05 µs ± 33.3 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

%timeit df.at[987987, "Name"]
#2.39 µs ± 23.3 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

Быстрее

Swifter — это пакет Python, который помогает применять любую функцию к фрейму данных pandas более эффективно, чем обычный метод apply.

ускорить установку pip

import swifter

#apply
%timeit df["Normalized_Salary"] = df["Salary"].apply(lambda x: (x - min_salary) / (max_salary - min_salary))
#192 ms ± 9.08 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

#swifter.apply
%timeit df["Normalized_Salary"] = df["Salary"].swifter.apply(lambda x: (x - min_salary) / (max_salary - min_salary))
#83.5 ms ± 478 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Заключение

Если можно использовать векторизацию, любые операции с фреймами данных должны выполняться с ее помощью. Помимо этого, предпочтительны такие методы, как itertuples, apply или map. Помимо всего этого, существуют отдельные пакеты Python, такие как dask, vaex, koalas и т. д., которые построены поверх панд или выполняют аналогичные функции. Возможно, будет полезно взглянуть и на них. Я расскажу об этих других пакетах в моих следующих сообщениях.

Спасибо за прочтение.

Читать далее















Источники

https://pandas.pydata.org/

https://faker.readthedocs.io/ru/master/

https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.iterrows.html

https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.itertuples.html

https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.apply.html

https://pandas.pydata.org/docs/reference/api/pandas.Series.map.html

https://numpy.org/doc/stable/reference/generated/numpy.vectorize.html

https://github.com/jmcarpenter2/swifter

https://pbpython.com/pandas_dtypes.html



Больше контента на PlainEnglish.io.

Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter, LinkedIn, YouTube и Discord.

Хотите масштабировать свой запуск программного обеспечения? Посмотрите Цирк.