三種開發模式
使用TensorFlow 2.0完成機器學習一般有三種方式:
- 使用底層邏輯
這種方式使用Python函數自定義學習模型,把數學公式轉化為可執行的程序邏輯。接着在訓練循環中,通過tf.GradientTape()迭代,使用tape.gradient()梯度下降,使用optimizer.apply_gradients()更新模型權重,逐次逼近,完成模型訓練。 - 使用Keras高層接口
TensorFlow 1.x的開發中,Keras就作為第三方庫存在。2.0中,更是已經成為標准配置。我們前面大多的例子都是基於Keras或者自定義Keras模型配合底層訓練循環完成。從網上的一些開源項目來看,這已經是應用最廣泛的方式。 - 今天要介紹的評估器tf.estimator
評估器是TensorFlow官方推薦的內置高級API,層次上看跟Keras實際處於同樣位置,只是似乎大家都視而不見了,以至於現在從用戶的實際情況看用的人要遠遠少於Keras。
通常認為評估器因為內置的緊密結合,運行速度要高於Keras。Keras一直是一個通用的高層框架,除了支持TensorFlow作為后端,還同時支持Theano和CNTK。高度的抽象肯定會影響Keras的速度,不過本人並未實際對比測試。我覺的,對於大量數據導致的長時間訓練來說,這點效率上的差異不應當成為大問題,否則Python這種解釋型的語言就不會成為優選的機器學習基礎平台了。
在TensorFlow 1.x中可以使用tf.estimator.model_to_estimator方法將Keras模型轉換為TensorFlow評估器。TensorFlow 2.0中,統一到了tf.keras.estimator.model_to_estimator方法。所以如果偏愛評估器的話,使用Keras也不會成為障礙。
評估器基本工作流程
其實從編程邏輯來看,這些高層API所提供的工作方式是很相似的。使用評估器開發機器學習大致分為如下步驟:
- 載入數據
- 數據清洗和數據預處理
- 編寫數據流水線輸入函數
- 定義評估器模型
- 訓練
- 評估
在這個流程里面,只有“編寫數據流水線輸入函數”這一步是跟Keras模型是不同的。在Keras模型中,我們直接准備數據集,把數據集送入到模型即可。而在評估器中,數據的輸入,需要指定一個函數供評估器調用。
使用評估器的實例
這一個來自官方文檔的實例比較殘酷,使用泰坦尼克號的乘客名單,評估在沉船事件發生后,客戶能生存下來的可能性。
數據格式是csv,建議先下載,保存到工作目錄:
訓練集數據:https://storage.googleapis.com/tf-datasets/titanic/train.csv
評估集數據:https://storage.googleapis.com/tf-datasets/titanic/eval.csv
文件下載后不要修改名稱。
數據包含如下屬性維度:
屬性名稱 | 屬性描述 |
---|---|
sex | 乘客性別 |
age | 乘客年齡 |
n_siblings_spouses | 隨行兄弟或者配偶數量 |
parch | 隨行父母或者子女數量 |
fare | 船費金額 |
class | 船艙等級 |
deck | 甲板編號 |
embark_town | 登船地點 |
alone | 是否為獨自旅行 |
從這些屬性中能看出,數據的收集者是非常用心的。
比如隨行兄弟或者配偶、隨行父母或者子女這種特征,在大多人的傳統觀念中,肯定會用類似“隨行家屬數量”這樣的維度合並在一起。
但在這個案例中,兩個不同的維度,對於最終存活影響肯定是不同的。
基本數據分析
這部分的工作其實跟評估器的使用沒有什么關系,但這正是大數據時代的魅力所在,所以我們還是延續官方文檔的思路來看一看。
先在命令行執行Python,啟動交互環境。然后把下面這部分代碼拷貝到Python執行。這些代碼完成引用擴展庫、載入數據等基本工作。
# 引入擴展庫
from __future__ import absolute_import, division, print_function, unicode_literals
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import tensorflow as tf
# 載入數據
dftrain = pd.read_csv('train.csv')
dfeval = pd.read_csv('eval.csv')
# 分離標注字段
y_train = dftrain.pop('survived')
y_eval = dfeval.pop('survived')
dftrain.head()
這時候命令行看起來大致是這個樣子:
$ python3
Python 3.7.3 (default, Mar 27 2019, 09:23:39)
[Clang 10.0.0 (clang-1000.11.45.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> # 引入擴展庫
... from __future__ import absolute_import, division, print_function, unicode_literals
>>>
>>> import numpy as np
>>> import pandas as pd
>>> import matplotlib.pyplot as plt
>>> import tensorflow as tf
>>>
>>> # 載入數據
... dftrain = pd.read_csv('train.csv')
>>> dfeval = pd.read_csv('eval.csv')
>>> # 分離標注字段
... y_train = dftrain.pop('survived')
>>> y_eval = dfeval.pop('survived')
>>>
>>> dftrain.head()
sex age n_siblings_spouses parch fare class deck embark_town alone
0 male 22.0 1 0 7.2500 Third unknown Southampton n
1 female 38.0 1 0 71.2833 First C Cherbourg n
2 female 26.0 0 0 7.9250 Third unknown Southampton y
3 female 35.0 1 0 53.1000 First C Southampton n
4 male 28.0 0 0 8.4583 Third unknown Queenstown y
>>>
最后是列出的訓練集頭5條記錄。
我們先看看乘客的年齡分布(后續的代碼都是直接拷貝到Python命令行執行):
dftrain.age.hist(bins=20)
plt.show()
直方圖中顯示,乘客年齡主要分布在20歲至30歲之間。
再來看看性別分布:
dftrain.sex.value_counts().plot(kind='barh')
plt.show()
男性乘客的數量,幾乎是女性乘客的兩倍。
接着是船艙等級的分布,這個參數能間接體現乘客的經濟實力:
dftrain['class'].value_counts().plot(kind='barh')
plt.show()
圖中顯示,大多數乘客還是在三等艙。
繼續看乘客上船的地點:
dftrain['embark_town'].value_counts().plot(kind='barh')
plt.show()
大多數乘客來自南安普頓。
繼續,把性別跟最后生存標注關聯起來:
pd.concat([dftrain, y_train], axis=1).groupby('sex').survived.mean().plot(kind='barh').set_xlabel('% survive')
plt.show()
女性的存活率幾乎超過男性的5倍。
再來一個更復雜的統計,我們首先把年齡分段,然后看看不同年齡段的乘客最終存活率:
def calc_age_section(n, lim):
return'[%.f,%.f)' % (lim*(n//lim), lim*(n//lim)+lim) # map function
addone = pd.Series([calc_age_section(s, 10) for s in dftrain.age])
dftrain['ages'] = addone
pd.concat([dftrain, y_train], axis=1).groupby('ages').survived.mean().plot(kind='barh').set_xlabel('% survive');
plt.show()
10歲以下兒童和80歲以上的老人得到了最多的生存機會。
在那個寒冷、慌亂的沉船夜晚,弱者反而更多的活了下來。
數據的預處理
數據預處理這個話題我們講了很多次,這是通常機器學習研發工作中,工程師需要做的最多工作。
泰坦尼克號乘客名單的數據雖然不復雜,也屬於典型的結構化數據。
其中主要包含兩類,一種是分類型的數據,比如船艙等級,比如上船城市名稱。另一類則是簡單的數值,比如年齡和購票價格。
對於數值型的數據可以直接規范化后進入模型,對於分類型的數據,則還需要做編碼,我們這里還是使用最常見的one-hot。
# 定義所需的數據列,分為分類型屬性和數值型屬性分別定義
CATEGORICAL_COLUMNS = ['sex', 'n_siblings_spouses', 'parch', 'class', 'deck',
'embark_town', 'alone']
NUMERIC_COLUMNS = ['age', 'fare']
# 輔助函數,把給定數據列做one-hot編碼
def one_hot_cat_column(feature_name, vocab):
return tf.feature_column.indicator_column(
tf.feature_column.categorical_column_with_vocabulary_list(feature_name,
vocab))
# 最終使用的數據列,先置空
feature_columns = []
for feature_name in CATEGORICAL_COLUMNS:
# 分類的屬性都要做one-hot編碼,然后加入數據列
vocabulary = dftrain[feature_name].unique()
feature_columns.append(one_hot_cat_column(feature_name, vocabulary))
for feature_name in NUMERIC_COLUMNS:
# 數值類的屬性直接入列
feature_columns.append(tf.feature_column.numeric_column(feature_name,
dtype=tf.float32))
數據輸入函數
評估器的訓練、評估都需要使用數據輸入函數作為參數。輸入函數本身不接受任何參數,返回一個tf.data.Dataset對象給模型用於供給數據。
因為除了數據集不同,訓練和評估模型所使用的數據格式通常都是一樣的。所以經常會在程序代碼上,共用一個函數,然后用參數來區分用於評估還是用於訓練。
然而輸入函數相當於回調函數,由評估器控制着調用,這過程中並沒有參數傳遞。所以比較聰明的做法可以使用嵌套函數的方法來定義,比如:
# 這是一個很少量數據的樣本,直接把整個數據集當做一批
NUM_EXAMPLES = len(y_train)
# 輸入函數的構造函數
def make_input_fn(X, y, n_epochs=None, shuffle=True):
def input_fn():
dataset = tf.data.Dataset.from_tensor_slices((dict(X), y))
# 亂序
if shuffle:
dataset = dataset.shuffle(NUM_EXAMPLES)
# 訓練時讓數據重復盡量多的次數
dataset = dataset.repeat(n_epochs)
dataset = dataset.batch(NUM_EXAMPLES)
return dataset
return input_fn
# 訓練、評估所使用的數據輸入函數,區別只是數據是否亂序以及迭代多少次
train_input_fn = make_input_fn(dftrain, y_train)
eval_input_fn = make_input_fn(dfeval, y_eval, shuffle=False, n_epochs=1)
模型和源碼
本例中我們直接使用預定義的評估器模型(pre-made estimator)。所以代碼非常簡單,定義、訓練、評估都是只需要一行代碼:
# 使用線性分類器作為模型
linear_est = tf.estimator.LinearClassifier(feature_columns)
# 訓練
linear_est.train(train_input_fn, max_steps=100)
# 評估
result = linear_est.evaluate(eval_input_fn)
我們來看看完整代碼:
#!/usr/bin/env python3
# 引入擴展庫
from __future__ import absolute_import, division, print_function, unicode_literals
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import tensorflow as tf
# 載入數據
dftrain = pd.read_csv('train.csv')
dfeval = pd.read_csv('eval.csv')
# 分離標注字段
y_train = dftrain.pop('survived')
y_eval = dfeval.pop('survived')
################################################################
# 定義所需的數據列,分為分類型屬性和數值型屬性分別定義
CATEGORICAL_COLUMNS = ['sex', 'n_siblings_spouses', 'parch', 'class', 'deck',
'embark_town', 'alone']
NUMERIC_COLUMNS = ['age', 'fare']
# 輔助函數,把給定數據列做one-hot編碼
def one_hot_cat_column(feature_name, vocab):
return tf.feature_column.indicator_column(
tf.feature_column.categorical_column_with_vocabulary_list(feature_name,
vocab))
# 最終使用的數據列,先置空
feature_columns = []
for feature_name in CATEGORICAL_COLUMNS:
# 分類的屬性都要做one-hot編碼,然后加入數據列
vocabulary = dftrain[feature_name].unique()
feature_columns.append(one_hot_cat_column(feature_name, vocabulary))
for feature_name in NUMERIC_COLUMNS:
# 數值類的屬性直接入列
feature_columns.append(tf.feature_column.numeric_column(feature_name,
dtype=tf.float32))
################################################################
# 這是一個很少量數據的樣本,直接把整個數據集當做一批
NUM_EXAMPLES = len(y_train)
# 輸入函數的構造函數
def make_input_fn(X, y, n_epochs=None, shuffle=True):
def input_fn():
dataset = tf.data.Dataset.from_tensor_slices((dict(X), y))
# 亂序
if shuffle:
dataset = dataset.shuffle(NUM_EXAMPLES)
# 訓練時讓數據重復盡量多的次數
dataset = dataset.repeat(n_epochs)
dataset = dataset.batch(NUM_EXAMPLES)
return dataset
return input_fn
# 訓練、評估所使用的數據輸入函數,區別只是數據是否亂序以及迭代多少次
train_input_fn = make_input_fn(dftrain, y_train)
eval_input_fn = make_input_fn(dfeval, y_eval, shuffle=False, n_epochs=1)
# 使用線性分類器作為模型
linear_est = tf.estimator.LinearClassifier(feature_columns)
# 訓練
linear_est.train(train_input_fn, max_steps=100)
# 評估
result = linear_est.evaluate(eval_input_fn)
print("----------------------------------")
print(pd.Series(result))
程序執行的最后顯示了評估的結果,在我的電腦上顯示的結果是這樣的:
----------------------------------
accuracy 0.765152
accuracy_baseline 0.625000
auc 0.832844
auc_precision_recall 0.789631
average_loss 0.478908
label/mean 0.375000
loss 0.478908
precision 0.703297
prediction/mean 0.350790
recall 0.646465
global_step 100.000000
正確率不算太高。
評估器的模型使用起來很簡單,我們嘗試換用另外一種模型,比如提升樹分類器。
# 以下代碼放在程序最后,因為這個數據集非常小,速度很快,所以做兩次學習也並不感覺慢
n_batches = 1
est = tf.estimator.BoostedTreesClassifier(feature_columns,
n_batches_per_layer=n_batches)
# 訓練
est.train(train_input_fn, max_steps=100)
# 評估
result = est.evaluate(eval_input_fn)
print("----------------------------------")
print(pd.Series(result))
這次得到的結果是這樣的:
----------------------------------
accuracy 0.825758
accuracy_baseline 0.625000
auc 0.872360
auc_precision_recall 0.857325
average_loss 0.411853
label/mean 0.375000
loss 0.411853
precision 0.784946
prediction/mean 0.382282
recall 0.737374
global_step 100.000000
雖然准確率仍然並不高,但比起來線性分類器,提高還是算的上明顯。
性能評價
評價機器學習模型的性能,除了看剛才的統計信息,繪圖是非常好的一種方式,可以更直觀,某些問題也能體現的一目了然。
我們在上面程序的最后再增加幾行代碼,繪制預測概率的統計信息:
# 繪制預測概率直方圖
pred_dicts1 = list(linear_est.predict(eval_input_fn))
pred_dicts2 = list(bt_est.predict(eval_input_fn))
probs1 = pd.Series([pred['probabilities'][1] for pred in pred_dicts1])
probs2 = pd.Series([pred['probabilities'][1] for pred in pred_dicts2])
plt.figure(figsize=(14, 5))
plt.subplot(1, 2, 1)
probs1.plot(kind='hist', bins=20, title='linear-est predicted probabilities');
plt.subplot(1, 2, 2)
probs2.plot(kind='hist', bins=20, title='bt-est predicted probabilities');
plt.show()
大量集中在圖形左側的數據簇,顯示了乘客九死一生的悲慘命運。
因為我們的預測結果只有兩種可能:0表示未能生存;1表示生存下來。所以預測的結果,應當明顯的盡量靠近0和1兩端。中間懸而未決的部分應當盡可能少。從圖形的情況看,如果不考慮分類准確率問題,提升樹分類器效果要更好一些。
當然作為成熟的預定義模型,模型都是很優秀的,只是提升樹可能更適合本應用的場景。
盡管這個例子很簡單,但現在的分類算法實際越來越復雜。預測結果在不同類別數據上表現並不不均衡,使得使用正確率這樣的傳統標准不能恰當的反應分類器的性能,本例中也已經出現了這種傾向。或者說,分類器,對於不同類別的樣本,性能表現是不一致的。
這種情況,使用ROC(Receiver Operating Characteristic)觀察者操作曲線能夠表現的更清楚。
對於一個分類器的分類結果,一般有以下四種情況:
- 真陽性(TP):判斷為1,實際上也為1。
- 偽陽性(FP):判斷為1,實際上為0。
- 真陰性(TN):判斷為0,實際上也為0。
- 偽陰性(FN):判斷為0,實際上為1。
ROC圖中,左上角是真陽性的極點,曲線越接近左上角,意味着分類器性能越好。所以左上角是分類器追求的方向。
下面代碼,請接續在上面代碼之后,用來繪制ROC曲線:
# 繪制ROC(Receiver Operating Characteristic)曲線
from sklearn.metrics import roc_curve
def plot_roc(probs, title):
fpr, tpr, _ = roc_curve(y_eval, probs)
plt.plot(fpr, tpr)
plt.title(title)
plt.xlabel('false positive rate')
plt.ylabel('true positive rate')
plt.xlim(0,)
plt.ylim(0,)
plt.figure(figsize=(14, 5))
plt.subplot(1, 2, 1)
plot_roc(probs1, "linear-est ROC")
plt.subplot(1, 2, 2)
plot_roc(probs2, "bt-est ROC")
plt.show()
從ROC曲線看,在本例中使用提升樹模型的優勢更為明顯。
(待續...)