第八课.特征工程与CTR预测

本文介绍特征工程的基本步骤,包括缺失值处理、特征归一化、连续特征离散化、离散特征one-hot编码、ID特征Embedding及特征构造方法。并通过LR预估CTR实验展示特征工程在实际应用中的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

特征工程

缺失值处理

pandas 是为了解决数据分析任务而创建的,并考虑了缺失值的处理。在 pandas 中可以使用如下代码进行缺失值的判断:

>>> import pandas as pd
# 创建一个Series
>>> s = pd.Series([1,2,3,None,5])
# 输出s的缺失值数量
>>> s.isna().sum()
1
# 创建一个DataFrame
>>> df = pd.DataFrame({'a':[1,2,3,None,5],
                       'b':[None]*4+[5],
                       'c':[2,3,4,5,6]})
# 输出每一列的缺失值数量
>>> df.isna().sum()
a    1
b    4
c    0
# dtype: int64

None表示人为指定的缺失值,也可以用 numpy 中的 nan 来替代,即:np.nan

缺失值删除
当 dataframe 中的某列缺失值很多时,可以通过drop()方法将这列数据全部删除,以上面的 df 为例,删除 b 列数据的代码如下:

# 删除 b 列,返回新数据
>>> df.drop('b',axis=1)
    a	  c
0	1.0	  2
1	2.0	  3
2	3.0	  4
3	NaN	  5
4	5.0	  6
# 删除 b 列,inplace操作修改原数据
>>> df.drop('b',axis=1,inplace=True)

另外还有一个df.dropna(axis=0, inplace=False)方法,它会将含有缺失值的行或列全部删除,使用方法如下:

import pandas as pd

df = pd.DataFrame({'a':[1,2,3,None,5],
                       'b':[None]*4+[5],
                       'c':[2,3,4,5,6]})
df

fig1

# 删除含有缺失值的行,返回新数据
dfnew=df.dropna(axis=0)
dfnew

fig2

# 删除含有缺失值的列,返回新数据
dfnew=df.dropna(axis=1)
dfnew

fig3


缺失值填充
通过fillna()方法可以填充缺失值,主要有两类填充方式,分别对应该方法的value参数和method参数:

>>> import pandas as pd

# 创建一个Series
>>> s = pd.Series([4,1,3,None,4,1,5])

# 1. value参数:使用指定值填充

# 使用常数值填充
>>> s1 = s.fillna(value=0)
# 使用均值填充
>>> s2 = s.fillna(value=s.mean())
# 使用中位数填充
>>> s3 = s.fillna(value=s.median())
# 使用众数填充(众数可能有多个,此处取第一个)
>>> s4 = s.fillna(value=s.mode()[0])

# 2. method参数:使用指定方法填充

# ffill方法:用 NaN 前面的一个值填充
>>> s5 = s.fillna(method='ffill')
# bfill方法:用 NaN 后面的一个值填充
>>> s6 = s.fillna(method='bfill')

若要在原数据上修改,可以将fillna()中的inplace参数设置为True,此时该方法无返回值;同理,上述缺失值填充的方法也适用于 dataframe;

特征归一化

多维特征取值差异较大时会影响梯度下降的收敛效果,所以需要对每个特征进行归一化处理。如果不进行特征归一化处理,模型收敛会很慢甚至可能不收敛。 特征归一化在 sklearn 中的使用方法如下:

>>> import numpy as np
>>> from sklearn.preprocessing import MinMaxScaler
# 创建一个特征矩阵,每一行是一个样本的特征向量
>>> X = [[2,35,500],
         [6,75,700],
         [8,65,800],
         [18,85,900]]
# 特征归一化
>>> MinMaxScaler().fit_transform(X)
array([[0.   , 0.   , 0.   ],
       [0.25 , 0.8  , 0.5  ],
       [0.375, 0.6  , 0.75 ],
       [1.   , 1.   , 1.   ]])

假如有100个样本,特征 x x x 在这100个样本上的取值最大值为 m a x ( x ) max(x) max(x),最小值为 m i n ( x ) min(x) min(x),现在要把特征 x x x 的这100个取值映射到区间 [ a , b ] [a,b] [a,b]上,则映射后的特征值可以使用如下公式进行计算:
x = x − m i n ( x ) m a x ( x ) − m i n ( x ) ( b − a ) + a x=\frac{x-min(x)}{max(x)-min(x)}(b-a)+a x=max(x)min(x)xmin(x)(ba)+a
sklearn.preprocessing中的MinMaxScaler在实例化时,有一个feature_range参数,默认为(0,1),可以通过下面的代码了解其实现细节:

# 各维特征归一化范围,默认(0,1)
>>> feature_range = (0, 1)
# 上文代码中的X转化为numpy数组,方便计算
>>> X = np.array(X)
# 各列数值减去所在列的最小值/各列取值长度,得到的是(0,1)内的特征值
>>> X_std = (X-X.min(axis=0))/(X.max(axis=0)-X.min(axis=0))
# 归一化区间内的最小值和最大值
>>> min, max = feature_range
# 得到特定范围内的特征值
>>> X_scaled = X_std * (max - min) + min
>>> X_scaled
array([[0.   , 0.   , 0.   ],
       [0.25 , 0.8  , 0.5  ],
       [0.375, 0.6  , 0.75 ],
       [1.   , 1.   , 1.   ]])

连续特征离散化

工业界很少将连续值作为逻辑回归模型的特征输入,而是将连续特征离散化为一系列的 0、1 特征交给逻辑回归模型。连续特征离散化(特征分箱)本质上是一个分段函数的映射过程,属于非线性变换。这种变换可以克服极端值和异常值对模型训练效果的影响,避免模型对噪声数据过拟合。一般常用几种简单的离散化方法:自定义分箱、等距分箱和等频分箱。
自定义分箱
fig4

>>> import pandas as pd
# bins 是分箱边界值
>>> a = pd.cut([1,3,4,5,8],bins=[0,3,5,7,9])
# 获取原数据的分箱索引,作为离散化的特征
>>> a.codes
array([0, 0, 1, 1, 3], dtype=int8)
# 查看分箱统计信息:类别、计数、频率
>>> a.describe()
	       counts	freqs
categories		
(0, 3]	      2  	0.4
(3, 5]	      2	    0.4
(5, 7]	      0	    0.0
(7, 9]	      1	    0.2

[1,3,4,5,8]中的每个特征值分别被映射到了(0,3]、(3,5]、(5,7]、(7,9]4个区间中,这些区间是通过bins这个参数人为定义好的

等距分箱
fig5

>>> b = pd.cut([4,10,20,10,6],bins=4,precision=0)
# 获取原数据的分箱索引,作为离散化的特征
>>> b.codes
array([0, 1, 3, 1, 0], dtype=int8)
# 查看分箱统计信息:类别、计数、频率
>>> b.describe()
           counts	 freqs
categories  		
(4.0, 8.0]  	2	 0.4
(8.0, 12.0] 	2	 0.4
(12.0, 16.0]	0	 0.0
(16.0, 20.0]	1	 0.2

等距分箱和自定义分箱的主要区别在于bins参数,上述代码将[4,10,20,10,6]中的每个值映射到了长度相等的4个区间内;

等频分箱
fig6

>>> import pandas as pd
# q 表示分位数,q=4 表示每个分箱内的样本数占总样本的1/4
>>> c = pd.qcut([9,5,2,1,30,50,75,80],q=4,precision=0)
# 获取原数据的分箱索引,作为离散化的特征
>>> c.codes
array([1, 1, 0, 0, 2, 2, 3, 3], dtype=int8)
# 查看分箱统计信息:类别、计数、频率
>>> c.describe()
            counts	freqs
categories		
(0.0, 4.0]	    2	0.25
(4.0, 20.0]    	2	0.25
(20.0, 56.0]	2	0.25
(56.0, 80.0]	2	0.25

可以看到:每个分箱内的样本数占比都是 0.25,即实现了等频分箱

离散特征one-hot编码

特征分箱之后,可以对离散型变量做进一步的OneHot编码处理,假设已有一个表示职业类型的特征 j o b job job ,共有 4 个取值:医生、老师、学生、警察,将其 OneHot 编码之后,特征 j o b job job 从1维升高至4维:
fig7
特征 j o b job job 的每一个取值都可以用一个4维向量来表示,该向量的元素非零即一,形象把它叫做OneHot特征向量:向量中只有一个元素取值为 1,其余元素的取值均为 0;

使用numpy实现onehot编码:

import numpy as np

# 定义onehot编码函数
def onehot(size,index):
    result = np.zeros(size,dtype=int)
    result[index] = 1
    return result

# 特征取值数量
feature_size = 4

# 依次遍历该特征每个取值的索引,输出onehot编码
for i in range(feature_size):
    print(onehot(feature_size,i))
    
# 运行结果如下:
# [1 0 0 0]
# [0 1 0 0]
# [0 0 1 0]
# [0 0 0 1]    

也可以使用 sklearn 封装的方法来实现这一过程,并且可以对多个类别特征同时进行OneHot 编码:

>>> from sklearn.preprocessing import  OneHotEncoder
# 样本特征矩阵,每一行为一个样本,每一列为一个特征
>>> X = [[0, 0, 0],
         [1, 1, 1],
         [0, 2, 2],
         [1, 0, 3]]
# sparse=True时,返回稀疏存储的矩阵,否则返回numpy数组
>>> OneHotEncoder(sparse=False,dtype=int).fit_transform(X)
array([[1, 0, 1, 0, 0, 1, 0, 0, 0],
       [0, 1, 0, 1, 0, 0, 1, 0, 0],
       [1, 0, 0, 0, 1, 0, 0, 1, 0],
       [0, 1, 1, 0, 0, 0, 0, 0, 1]], dtype=int64)

X有3列特征,第一列特征有2个取值,对应的OneHot特征向量维度为2;第二列特征有3个取值,对应的OneHot特征向量维度为3;第三列特征有4个取值,对应的OneHot特征向量维度为4;三列特征的OneHot特征向量拼接起来即为最后的样本特征向量(9维)

ID特征Embedding

在数据采集阶段,得到的大部分数据都是ID类的特征,比如用户ID、商品ID、店铺ID等,这些ID类的特征本质上是一些离散型的特征,并且取值数非常多,如果将所有的ID类特征进行OneHot 编码,得到的样本特征向量维度会非常高。

所以,后来出现了特征Embedding技术:将高维稀疏的特征向量映射到低维空间中,得到低维稠密的特征向量。如图所示是OneHot特征向量通过Embedding技术映射到低维向量空间的原理示意图:

fig8
Embedding矩阵可以通过两种方式得到:一种是预训练,另一种是作为模型参数先随机初始化,然后通过特定任务训练更新;

获取Embedding特征实现如下,实际就是矩阵乘法:

>>> import numpy as np
# OneHot特征向量维度,embedding特征向量维度
>>> feature_size,emb_size = 6,4
# 随机初始化的embedding矩阵
>>> w = np.random.randn(feature_size,emb_size,)
>>> w
array([[ 0.73541001, -0.55067429,  0.92116447, -0.19135519],
       [ 0.42867673,  0.9408499 , -0.30474845, -0.83732498],
       [-0.13808742,  1.3788042 ,  0.24563835,  0.38514269],
       [-0.95431867, -1.79045266,  0.92817987,  1.20328236],
       [-1.14640283,  1.25422919,  0.48205121,  0.33136124],
       [ 0.14712547,  0.2786036 ,  0.70921497,  2.69474609]])
# 通过矩阵乘法得到embedding特征向量
>>> np.array([0,0,0,0,0,1]).dot(w)
array([0.14712547, 0.2786036 , 0.70921497, 2.69474609])

特征构造方法

基础特征不足会限制模型的表达能力,所以就有必要人为构造一些交叉特征和统计特征来提升模型效果,假设有以下场景:数据集只有两种特征,性别(男,女)和职业(医生,老师,警察);

一种方法是直接将两列特征的文本字符串进行拼接,得到组合特征:
fig9
统计特征的加入对于模型预测效果的提升也是至关重要的,常见的统计特征比如有前1天用户的点击量、前1天商品的点击量、前1天用户的浏览量、前1天商品的曝光量、前1天用户的点击率、前1天商品的点击率,这些特征在时间窗口和行为类型上可以进一步拓展;

此外,还有前1天用户行为间隔时间的均值和方差、前1天商品行为间隔时间的均值和方差、用户过去一天的行为次数 / 用户过去三天的行为次数均值、商品过去一天的行为次数 / 商品过去三天的行为次数均值等,这些特征也可以在时间窗口和行为类型上做进一步拓展;总之,构造特征的方法灵活多变,根据不同问题使用适当的构造方法即可。

AUC指标补充

AUC是CTR预估中经常用到的离线评价指标,用来衡量模型将正样本(可以理解为点击率较高的样本)排在前面的能力。理论上AUC越高,带来的广告收入也会越高;

首先回顾第六课中AUC的内容,逻辑回归预测为正例的样本可以分为两类:一类是真正例(True Positive,简写为TP),一类是假正例(False Positive,简写为FP)。顾名思义,在这些预测为正例的样本中,TP是预测对了的,FP是预测错了的;

在真实正样本中统计TP的比例可以得到真正例率(True Positive Rate,简写为TPR),在真实负样本中统计FP的比例可以得到假正例率(False Positive Rate,简写为FPR);逻辑回归选择不同的分类阈值可以得到不同的TPR和FPR,由此得到一系列(FPR:x轴,TPR:y轴)数据点,根据这些数据点可以画出一条ROC曲线,ROC 曲线下的面积即:AUC(Area Under ROC Curve);

实际计算时,会以每个样本的预测值分别作为分类阈值, m m m 为样本个数,将每个样本的概率预测值作为阈值,可以得到一个数据点(FPR,TPR), m m m 个样本总共可以得到 m m m 个数据点,对应的 AUC 是由 m − 1 m-1 m1 个梯形的面积累加得到的:
A U C = 1 2 ∑ i = 1 m − 1 ( T P R i + 1 + T P R i ) ( F P R i + 1 − F P R i ) AUC=\frac{1}{2}\sum_{i=1}^{m-1}(TPR_{i+1}+TPR_{i})(FPR_{i+1}-FPR_{i}) AUC=21i=1m1(TPRi+1+TPRi)(FPRi+1FPRi)
实现AUC的函数如下:

def calc_auc(raw_arr):
    """
    raw_arr: 二维数组,其中每个元素的定义为 [预测为正类的概率,样本标记(0或1)]
    return: auc
    """

    # 按照正类概率从大到小对数组排序
    arr = sorted(raw_arr, key=lambda d:d[0], reverse=True)

    # 统计数组中正负样本的个数
    pos, neg = 0., 0.
    for record in arr:
        if record[1] == 1.:
            pos += 1
        else:
            neg += 1

    # 计算不同概率阈值下的fpr和tpr
    fp, tp = 0., 0.
    xy_arr = []
    for record in arr:
        if record[1] == 1.:
            tp += 1
        else:
            fp += 1
        xy_arr.append([fp/neg, tp/pos])

    # 计算auc
    auc = 0.
    prev_x = 0.
    prev_y = 0.
    for x, y in xy_arr:
        if x != prev_x:
            # 曲边梯形的面积:(上底+下底)*高/2
            auc += ((prev_y + y)*(x - prev_x)/ 2.)
        prev_x = x
        prev_y = y

    return auc

实验:LR预估CTR

点击率(Click-Through-Rate,简称CTR)是互联网广告中经常提到一个概念,通过机器学习算法预估广告点击率,然后将预测值较高的广告展现给用户,如果用户点击了这些CTR预估较高的广告,就可以为平台带来巨大的广告收入;

LR曾是各大互联网公司在CTR预估上使用的主流模型。它有着可解释性强、易于并行化、便于在线学习等不可替代的优势。本次实验是基于LR的广告点击率预估;

实验使用的数据集是电商领域数据集,数据集在个人资源处,共计200万的样本记录。原始特征均为ID类特征,需要将所有ID类特征onehot编码,然后送入逻辑回归模型,预测用户对商品发生购买行为的概率,并使用AUC作为模型的评价指标。(购买属于电商网站里点击行为的一种,商品作为广告在网站里进行曝光)

首先读取数据:

import pandas as pd
data_path = 'data.zip'
data_col = ['userid','itemid','categoryid','action','timestamp']
df = pd.read_csv(data_path,names=data_col,compression='zip')
df.head()

fig10

字段说明:

  • userid 用户ID
  • itemid 商品ID
  • categoryid 商品类目ID
  • action 用户行为类型
  • timestamp 时间戳

输出每个字段空值数量:

df.isna().sum()
"""
userid        0
itemid        0
categoryid    0
action        0
timestamp     0
dtype: int64
"""

查看每个字段的取值有多少种:

feats=['userid','itemid','categoryid','action','timestamp']

for f in feats:
    print(f,":",len(set(df[f].tolist())))

"""
userid : 19544
itemid : 629096
categoryid : 6494
action : 4
timestamp : 627028
"""

可看出,用户行为可分为四类,因为action有4种取值;


补充nonzero()用于返回数组array中非零元素的索引;

比如有编码:

sample = [0]*7+[1]+[0]*8+[1]+[0]*5+[1]+[0]*3
sample
"""
[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0]
"""

返回这个稀疏特征的非零索引为:

import numpy as np
np.array(sample).nonzero()
"""
(array([ 7, 16, 22], dtype=int64),)
"""

定义特征重编码函数,将样本矩阵中的每一列特征使用自然数统一编码,编码后的特征值相当于原特征的唯一索引号,每个特征的取值,编码后都不再重复,比如:
fig11

def recode(features):
    '''
    features: 二维numpy数组:一行为一个样本,一列为一个特征
    '''
    # 结果列表
    result = []
    # 将原特征统一编码的索引值
    index = 0
    
    # 遍历每一列特征
    for f in features.T:        
        f_key = set(f)
        f_value = range(index,index+len(f_key))
        f_dict = dict(zip(f_key,f_value))
        # 根据编码字典将原特征转换为自然数索引
        result.append([f_dict[key] for key in f])
        index += len(f_key)
        
    # 返回新的样本矩阵
    return np.array(result).T

arr=np.array([[0,0,0],
             [1,1,1],
             [2,2,2],
             [3,3,3]])

arr_new=recode(arr)
"""
array([[ 0,  4,  8],
       [ 1,  5,  9],
       [ 2,  6, 10],
       [ 3,  7, 11]])
"""

定义函数计算交叉熵损失:

# 单个样本二分类的交叉熵损失
def cross_entropy_loss(y_prob,y):
    '''
    y_prob:正类预测概率,取值0-1
    y:样本标记值,正样本为1,负样本为0,y为向量
    '''
    return -1*(y*np.log(y_prob)+(1-y)*np.log(1-y_prob))

定义模型,本实验的逻辑回归不同于第七课的逻辑回归,本实验的输入特征准确来说,原数据的每个特征的每种取值才是一个特征(这样设计是因为输入数据比如userid的取值不是一种连续的曲线,id数据代表不同的类别),因此为:

class LR: 
    def __init__(self,feat_size,alpha=0.2):
        '''
         feat_size:高维稀疏特征向量的维度
        '''        
        # LR的特征权重向量
        self.w = np.random.randn(feat_size)
        
        # 学习率
        self.alpha = alpha
    
    # 模型预测
    def predict_prob(self,x):
        '''
        x:样本输入特征向量中的非零元素索引值
        '''
        # 线性回归的输出值
        z =  np.dot(x, self.w[x])
        # 逻辑回归的正类概率输出值
        return 1/(1+np.exp(-z))
    
    # 模型训练(随机梯度下降法)
    def train(self,X_train,y_train):
        # 遍历训练集的每个样本
        for feat,label in zip(X_train,y_train):
            y_prob = self.predict_prob(feat)
            # 遍历样本非零特征索引
            for i in feat:
                # 更新对应的权重参数
                self.w[i] += -1*self.alpha*(y_prob-label)*i

    # 模型评估(loss和auc)
    def metric(self,X_test,y_test):
        # 存放每个测试样本的交叉熵损失
        cost = []
        # 存放每个测试样本的预测概率和真实标记值
        preds = []
        # 遍历测试集样本
        for feat,label in zip(X_test,y_test):
            # 计算当前样本的正类概率预测值
            y_prob = self.predict_prob(feat)
            # 计算当前样本的交叉熵损失
    
            loss = cross_entropy_loss(y_prob,label)
            
            preds.append([y_prob,label])
            cost.append(loss)
            
        # 打印测试集的平均损失
        print('test_cost:',np.mean(cost))
        # 打印模型在测试集上的AUC
        print('test_auc:',calc_auc(preds))

from sklearn.model_selection import train_test_split

if __name__ == '__main__':
    # 取特征矩阵:200万行x3列的numpy数组
    X = recode(df.iloc[:,:3].values)
    # 取样本标记:包含200万元素的一维数组 ,根据 action 字段获取样本标记,若action=buy,则label=1,否则label=0
    y =np.array([1 if action=='buy' else 0 for action in df['action'].values])
    # 划分训练集和测试集,测试集占20%
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
    
    # 查看有多少个特征
    feat_size = X.max()+1
    print('特征总数:',feat_size)
    # 特征总数: 655134
    
    # 模型实例化
    model = LR(feat_size)
    # 模型训练迭代,打印评价指标
    for i in range(10):
        print('\nepoch =',i)
        model.train(X_train,y_train)
        model.metric(X_test,y_test)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值