这个项目的主要的目的是通过给定的广告信息和用户信息来预测一个广告被点击与否。 如果广告有很大概率被点击就展示广告,如果概率低,就不展示。 因为如果广告没有被点击,对双方(广告主、平台)来讲都没有好处。所以预测这个概率非常重要,也是此项目的目标。
在这个项目中,你需要完成以下的任务:
- 数据的读取和理解:把给定的.csv文件读入到内存,并通过pandas做数据方面的统计以及可视化来更深入地理解数据。
- 特征构造:从原始特征中衍生出一些新的特征,这部分在机器学习领域也是很重要的工作。
- 特征的转化:特征一般分为连续型(continuous)和类别型(categorical), 需要分别做不同的处理。
- 特征选择:从已有的特征中选择合适的特征,这部分也是很多项目中必不可少的部分。
- 模型训练与评估:通过交叉验证方式来训练模型,这里需要涉及到网格搜索等技术。
1. 数据读取和理解
对于.CSV的文件,我们一般使用pandas工具来读取,读取之后的数据会存放在dataframe中。在此项目中,我们使用的是kaggle的一个竞赛数据,具体官网地址为:https://www.kaggle.com/c/avazu-ctr-prediction 。 训练和测试数据分别为train.csv和test.csv。 官网提供的数据比较大,压缩之后的已经达到1G以上。 在此项目中,我们特意去采样了一部分数据。 采样的规则为:从train.csv文件中读取头400000个样本,并重命名为train_subset.csv。 之后在这个数据的基础上我们会进一步分为训练集和测试集。所以解压完.zip文件后会发现只有一个train_subset.csv文件。
1.1 数据的读取
# 导入基本的库,每个项目的必备
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# 设置matplotlib的模式
%matplotlib inline
# 设置matplot的样式
import matplotlib
matplotlib.style.use('ggplot')
# 通过pandas读取.csv文件,并展示头几个样本。
data_df = pd.read_csv('train_subset.csv')
data_df.head()

在上面的数据中有一个特征叫作hour, 是时间的特征,但这个值有些看不懂… 这部分需要通过pandas来做处理。把这个数转换成具体时间的格式。请把这个特征格式化成%y%m%d%H形式。格式化完之后请覆盖掉原来的特征。
data_df['hour'] = pd.to_datetime(data_df['hour'], format='%y%m%d%H')
data_df.head()

从上述数据中,你会发现大量的特征为类别型特征,而且很多特征已经被编码成看不懂的字符串(这些都是为了不公开用户数据),但即便如此,我们也可以把它们直接看成是类别型特征(categorical featuer)。
# 查看一下每一个特征的类型以及是否存在null
data_df.info()

1.2 数据的理解
如果对于每一个样本,id
都是不一样的,那显然是无用的特征。
# 删除id特征
data_df = data_df.drop(labels=['id'],axis=1)
对标签分布的理解是必不可少的,因为这直接跟样本不平衡相关。
# 输出正样本的负样本各自的比例
click = len(data_df[data_df['click']==1]) / len(data_df)
no_click = len(data_df[data_df['click']==0]) / len(data_df)
print('click:',np.around(click,2))
print('no-click:',np.around(no_click,2))
- click: 0.17
- no-click: 0.83
通过上述的数据,可以很容易看出被点击的次数要远小于没有被点击的次数。所以这个数据是不平衡的数据。但这个不平衡还没有那么严重。其实不平衡严重时,负样本和正样本比例有可能1000:1, 甚至更悬殊。 由于样本的不平衡,使用准确率是不明智的,所以评估指标我们选用F1-score。
时间特征有可能对我们帮助,比如是否凌晨点击率要低于早上的,是否早上的要低于下午的? 从直观上理解其实是有帮助的。 但由于在这个项目中,我们只提取了前40万个样本,有可能时间上的差别不大(我们要知道一个大的平台,仅仅1分钟就可以收集到数十万到几百万以上的样本)。 但是,不管怎样,打印一下hour特征相关的信息看看:
data_df.hour.describe()

其实从上述的结果中可以看到,时间的区间为10-21的00点到10-21的02点,也就是2个小时的间隔。所以在使用这个特征的时候,可以把小时的特征提取出来,因为日期都是一样的(这部分没价值)。
banner_pos
特征
这是广告投放的位置,从直观上来看对广告点击的结果影响比较大,所以做一下可视化的分析并更好地理解这个特征。首先来看一下banner_pos的取值范围:
data_df['banner_pos'].unique()
- array([0, 1, 4, 5, 2, 7], dtype=int64)
从这个结果里可以看出,它的范围是0-7, 但中间不包含3和6, 有可能是我们的训练数据不全。 对于这些数据请不要理所当然地理解为它表示的是具体的位置信息,比如1代表最前面的位置……因为我们也不知道它的编码规则是怎么样的。但不管怎样,我们可以通过可视化方式来大概了解一下每一个位置对点击率的影响。
通过可视化方式来展示每一个位置上的样本总数以及其中被点击和没有被点击的样本个数。
x_data = data_df['banner_pos'].unique()
y1_data = []
y2_data = []
for i in x_data:
temp = data_df[data_df['banner_pos'] == i]
y1_data.append(len(temp[temp['click'] == 0]))
y2_data.append(len(temp[temp['click'] == 1]))
plt.bar(x=x_data,height=y1_data,width=0.5,label='no-click')
plt.bar(x=x_data,height=y2_data,width=0.5,label='click',bottom=y1_data)
plt.legend(labels = ['no-click','click'])

生成完上面的图之后能感觉到这个特征还是蛮重要的,而且由于banner_pos=2,4,5,7的样本比较少,在图里不那么直观。所以我们就尝试打印一下一个表格。表格里的每一行针对于的是banner_pos具体的值,另外表格有两列,分别是false和true, 分别代表在某一个banner_pos的样本,有百分之多少的概率不被点击和被点击。
sum_data = []
for i in x_data:
sum_data.append(len(data_df[data_df['banner_pos'] == i]))
p1 = np.array(y1_data) / np.array(sum_data)
p2 = np.array(y2_data) / np.array(sum_data)
p = np.array([p1,p2]).transpose()
table = pd.DataFrame(data=np.around(p,2),index=x_data,columns=['false','true'])
print(table)
false | true | |
0 | 0.84 | 0.16 |
1 | 0.8 | 0.2 |
4 | 0.87 | 0.13 |
5 | 0.88 | 0.12 |
2 | 0.9 | 0.1 |
7 | 0.91 | 0.09 |
site相关特征
site_features = ['site_id', 'site_domain', 'site_category']
data_df[site_features].describe()
app_features = ['app_id', 'app_domain', 'app_category']
data_df[app_features].describe()
这里重点研究一下,app_category特征,看是否跟标签有比较强的关系。 为了理解这一点,对于每一种类型的app_category值,请画出histogram,展示每一种取值条件下样本被点击或者没有被点击的概率。
x_data = data_df['app_category'].unique()
p1,p2 = [],[]
for i in x_data:
temp = data_df[data_df['app_category'] == i]
p1.append(len(temp[temp['click'] == 1]) / len(temp))
p2.append(len(temp[temp['click'] == 0]) / len(temp))
plt.bar(x=x_data,height=np.around(p2,2))
plt.bar(x=x_data,height=np.around(p1,2),bottom=np.around(p2,2))
plt.xticks(rotation=270)
plt.legend(labels = ['no-click','click'])

device相关的特征
查看跟device相关的特征信息
device_features = ['device_id', 'device_ip', 'device_model', 'device_type', 'device_conn_type']
data_df[device_features].astype('object').describe()
对于不同的device_conn_type, 画一个histogram,并表示在不同type的情况下被点击和没有被点击的概率。
x_data = data_df['device_conn_type'].unique()
p1,p2 = [],[]
for i in x_data:
temp = data_df[data_df['device_conn_type'] == i]
p1.append(len(temp[temp['click'] == 1]) / len(temp))
p2.append(len(temp[temp['click'] == 0]) / len(temp))
plt.bar(x=x_data,height=np.around(p2,2), width=0.5)
plt.bar(x=x_data,height=np.around(p1,2), width=0.5, bottom=np.around(p2,2))
plt.legend(labels = ['no-click','click'])

C1,C14-C21特征
这些特征没有具体被标记到底是什么意思,有可能是涉及到公司的隐私。 当然,理解一个特征的含义其实挺重要的,但对于这个问题没办法,毕竟他们没有提供描述。但无论如何,也可以通过可视化分析去理解这些特征是否影响点击率。
c_features = ['C1', 'C14', 'C15', 'C16', 'C17', 'C18', 'C19', 'C20', 'C21']
data_df[c_features].astype('object').describe()
画出C1和点击率之间的关系。
x_data = data_df['C1'].unique()
p1,p2 = [],[]
for i in x_data:
temp = data_df[data_df['C1'] == i]
p1.append(len(temp[temp['click'] == 1]) / len(temp))
p2.append(len(temp[temp['click'] == 0]) / len(temp))
plt.bar(x=x_data,height=np.around(p2,2), width=0.5,label='no_click')
plt.bar(x=x_data,height=np.around(p1,2), width=0.5,label='click',bottom=np.around(p2,2))
plt.legend(labels = ['no-click','click'])

2. 特征的构造
特征构造对于一个机器学习建模非常重要。它的意思就是基于原有给定的特征基础上构造一些新的特征。构造特征的方法有很多:
- 在原有的特征基础上做一些转换从而提取特征。
- 不同特征之间利用常规的运算来构造更复杂的特征(比如有特征f1, f2, 则可以通过f1 * f2操作生成新的特征)。
首先,我们来看一下数据集里的hour字段。
data_df['hour']

由于我们的数据只是做了部分采样,所以对于所有的样本日期是一致的。唯一不一样的是具体的时间。时间从10-21 00点到 10-21 02点,总共3个不同的时间段来记录。
把hour
这个字段转换成离散型变量,分别是0,1,2。 也就是2014-10-21 00点对应到0, 2014-10-21 01点对应到1, 2014-10-21 02点对应到2. 并把原来的hour字段替换一下。
data_df['hour'] = data_df['hour'].dt.hour
3. 特征转化
在上述数据中,存在着大量的类别型特征(categorical feature),这部分的特征我们需要转换成独热编码的形式(one-hot encoding)。 比如”男”,“女”这个特征分别转换成(0, 1), (1, 0)这种形式。
# 由于这两个特征的稀疏性,从特征库中去掉。 但如果计算资源允许,可以加入进来。
data_df.drop('device_id', axis=1, inplace=True)
data_df.drop('device_ip', axis=1, inplace=True)
data_df.drop('device_model', axis=1, inplace=True)
data_df.drop('site_id', axis=1, inplace=True)
data_df.drop('site_domain', axis=1, inplace=True)
data_df.drop('app_id', axis=1, inplace=True)
# 输出数据的描述
data_df.astype('object').describe()
数据中的每一个特征,其实都可以看作是类别型特征(离散型), 所以我们接下来要对所有的特征做独热编码的转换。这个时候总特征的维度就 变成每一个特征独热编码长度之和(也就是表格里的unique的之和)。务必要删除原始特征,因为已经把它们转换成了新的独热编码的形式。
# 实现对每一个特征的独热编码转换, 并删除原始特征
cat_vars = np.array(data_df.columns[data_df.columns != 'click'].tolist())
for var in cat_vars:
cat_list=pd.get_dummies(data_df[var], prefix=var)
data_df=data_df.join(cat_list)
data_df = data_df.drop(cat_vars, axis=1)
data_df.astype('object').describe()

# 构造训练数据和测试数据
feature_names = np.array(data_df.columns[data_df.columns != 'click'].tolist())
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
data_df[feature_names].values,
data_df['click'].values,
test_size=0.2,
random_state=42
)
print (X_train.shape, X_test.shape, y_train.shape, y_test.shape)
4. 特征选择
由于转换成了独热编码的形式,你会发现数据的维度一下子变多了,从几十维编程了上千维。接下来,我们来做个特征选择。我们可以回顾一下课程中提到的特征选择的方法, 也思考一下哪一种可能不太适合这个场景。 很显然生成所有的可能性方法和贪心方法是不太适合的,因为特征维度很高,计算量就变得特别大。在这里,我们使用基于L1+逻辑回归的方法。
使用基于L1的方法,请参考https://scikit-learn.org/stable/modules/feature_selection.html (SelectFromModel部分)。 我们使用的模型是逻辑回归 + L1的正则。 我们都知道L1正则会产生稀疏解,相当于帮我们选出特征。具体的方法是: 对于每一种可能的C值(代表正则的强弱)做交叉验证,从中选择效果最好的C值, 而且对于这个C值,我们有对应的选出来的特征。
from sklearn.model_selection import GridSearchCV
from sklearn.linear_model import LogisticRegression
from sklearn.feature_selection import SelectFromModel
from sklearn.metrics import f1_score
from sklearn.model_selection import KFold
params_c = np.logspace(-4, 1, 11)
# 循环每一个C值,计算交叉验证后的F1-SCORE, 最终选择最好的C值c_best, 然后选出针对这个c_best对应的特征。 务必要使用L1正则。
# 对于实现,有很多方法,自行选择合理的方法就可以了。 关键是包括以下模块:1. 逻辑回归 2. 交叉验证 3. L1正则 4. SelectFromModel
hyperparameters = dict(C=params_c)
clf = GridSearchCV(LogisticRegression(solver='liblinear', penalty='l1'), param_grid=hyperparameters, scoring ='f1', verbose=0)
best_model = clf.fit(X_train, y_train)
# 求出c_best
c_best = best_model.best_estimator_.get_params()['C']
# 通过c_best值,重新在整个X_train里做训练,并选出特征。
lr_clf = LogisticRegression(penalty='l1', C=c_best)
lr_clf.fit(X_train, y_train) # 在整个训练数据重新训练
select_model = SelectFromModel(lr_clf, prefit=True)
selected_features = select_model.get_support() # 被选出来的特征
# 重新构造feature_names
feature_names = feature_names[selected_features]
# 重新构造训练数据和测试数据
X_train = X_train[:, selected_features]
X_test = X_test[:, selected_features]
5. 模型训练与评估
选择完特征之后,我们来构建模型并做训练。这部分的内容跟第一个项目没什么太大区别,无非就是选择模型之后,通过交叉验证来学习最好的超参数。在这里我们使用两种类型的模型,分别是逻辑回归+L2正则,以及决策树。
5.1 使用逻辑回归模型
在我们选择特征的时候其实也用了逻辑回归,但要记住,选特征的时候用的是L1的正则。但是在真正来训练最终版本模型的时候我们通常都是使用L2正则。所以这里就按照这个逻辑来训练一个逻辑回归模型。需要注意的一点是:评价标准使用F1-SCORE, 包括在交叉验证阶段。
from sklearn.metrics import classification_report # 这个用来打印最终的结果,包括F1-SCORE
params_c = np.logspace(-5,2,15) # 也可以自行定义一个范围
# 实现逻辑回归 + L2正则, 利用GrisSearchCV
hyperparameters = dict(C=params_c)
clf = GridSearchCV(LogisticRegression(penalty='l2',max_iter=100000), param_grid=hyperparameters, scoring ='f1', verbose=0)
model = clf.fit(X_train, y_train)
# 输出最好的参数
print(model.best_params_)
- {‘C’: 100.0}
# TODO: 在测试数据上预测,并打印在测试集上的结果
predictions = model.predict(X_test)
print(classification_report(y_test, predictions))

5.2 使用决策树模型
在这里,我们使用决策树算法做分类。 决策树本身有很多超参数需要调节,所以调节决策树的复杂度要远高于逻辑回归模型。决策树的使用请参考: https://scikit-learn.org/stable/modules/generated/sklearn.tree.DecisionTreeClassifier.html
from sklearn.tree import DecisionTreeClassifier
params_min_samples_split = np.linspace(5, 20, 4, dtype=int)
params_min_samples_leaf = np.linspace(2, 10, 5, dtype=int)
params_max_depth = np.linspace(4, 10, 4, dtype=int)
# 构造决策树,并做交叉验证。 除了上面三个参数,其他参数用默认的。
hyperparameters = dict(min_samples_split=params_min_samples_split,
min_samples_leaf=params_min_samples_leaf,
max_depth=params_max_depth)
clf = GridSearchCV(DecisionTreeClassifier(), param_grid=hyperparameters, scoring ='f1', verbose=0)
model = clf.fit(X_train, y_train)
# 输出最好的参数
print(model.best_params_)
- {‘max_depth’: 4, ‘min_samples_leaf’: 2, ‘min_samples_split’: 5}
# 在测试数据上预测,并打印在测试集上的结果
predictions = model.predict(X_test)
print(classification_report(y_test, predictions))

5.3 利用启发式算法来调节参数
我们使用贝叶斯优化方法去选择超参数,具体的使用方法请参考: https://github.com/fmfn/BayesianOptimization 也需要提前安装好这个库,请按照此链接中的方法来安装。
同时,我们在使用决策树的过程中也发现参数数量多,花费的时间也很长。这种现象在参数越多的时候越明显。所以,可以适当采用启发式算法比如贝叶斯优化。贝叶斯优化整体的思路是构建在贝叶斯模型之上的,内核包括高斯过程。具体细节可以参考Adam Ryans(princeton)教授的相关文章。
from bayes_opt import BayesianOptimization
from sklearn.model_selection import cross_val_score
def black_box_function(min_samples_split,min_samples_leaf,max_depth):
clf = DecisionTreeClassifier(min_samples_split=int(min_samples_split),min_samples_leaf=int(min_samples_leaf),max_depth=int(max_depth))
score = cross_val_score(clf,X_train,y_train,cv=5,scoring='f1').mean()
return score
pbounds = dict(min_samples_split=(5,20),
min_samples_leaf=(2,10),
max_depth=(4,10))
# 使用贝叶斯优化去选择超参数
optimizer = BayesianOptimization(
f=black_box_function,
pbounds=pbounds,
verbose=0,
)
optimizer.maximize()
print(optimizer.max)
- {‘target’: 0.21648269918203553, ‘params’: {‘max_depth’: 4.351785102940823, ‘min_samples_leaf’: 6.616013729930804, ‘min_samples_split’: 10.445640730801527}}
# 在测试数据上预测,并打印在测试集上的结果
clf = DecisionTreeClassifier(min_samples_split=10,min_samples_leaf=6,max_depth=4)
mode = clf.fit(X_train,y_train)
predictions = model.predict(X_test)
print(classification_report(y_test, predictions))

5.4 使用XGBoost做分类
还有一类算法叫做XGBoost, 这是目前工业界和各类比赛最常用的算法之一。 它是一种集成式的方法,相当于多位专家共同去决策,所以模型既稳定效果也不错。这个模型也需要单独安装,具体安装请见: https://pypi.org/project/xgboost/
from xgboost import XGBClassifier
# 训练XGBoost模型
model = XGBClassifier(objective='binary:logitraw', learning_rate=0.01, max_depth=7, tree_method='gpu_hist')
model.fit(X_train,y_train)
# 在测试数据上预测,并打印在测试集上的结果
predictions = model.predict(X_test)
print(classification_report(y_test, predictions))
