Элегантно вычислить количество перекрывающихся временных интервалов для объединенного отчетного периода, используя Python / Pandas / …?

#python #pandas

Вопрос:

У меня есть фрейм данных с событиями, каждое из которых имеет дату начала и окончания. У меня также есть отчетный период с датой начала и окончания и периодичностью отчетности, например, ежемесячно. Я хочу вычислить количество «активных» событий в каждой ячейке отчетного периода. Активное событие — это событие, для которого время перекрывается с временным интервалом ячейки отчетного периода.

После долгих усилий с функциями агрегирования фреймов данных я придумал следующий код, который выполняет эту работу, но который далек от компактного и элегантного.

Я почти уверен, что есть способ написать это более компактно, но нужны некоторые выводы.

 import numpy as np
import pandas as pd
import datetime as dt

# Example DF of events each with a start and end date provided as a string (my input data)
df = pd.DataFrame(columns=['id','start','end'], index=range(7), 
                  data=[[1,'2006-01-01','2007-10-01'],
                        [2,'2007-10-02','2008-12-01'],
                        [3,'2010-01-15','2010-10-20'],
                        [4,'2009-04-04','2010-06-03'],
                        [5,'2010-05-12','2010-08-31'],
                        [6,'2016-05-12','2199-12-31'],                       
                        [7,'2016-05-12','2199-12-31']])

# Reporting period in which we want to calculate the number of "ongoing"/"active" events:
reporting_period_start = '2010-01-01'
reporting_period_end   = '2011-01-01'
reporting_freq         = 'MS'

print('Input data:')
print(df)

# Convert the string dates to timestamps
def to_timestamp(str):
    return pd.Timestamp(str)
df.start = df.start.apply(to_timestamp)
df.end   = df.end.apply(to_timestamp)

# Create an additional colmun in the dataframe to capture the event time interval as an pandas.Interval 
# pandas.Intervals offer a since .overlaps() function
def to_interval(s, e):
    return pd.Interval(s, e)
df['interval'] = df.apply(lambda row: to_interval(row.start, row.end), axis=1)

# Create a data range and a period range to create reporting intervals (e.g. monthly)
# for which we want to count the number of event intervals that overlap with the reporting interval.
bins = pd.date_range(reporting_period_start, reporting_period_end, freq=reporting_freq)
print(bins)

# Convert the date ranges into a list of reporting intervals
# This is ugly code that most probably can be writting a lot more elegantly
intervals = []
n = bins.values.shape[0]
i = 0;
for b in bins[:-1]:
    intervals.append(pd.Interval(pd.to_datetime(bins.values[i]), pd.to_datetime(bins.values[(i 1)%n]), closed='right'))
    i = i   1

# Function for trying a pandas.Dataframe.apply / resample / groupby or something alike...
def overlaps(i1, i2):
    try:
        return i1.overlaps(i2)
    except:
        return None

result_list = np.zeros(len(intervals)).astype(int)
for index, row in df.iterrows():
    j = 0
    for interval in intervals:
        result_list[j] = result_list[j] overlaps(intervals[j], row.interval)
        j = j   1

print(result_list)
 

Ответ №1:

Если вы рассматриваете свои интервалы как пошаговые функции, которые имеют значение 1 для продолжительности интервала и 0 в противном случае, то это можно кратко решить с помощью staircase, которая была построена на pandas основе и numpy для анализа с помощью пошаговых функций.

В этом коде настройки я изменил даты в 2199 году на None, чтобы указать, что время окончания неизвестно. Я предполагаю, что это то, чего вы, возможно, хотели. Если это неверно, не вносите это изменение.

настройка

 import numpy as np
import pandas as pd

# Example DF of events each with a start and end date provided as a string   
df = pd.DataFrame(
    columns=['id','start','end'],
    index=range(7),
    data=[[1,'2006-01-01','2007-10-01'],
          [2,'2007-10-02','2008-12-01'],
          [3,'2010-01-15','2010-10-20'],
          [4,'2009-04-04','2010-06-03'],
          [5,'2010-05-12','2010-08-31'],
          [6,'2016-05-12',None],                       
          [7,'2016-05-12',None]])


df["start"] = pd.to_datetime(df["start"])
df["end"] = pd.to_datetime(df["end"])

reporting_period_start = '2010-01-01'
reporting_period_end   = '2011-01-01'
reporting_freq         = 'MS'
 

решение

Ваши интервалы не начинаются и не заканчиваются на границах месяца. Нам нужно «минимизировать» время начала до границ месяца и «ограничить» время окончания до границ месяца, чтобы убедиться, что интервалы, которые перекрывают месяц, также перекрывают друг друга. Насколько мне известно, в настоящее время нет элегантного способа сделать это, но будет работать следующее

 df["start"] = df["start"].dt.to_period("M").dt.to_timestamp()
df["end"] = (df["end"].dt.to_period("M") 1).dt.to_timestamp()
 

df теперь выглядит так

    id      start        end
0   1 2006-01-01 2007-11-01
1   2 2007-10-01 2009-01-01
2   3 2010-01-01 2010-11-01
3   4 2009-04-01 2010-07-01
4   5 2010-05-01 2010-09-01
5   6 2016-05-01        NaT
6   7 2016-05-01        NaT
 

Теперь мы создаем пошаговую функцию, которая представляет собой комбинацию всех интервалов. Когда начинается интервал, значение функции шага увеличивается на 1. Когда интервал заканчивается, значение уменьшается на 1. Таким образом, значением пошаговой функции в любой точке будет количество интервалов, перекрывающих эту точку. Ступенчатая функция представлена лестницей.Класс лестницы. Этот класс является to staircase как Series есть to pandas .

 import staircase as sc

stepfunction = sc.Stairs(df, "start", "end")
 

Есть много вещей, которые вы можете сделать со ступенчатыми функциями в staircase, включая построение графиков.

 stepfunction.plot(style="hlines")
 

график пошаговой функции

Поскольку интервалы теперь начинаются и заканчиваются на границах месяца, а ячейки являются границами месяца, мы можем ответить на ваш вопрос, найдя максимальное значение пошаговой функции для каждого месяца.

 bins = pd.date_range(reporting_period_start, reporting_period_end, freq=reporting_freq)
result = stepfunction.slice(bins).max()
 

result будет индексироваться pandas.Series индексом месячного интервала, значения которого представляют собой количество интервалов, перекрывающихся с этим месяцем

 [2010-01-01, 2010-02-01)    2.0
[2010-02-01, 2010-03-01)    2.0
[2010-03-01, 2010-04-01)    2.0
[2010-04-01, 2010-05-01)    2.0
[2010-05-01, 2010-06-01)    3.0
[2010-06-01, 2010-07-01)    3.0
[2010-07-01, 2010-08-01)    2.0
[2010-08-01, 2010-09-01)    2.0
[2010-09-01, 2010-10-01)    1.0
[2010-10-01, 2010-11-01)    1.0
[2010-11-01, 2010-12-01)    0.0
[2010-12-01, 2011-01-01)    0.0
dtype: float64
 

Напомним, что решение (после импорта и настройки)

 df["start"] = df["start"].dt.to_period("M").dt.to_timestamp()
df["end"] = (df["end"].dt.to_period("M") 1).dt.to_timestamp()
result = sc.Stairs(df, "start", "end").slice(bins).max()
 

примечание: я создатель staircase. Пожалуйста, не стесняйтесь обращаться с отзывами или вопросами, если они у вас есть.