PAYATHON 2026

基于账户付款行为创建 Status 列

支付老李

结论

仅看示例中的付款列,无法可靠区分 contract14 的 Renewal 和 contract22 的 Winback。两行的付款数据都是空值,还需要结合各份合同的生效日期,例如 date_activated,检查合同生效前是否连续 4 个月没有付款。

可以按以下方式处理:

  1. 按 account_id 和 date_activated 对合同排序。
  2. 提取每份合同可用于比较的月付款金额。
  3. 检查合同生效前 4 个完整月份内,该账户是否一直没有付款。
  4. 如果不属于 Winback,再比较当前合同与前一份合同的金额。
  5. 如果当前合同没有单独填写付款金额,就沿用上一份合同的金额,并归为 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。

备注:内容仅供参考。