結構化數據的預處理
前面所展示的一些示例已經很讓人興奮。但從總體看,數據類型還是比較單一的,比如圖片,比如文本。
這個單一並非指數據的類型單一,而是指數據組成的每一部分,在模型中對於結果預測的影響基本是一致的。
更通俗一點說,比如在手寫數字識別的案例中,圖片坐標(10,10)的點、(14,14)的點、(20,20)的點,對於最終的識別結果的影響,基本是同一個維度。
再比如在影評中,第10個單詞、第20個單詞、第30個單詞,對於最終結果的影響,也在同一個維度。
是的,這里指的是數據在維度上的不同。在某些問題中,數據集中的不同數據,對於結果的影響維度完全不同。這是數據所代表的屬性意義不同所決定的。這種情況在《從鍋爐工到AI專家(2)》一文中我們做了簡單描述,並講述了使用規范化數據的方式在保持數據內涵的同時降低數據取值范圍差異對於最終結果的負面影響。
隨着機器學習應用范圍的拓展,不同行業的不同問題,讓此類情況出現的越加頻繁。特別是在與大數據相連接的商業智能范疇,數據的來源、類型、維度,區別都很大。
在此我們使用心臟病預測的案例,對結構化數據的預處理做一個分享。
心臟病預測
我們能從TensorFlow 2.0的變化中看出來,TensorFlow越來越集注,只做好自己擅長的事情。很多必要的工作,TensorFlow會借助第三方的工具來完成。本例中的數據處理,將使用Python的Pandas和sklearn庫。這兩個庫在第一篇的開始部分我們已經安裝了。
樣本數據來自於克利夫蘭臨床基金會,是美國最大的心臟外科中心。樣本是一個包含幾百行數據的csv文件。每一行屬於一個病患,而每一列,則描述病人的某一項指征。我們試圖使用這些數據來預測一個病人是否患有心臟病。
延續我們的習慣,首先關注原始數據。這里只是一個示例,有很多樣本的選取只是為了說明問題,並不符合心臟外科的理論。在任何一個機器學習的實際應用中,都應當是專業人員,配合機器學習工程師一起分析、篩選、設計出這樣的表格,進而由全部團隊配合,得到盡可能多的原始數據。
樣本數據各列的名稱和所代表的含義成表如下:
特征名稱 | 描述 | 特征類型 | 數據類型 |
---|---|---|---|
Age | 年齡 | 數值 | integer |
Sex | (1 = 男; 0 = 女) | 分類 | integer |
CP | 胸腔疼痛類型(0, 1, 2, 3, 4) | 分類 | integer |
Trestbpd | 靜態血壓 (in mm Hg on admission to the hospital) | 數值 | integer |
Chol | 膽固醇 in mg/dl | 數值 | integer |
FBS | (空腹血糖含量達到120 mg/dl) (1 = 是; 0 = 否) | 分類 | integer |
RestECG | 靜態心電圖 (0, 1, 2) | 分類 | integer |
Thalach | 最大心率 | 數值 | integer |
Exang | 運動是否引發心絞痛 (1 = 是; 0 = 否) | 分類 | integer |
Oldpeak | 運動相對休息誘發ST段壓低 | 數值 | integer |
Slope | 運動峰ST段坡度 | 數值 | float |
CA | 用熒光染色的主要血管數量(0-3) | 數值 | integer |
Thal | 地中海貧血:3 = 正常; 6 = 固定缺陷; 7 = 可逆轉缺陷 | 分類 | string |
Target | 診出心臟疾病 (1 = 是; 0 = 否) | 分類標注結果 | integer |
表格出來,我們的問題也能看的很清楚了,這是一個典型的監督學習。使用表格中所有特征的值,進行模型訓練,最后一行的人工確診結果,相當於標定的目標值。
正式應用的時候,通過填表、體檢獲取模型所需各項數據,數據經過模型的預測,就能得到一個可以提供給醫生參考的心臟病初步診斷結果。
Pandas庫支持直接使用網址打開數據文件。但考慮到網絡訪問的問題,建議先手工自https://storage.googleapis.com/applied-dl/heart.csv下載數據文件。下載后保存到工作目錄,不要修改文件名稱。
接着我們先在Python3交互模式中,直觀的看一下數據內容。
$ 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.
>>> import pandas as pd
>>> dataframe = pd.read_csv('heart.csv')
>>> dataframe.head()
age sex cp trestbps chol fbs restecg thalach exang oldpeak slope ca thal target
0 63 1 1 145 233 1 2 150 0 2.3 3 0 fixed 0
1 67 1 4 160 286 0 2 108 1 1.5 2 3 normal 1
2 67 1 4 120 229 0 2 129 1 2.6 2 2 reversible 0
3 37 1 3 130 250 0 0 187 0 3.5 3 0 normal 0
4 41 0 2 130 204 0 2 172 0 1.4 1 0 normal 0
>>>
這些數據中,我們會根據不同數據的特征,采用不同的方式進行預處理。
以年齡數據為例,年齡是一個數值特征。同專業人員溝通之后就知道,一個人的年齡是31歲還是32歲,對於確診是否有心臟病幫助並不大。反而年齡段,一個人是30多歲(30-39歲)還是40多歲(40-49歲),對於判斷心臟病的可能性幫助更大。所以我們更希望的數據是年齡段數據。
接着問題來了,即便我們計算得到了年齡段數據,仍然存在數據數字化和規范化的問題。我們怎么表達60-69歲、70-79歲這樣的年齡段呢?用了這么久的機器學習,你肯定不會天真的認為計算機就應當知道“年齡段”是啥意思吧。
常用編碼方式
這里打斷一下,我們先梳理一下數據數字化的常用編碼方式。
數據的數字化,最常見有三種編碼方式,也就是所謂數字化方式。
第一種是 One-hot 。這種編碼方式,把每一項數據當成一個N項的數組,數據有多少種,數組就有多少項。數組中每一個元組取值只有0、1兩種形式。並且每一個數組中,只有一項是1。你想到了,前面手寫數字識別,有一種樣本的標簽就是這種形式。手寫數字的識別結果,實際也是這種形式。我們用一張表格來描述一下,假設我們對貓、狗、猴、雞四種動物做編碼:
| 貓 | 狗 | 猴 | 雞
---|---|---|---
貓|1|0|0|0
狗|0|1|0|0
猴|0|0|1|0
雞|0|0|0|1
這種方式編碼效率最低,直觀度也不夠。但是通常實現比較容易,速度快,並且適合表達某一特征“是”或者“否”的強烈因素。再者每一分類之間,並沒有強烈的連接性關系。
第二種編碼方式最常見,就是 序列化的唯一值 。比如1代表貓;2代表狗;3代表猴;4代表雞。
這種方式是我們平常用的最多的,至少下意識的,數據庫中每行記錄都是一個序列遞增值。
但這種編碼方式用在機器學習中通常有比較大的副作用,就是值的大小,往往會在神經網絡的數學運算中被賦予我們並不期望的含義。而且這些值,也不適合規范化到0到1、-1到+1這樣的浮點數字空間。所以在機器學習領域,除非這種值的遞增本身就有特殊的意義,否則並不建議使用。
第三種編碼方式就是我們在NLP中使用的 向量化 。向量化同樣首先確定一個N項的數組,每個數組元素值的取值范圍會非常廣,通常都是用浮點數據。這使得向量化的結果密度很高,能代表更多的分類。
不僅如此,對於NLP類的項目,向量化提供了對編碼結果進一步調整的機會。兩個我們期望更緊密的分類,比如意義相近的詞,可以在向量空間中更接近。這個“更接近”如果太抽象,你想象一下二維、或者三維空間中的兩個點之間的距離就理解了。
我們用表格做一個示例(僅為示例,表中數字並無特殊含義):
| | | | |
---|---|---|---
貓|1.3|0.7|0.1|2.1|
狗|0.9|1.1|0.9|1.0|
猴|1.8|0.4|1.3|0.8|
雞|0.6|0.5|0.5|1.3|
...|
其它的編碼方案多為這些方案的變種,我們后面在示例講解的部分會說到。
結構化數據的預處理
回到我們的心臟病預測實例。
年齡段的數據,實際就非常適合One-Hot編碼方式。因為我們關注的是某個年齡段的人,屬於心臟病的高發人群。特別是在經驗數據足夠之前,也不能簡單的就認為年齡大於多少就高發心臟病。因此年齡的線性特征,在我們的例子中也沒有必要過分強調。
我們把年齡段划分為18歲以下、18-25歲、25-30歲、30-35歲、35-40歲、40-45歲、45-50歲、50-55歲、55-60歲、60-65歲、65歲以上共11個年齡段。
那如下的年齡數據:
[[60.]
[41.]
[61.]
[59.]
[52.]]
經過處理之后,就是這樣的形式:
[[0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0.]
[0. 0. 0. 0. 0. 1. 0. 0. 0. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 0.]
[0. 0. 0. 0. 0. 0. 0. 0. 1. 0. 0.]
[0. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0.]]
TensorFlow中對於這種情況的數據已經有了專門的處理方式,以下一行語句就是完成這個工作:
# 代碼請在完整程序中執行
age_buckets = feature_column.bucketized_column(age, boundaries=[18, 25, 30, 35, 40, 45, 50, 55, 60, 65])
這等於是將線性的年齡數據,變成了年齡段的分類數據。
我們繼續看Thal字段,這代表患者地中海貧血症的表現情況。原始數據包括normal(正常)、fixed(固定)、reversible(可逆轉)三種情況。並且在原始數據中,是直接以字符串的形式來表達的。
我們可以使用下面語句,將Thal字段也轉換為one-hot編碼方式:
# 請在完整代碼中執行
# 獲取thal字段原始數據
thal = feature_column.categorical_column_with_vocabulary_list(
'thal', ['fixed', 'normal', 'reversible'])
# 轉換為one-hot編碼
thal_one_hot = feature_column.indicator_column(thal)
新的thal字段會是這個樣子:
[[0. 0. 1.]
[0. 1. 0.]
[0. 1. 0.]
[0. 0. 1.]
[0. 1. 0.]]
那么如果實例中不僅這三種可能,而是成千上萬中可能呢?你想到了,這種情況就需要選用向量化的編碼方式(還記得我們在前面自然語言語義識別中先將單詞數字化,然后再嵌入向量中的例子嗎?),比如:
# 此代碼不要執行,僅為示例
# 將thal字段嵌入到8維空間
thal_embedding = feature_column.embedding_column(thal, dimension=8)
編碼的結果會類似這樣:
[[ 0.15909313 -0.17830053 -0.01482905 0.26818395 -0.7063258 0.17809148
-0.33043832 0.34121528]
[ 0.2877485 0.20686264 0.2649153 -0.2827308 0.10686944 -0.12080232
-0.28829345 0.43876123]
[ 0.2877485 0.20686264 0.2649153 -0.2827308 0.10686944 -0.12080232
-0.28829345 0.43876123]
[ 0.15909313 -0.17830053 -0.01482905 0.26818395 -0.7063258 0.17809148
-0.33043832 0.34121528]
[ 0.2877485 0.20686264 0.2649153 -0.2827308 0.10686944 -0.12080232
-0.28829345 0.43876123]]
在分類可能性非常多的時候,還有一種可選的編碼方案是使用哈希表:
# 本代碼僅為示例,不要執行
thal_hashed = feature_column.categorical_column_with_hash_bucket(
'thal', hash_bucket_size=1000)
某兩項或者某多項字段互相關聯作用,需要整體表達的情況也很常見。這時候可以使用feature crosses編碼方式:
# 本代碼僅為示例,不要執行
crossed_feature = feature_column.crossed_column([age_buckets, thal], hash_bucket_size=1000)
上面三個編碼都是為了說明編碼方式本身,醫學方面的工作者千萬不要用來參考。
建模
建模本身跟前幾篇講過的基本相同。網絡的第一層也就是數據的輸入層需要單獨說明一下,那就是Keras已經為這種復雜的自定義結構化數據提供了輸入層的支持:
# 定義輸入層
feature_layer = tf.keras.layers.DenseFeatures(feature_columns)
# 將輸入層一定要放在模型的第一層
model = tf.keras.Sequential([
feature_layer,
layers.Dense(128, activation='relu'),
layers.Dense(128, activation='relu'),
layers.Dense(1, activation='sigmoid')
])
有了自定義結構化數據的自動處理,節省了我們在TensorFlow1.0中需要自己操作的大量預處理過程,工作量減少,出錯的幾率也少了。
模型的訓練和評估就都是一條語句,略去不講。
完整代碼
好了,貼出完整的可執行代碼:
#!/usr/bin/env python3
from __future__ import absolute_import, division, print_function
# 引入所需頭文件
import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow import feature_column
from tensorflow.keras import layers
from sklearn.model_selection import train_test_split
# 打開樣本數據文件
# URL = 'https://storage.googleapis.com/applied-dl/heart.csv' #直接從網上打開可以使用這一行
URL = 'heart.csv'
dataframe = pd.read_csv(URL)
# 顯示數據的頭幾行
# dataframe.head()
# 將數據中20%分做測試數據
train, test = train_test_split(dataframe, test_size=0.2)
# 將數據的64%作為訓練數據,16%作為驗證數據
train, val = train_test_split(train, test_size=0.2)
# 顯示訓練、驗證、測試三個數據集的記錄數量
print(len(train), 'train examples')
print(len(val), 'validation examples')
print(len(test), 'test examples')
# 定義一個函數,將Pandas Dataframe對象轉換為TensorFlow的Dataset對象
def df_to_dataset(dataframe, shuffle=True, batch_size=32):
dataframe = dataframe.copy()
# target字段是確診是否罹患心臟病的數據,取出來作為標注數據
labels = dataframe.pop('target')
# 生成Dataset
ds = tf.data.Dataset.from_tensor_slices((dict(dataframe), labels))
if shuffle:
# 是否需要亂序
ds = ds.shuffle(buffer_size=len(dataframe))
# 設置每批次的記錄數量
ds = ds.batch(batch_size)
return ds
# 訓練、驗證、測試三個數據集都轉換成Dataset類型,其中訓練集需要重新排序
train_ds = df_to_dataset(train)
val_ds = df_to_dataset(val, shuffle=False)
test_ds = df_to_dataset(test, shuffle=False)
# 用於保存所需的數據列
feature_columns = []
# 根據字段名,添加所需的數據列
for header in ['age', 'trestbps', 'chol', 'thalach', 'oldpeak', 'slope', 'ca']:
feature_columns.append(feature_column.numeric_column(header))
# 取出年齡數據
age = feature_column.numeric_column("age")
# 按照18-25/25-30/30-35/.../60-65為年齡分段,最后形成one-hot編碼
age_buckets = feature_column.bucketized_column(age, boundaries=[18, 25, 30, 35, 40, 45, 50, 55, 60, 65])
# 數據段作為一個新參量添加到數據集
feature_columns.append(age_buckets)
# 獲取thal字段原始數據
thal = feature_column.categorical_column_with_vocabulary_list(
'thal', ['fixed', 'normal', 'reversible'])
# 做one-hot編碼
thal_one_hot = feature_column.indicator_column(thal)
# 作為新的數據列添加
feature_columns.append(thal_one_hot)
# 將thal嵌入8維空間做向量化
thal_embedding = feature_column.embedding_column(thal, dimension=8)
feature_columns.append(thal_embedding)
# 把年齡段和thal字段作為關聯屬性加入新列
crossed_feature = feature_column.crossed_column([age_buckets, thal], hash_bucket_size=1000)
crossed_feature = feature_column.indicator_column(crossed_feature)
feature_columns.append(crossed_feature)
# 定義輸入層
feature_layer = tf.keras.layers.DenseFeatures(feature_columns)
# 定義完整模型
model = tf.keras.Sequential([
feature_layer,
layers.Dense(128, activation='relu'),
layers.Dense(128, activation='relu'),
layers.Dense(1, activation='sigmoid')
])
# 模型編譯
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])
# 訓練
model.fit(train_ds,
validation_data=val_ds,
epochs=5)
# 評估
test_loss, test_acc = model.evaluate(test_ds)
# 顯示評估的正確率
print('===================\nTest accuracy:', test_acc)
這樣的內容,的確是使用IPython筆記本的互動方式邊講邊試效果最好。不過可惜國內訪問Colab這樣的工具網站還是不方便。
上面程序執行的輸出如下:
Epoch 1/5
7/7 [==============================] - 1s 110ms/step - loss: 1.2045 - accuracy: 0.5884 - val_loss: 1.1234 - val_accuracy: 0.7755
Epoch 2/5
7/7 [==============================] - 0s 46ms/step - loss: 1.0691 - accuracy: 0.6383 - val_loss: 0.5731 - val_accuracy: 0.7959
Epoch 3/5
7/7 [==============================] - 0s 43ms/step - loss: 0.9016 - accuracy: 0.7100 - val_loss: 0.5924 - val_accuracy: 0.7551
Epoch 4/5
7/7 [==============================] - 0s 44ms/step - loss: 0.5362 - accuracy: 0.7055 - val_loss: 0.6440 - val_accuracy: 0.7755
Epoch 5/5
7/7 [==============================] - 0s 43ms/step - loss: 0.7290 - accuracy: 0.6940 - val_loss: 0.5966 - val_accuracy: 0.7347
2/2 [==============================] - 0s 24ms/step - loss: 0.4600 - accuracy: 0.7705
===================
Test accuracy: 0.7704918
為了說明數據的預處理,我們選用了一些並不合理的特征項用於演示。再加上較少的訓練數據和訓練過程,預測准確率很低也就沒有什么好奇怪了。
最后還有一個問題要補充。就是比如年齡字段,我們已經預處理並且增加了一個年齡段字段,那原來的年齡字段還需要保留嗎?
我們上面的代碼僅為示例,保留了年齡字段,但這並不能說明什么問題。類似這樣的字段是否保留,關鍵還是看專業方面的需求。如果覺得年齡的線性特征本身對於預測結果還是有意義的,那就保留。額外增加的年齡段等於是一個強調的作用。
如果覺得年齡原始數據本身並沒有什么意義,用年齡段表達足以說明問題,那年齡字段就應當去掉。通常說,在機器學習中,如果特征項非常多的話,單獨一個年齡字段保留或者不保留,對最終結果的影響都不大,不用太過認真。
與此對應的,thal字段,原本就是字符串類型。這種字段一定需要預處理之后再進入數據集,而原始的字段是不能保留在數據集中的。字符串在神經網絡中不能直接處理是一方面。即便能處理,這種無數學意義的高維數據對最終結果一定有很大的負面影響。
(待續...)