在 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']
)
备注:内容仅供参考。
