赛事官方入口:https://codalab.lisn.upsaclay.fr/competitions/823#learn_the_details-overview
深度强化实验室的中文说明:
http://deeprl.neurondance.com/d/583-ai
http://deeprl.neurondance.com/d/584-ai
现在商家想要将促销策略从个性化促销转换为平等化促销,执行的方式是发放优惠券,我们可以通过控制优惠券的数量和折扣来达成目标,通过对不同的消费者投放不同数量不同折扣的消费券来达成目的。
对于非平等化促销策略,它输入单个用户状态,输出给单个用户发放的促销动作,因此每个人的促销动作可以各不相同。要学习一个平等化促销策略,它输入的是全体用户的状态,输出一个给全体用户发放相同的促销动作。
如图所示,由于用户的个体间存在的差异,用户对于促销策略的反应也不相同,反应这些用户差异的信息我们称为用户状态,我们的网络模型接收这个用户的状态,然后输出对应的促销动作,如何优化这个策略网络使得促销效果最好是我们本次比赛的任务。当用户接收了我们的促销动作之后,就会输出对应的动作:即用户当天订单数以及订单的平均金额,这时候用户的状态也会随着更新而改变。也就是说,用户相当于强化学习的环境本身,环境的输入是用户前一天的状态以及促销的动作,环境的输出为用户今天的状态以及用户对应的动作,用户的动作可以视作环境的奖励。
下面我们来看看官方提供的数据:
官方初赛的数据是一个csv文件,里面包含了1000个用户60天内的数据,总共有6000条数据。
每一列代表的含义是:
index:用户ID。
day_deliver_coupon_num:营销平台当天发放的优惠券张数。注意,每张优惠券的有效期为当日。
coupon_discount:营销平台当天发放的优惠券折扣。
day_order_num:用户当天订单次数。优惠券默认会随着订单使用,直到优惠券用完。
day_average_order_fee:用户当天所有订单折扣前的平均金额。
step:天数,范围0-59。
date:日期,范围为2021/03/19到2021/05/17。
我们需要自行定义用户的状态输入来对我们的策略网络进行训练,定义一个合理的状态输入对策略有着至关重要的影响。
评分规则:
目标:在Total_ROI>=6.5的前提下,最大化Total_GMV。
定义优惠订单数(coupon_order_num)为:
c o u p o n _ o r d e r _ n u m = m i n ( d a y _ d e l i v e r _ c o u p o n _ n u m , d a y _ o r d e r _ n u m ) coupon\_order\_num = min(day\_deliver\_coupon\_num , day\_order\_num) coupon_order_num=min(day_deliver_coupon_num,day_order_num)
定义成本(coupon_order_fee)为:
c o u p o n _ o r d e r _ f e e = c o u p o n _ o r d e r _ n u m ∗ d a y _ a v e r a g e _ o r d e r _ f e e ∗ ( 1 − c o u p o n _ d i s c o u n t ) coupon\_order\_fee = coupon\_order\_num * day\_average\_order\_fee * (1-coupon\_discount) coupon_order_fee=coupon_order_num∗day_average_order_fee∗(1−coupon_discount)
定义单人应收(Per_GMV)为:
P e r _ G M V = d a y _ o r d e r _ n u m ∗ d a y _ a v e r a g e _ o r d e r _ f e e − c o u p o n _ o r d e r _ f e e Per\_GMV = day\_order\_num * day\_average\_order\_fee - coupon\_order\_fee Per_GMV=day_order_num∗day_average_order_fee−coupon_order_fee
总营收(Total_GMV):
T o t a l _ G M V = ∑ P e r _ G M V Total\_GMV = \sum Per\_GMV Total_GMV=∑Per_GMV
总成本(Total_Cost):
T o t a l _ C o s t = ∑ c o u p o n _ o r d e r _ f e e Total\_Cost = \sum coupon\_order\_fee Total_Cost=∑coupon_order_fee
总盈利率(Total_ROI):
T o t a l _ R O I = T o t a l _ G M V / m a x ( T o t a l _ C o s t , 1 ) Total\_ROI = Total\_GMV \ / \ max(Total\_Cost,1) Total_ROI=Total_GMV / max(Total_Cost,1)
因此,用户输出的动作决定了我们强化学习的奖励,若Total_ROI<6.5,得分为0。也就是说我们训练的策略网络决不能让用户的动作去越过这条红线。在此基础上,得分为Total_GMV,也就是说满足一定利率的情况下,尽最大可能提高应收总额。
参赛者基于大赛提供的接口,上传测评代码以及相关模型压缩文件, 解压后项目最外层目录定义好入口文件policy_validation.py, 并根据我们提供的模板,实现抽象类PolicyValidation,完成policy_validation.py。
policy_validation.py内容如下,参赛者主要需实现:
get_next_states (cur_states, coupon_action, user_actions)
输入当天所有用户的动作,输出由参赛者自行定义的次日用户状态。注意这里和一般强化学习不同的地方在于,下一个状态是需要自己定义的,环境负责输出的是一个用户的动作,这个用户的动作包含了奖励以及下一个状态的信息,如何运用这个用户的动作及其它数据来构成下一个状态是一个值得研究的课题。
get_action_from_policy (user_states)
输入当天用户状态,由参赛者策略输出当天所有用户的促销动作。这个就是我们需要训练的策略网络模型。
下面是一个模板:
class PolicyValidation:
"""定义评测程序所需接口的虚拟类。
"""
"""initial_states 是参赛者根据离线数据定义的评测第一天(自5月18日开始)的用户状态。
"""
initial_states: Any = None
@abstractmethod
def __init__(self, *args, **kwargs):
"""根据自身需求初始化模型所需变量。参赛者可以为此构造函数提供一些形参,
但必须在 get_pv_instance() 函数中自行填充策略类所需的实参。
"""
@abstractmethod
def get_next_states(self, cur_states: Any, coupon_action: np.ndarray, user_actions: List[np.ndarray]) -> Any:
"""基于当天全体用户状态、当天的发券动作、与当天响应的用户动作, 生成次日用户状态。
Args:
cur_states (Any): 当天全体用户状态,具体形式由参赛者定义。
coupon_action (np.ndarray): 当天发券动作。
example: np.array([2.0, 0.70]), 即当天给所有用户发2张7折优惠券。
user_actions (List[np.ndarray]): 环境返回的当天所有用户动作, 具体形式和离线数据中一致。
example: [np.array([1.0, 22.34]), np.array([0, 0]), ... , np.array([2.0, 54.21])], 列表大小
和离线数据中用户数一致, 且按顺序一一对应,即 user_actions[0] 对应离线数据中 index 为0的用户。
Return:
next_states (Any): 次日全体用户状态,具体形式由参赛者定义。
"""
@abstractmethod
def get_action_from_policy(self, user_states: Any) -> np.ndarray:
"""基于当天全体用户状态输出当天发券动作
Args:
user_states (Any): 当天全体用户状态,具体形式由参赛者定义。
Return:
coupon_action (np.ndarray): 当天由参赛者策略给出的发券动作(所有用户相同), 具体形式和离线数据保持一致。
example: np.array([2.0, 0.70]), 即当天给所有用户发两张7折优惠券。
"""
class MyPolicyValidation(PolicyValidation):
"""在此继承 PolicyValidation 接口类,实现参赛者自己的策略模型。
"""
pass
def get_pv_instance() -> PolicyValidation:
"""返回一个符合 PolicyValidation 接口类的实例。该函数会被评测程序调用,用于获取参赛者的策略实现。"""
return MyPolicyValidation()
平台的策略测试评估核心代码如下:
def validate(self, pv):
"""在平台真实环境中评估策略性能
Args:
pv (PolicyValidation): 参赛者实现的类型为PolicyValidation的实例化对象
Return:
Total_ROI, Total_GMV: 参赛者的策略在测试期的总ROI和总GMV
"""
Total_GMV = 0.0
Total_Cost = 0.0
user_states = pv.initial_states
for day_index in range(self.validation_length):
print('Day', day_index+1)
self.reset()
coupon_action = pv.get_action_from_policy(user_states)
Cost, GMV, user_actions = self.step(coupon_action)
user_states = pv.get_next_states(user_states, coupon_action, user_actions)
Total_GMV += GMV
Total_Cost += Cost
Total_ROI = Total_GMV / max(Total_Cost, 1)
return Total_ROI, Total_GMV
如此,在函数的输出Total_ROI不小于6.5下,Total_GMV就代表了最终的分数。
为了学习用户模型,我们首先需要定义用户状态(即用户画像)。作为baseline,我们采取最简单(而非最好)的方式来定义每个用户的状态。
比赛数据提供了历史60天的促销动作和用户动作,每一个用户收到的促销动作每天各不相同。我们仅取60天中的前30天数据去定义用户的初始状态,后30天的数据将用于学习用户模型。
我们定义了如下表所示的三维特征,来表示每一个用户的初始状态。值得注意的是,表1所定义的用户状态是最简单的一种方式,参赛者为了获得更好的效果需要自定义更复杂的用户状态。
特征名 | 说明 |
---|---|
total_num | 用户历史总订单数 |
average_num | 用户历史单日订单数的平均(不考虑为0的天) |
average_fee | 用户历史单日订单均价的平均(不考虑为0的天) |
上面的三个数构成的三维向量代表了用户的状态,根据这个状态,我们需要训练一个策略网络,输入该状态,输出需要发放的优惠券的数量和折扣。当用户得到了优惠券的数量和折扣后,就会输出一个用户的动作,即当天的订单数以及订单的平均金额。这是由环境决定的,属于不可控因素的一部分,但我们可以另外训练一个环境的模型来拟合用户的行为。
注意:用户的状态可能还与促销的动作相关,但在baseline中没有把这个因素考虑进去。
当得到用户当前的状态和用户采取的动作之后,我们可以定义用户的下一个状态:
import numpy as np
next_state = np.empty(state.shape)
size = (state[0] / state[1]) if state[1] > 0 else 0
next_state[0] = state[0] + act[0]
next_state[1] = state[1] + 1 / (size + 1) * (act[0] - state[1]) * float(act[0] > 0.0)
next_state[2] = state[2] + 1 / (size + 1) * (act[1] - state[2]) * float(act[1] > 0.0)
我们把前30天的用户数据作为第31天的用户状态,我们可以通过第31天~第60天的用户动作,我们就可以去生成第32天到第61天的用户状态。
Revive SDK
支持通过编写yaml文件来描述决策流程。
metadata:
graph:
action_1:
- state
action_2:
- state
- action_1
next_state:
- action_2
- state
columns:
- total_num:
dim: state
type: discrete
max: 199
min: 0
num: 200
- average_num:
dim: state
type: continuous
- average_fee:
dim: state
type: continuous
- day_deliver_coupon_num:
dim: action_1
type: discrete
max: 5
min: 0
num: 6
- coupon_discount:
dim: action_1
type: discrete
max: 0.95
min: 0.6
num: 8
- day_order_num:
dim: action_2
type: discrete
max: 99
min: 0
num: 100
- day_average_order_fee:
dim: action_2
type: continuous
以graph开头的部分负责描述决策流程。其中state
、action_1
、action_2
、next_state
是自定义的变量名,在此处分别代表当天用户状态
、促销动作
、用户动作
和次日用户状态
。从yaml文件我们可以看出action_1
的结果受state
影响,对应决策图就是当天用户状态
指向促销动作
,也可以理解为action_1
是输出,state
是对应的输入。同理,action_2
的结果受state
、action_1
影响,next_state
的结果受state
和action_2
的影响。
在graph的下方就是定义state
、action_1
、action_2
各个维度的含义,其中state
包含三个维度,其中,一个离散变量0到199的总订单数total_num,一个连续变量历史单日订单数的平均average_num,一个连续变量历史单日订单均价的平均average_fee。action_1
包含两个维度,其中,一个0到5的离散变量表示当日发放优惠券数day_deliver_coupon_num,一个0.6到0.95的离散变量表示优惠券折扣coupon_discount。action_2
格子包含两个离散变量,其中,一个离散变量表示用户当天的订单数day_order_num,一个连续变量表示用户当天的平均订单花费day_average_order_fee。
定义好yaml文件后,配置好有关训练数据和训练参数就可以调用算法包开始训练,训练完成后我们可以得到我们需要的用户策略模型venv_model
,venv_model
模型的使用方式是:
import numpy as np
def venv_model_use_demo(states, coupon_actions):
"""调用用户策略模型
Args:
states(np.array):用户状态
coupon_actions(np.array):发券动作
Return:
user_actions(np.array):用户动作
"""
out = venv_model.infer_one_step({'state':states, 'action_1':coupon_actions})
return out['action_2']
对于非平等化促销策略,它输入单个用户状态,输出给单个用户发放的促销动作,因此每个人的促销动作可以各不相同。要学习一个平等化促销策略,它输入的是全体用户的状态,输出一个给全体用户发放相同的促销动作。全体用户的状态如果直接拼接成一维数组,是一个非常高维的输入,所以在输入平等化促销策略前,需要降维处理。这里我们采用最简单的方式,去计算当天全体用户状态每一维的统计量,并额外引入了两维实时统计量,实现代码如下:
import numpy as np
def _states_to_obs(states: np.ndarray, day_total_order_num: int=0, day_roi: float=0.0):
"""将所有用户状态的二维数组降维为一维的用户群体的状态
Args:
states(np.ndarray): 包含每个用户各自状态的二维数组
day_total_order_num(int): 全体用户前一天的总订单数,如果是初始第一天,默认为0
day_roi(float): 全体用户前一天的ROI,如果是初始第一天,默认为0.0
Return:
用户群体的状态(np.array)
"""
assert len(states.shape) == 2
mean_obs = np.mean(states, axis=0)
std_obs = np.std(states, axis=0)
max_obs = np.max(states, axis=0)
min_obs = np.min(states, axis=0)
day_total_order_num, day_roi = np.array([day_total_order_num]), np.array([day_roi])
return np.concatenate([mean_obs, std_obs, max_obs, min_obs, day_total_order_num, day_roi], 0)
降维之后得到的的用户群体的状态是平等化促销策略的真正输入,策略的动作空间则和比赛数据中保持一致。我们可以通过以下的方式定义奖励函数:
from gym import Env
class VirtualMarketEnv(Env):
MAX_ENV_STEP = 14 # 评估天数,作为环境的步长, 初赛14, 复赛30
ROI_THRESHOLD = 7.5 # 考虑虚拟环境误差, 评估的总ROI阈值取7.5, 比实际线上6.5要高1.0
ZERO_GMV = 81840.0763705537 #在评估环境中, 在评估天数里, 不发券带来的总GMV
# self.total_gmv: 评估期间总的GMV, self.total_cost: 评估期间总的成本
def step(self, action):
...
# 稀疏reward, 评估最后一天返回最终的reward, 前面的天reward都是0
if (self.current_env_step+1) < VirtualMarketEnv.MAX_ENV_STEP:
reward = 0
else:
total_roi = self.total_gmv / self.total_cost
if total_roi >= VirtualMarketEnv.ROI_THRESHOLD:
# 超过ROI阈值, (总GMV/基线总GMV)作为回报
reward = self.total_gmv / VirtualMarketEnv.ZERO_GMV
else: # 未超过ROI阈值, (总ROI - ROI阈值)作为回报
reward = total_roi - VirtualMarketEnv.ROI_THRESHOLD
确定好整个问题的MDP后,可以使用任何可行的强化学习算法,Baseline中直接使用了Proximal Policy Optimization(PPO)
来训练最终的平等化促销策略。需要注意PPO训练的是随机策略,如果直接提交随机策略,有可能造成每次评估的结果有随机扰动。
训练好平等化促销策略后,需要上传策略进行在线评估。上传的策略是一个打包好的zip文件,其中以policy_validation.py
作为入口点,并通过metadata
文件指定线上评估所需要的环境(我们支持:pytorch-1.8
, pytorch-1.9
, pytorch-1.10
)。
在policy_validation.py
中,有一个PolicyValidation
接口类,以及一个调用后返回一个PolicyValidation
实例的get_pv_instance()
函数。提交策略后,线上环境会调用get_pv_instance()
,并根据PolicyValidation
定义好的接口规范对选手的策略进行评估。选手则需要继承PolicyValidation
接口类,实现需要的抽象方法与成员,并在get_pv_instance()
的实现中返回自己继承的子类实例。
PolicyValidation
类需要实现:
* 以第61天的用户状态数据,初始化PolicyValidation
类的全体用户的初始状态(对应评估的第一天的用户状态)。
* 根据当天全体用户的状态、促销动作和当天响应的用户动作,返回次日全体用户的状态。
* 据当天全体用户的状态,返回当天的促销动作。
import os
import numpy as np
from abc import abstractmethod
from typing import Any, List
# 抽象类
class PolicyValidation:
# 初始化的用户状态由我们之前训练的60天的数据得到
initial_states: Any = None
@abstractmethod
def __init__(self, *args, **kwargs):
"""可以定义你自己的参数以及初始化网络模型等
但必须自己在 get_pv_instance 函数中填写参数。
"""
@abstractmethod
def get_next_states(self, cur_states: Any, coupon_action: np.ndarray, user_actions: List[np.ndarray]) -> Any:
"""利用今日的用户状态,用户的动作,优惠券的动作,返回次日的用户状态."""
@abstractmethod
def get_action_from_policy(self, user_states: Any) -> np.ndarray:
"""利用今日用户的状态,返回优惠券动作"""
def get_pv_instance() -> PolicyValidation:
"""该函数由评估方运行,返回一个策略验证类,用来评估参与者策略效果
"""
from baseline_policy_validation import BaselinePolicyValidation
# os.path.split返回路径和文件名,os.path.abspath(__file__)获取当前脚本的完整路径
submission_dir, _ = os.path.split(os.path.abspath(__file__))
return BaselinePolicyValidation(f"{submission_dir}/data/evaluation_start_states.npy", f"{submission_dir}/data/rl_model.zip")
接下来看看继承这个抽象类的BaselinePolicyValidation是怎么实现的:
import numpy as np
# 通过typing引入注解类型,就能标注函数的返回类型和参数类型了
from typing import List, Optional
from stable_baselines3 import PPO
from policy_validation import PolicyValidation
class BaselinePolicyValidation(PolicyValidation):
# 初始化
def __init__(self, initial_states_path, policy_model_path):
# 导入初始用户状态
self.initial_states = np.load(initial_states_path)
# 导入训练模型
self.policy_model = PPO.load(policy_model_path)
# 数据清零
self.cur_day_total_order_num = 0
self.cur_day_roi = 0.0
# 对用户的状态进行整体的降维
def _states_to_obs(self, states: np.ndarray):
assert len(states.shape) == 2
mean_obs = np.mean(states, axis=0)
std_obs = np.std(states, axis=0)
max_obs = np.max(states, axis=0)
min_obs = np.min(states, axis=0)
day_total_order_num, day_roi = np.array([self.cur_day_total_order_num]), np.array([self.cur_day_roi])
return np.concatenate([mean_obs, std_obs, max_obs, min_obs, day_total_order_num, day_roi], 0)
# 计算用户的下一个状态
def get_next_states(self, cur_states: Optional[List[np.ndarray]], coupon_action: np.ndarray, user_actions: List[np.ndarray]):
# 获取相应的数据
user_actions_array = np.array(user_actions)
day_order_num, day_avg_fee = user_actions_array[:, [0]], user_actions_array[:, [1]]
coupon_num, discount = coupon_action[0], coupon_action[1]
# 下面几行是由一维数组拓展成二维数组的下一个状态计算方式
next_states = np.empty(cur_states.shape)
size_array = np.array([[x[0] / x[1] if x[1] > 0 else 0] for i, x in enumerate(cur_states)])
next_states[:, [0]] = cur_states[:, [0]] + day_order_num
next_states[:, [1]] = cur_states[:, [1]] + 1 / (size_array + 1) * (day_order_num - cur_states[:, [1]]) * (day_order_num > 0.0).astype(np.float32)
next_states[:, [2]] = cur_states[:, [2]] + 1 / (size_array + 1) * (day_avg_fee - cur_states[:, [2]]) * (day_avg_fee > 0.0).astype(np.float32)
self.cur_day_total_order_num = np.sum(day_order_num)
day_coupon_used_num = np.min(np.concatenate([day_order_num, coupon_num * np.ones(day_order_num.shape)], -1), -1, keepdims=True)
cost_array = (1 - discount) * day_coupon_used_num * day_avg_fee
gmv_array = day_avg_fee * day_order_num - cost_array
self.cur_day_roi = np.sum(gmv_array) / max(np.sum(cost_array), 1)
return next_states
def get_action_from_policy(self, user_states: Optional[List[np.ndarray]]=None):
# 获取所有用户状态降维后得到的神经网络的输入
obs = self._states_to_obs(user_states)
# 通过我们训练的模型得到我们的优惠券动作
action, _ = self.policy_model.predict(obs, deterministic=False)
action = action.astype(np.float32)
action[1] = 0.95 - 0.05 * action[1]
return action
赛题本身的内容和baseline提交方式讲解完毕,后面我会讲解详细的训练过程,以及尝试更好的方案,敬请期待!