因子分解机(libffm+xlearn)

因子分解机

一、简介

  在CTR和CVR预估任务中,可能有大量的ID类特征(Categorical Feature),一般来说并不适合直接送入树模型(xgboost完全不支持,lightgbm只根据取值不同),一种常用的做法是通过Label Encoding变成离散的稀疏的高维向量(最具代表的是Onehot独热编码),然后送入LR训练。在独热编码的作用下,产生了多项式模型参数学习困难的问题。
  因子分解机 Factorization Machine(FM)解决了稀疏特征下的参数学习问题,实现了树模型很难做到的特征交叉(树模型需要手动提取交叉特征),而且可以不考虑0特征以加速训练,对稀疏严重的数据有着较好的相性,因此是LR以外的一个不错的选择。常见的使用方法是Onehot特征直接送入FM,或者经过其他模型输出的离散高维特征(比如提升树模型输出的叶子序号的序列)送入FM做stacking。本文参考了其他的文献,对FM、FFM以及相应的实现包括libffm和xlearn做出基本的介绍。文章以后仍会继续完善,尤其是libffm和xlearn还需要进一步follow代码。


二、多项式模型

2.1 多项式模型形式

  考虑一个模型,它的输出由单特征( d d 维)与组合特征的线性组合构成,如果不看二次项,这就是一个线性回归模型,现在引入了交叉项。

f(x)=w0+i=1dwixi+i=1dj=i+1dwijxixj

  其中单特征的参数 wi w i d d 个,组合特征的参数 wij d(d1)2 d ( d − 1 ) 2 个,且任意两个 wij w i j 之间相互独立。

2.2 交叉项参数训练问题

  现在假设目标函数是 L(y,f(x)) L ( y , f ( x ) ) ,为了使用梯度下降法训练交叉项参数,需要求导:

Lwij=Lf(x)f(x)wij=Lf(x)xixj ∂ L ∂ w i j = ∂ L ∂ f ( x ) ⋅ ∂ f ( x ) ∂ w i j = ∂ L ∂ f ( x ) x i x j

  也就是说,每个二次项参数 wij w i j 的训练需要 xi x i xj x j 同时非零,若特征稀疏(例如Onehot过),则一整行中只有一个1,容易导致 wij w i j 训练无法进行。


三、FM

3.1 FM是什么

  将矩阵 W={wi,j} W = { w i , j } 矩阵(这是一个对称方阵)分解成 W=VTV W = V T V 的形式,其中 V=(v1,v2,,vd) V = ( v 1 , v 2 , ⋯ , v d ) 是一个 k×d k × d 矩阵,且 kd k ≪ d ,于是 W W 矩阵的每一个元素都可以用 V 矩阵对应的两列做内积得到: wij=vivj w i j = v i ⋅ v j ,同时多项式模型可以重写,这就是因子分解机模型。

f(x)=w0+i=1dwixi+i=1dj=i+1d(vivj)xixj f ( x ) = w 0 + ∑ i = 1 d w i ⋅ x i + ∑ i = 1 d ∑ j = i + 1 d ( v i ⋅ v j ) ⋅ x i x j

  由于只需要用分解后产生的 V V 就能表达 W,使得参数个数由 d2 d 2 变成了 kd k d 。另一方面, V V 矩阵的每一列 vi 是第 i i 维特征的隐向量,一个隐向量包含 k 个描述第 i i 维特征的因子,故称因子分解

3.2 为什么FM能解决参数训练问题

  经过因子化之后,组合特征 xixj xjxk x j x k 的系数 (vivj) ( v i ⋅ v j ) (vjvk) ( v j ⋅ v k ) 不再独立,他们共有了 vj v j ,因此所有包含 xj x j 特征的非零组合特征的样本都能拿来训练。这是什么意思呢?现在,如果只看交叉项(不管用什么loss,根据链式法则我们总需要乘上 f(x)wij ∂ f ( x ) ∂ w i j ):

f(x)ijwijxixjf(x)wij=xixj f ( x ) ∝ ∑ i ∑ j w i j x i x j → ∂ f ( x ) ∂ w i j = x i x j

对于稀疏数据而言, xixj=0 x i x j = 0 很常见,梯度为0,FM改一下变成:
f(x)i=1dj=i+1d(vivj)xixjyvi=jvjxixj f ( x ) ∝ ∑ i = 1 d ∑ j = i + 1 d ( v i ⋅ v j ) x i x j → ∂ y ∂ v i = ∑ j v j ⋅ x i x j

  原本的多项式模型,为了训练 wij w i j ,要求 xi x i xj x j 不能同时为0,现在我们假设 xi0 x i ≠ 0 ,则条件变为 “ xj x j 绝对不可以为0”。另一方面,同样假设 xi0 x i ≠ 0 ,但是对 j j 没有限制,在所有的特征中,任意不为0的 xj 都可以参与训练,条件减弱为 “ 存在 xj0 x j ≠ 0 即可”。因此, FM缓解了交叉项参数难以训练的问题

3.3 FM计算的时间复杂度

f(x)i=1dj=i+1d(vivj)xixj f ( x ) ∝ ∑ i = 1 d ∑ j = i + 1 d ( v i ⋅ v j ) x i x j

  时间复杂度上,若只看交叉项,两层循环 O(n2) O ( n 2 ) ,内层k维内积(O(k)),综合起来应该是 O(kd2) O ( k d 2 ) 。然而,交叉项是可以化简的,化简为下面的形式后,复杂度是 O(kd) O ( k d )
i=1dj=i+1d(vivj)xixj=12j=1k(i=1dvijxi)2i=1dv2ijx2i ∑ i = 1 d ∑ j = i + 1 d ( v i ⋅ v j ) x i x j = 1 2 ∑ j = 1 k ( ( ∑ i = 1 d v i j x i ) 2 − ∑ i = 1 d v i j 2 x i 2 )

3.4 FM的梯度下降求解

  FM模型方程似乎是通用的,根据任务不同,使用不同的loss。比如,回归问题用MSE,分类问题先取sigmoid或者softmax,然后用cross-entropy,比较灵活。

f(x)=w0+i=1dwixi+12j=1k(i=1dvijxi)2i=1dv2ijx2i f ( x ) = w 0 + ∑ i = 1 d w i ⋅ x i + 1 2 ∑ j = 1 k ( ( ∑ i = 1 d v i j x i ) 2 − ∑ i = 1 d v i j 2 x i 2 )

计算FM对各参数的梯度:
y(x)w0=1y(x)wi=xiy(x)vij=xidt=1vtjxtx2ivij { ∂ y ( x ) ∂ w 0 = 1 ∂ y ( x ) ∂ w i = x i ∂ y ( x ) ∂ v i j = x i ∑ t = 1 d v t j x t − x i 2 v i j


四、FFM

4.1 FFM (Field-aware Factorization Machine)

  在FM的基础上,进一步提出 field 的概念。一般来说,同一个ID类特征进行Onehot而产生的所有特征都可以归为同一个 field。在FFM中,对每一个特征 xi x i ,每一个field fj f j ,学习一个隐向量 vi,fj v i , f j ,不同的特征跟同一个 field 进行关联时使用不同的隐向量。假设总共有 d d 个特征,属于 f 个field,那么每个特征都用 f f 个隐向量来描述,所以总共有 d×f 个隐向量。而FM中,一个特征只有一个隐向量,所以FM可以看成FFM中所有特征都属于同一个 field 的特例。

f(x)=w0+i=1dwixi+i=1dj=i+1d(vi,fjvj,fi)xixj f ( x ) = w 0 + ∑ i = 1 d w i ⋅ x i + ∑ i = 1 d ∑ j = i + 1 d ( v i , f j ⋅ v j , f i ) ⋅ x i x j

  观察一下,内积 vi,fjvj,fi v i , f j ⋅ v j , f i 表示让特征 i i 与 特征 jfield 关联,同时让特征 j j ifield 关联,由此可见,FM的交叉是针对特征之间的,而FFM是针对特征与 field 之间的。

4.2 FFM计算举例

为了更直观地理解FFM的计算,下面给出一个例子。

ClickedUser(U)Advertizer(A)Gender(G)
0ArthurLancelotMale
1ArthurGuinevereMale

One-Hot编码转换,其中等于其他情况的列都是0,省略掉了。

ClickedU=ArthurA=LancelotA=GuinevereG=Male
01101
11011

这么看不够直观,将特征和对应的field映射成整数编号。

field namefield indexfeature namefeature index
User1U=Arthur1
Advertizer2A=Lancelot2
A=Guinevere3
Gender3G=Male4

第一个instance的FFM的组合项有6项,注意 vi,fj v i , f j 下标的含义,就很好懂了。由于存在部分0,最后实际上只有3项。

i=1dj=i+1d(vi,fjvj,fi)xixj=(v1,2v2,1)11+(v1,2v3,1)10+(v1,3v4,1)11+(v2,2v3,2)10+(v2,3v4,2)11+(v1,4v3,1)01 ∑ i = 1 d ∑ j = i + 1 d ( v i , f j ⋅ v j , f i ) ⋅ x i x j = ( v 1 , 2 ⋅ v 2 , 1 ) ⋅ 1 ⋅ 1 + ( v 1 , 2 ⋅ v 3 , 1 ) ⋅ 1 ⋅ 0 + ( v 1 , 3 ⋅ v 4 , 1 ) ⋅ 1 ⋅ 1 + ( v 2 , 2 ⋅ v 3 , 2 ) ⋅ 1 ⋅ 0 + ( v 2 , 3 ⋅ v 4 , 2 ) ⋅ 1 ⋅ 1 + ( v 1 , 4 ⋅ v 3 , 1 ) ⋅ 0 ⋅ 1

第二个instance的FFM的组合项也有6项,最后实际只有3项。
i=1dj=i+1d(vi,fjvj,fi)xixj=(v1,2v2,1)10+(v1,2v3,1)11+(v1,3v4,1)11+(v2,2v3,2)01+(v2,3v4,2)01+(v1,4v3,1)11 ∑ i = 1 d ∑ j = i + 1 d ( v i , f j ⋅ v j , f i ) ⋅ x i x j = ( v 1 , 2 ⋅ v 2 , 1 ) ⋅ 1 ⋅ 0 + ( v 1 , 2 ⋅ v 3 , 1 ) ⋅ 1 ⋅ 1 + ( v 1 , 3 ⋅ v 4 , 1 ) ⋅ 1 ⋅ 1 + ( v 2 , 2 ⋅ v 3 , 2 ) ⋅ 0 ⋅ 1 + ( v 2 , 3 ⋅ v 4 , 2 ) ⋅ 0 ⋅ 1 + ( v 1 , 4 ⋅ v 3 , 1 ) ⋅ 1 ⋅ 1

4.3 FFM的实现

  下面这个算法流程摘自美团-深入FFM原理与实践,实际上正是libffm的实现,具体的介绍可以在ffm论文里找到。首先对数据逐列归一化,然后进行迭代,每次迭代计算梯度并更新参数。

这里写图片描述

libffm模型省略了常数和一次项,将FFM目标简化为下面这个形式。

ϕ(x)=j1,j2C(vj1,f2vj2,f1)xj1xj2 ϕ ( x ) = ∑ j 1 , j 2 ∈ C ( v j 1 , f 2 ⋅ v j 2 , f 1 ) x j 1 x j 2

这里的 C C 是组合非零的集合(即xj1xj20), j1 j 1 j2 j 2 是特征编号,两个特征分别属于 field f1 f 1 f2 f 2 。下面以logistic loss为损失函数举例,并给目标增加L2 正则。
minvilog(1+eyiϕ(xi))+λ2||v||22 min v ∑ i log ⁡ ( 1 + e − y i ϕ ( x i ) ) + λ 2 | | v | | 2 2

  其中,前半部分是风险损失 Lerr L e r r ,后半部分是结构损失 Lreg L r e g 。这个 Lerr L e r r 和我之前看过的logistic loss(交叉熵)不太一样,以前看到的是 yi y i 乘在log外面,但其实这么定义也符合loss的规律, yi=1 y i = 1 时, ϕ(x) ϕ ( x ) 越大loss越接近0.

如果用SGD进行更新,具体在计算梯度的时候有一点trick,由于链式法则:

Lv=Lerrϕϕv+Lregv ∂ L ∂ v = ∂ L e r r ∂ ϕ ∂ ϕ ∂ v + ∂ L r e g ∂ v

Lerrϕ ∂ L e r r ∂ ϕ 其实和模型参数无关,只需要预先计算一次就够了,之后在对每一个参数 v v 求梯度的时候,直接调用乘上去即可。

  根据论文的描述,学习率更新使用了AdaGrad(但据我所知libffm默认是用sgd的,xlearn默认是用AdaGrad的),学习率分母用历次迭代梯度和代替,前期加速,后期缓和,且保证每个参数的学习率都不同,每个参数既能较快收敛,又不容易震荡。

vj1,f2vj1,f2α1+t(gvj1,f2(t))2gvj1,f2


五、ffm开源实现之libffm

5.1 数据输入格式

libffm 数据格式如下:

labelfield1:feature1:value1field2:feature2:value2 l a b e l f i e l d 1 : f e a t u r e 1 : v a l u e 1 f i e l d 2 : f e a t u r e 2 : v a l u e 2

其中,numeric特征的value用原值,ID类特征的value用1代替。
具体以下面这个instance为例。

ClickedUser(U)Advertizer(A)Price(P)
0ArthurLancelot9.99
1ArthurGuinevere9.99

那么这两条样本应该处理成

011:U=Arthur:11:U=Arthur:12:A=Lancelot:12:A=Guinevere:13:Price:9.993:Price:9.99 0 1 : U = A r t h u r : 1 2 : A = L a n c e l o t : 1 3 : P r i c e : 9.99 1 1 : U = A r t h u r : 1 2 : A = G u i n e v e r e : 1 3 : P r i c e : 9.99

然后我们要对feature进行编码,编码的方式多种多样,可以直接根据feature取值构造字典(vocabulary),也可以使用哈希函数转换字符串。

例如直接构造字典如下。值得注意的是,只有category类特征需要对不同的特征取值进行编码,数值型特征共享同一编码即可。

vocabulary = {"U=Arthur":1,
              "A=Lancelot":2,
              "A=Guinevere":3,
              "price":4} # 数值型特征共享同一编码

下面列出编码后的结果:

field namefield indexfeature namefeature index
User1U=Arthur1
Advertizer2A=Lancelot2
A=Guinevere3
Price3Price4

根据以上编码结果,我们的样本变成这样:

011:1:11:1:12:2:12:3:13:4:9.993:4:9.99 0 1 : 1 : 1 2 : 2 : 1 3 : 4 : 9.99 1 1 : 1 : 1 2 : 3 : 1 3 : 4 : 9.99

另外提供一个hash转换函数

def hashstr(str, nr_bins=1e+6):
    return int(hashlib.md5(str.encode('utf8')).hexdigest(), 16)%(nr_bins-1)+1

5.2 数据处理技巧

1. 样本归一化

libffm有个参数是pa.norm,默认对样本归一化,如果不这样做可能会导致计算的梯度太大而出现inf的溢出。

2. 特征归一化

当特征中同时含有数值类和ID类时,例如某数值特征a取值为10000,ID类特征b取值是1,做样本归一化后,a=0.9999,b=9.999E-5,就会导致ID类特征没有贡献。因此要对每一列特征先做归一化。

3. 零值省略

上面的输入其实应该是这样的:

011:1:11:1:12:2:12:2:02:3:02:3:13:4:9.993:4:9.99 0 1 : 1 : 1 2 : 2 : 1 2 : 3 : 0 3 : 4 : 9.99 1 1 : 1 : 1 2 : 2 : 0 2 : 3 : 1 3 : 4 : 9.99

但是没有必要,因为等于0的特征没有贡献,所以在生成输入文件的时候不需要写零值项,这样可以加速训练速度,也减少了文件的空间占用。


六、ffm开源实现之xlearn

xlearn其实不止支持ffm,还支持LR和FM。

安装

  1. pip安装即可

    sudo pip install xlearn
    
  2. git clone后自己编译

    git clone https://github.com/aksnzhy/xlearn.git
    cd xlearn
    ./build.sh
    

使用

在python中调用

    import xlearn as xl

具体使用可参考 ./demo

在命令行中使用

  1. build/xlearn_train

    ./xlearn_train train_set -m model
    
  2. build/xlearn_predict

    ./xlearn_predict test_set model -o output
    

参数

训练指定模型输出文件

-m
-t

# 默认输出模型是 train_set + ".model" 文件
./xlearn_train train_set

# 指定输出模型,就会输出一个 xlearn_model 文件
./xlearn_train train_set -m xlearn_model

# 指定输出模型到txt
./xlearn_train train_set -t xlearn_model.txt

训练指定机器学习算法

支持GLM(LR),FM和FFM,三种算法对数据有所要求。LR和FM支持libsvm和csv输入格式,libffm格式会被处理成libsvm格式;FFM只支持libffm格式。

<libsvm format>:
   label index_1:value_1 index_2:value_2 ... index_n:value_n
<CSV format>:
   value_1 value_2 .. value_n label
<libffm format>:
   label field_1:index_1:value_1 field_2:index_2:value_2 ...

-s 0/1/2

./xlearn_train train_set -s 0  # Linear model
./xlearn_train train_set -s 1  # Factorization machine (FM)
./xlearn_train train_set -s 2  # Field-awre factorization machine (FFM)

训练指定验证集

-v

./xlearn_train train_set -v test_set

训练指定指标metric

分类问题支持accuracy、precision、F1和AUC
-x acc/prec/f1/auc

./xlearn_train train_set -v test_set -x acc
./xlearn_train train_set -v test_set -x prec
./xlearn_train train_set -v test_set -x f1
./xlearn_train train_set -v test_set -x auc

回归问题支持mae、mape和rmsd(rmse)

./xlearn_train train_set -v test_set -x mae
./xlearn_train train_set -v test_set -x rmsd

训练时采用交叉验证

默认是5折,可以用-f改变折数
-f
–cv

./xlearn_train train_set -f 3 --cv # 3折交叉验证

训练选择优化方法

支持sgd、adagrad和FTRL
-p

./xlearn_train train_set -p sgd
./xlearn_train train_set -p adagrad
./xlearn_train train_set -p ftrl

训练参数调整

学习率(默认0.2)

-r

./xlearn_train train_set -r 0.01
正则化(默认L2正则且 λ λ 是0.00002)
./xlearn_train train_set -b 0.01

FTRL专用参数

-alpha
-beta
-lambda_1
-lambda_2

./xlearn_train train_set -p ftrl -alpha 0.002 -beta 0.8 -lambda_1 0.001 -lambda_2 1.0

FM和FFM专用参数

latent factor

-k

./xlearn_train train_set -s 2 -k 4
模型初始化(默认0.66)

-u

./xlearn_train train_set -s 2 -u 0.1

训练的迭代次数和EarlyStopping

迭代次数

-e

./xlearn_train train_set -e 10 # 训练10个epoch
EarlyStopping

EarlyStopping是默认选项,但是可以设置不用它
–dis-es

./xlearn_train train_set -e 10 --dis-es

样本归一化

归一化是默认选项,但是可以设置不用它
–no-norm

./xlearn_train train_set --no-norm

安静训练

训练时不会计算指标,可以加速
–quiet

./xlearn_train train_set --quiet

预测时输出概率或者01

–sigmoid
–sign

$ 输出概率
./xlearn_predict test_set model --sigmoid

$ 输出0和1
./xlearn_predict test_set model --sigmoid

预测时指定输出文件

-o

$ 指定输出文件
./xlearn_predict test_set model --sigmoid -o submission.txt

数据格式

  xlearn支持三种算法LR、FM和FFM,同时支持三种输入数据格式CSV、libsvm和libffm。其中LR和FM支持CSV和libsvm格式,但是FFM只支持libffm格式。LR和FM当然也支持libffm,只不过它会被当成libsvm格式,即field字段无效。
  值得注意的是,一般测试集test是没有label的,但仍然需要添加一列label做占位符(全部设-1或0都可以),否则parser会把第一列数据当成Label的。

libsvm format:

labelindex1:value1index2:value2 l a b e l i n d e x 1 : v a l u e 1 i n d e x 2 : v a l u e 2 ⋯

CSV format:

labelvalue1value2 l a b e l v a l u e 1 v a l u e 2 ⋯

libffm format:

labelfield1:feature1:value1field2:feature2:value2 l a b e l f i e l d 1 : f e a t u r e 1 : v a l u e 1 f i e l d 2 : f e a t u r e 2 : v a l u e 2 ⋯


python调用

  我最喜欢xlearn的一点是,方便python调用,不得不佩服这些造轮子的人。具体的使用参考这里


使用感受

  支持分类和回归,更像是libffm的改进版,在mushroom数据集上比libffm更快,准确率更高(训练集和测试集都是),libffm不支持直接python调用,xlearn则支持。
  根据github上的描述,xlearn支持外存训练(out-of-core),可以并行化。


七、从神经网络的角度看FM

  输入是稀疏的高维特征,黑色线是带权值的,将每一维输入直接连接到黄色的带“+”号的结点,这部分是FM中的常数和一次项,相当于LR。另一方面,FM中的二次项相当于首先对输入做了某种Embedding,变成稠密的向量,然后这些向量进行内积,即图中褐色结点,红色线上权值恒为1,不可训练。Embedding涉及到因子 vi v i ,它们是可训练的。最终一次项和二次项被加起来,送给sigmoid输出概率。
这里写图片描述

  举个例子,field i 是某个原始特征(比如性别=男)onehot后的稀疏特征: [1,0,0] [ 1 , 0 , 0 ] ,让 xi=1 x i = 1 ,我们知道这个field中其他特征都完全没用,因为它们都是0。一方面, xi x i 参与了一次项的计算:

wixi ∑ w i ⋅ x i

  另一方面, xi x i 经过embedding,变成了 k k 维向量。怎么做embedding的呢?还记得FM每个 xi (标量)对应一个隐向量 vi v i k k 维向量)吗?embedding很简单,就是 xivi 而已。然后,它和其他非零特征的embedding两两交叉,正是FM的二次项:
ijxivixjvj=ij(vivj)xixj ∑ i ∑ j x i v i ⋅ x j v j = ∑ i ∑ j ( v i ⋅ v j ) x i x j

因此,对于 m m 个field,共d维特征,实际上参与训练的参数包括如下几方面:

  1. w w 向量,长度为 d
  2. v v 矩阵,大小为 d×k

  那么,FM输入格式应该怎么对应到这个网络图上呢?先回顾一下FM格式,以及上面给出的一个实例(省略0)。

labelindex1:value1index2:value2 l a b e l i n d e x 1 : v a l u e 1 i n d e x 2 : v a l u e 2 ⋯

ClickedU=ArthurA=LancelotA=GuinevereG=Male
01101

01:12:14:1 0 1 : 1 2 : 1 4 : 1

  首先设定隐向量长度 k k ,然后为每个 index 初始化向量 vi ,这里 index 最大为4,因此 v v 矩阵的大小为 4×k。接着,对于每个特征 xi x i 选取 v v 矩阵对应的 indexi 行向量 vi v i ,做Embedding得到 xivi x i v i 。接下来的步骤不再赘述。
  训练的时候,根据样本中每一个 index 取出 v v 矩阵中的某一行,经过FM前向计算得到loss,然后梯度下降更新参数。从这里可以看出,只要存在 xi0 的样本, vi v i 就能够得到训练。

多值离散特征的处理

  有时候我们会遇到一些特殊的离散特征,在一个样本中该特征会取多个值,例如下面这个例子,我们的劳模Arthur喜欢的水果有四种,如果做onehot的话,Like特征就不像上面介绍的那些特征那样只有一个位置上有1,而是有4个位置有1,其余是0。

ClickedUserLike
0ArthurApple,Banana,Orange,Grapes

  如果我们按照libsvm格式处理样本,让“User=Arthur”特征编码为1,“Like=Apple”到“Like=Grapes”分别编码为2到5,注意虽然2到5特征编码不同,但它们是属于同一个field的(在FFM的概念里)。我们能得到样本:

01:12:13:14:15:1 0 1 : 1 2 : 1 3 : 1 4 : 1 5 : 1

  可以发现其实没有任何区别,只不过因为FM里面没有field的概念,所以看起来好像是把Like这个特征分成了4个不同的Onehot field,每个field只有一个位置取值为1.


参考

  1. 【github】libffm下载
  2. 【博客】美团深入ffm
  3. 【论文】libffm原理
  4. 【github】xlearn下载
  5. 【博客】推荐系统中使用ctr排序的f(x)的设计-dnn篇之DeepFM模型
  6. 【API】xlearn文档
  7. 【博客】推荐系统遇上深度学习 (一) FM模型理论和实践
  8. 【博客】推荐系统遇上深度学习(二) -FFM模型理论和实践
  9. 【博客】推荐系统遇上深度学习(三) -DeepFM模型理论和实践
  10. 【博客】推荐系统遇上深度学习(四)-多值离散特征的embedding解决方案
  • 10
    点赞
  • 42
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值