Хорошей новостью является то, что это можно векторизовать.Плохая новость ... это не совсем просто.
Вот эталонный тест perfplot код:
import numpy as np
import pandas as pd
import perfplot
def orig(df):
nr_20_min_bef = []
nr_20_min_after = []
for i in range(0, len(df)):
nr_20_min_bef.append(df.Date.between(
df.Date[i] - pd.offsets.DateOffset(minutes=20), df.Date[i], inclusive = True).sum())
nr_20_min_after.append(df.Date.between(
df.Date[i], df.Date[i] + pd.offsets.DateOffset(minutes=20), inclusive = True).sum())
df['nr_20_min_bef'] = nr_20_min_bef
df['nr_20_min_after'] = nr_20_min_after
return df
def alt(df):
df = df.copy()
df['Date'] = pd.to_datetime(df['Date'])
df['num'] = 1
df = df.set_index('Date')
dup_count = df.groupby(level=0)['num'].count()
result = dup_count.rolling('20T', closed='both').sum()
df['nr_20_min_bef'] = result.astype(int)
max_date = df.index.max()
min_date = df.index.min()
dup_count_reversed = df.groupby((max_date - df.index)[::-1] + min_date)['num'].count()
result = dup_count_reversed.rolling('20T', closed='both').sum()
result = pd.Series(result.values[::-1], dup_count.index)
df['nr_20_min_after'] = result.astype(int)
df = df.drop('num', axis=1)
df = df.reset_index()
return df
def make_df(N):
dates = (np.array(['2018-04-10'], dtype='M8[m]')
+ (np.random.randint(10, size=N).cumsum()).astype('<i8').astype('<m8[m]'))
df = pd.DataFrame({'Date': dates})
return df
def check(df1, df2):
return df1.equals(df2)
perfplot.show(
setup=make_df,
kernels=[orig, alt],
n_range=[2**k for k in range(4,10)],
logx=True,
logy=True,
xlabel='N',
equality_check=check)
, который показывает, что alt
значительно быстрее, чем orig
:
В дополнение к бенчмаркингу orig
и alt
, perfplot.show
также проверяет, что фреймы данных, возвращаемые orig
и alt
, равны.Учитывая сложность alt
, это, по крайней мере, дает нам некоторую уверенность в том, что он ведет себя так же, как и orig
.
Немного сложно создать перфлоплот для большого N, поскольку orig
начинает принимать довольнодолгое время и каждый тест повторяется сотни раз.Итак, вот несколько сравнений %timeit
для больших N
:
| N | orig (ms) | alt (ms) |
|-------+-----------+----------|
| 2**10 | 3040 | 9.32 |
| 2**12 | 12600 | 10.8 |
| 2**20 | ? | 909 |
In [300]: df = make_df(2**10)
In [301]: %timeit orig(df)
1 loop, best of 3: 3.04 s per loop
In [302]: %timeit alt(df)
100 loops, best of 3: 9.32 ms per loop
In [303]: df = make_df(2**12)
In [304]: %timeit orig(df)
1 loop, best of 3: 12.6 s per loop
In [305]: %timeit alt(df)
100 loops, best of 3: 10.8 ms per loop
In [306]: df = make_df(2**20)
In [307]: %timeit alt(df)
1 loop, best of 3: 909 ms per loop
Что теперь делает alt
?Возможно, проще всего взглянуть на небольшой пример, используя опубликованную вами df
:
df = pd.DataFrame(pd.to_datetime(['2018-04-10 21:05:00',
'2018-04-10 21:05:00',
'2018-04-10 21:10:00',
'2018-04-10 21:15:00',
'2018-04-10 21:35:00']),columns = ['Date'])
Основная идея - использовать Series.rolling
для выполнения скользящей суммы.Когда у Series есть DatetimeIndex, Series.rolling
может принять частоту времени для размера окна.Таким образом, мы можем вычислить скользящие суммы с переменными окнами фиксированного промежутка времени.Поэтому сначала необходимо сделать даты DatetimeIndex:
df['Date'] = pd.to_datetime(df['Date'])
df['num'] = 1
df = df.set_index('Date')
Поскольку df
имеет повторяющиеся даты, сгруппируйте их по значениям DatetimeIndex и посчитайте количество дубликатов:
dup_count = df.groupby(level=0)['num'].count()
# Date
# 2018-04-10 21:05:00 2
# 2018-04-10 21:10:00 1
# 2018-04-10 21:15:00 1
# 2018-04-10 21:35:00 1
# Name: num, dtype: int64
Теперь вычисляем скользящую сумму на dup_count
:
result = dup_count.rolling('20T', closed='both').sum()
# Date
# 2018-04-10 21:05:00 2.0
# 2018-04-10 21:10:00 3.0
# 2018-04-10 21:15:00 4.0
# 2018-04-10 21:35:00 2.0
# Name: num, dtype: float64
Виола, это nr_20_min_bef
.20T
определяет размер окна длиной 20 минут.closed='both'
указывает, что каждое окно включает в себя как левую, так и правую конечные точки.
Теперь, если бы только вычисления nr_20_min_after
были такими же простыми.Теоретически все, что нам нужно сделать, - это изменить порядок строк в dup_count
и вычислить другую скользящую сумму.К сожалению, Series.rolling
требует, чтобы DatetimeIndex монотонно увеличивался :
In [275]: dup_count[::-1].rolling('20T', closed='both').sum()
ValueError: index must be monotonic
Поскольку очевидный путь заблокирован, мы берем объезд:
max_date = df.index.max()
min_date = df.index.min()
dup_count_reversed = df.groupby((max_date - df.index)[::-1] + min_date)['num'].count()
# Date
# 2018-04-10 21:05:00 1
# 2018-04-10 21:25:00 1
# 2018-04-10 21:30:00 1
# 2018-04-10 21:35:00 2
# Name: num, dtype: int64
Thisгенерирует новый псевдо datetime DatetimeIndex для группировки по:
In [288]: (max_date - df.index)[::-1] + min_date
Out[288]:
DatetimeIndex(['2018-04-10 21:05:00', '2018-04-10 21:25:00',
'2018-04-10 21:30:00', '2018-04-10 21:35:00',
'2018-04-10 21:35:00'],
dtype='datetime64[ns]', name='Date', freq=None)
Эти значения могут быть не в df.index
- но это нормально.Единственное, что нам нужно, это то, что значения монотонно растут и что разница между датами и временем соответствует разнице в df.index
при обращении.
Теперь, используя это обращенное dup_count, мы можемнаслаждайтесь большим выигрышем (в исполнении), взяв скользящую сумму:
result = dup_count_reversed.rolling('20T', closed='both').sum()
# Date
# 2018-04-10 21:05:00 1.0
# 2018-04-10 21:25:00 2.0
# 2018-04-10 21:30:00 2.0
# 2018-04-10 21:35:00 4.0
# Name: num, dtype: float64
result
имеет значения, которые мы хотим получить nr_20_min_after
, но в обратном порядке и с неверным индексом.Вот как мы можем исправить это:
result = pd.Series(result.values[::-1], dup_count.index)
# Date
# 2018-04-10 21:05:00 4.0
# 2018-04-10 21:10:00 2.0
# 2018-04-10 21:15:00 2.0
# 2018-04-10 21:35:00 1.0
# dtype: float64
И это в основном все, что нужно alt
.