Повышение эффективности функции ранжирования путем замены лямбда 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
Ответы
Ответ 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