1. 引言
按期支付模式通常用于包月会员、分期购物、个人贷款等业务的缴费。用户在成单之后可以成功续多少期、未来一共能收多少钱是这类业务关心的重点。本文设计了一套DNN模型,用于预估这类支付场景下订单续期率和生命周期总价值(LTV)。
环境:Python 3.7, tensorflow 2.1.0
2. 按期支付模式介绍
通过互联网渠道转化的用户一般会签约支付宝或微信等支付平台的自动扣费功能。以月缴、12期的支付模式为例:
- 用户在下单日(D)支付首期费用并与支付平台签约自动扣费;
- 往后每月从D~D+30,支付平台会按一定频率发起自动扣费,直至扣费成功。其中30为支付的宽限天数,过了宽限日期之后,订单自动失效。
- 同时,用户在扣费成功之后的30日内可以选择解约或退费,解退动作发生之后订单也随之失效,生命周期结束。如果没有发生解退,订单自动进入到下一期,12期满之后订单会自动失效。
针对这个场景,我们需要建立一套模型在订单粒度预估订单从成单至失效一共能收回多少钱(LTV)。
3. 模型设计
3.1 任务确定与选型
如果直接以总目标LTV值作为label、当做回归任务来建模是不太合理的:
- 只有12期已过的订单才能获取label,这些订单距离新单时间跨度太长,预测和训练数据集分布可能已经发生了变化;
- 模型输出单一且耦合性高,在此基础上做营收、ROI评估,以及相关的分析、监控和二次建模的工作会变得十分困难。为此,我们需要对目标进行拆解。
对预估目标进行拆解
首先,订单的LTV实际上是12期实收之和,因此我们可以将总目标拆解到各期次上分别建模。而每个期次的实收金额由扣费、解约、退费三个动作决定。针对这三个动作,需要分别训练一个模型来预估其在整期次下发生的概率。至此,每个期次我们需要训练3个模型。
当作分类任务来做
模型的预测要覆盖订单的整个生命周期,无论订单处于12期的哪个阶段,只要未失效,我们都需要对订单未来的价值进行预估。如:
-
订单A处于成单当日,我们需要预估各期次D~D+30整个阶段行为发生的概率;
-
订单B处于第二期(M2)的D+15,M2需要预估的是D+16~D+30期间行为发生的概率,M2之后的期次同订单A一样,需要预估整个阶段的概率。对于这类需要随订单所处阶段动态预估的场景,我们有两种建模方案:
方案一
将日期作为特征加入,以时间序列的方式构造训练数据集,并对行为实际发生之后的日期记录进行mask。具体序列数据样例flatten后如下图-1所示。
图-1 序列数据集:其中PO0001在第15日有动作发生,15日之后记录被mask掉;PO0002在D~D+30日都没有动作发生,30条记录完整保留
方案二
预估各天上动作发生的概率,采用多分类的任务将发生的日期作为label,没发生的订单也单独作为一类,label取值共31类,训练数据集样例如下图-2所示
图-2 多分类训练数据集,label 0~30共31类,其中0~29代表订单分别在各期次1~30日发生动作,lebel 30表示订单不发生动作。
多分类任务模型在预测阶段输出概率之后,还需要根据订单实际所处的阶段进行聚合,得到未来天数累积发生的概率,计算公式如下:
其中ri表示订单处于当前期次的第i+1日,pi为各类别下模型输出的概率,ri为第i+2日~i+30日发生动作的概率,聚合实现方式如下:
def prob2rate(prob,days):
"""
:param prob: [n_sample, 31] 前30列为每期30日内(索引0~29)行为发生的概率分布,第31列为行为不发生的概率
:param days: [n_sample, 1] 样本当前已经历的天(-1~29),-1表示当前期次还未到,0表示处在第1天
:return:[n_sample,1]
根据行为发生的概率分布prob与已经历的天days,计算样本已过n日行为仍未发生,但在未来仍然可能发生的概率
"""
if prob.shape[0] != days.shape[0]:
raise ValueError("the sample num of pred and days are not equal!")
if prob.shape[1] != 31:
raise ValueError("Unexpected pred shape[1] %d, expect 31" % (prob.shape[1]))
days_mask = []
for i in range(31):
days_mask.append(i > days)
days_mask = tf.cast(tf.concat(days_mask, axis=1), tf.int32) # [n_sample, 31]
numerator = tf.reduce_sum(prob[:,:-1]*days_mask[:,:-1],axis=1,keepdims=True) #[n_sample, 30]*[n_sample, 30]->[n_sample, 1]
denominator = tf.reduce_sum(prob*days_mask,axis=1,keepdims=True) #[n_sample, 1]
rate = tf.divide(numerator,denominator)
return rate
在实际项目中我们采用的是第2种多分类任务去搭建模型,主要考虑点如下:
- 输出维度更全,在如此全面输出的基础上继续做分析、监控、纠偏更方便。比如业务方计划在第i天施加一个运营的动作,并做了相应的A/B实验,需要重新预估施加动作之后的续期率与当期实收。针对这样的场景我们既可以用A/B测回收的数据对相应模型做微调,或是在此基础上重新训练一个小模型,将原模型的输出作为输入进行纠偏。
- 训练所用的数据集更小,如果采用序列数据训练二分类模型,加入天数特征后每条订单样本需要膨胀1~30倍,会导致训练数据集过于庞大,训练时所耗费的资源更大,性能还不见得能有提升。
3.2 网络结构
图-3 宽限期概率分布预测DNN结构示意图
我们选用标准的Embedding+MLP范式的模型设计,结构如图-3所示,特征处理方面在Embedding之后采取了显式的特征交叉(inner product)来提升模型的性能。对于历史行为类的序列特征(sequence),如成单用户历史上最近几次下单行为,在时间步上做一个注意力的汇聚,注意力机制参考 arXiv:1512.08756,实现如下 :
import tensorflow as tf
from tensorflow.keras.layers import Layer, Dense
import tensorflow.keras.backend as bk
class AttentionLayer(Layer):
"""
注意力层,用于行为特征序列的汇聚:
input: [None, T, n] T个时间步,n个特征
output: [None, n] 通过两个Dense层+softmax学出时间步权重score [None, 1, T],然后经过矩阵乘法运算对各时间步特征加权求和
"""
def __init__(self,k_dim):
super(AttentionLayer, self).__init__()
self.k_dim = k_dim
self.w = Dense(self.k_dim, activation='relu')
self.h = Dense(1, activation=None)
def call(self, inputs, **kwargs): # [None, T, n]
if bk.ndim(inputs) != 3:
raise ValueError(
"Unexpected inputs dimensions %d, expect 3 dimensions" % (bk.ndim(inputs))
)
x = self.w(inputs) # [None, T, k_dim]
x = self.h(x) # [None, T, 1]
score = tf.nn.softmax(x,axis=1) # [None,T,1]
score = tf.transpose(score,[0,2,1]) # [None,1,T]
value = tf.matmul(score, inputs) # [None, 1, T]x[None, T, n] = [None, 1, n]
output = tf.reshape(value, [-1, inputs.shape[-1]]) # [None, n]
return output
inner product实现如下:
class InnerProductLayer(Layer):
"""
计算特征内积
"""
def __init__(self):
super(InnerProductLayer, self).__init__()
def call(self, inputs, **kwargs):
if bk.ndim(inputs) != 3:
raise ValueError(
"Unexpected inputs dimensions %d, expect 3 dimensions" % (bk.ndim(inputs))
)
field_num = inputs.shape[1]
idx_i, idx_j = [], []
for i in range(field_num-1):
for j in range(i + 1, field_num):
idx_i.append(i)
idx_j.append(j)
i_interaction = tf.gather(inputs, idx_i, axis=1)
j_interaction = tf.gather(inputs, idx_j, axis=1)
product = tf.reduce_sum(i_interaction * j_interaction, axis=-1, keepdims=False)
return product
将几个部分组合后,DNN实现如下:
# -*- coding:utf-8 -*-
# @Author : Wang Jiahui
# @File : joint.py
import tensorflow as tf
from tensorflow.keras.layers import Dense
from tensorflow.keras.models import Model
from layers.products import InnerProductLayer
from base.Embeddings import Embeds
from base.Deep import Deep, ResDeep
from layers.attentions import AttentionLayer
class DNNClassifier(Model):
def __init__(self, dense_feature_info=None, sparse_feature_info=None, behavior_feature_info=None,
hidden_unit=None, att_dim=None, output_unit=31, dropout=0.2):
"""
:param dense_feature_info: e.y. [{name:'D1', idx:0}, {name:'D2', idx:1}...]
:param sparse_feature_info: e.y. [{name:'S1', idx:10, 'onehot_dim':12215, 'embed_size':128},
# {name:'S2', idx:11, 'onehot_dim':327621,'embed_size':128}...]
:param behavior_feature_info: e.y. [[{name:'BS1', idx:20, 'onehot_dim':1000, 'embed_size':128},
# {name:'S2', idx:21, 'onehot_dim':2000,'embed_size':128}...],
[{name:'BS1', idx:20, 'onehot_dim':1000, 'embed_size':128},
# {name:'S2', idx:21, 'onehot_dim':2000,'embed_size':128}...]...]
:param hidden_unit: hidden units of Deep(MLP)
:param att_dim: hidden units of attention layer
:param output_unit: output unit
:param dropout: dropout rate of MLP
"""
super(DNNClassifier, self).__init__()
self.dense_feature_info = dense_feature_info
self.sparse_feature_info = sparse_feature_info
self.behavior_feature_info = behavior_feature_info
self.hidden_unit = hidden_unit
self.att_dim = att_dim
self.output_unit = output_unit
self.dropout = dropout
self.common_embeds = Embeds(self.sparse_feature_info) # embeds for common sparse feat
self.seq_embeds = [Embeds(t, True) for t in self.behavior_feature_info] # embeds for sequential features
self.deep = Deep(self.hidden_unit, self.dropout) # MLP
self.inner_product_layer = InnerProductLayer() # inner product layer for common sparse features
self.attention_layer = AttentionLayer(self.att_dim) # attention layer for sequential features
self.output_layer = Dense(self.output_unit) # output layer
def call(self, inputs, training=None, mask=None):
common_embed_x = self.embeds(inputs) # [None, n, k]
product = self.inner_product_layer(common_embed_x) # [None, n*(n-1)/2]
common_embed_x = tf.reshape(common_embed_x,
[-1, common_embed_x.shape[1] * common_embed_x.shape[2]]) # [None, n*k]
dense_x = tf.gather(inputs, [dfeat['idx'] for dfeat in self.dense_feature_info], axis=1)
behavior_x = []
for t_embed in self.seq_embeds:
behavior_x.append(behavior_x)
behavior_x = tf.transpose(tf.convert_to_tensor(behavior_x),[1,0,2]) # [None, T, m]
behavior_x = self.attention_layer(behavior_x) # [None, m]
x = tf.concat([dense_x, common_embed_x, product, behavior_x]) # [None, m+n*k+...]
x = self.deep(x)
output = self.output_layer(x) # [None, 31]
return tf.nn.softmax(output,axis=1) # [None, 31]
设计的这一个DNN模型将用于预估单个期次下、单个动作发生的概率,我们可以根据实际需要进行预估的期次和每个期次下可能发生的动作训练多个这样的DNN,最后按计算公式将预估结果合并得到每期次的续期率、实收与订单总LTV的预估值。
4. 小结
本文介绍了一种按期支付订单续期率的预估场景,并提出了分期次、分行为动作分别进行建模的方案,然后设计了一套神经网络结构用于预估订单在单期次下、单个动作发生的概率。由于实验部分容易涉密,本文没有提供模型训练以及实验相关的内容。读者可以根据自己的业务和数据,参考本文提出的模型设计方案进行实验。