【论文笔记】DIN: Deep Interest Network for Click-Through Rate Prediction

本文记录DIN: Deep Interest Network for Click-Through Rate Prediction的学习笔记。DIN将attention机制考虑进了CTR的预估任务中,通过设计一个局部激活单元从用户的历史行为中自适应的学习用户的兴趣。
学习过程中尤其注意不同模型的改进方向,改进原因,以及主要的缺点。

论文及代码地址

论文地址:https://arxiv.org/abs/1706.06978
代码地址:TensorFlow:https://github.com/zhougr1993/DeepInterestNetwork
规范之后的代码,包含DIN,以及后续改进的DIEN的代码实现:
Tensorflow: https://github.com/mouna99/dien

论文细节
1. motivation

大多数DL用于推荐的模型采取相似的embedding+MLP的范式,他们通常将大规模的稀疏特征映射成为低维的embedding向量,然后按group转化成定长的向量,最后concat在一起输入到MLP学习特征间的非线性关系。但是在映射成定长向量的时候一般没有考虑到用户以前点击过的item/ad,这对于embedding+MLP学习造成了一定的瓶颈问题。为了满足用户兴趣的丰富度,特征维度应该尽可能的扩大,但是这样的话会增加模型的参数以及增加过拟合的风险。那在长度有限且固定的embedding向量的前提下,有没有什么方法可以同时满足用户兴趣的diversity呢?DIN将用户的历史ad采用attention的机制考虑进embedding的生成部分,很大程度的缓解了这一问题。

2. method
2.1 特征表示

在CTR预测任务中,数据常常表现为多组的类别形式,对某一组的第i个特征可表示为 t i ∈ R K i t_i \in R^{K_i} tiRKi K i K_i Ki 表示第i个特征的特征维度。 t i [ j ] t_i[j] ti[j]表示第i个特征的第j个元素并且 t i [ j ] ∈ { 0 , 1 } t_i[j] \in \{0, 1\} ti[j]{0,1}。记 ∑ j = 1 K i t i [ j ] = k \sum_{j=1}^{K_i}t_i[j]=k j=1Kiti[j]=k,如果 k = 1 k=1 k=1,则特征的编码形式为one-hot 编码,如果 k > 1 k \gt 1 k>1,那么特征的编码形式为multi-hot 编码。那么网络的输入可以表示成 x = [ t 1 T , t 2 T , . . . , t M T ] T x=[t_1^{T}, t_2^T,...,t_M^T]^T x=[t1T,t2T,...,tMT]T,其中 M M M表示特征的组数,比如性别,点击时间,点击的行为序列,点击的商品,商品类别等等。

2.2 基础模型 embedding&MLP

大部分主流的推荐网络结构采用Embedding+MLP的范式,如下图:
主要包括几个部分:Embedding layer, pooling layer, concat layer, 最后是MLP

在Embedding layer中,分为两种情况:
如果输入的 t i t_i ti是one-hot的向量且 t i [ j ] = 1 t_i[j]=1 ti[j]=1,那么 t i t_i ti的embedding向量记为: e i = w j i e_i=w_j^i ei=wji
如果输入的 t i t_i ti是multi-hot的向量,并且有 t i [ j ] = 1 t_i[j]=1 ti[j]=1对于所有的 j ∈ { i 1 , i 2 , . . . , i k } j \in \{i_1,i_2,...,i_k\} j{i1,i2,...,ik}成立,那么 t i t_i ti的embedding向量可以记为 { e i 1 , . . . , e i k } = { w i 1 i , . . . , w i k i } \{e_{i_1}, ...,e_{i_k}\}=\{w_{i_1}^i,...,w_{i_k}^i\} {ei1,...,eik}={wi1i,...,wiki}

在pooling layer中,由于不同group对应的Embedding向量的长度不一定是相同的,所以采用一个pooling layer将所有的embedding向量统一到一个长度: e i = p o o l i n g ( e i 1 , . . . , e i k ) e_i=pooling(e_{i_1},...,e_{i_k}) ei=pooling(ei1,...,eik)。常用的pooling方式包括sum pooling和average pooling。所有的embedding向量和pooling操作都是在特征组内进行,最后把原始稀疏的特征映射到多个定长的表示向量中,这些向量concat到一起作为MLP的输入。

在MLP中,一般有多个全连接层组成。最后的优化目标如下:
L = − 1 N ∑ ( x , y ) ∈ S ( y l o g p ( x ) + ( 1 − y ) l o g ( 1 − p ( x ) ) ) L=- \frac{1}{N}\sum_{(x,y)\in S}(ylogp(x)+(1-y)log(1-p(x))) L=N1(x,y)S(ylogp(x)+(1y)log(1p(x)))
其中 S S S表示训练集, y ∈ { 0 , 1 } y\in\{0,1\} y{0,1}表示当前这个item是否被点击, p ( x ) p(x) p(x)为网络的softmax输出,表示点击的概率。

2.3 DIN的具体结构

之前提到由于embedding之后特征向量的长度有限,不一定能完全建模用户兴趣的diversity。DIN观察到用户的历史行为与展示的广告的点击量有明显的关系,于是考虑把用户行为的信息作为attention机制建模在embedding向量,对于下图输入的N个商品,不同用户的权重应该是不同的,利用attention机制,也就是文中所提到的local activation unit可以自适应学习每个goods的权重。
如下图所示,记用户点击的行为序列的表示向量为 v U v_U vU,候选的广告为 A A A v A v_A vA A A A的embedding向量,有:
v U ( A ) = f ( v A , e 1 , . . . , e H ) = ∑ j = 1 H a ( e j , v A ) e j = ∑ j = 1 H w j e j v_U(A)=f(v_A, e_1,...,e_H)=\sum_{j=1}^{H} a(e_j,v_A)e_j = \sum_{j=1}^{H}w_je_j vU(A)=f(vA,e1,...,eH)=j=1Ha(ej,vA)ej=j=1Hwjej
注意这里的 H H H表示用户行为embedding之后的向量长度,不是原始的点击的行为长度,同理 e 1 , e 2 , . . . e H e_1,e_2,...e_H e1,e2,...eH也表示用户行为embedding之后的向量。 a ( . ) a(.) a(.)表示一个MLP,用来学习用户行为的权重。
学习权重的时候采用机器翻译(NMT)任务中的attention机制,即quiry, key, value机制,但是作者做了一定的改变,比如这里的key和value均表示下图中的 U s e r   b e h a v i o r s User\ behaviors User behaviors,quiry表示 C a n d i d a t e   A d Candidate\ Ad Candidate Ad,attention机制中的 D h D_h Dh表示embedding的长度 H H H
具体而言:

  1. 参考右边的小图,输入attention unit包括 U s e r   b e h a v i o r s User\ behaviors User behaviors C a n d i d a t e   A d Candidate\ Ad Candidate Ad两部分,两者首先做特征交叉,利用 k e y s keys keys表示 U s e r   b e h a v i o r s User\ behaviors User behaviors, 利用 q u e r i e s queries queries表示 C a n d i d a t e   A d Candidate\ Ad Candidate Ad,作者在实现的时候采用了 q u e r i e s − k e y s queries - keys querieskeys, q u e r i e s ∗ k e y s queries * keys querieskeys两种交叉方式,之后将 q u e r i e s − k e y s queries - keys querieskeys, q u e r i e s ∗ k e y s queries * keys querieskeys q u e r i e s queries queries, k e y s keys keys四种特征concat在一起,送入MLP
  2. 参考attention机制里面的scaling,对MLP的结果output进行缩放: o u t p u t = o u t p u t / H output=output/\sqrt H output=output/H
  3. 归一化: o u t p u t = s o f t m a x ( o u t p o u t ) output=softmax(outpout) output=softmax(outpout)
  4. 对用户行为进行加权: o u t p u t = o u t p u t ∗ k e y s output=output*keys output=outputkeys
2.4 训练过程中的优化

以上主要的部分讲完了,但是作者在训练过程中还做了一些优化。
2.4.1 Mini-batch Aware Regularization
在下图中,如果不用正则化,可以看到trainging的loss在第一个epoch之后下降的很厉害,于此同时test loss在第一个epoch之后上升很快。但是由于输入的特征是大规模的稀疏特征,直接利用 L 2 L2 L2正则不太现实。

所以作者在正则化这里做了一定的优化。具体的: L 2 ( W ) = ∑ j = 1 K ∑ m = 1 B ∑ ( x , y ) ∈ B m I ( x j ≠ 0 ) n j ∣ ∣ w j ∣ ∣ 2 2 L_2(W)=\sum_{j=1}^{K}\sum_{m=1}^{B}\sum_{(x,y)\in B_{m}}\frac{I(x_j \ne 0)}{n_j}||w_j||_2^2 L2(W)=j=1Km=1B(x,y)BmnjI(xj=0)wj22求解的时间复杂度为 O ( K B ∣ B m ∣ ) O(KB|B_m|) O(KBBm),其中 K K K为embedding向量的长度, B B B为batch数目, ∣ B m ∣ |B_m| Bm为一个batch内的数据数目

在这里记 a m j = m a x ( x , y ) ∈ B m I ( x j ≠ 0 ) a_{mj}=max_{(x,y)\in B_m}I(x_j \ne 0) amj=max(x,y)BmI(xj=0),即首先对一个batch的所有数据的第 j j j维embedding预处理,因为 x x x为大规模的one-hot类型的特征, x x x的绝大部分都是0,所以这里直接利用 x x x的第 j j j维特征的最大值近似代替之前 x x x的值。这样 x x x可能会从原来的one-hot类型的向量转化为multi-hot类型的向量。但是优化之后的正则表达式为: L 2 ( W )   ≈ ∑ j = 1 K ∑ m = 1 B a m j n j ∣ ∣ w j ∣ ∣ 2 2 L_2(W) \ \approx\sum_{j=1}^K\sum_{m=1}^B\frac{a_{mj}}{n_j}||w_j||_2^2 L2(W) j=1Km=1Bnjamjwj22,优化之后的时间复杂度为: O ( K B ) O(KB) O(KB),降低了原始问题的求解复杂度。

在反向传播时,利用链式法则可以对权重更新:
w j = w j − η [ 1 ∣ B m ∣ ∑ ( x , y ) ∈ B m ∂ L ( p ( x ) , y ) ∂ w j + λ a m j n j w j ] w_j=w_j-\eta[\frac{1}{|B_m|}\sum_{(x,y)\in B_m}\frac{\partial L(p(x),y) }{\partial w_j}+\lambda\frac{a_{mj}}{n_j}w_j] wj=wjη[Bm1(x,y)BmwjL(p(x),y)+λnjamjwj]

2.4.2 Data Adaptive Activation Function
这部分主要改变了激活函数,以前的PReLU没有考虑到网络的每一层之后的数据分布的变化,所以作者提出了Dice这一激活函数,可以根据数据的分布自适应的调整激活单元的位置。
PReLU的具体形式: f ( x ) = { s   i f   s > 0 α s   i f   s ≤ 0 f(x)=\left\{ \begin{aligned} s \ if \ s \gt 0 \\ \alpha s \ if \ s \le 0 \end{aligned} \right. f(x)={s if s>0αs if s0
Dice激活函数的具体形式:
f ( x ) = p ( s ) ⋅ s + ( 1 − p ( s ) ) ⋅ α s f(x)=p(s)\cdot s+(1-p(s))\cdot \alpha s f(x)=p(s)s+(1p(s))αs,其中 p ( s ) = 1 1 + e x p ( − s − E ( s ) V a r ( s ) + ϵ ) p(s)=\frac{1}{1+exp(-\frac{s-E(s)}{\sqrt{Var(s)+\epsilon}})} p(s)=1+exp(Var(s)+ϵ sE(s))1 E ( s ) E(s) E(s) V a r ( s ) Var(s) Var(s)为每个layer之后数据的均值和方差。

3. 源码理解

本部分参考DIN的tensorfow源码:https://github.com/zhougr1993/DeepInterestNetwork

3.1 数据集负样本的生成

参考din/build_dataset.py
训练集:包含train_set.append((reviewerID, hist, pos_list[i], 1)),其中reviewerID为用户的id, hist为点击的时序序列,pos_list[i]为下一个将会点击的序列,1表示label,表示将会点击;负样本的生成,作者对于每个点击的样本从所有商品里面未点击的样本中采样。
测试集:test_set.append((reviewerID, hist, label)) ,其中reviewerID为用户的id, hist为点击的时序序列,label为当前序列未来点击的正负样本的组合:(pos_list[i], neg_list[i])

for reviewerID, hist in reviews_df.groupby('reviewerID'):  # 生成训练集和测试集
    pos_list = hist['asin'].tolist()
    # print('reviewerID:{} hist:{}'.format(reviewerID, hist))

    def gen_neg():
        neg = pos_list[0]
        while neg in pos_list:
            neg = random.randint(0, item_count - 1)
        return neg

    neg_list = [gen_neg() for i in range(len(pos_list))] # 这里生成negtive 样本是什么意思,这样pos list里面的每个样本都会对应一个负的样本

    for i in range(1, len(pos_list)): # 学习训练集和验证集的生成方式
        hist = pos_list[:i] #
        print('i: {}, hist: {}'.format(i,hist))

        if i != len(pos_list) - 1:
            train_set.append((reviewerID, hist, pos_list[i], 1))
            train_set.append((reviewerID, hist, neg_list[i], 0)) # 这部分表示没有被点击的item
        else:
            label = (pos_list[i], neg_list[i])
            test_set.append((reviewerID, hist, label)) # 这里的label为什么是二元组呢 #test_set[0]:(114981, [24265, 8283, 43319, 45475], (56743, 31087))
    break

# 注意:他这里并没有划分验证集
random.shuffle(train_set)
random.shuffle(test_set)
3.2 DIN模型的具体内容

这部分参考:din/model.py
首先是模型的初始化,user_count(用户的数目), item_count(商品的数目), cate_count(商品的类别数目), cate_list(商品的类别), predict_batch_size(batch的大小), predict_ads_num(预测的广告的数目)
在模型的变量这里(比如self.u),可参考下图的注释:

class Model(object):

    def __init__(self, user_count, item_count, cate_count, cate_list, predict_batch_size, predict_ads_num):
        # loss, _ = sess.run([self.loss, self.train_op], feed_dict={
        #     self.u: uij[0], reviewer id
        #     self.i: uij[1], the last item
        #     self.y: uij[2], label
        #     self.hist_i: uij[3], 点击的行为序列,不满足sl的数值会被补零填充
        #     self.sl: uij[4], 每个行为序列的长度
        #     self.lr: l, 学习率
        # })
        self.u = tf.placeholder(tf.int32, [None, ])  # [B] #定义形参 用户的embedding
        self.i = tf.placeholder(tf.int32, [None, ])  # [B] 下一个将要点击正样本的item的embedding
        self.j = tf.placeholder(tf.int32, [None, ])  # [B] 下一个将要点击负样本的item的embedding
        self.y = tf.placeholder(tf.float32, [None, ])  # [B] label
        self.hist_i = tf.placeholder(tf.int32, [None, None])  # [B, T] 点击的行为序列,已经padding 0
        self.sl = tf.placeholder(tf.int32, [None, ])  # [B] 每条行为序列的长度,不含padding
        self.lr = tf.placeholder(tf.float64, []) #学习率

正样本(i_emb),负样本(j_emb),行为序列(h_emb)的embedding构造:
注意在训练的时后在计算图中并不需要计算j_emb,在测试的时候需要计算j_emb,用来计算GAUC等指标

 hidden_units = 128

        user_emb_w = tf.get_variable("user_emb_w", [user_count, hidden_units])  # 变量的名称和维度
        item_emb_w = tf.get_variable("item_emb_w", [item_count, hidden_units // 2]) # 将item_count长度的向量 映射成为64维的
        # 怎么设置初始值呢
        item_b = tf.get_variable("item_b", [item_count],
                                 initializer=tf.constant_initializer(0.0))
        cate_emb_w = tf.get_variable("cate_emb_w", [cate_count, hidden_units // 2])
        cate_list = tf.convert_to_tensor(cate_list, dtype=tf.int64)

        ic = tf.gather(cate_list, self.i)  # 选出最后一个正负样本对应的类别 ic
        i_emb = tf.concat(values=[
            tf.nn.embedding_lookup(item_emb_w, self.i), # self.i list
            tf.nn.embedding_lookup(cate_emb_w, ic), # ic list
        ], axis=1)

        i_b = tf.gather(item_b, self.i)

        jc = tf.gather(cate_list, self.j)  # self.j 表示什么意思呢 这部分代码未使用
        j_emb = tf.concat([
            tf.nn.embedding_lookup(item_emb_w, self.j),
            tf.nn.embedding_lookup(cate_emb_w, jc),
        ], axis=1)
        j_b = tf.gather(item_b, self.j)

        # print('self.hist_i :{}'.format(self.hist_i))
        hc = tf.gather(cate_list, self.hist_i) # 行为序列
        h_emb = tf.concat([
            tf.nn.embedding_lookup(item_emb_w, self.hist_i),
            tf.nn.embedding_lookup(cate_emb_w, hc),
        ], axis=2)  # 分成两部分 前面一部分为 行为序列item的weight 后面一部分为行为序列item类别对应的 wieght 两部分concat在一起
        # 这里的self.sl 应该不止batch数量吧

attention机制的具体实现:

		hist_i = attention(i_emb, h_emb, self.sl)  #sl表示每条行为序列的长度
		hist_i = tf.layers.batch_normalization(inputs=hist_i)  # 这里默认是false
        hist_i = tf.reshape(hist_i, [-1, hidden_units], name='hist_bn')
        hist_i = tf.layers.dense(hist_i, hidden_units, name='hist_fcn') # 全连接层

        u_emb_i = hist_i

        hist_j = attention(j_emb, h_emb, self.sl)
        # -- attention end ---

        # hist_j = tf.layers.batch_normalization(inputs = hist_j)
        hist_j = tf.layers.batch_normalization(inputs=hist_j, reuse=True)
        hist_j = tf.reshape(hist_j, [-1, hidden_units], name='hist_bn')
        hist_j = tf.layers.dense(hist_j, hidden_units, name='hist_fcn', reuse=True)

        u_emb_j = hist_j

这里的 q u e r i e s queries queries值的是 C a n d i d a t e   a d Candidate \ ad Candidate ad k e y s keys keys指的是行为点击序列, k e y s _ l e n g t h keys\_length keys_length指的是有效行为序列(不含padding)的长度。具体理解可参考2.3.

def attention(queries, keys, keys_length):
    '''
      queries:     [B, H] batch数量,hidden层
      keys:        [B, T, H] batch 数量, 行为序列的条数, hidd层
      keys_length: [B]
      self.sl :[None]
        self.sl :Tensor("Placeholder_5:0", shape=(?,), dtype=int32)
        h_emb :[None, None, 128]
        i_emb :[None, 128]
    '''
    queries_hidden_units = queries.get_shape().as_list()[-1]

    queries = tf.tile(queries, [1, tf.shape(keys)[1]])
    queries = tf.reshape(queries, [-1, tf.shape(keys)[1], queries_hidden_units])
    din_all = tf.concat([queries, keys, queries - keys, queries * keys], axis=-1) # out product这部分包含两个vector的减法和乘法
    d_layer_1_all = tf.layers.dense(din_all, 80, activation=tf.nn.sigmoid, name='f1_att', reuse=tf.AUTO_REUSE)
    d_layer_2_all = tf.layers.dense(d_layer_1_all, 40, activation=tf.nn.sigmoid, name='f2_att', reuse=tf.AUTO_REUSE)
    d_layer_3_all = tf.layers.dense(d_layer_2_all, 1, activation=None, name='f3_att', reuse=tf.AUTO_REUSE)
    d_layer_3_all = tf.reshape(d_layer_3_all, [-1, 1, tf.shape(keys)[1]])
    outputs = d_layer_3_all
    # Mask
    # mask的作用?
    key_masks = tf.sequence_mask(keys_length, tf.shape(keys)[1])  # [B, T]
    key_masks = tf.expand_dims(key_masks, 1)  # [B, 1, T]
    paddings = tf.ones_like(outputs) * (-2 ** 32 + 1)
    outputs = tf.where(key_masks, outputs, paddings)  # [B, 1, T]
    # 返回值是对应元素,condition中元素为True的元素替换为x中的元素,为False的元素替换为y中对应元素

    # Scale
    outputs = outputs / (keys.get_shape().as_list()[-1] ** 0.5)

    # Activation
    outputs = tf.nn.softmax(outputs)  # [B, 1, T]

    # Weighted sum
    outputs = tf.matmul(outputs, keys)  # [B, 1, H]

    return outputs

MLP结构,在attention机制之后:参考2.3中DIN的模型结构图好理解一些。

 		# -- fcn begin -------
        din_i = tf.concat([u_emb_i, i_emb, u_emb_i * i_emb], axis=-1)  # 这里也用到了特征的交叉
        din_i = tf.layers.batch_normalization(inputs=din_i, name='b1')
        d_layer_1_i = tf.layers.dense(din_i, 80, activation=tf.nn.sigmoid, name='f1')
        # if u want try dice change sigmoid to None and add dice layer like following two lines. You can also find model_dice.py in this folder.
        # d_layer_1_i = tf.layers.dense(din_i, 80, activation=None, name='f1')
        # d_layer_1_i = dice(d_layer_1_i, name='dice_1_i')
        d_layer_2_i = tf.layers.dense(d_layer_1_i, 40, activation=tf.nn.sigmoid, name='f2')
        # d_layer_2_i = tf.layers.dense(d_layer_1_i, 40, activation=None, name='f2')
        # d_layer_2_i = dice(d_layer_2_i, name='dice_2_i')
        d_layer_3_i = tf.layers.dense(d_layer_2_i, 1, activation=None, name='f3')
        din_j = tf.concat([u_emb_j, j_emb, u_emb_j * j_emb], axis=-1)
        din_j = tf.layers.batch_normalization(inputs=din_j, name='b1', reuse=True)
        d_layer_1_j = tf.layers.dense(din_j, 80, activation=tf.nn.sigmoid, name='f1', reuse=True)
        # d_layer_1_j = tf.layers.dense(din_j, 80, activation=None, name='f1', reuse=True)
        # d_layer_1_j = dice(d_layer_1_j, name='dice_1_j')
        d_layer_2_j = tf.layers.dense(d_layer_1_j, 40, activation=tf.nn.sigmoid, name='f2', reuse=True)
        # d_layer_2_j = tf.layers.dense(d_layer_1_j, 40, activation=None, name='f2', reuse=True)
        # d_layer_2_j = dice(d_layer_2_j, name='dice_2_j')
        d_layer_3_j = tf.layers.dense(d_layer_2_j, 1, activation=None, name='f3', reuse=True)
        d_layer_3_i = tf.reshape(d_layer_3_i, [-1])
        d_layer_3_j = tf.reshape(d_layer_3_j, [-1])
        x = i_b - j_b + d_layer_3_i - d_layer_3_j  # [B]
        self.logits = i_b + d_layer_3_i

训练过程中的loss函数:

		self.loss = tf.reduce_mean(
            tf.nn.sigmoid_cross_entropy_with_logits(
                logits=self.logits,
                labels=self.y)
        )

参考文献:
[1] https://arxiv.org/abs/1706.06978
[2] https://github.com/zhougr1993/DeepInterestNetwork

HIVT(Hierarchical Vector Transformer for Multi-Agent Motion Prediction)是一种用于多智能体运动预测的分层向量变换器。该模型使用了向量变换器(Vector Transformer)的层级架构,用于对多智能体的运动轨迹进行预测。 HIVT模型旨在解决多智能体之间相互影响和合作的问题。在多智能体系统中,智能体之间的运动和行为往往会相互影响,因此准确预测智能体的运动轨迹变得非常重要。传统的方法往往难以捕捉到智能体之间的复杂相互作用和外部环境的影响,而HIVT模型通过分层向量变换器的架构,可以更好地捕捉到多智能体系统中的相互作用。 HIVT模型首先使用一个全局的向量变换器来处理整个多智能体系统的运动轨迹,以捕捉全局的趋势和相互作用。然后,对于每个智能体,模型使用一个局部的向量变换器来预测其个体的运动轨迹,以考虑个体特定的动态特征和周围智能体的影响。 通过分层向量变换器的架构,HIVT模型能够更好地处理多智能体系统中的动态变化和相互作用,提高了运动轨迹预测的准确性。同时,该模型还可以应用于多个领域,如智能交通、无人机团队协作等。 总而言之,HIVT模型是一种基于分层向量变换器的多智能体运动预测方法,通过捕捉多智能体系统中的相互作用和全局趋势,提高了运动轨迹预测的准确性和适用性。该模型在多个领域具有广泛的应用前景。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值