本文详解如何在 Pandas 中实现按组反向累计推算日期——以每组首个非空 Start 为基准,结合 LT(Lead Time)列所表示的业务日天数,自底向上逐行减去对应工作日,精准跳过周末与节假日。
本文详解如何在 pandas 中实现按组反向累计推算日期——以每组首个非空 `start` 为基准,结合 `lt` 列所表示的**业务日天数**,自底向上逐行减去对应工作日,精准跳过周末与节假日。
在供应链、项目排程或订单履约等场景中,常需基于交付截止日(Start)和前置周期(LT,单位为业务日)反向推算各环节的计划开始时间。难点在于:
- 每组(如产品线 Group)的参考起始日期并非固定位于组末,而是首个有效日期(即第一个非 NaT 的 Start 值);
- 后续所有 NaN 行需从该起始点逆序向前推算,且 LT 必须按真实工作日(周一至周五)扣减,而非自然日;
- 同一组内可能存在多个有效 Start(如 C 组有 04-04、10-04、24-04),但仅首个生效,其后的 Start 仅保留原值,不参与推算。
以下方案使用纯 Pandas 向量化操作,无需循环或 apply(lambda x: ...),兼顾性能与可读性。
✅ 核心思路:双层分组 + 反向累计 + 业务日偏移
关键创新在于构建一个逻辑分组标识 grp,用于区分“首个有效起始点之后的子段”:
- 先识别所有 LT == 0 的行(通常代表锚定的起始日期);
- 对每组 Group 内,用 cumsum() 累计 LT == 0 的出现次数,并减去当前行是否为 LT == 0,从而将每个 LT == 0 行及其上方连续 NaN Start 行划入同一逻辑子组;
- 在该双层索引 [Group, grp] 上执行反向累计(.loc[::-1]),确保从组底向上累加 LT;
- 将累计值转为 pd.offsets.BusinessDay,再与各子组的首个 Start 相减,完成业务日回溯。
? 完整可运行代码
import pandas as pd
# 构造示例数据
data = {
'Group': ['A', 'A', 'A', 'B', 'B', 'C', 'C', 'C', 'C', 'C'],
'LT': [5, 10, 0, 3, 0, 2, 4, 0, 0, 0],
'Start': [None, None, '20-03-2024', None, '04-03-2024', None, None, '04-04-2024', '10-04-2024', '24-04-2024']
}
df = pd.DataFrame(data)
# 步骤1:统一转换为 datetime,无效值设为 NaT
df['Start'] = pd.to_datetime(df['Start'], errors='coerce', format='%d-%m-%Y')
# 步骤2:构建逻辑分组标识 grp
# 目标:将每个 Group 中首个 LT==0 行及其上方 NaN 行归为同一子组
s = df['LT'].eq(0) # 标记 LT 为 0 的行(锚点)
grp = s.groupby(df['Group']).cumsum() - s # 关键:使首个 0 行的 grp=0,其上 NaN 行 grp=0,后续 0 行 grp=1,2...
# 步骤3:反向累计 LT(从组底向上),按 [Group, grp] 分组
lt_cumrev = (
df.loc[::-1, 'LT'] # 反序取 LT 列
.groupby([df['Group'], grp]) # 双层分组:确保只在「首个锚点及其上方」子段内累计
.cumsum()
.apply(pd.offsets.BusinessDay) # 转为 BusinessDay 偏移量(自动跳过周末)
)
# 步骤4:获取每个 [Group, grp] 子组的首个 Start 值,并减去累计偏移
base_start = df.groupby(['Group', grp])['Start'].transform('first')
result_dates = (base_start - lt_cumrev).dt.strftime('%d-%m-%Y')
# 步骤5:更新原 DataFrame(仅覆盖 Start 为 NaT 的行)
mask_na = df['Start'].isna()
df.loc[mask_na, 'Start'] = result_dates[mask_na]
print(df)⚠️ 注意事项与最佳实践
- 日期格式一致性:务必在 pd.to_datetime() 中指定 format='%d-%m-%Y'(若原始为 dd-mm-yyyy),否则解析可能出错或产生时区歧义;
- LT == 0 的语义:本方案隐含假设 LT == 0 行即为各组的“锚定日期”。若业务中存在 LT > 0 但 Start 非空的情况,需先用 df.loc[df['Start'].notna(), 'LT'] = 0 显式对齐;
- 节假日扩展:pd.offsets.BusinessDay 默认仅排除周末。如需排除法定节假日,应改用 pd.tseries.offsets.CustomBusinessDay(holidays=your_holiday_list);
- 性能提示:对于百万级数据,避免多次 groupby().transform();本方案仅两次 groupby(grp 构建 + lt_cumrev 计算),已属最优;
-
验证逻辑:建议添加断言检查每组首个非空 Start 是否未被修改:
assert all(df.loc[~df['Start'].isna()].groupby('Group').head(1)['Start'] == pd.to_datetime(df['Start'], errors='coerce').groupby(df['Group']).first())
该方法将动态起始点识别与业务日精确回溯无缝融合,是 Pandas 高级分组技巧的典型应用——既规避了显式循环的低效,又超越了简单 bfill() 或 cumsum() 的功能边界。










