类别特征在现实里十分常见,处理的方法也很多,最常见的思路是转为one-hot编码,当然这种处理方式比较粗暴,在许多算法里效果也不是很好。还有的处理方式寻找类别特征的最优切分,这个需要特定工具的支持,如LightGBM,细节见这篇文章。
本篇文章主要讲解如何利用神经网络的embedding层处理类别特征。可以说,本篇文章是目前讲解利用神经网络的embedding层处理类别特征的最清晰的文章,相信读者一定会有很多收获。
一、前言
Embedding的起源和火爆都是在NLP中的,经典的word2vec都是在做word embedding这件事情,而真正首先在结构数据探索embedding的是在kaggle上的《Rossmann Store Sales》中的rank 3的解决方案,作者在比赛完后为此方法整理一篇论文放在了arXiv,文章名:《Entity Embeddings of Categorical Variables》。
其网络结构如下图所示:
结构非常简单,就是embedding层后面接上了两个全连接层,代码用keras写的,构建模型的代码量也非常少,用的keras的sequence model。文章的代码地址。
文章有一点值得关注的地方:
使用嵌入后的向量可以提高其他算法(KNN、随机森林、gdbt)的准确性。
因此,如果我们对类别特征做embedding之后,拿到embedding后的特征后,就可以放到其它的一些学习器中进行训练,并且效果在诸多比赛中得到了验证,所以说这是一种处理类别特征的方法。
注意:对于一个任务,本文并不是用神经网络来做预测还是怎么样,重点是利用神经网络中的embedding层得到的结果,来作为原来类别特征的替换,然后用于训练其它模型。当然,直接用神经网络学习也可以。我主要是想突出使用嵌入后的向量可以提高其他算法的准确性。
二、如何利用神经网络的embedding处理类别特征。
我用的神经网络的工具是Keras。Keras对Tensorflow又进行了一层封装,操作简单,功能强大。详情可见参考文献【1】。
比如说,Keras 文档里是这么写embedding的:“把正整数(索引)转换为固定大小的稠密向量”。这句话看起来很难理解。然而,从某种意义上来说,Keras 文档的描述是正确的。那么为什么你应该使用嵌入层呢?这里有两个主要原因:
- 独热编码(One-hot encoding)向量是高维且稀疏的。假如说我们在做自然语言处理(NLP)的工作,并且有一个包含 2000 个单词的字典。这意味着当我们使用独热编码时,每个单词由一个含有 2000 个整数的向量来表示,并且其中的 1999 个整数都是 0。在大数据集下这种方法的计算效率是很低的。
- 每个嵌入向量会在训练神经网络时更新。下面有几张图片,它们展示了在多维空间之中,词语之间的相似程度,这使得我们能够可视化词语之间的关系。同样,对于任何能够通过使用嵌入层而把它变成向量的东西,我们都能够这么做。换句话说,embedding层能够让我们捕捉原来特征的内在属性,使得一些原来意义上离得“近”的东西,在转换后的向量中确实离得近(欧式空间)。如果这里还不是那么容易理解,我再举一个更形象的例子:新闻有许多板块,如体育,财经,娱乐等,我们可以认为这是一个多分类问题。篮球足球等词语必然是经常出现在体育板块,而不会出现在财经板块,我们训练了神经网络使得多分类问题尽可能地正确,这就意味着在神经网络里篮球足球确实经常“在一起”,否则的话,多分类效果肯定很差。经常“在一起”就意味着离得近,即embedding里距离近。这就达到了embedding的作用:使得一些原来意义上离得“近”的东西(篮球足球),在转换后的向量中(embedding层输出的向量)确实离得近。归根到底:是训练的作用,即标签已经蕴含了这些信息。
我们知道了embedding的强大作用,这就解释了为什么比赛的效果很好。
那么代码中如何实现呢?很容易理解。
import numpy as np
from keras.layers.embeddings import Embedding
from keras.models import Sequential
import tensorflow as tf
import random as rn
# ===================================================================================================
# 保证结果的复现
import os
os.environ['PYTHONHASHSEED'] = '0'
np.random.seed(42)
rn.seed(12345)
session_conf = tf.ConfigProto(intra_op_parallelism_threads=1, inter_op_parallelism_threads=1)
from keras import backend as K
tf.set_random_seed(1234)
sess = tf.Session(graph=tf.get_default_graph(), config=session_conf)
K.set_session(sess)
# ===================================================================================================
'''
输入数据是32*2,32个样本,2个类别特征,且类别特征的可能值是0到9之间(10个)。
对这2个特征做one-hot的话,应该为32*20,
embedding就是使1个特征原本应该one-hot的10维变为3维(手动设定,也可以是其它),因为有2个类别特征
这样输出的结果就应该是32*6
'''
model = Sequential()
model.add(Embedding(10, 3, input_length=2))
# 构造输入数据
input_array = np.random.randint(10, size=(32, 2))
# 搭建模型
model.compile('rmsprop', 'mse')
# 得到输出数据 输出格式为32*2*3。我们最终想要的格式为32*6,其实就是把2*3按照行拉成6维,然后就是我们对类别特征进行
# embedding后得到的结果了。
output_array = model.predict(input_array)
# 查看权重参数
weight = model.get_weights()
'''
我们肯定好奇:output_array是怎么得到的?
我们先来看weight的内容:10*3。这是什么意思呢,就是其实就是一个索引的结果表,如果原来特征值为0,那么就找第一行,如果原来特征值为3,
那么就找第4行。
0.00312117 -0.0475833 0.0386381
0.0153809 -0.0185934 0.0234457
0.0137821 0.00433551 0.018144
0.0468446 -0.00687895 0.0320682
0.0313594 -0.0179525 0.03054
0.00135239 0.0309016 0.0453686
0.0145149 -0.0165581 -0.0280098
0.0370018 -0.0200525 -0.0332663
0.0330335 0.0110769 0.00161555
0.00262188 -0.0495747 -0.0343777
以input_array的第一行为例
input_array的第一行是7和4,那么就找第8行和第5行,形成了output_array的第一个2*3,即
0.0370018 -0.0200525 -0.0332663
0.0313594 -0.0179525 0.03054
然后,拉成一个向量0.0370018 -0.0200525 -0.0332663 0.0313594 -0.0179525 0.03054
这就是原始特征值8和5经过embedding层后的转换结果!
'''
在上述的代码中,我们可以看到2个类别特征的值都在0到9,并且我们没有对模型进行训练,而是直接就搭建了一个网络,就输出结果了。在真实的应用中,不是这样。有2点需要改进:
1、对每一个类别特征构建一个embedding层。对embedding层进行拼接。
2、训练网络,得到训练后的embedding层的输出作为类别特征one-hot的替换,这样的embedding的输出更精确。
三、真实可用的方法
为了解决上述的2个问题,我们这里还是人工构建训练集,我们搭建的模型如图:
从模型中,我们可以看到,这是符合现实世界的数据集的:即既有分类特征,又有连续特征。我们先训练一个网络,embedding_3和embedding_4层的输出结果就是用embedding处理类别特征后的结果。
直接上代码:
# -*- coding: utf-8 -*-
"""
Created on Thu Nov 1 10:07:50 2018
@author: anshuai1
"""
'''
完整的利用embeddding处理类别特征的程序。
'''
import numpy as np
import tensorflow as tf
import random as rn
#random seeds for stochastic parts of neural network
np.random.seed(10)
from tensorflow import set_random_seed
set_random_seed(15)
from keras.models import Model
from keras.layers import Input, Dense, Concatenate, Reshape, Dropout
from keras.layers.embeddings import Embedding
# ===================================================================================================
# 保证神经网络结果的复现
# ===================================================================================================
import os
os.environ['PYTHONHASHSEED'] = '0'
np.random.seed(42)
rn.seed(12345)
session_conf = tf.ConfigProto(intra_op_parallelism_threads=1, inter_op_parallelism_threads=1)
from keras import backend as K
tf.set_random_seed(1234)
sess = tf.Session(graph=tf.get_default_graph(), config=session_conf)
K.set_session(sess)
# ===================================================================================================
# 记录类别特征embedding后的维度。key为类别特征索引,value为embedding后的维度
cate_embedding_dimension = {'0':3, '1':2}
def build_embedding_network():
# 以网络结构embeddding层在前,dense层在后。即训练集的X必须以分类特征在前,连续特征在后。
inputs = []
embeddings = []
input_cate_feature_1 = Input(shape=(1,))
embedding = Embedding(10, 3, input_length=1)(input_cate_feature_1)
# embedding后是10*1*3,为了后续计算方便,因此使用Reshape转为10*3
embedding = Reshape(target_shape=(3,))(embedding)
inputs.append(input_cate_feature_1)
embeddings.append(embedding)
input_cate_feature_2 = Input(shape=(1,))
embedding = Embedding(4, 2, input_length=1)(input_cate_feature_2)
embedding = Reshape(target_shape=(2,))(embedding)
inputs.append(input_cate_feature_2)
embeddings.append(embedding)
input_numeric = Input(shape=(1,))
embedding_numeric = Dense(16)(input_numeric)
inputs.append(input_numeric)
embeddings.append(embedding_numeric)
x = Concatenate()(embeddings)
x = Dense(10, activation='relu')(x)
x = Dropout(.15)(x)
output = Dense(1, activation='sigmoid')(x)
model = Model(inputs, output)
model.compile(loss='binary_crossentropy', optimizer='adam')
return model
# ===================================================================================================
# 程序入口
# ===================================================================================================
'''
输入数据是32*3,32个样本,2个类别特征,1个连续特征。
对类别特征做entity embedding,第一个类别特征的可能值是0到9之间(10个),第二个类别特征的可能值是0到3之间(4个)。
对这2个特征做one-hot的话,应该为32*14,
对第一个类别特征做embedding使其为3维,对第二个类别特征做embedding使其为2维。3维和2维的设定是根据实验效果和交叉验证设定。
对连续特征不做处理。
这样理想输出的结果就应该是32*6,其中,类别特征维度为5,连续特征维度为1。
'''
# ===================================================================================================
# 构造训练数据
# ===================================================================================================
sample_num = 32 #样本数为32
cate_feature_num = 2 #类别特征为2
contious_feature_num = 1 #连续特征为1
# 保证了训练集的复现
rng = np.random.RandomState(123)
cate_feature_1 = rng.randint(10, size=(32, 1))
cate_feature_2 = rng.randint(4, size=(32, 1))
contious_feature = rng.rand(32,1)
X = []
X.append(cate_feature_1)
X.append(cate_feature_2)
X.append(contious_feature)
# 二分类
Y = np.random.randint(2, size=(32, 1))
# ===================================================================================================
# 训练和预测
# ===================================================================================================
# train
NN = build_embedding_network()
NN.fit(X, Y, epochs=3, batch_size=4, verbose=0)
# predict
y_preds = NN.predict(X)[:,0]
# 画出模型,需要GraphViz包。
#from keras.utils import plot_model
#plot_model(NN, to_file='NN.png')
# ===================================================================================================
# 读embedding层的输出结果
# ===================================================================================================
model = NN # 创建原始模型
for i in range(cate_feature_num):
# 由NN.png图可知,如果把类别特征放前,连续特征放后,cate_feature_num+i就是所有embedding层
layer_name = NN.get_config()['layers'][cate_feature_num+i]['name']
intermediate_layer_model = Model(inputs=NN.input,
outputs=model.get_layer(layer_name).output)
# numpy.array
intermediate_output = intermediate_layer_model.predict(X)
intermediate_output.resize([32,cate_embedding_dimension[str(i)]])
if i == 0:
X_embedding_trans = intermediate_output
else:
X_embedding_trans = np.hstack((X_embedding_trans,intermediate_output)) #水平拼接
# 取出原来的连续特征。这里的list我转numpy一直出问题,被迫这么写循环了。
for i in range(contious_feature_num):
if i == 0:
X_contious = X[cate_feature_num+i]
else:
X_contious = np.hstack((X_contious,X[cate_feature_num+i]))
# ===================================================================================================
# 在类别特征做embedding后的基础上,拼接连续特征,形成最终矩阵,也就是其它学习器的输入
# ===================================================================================================
'''
最终的结果:32*6.其中,类别特征维度为5(前5个),连续特征维度为1(最后1个)
'''
X_trans = np.hstack((X_embedding_trans,X_contious))
'''
好了,我们现在来验证一下embeddding后的结果是不是一个索引的结果表。
以第一个类别特征为例,利用代码NN.trainable_weights[0].eval(session=sess)我们输出embedding_1层的参数。
-0.0464945 0.0284733 -0.0365357
0.051283 0.0336468 0.0440866
0.0370058 -0.0378573 -0.0357488
0.0249379 0.031956 0.024898
-0.0075664 0.0355627 -0.0149643
-0.0481578 -0.0210528 0.0118361
-0.0178293 -0.0212218 0.0246742
0.0160812 0.0294887 -0.0069619
0.0200302 0.0472979 0.0312307
0.0416624 0.0408308 0.0405323
这是一个10*3的矩阵,符合我们的想法,即应该one-hot的10维变为3维。
为了方便,我们只看第一个类别特征cate_feature_1的前5行:
2
2
6
1
3
去索引权重,结果应该是:
0.0370058 -0.0378573 -0.0357488
0.0370058 -0.0378573 -0.0357488
-0.0178293 -0.0212218 0.0246742
0.051283 0.0336468 0.0440866
0.0249379 0.031956 0.024898
我们接着去查看X_trans中的前5行前3列:
发现确实是这样的结果。
我们又一次证明了embedding层的输出就是类别特征的值索引权重矩阵的结果!
值得注意的是:embedding层权重矩阵的训练跟其它层没有什么区别,都是反向传播更新的。
'''
参考文献:
【1】Keras中文官方文档
【3】https://www.kaggle.com/aquatic/entity-embedding-neural-net/comments