作者|黄崇远(题图:ssyer.com,CCO协议) 公号,数据虫巢(ID: blogchong)
“ 看完了这篇,你就是个见习级算法工程师了。你觉得可能吗?”
接上一篇《数据与广告系列十一:从性别预测的CASE开始手撕机器学习代码》,姐妹篇,并且通篇完全是基于上一篇进行进一步深入讲解的,如果没有看过上一篇的朋友,强烈建议先看完上一篇,当然,更建议看完整个系列。
是的,这篇下来,我希望你对于整个相对完整的机器学习的过程有个更深一步的了解,特别是围绕分类这种典型的监督式的学习过程。
01
—
我们是要有深度的
我们在上一篇,介绍了正式的数据源,通过pandas提供的一些dataframe函数,我们进行了数据的初步观测。然后基于matplotlib提供的scatter散点图函数,我们逐一的对可能起预测作用的部分特征进行了最最原始的分布关系观测,然后快刀斩乱麻,使用了多个分类模型算法,逐一的完成了训练到预测,再到模型评估的过程。
总体而言,基本的流程算是走了一遭,最起码让大伙儿知道,最简单的流程是怎么回事了,其实并不神秘。
但确实是最简单的流程,还有更深的套路我们不清楚。所以,接下来我们要尝试去看一下文本类的特征如何使用,有没有更加合理的特征选择的方法,模型的参数如何进行调优,单纯的依靠train_test_split进行数据分割然后进行训练校验,有没有更合理的方式,效果观测还有没有其他更加合理的方式,最终模型上线该如何评估以及选择最终的预测结果。
然后补充完这些,我们再来从头梳理一下完整的机器学习流程,算是基于这些对于这个CASE有个收尾,基于这个CASE也能完整的走一遭完整流程。
02
—
文本分类
上一篇我们几乎把可能影响到性别的数值型以及LABEL型的特征都用上了,唯有几个文本特征不敢下手。包括了随机选取的一条推文text,账户的描述信息description,还有账户昵称。
文本特征跟常规的数值型特征不同,数值型特征是一种非常标准的模型输入格式,而类别类型的特征也只需要做字典编码之后,也可以转换为有限个数的类别,唯有文本类的特征,都是一段话一段话组成,无法直接使用。
但我们直观的可以看到,text和description两个字段,理论上应该相对是比较有区分度的,特别是在我们试过了N个算法accuracy不过5.2之后,剩下的这两个特征不行也得行了。
针对于文本特征,常见的处理手段是进行分词,不管是中文还是英文文本这个逻辑都是一致的,把词拆分之后,你会发现一个文本特征是由N个词组成的,而一个文本特征则意味着拥有N个词label,即我们可以模仿常规的label编码方式进行特征进一步处理了。
当然,实际过程中并没有这么简单,我们希望拆分出来的词特征对于我们的预测目标是有一定帮助的,但实际过程中必然会参杂着大量的噪音词label,号称“停用词”。中文中的“的”“了”“个”还有中文符号等,英文中的“the”“a”“are”等等。这些词对于类别的判断是总体上是没有太多帮助的,反而会影响train的性能,毕竟最终拆解出来的label维度会多很多,且形成干扰。所以,针对于文本类的特征,首先是分词,然后是停用词的过滤。
除此之外,由于句子中词的复用性,所以必然会出现同个label出现多次的情况,特别是一些长文本,是一个非常普遍的情况。单纯的依靠是否出现来进行特征化是不够的,我们可以想方设法在特征阶段就把特征的重要程度给量化出来,给与不同的权重,这样在实际预测中必然是可以带来正向作用的。
针对于词的权重化,sklearn的sklearn.feature_extraction.text提供了两种常规方法,一种是词频统计,这个好理解,即把词频出现的个数当成权重,还有一种是tfidf。tfidf是一种从全局思考角度出发,对词进行权重量化的一种方式。
其中TF即词频=某个词在文档中出现的次数/文档的总个数,这里的文档,你可以认为是有多少条文本记录,比如多少个text等,这里量化的是词在单一文档里的权重。
而idf则是逆向文档频=log(文档总数/(包含该词的文档数+1)),idf则是从全局的角度,对于那种分布特征较独特的词进行加权,对于通用性较强的词进行变相的降权处理。
两则结合之后,一方面 考虑词特征在单一记录中的权重,另一方面也考虑该词的全局权重,最终拿到一个tfidf的综合权重。更具体的可以从其他渠道去了解,总之难度不是很大。
##由于特征中包含了文本属性,文本的特征性,且信息区分度可能更高,进行优先处理
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
c_vec_s = CountVectorizer(analyzer='word',stop_words='english')
df_text = pd.concat([df['description_norm'], df['name_norm'] , df['text_norm']], axis=1)
# 要做类型转换
x_text_count = c_vec_s.fit_transform((df['text']+df['description']).tolist())
x_train, x_test, y_train, y_test = train_test_split(x_text_count, y, test_size = 0.35)
#贝叶斯
nb = MultinomialNB()
nb.fit(x_train, y_train)
y_predict = nb.predict(x_test)
text_confusion = confusion_matrix(y_test, y_predict,labels=[0,1,2])
print(f'confusion_matrix: \n{text_confusion}')
print_score(y_test, y_predict, text_confusion)
这里使用了朴素贝叶斯的分类模型,且使用的是多项式分布NB,趁机我们来补充点理论知识。朴素贝叶斯分类的核心是通过已有样本的特征构建先验概率,然后通过先验概率来计算未知分类的概率。
结合这个例子来说就是,通过分词,拿到词维度的带权特征,结合带标的结果数据,可以计算出已知词特征与结果之间的可能概率,而这个概率则类似于先验的概率。在预测阶段,测试数据或者实际数据的词的带权特征是可以计算出来的,结合训练计算获取到先验概率,就可以计算或者说预测每个类别的概率了,从而达到了预测目的。
其中,P(A|B)是指事件B发生的情况下事件A发生的概率(条件概率)。
而在常规的朴素贝叶斯的分类中,关于先验概率的计算,跟特征的分布又及其相关,分为三种分布:
高斯分布-即正态分布,这个时候计算先验概率会认为特征是按照正态进行分布的,比如常见的身高的分布就是正态分布。回到这个例子中词分布显然比较难形成标准的正态,当然你也可以把分布图画出来。
多项式分布-有点难解释,还是结合文本分类来说,针对于特征矩阵来说有N维,N维分布是离散的,这样针对于每个类别就有N次抽样概率计算,当然每一维的词特征依然是带权的,最终拿到一个整体的概率。有个更通俗的描述就是,多项分布有点像抛掷N次硬币的最终正反面的总概率(当然有时候你没有硬币-没有这个词,那么这次就不需要抛掷了,自然这个词的对应概率就不用计算了),不过与实际相应就是可能并不是每一次抛掷的硬币硬币的重心都是绝对中位的,所以最终结果自然不是0.5了。
伯努利分布-还是结合这次的例子来说,对于一个样本,其特征用的是全局全局的特征,即跟多项式分布不同,虽然可能最终是N维的词矩阵,但实际上每个样本参与概率计算的词特征是远低于N的,正如上面说的,有时候你是获取不到硬币的,自然不用抛掷了。但伯努利不同,他相当于站在全局的角度上来观测样本的概率,对于没有出现的词特征来说,也是有意义的,即“未出现”也是一种特征表征,也是要参与概率计算的,最终是实打实的N维概率。
所以针对于文本分类来说,经常使用朴素贝叶斯进行分类就很容易理解了,先验概率计算的逻辑让这种特征稀疏且分散的场景最大化每个细微的特征产生的概率影响作用,并且让这种起作用的机制变得很稳定,稳定到朴素贝叶斯很难做模型层面的调优,本身也没有几个参数,所以更多的从模型的层面进行尝试优化,上述中count特征化和tfidf特征化就是两种思路。
这是MultinomialNB中使用count特征化的结果:
confusion_matrix:
[[1593 507 236]
[ 863 1052 283]
[ 305 348 1406]]
0-precision: 0.5769648678015212
0-recall: 0.6819349315068494
1-precision: 0.551651809124279
1-recall: 0.4786169244767971
2-precision: 0.7303896103896104
2-recall: 0.6828557552209811
avg-precison: 0.6196687624384702
avg-recall: 0.6144692037348759
accuracy: 0.6144395571060215
这是MultinomialNB中使用tfidf特征化的结果:
confusion_matrix:
[[1875 352 130]
[1072 922 242]
[ 460 276 1264]]
0-precision: 0.5503375403580862
0-recall: 0.7955027577428935
1-precision: 0.5948387096774194
1-recall: 0.41234347048300535
2-precision: 0.7726161369193154
2-recall: 0.632
avg-precison: 0.6392641289849403
avg-recall: 0.6132820760752996
accuracy: 0.6159563173062339
以下是使用伯努利分布(BernoulliNB)朴素贝叶斯,且使用tfidf特征化的结果:
confusion_matrix:
[[2001 253 123]
[1148 779 196]
[ 526 236 1331]]
0-precision: 0.5444897959183673
0-recall: 0.841817416912074
1-precision: 0.6143533123028391
1-recall: 0.36693358455016484
2-precision: 0.8066666666666666
2-recall: 0.6359292881032012
avg-precison: 0.6551699249626243
avg-recall: 0.6148934298551466
accuracy: 0.6235401183072956
几个结果accuracy最好的才0.623,真算不上好,但是跟之前最大不超过0.55比,简直好的太多了,简直感动的都要哭了。不过依然很蛋疼的是,1-male类别依然还是最惨的类别,那真的有可能male的特征不具备有相对的显著性。
而这里也再次说明了,文本类的特征信息量还是比较大的,虽然特征维度比较高(),看着很复杂,但正是因为这样,所以越能侧面反映一些东西,针对于文本特征来说,样本数量越多的时候,越容易使分类更准确:
x_train.shape
(12243, 66195)
#前面的是行数,即样本数,后面的是列数,即最终特征维度
03
—
更合理的特征选择方式
在上一篇里,我们先使用经验判断+肉眼观测相关分布的方式来选择数值型的特征,其实还有更加合理的判断方式。
第一种,判断特征变化与Y的相关系数(Pearsonr相关系数):
from scipy.stats import pearsonr
print(f"tweet_count - y (pearsonr):{pearsonr(df_x['tweet_count'],y)}")
print(f"retweet_count - y (pearsonr):{pearsonr(df_x['retweet_count'],y)}")
tweet_count - y (pearsonr):(0.11123466734351128, 6.275174261723037e-53)
retweet_count - y (pearsonr):(0.010437521513536177, 0.15201955024839234)
通过分析tweet_count与retweet_count与Y之间的相关度,显然tweet_count的相关度远大于retweet_count,即tweet_count特征对于Y值的类别影响能产生较大的作用,这种就是重点参考的特征了。
当然从绝对值的角度上看,就算是tweet_count的相关度也不算特别大,其取值是-1到1,复数为负相关,正数为正相关,即print中的第一个值。而第二个值为P-value,即简称为P值,属于假设检验范畴的概念,P值是衡量假设与结果之间显著程度的描述。
具体如果展开讲,光一个Pearsonr相关系数都能讲一篇,假设校验能讲两篇,还不一定能讲明白,这部分的理论暂时先略过,有机会再开章节聊原理。从使用的角度来说,就可以通过Pearsonr相关系数,进行特征与Y之间的相关关系进行量化,从而达到判断特征的目的,不过Pearsonr相关系数对于数值型的特征还是比较敏感的,对于那种label转换的特征来说,稍微效果差点。
第二种,其实底层也可以是Pearsonr相关系数,也可以是其他差异计算的方式,这里更多体现的是sklearn集成这种计算以及量化逻辑(SelectKBest):
from sklearn.feature_selection import SelectKBest
from sklearn.feature_selection import chi2
df_x.info()
model_f = SelectKBest(chi2, k=3)
model_f.fit_transform(df_x, y)
print(model_f.scores_)
Data columns (total 7 columns):
_trusted_judgments 18836 non-null int64
fav_number 18836 non-null int64
retweet_count 18836 non-null int64
tweet_count 18836 non-null int64
_golden 18836 non-null int64
link_color_id 18836 non-null int64
sidebar_color_id 18836 non-null int64
[8.64891987e+01 1.13400454e+07 1.88537431e+02 9.95882595e+07
1.89903792e+00 6.14738280e+05 4.28232801e+02]
从名字上看,可以知道是最优特征选择的一种集成方式,不过其实SelectKBest就是一个壳子,核心判断计算方法是初始化的第一个参数,这里选择的是chi2,输出的是对应的评估分值。
从结果的的角度看fav_number特征和tweet_count特征是相对比较显著的,结合我们第一篇的特征分布,其实也是可以验证的。
同样,如果我们是要做回归预测,第一个参数里头是可以传入Pearsonr相关系数作为计算标准的。作为分类模型,常用的计算模型是卡方校验,而卡方校验也是一种非常典型的假设检验的方法。
原理没法细说了,人家一说都是通篇说的,我这里几句话肯定没有能力说清楚,推荐一个博文,非常贴地气的解释:
https://www.jianshu.com/p/807b2c2bfd9b
其实还有其他做特征选择以及判断的量化方式,以及集成的现有工具,请自行扩展,总之观测是必要的,但是用更科学和可量化的方式也是必不可缺少的。
04
—
超参调优&交叉验证
在第一篇里,我们没有对任何的模型参数进行设置,都是使用函数默认的参数,然后训练模型拿到结果。
有些模型可调控的参数不多,比如之前我们说的朴素贝叶斯,以MultinomialNB为例,可控参数只有3个,alpha,fit_prior,class_prior。分别为拉普拉斯平滑参数,先验概率是否使用的开关,如果false,第三个参数则为手动指定的先验概率。
所以,实际上真正能起到作用的,几乎只有第一个参数,来实际调整整体的概率公式的偏向,会改变整体概率与预测值的实际概率值,从而产生平滑的效果,一定程度上有助于避免过拟合的产生。
###对于text的结果进行参数调优
from sklearn.model_selection import GridSearchCV
from sklearn.model_selection import StratifiedKFold
x_train, x_test, y_train, y_test = train_test_split(x_text_count, y, test_size = 0.35)
model = MultinomialNB()
alpha = [0, 0.2, 0.5, 0.8, 0.9, 1.0, 2.0]
fit_prior = [True]
param_grid = dict(alpha = alpha, fit_prior = fit_prior)
kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=7)
grid_search = GridSearchCV(model, param_grid, scoring='accuracy', n_jobs = 3, cv = kfold)
grid_result = grid_search.fit(x_train, y_train)
以count特征化的样本,再加上MultinomialNB模型的例子进行调参,我们可以使用GridSearchCV网格搜索的方式来进行参数的所有参数组合的探测。这里由于实际上只有一组alpha参数,即实际上探测的只有7组。
然后我们使用StratifiedKFold来获取CV的样本设置,这里就涉及到另外一个概念,即交叉验证。我们在之前的case中,使用的train_test_split将样本切割为两个部分,一个用于train,一个用于test,从而实现训练和测试分离的目的。
但是,这种方式并不一定合理,因为特征切割的偶然性也同样会影响最终指标的实际效果。而交叉验证,则是一种可以处理如上情况的一种样本切割的机制,采样类似交叉采样,然后最终看指标均值的方式,这样拿到的评估指标数据会更加的客观一些。
这里,StratifiedKFold只是设置了采样的方式,比如指定切割的个数,采样切割是否随机,随机种子等等,最终生效的地方还是GridSearchCV,当然这只是说GCV集成了交叉验证的方式而已。
单独使用交叉验证的话,可以使用cross_validata cross_vali_data, cross_val_score等几个函数,可以作为自行扩展交叉验证的知识点,以及实际使用的例子。
这里附上之前GCV的探测结果的逻辑:
print("Best: %f using %s" % (grid_result.best_score_,grid_search.best_params_))
means = grid_result.cv_results_['mean_test_score']
params = grid_result.cv_results_['params']
for mean,param in zip(means,params):
print("%f with: %r" % (mean,param))
y_predict = grid_search.predict(x_test)
other_confusion = confusion_matrix(y_test, y_predict,labels=[0,1,2])
print(f'confusion_matrix: \n{other_confusion}')
print_score(y_test,y_predict, other_confusion)
结果为:
Best: 0.626399 using {'alpha': 2.0, 'fit_prior': True}
0.623703 with: {'alpha': 0.9, 'fit_prior': True}
0.626399 with: {'alpha': 2.0, 'fit_prior': True}
confusion_matrix:
[[1634 460 211]
[ 798 1050 304]
[ 364 307 1465]]
0-precision: 0.5844062947067239
0-recall: 0.7088937093275488
1-precision: 0.5778756191524491
1-recall: 0.4879182156133829
2-precision: 0.73989898989899
2-recall: 0.6858614232209738
avg-precison: 0.634060301252721
avg-recall: 0.6275577827206352
accuracy: 0.6293038070681025
其实看到,相比于之前的accuracy: 0.6144395571060215,综合accuracy还是有一些提升的。
对于那些 参数多的模型,其价值就更大了,不过同样,其组合的探测可能需要更多的训练以及拿到指标结果的时间。
以Xgboost为例:
n_estimatores = [50,100,150,200,300,400]
max_depth = [8,12,18,20,25,30]
subsample = [0.75, 0.9]
colsample_bytree = [0.75, 0.9]
learning_rate = [0.1, 0.2, 0.5]
objective = ['multi:softmax num_class=3']
gamma = [0, 0.2, 0.5]
如上参数调优,将需要进行1296次组合测试,从中拿到最优的参数组合,在实际的调优过程中,特别是生产流程中,这些组合测试的次数甚至还会更多,在样本数大,特征维度多的时候,将需要耗费巨大的调优时间。
当然,在实际的操作过程中,很多经验是可以借鉴的,比如大概多少维的特征,对应于大概的树深范围,大概需要的迭代次数等等,都是有迹可循的,并不是要缺心眼的把参数都撸一遍。
05
—
效果评估进阶
在之前的CASE中,我们最常用的几个评估方式是,混淆矩阵confusion_matrix,用于观测多分类的实际分布情况,这是一种非常笨但很直观的方式;accuracy从整体上看分类的全局准确率,从而衡量整体的效果;如果我们关注具体某一类的精准程度和召回程度,可以观测各自的precision和recall。
在不同的业务场景下,可能对于precision和recall的要求不同,在常规的分类中,可能希望的是一个全局的accuracy即可,但是部分场景下,比如之前说过的异常识别的场景,如果异常带来的损失巨大,那么我们可能需要优先保证召回率的情况下,尽可能的提升精准率。
换句话说就是,宁可错杀一千(把正常的判断为异常),也不可放过一百(虽然误判很多,但是几乎把异常都捕获到了)。当然,极端情况下,你把所有样本都判断为异常,那么recall=100%了。但在实际的场景中是不可能这么搞的,所以更多的是一个平衡以及均衡的问题。
在实际场景中,我们可以通过sklearn.metrics中的precision_recall_curve,获取到样本的PR曲线,实际上是对应不同判断概率,获取到对应不同的precision和recall。因为这两个指标本来就是类似互斥的,理论上期望precision越高,则阈值会越严格,而如果阈值空值越松懈,则recall召回的程度越大。
from sklearn.metrics import auc,precision_recall_curve
y_proba = nb.predict_proba(x_test)
# 最重要的函数:通过precision_recall_curve()函数,求出recall,precision,以及阈值
precision,recall,thresholds = precision_recall_curve(y_test,y_proba[:,1])
plt.plot(recall,precision,lw=1)
plt.plot([0,1],[0,1],'--',color=(0.6,0.6,0.6),label="Luck")
plt.xlim([-0.05,1.05])
plt.ylim([-0.05,1.05])
plt.xlabel("Recall Rate")
plt.ylabel("Precision Rate")
plt.show()
并且这里通常是用于做二值分类的观测,最终选择合适的实际阈值,从而达到最终precision和recall实际均衡的目的。
06
—
见习算法工程师操作手册
综合上一篇以及这一篇,我们来简单归纳汇总一下,一个常规的监督类学习过程是怎样的。
业务理解。
在我看来,所有参与到算法和机器学习为业务赋能的工作,首先都要理解业务,到底是一个什么样的需求(例如,是常规分类还是异常检测这种偏门要求),只有理解了业务,你对于模型的选择,最终评估方式才有个底。
理解业务,还包括理解业务数据,你需要清楚的知道有什么样的样本,样本是否可靠,来源是什么,大概相关能获取到的样本特征有多少,从业务经验上进行特征的初始判断。
数据处理。
基于业务数据理解的基础上,我们需要进行数据的清洗和获取,最终拿到我们需要的样本以及样本对应的各个维度的数据,甚至对于数据进行特征化,比如连续数据的离散化处理,分箱分桶,对于类别特征的处理,对于类似词这种的进行one-hot编码等等,都属于数据处理范畴。所以,你需要有强大的数据处理能力,各种hive sql,甚至spark的清洗逻辑开发,都只是基础功。
当然,这里也包括各种对于异常数据的处理判断能力。
特征选择。
熟悉各种特征处理的前提下,对于各种数据的观测,以及特征与特征之间的相关性,互信息的计算,特征与结果的分布等等,各种手段,让你来判断那些特征之间的关联度是如何的,从而判断特征之间的独立性,以及判断特征对于最终预测结果是否有正向作用。
特征在很多模型中,场景中,并不是越多越好,而是越显著越好,有些特征甚至会带来干扰,所以在特征选择阶段能够有效移除则对于最终的预测来说是一个正向的操作。
模型选择。
对于有经验的一些算法工程师来说,看到业务场景,看到数据,看到特征,他大概就知道大概什么类型的模型可能适合。最终,可能都需要经过一个试的过程,但是经验老鸟可以让这个过程变得更加的效率,成本更低。
参数调优。
模型决定了后续能够调优的大致范围,一些极端不合适的模型,就算你调到死估计也调不出效果来。所以,在选择了大概的模型之后,接下来就是针对有希望进一步提升的模型进行参数调优。
有部分模型的可调空间还是蛮大的,诸如上面说到过的Xgboost之类的,SVM之类的,他们参数相对较多,并且部分核心参数的改变对于结果的影响很大,还是值得一试的。除了网格搜索的方式,还有随机搜索,梯度参数搜索等方式,进行参数调优。
效果评估。
评估算法模型有很多很多的方法,对于预测分类等模型,常见的有precision,recall,accuracy,还有诸如auc,roc曲线,pr曲线,混淆矩阵等等。只不过不同的场景下可能追求的指标有所不同,观测的重点不一样而已。
实际预测。
在实际预测的过程中,我们可能直接使用model的predict拿到我们想要的结果,也可能使用拿到predict_proba每个类别的概率值,人为的操控每个类别对应的可信概率,作为最终的结果,从而达到了人为调控precision和recall的目的。
07
—
收尾和下个章节
从第6章的逻辑来看,如果我们能够把第6章的所有流程都能熟悉的跑一遍,且对于各种模型了然于胸,能够进行正常的数据处理,和特征维度的前置工作,并且最终能够将模型实际应用于业务中,基本上见习算法工程师的水平还是有的了。
最起码能用了嘛,并且用于解决实际问题了。
通过上一篇和这一篇,结合广告中性别预测的场景,把预测类的机器学习流程跑了一遍,虽然最终不管怎么调试效果实在堪忧(这个数据源真的被人各种吐槽),但是不影响我们对于整个流程的理解和认知。
当然,里头篇幅有限,很多只是提到没有深入的,还需要大家自行去扩展,这里只是作为一个引子,让大家结合实际场景把一些东西串联起来,形成认知。
在下个章节里,我们可能不会这么细致的进行每个步骤进行拆解了,但我们依然会结合广告中最常见的机器学习应用场景来实际看一下广告中实际的算法应用,比如人群扩展,异常检测,CTR预估,实时竞价RTB等。
请期待,顺手点个“在看”,置顶一下“数据虫巢”,转发朋友圈,赞赏一杯深夜写文的咖啡,都是给我/给数据虫巢持续写下去的动力。
OTHER相关系列文章
数据与广告系列:
《数据与广告系列十一:从性别预测的CASE开始手撕机器学习代码》
推荐系统系列: