基于账户付款行为创建 Status 列
结论
仅看示例中的付款列,无法可靠区分 contract14 的 Renewal 和 contract22 的 Winback。两行的付款数据都是空值,还需要结合各份合同的生效日期,例如 date_activated,检查合同生效前是否连续 4 个月没有付款。
可以按以下方式处理:
- 按
account_id和date_activated对合同排序。 - 提取每份合同可用于比较的月付款金额。
- 检查合同生效前 4 个完整月份内,该账户是否一直没有付款。
- 如果不属于
Winback,再比较当前合同与前一份合同的金额。 - 如果当前合同没有单独填写付款金额,就沿用上一份合同的金额,并归为
Renewal。
下面的实现不需要在逐行 apply 中使用复杂闭包,同时可以处理 Upgrade、Downgrade 和浮点数比较。
完整实现
假设原始数据中有 date_activated 字段,所有付款列均采用 YYYY-MM-DD 格式:
import re
import numpy as np
import pandas as pd
# 找出付款月份列,避免依赖固定的列位置
payment_cols = [
col for col in df.columns
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(col))
]
payment_cols = sorted(
payment_cols,
key=lambda col: pd.to_datetime(col)
)
result = df.copy()
result["date_activated"] = pd.to_datetime(result["date_activated"])
# 用月份作为索引,便于检查生效前的 4 个自然月
period_to_col = {
pd.Period(pd.to_datetime(col), freq="M"): col
for col in payment_cols
}
def classify_account(group):
group = group.sort_values(
["date_activated", "_row_order"],
kind="stable"
).copy()
payments = group[payment_cols].apply(
pd.to_numeric,
errors="coerce"
)
# 这里把合同第一个非空付款额作为该合同的月付款额。
# 如果业务上应使用最大值或平均值,可修改这一行。
contract_amount = payments.bfill(axis=1).iloc[:, 0]
# 全空合同默认沿用上一份合同的金额。
# Winback 会在金额比较之前判断,因此不会被误判为 Renewal。
effective_amount = contract_amount.ffill()
# 计算账户每个月的总付款。
# 当该月所有合同都是 NaN 时,将账户付款视为 0。
account_monthly = (
payments
.sum(axis=0, min_count=1)
.fillna(0)
)
account_monthly.index = [
pd.Period(pd.to_datetime(col), freq="M")
for col in payment_cols
]
statuses = []
for position, (_, row) in enumerate(group.iterrows()):
# 假设账户按时间排列后的第一份合同产生了首次付款
if position == 0:
statuses.append("Original")
continue
activation_month = pd.Period(
row["date_activated"],
freq="M"
)
previous_four_months = [
activation_month - offset
for offset in range(4, 0, -1)
]
# 只有数据完整覆盖这 4 个月时,才允许判定 Winback。
history_is_complete = all(
month in account_monthly.index
for month in previous_four_months
)
is_winback = (
history_is_complete
and account_monthly
.reindex(previous_four_months)
.eq(0)
.all()
)
if is_winback:
statuses.append("Winback")
continue
current_amount = effective_amount.iloc[position]
previous_amount = effective_amount.iloc[position - 1]
if pd.isna(current_amount) or pd.isna(previous_amount):
raise ValueError(
"无法比较合同金额:当前合同和前一份合同均没有可用付款额,"
f"account_id={row['account_id']!r}, "
f"contract_id={row['contract_id']!r}"
)
if np.isclose(current_amount, previous_amount):
statuses.append("Renewal")
elif current_amount > previous_amount:
statuses.append("Upgrade")
else:
statuses.append("Downgrade")
group["status"] = statuses
return group
result["_row_order"] = np.arange(len(result))
result = (
result
.groupby("account_id", group_keys=False, sort=False)
.apply(classify_account)
.sort_values("_row_order")
.drop(columns="_row_order")
.reset_index(drop=True)
)
# 将 status 放到 contract_id 后面
status = result.pop("status")
insert_at = result.columns.get_loc("contract_id") + 1
result.insert(insert_at, "status", status)
print(result)
判断顺序为什么重要
必须先判断 Winback,再比较付款金额。
如果一份新合同的付款列全为空,直接对合同金额执行 ffill(),它会继承上一份合同的金额,于是被判为 Renewal。但如果该合同生效前已经连续 4 个月没有任何账户付款,正确结果应该是 Winback。
判断顺序如下:
第一份合同
→ Original
否则,生效前连续 4 个月无付款
→ Winback
否则,比较当前合同和前一份合同的月付款
→ Upgrade / Downgrade / Renewal
原代码为什么得不到预期结果
原代码有几个彼此独立的问题。
首先,内部函数 f() 修改了 s,却没有返回结果:
def f(s):
...
s["status"] = "Renewal"
在 apply() 中使用时,需要返回处理后的对象:
return s
其次,现有分支没有处理 Downgrade。当前金额低于上一份合同时,代码会进入最后的 else,从而被错误地标记为 Renewal。
下面这段代码也有问题:
monthly_payments.astype(bool)
在 pandas/NumPy 中,不能简单地把 NaN 转换后的布尔值理解为”没有付款”。判断付款状态时,应明确使用:
monthly_payments.notna()
判断没有付款时,也可以使用:
monthly_payments.fillna(0).eq(0)
原代码还按照账户每个月的最大付款额进行比较:
monthly_payments["max_pmt"] = monthly_payments.max(axis=1)
这样会丢失同一个月内不同合同之间的金额差异。例如,contract12 为 300,contract13 为 100。如果只保留当月最大值 300,就无法把前者判为 Upgrade、后者判为 Downgrade。
示例数据需要满足的前提
要准确得到给出的预期结果,至少需要满足以下条件:
date_activated能确定合同的实际先后顺序。- 数据完整包含判断
Winback所需的月份。 contract22生效前确实有连续 4 个完整月份的零付款记录。contract14生效前没有连续 4 个月无付款,因此其空付款额按沿用上一份合同处理,并归为Renewal。- 同一份合同中的多个非空付款值代表相同的月付款水平。
如果合同的月付款会发生变化,就不能直接取第一个非空值。此时需要先确定比较口径,例如生效月金额、最近一次金额、平均值或最大值,然后替换下面这行代码:
contract_amount = payments.bfill(axis=1).iloc[:, 0]
如果缺少 date_activated 和完整的历史月份,代码仍可以按行顺序比较金额,但无法可靠判断 Winback,也无法仅根据两份全空合同推断其中一份是 Renewal,另一份是 Winback。
备注:内容仅供参考。
