0
雷鋒網 AI 科技評論按:本文是由來自英特爾的數據科學家 Dipanjan Sarkar 在 Medium 上發布的「特征工程」博客下篇,給領域內的研究人員補充特征工程的相關知識,不論學術研究、數據競賽還是解決商業問題都必不可少。在上篇中,作者介紹了連續型數值數據的特征工程處理方法。本篇為下篇,主要介紹離散數據的除了方法。雷鋒網 AI 科技評論對原文進行了編譯。

在上一篇文章中,我們介紹了許多用于處理結構化的連續數值數據(continuous numeric data)的特征工程。而在本篇文章中,我們將繼續介紹另一種結構化數據的處理 —— 這種數據本質上是離散的,俗稱分類數據(categorical data)。由于在處理數值數據的時候,我們不必處理屬于某一分類類型的數據屬性中與每個類別值有關的額外的語義復雜性,因此處理數值數據通常比處理分類數據來得更加容易。我們將結合實際操作來討論處理分類數據的幾種編碼方案,以及處理大規模特征爆炸(通常稱為「維度詛咒 curse of dimensionality」)的一些流行技巧。
我認為你現在必須要認識到特征工程的動機和重要性,我們在該系列文章的第一部分中也詳細強調了這一點。如果有必要,請快速溫習一下。簡而言之,機器學習算法不能直接處理分類數據,因此在開始為數據建模之前我們需要對數據進行一些工程處理和轉換。
在深入研究特征工程之前,讓我們先了解一下分類數據。通常,在自然界中可分類的任意數據屬性都是離散值,這意味著它們屬于某一特定的有限類別。在模型預測的屬性或者變量(通常被稱為響應變量 response variables)中,這些也經常被稱為類別或者標簽。這些離散值在自然界中可以是文本或者數字(甚至是諸如圖像這樣的非結構化數據)。分類數據有兩大類——定類(Nominal)和定序(Ordinal)。
在任意定類分類數據屬性中,這些屬性值之間沒有順序的概念。如下圖所示,舉個簡單的例子,天氣分類。我們可以看到,在這個特定的場景中,主要有六個大類,而這些類之間沒有任何順序上的關系(刮風天并不總是發生在晴天之前,并且也不能說比晴天來的更小或者更大)

將天氣作為分類屬性
與天氣相類似的屬性還有很多,比如電影、音樂、電子游戲、國家、食物和美食類型等等,這些都屬于定類分類屬性。
定序分類的屬性值則存在著一定的順序意義或概念。例如,下圖中的字母標識了襯衫的大小。顯而易見的是,當我們考慮襯衫的時候,它的“大小”屬性是很重要的(S 碼比 M 碼來的小,而 M 碼又小于 L 碼等等)。

襯衫大小作為定序分類屬性
鞋號、受教育水平和公司職位則是定序分類屬性的一些其它例子。既然已經對分類數據有了一個大致的理解之后,接下來我們來看看一些特征工程的策略。
在接受像文本標簽這樣復雜的分類數據類型問題上,各種機器學習框架均已取得了許多的進步。通常,特征工程中的任意標準工作流都涉及將這些分類值轉換為數值標簽的某種形式,然后對這些值應用一些編碼方案。我們將在開始之前導入必要的工具包。
import pandas as pd
import numpy as np
定類屬性由離散的分類值組成,它們沒有先后順序概念。這里的思想是將這些屬性轉換成更具代表性的數值格式,這樣可以很容易被下游的代碼和流水線所理解。我們來看一個關于視頻游戲銷售的新數據集。這個數據集也可以在 Kaggle 和我的 GitHub 倉庫中找到。
vg_df = pd.read_csv('datasets/vgsales.csv', encoding='utf-8')
vg_df[['Name', 'Platform', 'Year', 'Genre', 'Publisher']].iloc[1:7]

游戲銷售數據
讓我們首先專注于上面數據框中“視頻游戲風格(Genre)”屬性。顯而易見的是,這是一個類似于“發行商(Publisher)”和“平臺(Platform)”屬性一樣的定類分類屬性。我們可以很容易得到一個獨特的視頻游戲風格列表,如下。
genres = np.unique(vg_df['Genre'])
genres
Output
------
array(['Action', 'Adventure', 'Fighting', 'Misc', 'Platform', 'Puzzle', 'Racing', 'Role-Playing', 'Shooter', 'Simulation', 'Sports', 'Strategy'], dtype=object)
輸出結果表明,我們有 12 種不同的視頻游戲風格。我們現在可以生成一個標簽編碼方法,即利用 scikit-learn 將每個類別映射到一個數值。
from sklearn.preprocessing import LabelEncoder
gle = LabelEncoder()
genre_labels = gle.fit_transform(vg_df['Genre'])
genre_mappings = {index: label for index, label in enumerate(gle.classes_)}
genre_mappings
Output
------
{0: 'Action', 1: 'Adventure', 2: 'Fighting', 3: 'Misc', 4: 'Platform', 5: 'Puzzle', 6: 'Racing', 7: 'Role-Playing', 8: 'Shooter', 9: 'Simulation', 10: 'Sports', 11: 'Strategy'}
因此,在 LabelEncoder 類的實例對象 gle 的幫助下生成了一個映射方案,成功地將每個風格屬性映射到一個數值。轉換后的標簽存儲在 genre_labels 中,該變量允許我們將其寫回數據表中。
vg_df['GenreLabel'] = genre_labels
vg_df[['Name', 'Platform', 'Year', 'Genre', 'GenreLabel']].iloc[1:7]

視頻游戲風格及其編碼標簽
如果你打算將它們用作預測的響應變量,那么這些標簽通常可以直接用于諸如 sikit-learn 這樣的框架。但是如前所述,我們還需要額外的編碼步驟才能將它們用作特征。
定序屬性是一種帶有先后順序概念的分類屬性。這里我將以本系列文章第一部分所使用的神奇寶貝數據集進行說明。讓我們先專注于 「世代(Generation)」 屬性。
poke_df = pd.read_csv('datasets/Pokemon.csv', encoding='utf-8')
poke_df = poke_df.sample(random_state=1, frac=1).reset_index(drop=True)
np.unique(poke_df['Generation'])
Output
------
array(['Gen 1', 'Gen 2', 'Gen 3', 'Gen 4', 'Gen 5', 'Gen 6'], dtype=object)
根據上面的輸出,我們可以看到一共有 6 代,并且每個神奇寶貝通常屬于視頻游戲的特定世代(依據發布順序),而且電視系列也遵循了相似的時間線。這個屬性通常是定序的(需要相關的領域知識才能理解),因為屬于第一代的大多數神奇寶貝在第二代的視頻游戲或者電視節目中也會被更早地引入。神奇寶貝的粉絲們可以看下下圖,然后記住每一代中一些比較受歡迎的神奇寶貝(不同的粉絲可能有不同的看法)。

基于不同類型和世代選出的一些受歡迎的神奇寶貝
因此,它們之間存在著先后順序。一般來說,沒有通用的模塊或者函數可以根據這些順序自動將這些特征轉換和映射到數值表示。因此,我們可以使用自定義的編碼\映射方案。
gen_ord_map = {'Gen 1': 1, 'Gen 2': 2, 'Gen 3': 3, 'Gen 4': 4, 'Gen 5': 5, 'Gen 6': 6}
poke_df['GenerationLabel'] = poke_df['Generation'].map(gen_ord_map)
poke_df[['Name', 'Generation', 'GenerationLabel']].iloc[4:10]

神奇寶貝世代編碼
從上面的代碼中可以看出,來自 pandas 庫的 map(...) 函數在轉換這種定序特征的時候非常有用。
如果你還記得我們之前提到過的內容,通常對分類數據進行特征工程就涉及到一個轉換過程,我們在前一部分描述了一個轉換過程,還有一個強制編碼過程,我們應用特定的編碼方案為特定的每個類別創建虛擬變量或特征分類屬性。
你可能想知道,我們剛剛在上一節說到將類別轉換為數字標簽,為什么現在我們又需要這個?原因很簡單。考慮到視頻游戲風格,如果我們直接將 GenereLabel 作為屬性特征提供給機器學習模型,則模型會認為它是一個連續的數值特征,從而認為值 10 (體育)要大于值 6 (賽車),然而事實上這種信息是毫無意義的,因為體育類型顯然并不大于或者小于賽車類型,這些不同值或者類別無法直接進行比較。因此我們需要另一套編碼方案層,它要能為每個屬性的所有不同類別中的每個唯一值或類別創建虛擬特征。
考慮到任意具有 m 個標簽的分類屬性(變換之后)的數字表示,獨熱編碼方案將該屬性編碼或變換成 m 個二進制特征向量(向量中的每一維的值只能為 0 或 1)。那么在這個分類特征中每個屬性值都被轉換成一個 m 維的向量,其中只有某一維的值為 1。讓我們來看看神奇寶貝數據集的一個子集。
poke_df[['Name', 'Generation', 'Legendary']].iloc[4:10]

神奇寶貝數據集子集
這里關注的屬性是神奇寶貝的「世代(Generation)」和「傳奇(Legendary)」狀態。第一步是根據之前學到的將這些屬性轉換為數值表示。
from sklearn.preprocessing import OneHotEncoder, LabelEncoder
# transform and map pokemon generations
gen_le = LabelEncoder()
gen_labels = gen_le.fit_transform(poke_df['Generation'])
poke_df['Gen_Label'] = gen_labels
# transform and map pokemon legendary status
leg_le = LabelEncoder()
leg_labels = leg_le.fit_transform(poke_df['Legendary'])
poke_df['Lgnd_Label'] = leg_labels
poke_df_sub = poke_df[['Name', 'Generation', 'Gen_Label', 'Legendary', 'Lgnd_Label']]
poke_df_sub.iloc[4:10]

轉換后的標簽屬性
Gen_Label 和 Lgnd_Label 特征描述了我們分類特征的數值表示。現在讓我們在這些特征上應用獨熱編碼方案。
# encode generation labels using one-hot encoding scheme
gen_ohe = OneHotEncoder()
gen_feature_arr = gen_ohe.fit_transform(poke_df[['Gen_Label']]).toarray()
gen_feature_labels = list(gen_le.classes_)
gen_features = pd.DataFrame(gen_feature_arr, columns=gen_feature_labels)
# encode legendary status labels using one-hot encoding scheme
leg_ohe = OneHotEncoder()
leg_feature_arr = leg_ohe.fit_transform(poke_df[['Lgnd_Label']]).toarray()
leg_feature_labels = ['Legendary_'+str(cls_label) for cls_label in leg_le.classes_]
leg_features = pd.DataFrame(leg_feature_arr, columns=leg_feature_labels)
通常來說,你可以使用 fit_transform 函數將兩個特征一起編碼(通過將兩個特征的二維數組一起傳遞給函數,詳情查看文檔)。但是我們分開編碼每個特征,這樣可以更易于理解。除此之外,我們還可以創建單獨的數據表并相應地標記它們。現在讓我們鏈接這些特征表(Feature frames)然后看看最終的結果。
poke_df_ohe = pd.concat([poke_df_sub, gen_features, leg_features], axis=1)
columns = sum([['Name', 'Generation', 'Gen_Label'], gen_feature_labels, ['Legendary', 'Lgnd_Label'], leg_feature_labels], [])
poke_df_ohe[columns].iloc[4:10]

神奇寶貝世代和傳奇狀態的獨熱編碼特征
此時可以看到已經為「世代(Generation)」生成 6 個虛擬變量或者二進制特征,并為「傳奇(Legendary)」生成了 2 個特征。這些特征數量是這些屬性中不同類別的總數。某一類別的激活狀態通過將對應的虛擬變量置 1 來表示,這從上面的數據表中可以非常明顯地體現出來。
考慮你在訓練數據上建立了這個編碼方案,并建立了一些模型,現在你有了一些新的數據,這些數據必須在預測之前進行如下設計。
new_poke_df = pd.DataFrame([['PikaZoom', 'Gen 3', True], ['CharMyToast', 'Gen 4', False]], columns=['Name', 'Generation', 'Legendary'])
new_poke_df

新數據
你可以通過調用之前構建的 LabelEncoder 和 OneHotEncoder 對象的 transform() 方法來處理新數據。請記得我們的工作流程,首先我們要做轉換。
new_gen_labels = gen_le.transform(new_poke_df['Generation'])
new_poke_df['Gen_Label'] = new_gen_labels
new_leg_labels = leg_le.transform(new_poke_df['Legendary'])
new_poke_df['Lgnd_Label'] = new_leg_labels
new_poke_df[['Name', 'Generation', 'Gen_Label', 'Legendary', 'Lgnd_Label']]

轉換之后的分類屬性
在得到了數值標簽之后,接下來讓我們應用編碼方案吧!
new_gen_feature_arr = gen_ohe.transform(new_poke_df[['Gen_Label']]).toarray()
new_gen_features = pd.DataFrame(new_gen_feature_arr, columns=gen_feature_labels)
new_leg_feature_arr = leg_ohe.transform(new_poke_df[['Lgnd_Label']]).toarray()
new_leg_features = pd.DataFrame(new_leg_feature_arr, columns=leg_feature_labels)
new_poke_ohe = pd.concat([new_poke_df, new_gen_features, new_leg_features], axis=1)
columns = sum([['Name', 'Generation', 'Gen_Label'], gen_feature_labels, ['Legendary', 'Lgnd_Label'], leg_feature_labels], [])
new_poke_ohe[columns]

獨熱編碼之后的分類屬性
因此,通過利用 scikit-learn 強大的 API,我們可以很容易將編碼方案應用于新數據。
你也可以通過利用來自 pandas 的 to_dummies() 函數輕松應用獨熱編碼方案。
gen_onehot_features = pd.get_dummies(poke_df['Generation'])
pd.concat([poke_df[['Name', 'Generation']], gen_onehot_features], axis=1).iloc[4:10]

使用 pandas 實現的獨熱編碼特征
上面的數據表描述了應用在「世代(Generation)」屬性上的獨熱編碼方案,結果與之前的一致。
虛擬編碼方案(Dummy coding scheme)與獨熱編碼方案相似,不同之處在于,在虛擬編碼方案中,當應用于具有 m 個不同標簽的分類特征時,我們將得到 m-1 個二進制特征。因此,分類變量的每個值都被轉換成 m-1 維向量。額外的特征將被完全忽略,因此如果分類取值范圍為{0, 1, ..., m-1},那么第一個(序號為 0)或者第 m 個(序號為 m-1)特征列將被丟棄,然后其對應類別值由一個 0 向量表示。接下來我們嘗試通過丟棄第一個特征列(Gen 1)來將神奇寶貝“世代(Generation)”屬性轉換成虛擬編碼。
gen_dummy_features = pd.get_dummies(poke_df['Generation'], drop_first=True)
pd.concat([poke_df[['Name', 'Generation']], gen_dummy_features], axis=1).iloc[4:10]

神奇寶貝世代屬性的虛擬編碼
當然你也可以通過如下操作來選擇丟棄最后一個特征列(Gen 6)。
gen_onehot_features = pd.get_dummies(poke_df['Generation'])
gen_dummy_features = gen_onehot_features.iloc[:,:-1]
pd.concat([poke_df[['Name', 'Generation']], gen_dummy_features], axis=1).iloc[4:10]

神奇寶貝世代屬性的虛擬編碼
基于上述描述,很明顯那些屬于被丟棄的類別(這里是 Gen 6)被表示為一個零向量。
效果編碼方案(Effect coding scheme)實際上非常類似于虛擬編碼方案,不同的是,對于在虛擬編碼方案中被編碼為零向量的類別,在效果編碼方案中將被編碼為全是 -1 的向量。下面的例子將清楚地展示這一點。
gen_onehot_features = pd.get_dummies(poke_df['Generation'])
gen_effect_features = gen_onehot_features.iloc[:,:-1]
gen_effect_features.loc[np.all(gen_effect_features == 0, axis=1)] = -1.
pd.concat([poke_df[['Name', 'Generation']], gen_effect_features], axis=1).iloc[4:10]

神奇寶貝世代的效果編碼特征
上面的輸出清楚地表明,與虛擬編碼中的零不同,屬于第六代的神奇寶貝現在由 -1 向量表示。
到目前為止,我們所討論的編碼方案在分類數據方面效果還不錯,但是當任意特征的不同類別數量變得很大的時候,問題開始出現。對于具有 m 個不同標簽的任意分類特征這點非常重要,你將得到 m 個獨立的特征。這會很容易地增加特征集的大小,從而導致在時間、空間和內存方面出現存儲問題或者模型訓練問題。除此之外,我們還必須處理“維度詛咒”問題,通常指的是擁有大量的特征,卻缺乏足夠的代表性樣本,然后模型的性能開始受到影響并導致過擬合。

因此,我們需要針對那些可能具有非常多種類別的特征(如 IP 地址),研究其它分類數據特征工程方案。區間計數方案是處理具有多個類別的分類變量的有效方案。在這個方案中,我們使用基于概率的統計信息和在建模過程中所要預測的實際目標或者響應值,而不是使用實際的標簽值進行編碼。一個簡單的例子是,基于過去的 IP 地址歷史數據和 DDOS 攻擊中所使用的歷史數據,我們可以為任一 IP 地址會被 DDOS 攻擊的可能性建立概率模型。使用這些信息,我們可以對輸入特征進行編碼,該輸入特征描述了如果將來出現相同的 IP 地址,則引起 DDOS 攻擊的概率值是多少。這個方案需要歷史數據作為先決條件,并且要求數據非常詳盡。
特征哈希方案(Feature Hashing Scheme)是處理大規模分類特征的另一個有用的特征工程方案。在該方案中,哈希函數通常與預設的編碼特征的數量(作為預定義長度向量)一起使用,使得特征的哈希值被用作這個預定義向量中的索引,并且值也要做相應的更新。由于哈希函數將大量的值映射到一個小的有限集合中,因此多個不同值可能會創建相同的哈希,這一現象稱為沖突。典型地,使用帶符號的哈希函數,使得從哈希獲得的值的符號被用作那些在適當的索引處存儲在最終特征向量中的值的符號。這樣能夠確保實現較少的沖突和由于沖突導致的誤差累積。
哈希方案適用于字符串、數字和其它結構(如向量)。你可以將哈希輸出看作一個有限的 b bins 集合,以便于當將哈希函數應用于相同的值\類別時,哈希函數能根據哈希值將其分配到 b bins 中的同一個 bin(或者 bins 的子集)。我們可以預先定義 b 的值,它成為我們使用特征哈希方案編碼的每個分類屬性的編碼特征向量的最終尺寸。
因此,即使我們有一個特征擁有超過 1000 個不同的類別,我們設置 b = 10 作為最終的特征向量長度,那么最終輸出的特征將只有 10 個特征。而采用獨熱編碼方案則有 1000 個二進制特征。我們來考慮下視頻游戲數據集中的「風格(Genre)」屬性。
unique_genres = np.unique(vg_df[['Genre']])
print("Total game genres:", len(unique_genres))
print(unique_genres)
Output
------
Total game genres: 12
['Action' 'Adventure' 'Fighting' 'Misc' 'Platform' 'Puzzle' 'Racing' 'Role-Playing' 'Shooter' 'Simulation' 'Sports' 'Strategy']
我們可以看到,總共有 12 中風格的游戲。如果我們在“風格”特征中采用獨熱編碼方案,則將得到 12 個二進制特征。而這次,我們將通過 scikit-learn 的 FeatureHasher 類來使用特征哈希方案,該類使用了一個有符號的 32 位版本的 Murmurhash3 哈希函數。在這種情況下,我們將預先定義最終的特征向量大小為 6。
from sklearn.feature_extraction import FeatureHasher
fh = FeatureHasher(n_features=6, input_type='string')
hashed_features = fh.fit_transform(vg_df['Genre'])
hashed_features = hashed_features.toarray()pd.concat([vg_df[['Name', 'Genre']], pd.DataFrame(hashed_features)], axis=1).iloc[1:7]

風格屬性的特征哈希
基于上述輸出,「風格(Genre)」屬性已經使用哈希方案編碼成 6 個特征而不是 12 個。我們還可以看到,第 1 行和第 6 行表示相同風格的游戲「平臺(Platform)」,而它們也被正確編碼成了相同的特征向量。
這些例子向你展示了一些在離散分類數據上進行特征工程的主流策略。如果你看過了這篇文章的上篇,你將會發現,比起處理連續數值數據,處理分類數據要難得多,但是也很有趣!我們還談到了使用特征工程處理大型特征空間的一些方法,但是你應該要記住還有其它技術,包括特征選擇和降維方法來處理大型特征空間。我們將在未來的文章中介紹其中的一些方法。
PS: 文中所有代碼和數據集都可以從我的 GitHub 上獲得。
Via Understanding Feature Engineering (Part 2) — Categorical Data,雷鋒網 AI 科技評論編譯
相關文章:
不會做特征工程的 AI 研究員不是好數據科學家!上篇 - 連續數據的處理方法
Kaggle16000份問卷揭示數據科學家平均畫像:30歲,碩士學位,年薪36萬
雷峰網原創文章,未經授權禁止轉載。詳情見轉載須知。