Проблема с производительностью, превращающая строки с началом-концом в кадр данных с TimeIndex

У меня есть большой набор данных, где каждая строка представляет значение определенного типа (например, датчик) за временной интервал (между началом и концом). Это выглядит так:

    start       end    type value
2015-01-01  2015-01-05  1   3
2015-01-06  2015-01-08  1   2
2015-01-05  2015-01-08  3   3
2015-01-13  2015-01-16  2   1

Я хочу превратить его в ежедневный индексированный по времени кадр следующим образом:

day       type  value
2015-01-01  1   3
2015-01-02  1   3
2015-01-03  1   3
2015-01-04  1   3
2015-01-05  1   3
2015-01-06  1   2
2015-01-07  1   2
2015-01-08  1   2
2015-01-05  3   3
2015-01-16  3   3
2015-01-07  3   3
2015-01-08  3   3
2015-01-13  2   1
2015-01-14  2   1
2015-01-15  2   1
2015-01-16  2   1

(Обратите внимание, что мы не можем делать никаких предположений относительно интервала: они должны быть непрерывными и не перекрываться, но мы не можем этого гарантировать)

На основе этих ответов Stack Overflow [1] (передискретизация DataFrame по диапазонам дат) [2] (pandas: агрегат на основе начала /конечная дата), похоже, существует два метода: один вокруг itertuples, другой вокруг Mel (2 выше использовал stack/unstack, но он похож на Mel). Сравним их по производительности.

# Creating a big enough dataframe
date_range = pd.date_range(start=dt.datetime(2015,1,1), end=dt.datetime(2019,12,31), freq='4D')
to_concat = []
for val in range(1,50):
    frame_tmp = pd.DataFrame()
    frame_tmp['start'] = date_range
    frame_tmp['end'] = frame_tmp['start']+ dt.timedelta(3)
    frame_tmp['type'] = val
    frame_tmp['value'] = np.random.randint(1, 6, frame_tmp.shape[0])
    to_concat.append(frame_tmp)
df = pd.concat(to_concat, ignore_index=True)

# Method 1 
def method_1(df):
    df1 = (pd.concat([pd.Series(r.Index,
                                pd.date_range(r.start,
                                              r.end,
                                              freq='D'))
                      for r in df.itertuples()])) \
        .reset_index()
    df1.columns = ['start_2', 'idx']

    df2 = df1.set_index('idx').join(df).reset_index(drop=True)

    return df2.set_index('start_2')

df_method_1=df.groupby(['type']).apply(method_1)

# Method 2
df_tmp= df.reset_index()
df1 = (df_tmp.melt(df_tmp.columns.difference(['start','end']),
          ['start', 'end'],
          value_name='current_time')
  )
df_method_2 = df1.set_index('current_time').groupby('index', group_keys=False)\
.resample('D').ffill()

С %%timeit в Jupyter метод 1 занимает ~ 8 с, а метод 2 - ~ 25 с для кадра данных, определенного в качестве примера. Это слишком медленно, так как реальный набор данных, с которым я имею дело, намного больше этого. В этом кадре данных метод 1 занимает ~ 20 минут.

У вас есть идеи, как сделать это быстрее?


person Phik    schedule 07.06.2018    source источник
comment
Я не могу запустить ваш код, что такое date_range в frame_tmp['start'] = date_range?   -  person Ben.T    schedule 07.06.2018
comment
Хороший улов! Я обновил код. Спасибо @Ben.T   -  person Phik    schedule 07.06.2018
comment
Что такое df_tmp? Кроме того, поскольку вы говорите «мы не можем делать никаких предположений относительно интервала», я предполагаю, что вы хотите расширить указанные показания (даже если они перекрываются или имеют пробелы), а не искать каждый день года среди доступных показаний. Это правильно?   -  person Matthias Fripp    schedule 07.06.2018
comment
Хороший улов! просто .reset_index(). Я обновил код. И да, я хочу расширить конкретные показания, так как это позволит мне выполнять любые проверки общим способом. @MatthiasFripp   -  person Phik    schedule 07.06.2018


Ответы (1)


Это примерно в 1,7 раза быстрее, чем ваш method_1, и немного аккуратнее:

df_expand = pd.DataFrame.from_records(
    (
        (d, r.type, r.value) 
        for r in df.itertuples()
        for d in pd.date_range(start=r.start, end=r.end, freq='D')
    ),
    columns=['day', 'type', 'row']
)

Вы можете получить примерно в 7 раз быстрее, создав свой собственный диапазон дат вместо вызова pd.date_range():

one_day = dt.timedelta(1)
df_expand = pd.DataFrame.from_records(
    (
        (r.start + i * one_day, r.type, r.value) 
        for r in df.itertuples()
        for i in range(int((r.end-r.start)/one_day)+1)
    ),
    columns=['day', 'type', 'row']
)

Или вы можете получить до 24 раз быстрее, используя функцию arange numpy для генерации дат:

one_day = dt.timedelta(1)
df_expand = pd.DataFrame.from_records(
    (
        (d, r.type, r.value) 
        for r in df.itertuples()
        for d in np.arange(r.start.date(), r.end.date()+one_day, dtype='datetime64[D]')
    ),
    columns=['day', 'type', 'row']
)

Я не мог не добавить еще один, который чуть более чем в два раза быстрее, чем предыдущий. К сожалению, читать намного сложнее. Это группирует показания в зависимости от того, сколько дней они охватывают («dur»), а затем использует векторизованные операции numpy для расширения каждой группы в одном пакете.

def expand_group(g):
    dur = g.dur.iloc[0] # how many days for each reading in this group?
    return pd.DataFrame({
        'day': (g.start.values[:,None] + np.timedelta64(1, 'D') * np.arange(dur)).ravel(),
        'type': np.repeat(g.type.values, dur),
        'value': np.repeat(g.value.values, dur),
    })
# take all readings with the same duration and process them together using vectorized code
df_expand = (
    df.assign(dur=(df['end']-df['start']).dt.days + 1)
    .groupby('dur').apply(expand_group)
    .reset_index('dur', drop=True)
)

Обновление: В ответ на ваш комментарий ниже представлена ​​упрощенная версия векторизованного подхода, которая быстрее и легче читается. Вместо использования шага groupby создается единая матрица шириной самого длинного чтения, а затем отфильтровываются ненужные записи. Это должно быть довольно эффективно, если только максимальная продолжительность ваших показаний не намного больше средней. С тестовым кадром данных (все показания длятся 4 дня) это примерно в 15 раз быстрее, чем решение groupby, и примерно в 700 раз быстрее, чем method_1.

dur = (df['end']-df['start']).max().days + 1
df_expand = pd.DataFrame({
    'day': (
        df['start'].values[:,None] + np.timedelta64(1, 'D') * np.arange(dur)
    ).ravel(),
    'type': np.repeat(df['type'].values, dur),
    'value': np.repeat(df['value'].values, dur),
    'end': np.repeat(df['end'].values, dur),
})
df_expand = df_expand.loc[df_expand['day']<=df_expand['end'], 'day':'value']
person Matthias Fripp    schedule 07.06.2018
comment
Это такое невероятное ускорение! Я проверил реальные наборы данных, которые вызвали этот вопрос. Эта часть процесса теперь занимает меньше ~25 минут, тогда как раньше она занимала более 20 минут. Огромное спасибо! - person Phik; 07.06.2018
comment
@Phik, просто чтобы вы знали, в моем третьем методе я изначально не учитывал тот факт, что np.arange() не включает верхний конец, поэтому он опускал последнюю дату каждого чтения. Я исправил это сейчас. - person Matthias Fripp; 08.06.2018
comment
на самом деле в моих реальных наборах данных ваш векторизованный метод (№ 4) в 6,5 раз быстрее, чем № 3! Это невероятно! Еще раз большое спасибо. Мне все еще интересно, можно ли обойтись без groupby, вы пробовали что-нибудь подобное? - person Phik; 20.06.2018
comment
@Phik, хитрость с векторизованным кодом numpy заключается в том, что он создает матрицу с одной строкой для каждого дня начала и одним столбцом для каждого дня в окне измерения. Это возможно только в том случае, если каждая партия имеет одинаковую длину окна измерения (чтобы сделать регулярную матрицу), поэтому она группируется по dur. Но, может быть, можно использовать максимальную продолжительность и заполнять нули после dur для каждого чтения, а затем отбрасывать их позже. Или вы можете многократно добавлять записи для всех строк с длительностью ›10, ›9, ›8 и т. д., а затем снова сортировать их вместе. Но это, вероятно, будет медленнее, чем groupby. - person Matthias Fripp; 21.06.2018
comment
@Phik, вы были правы, устранение groupby экономит много времени, а также упрощает понимание кода. Смотрите новую версию выше. - person Matthias Fripp; 21.06.2018
comment
В моем реальном наборе данных этот последний метод немного медленнее (~ 20%), чем последний метод groupby (# 4). Есть некоторые «dur», которые охватывают месяц, но типичный «dur» - это неделя. Таким образом, создание в ~ 4 раза большего количества строк и последующая фильтрация, вероятно, делают его медленнее (как вы намекнули два комментария назад). Это предел поддельного фрейма данных, который я создал для этого вопроса SO, но я уверен, что он будет полезен другим. Еще раз большое спасибо, я многому научился из этого вопроса! - person Phik; 22.06.2018
comment
@Phik, Да, это звучит как проблема. Вы можете попробовать последний метод в два прохода: один для всех показаний за 7 дней или меньше, а другой для более длинных показаний. Это может улучшить скорость, если есть только несколько длинных показаний. Но это похоже на выполнение мини-группы самостоятельно, так что это может быть ненамного быстрее, а дополнительная сложность будет раздражать. - person Matthias Fripp; 22.06.2018