不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

 2018-01-22 10:32:00.0

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

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

背景

在上一篇文章中,我們介紹了許多用於處理結構化的連續數值數據(continuous numeric data)的特徵工程。而在本篇文章中,我們將繼續介紹另一種結構化數據的處理 —— 這種數據本質上是離散的,俗稱分類數據(categorical data)。由於在處理數值數據的時候,我們不必處理屬於某一分類類型的數據屬性中與每個類別值有關的額外的語義複雜性,因此處理數值數據通常比處理分類數據來得更加容易。我們將結合實際操作來討論處理分類數據的幾種編碼方案,以及處理大規模特徵爆炸(通常稱爲「維度詛咒 curse of dimensionality」)的一些流行技巧。

動機

我認爲你現在必須要認識到特徵工程的動機和重要性,我們在該系列文章的第一部分中也詳細強調了這一點。如果有必要,請快速溫習一下。簡而言之,機器學習算法不能直接處理分類數據,因此在開始爲數據建模之前我們需要對數據進行一些工程處理和轉換。

什麼是分類數據?

在深入研究特徵工程之前,讓我們先了解一下分類數據。通常,在自然界中可分類的任意數據屬性都是離散值,這意味着它們屬於某一特定的有限類別。在模型預測的屬性或者變量(通常被稱爲響應變量 response variables)中,這些也經常被稱爲類別或者標籤。這些離散值在自然界中可以是文本或者數字(甚至是諸如圖像這樣的非結構化數據)。分類數據有兩大類——定類(Nominal)和定序(Ordinal)

在任意定類分類數據屬性中,這些屬性值之間沒有順序的概念。如下圖所示,舉個簡單的例子,天氣分類。我們可以看到,在這個特定的場景中,主要有六個大類,而這些類之間沒有任何順序上的關係(颳風天並不總是發生在晴天之前,並且也不能說比晴天來的更小或者更大)

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

將天氣作爲分類屬性

與天氣相類似的屬性還有很多,比如電影、音樂、電子遊戲、國家、食物和美食類型等等,這些都屬於定類分類屬性。

定序分類的屬性值則存在着一定的順序意義或概念。例如,下圖中的字母標識了襯衫的大小。顯而易見的是,當我們考慮襯衫的時候,它的「大小」屬性是很重要的(S 碼比 M 碼來的小,而 M 碼又小於 L 碼等等)。

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

襯衫大小作爲定序分類屬性

鞋號、受教育水平和公司職位則是定序分類屬性的一些其它例子。既然已經對分類數據有了一個大致的理解之後,接下來我們來看看一些特徵工程的策略。

分類數據的特徵工程

在接受像文本標籤這樣複雜的分類數據類型問題上,各種機器學習框架均已取得了許多的進步。通常,特徵工程中的任意標準工作流都涉及將這些分類值轉換爲數值標籤的某種形式,然後對這些值應用一些編碼方案。我們將在開始之前導入必要的工具包。

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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

遊戲銷售數據

讓我們首先專注於上面數據框中「視頻遊戲風格(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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

視頻遊戲風格及其編碼標籤

如果你打算將它們用作預測的響應變量,那麼這些標籤通常可以直接用於諸如 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 代,並且每個神奇寶貝通常屬於視頻遊戲的特定世代(依據發佈順序),而且電視系列也遵循了相似的時間線。這個屬性通常是定序的(需要相關的領域知識才能理解),因爲屬於第一代的大多數神奇寶貝在第二代的視頻遊戲或者電視節目中也會被更早地引入。神奇寶貝的粉絲們可以看下下圖,然後記住每一代中一些比較受歡迎的神奇寶貝(不同的粉絲可能有不同的看法)。

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

基於不同類型和世代選出的一些受歡迎的神奇寶貝

因此,它們之間存在着先後順序。一般來說,沒有通用的模塊或者函數可以根據這些順序自動將這些特徵轉換和映射到數值表示。因此,我們可以使用自定義的編碼\映射方案。

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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

神奇寶貝世代編碼

從上面的代碼中可以看出,來自 pandas 庫的 map(...) 函數在轉換這種定序特徵的時候非常有用。

編碼分類屬性

如果你還記得我們之前提到過的內容,通常對分類數據進行特徵工程就涉及到一個轉換過程,我們在前一部分描述了一個轉換過程,還有一個強制編碼過程,我們應用特定的編碼方案爲特定的每個類別創建虛擬變量或特徵分類屬性。

你可能想知道,我們剛剛在上一節說到將類別轉換爲數字標籤,爲什麼現在我們又需要這個?原因很簡單。考慮到視頻遊戲風格,如果我們直接將 GenereLabel 作爲屬性特徵提供給機器學習模型,則模型會認爲它是一個連續的數值特徵,從而認爲值 10 (體育)要大於值 6 (賽車),然而事實上這種信息是毫無意義的,因爲體育類型顯然並不大於或者小於賽車類型,這些不同值或者類別無法直接進行比較。因此我們需要另一套編碼方案層,它要能爲每個屬性的所有不同類別中的每個唯一值或類別創建虛擬特徵。

獨熱編碼方案(One-hot Encoding Scheme)

考慮到任意具有 m 個標籤的分類屬性(變換之後)的數字表示,獨熱編碼方案將該屬性編碼或變換成 m 個二進制特徵向量(向量中的每一維的值只能爲 0 或 1)。那麼在這個分類特徵中每個屬性值都被轉換成一個 m 維的向量,其中只有某一維的值爲 1。讓我們來看看神奇寶貝數據集的一個子集。

poke_df[['Name', 'Generation', 'Legendary']].iloc[4:10]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

神奇寶貝數據集子集

這裏關注的屬性是神奇寶貝的「世代(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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

轉換後的標籤屬性

Gen_LabelLgnd_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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

神奇寶貝世代和傳奇狀態的獨熱編碼特徵

此時可以看到已經爲「世代(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

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

新數據

你可以通過調用之前構建的 LabelEncoderOneHotEncoder 對象的 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']]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

轉換之後的分類屬性

在得到了數值標籤之後,接下來讓我們應用編碼方案吧!

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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

獨熱編碼之後的分類屬性

因此,通過利用 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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

使用 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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

神奇寶貝世代屬性的虛擬編碼

當然你也可以通過如下操作來選擇丟棄最後一個特徵列(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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

神奇寶貝世代屬性的虛擬編碼

基於上述描述,很明顯那些屬於被丟棄的類別(這裏是 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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

神奇寶貝世代的效果編碼特徵

上面的輸出清楚地表明,與虛擬編碼中的零不同,屬於第六代的神奇寶貝現在由 -1 向量表示。

區間計數方案(Bin-counting Scheme)

到目前爲止,我們所討論的編碼方案在分類數據方面效果還不錯,但是當任意特徵的不同類別數量變得很大的時候,問題開始出現。對於具有 m 個不同標籤的任意分類特徵這點非常重要,你將得到 m 個獨立的特徵。這會很容易地增加特徵集的大小,從而導致在時間、空間和內存方面出現存儲問題或者模型訓練問題。除此之外,我們還必須處理「維度詛咒」問題,通常指的是擁有大量的特徵,卻缺乏足夠的代表性樣本,然後模型的性能開始受到影響並導致過擬合。

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

因此,我們需要針對那些可能具有非常多種類別的特徵(如 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]

不會做特徵工程的 AI 研究員不是好數據科學家!下篇 - 離散數據的處理方法

風格屬性的特徵哈希

基於上述輸出,「風格(Genre)」屬性已經使用哈希方案編碼成 6 個特徵而不是 12 個。我們還可以看到,第 1 行和第 6 行表示相同風格的遊戲「平臺(Platform)」,而它們也被正確編碼成了相同的特徵向量。

總結

這些例子向你展示了一些在離散分類數據上進行特徵工程的主流策略。如果你看過了這篇文章的上篇,你將會發現,比起處理連續數值數據,處理分類數據要難得多,但是也很有趣!我們還談到了使用特徵工程處理大型特徵空間的一些方法,但是你應該要記住還有其它技術,包括特徵選擇和降維方法來處理大型特徵空間。我們將在未來的文章中介紹其中的一些方法。

PS: 文中所有代碼和數據集都可以從我的 GitHub 上獲得。

Via Understanding Feature Engineering (Part 2) — Categorical Data

相關文章:

不會做特徵工程的 AI 研究員不是好數據科學家!上篇 - 連續數據的處理方法

想成爲真正的數據科學家,除了資歷你還需要這4個技能

Kaggle16000份問卷揭示數據科學家平均畫像:30歲,碩士學位,年薪36萬

數據科學家必須知道的 10 個深度學習架構


文章來源:雷鋒網