從圖表秒懂機器學習模型的原理:以 matplotlib 視覺化 scikit-learn 的分類器(KNN、邏輯斯迴歸、SVM、決策樹、隨機森林)

Alan Wang
57 min readNov 21, 2020

--

如果你在學機器學習,應該會聽過「邏輯斯迴歸」。邏輯斯迴歸是個好用的分類器演算法,而想在 scikit-learn 中匯入、訓練和使用它也相當簡單。然而,邏輯斯迴歸是如何分類資料的,就需要一點技巧來解釋了。

很多人會從數學式子切入,但對如在下我這種數學不好的人來說,抽象的方程式永遠非常難懂。而有些書或網站會附上邏輯斯函數的圖表,但它們通常跟後面的程式範例沒有直接關係(此外這些書或網站經常也只是抄襲其他人的範例)。最後,已嚴然成為 Python 機器學習代名詞的 scikit-learn 儘管在其網站上有很多視覺化範例,但它們大多極其複雜,要拿來應用實在是有難度。

而這就是這篇文章的出發點:記錄我對於 scikit-learn 幾種模型之視覺化的簡單研究成果。將機器學習模型視覺化,對於教學其實有很大的好處 — — 這能讓人們從更直覺的方式來理解模型是如何分類資料,而不必只能從抽象的數學式子去想像,還能跟資料產生連結。此外,我也需要找個地方來記錄這些程式碼,以後就不必花時間重寫了。

我會稍微解釋一下某些東西,但這畢竟不是新手教學文,我仍假設你對其他套件(NumPy, matplotlib 等)有些基礎概念。

準備(以及釐清)資料

首先,我們自然需要資料。而為了能夠在二維平面上繪圖,這個資料必須只有 2 個特徵(自變數)和 2 個標籤(分類)。然而,我並不想用 scikit-learn 的隨機資料產生功能(例如 make_blobs() 或 make_moons())。

剛好,scikit-learn 內建的乳癌資料集雖有 30 個特徵,但實際用 PCA(principal component analysis,主成分分析)來篩選後,可發現變異度最大的 2 個就能解釋原資料的 99.8% 變異:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
dx = load_breast_cancer().data # 取出特徵資料# 對特徵資料跑 PCA, 取變異解釋能力最大的 2 個特徵
pca = PCA(n_components=2).fit(dx)
# 印出變異解釋能力百分比
print(pca.explained_variance_ratio_.round(3))
--------------------------------------------------
>>> [0.982 0.016]

篩選特徵

PCA 是一種非監督式機器學習演算法,最主要的用處之一就是用來篩選資料,把變異解釋度最大的 N 筆資料留下來。這麼一來就能在維持差不多預測準確率的情況下減少機器學習的訓練時間。

下面的程式碼複雜一點,列出所有特徵的變異解釋能力和其名稱,好讓我們知道留下來的是哪些資料:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
import numpy as np
dx = load_breast_cancer().data# 取出特徵名稱
features_names = load_breast_cancer().feature_names
pca = PCA().fit(dx)# 依變異解釋能力找出對應特徵
indexes = np.argmax(np.abs(pca.components_), axis=1)
var_ratio = pca.explained_variance_ratio_ * 100
for i, idx in enumerate(indexes):
print(f'PC{i+1} ({var_ratio[i]:.5f} %) ' + \
f'feature {idx} ({features_names[idx]})')
--------------------------------------------------
>>>
PC1 (98.20447 %): feature 23 (worst area)
PC2 (1.61765 %): feature 3 (mean area)
PC3 (0.15575 %): feature 13 (area error)
PC4 (0.01209 %): feature 22 (worst perimeter)
PC5 (0.00883 %): feature 21 (worst texture)
PC6 (0.00066 %): feature 2 (mean perimeter)
PC7 (0.00040 %): feature 1 (mean texture)
PC8 (0.00008 %): feature 12 (perimeter error)
PC9 (0.00003 %): feature 20 (worst radius)
PC10 (0.00002 %): feature 11 (texture error)
PC11 (0.00001 %): feature 26 (worst concavity)
PC12 (0.00000 %): feature 0 (mean radius)
PC13 (0.00000 %): feature 25 (worst compactness)
PC14 (0.00000 %): feature 28 (worst symmetry)
PC15 (0.00000 %): feature 10 (radius error)
...

可見 feature 23(worst area)和 feature 3(mean area)就是前面 PCA 保留下來的前 2 大特徵。

最後,此資料集有 569 筆資料,分類又只有 0 和 1,剛好是二元分類問題。下面就來整理整理資料,分割出訓練集與測試集:

# 匯入套件
from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import numpy as np
# 取出資料
dx = load_breast_cancer().data
dy = load_breast_cancer().target
# 用 PCA 留下變異度最大的 2 個特徵
dx = PCA(n_components=2).fit_transform(dx)
# 特徵資料標準化 (將特徵資料縮放到平均=0, 標準差=1)
dx = StandardScaler().fit_transform(dx)
# 分割資料集 (80% 訓練集, 20% 測試集), 分割亂樹種子=0
# dx_train: 訓練集特徵資料
# dx_test: 測試集特徵資料
# dy_train: 訓練集標籤資料
# dy_test: 測試集標籤資料
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 繪圖
plt.figure(figsize=(8, 8)) # 設定新圖表大小
plt.rcParams['font.size'] = 14 # 設定圖表字體大小
plt.title('Original data') # 圖表標題
# 將資料繪成散佈圖 (根據標籤分顏色)
plt.scatter(*dx.T, c=dy, cmap='Dark2', s=50, alpha=0.8)
plt.grid(True) # 繪製格線# 設定 X 與 Y 軸顯示範圍
plt.xlim([np.amin(dx.T[0]), np.amax(dx.T[0])])
plt.ylim([np.amin(dx.T[1]), np.amax(dx.T[1])])
plt.tight_layout() # 減少圖表的白邊
plt.show() # 顯示圖表

在 matplotlib 的許多繪圖功能中,你可以用參數 c 給資料指定額外的值,然後用 cmap 指定一個 color map,這樣 c 值的差異就會用顏色反映出來。

藉由顏色的識別,可看到資料確實大致分成兩類,但究竟哪個是標籤 0 或 1 呢?哪一個又代表良性或惡性腫瘤?

分類內容的探討

下面來費點功夫,把各標籤的名稱跟數量也一併標出來(這邊的過程稍微複雜一點,就不多解釋了):

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
from matplotlib import cm
import numpy as np
dx = load_breast_cancer().data
dy = load_breast_cancer().target
# 取出標籤名稱
class_names = load_breast_cancer().target_names
# 統計標籤種類和數量
classes, class_num = np.unique(dy, return_counts=True)
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title('Breast cancer classification data')
# 根據分類分開畫資料點和加上圖例
color = [cm.Dark2.colors[0], cm.Dark2.colors[-1]]
for label in classes:
data = dx[dy==label]
plt.scatter(*data.T, color=color[label], s=50, alpha=0.8,
label=f'Label={label} ' + \
f'({class_names[label]}), ' + \
f'{class_num[label]} data')
plt.legend() # 顯示圖例
plt.grid(True)
plt.xlabel('Worst area')
plt.ylabel('Mean area')
plt.xlim([np.amin(dx.T[0]), np.amax(dx.T[0])])
plt.ylim([np.amin(dx.T[1]), np.amax(dx.T[1])])
plt.tight_layout()
plt.show()

對照 scikit-learn 網站上對這資料集的說明:

Class Distribution: 212 - Malignant, 357 - Benign

可確認分類 0(右側綠色)代表惡性腫瘤,分類 1(左側灰色)則代表良性腫瘤。我自己一開始也跟很多網站一樣搞反了,所以資料意義的確認上真是不可不慎哪。

KNN

KNN(K-nearest neighbors)是所有機器學習模型中最好懂的:找出 K 個跟測試資料最接近的點,再統計這些點的分類做為預測結果。當然,你也可以將距離當成權重,使較近的點具有更強的影響力。

首先來看 KNN 對測試集的預測標籤和實際標籤,以視覺化比較的結果:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
import matplotlib.pyplot as plt
import numpy as np
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
# 分割資料集 (80% 訓練集, 20% 測試集)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 訓練 KNN 並預測結果
k = 5 # 選擇 K 值
weights = 'uniform' # 選擇權重:'uniform' 或 'distance'
model = KNeighborsClassifier(n_neighbors=k, weights=weights)
model.fit(dx_train, dy_train) # 訓練模型
predict = model.predict(dx_test) # 對測試集特徵預測標籤
test_score = model.score(dx_test, dy_test) * 100 # 對測試集的預測準確率
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'KNN (accuracy={test_score:.1f}%)')
# 畫出訓練集資料
# 預測標籤 (大圓)
plt.scatter(*dx_test.T, c=predict, cmap='tab10', s=100, alpha=0.8)
# 實際標籤 (小圓)
plt.scatter(*dx_test.T, c=dy_test, cmap='Set3', s=35, alpha=0.8)
plt.grid(True)
plt.xlim([np.amin(dx_test.T[0]), np.amax(dx_test.T[0])])
plt.ylim([np.amin(dx_test.T[1]), np.amax(dx_test.T[1])])
plt.tight_layout()
plt.show()

現在點外側的顏色對應到預測標籤,內側則對應實際標籤。這麼一來,就很容易看出分類的效果,以及兩組資料的邊界有些點預測錯誤。

k-neighbors 的繪製

那麼,要怎麼實際看到 KNN 挑選出來的最近 k 鄰呢?這就可以用模型的 kneighbors() 來取得這 k 個點的距離和索引(目前用不到距離,但也許你能拿來做些什麼)。注意這裡的索引是訓練集的索引,畢竟 KNN 是拿訓練集的點作為參考。

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
import matplotlib.pyplot as plt
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
# 分割資料集 (80% 訓練集, 20% 測試集)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 訓練 KNN 並預測結果
k = 5 # 選擇 K 值
weights = 'uniform' # 選擇權重:'uniform' 或 'distance'
model = KNeighborsClassifier(n_neighbors=k, weights=weights)
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
pred_prob = model.predict_proba(dx_test) * 100
test_score = model.score(dx_test, dy_test) * 100
# 從測試集選一個點
idx = 3 # 選擇測試集索引
# 取回最近 K 個點的訓練集索引 (這裡用不到距離)
_, indices = model.kneighbors(dx_test[idx:idx+1], n_neighbors=k)
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'KNN (accuracy={test_score:.1f}%)')
# 畫出訓練集資料
plt.scatter(*dx_train.T, c=dy_train, cmap='tab10', s=75, alpha=0.8)
# 對最靠近測試點的 k 個訓練集點標上黑方框
plt.scatter(*dx_train[indices][0].T, color='None', s=75, alpha=0.8,
linewidth=2, edgecolors='black', marker='s',
label=f'{k}-neighbors')
# 畫出測試點 (紅三角)
plt.scatter(*dx_test[idx], color='red', s=75, marker='^',
label=f'Label 0={pred_prob[idx][0]:.1f}%, ' + \
f'1={pred_prob[idx][1]:.1f}% ' + \
f'(actual={predict[idx]})')
plt.legend() # 顯示圖例 (上面的 label 參數文字)
plt.grid(True)
plt.xlim([-1.5, 1.5])
plt.ylim([-1.5, 1.5])
plt.tight_layout()
plt.show()

你也可以把 KNeighborsClassifier() 的 weights 參數改成 ‘distance’ 來使用距離當權重。下面是 k = 7 且 weights = ‘distance’ 時跑出來的結果:

可見 7 個值有 4 個是分類 1(淺藍色),機率應為 57%,但分類 0 有一個點比較接近測試資料,使分類 0 加權後提高了機率。

Photo by Toa Heftiba on Unsplash

邏輯斯迴歸

邏輯斯迴歸(logistic regression)是二元分類器,藉由邏輯斯函數來將特徵資料投射到介於 0 到 1 之間的值,來判斷資料是否屬於某個分類。如果要預測的標籤超過 2 個以上,則可使用「一對多」的方式來預測每個標籤的機率,再決定最終預測結果(這便是 sciki-learn 的邏輯斯迴歸的做法;沒搞錯的話,這種版本的邏輯斯函數稱為 softmax)。

sciki-learn 邏輯斯迴歸模型的 coef_ 和 intercept_ 屬性會傳回係數跟截距,用這些資料算出的線性方程式再代入 scipy 的 expit()(即 logistic sigmoid 函數)後就會得到邏輯斯函數。(當然你也可以不用 expit() 而自己套公式,但我們就省點麻煩吧。)

雖然前面說要用 2 個特徵,但既然邏輯斯函數會把特徵映射到新的 Y 軸,只用 1 個特徵來做會比較好理解(反正前面已經看到,即使 1 個特徵也具備 98.2% 的變異解釋度):

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
import matplotlib.pyplot as plt
import numpy as np
from scipy.special import expit
dx = load_breast_cancer().data
dy = load_breast_cancer().target
# 只留 1 個特徵
dx = PCA(n_components=1).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 訓練邏輯斯迴歸並預測結果
model = LogisticRegression()
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
pred_prob = model.predict_proba(dx_test)
test_score = model.score(dx_test, dy_test) * 100
# 繪圖
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'Logistic regression (accuracy={test_score:.1f}%)')
# 訓練集範圍的邏輯斯函數
x = np.linspace(np.amin(dx_train), np.amax(dx_train),
num=dx_train.size)
y = expit(x * model.coef_[0] + model.intercept_[0])
# 測試集的邏輯斯函數映射
y_t = expit(dx_test * model.coef_[0] + model.intercept_[0])
# y=0.5 分隔線
plt.plot([np.amin(dx_test), np.amax(dx_test)], [0.5, 0.5],
color='coral', linewidth=2, linestyle='--')
# 訓練集範圍邏輯斯函數
plt.plot(x, y, color='teal', linewidth=2,
label=f'expit(x * {model.coef_[0][0]:.3f} ' + \
f'+ {model.intercept_[0]:.3f})')
# 測試集的預測及實際標籤
plt.scatter(dx_test, y_t, c=predict, cmap='tab10', s=100)
plt.scatter(dx_test, y_t, c=dy_test, cmap='Set3', s=35)
plt.legend()
plt.grid(True)
plt.xlim([np.amin(dx_test), np.amax(dx_test)])
plt.ylim([-0.05, 1.05])
plt.tight_layout()
plt.show()

上面可以看到,測試集資料換算後剛好都落在邏輯斯函數上,而只要根據每個點的 Y 軸值是否大於 0.5,就能預測它是否屬於標籤 1(既然是二元分類,不是 1 就一定是 0)。我們也能藉由內外顏色來觀察哪些資料是預測錯誤的。

事實上,如果對前面程式中的 y_t 做四捨五入,它就會投射到 Y = 1 或 Y = 0,變成一般教材中常見的邏輯斯迴歸圖:

# 測試集的預測及實際標籤 (投射到 y=1 或 y=0)
plt.scatter(dx_test, y_t.round(), c=predict, cmap='tab10', s=100)
plt.scatter(dx_test, y_t.round(), c=dy_test, cmap='Set3', s=35)

有意思的是,y_t.round() 得到的內容會和 predict(呼叫 model.predict() 的結果)完全一致。而若拿 y_t 和 pred_prob(呼叫 model.predict_proba() 傳回的機率值)來比較,也會發現 y_t 的內容正是 pred_prob 對於標籤 1 的預測機率:

from sklearn.metrics import accuracy_score
print(accuracy_score(predict, y_t.round()))
print('y_t:')
print(y_t[:5]) # 只印出前 5 筆
print('pred_prob:')
print(pred_prob[:5]) # 只印出前 5 筆

會得到

1.0  # y_t.round() 和 predict 100% 吻合
y_t:
[[0.6564023 ]
[0.87003846]
[0.7949639 ]
[0.63033087]
[0.91960843]]
pred_prob:
[[0.3435977 0.6564023 ] # [分類 0] 機率即為 1.0 減去 [分類 1] 機率
[0.12996154 0.87003846]
[0.2050361 0.7949639 ]
[0.36966913 0.63033087]
[0.08039157 0.91960843]]

這證實了我們在程式內求出的邏輯斯函數,跟 scikit-learn 本身算出的是一樣的。

多特徵的邏輯斯函數

現在我們要回到 2 個特徵的問題;用 2 個特徵訓練完模型時,model.coef_[0] 得到的係數就會是 2 個,model.intercept_[0] 則仍是一個。你可以拿它們各別求出 2 條邏輯斯函數,不過這麼一來其實跟前面做的事是一樣的。

反而,我們稍微改一下算法,使得可以用 2 個特徵來直接算出標籤 1 的機率:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
import matplotlib.pyplot as plt
import numpy as np
from scipy.special import expit
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
model = LogisticRegression()
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
pred_prob = model.predict_proba(dx_test)
test_score = model.score(dx_test, dy_test) * 100
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'Logistic regression (accuracy={test_score:.1f}%)')
# 邏輯斯函數
y_t = expit(np.dot(dx_test, model.coef_[0]) + model.intercept_[0])
plt.plot([np.amin(dx_test), np.amax(dx_test)], [0.5, 0.5],
color='coral', linewidth=2, linestyle='--')
# 測試集資料與其實際/預測標籤
plt.scatter(dx_test.T[0], y_t, c=predict, cmap='tab10', s=100)
plt.scatter(dx_test.T[0], y_t, c=dy_test, cmap='Set3', s=35)
plt.grid(True)
plt.xlim([np.amin(dx_test.T[0]), np.amax(dx_test.T[0])])
plt.ylim([-0.05, 1.05])
plt.tight_layout()
plt.show()

就和前面一樣,這個結果和你呼叫 model.predict()、model.predict_proba() 的結果是一模一樣的。

scikit-learn 的邏輯斯迴歸對二元分類會採用 ovr(one vs. rest,一對多)策略,對 3 個以上的標籤則會用 multinomial(即 softmax),除非你用 multi_class 參數來強制指定。我想多元預測的函數求法也是一樣的,不過這就不是這篇文要討論的主題了。

邏輯斯函數的決策邊界

那麼,邏輯斯迴歸的資料預測,對最開始的原始資料會有何影響呢?下面就來在資料當中畫出決策邊界(同樣用係數和截距求出該線的方程式)。比對一下前面的圖表,便不難看出為何有些資料會預測錯誤:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
import matplotlib.pyplot as plt
import numpy as np
from scipy.special import expit
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
model = LogisticRegression()
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
pred_prob = model.predict_proba(dx_test)
test_score = model.score(dx_test, dy_test) * 100
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'Logistic regression (accuracy={test_score:.1f}%)')
# 求出決策邊界
x = np.linspace(np.amin(dx_test.T[0]), np.amax(dx_test.T[0]))
y = -model.coef_[0][0] / model.coef_[0][1] * x - \
model.intercept_ / model.coef_[0][1]
# 畫出決策邊界
plt.plot(x, y, color='coral', linewidth=2,
label='Decision boundary')
# 畫出測試集與標籤
plt.scatter(*dx_test.T, c=predict, cmap='tab10', s=100)
plt.scatter(*dx_test.T, c=dy_test, cmap='Set3', s=35)
plt.legend()
plt.grid(True)
plt.xlim([np.amin(dx_test.T[0]), np.amax(dx_test.T[0])])
plt.ylim([np.amin(dx_test.T[1]), np.amax(dx_test.T[1])])
plt.tight_layout()
plt.show()
Photo by Josh Spires on Unsplash

SVM

支援向量機(support vector machine)的分類效果跟邏輯斯迴歸很像,原理卻大不同。SVM 是藉由將資料投射到更高維度的方式,來找出能夠分隔資料的超平面(可想像成馬路的中線),這個超平面兩側到某分類之資料的邊界(margin,人行道邊緣)必須盡量拉大。這個分界可以是線性的,也可以藉由 kernel 函式轉換來求出非線性的超平面 — — 我就點到為止,再講下去就是讓人聽不懂的數學啦。

下面先來看線性版本,畫出超平面的基本原理和前面的邏輯斯迴歸很像,只是多了邊界而已:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.svm import LinearSVC
import matplotlib.pyplot as plt
import numpy as np
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 訓練線性 SVM 並預測結果
model = LinearSVC()
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
test_score = model.score(dx_test, dy_test) * 100
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'Lineaer SVM (accuracy={test_score:.1f}%)')
plt.scatter(*dx_test.T, c=predict, cmap='tab10', s=100)
plt.scatter(*dx_test.T, c=dy_test, cmap='Set3', s=35)
# 求出超平面
a = -model.coef_[0][0] / model.coef_[0][1]
x = np.linspace(np.amin(dx_test.T[0]), np.amax(dx_test.T[0]))
y = a * x - model.intercept_[0] / model.coef_[0][1]
# 求出邊界
margin = 1 / np.sqrt(np.sum(model.coef_ ** 2))
y_down = y - np.sqrt(1 + a ** 2) * margin
y_up = y + np.sqrt(1 + a ** 2) * margin
# 畫出超平面與邊界
plt.plot(x, y, color='coral', linewidth=2, label='Hyperplane')
plt.plot(x, y_down, color='grey', linewidth=2, linestyle='--')
plt.plot(x, y_up, color='grey', linewidth=2, linestyle='--')
plt.legend()
plt.grid(True)
plt.xlim([np.amin(dx_test.T[0]), np.amax(dx_test.T[0])])
plt.ylim([np.amin(dx_test.T[1]), np.amax(dx_test.T[1])])
plt.tight_layout()
plt.show()

至於非線性 SVM,畫法就不太一樣了。基本上這在 scikit-learn 官網上有蠻明確的範例,我只有稍微簡化。簡單來說,就是直接使用模型本身的 decision_function() 來取得超平面跟邊界的函數:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
import matplotlib.pyplot as plt
import numpy as np
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 訓練線性 SVM 並預測結果
kernel = 'rbf' # 選擇 kernel: 'linear', 'poly', 'rbf', 'sigmoid'
model = SVC(kernel=kernel)
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
test_score = model.score(dx_test, dy_test) * 100
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'SVM (accuracy={test_score:.1f}%)')
plt.scatter(*dx_test.T, c=predict, cmap='tab10', s=100)
plt.scatter(*dx_test.T, c=dy_test, cmap='Set3', s=35)
# 求出超平面與邊界
x_min = np.amin(dx_test.T[0])
x_max = np.amax(dx_test.T[0])
y_min = np.amin(dx_test.T[1])
y_max = np.amax(dx_test.T[1])
XX, YY = np.mgrid[x_min:x_max:200j, y_min:y_max:200j]
Z = model.decision_function(
np.c_[XX.ravel(), YY.ravel()]).reshape(XX.shape)
# 畫出超平面與邊界
plt.contour(XX, YY, Z, colors=['grey', 'coral', 'grey'],
linestyles=['--', '-', '--'], linewidths=[2, 2, 2],
levels=[-1, 0, 1])
plt.grid(True)
plt.xlim([x_min, x_max])
plt.ylim([y_min, y_max])
plt.tight_layout()
plt.show()

SVM 的 kernel 有幾種可以選擇,預設是 rbf(徑向基函數)。linear 就是前面的線性版本。至於 poly 和 sigmoid 對這份資料的分類效果不佳,所以就不示範了,這裡也不討論各個 kernel 的原理。

繪製支援向量

最後,SVM() 的 support_vectors_ 屬性 — — LinearSVC() 沒有這玩意 — — 會包含一系列座標,就是訓練集中位於邊界內的資料點,你可藉此看看支援向量的視覺化:

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
import matplotlib.pyplot as plt
import numpy as np
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
kernel = 'rbf'
model = SVC(kernel=kernel)
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
# 取訓練集和測試集準確率
train_score = model.score(dx_train, dy_train) * 100
test_score = model.score(dx_test, dy_test) * 100
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'SVM (train accuracy={train_score:.1f}%, ' + \
f'test={test_score:.1f}%)')
# 畫出訓練集資料
plt.scatter(*dx_train.T, c=dy_train, cmap='tab10', s=50)
# 在支援向量的點加上圓框
plt.scatter(*model.support_vectors_.T, color='None', s=50,
linewidth=2, edgecolor='black', alpha=0.7,
label='Support vectors')
# 求出超平面與邊界
x_min = np.amin(dx_test.T[0])
x_max = np.amax(dx_test.T[0])
y_min = np.amin(dx_test.T[1])
y_max = np.amax(dx_test.T[1])
XX, YY = np.mgrid[x_min:x_max:200j, y_min:y_max:200j]
Z = model.decision_function(
np.c_[XX.ravel(), YY.ravel()]).reshape(XX.shape)
# 畫出超平面與邊界
plt.contour(XX, YY, Z, colors=['grey', 'coral', 'grey'],
linestyles=['--', '-', '--'], linewidths=[2, 2, 2],
levels=[-1, 0, 1])
plt.legend()
plt.grid(True)
plt.xlim([x_min, x_max])
plt.ylim([y_min, y_max])
plt.tight_layout()
plt.show()

你也可以用 model.support_ 取回這些訓練集資料的索引,和前面 KNN 的做法很像。

理論上,所謂的支援向量是指能夠用來定義最大邊界的資料點(這麼做的 SVM 即 hard-margin SVM),但資料分界不夠明確時效果就很差,所以現在一般會使用所謂的 soft-margin SVM,也就是能容忍誤差或「雜訊」。這便是為何有許多點會落在邊界之內。這樣做是必須的,畢竟兩筆資料有一部分為重疊。

對 SVM() 來說,你可以用參數 C 和 gamma 來微調分界方式:C 即為用來控制 soft-margin 的損失函數,設得越高對誤差的容忍就越小,gamma 則代表各別訓練資料點的影響能力,值越大曲線會越曲折。這些知識牽涉到數學,在此就不多介紹啦。

但下面來做個簡單實驗,改用 make_circles() 來產生兩群不重疊、形成圓圈狀的資料,然後改變 C 與 gamma 參數。這麼一來,你就能看到支援向量剛好都落在 margin 上,很類似 hard-margin SVM 的做法:

from sklearn.datasets import make_circles
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
import matplotlib.pyplot as plt
import numpy as np
dx, dy = make_circles(n_samples=500, noise=0.15, factor=0.15,
random_state=0)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
kernel = 'rbf'
model = SVC(C=1000, gamma=0.1, kernel=kernel)
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
train_score = model.score(dx_train, dy_train) * 100
test_score = model.score(dx_test, dy_test) * 100
plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
plt.title(f'SVM (train accuracy={train_score:.1f}%, ' + \
f'test={test_score:.1f}%)')
plt.scatter(*dx_train.T, c=dy_train, cmap='tab10', s=50)
plt.scatter(*model.support_vectors_.T, color='None', s=50,
linewidth=2, edgecolor='red', label='Support vectors')
x_min = np.amin(dx_test.T[0])
x_max = np.amax(dx_test.T[0])
y_min = np.amin(dx_test.T[1])
y_max = np.amax(dx_test.T[1])
XX, YY = np.mgrid[x_min:x_max:100j, y_min:y_max:100j]
Z = model.decision_function(
np.c_[XX.ravel(), YY.ravel()]).reshape(XX.shape)
plt.contour(XX, YY, Z, colors=['grey', 'coral', 'grey'],
linestyles=['--', '-', '--'], linewidths=[2, 2, 2],
levels=[-1, 0, 1])
plt.legend()
plt.grid(True)
plt.xlim([x_min, x_max])
plt.ylim([y_min, y_max])
plt.tight_layout()
plt.show()

支援向量在更高維度空間之探討

最後,我們來看看另一個有趣的玩意。前面提到 SVM 會將資料投射到更高維度來尋找更明顯的分界線,但這種投射過程看起來是什麼樣子呢?

下面的程式使用 scikit-learn 的 RBF 函式來換算出訓練集資料點的 Z 軸,好把它們投射在三維座標軸裡,並也把支援向量畫出來:

# 匯入套件
from sklearn.datasets import make_circles
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.svm import SVC
import matplotlib.pyplot as plt
import numpy as np
from sklearn.gaussian_process.kernels import RBFdx, dy = make_circles(n_samples=500, noise=0.15, factor=0.15,
random_state=0)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 用 RBF 函數換算 Z 軸
rbf = RBF()
r = rbf(dx_train)
r_sum = r.sum(axis=1)
kernel = 'rbf'
model = SVC(C=1000, gamma=0.1, kernel=kernel)
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
train_score = model.score(dx_train, dy_train) * 100
test_score = model.score(dx_test, dy_test) * 100
fig = plt.figure(figsize=(8, 8))
plt.rcParams['font.size'] = 14
# 繪製三維散佈圖
ax = fig.add_subplot(111, projection='3d')
plt.title(f'SVM (train accuracy={train_score:.1f}%, ' + \
f'test={test_score:.1f}%)')
ax.scatter(*dx_train.T, r_sum, c=dy_train, cmap='tab10', s=50)
ax.scatter(*model.support_vectors_.T, r[model.support_].sum(axis=1),
color='None', linewidth=3, edgecolor='red',
s=50, label='Support vectors')
plt.grid(True)
plt.legend()
plt.xlim([np.amin(dx.T[0]), np.amax(dx.T[0])])
plt.ylim([np.amin(dx.T[1]), np.amax(dx.T[1])])
plt.tight_layout()
plt.show()

輸入長度為 N 的資料時,不管有幾個特徵,RBF 函數都會傳回一個 N x N 陣列。我將每一行子陣列的值加總,用這個值來代表 Z 軸。

身為數學苦手,且不管是 scikit-learn 或 scipy 的 RBF 功能,要如何拿來換算資料的說明文件都很少。但如上所見,兩個圈圈在三維空間上下明顯分開來了 — — 如果你從 Z 軸上方直直往下看,就會跟前面的二維圖一樣 。

此外,在三維空間的版本中,可見兩群資料之間最靠近的幾個點就是支援向量。也就是說,SVM 找到的超平面(在三維空間中是個二維曲面)從支援向量之間的位置切過去,實現了分類的目的。

當然,要實際畫出超平面本身就困難多了,畫出來說不定還讓電腦跑得很慢,所以這篇文就做到這裡吧。

下面是把乳癌資料集的兩大特徵套用 RBF 函數,並標出支援向量的結果:

決策樹

決策樹(decision tree)藉由建立樹狀的節點結構來判定資料分類,簡單又有效。但相較於前面的模型,決策樹的圖形化方式就比較不同了。

scikit-learn 自己提供了繪製決策樹的功能(這你可能在網站或書上看過)。此外,為了能在樹的節點中顯示合理的數值跟標籤,下面我們就不使用 PCA 與資料標準化,用完整的 30 個特徵下去訓練:

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier, export_text, plot_tree
import matplotlib.pyplot as plt
dx = load_breast_cancer().data
dy = load_breast_cancer().target
feature_names = load_breast_cancer().feature_names # 取出特徵名稱
class_names = load_breast_cancer().target_names # 取出標籤/分類名稱
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 建立決策樹 (2 層) 並預測結果
model = DecisionTreeClassifier(max_depth=2)
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
test_score = model.score(dx_test, dy_test) * 100
# 印出預測精確率
print(f'Accuracy: {test_score:.1f}%')
# 印出文字版的決策樹
print(export_text(model, feature_names=list(feature_names)))
# 繪製決策樹
plt.figure(figsize=(8, 8))
plot_tree(model, # 填滿顏色, 開啟圓角, 顯示百分比
filled=True, rounded=True, proportion=True,
feature_names=feature_names,
class_names=class_names)
plt.show()

文字的輸出結果為

Accuracy: 96.5%
|--- worst concave points <= 0.14
| |--- worst area <= 957.45
| | |--- class: 1
| |--- worst area > 957.45
| | |--- class: 0
|--- worst concave points > 0.14
| |--- worst area <= 729.55
| | |--- class: 1
| |--- worst area > 729.55
| | |--- class: 0

留意 export_text() 的 feature_names 參數只吃 list,所以直接給它 ndarray 會出錯,要先轉換一下。

產生的圖則為:

文字版比較單純,但圖片版會正確顯示用來判定的特徵以及標籤/分類的名稱。此外,你也能看到框的顏色反映了對某分類的判定機率。

比較麻煩的是,如果決策樹的層級變多,tree.plot_tree() 畫出來的文字和框就會變得難以閱讀,而這功能也沒有什麼調整空間。這時你可以選擇把它畫成一張更大的圖,並直接輸出到檔案:

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn import tree
import matplotlib.pyplot as plt
dx = load_breast_cancer().data
dy = load_breast_cancer().target
feature_names = load_breast_cancer().feature_names
class_names = load_breast_cancer().target_names
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
model = DecisionTreeClassifier() # 不限層級,直到所有分類達到 100% 為止
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
test_score = model.score(dx_test, dy_test) * 100
print(f'Accuracy: {test_score:.1f}%')plt.figure(figsize=(64, 64))
tree.plot_tree(model,
filled=True, rounded=True, proportion=True,
feature_names=feature_names,
class_names=class_names)
plt.savefig('tree.png') # 寫入到檔案

隨機森林

隨機森林(random forest)就是用一群決策樹來預測。靠著俗稱的「群眾智慧」現象,隨機森林不僅能進一步提高準確率,還能避免單一決策樹可能的過度配適(overfitting)問題,是集成學習(ensemble learning)的代表之一。

RandomForestClassifier() 模型的 estimators_ 屬性會包含它所有的決策樹(不指定時預設為 100 棵),所以只要把這些樹用 tree.plot_tree 全部畫出來即可。當然,為了能輸出成圖形,這裡我們還是限制了樹的層級跟數量:

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn import tree
import matplotlib.pyplot as plt
dx = load_breast_cancer().data
dy = load_breast_cancer().target
feature_names = load_breast_cancer().feature_names
class_names = load_breast_cancer().target_names
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 訓練隨機森林 (每個 2 層) 並預測結果
t = 5 # 選擇決策樹數量
model = RandomForestClassifier(n_estimators=t, max_depth=2)
model.fit(dx_train, dy_train)
predict = model.predict(dx_test)
test_score = model.score(dx_test, dy_test) * 100
print(f'Accuracy: {test_score:.1f}%')# 走訪和繪製隨機森林的所有決策樹
plt.figure(figsize=(128, 64))
for idx, dec_tree in enumerate(model.estimators_):
plt.subplot(1, t, idx+1)
tree.plot_tree(dec_tree,
filled=True, rounded=True, proportion=True,
feature_names=feature_names,
class_names=class_names)
plt.savefig('forest.png')

ROC

ROC(receiver operating characteristic)曲線也是個很常看到的圖表,代表模型預測時的真陽性率(TPR)和假陽性率(FPR)的關係。簡單來說,這曲線離對角線越遠、畫出的區域(auc 或 area under curve)越大,就代表預測效果越好。

因此,本文最後就來以這個收尾吧,畫一下前面幾個模型的 ROC 曲線。

scikit-learn 裡畫 ROC 最簡單的方式是使用 plot_roc_curve()(ROC 繪圖只適用於二元分類問題),不過有個小問題:這功能會開自己的繪圖視窗。解決辦法是把它嵌進一個子圖表內,這樣才能調整大小。除此以外,plot_roc_curve() 本質上似乎是 plt.plot(),所以能傳同樣的參數進去。

你也能看到 plot_roc_curve() 會產生自己的圖例,算是蠻方便的。

from sklearn.datasets import load_breast_cancer
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import plot_roc_curve
from sklearn.neighbors import KNeighborsClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import LinearSVC
from sklearn.ensemble import RandomForestClassifier
import matplotlib.pyplot as plt
dx = load_breast_cancer().data
dy = load_breast_cancer().target
dx = PCA(n_components=2).fit_transform(dx)
dx = StandardScaler().fit_transform(dx)
dx_train, dx_test, dy_train, dy_test = \
train_test_split(dx, dy, test_size=0.2, random_state=0)
# 建立不同模型
models = [
KNeighborsClassifier(),
LogisticRegression(),
LinearSVC(),
RandomForestClassifier()
]
# 訓練不同模型
for i, _ in enumerate(models):
models[i].fit(dx_train, dy_train)
plt.rcParams['font.size'] = 12
plt.figure(figsize=(8, 8))
# 建立子圖表
ax = plt.subplot(111)
ax.set_title('ROC')
# 畫對角線
ax.plot([0, 1], [0, 1], color='grey',
linewidth=2, linestyle='--')
# 對每個模型畫 ROC 曲線
for model in models:
plot_roc_curve(model, dx_test, dy_test,
linewidth=5, alpha=0.5, ax=ax)
plt.grid(True)
plt.xlim([-0.05, 1.05])
plt.ylim([-0.05, 1.05])
plt.tight_layout()
plt.show()

--

--

Alan Wang
Alan Wang

No responses yet