本文是Building machine learning system by python这本书的一篇实践笔记。建立机器学习系统的步骤比较繁琐,开发人员需要根据实际情况选择特征和学习算法。
分析StackOverFlow中回复答案的优劣
开发环境:
硬件:macbook-air 4g内存,1.4 GHz Intel Core i5 操作系统:macOs sierra version 10.12 网络环境:xfinitywifi,带宽:50M 数据:stack overflow-post
数据预处理
分割xml
因为从stackoverflow下载是的xml数据文件有50多个G,要将其分割,不然数据量太大。写了一个小程序用来分隔XML文件,取某特定年份的数据作为本文使用的数据。xml分割的代码如下:
import xml.etree.ElementTree as ET context = ET.iterparse('E:\\Posts.xml', events=('end', )) filename = "2008" + ".xml" with open(filename, 'wb') as f: f.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n") f.write("<posts>\n") i = 0 for event, elem in context: if elem.tag == 'row': title = elem.get('CreationDate') #print title #title = elem.attrib if title.find('2008')!=-1: #print '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n' print ET.tostring(elem) with open(filename, 'a') as f: f.write(ET.tostring(elem)) else: break with open(filename, 'a') as f: f.write("</posts>\n")
用以上代码分割得到的所有2008年的xml文件,居然再次读取时提示xml格式错误。用一般的编辑器无法打开,所以至今不知道什么原因。
7z分割大xml文件
没想到7z压缩工具有分割大文件的功能: 写个程序将分割后的合并:
import glob read_files = glob.glob("../data/2012/*") with open("2012-post.xml", "wb") as outfile: outfile.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n") for f in read_files: print f with open(f, "rb") as infile: outfile.write(infile.read())
特征选择
通过分割和合并得到的2012年的数据如下: 每一个row存储的是一个post的数据,包含以下特征:
名称 | 数据类型 | 描述 |
Id
|
Integer
|
post id
|
PostTypeId
|
Integer
|
This describes the category of the post. The values interesting to us are the following: • Question
• Answer Other values will be ignored.
|
ParentId
|
Integer
|
这个answer回答的是哪个问题的
|
CreationDate
|
DateTime
|
This is the date of submission.
|
Score
|
Integer
|
This is the score of the post.
|
ViewCount
|
Integer or empty
|
This is the number of user views for this post.
|
Body
|
String
|
This is the complete post as encoded HTML text.
|
OwnerUserId
|
Id
|
This is a unique identifier of the poster. If 1, then it is a wiki question.
|
Title
|
String
|
This is the title of the question (missing for answers).
|
AcceptedAnswerId
|
Id
|
This is the ID for the accepted answer (missing for answers).
|
CommentCount
|
Integer
|
This is the number of comments for the post.
|
从上表中并不能一眼就能识别哪些特征对本文任务是有用的,哪些是没有用的,逐个分析:
- PostTypeId:对于识别post是问题还是答案是有用的,保留;
- CreationDate:可用此计算出提出问题到提供答案的时间间隔,保留;
- Score:表示大家对此答案的评分高低,保留;
- ViewCount:浏览次数,没有区分作用,丢弃;
- Body:post的主要内容,可进一步挖掘出重要信息,保留;
- OwnerUserId:没有作用,丢弃;
- Title:只是问题的标题,丢弃;
- CommentCount:越多的评论,并不一定代表该回答越好,所以也丢弃;
- AcceptedAnswerId:将转化成每个答案都用的属性isAccepted,表示该答案是否别接受,保留;
使用KNN算法进行分类
sklearn KNN 简单实例
from sklearn import neighbors knn = neighbors.KNeighborsClassifier(n_neighbors=2) print(knn) knn.fit([[1],[2],[3],[4],[5],[6]], [0,0,0,1,1,1]) print(knn.predict(1.5)) print(knn.predict(3)) print(knn.predict(4.5)) print(knn.predict(5))
结果为:0 0 1 1
sklearn打印报告
sklearn提供了classification_report方法,利用其可以很方便实现比较整洁的报告打印格式,该方法将真实值、预测值和分类名称作为输入,返回string类型作为报告内容其中包括准确率、召回率、f1值和支持个数(support)。 代码示例如下:
from sklearn.metrics import classification_report y_true = [0, 1, 2, 2, 2] y_pred = [0, 0, 2, 2, 1] target_names = ['class 0', 'class 1', 'class 2'] print(classification_report(y_true, y_pred, target_names=target_names)) precision recall f1-score support <BLANKLINE> class 0 0.50 1.00 0.67 1 class 1 0.00 0.00 0.00 1 class 2 1.00 0.67 0.80 3 <BLANKLINE> avg / total 0.70 0.60 0.61 5 <BLANKLINE>
sklearn交叉验证
因为将数据集划分为训练集和测试集,不同的划分方式会对分类结果有影响。为了让结果不过多地依赖于划分方式,将数据集按照指定大小划分成块,再将这些块交替作为训练集和测试集,来做分类。这就是我理解的交叉验证。 sklearn中也提供了交叉验证的方法:
from sklearn.model_selection import KFold #交叉验证,将数据分成10组 cv = KFold(n_splits=10) #对每一次分组的数据进行分类 for (train, test) in cv.split(X): X_train, y_train = X[train], Y[train] X_test, y_test = X[test], Y[test] #开始分类
分类框架
有了数据集分割方法(交叉验证)、分类算法(KNN)、评价标准(数据报告中的准确率,召回率和f1值),就可以实现分类框架了。
- 第一步:交叉验证,给数据分组;
- 第二步:对每一次的数据集划分,执行分类算法;
- 第三步:打印报告。
代码:
def measure(clf_class, parameters, name, data_size=None, plot=False): start_time_clf = time.time() if data_size is None: X = qa_X Y = qa_Y else: X = qa_X[:data_size] Y = qa_Y[:data_size] #交叉验证,将数据分成10组 cv = KFold(n_splits=10) train_errors = [] test_errors = [] scores = [] roc_scores = [] fprs, tprs = [], [] pr_scores = [] precisions, recalls, thresholds = [], [], [] #对每一次分组的数据进行分类 for (train, test) in cv.split(X): #print 'train' + str(train) +' ' + 'test' + str(test) X_train, y_train = X[train], Y[train] X_test, y_test = X[test], Y[test] only_one_class_in_train = len(set(y_train)) == 1 only_one_class_in_test = len(set(y_test)) == 1 if only_one_class_in_train or only_one_class_in_test: # this would pose problems later on continue #参数是:'n_neighbors': k clf = clf_class(**parameters) #进行拟合分类 clf.fit(X_train, y_train) #score表示准确率 train_score = clf.score(X_train, y_train) test_score = clf.score(X_test, y_test) train_errors.append(1 - train_score) test_errors.append(1 - test_score) scores.append(test_score) proba = clf.predict_proba(X_test) label_idx = 1 fpr, tpr, roc_thresholds = roc_curve(y_test, proba[:, label_idx]) precision, recall, pr_thresholds = precision_recall_curve( y_test, proba[:, label_idx]) roc_scores.append(auc(fpr, tpr)) fprs.append(fpr) tprs.append(tpr) pr_scores.append(auc(recall, precision)) precisions.append(precision) recalls.append(recall) thresholds.append(pr_thresholds) # This threshold is determined at the end of the chapter 5, # where we find conditions such that precision is in the area of # about 80%. With it we trade off recall for precision. threshold_for_detecting_good_answers = 0.59 print(classification_report(y_test, proba[:, label_idx] > threshold_for_detecting_good_answers, target_names=['Not good', 'good'])) # get medium clone scores_to_sort = pr_scores # roc_scores medium = np.argsort(scores_to_sort)[len(scores_to_sort) / 2] print("Medium clone is #%i" % medium) if plot: #plot_roc(roc_scores[medium], name, fprs[medium], tprs[medium]) plot_pr(pr_scores[medium], name, precisions[medium], recalls[medium], classifying_answer + " answers") if hasattr(clf, 'coef_'): plot_feat_importance(feature_names, clf, name) summary = (name, np.mean(scores), np.std(scores), np.mean(roc_scores), np.std(roc_scores), np.mean(pr_scores), np.std(pr_scores), time.time() - start_time_clf) print(summary) avg_scores_summary.append(summary) precisions = precisions[medium] recalls = recalls[medium] thresholds = np.hstack(([0], thresholds[medium])) idx80 = precisions >= 0.8 print("P=%.2f R=%.2f thresh=%.2f" % (precisions[idx80][0], recalls[ idx80][0], thresholds[idx80][0])) return np.mean(train_errors), np.mean(test_errors)
分析分类器性能
先用一个特征做分类,选择的特征是NumTextTokens,结果为: [caption id="attachment_1342" align="aligncenter" width="497"] 利用一个特征的结果[/caption] 其中利用k=5的knn分类器,因为利用交叉验证的数据集,所以得到的是平均值 Mean(scores) = 0.56 Std(scores) = 0.04 准确为0.56与投掷硬币才正反面的概率相当了,增加特征再次实验,将所有的特征都加上。分类结果为:
Mean(scores) = 0.58 Std(scores) = 0.033 只提高了0.02,加了那么多特征只提高了一点点,所以不是特征维度的问题,要从其他方面考虑提高了。
问题根源
为什么加这么多的特征分类效果却没有显著提高?这要分析一下分类器knn。5knn用一句话来描述是:先根据欧氏距离选5个距离目标最近的点,并将这个5个点所属的主要分类作为这个目标的分类。 选择的7个特征中如:超链接NumLinks的数量比文字数量NumTextTokens重要,但knn是不会考虑到这些的。所以造成了一些误判。
Post
|
NumLinks | NumTextTokens |
A
|
2
|
20
|
B
|
0
|
25
|
new
|
1
|
23
|
上表中使用knn计算的new post距离较近的点是B而不是A。尽管NumLinks更加重要,结果却因为NumTestTokens而偏向于将B作为邻居。
怎样着手提高分类器性能
要想提高分类器性能,有以下几个基本的方式:
- 增加更多数据
当前的数据集对于训练是否足够?
- 从模型复杂度考虑
当前的模型参数是过于复杂还是过于简单,在这个例子中,我们选取了最近的5个邻居,那么5是最好的参数吗,要怎样调整?
- 调整特征空间
我们是否有合适的特征集合?是否需要调整当前的特征空间还是增加新的特征?这些特征当中是否有相互依赖的特征?
- 分类算法是否适合
如果不管调整算法的参数和特征空间都不能带来分类效果的显著提高,是否需要更换算法? 人们往往随机地选择上面的改进方法来“碰运气”,本文将会采取逐步分析的路线,来选择从哪方面来提高性能。
偏差和取舍
拟合结果会出现两种相反的情况:欠拟合和过拟合。
- 欠拟合
如模型为拟合一个二项式,则曲线是一条直线。而真实数据用一条直线表示是不足的,很多店不能出现在这条直线上。当使用该模型进行分类时发现新的数据不在该直线上,所以分类不准确。需要更复杂的多项式来表示该模型。
- 过拟合
过拟合则与欠拟合是相反的,为了让尽可能多的数据符合所选的多项式模型,所选的多项式过于复杂,即使某些数据本身就是不准确的。当用来预测时,也是不准确的。用不同的数据集来训练却得到不同的多项式。 我们需要最后的模型是“低偏差”和“低方差”的,但往往事与愿违,鱼和熊掌不能兼得。
修复“高偏差”
因为模型是过于简单的,在本例中,增加数据集和和减少特征都是无济于事的。出现高偏差,要增加模型的复杂度。
修复“高方差”
相反的,出现高方差表示对于当前数据模型过于复杂,解决方法是减少模型复杂度或增加更多的数据。
“偏差-方差”曲线
代码:
def bias_variance_analysis(clf_class, parameters, name): #数据集从小到大,步进大小为4 data_sizes = np.arange(60, 2000, 4) train_errors = [] test_errors = [] for data_size in data_sizes: train_error, test_error = measure( clf_class, parameters, name, data_size=data_size) train_errors.append(train_error) test_errors.append(test_error) #绘制“偏差-方差”曲线 plot_bias_variance(data_sizes, train_errors, test_errors, name, "Bias-Variance for '%s'" % name)
运行结果: 上图中高偏差的表现是随着数据集的增加,test error呈现了先下降后增高的曲线。而从两条曲线之间相隔很大的距离可以看出高方差。增加数据集的方式没有带来分类效果上的提升。 那么只能从减少特征个数和减少模型复杂度来考虑。 只使用两个特征NumTextTokens和LinkCount的运行结果,并没有多大的提高:
减少模型复杂度,变化knn中k的值来分析:
在k=40左右可以达到理想的效果,但knn是一个基于数据的模型,随着数据的增加,得到模型所花费的机器学习时间会越来越长,如上面的曲线,需要40个邻居来预测新数据,开销较大。这是knn的本质,此时需要考虑更换分类算法。
使用逻辑回归算法进行分类
为了缩短篇幅,参考另一篇文章逻辑回归算法简单示例。 在逻辑回归中有个参数c来控制模型的复杂度,相当于knn的k。sklearn中使用c来控制regulation,正规化,我理解为可以用c来调节过拟合的情况,c值越小惩罚度越高。从下表的实验结果可以看出LogReg比40NN的效果都要好。
Method
|
mean(scores)
|
stddev(scores)
|
LogReg C=0.1
|
0.64650
|
0.03139
|
LogReg C=1.00
|
0.64650
|
0.03155
|
LogReg C=10.00
|
0.64550
|
0.03102
|
LogReg C=0.01
|
0.63850
|
0.01950
|
40NN
|
0.62800
|
0.03750
|
但是,虽然较于knn,逻辑回归算法有了分类效果上的提高,但还远没有达到期望。 实际上,不必要求一个分类器同时对所有的分类都能预测得很好。如果能对于特定的分类,通过调整参数来分别获得好的预测效果也是可取的。这可以通过准确率,召回率和f1值来评估。 例如对与分类为good的回复,希望得到较高的准确率和召回率。那么在什么样的阈值下,分类结果被设定为good会得到较高的准确率和召回率呢?治理区medium clone来获得阈值。 最后得到的阈值为0.59,得到的准确率是80%以上,召回率是30%以上。这表示30%的good能被我们找到,并且这30%中80%以上确实是good。然后再用该阈值去进行分类。
总结
本文是笔者的一个学习笔记,大多是根据教程翻译而来,为了加深理解,每一步都做了实验。但还是有许多理解不深的地方,会在以后的学习和实践中逐渐加深。 原文在得到0.59的阈值而进行分类后,个人认为这时的分类器性能并不一定很好。但也要看需求。追求高的准确率会带来召回率(查全率)的下降,追求召回率也会使准确率下降。如何调节这个阈值,要看具体问题认为准确率重要还是查全率重要了。 机器学习偏重于理论研究,但没有实践的话,机器学习会是一个比较远的概念。相信在实践中不断发现和解决问题,会提高地很快。