【问题标题】:Merge consecutive and overlapping date ranges合并连续和重叠的日期范围
【发布时间】:2021-08-12 16:46:06
【问题描述】:

我希望这里有人可以帮助我。我已经尝试了几乎所有我能想到的网络搜索,但我似乎无法找到可以帮助我获得所需内容的信息。

我有几个数据集,其中客户服务期是连续或重叠的,类似于下面。

import pandas as pd 
cust = ['CUST123', 'CUST123', 'CUST123', 'CUST123', 'CUST456', 'CUST456']
start_date = ['01/01/2021','01/07/2021','01/15/2021','02/02/2021','01/05/2021','01/16/2021']
end_date = ['01/11/2021','01/14/2021','01/31/2021','02/28/2021','01/15/2021','01/31/2021']
df = pd.DataFrame(list(zip(cust, start_date, end_date)), columns = ['cust', 'start_date', 'end_date'])
df['start_date'] = pd.to_datetime(df['start_date'])
df['end_date'] = pd.to_datetime(df['end_date'])

我需要做的是折叠重叠或连续的日期范围,以便之后我只剩下连续的服务期:

我有一个 SAS 程序可以做到这一点,并且在过去 10 年左右为我提供了良好的服务,但遗憾的是,我的公司将在 2022 年底放弃 SAS 转而使用 Python,而我之前正争先恐后地移植多年的 SAS 代码那么。

SAS 程序甚至可以设置一个变量来确定结束日期和开始日期之间可以存在多少时间(以天为单位),以便被认为是连续的。因此,例如,使用上面的示例,如果我将变量设置为 2,则 CUST123 将只有 1 个输出行,因为 2/2/21 在 21/1/31 的 2 天内。

此时,任何帮助或指导都会被欣然接受。我现在很迷茫。

更新:

我非常接近,但我似乎无法完全到达那里。 鉴于下面的原始数据框,我将共享的原始代码修改为:

df.groupby(["cust","service_tier"], as_index=False).apply(
    lambda d: d.sort_values(["cust", "service_tier", "start_date", "end_date"])
    .groupby(
        ["cust", "service_tier", (~(d["start_date"] <= (d["end_date"].shift() + pd.Timedelta(days=3)))).cumsum()],
        as_index=False
    )
    .agg({"start_date": "min", "end_date": "max"})
).reset_index(drop=True)

原始数据帧:

       cust  service_tier start_date   end_date
0   CUST123             1 2020-06-13 2020-06-27
1   CUST123             1 2020-06-15 2020-07-13
2   CUST123             1 2020-06-22 2020-07-23
3   CUST123             1 2020-06-24 2020-07-08
4   CUST123             1 2020-07-13 2020-07-27
5   CUST123             1 2020-08-20 2020-09-17
6   CUST123             1 2020-08-21 2020-09-04
7   CUST123             1 2020-08-25 2020-09-05
8   CUST123             1 2020-08-27 2020-09-21
9   CUST123             1 2020-09-01 2020-10-05
10  CUST123             1 2020-09-10 2020-09-17
11  CUST123             1 2020-10-05 2020-11-30
12  CUST123             1 2020-12-01 2021-05-31
13  CUST123             1 2021-06-01 2021-11-30
14  CUST456             1 2021-01-05 2021-01-15
15  CUST456             2 2021-01-16 2021-01-31

修改后的代码让我明白了:

      cust  service_tier start_date   end_date
0  CUST123             1 2020-06-13 2020-07-23
1  CUST123             1 2020-07-13 2020-07-27
2  CUST123             1 2020-08-20 2020-10-05
3  CUST123             1 2020-10-05 2021-11-30
4  CUST456             1 2021-01-05 2021-01-15
5  CUST456             2 2021-01-16 2021-01-31

什么时候真的应该是这样的:

      cust  service_tier start_date   end_date
0  CUST123             1 2020-06-13 2020-07-27
1  CUST123             1 2020-08-20 2021-11-30
2  CUST456             1 2021-01-05 2021-01-15
3  CUST456             2 2021-01-16 2021-01-31

我认为这是因为代码没有考虑到日期范围是否完全包含在先前的日期范围内。 我一直在试图弄清楚如何更新 "(~(d["start_date"] ”部分有多个条件,但我失败得很惨。

【问题讨论】:

    标签: python python-3.x pandas dataframe


    【解决方案1】:
    • 在客户groupby("cust", as_index=False) 中查找行中重叠的日期
    • cumsum() 对布尔值求和以生成重叠日期的组
    • 最后一个关于组内最小值/最大值的简单案例
    
    df.groupby("cust", as_index=False).apply(
        lambda d: d.sort_values(["start_date", "end_date"])
        .groupby(
            ["cust", (~(d["start_date"] <= (d["end_date"].shift() + pd.Timedelta(days=1)))).cumsum()],
            as_index=False
        )
        .agg({"start_date": "min", "end_date": "max"})
    ).reset_index(drop=True)
    
    cust start_date end_date
    0 CUST123 2021-01-01 00:00:00 2021-01-31 00:00:00
    1 CUST123 2021-02-02 00:00:00 2021-02-28 00:00:00
    2 CUST456 2021-01-05 00:00:00 2021-01-31 00:00:00

    补充更新

    • 这通过与前一行逐行测试来工作。因此sort_order() 很关键
    • 这实际上需要在 end_date 然后 start_date 之前排序
    • 创建了一个新列,因为我意识到订单被忽略了!此列可以在最终数据框中删除
    • 在修改 groupby() lambda 函数中的代码之前只与主要组合作解决了这个问题

    调试代码

    df.loc[df["cust"].eq("CUST123")].sort_values(["end_date", "start_date"]).assign(
        grp=lambda d: (
            ~(d["start_date"] <= (d["end_date"].shift() + pd.Timedelta(days=3)))
        ).cumsum()
    )
    

    最终解决方案

    df = pd.read_csv(io.StringIO("""       cust  service_tier start_date   end_date
    0   CUST123             1 2020-06-13 2020-06-27
    1   CUST123             1 2020-06-15 2020-07-13
    2   CUST123             1 2020-06-22 2020-07-23
    3   CUST123             1 2020-06-24 2020-07-08
    4   CUST123             1 2020-07-13 2020-07-27
    5   CUST123             1 2020-08-20 2020-09-17
    6   CUST123             1 2020-08-21 2020-09-04
    7   CUST123             1 2020-08-25 2020-09-05
    8   CUST123             1 2020-08-27 2020-09-21
    9   CUST123             1 2020-09-01 2020-10-05
    10  CUST123             1 2020-09-10 2020-09-17
    11  CUST123             1 2020-10-05 2020-11-30
    12  CUST123             1 2020-12-01 2021-05-31
    13  CUST123             1 2021-06-01 2021-11-30
    14  CUST456             1 2021-01-05 2021-01-15
    15  CUST456             2 2021-01-16 2021-01-31"""), sep="\s+")
    
    df = df.assign(**{c:pd.to_datetime(df[c]) for c in df.columns if "date" in c})
    
    df.groupby(["cust", "service_tier"], as_index=False).apply(
        lambda d: d.sort_values(["end_date", "start_date"])
        .assign(
            grp=lambda d: (
                ~(d["start_date"] <= (d["end_date"].shift() + pd.Timedelta(days=3)))
            ).cumsum()
        )
        .groupby(["cust", "service_tier", "grp"], as_index=False)
        .agg({"start_date": "min", "end_date": "max"})
    ).reset_index(drop=True)
    

    【讨论】:

    • 谢谢!我完全不会这么快就想通了。这个解决方案非常好和紧凑。快速提问,我是否可以使用变量代替“pd.Timedelta(days=1)”中的数字 1,以使结束日期和开始日期之间的动态时间量(以天为单位)被认为是连续的?看起来我可以,但我不完全知道如果我这样做会不会破坏任何东西。
    • 例如,如果 end_date 是星期五,start_date 是星期一,那么会有 3 天的偏移量。如果星期一是公共假期,它将变为 4... 它不是真正动态的(根据情况了解周末或公共假期),但可以设置为不同的静态偏移量
    • 嗨@rob-raymond,我已经添加了一些更新,这些更新基于我对您慷慨分享的代码sn-p 的应用。对此我表示感谢,并想知道您是否对我在更新中提到的新皱纹有任何想法。
    • 适用于最后一个示例,但现在一个客户的数据在之前还不错。 CUST789 的 2 个日期范围应该是 1. cust service_tier start_date end_date CUST789 1 2020-11-04 2020-12-30 CUST789 1 2020-11-16 2020-12-13 CUST789 1 2020-12-14 2022-01- 10 CUST789 1 2021-01-11 2022-02-07 CUST789 1 2021-02-08 2021-03-07 CUST789 1 2021-03-08 2021-05-02 CUST789 1 2021-04-26 2021-05-07 CUST456 1 2021-01-05 2021-01-15 CUST456 2 2021-01-16 2021-01-31
    • hmmm - 排列...我考虑过的选项之一是多次运行它,直到停止使用原始排序顺序折叠行。一种递归解决方案
    【解决方案2】:

    这是一个可能的解决方案:

    1. 将start_date和end_date的项目从str类型转换为datetime类型,以便它们可以作为日期对象处理。这对于防止基于其字符串值的日期比较很重要,这会错误地导致例如"01/01/2021" &lt; "12/31/2020" 实际上正好相反。

    2. 将cust、start_date 和end_date 压缩到一个列表中。

    3. 对lexicographical order 中的列表进行排序。这意味着它将主要按cust 对列表进行排序。具有相同cust 的所有项目将按start_date 排序。所有具有相同cust 和start_date 的项目将按end_date 排序。

    4. 迭代排序列表中的每个项目。

      一个。如果当前项目与前一个项目的cust 不同,则创建一个新项目。

      b.如果当前项属于同一个cust,但不在上一项的范围内,则创建一个新项。

      c。如果当前项属于同一个cust,并且在上一项的范围内,则将其与上一项合并。

    5. 创建数据框

    代码:

    from datetime import datetime, timedelta
    
    import pandas as pd
    
    
    # Original data
    cust = ['CUST123', 'CUST123', 'CUST123', 'CUST123', 'CUST456', 'CUST456']
    start_date = ['01/01/2021','01/07/2021','01/15/2021','02/02/2021','01/05/2021','01/16/2021']
    end_date = ['01/11/2021','01/14/2021','01/31/2021','02/28/2021','01/15/2021','01/31/2021']
    
    # To be able to sort the dates, convert them to datetime objects
    start_date = [datetime.strptime(dt_str, '%m/%d/%Y') for dt_str in start_date]
    end_date = [datetime.strptime(dt_str, '%m/%d/%Y') for dt_str in end_date]
    
    # Sort by cust. For equal custs, sort then by start_date. For equal start dates, sort then by end_date.
    sorted_cust_dates = sorted(zip(cust, start_date, end_date))
    
    # Create new items or merge to previous item if in same cust and if in range.
    merged_cust_dates = []
    for current_cust, current_start_date, current_end_date in sorted_cust_dates:
        if len(merged_cust_dates) == 0 or current_cust != merged_cust_dates[-1][0]:
            # Create a new item if this is the first loop or if the cust is different from the previous cust
            merged_cust_dates.append([current_cust, current_start_date, current_end_date])
        elif current_start_date <= merged_cust_dates[-1][2] + timedelta(days=1):
            # Merge the the current item to the previous item if in range
            merged_cust_dates[-1][2] = max(merged_cust_dates[-1][2], current_end_date)
        else:
            # Create a new item otherwise
            merged_cust_dates.append([current_cust, current_start_date, current_end_date])
    
    # Create the dataframe object
    df = pd.DataFrame(merged_cust_dates, columns = ['cust', 'start_date', 'end_date'])
    df['start_date'] = pd.to_datetime(df['start_date'])
    df['end_date'] = pd.to_datetime(df['end_date'])
    print(df)
    

    输出:

          cust start_date   end_date
    0  CUST123 2021-01-01 2021-01-31
    1  CUST123 2021-02-02 2021-02-28
    2  CUST456 2021-01-05 2021-01-31
    

    如果您需要更改字符串日期的格式,请使用datetime.strftime。

    【讨论】:

      【解决方案3】:

      以下工作完全符合预期:

      import pandas as pd 
      from datetime import datetime, timedelta
      
      cust = ['CUST123', 'CUST123', 'CUST123', 'CUST123', 'CUST456', 'CUST456', 'CUST123']
      start_date = ['01/01/2021','01/07/2021','01/15/2021','02/02/2021','01/05/2021','01/16/2021', '02/03/2021']
      end_date = ['01/11/2021','01/14/2021','01/31/2021','02/28/2021','01/15/2021','01/31/2021', '03/31/2021']
      
      df = pd.DataFrame(list(zip(cust, start_date, end_date)), columns = ['cust', 'start_date', 'end_date'])
      df['start_date'] = pd.to_datetime(df['start_date'])
      df['end_date'] = pd.to_datetime(df['end_date'])
      
      # Sort values so we always have date ranges incrementing
      df.sort_values("start_date", axis = 0, ascending = True,
                       inplace = True, na_position ='last')
      
      # Helper class                 
      class DateRange:
          def __init__(self, start, end):
              self.start = start
              self.end = end
              
          def __contains__(self, value):
              return self.start <= value <= self.end
          
          def __str__(self):
              return str(self.start) + " - " + str(self.end)
      
      subs = {k: [] for k in set(cust)}
      
      for index, row in df.iterrows():
          # if subs[k] empty fill in first Date Range
          if len(subs[row[0]]) == 0:
              subs[row[0]].append(DateRange(row[1], row[2]))
          else:
              noMatch = True
              # iterate over all available date ranges to see if the following fits
              for x in subs[row[0]]:
                  if row[1] in x or row[2] in x or row[1] == x.end + timedelta(1):
                      noMatch = False
                      if row[1] < x.start:
                          x.start = row[1]
                      if row[2] > x.end:
                          x.end = row[2]
              # if noMatch found it could be unique Date Range
              if noMatch:
                  subs[row[0]].append(DateRange(row[1], row[2]))
                      
      for k, v in subs.items():
          for i in v:
              print(k, str(i))
      

      【讨论】:

      • 这看起来可行。但我有个问题。如果我想通过 cust 和另一列获取范围(例如我更新示例中的 service_tier),我将如何修改它?
      • 这已经是自定义的了,subs = {k: [] for k in set(cust)} 这会创建一个字典,该字典稍后会填充日期范围。您可以使用像 (CUST123, "Service_Tier_1") : [list of date ranges] 这样的元组键创建字典。另一种方法是向class DateRange 添加另一个属性并修改方法以适应新属性。
      猜你喜欢
      • 1970-01-01
      • 2013-03-24
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-09-28
      • 2017-02-12
      • 1970-01-01
      相关资源
      最近更新 更多