Подтвердить что ты не робот

Повышение эффективности функции ранжирования путем замены лямбда x на векторизация

У меня есть функция ранжирования, которую я применяю к большому количеству столбцов из нескольких миллионов строк, для чего требуется несколько минут. Удалив всю логику, готовящую данные для применения метода .rank(, т.е. Делая это:

ranked = df[['period_id', 'sector_name'] + to_rank].groupby(['period_id', 'sector_name']).transform(lambda x: (x.rank(ascending = True) - 1)*100/len(x))        

Мне удалось довести это до нескольких секунд. Тем не менее, мне нужно сохранить свою логику и изо всех сил пытаюсь перестроить свой код: в конечном итоге самым большим узким местом является мое двойное использование лямбда x:, но, очевидно, другие аспекты замедляют работу (см. Ниже). Я предоставил образец кадра данных вместе с моими ранжирующими функциями ниже, т.е. MCVE. В целом, я думаю, что мои вопросы сводятся к следующему:

(i) Как можно заменить использование .apply(lambda x в коде быстрым, векторизованным эквивалентом? (ii) Как можно перебирать мультииндексированные, сгруппированные, кадры данных и применять функцию? в моем случае, каждой уникальной комбинации столбцов date_id и категории.
(iii) Что еще я могу сделать, чтобы ускорить мою рейтинговую логику? основные накладные расходы, по-видимому, находятся в .value_counts(). Это перекрывается с (i) выше; возможно, большую часть этой логики можно использовать для df, возможно, путем построения временных столбцов, прежде чем отправлять рейтинг. Аналогично, можно ли ранжировать субкадровый кадр за один вызов?
(iv) Зачем использовать pd.qcut(), а не df.rank()? последний cythonized и, кажется, имеет более гибкую обработку связей, но я не вижу сравнения между ними, и pd.qcut() представляется наиболее широко используемым.

Примеры входных данных следующие:

import pandas as pd
import numpy as np
import random

to_rank = ['var_1', 'var_2', 'var_3']
df = pd.DataFrame({'var_1' : np.random.randn(1000), 'var_2' : np.random.randn(1000), 'var_3' : np.random.randn(1000)})
df['date_id'] = np.random.choice(range(2001, 2012), df.shape[0])
df['category'] = ','.join(chr(random.randrange(97, 97 + 4 + 1)).upper() for x in range(1,df.shape[0]+1)).split(',')

Две функции ранжирования:

def rank_fun(df, to_rank): # calls ranking function f(x) to rank each category at each date
    #extra data tidying logic here beyond scope of question - can remove
    ranked = df[to_rank].apply(lambda x: f(x))
    return ranked


def f(x):
    nans = x[np.isnan(x)] # Remove nans as these will be ranked with 50
    sub_df = x.dropna() # 
    nans_ranked = nans.replace(np.nan, 50) # give nans rank of 50

    if len(sub_df.index) == 0: #check not all nan.  If no non-nan data, then return with rank 50
        return nans_ranked

    if len(sub_df.unique()) == 1: # if all data has same value, return rank 50
        sub_df[:] = 50
        return sub_df

    #Check that we don't have too many clustered values, such that we can't bin due to overlap of ties, and reduce bin size provided we can at least quintile rank.
    max_cluster = sub_df.value_counts().iloc[0] #value_counts sorts by counts, so first element will contain the max
    max_bins = len(sub_df) / max_cluster 

    if max_bins > 100: #if largest cluster <1% of available data, then we can percentile_rank
        max_bins = 100

    if max_bins < 5: #if we don't have the resolution to quintile rank then assume no data.
        sub_df[:] = 50
        return sub_df

    bins = int(max_bins) # bin using highest resolution that the data supports, subject to constraints above (max 100 bins, min 5 bins)

    sub_df_ranked = pd.qcut(sub_df, bins, labels=False) #currently using pd.qcut.  pd.rank( seems to have extra functionality, but overheads similar in practice
    sub_df_ranked *= (100 / bins) #Since we bin using the resolution specified in bins, to convert back to decile rank, we have to multiply by 100/bins.  E.g. with quintiles, we'll have scores 1 - 5, so have to multiply by 100 / 5 = 20 to convert to percentile ranking
    ranked_df = pd.concat([sub_df_ranked, nans_ranked])
    return ranked_df

И код для вызова моей функции ранжирования и рекомбинации с df:

# ensure don't get duplicate columns if ranking already executed
ranked_cols = [col + '_ranked' for col in to_rank]

ranked = df[['date_id', 'category'] + to_rank].groupby(['date_id', 'category'], as_index = False).apply(lambda x: rank_fun(x, to_rank)) 
ranked.columns = ranked_cols        
ranked.reset_index(inplace = True)
ranked.set_index('level_1', inplace = True)    
df = df.join(ranked[ranked_cols])

Я пытаюсь получить эту ранжирующую логику так быстро, как могу, удалив оба лямбда-х-вызовов; Я могу удалить логику в rank_fun, так что применима только логика f (x), но я также не знаю, как обрабатывать мультииндексные кадры данных в векторном виде. Дополнительный вопрос будет касаться различий между pd.qcut( и df.rank(: кажется, что у обоих есть разные способы борьбы со связями, но накладные расходы кажутся похожими, несмотря на то, что .rank(cythonized, возможно, это вводит в заблуждение, учитывая основные накладные расходы связаны с моим использованием лямбда x.

Я запустил %lprun на f(x), что дало мне следующие результаты, хотя основными издержками являются использование .apply(lambda x, а не векторный подход:

Линия # Время хитов% Хит% Содержание строки

 2                                           def tst_fun(df, field):
 3         1          685    685.0      0.2      x = df[field]
 4         1        20726  20726.0      5.8      nans = x[np.isnan(x)]
 5         1        28448  28448.0      8.0      sub_df = x.dropna()
 6         1          387    387.0      0.1      nans_ranked = nans.replace(np.nan, 50)
 7         1            5      5.0      0.0      if len(sub_df.index) == 0: 
 8                                                   pass #check not empty.  May be empty due to nans for first 5 years e.g. no revenue/operating margin data pre 1990
 9                                                   return nans_ranked
10                                           
11         1        65559  65559.0     18.4      if len(sub_df.unique()) == 1: 
12                                                   sub_df[:] = 50 #e.g. for subranks where all factors had nan so ranked as 50 e.g. in 1990
13                                                   return sub_df
14                                           
15                                               #Finally, check that we don't have too many clustered values, such that we can't bin, and reduce bin size provided we can at least quintile rank.
16         1        74610  74610.0     20.9      max_cluster = sub_df.value_counts().iloc[0] #value_counts sorts by counts, so first element will contain the max
17                                               # print(counts)
18         1            9      9.0      0.0      max_bins = len(sub_df) / max_cluster #
19                                           
20         1            3      3.0      0.0      if max_bins > 100: 
21         1            0      0.0      0.0          max_bins = 100 #if largest cluster <1% of available data, then we can percentile_rank
22                                           
23                                           
24         1            0      0.0      0.0      if max_bins < 5: 
25                                                   sub_df[:] = 50 #if we don't have the resolution to quintile rank then assume no data.
26                                           
27                                               #     return sub_df
28                                           
29         1            1      1.0      0.0      bins = int(max_bins) # bin using highest resolution that the data supports, subject to constraints above (max 100 bins, min 5 bins)
30                                           
31                                               #should track bin resolution for all data.  To add.
32                                           
33                                               #if get here, then neither nans_ranked, nor sub_df are empty
34                                               # sub_df_ranked = pd.qcut(sub_df, bins, labels=False)
35         1       160530 160530.0     45.0      sub_df_ranked = (sub_df.rank(ascending = True) - 1)*100/len(x)
36                                           
37         1         5777   5777.0      1.6      ranked_df = pd.concat([sub_df_ranked, nans_ranked])
38                                               
39         1            1      1.0      0.0      return ranked_df
4b9b3361

Ответ 1

Я предлагаю вам попробовать этот код. Это в 3 раза быстрее, чем у вас, и более понятно.

ранговая функция:

def rank(x):
    counts = x.value_counts()
    bins = int(0 if len(counts) == 0 else x.count() / counts.iloc[0])
    bins = 100 if bins > 100 else bins
    if bins < 5:
        return x.apply(lambda x: 50)
    else:
        return (pd.qcut(x, bins, labels=False) * (100 / bins)).fillna(50).astype(int)

применяется одна нить:

for col in to_rank:
    df[col + '_ranked'] = df.groupby(['date_id', 'category'])[col].apply(rank)

применить клятву mulple:

import sys
from multiprocessing import Pool

def tfunc(col):
    return df.groupby(['date_id', 'category'])[col].apply(rank)

pool = Pool(len(to_rank))
result = pool.map_async(tfunc, to_rank).get(sys.maxint)
for (col, val) in zip(to_rank, result):
    df[col + '_ranked'] = val

Ответ 2

Я бы построил функцию, используя numpy
Я планирую использовать это в каждой группе, определенной в pandas groupby

def rnk(df):
    a = df.values.argsort(0)
    n, m = a.shape
    r = np.arange(a.shape[1])
    b = np.empty_like(a)
    b[a, np.arange(m)[None, :]] = np.arange(n)[:, None]
    return pd.DataFrame(b / n, df.index, df.columns)

gcols = ['date_id', 'category']
rcols = ['var_1', 'var_2', 'var_3']
df.groupby(gcols)[rcols].apply(rnk).add_suffix('_ranked')

   var_1_ranked  var_2_ranked  var_3_ranked
0      0.333333      0.809524      0.428571
1      0.160000      0.360000      0.240000
2      0.153846      0.384615      0.461538
3      0.000000      0.315789      0.105263
4      0.560000      0.200000      0.160000
...

Как это работает

  • Поскольку я знаю, что ранжирование связано с сортировкой, я хочу использовать некоторую умную сортировку, чтобы сделать это быстрее.
  • numpy argsort создаст перестановку, которая может использоваться для разбиения массива на отсортированный массив.

    a = np.array([25, 300, 7])
    b = a.argsort()
    print(b)
    
    [2 0 1]
    
    print(a[b])
    
    [  7  25 300]
    
  • Итак, я собираюсь использовать argsort, чтобы указать мне, где находятся элементы первого, второго и третьего ранжирования.

    # create an empty array that is the same size as b or a
    # but these will be ranks, so I want them to be integers
    # so I use empty_like(b) because b is the result of 
    # argsort and is already integers.
    u = np.empty_like(b)
    
    # now just like when I sliced a above with a[b]
    # I slice u the same way but instead I assign to
    # those positions, the ranks I want.
    # In this case, I defined the ranks as np.arange(b.size) + 1
    u[b] = np.arange(b.size) + 1
    
    print(u)
    
    [2 3 1]
    
  • И это было точно. 7 был в последней позиции, но был нашим первым рангом. 300 был на второй позиции и был нашим третьим разрядом. 25 был в первой позиции и был нашим вторым рангом.

  • Наконец, я делюсь на число в ранге, чтобы получить процентили. Так получилось, что, поскольку я использовал нулевое ранжирование np.arange(n), в отличие от одного основанного np.arange(1, n+1) или np.arange(n) + 1, как в нашем примере, я могу сделать простое деление, чтобы получить процентили.
  • Что нужно сделать, это применить эту логику к каждой группе. Мы можем сделать это в pandas с помощью groupby
  • Некоторые из недостающих деталей включают в себя: как я использую argsort(0), чтобы получить независимые сортировки за столбец`, и что я делаю некоторые причудливые нарезки, чтобы независимо друг от друга перегруппировать каждый столбец.

Можем ли мы избежать groupby и иметь numpy все это?
Я также воспользуюсь numba как раз вовремя компиляции, чтобы ускорить некоторые вещи с помощью njit

from numba import njit

@njit
def count_factor(f):
    c = np.arange(f.max() + 2) * 0
    for i in f:
        c[i + 1] += 1
    return c

@njit
def factor_fun(f):
    c = count_factor(f)
    cc = c[:-1].cumsum()
    return c[1:][f], cc[f]

def lexsort(a, f):
    n, m = a.shape
    f = f * (a.max() - a.min() + 1)
    return (f.reshape(-1, 1) + a).argsort(0)


def rnk_numba(df, gcols, rcols):
    tups = list(zip(*[df[c].values.tolist() for c in gcols]))
    f = pd.Series(tups).factorize()[0]
    a = lexsort(np.column_stack([df[c].values for c in rcols]), f)
    c, cc = factor_fun(f)
    c = c[:, None]
    cc = cc[:, None]
    n, m = a.shape
    r = np.arange(a.shape[1])
    b = np.empty_like(a)
    b[a, np.arange(m)[None, :]] = np.arange(n)[:, None]
    return pd.DataFrame((b - cc) / c, df.index, rcols).add_suffix('_ranked')

Как это работает

  • Честно говоря, это трудно обрабатывать мысленно. Я буду придерживаться того, что я объяснил выше.
  • Я хочу снова использовать argsort, чтобы понизить рейтинг в правильные позиции. Тем не менее, я должен бороться с столбцами группировки. Итак, я делаю компиляцию списка tuple и factorize их, как было описано в здесь, здесь
  • Теперь, когда у меня есть факторизованный набор tuple, я могу выполнить модифицированный lexsort, который сортируется в моих факторизованных группах tuple. Этот вопрос касается lexsort.
  • Остается решить сложный бит, где я должен установить новые найденные ранги по размеру каждой группы, чтобы получить новые ряды для каждой группы. Об этом говорится в крошечном фрагменте b - cc в приведенном ниже коде. Но вычисление cc является необходимым компонентом.

Так что некоторые из философии высокого уровня. Что насчет @njit?

  • Обратите внимание, что когда я факторизую, я сопоставляю целые числа 0 с n - 1, где n - количество уникальных группировок tuple s. Я могу использовать массив длины n как удобный способ отслеживания счетчиков.
  • Чтобы выполнить смещение groupby, мне нужно было отслеживать подсчеты и кумулятивные подсчеты в позициях этих групп, поскольку они представлены в списке tuples или факторизованной версии этих tuple s. Я решил выполнить линейное сканирование через факторизованный массив f и подсчитать наблюдения в цикле numba. Хотя у меня была эта информация, я также предоставил необходимую информацию для создания совокупных смещений, которые мне также нужны.
  • numba предоставляет интерфейс для создания высокоэффективных скомпилированных функций. Это очень сложно, и вам нужно приобрести некоторый опыт, чтобы узнать, что возможно, и что невозможно. Я решил numba fy две функции, которым предшествует декодер numba @njit. Эта кодировка работает так же хорошо без этих декораторов, но ускоряется с ними.

Сроки

%%timeit 
ranked_cols = [col + '_ranked' for col in to_rank]
​
ranked = df[['date_id', 'category'] + to_rank].groupby(['date_id', 'category'], as_index = False).apply(lambda x: rank_fun(x, to_rank)) 
ranked.columns = ranked_cols        
ranked.reset_index(inplace = True)
ranked.set_index('level_1', inplace = True)  
1 loop, best of 3: 481 ms per loop

gcols = ['date_id', 'category']
rcols = ['var_1', 'var_2', 'var_3']

%timeit df.groupby(gcols)[rcols].apply(rnk_numpy).add_suffix('_ranked')
100 loops, best of 3: 16.4 ms per loop

%timeit rnk_numba(df, gcols, rcols).head()
1000 loops, best of 3: 1.03 ms per loop