PAYATHON 2026

在 python dataframe 中查找每位客户的最近付款

支付老李

结论

先把 PDATE 转为日期类型,再用 groupby('EXTRELNO')['PDATE'].idxmax() 找到每位 donor 最近一次付款对应的行索引。通过这些索引可以直接取出完整的 payment 记录,包括 PAMT、PID、PAYTYPECD 和 STATUS。

付款频次可用 groupby().size() 计算。按付款类型或状态统计频次时,可以使用 groupby().size().unstack() 或 pd.crosstab()。

获取每位 donor 最近一次付款的完整记录

import pandas as pd

df_pay['PDATE'] = pd.to_datetime(df_pay['PDATE'], errors='coerce')

latest_idx = df_pay.groupby('EXTRELNO')['PDATE'].idxmax()
latest_payment = df_pay.loc[latest_idx].reset_index(drop=True)

print(latest_payment)

idxmax() 会返回每组最大 PDATE 所在行的原始索引,再通过 .loc[] 取出整行。这样得到的不只是最近付款日期,还包括该笔付款的 PAMT 和其他字段。

如果只需要部分字段,可以写成:

latest_payment = (
    df_pay.loc[latest_idx, [
        'EXTRELNO',
        'PID',
        'PDATE',
        'PAMT',
        'PAYTYPECD',
        'STATUS'
    ]]
    .reset_index(drop=True)
)

如果某位 donor 的日期全部为空,idxmax() 无法处理。可以先过滤无效日期:

valid_payments = df_pay.dropna(subset=['PDATE'])

latest_idx = valid_payments.groupby('EXTRELNO')['PDATE'].idxmax()
latest_payment = valid_payments.loc[latest_idx].reset_index(drop=True)

同一天有多笔付款时如何处理

如果同一位 donor 在最近日期有多笔付款,idxmax() 默认只返回第一笔,因此需要先确定业务规则。

如需保留最近日期的所有付款:

latest_date = df_pay.groupby('EXTRELNO')['PDATE'].transform('max')

latest_payments_all = (
    df_pay[df_pay['PDATE'].eq(latest_date)]
    .sort_values(['EXTRELNO', 'PID'])
    .reset_index(drop=True)
)

如果只保留一笔,并把较大的 PID 视为较晚生成的记录:

latest_payment = (
    df_pay.sort_values(
        ['EXTRELNO', 'PDATE', 'PID'],
        ascending=[True, False, False]
    )
    .drop_duplicates('EXTRELNO', keep='first')
    .reset_index(drop=True)
)

这种写法假设较大的 PID 表示付款记录录入得更晚。如果 PID 本身没有时间顺序,应改用实际的交易时间或创建时间字段。

计算每位 donor 的 donation frequency

如果每一行代表一笔付款,那么付款频次就是每位 donor 的记录数:

donation_frequency = (
    df_pay.groupby('EXTRELNO')
    .size()
    .rename('DONATION_FREQUENCY')
    .reset_index()
)

也可以统计非空 PID 的数量:

donation_frequency = (
    df_pay.groupby('EXTRELNO')['PID']
    .count()
    .rename('DONATION_FREQUENCY')
    .reset_index()
)

两种写法有所不同:

  • size() 统计所有行;
  • count() 只统计指定字段不为空的行。

如果 PID 是付款记录的唯一标识,而数据中可能存在重复行,可以统计唯一付款数:

donation_frequency = (
    df_pay.groupby('EXTRELNO')['PID']
    .nunique()
    .rename('DONATION_FREQUENCY')
    .reset_index()
)

如果业务中的 donation frequency 指的是发生过付款的天数,就应该统计不同日期,而不是付款笔数:

donation_day_frequency = (
    df_pay.groupby('EXTRELNO')['PDATE']
    .nunique()
    .rename('DONATION_DAY_FREQUENCY')
    .reset_index()
)

统计每种 PAYTYPECD 的频次

统计整个数据集中每种付款类型出现的次数:

paytype_frequency = (
    df_pay['PAYTYPECD']
    .value_counts(dropna=False)
    .rename_axis('PAYTYPECD')
    .reset_index(name='FREQUENCY')
)

统计每位 donor 使用各种付款类型的次数:

paytype_by_donor = pd.crosstab(
    df_pay['EXTRELNO'],
    df_pay['PAYTYPECD']
).reset_index()

也可以用 groupby() 生成长表:

paytype_by_donor_long = (
    df_pay.groupby(['EXTRELNO', 'PAYTYPECD'], dropna=False)
    .size()
    .rename('FREQUENCY')
    .reset_index()
)

长表便于后续合并和建模,宽表则便于直接查看,也可以直接作为特征使用。

统计每种 STATUS 的频次

统计整个数据集中各种付款状态出现的次数:

status_frequency = (
    df_pay['STATUS']
    .value_counts(dropna=False)
    .rename_axis('STATUS')
    .reset_index(name='FREQUENCY')
)

统计每位 donor 的各种状态次数:

status_by_donor = pd.crosstab(
    df_pay['EXTRELNO'],
    df_pay['STATUS']
).reset_index()

对应的长表写法是:

status_by_donor_long = (
    df_pay.groupby(['EXTRELNO', 'STATUS'], dropna=False)
    .size()
    .rename('FREQUENCY')
    .reset_index()
)

如果还需要统计 PAYTYPECD 和 STATUS 的组合频次:

payment_status_frequency = (
    df_pay.groupby(
        ['EXTRELNO', 'PAYTYPECD', 'STATUS'],
        dropna=False
    )
    .size()
    .rename('FREQUENCY')
    .reset_index()
)

合并为 donor 级别的数据集

进行 churn prediction 时,通常需要把数据整理成每位 donor 一行。可以将最近付款、付款频次、付款类型频次和状态频次合并到一起:

latest_idx = (
    df_pay.dropna(subset=['PDATE'])
    .groupby('EXTRELNO')['PDATE']
    .idxmax()
)

latest_payment = (
    df_pay.loc[latest_idx, [
        'EXTRELNO',
        'PID',
        'PDATE',
        'PAMT',
        'PAYTYPECD',
        'STATUS'
    ]]
    .rename(columns={
        'PID': 'LATEST_PID',
        'PDATE': 'LATEST_PDATE',
        'PAMT': 'LATEST_PAMT',
        'PAYTYPECD': 'LATEST_PAYTYPECD',
        'STATUS': 'LATEST_STATUS'
    })
)

donation_frequency = (
    df_pay.groupby('EXTRELNO')['PID']
    .nunique()
    .rename('DONATION_FREQUENCY')
    .reset_index()
)

paytype_features = (
    pd.crosstab(df_pay['EXTRELNO'], df_pay['PAYTYPECD'])
    .add_prefix('PAYTYPE_FREQ_')
    .reset_index()
)

status_features = (
    pd.crosstab(df_pay['EXTRELNO'], df_pay['STATUS'])
    .add_prefix('STATUS_FREQ_')
    .reset_index()
)

donor_features = (
    latest_payment
    .merge(donation_frequency, on='EXTRELNO', how='left')
    .merge(paytype_features, on='EXTRELNO', how='left')
    .merge(status_features, on='EXTRELNO', how='left')
)

生成的 donor_features 中,每位 donor 只有一行,其中包含最近付款信息、总付款频次、各付款类型的频次和各状态的频次。

注意付款状态的业务含义

STATUS 可能包含退款、冲正、撤回等非正常付款。是否将这些记录计入 donation frequency,需要根据业务定义决定。如果只统计正常且有效的付款,应先过滤数据:

normal_payments = df_pay[df_pay['STATUS'].eq('OK')].copy()

之后在 normal_payments 上计算最近付款和频次。如果退款需要抵扣历史付款金额,仅统计记录行数并不足够,还要根据状态定义调整 PAMT 的正负方向,或者建立净捐赠金额指标。在没有确认状态含义之前,不应直接把所有记录都视为有效 donation。

读取数据时还要确保 EXTRELNO 保持字符串类型,否则 0000019 这类 donor code 会丢失前导零:

df_pay = pd.read_csv(
    'payments.csv',
    dtype={'EXTRELNO': 'string'},
    parse_dates=['PDATE']
)

备注:内容仅供参考。