本文是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的閾值而進行分類后,個人認為這時的分類器性能並不一定很好。但也要看需求。追求高的准確率會帶來召回率(查全率)的下降,追求召回率也會使准確率下降。如何調節這個閾值,要看具體問題認為准確率重要還是查全率重要了。 機器學習偏重於理論研究,但沒有實踐的話,機器學習會是一個比較遠的概念。相信在實踐中不斷發現和解決問題,會提高地很快。