Python · 樸素貝葉斯(二)· MultinomialNB
(這裡是本章會用到的 GitHub 地址)
本章主要介紹離散型樸素貝葉斯—— MultinomialNB 的實現。對於離散型樸素貝葉斯模型的實現,由於核心演算法都是在進行「計數」工作、所以問題的關鍵就轉換為了如何進行計數。幸運的是、Numpy 中的一個方法:bincount 就是專門用來計數的,它能夠非常快速地數出一個數組中各個數字出現的頻率;而且由於它是 Numpy 自帶的方法,其速度比 Python 標準庫 collections 中的計數器 Counter 還要快上非常多。不幸的是、該方法有如下兩個缺點:
- 只能處理非負整數型中數組
- 向量中的最大值即為返回的數組的長度,換句話說,如果用 bincount 方法對一個長度為 1、元素為 1000 的數組計數的話,返回的結果就是 999 個 0 加 1 個 1
所以我們做數據預處理時就要充分考慮到這兩點。我們之前曾經在這篇文章的最後說明了如何將數據進行數值化,該數據數值化的方法其實可以說是為 bincount 方法「量身定做」的。舉個栗子,當原始數據形如:
x, s, n, t, p, f
x, s, y, t, a, fb, s, w, t, l, f
x, y, w, t, p, fx, s, g, f, n, f
時,調用上述數值化數據的方法將會把數據數值化為:
0, 0, 0, 0, 0, 0
0, 0, 1, 0, 1, 01, 0, 2, 0, 2, 00, 1, 2, 0, 0, 00, 0, 3, 1, 3, 0
單就實現這個功能而言、實現是平凡的:
def quantize_data(x): features = [set(feat) for feat in xt] feat_dics = [{ _l: i for i, _l in enumerate(feats) } if not wc[i] else None for i, feats in enumerate(features)] x = np.array([[ feat_dics[i][_l] for i, _l in enumerate(sample)] for sample in x]) return x, feat_dics
不過考慮到離散型樸素貝葉斯需要的東西比這要多很多,所以才有了這裡最後所介紹的、相對而言繁複很多的版本。建議觀眾老爺們在看接下來的實現之前先把那個 quantize_data 函數的完整版看一遍、因為我接下來會直接用……(那你很棒棒哦)
當然考慮到樸素貝葉斯的相關說明已經遠在一個月以前了(你以為是誰的錯啊喂)、我就不把實現一股腦扔出來了,那樣估計所有人(包括我自己在內)都看不懂……所以我決定把離散型樸素貝葉斯演算法和對應的實現進行逐一講解 ( σ"ω")σ
計算先驗概率
這倒是在上一章已經講過了、但我還是打算重講一遍……首先把實現放出來:
def get_prior_probability(self, lb=1): return [(_c_num + lb) / (len(self._y) + lb * len(self._cat_counter)) for _c_num in self._cat_counter]
其中的 lb 為平滑係數(默認為 1、亦即拉普拉斯平滑),這對應的公式其實是帶平滑項的、先驗概率的極大似然估計:
(什麼?你說我以前沒講過平滑?一定是你的錯覺!不信看這裡!我明明今天更新了!)(喂所以代碼中的 self._cat_counter 的意義就很明確了——它存儲著 K 個
(cat counter 其實是 category counter 的簡稱……我知道我命名很差所以不要打我……)
計算條件概率
同樣先看核心實現:
data = [[] for _ in range(n_dim)]for dim, n_possibilities in enumerate(self._n_possibilities): data[dim] = [[ (self._con_counter[dim][c][p] + lb) / ( self._cat_counter[c] + lb * n_possibilities) for p in range(n_possibilities)] for c in range(n_category)]self._data = [np.array(dim_info) for dim_info in data]
這對應的公式其實就是帶平滑項(lb)的條件概率的極大似然估計:
其中可以看到我們利用到了 self._cat_counter 屬性來計算。同時可以看出:
- n_category 即為 K
- self._n_possibilities 儲存著 n 個
- self._con_counter 儲存的即是各個的值。具體而言:
至於 self._data、就只是為了向量化演算法而存在的一個變數而已,它將 data 中的每一個列表都轉成了 Numpy 數組、以便在計算後驗概率時利用 Numpy 數組的 Fancy Indexing 來加速演算法
聰明的觀眾老爺可能已經發現、其實 self._con_counter 才是計算條件概率的關鍵,事實上這裡也正是 bincount 大放異彩的地方。以下為計算 self._con_counter 的函數的實現:
def feed_sample_weight(self, sample_weight=None): self._con_counter = [] for dim, _p in enumerate(self._n_possibilities): if sample_weight is None: self._con_counter.append([ np.bincount(xx[dim], minlength=_p) for xx in self._labelled_x]) else: self._con_counter.append([ np.bincount(xx[dim], weights=sample_weight[ label] / sample_weight[label].mean(), minlength=_p) for label, xx in self._label_zip])
可以看到、bincount 方法甚至能幫我們處理樣本權重的問題。
代碼中有兩個我們還沒進行說明的屬性:self._labelled_x 和 self._label_zip,不過從代碼上下文不難推斷出、它們儲存的是應該是不同類別所對應的數據。具體而言:
- self._labelled_x:記錄按類別分開後的、輸入數據的數組
- self._label_zip:比 self._labelled_x 多記錄了個各個類別的數據所對應的下標
這裡就提前將它們的實現放出來以幫助理解吧:
# 獲得各類別數據的下標labels = [y == value for value in range(len(cat_counter))]# 利用下標獲取記錄按類別分開後的輸入數據的數組labelled_x = [x[ci].T for ci in labels]self._labelled_x, self._label_zip = labelled_x, list(zip(labels, labelled_x))
計算後驗概率
仍然先看核心實現:
def func(input_x, tar_category): input_x = np.atleast_2d(input_x).T rs = np.ones(input_x.shape[1]) for d, xx in enumerate(input_x): rs *= self._data[d][tar_category][xx] return rs * p_category[tar_category]
這對應的公式其實就是決策公式:
所以不難看出代碼中的 p_category 存儲著 K 個
封裝
最後要做的、無非就是把上述三個步驟進行封裝而已,首先是數據預處理:
def feed_data(self, x, y, sample_weight=None): if sample_weight is not None: sample_weight = np.array(sample_weight) # 調用 quantize_data 函數獲得諸多信息 x, y, _, features, feat_dics, label_dic = DataUtil.quantize_data( x, y, wc=np.array([False] * len(x[0]))) # 利用 bincount 函數直接獲得 self._cat_counter cat_counter = np.bincount(y) # 利用 features 變數獲取各個維度的特徵個數 Sj n_possibilities = [len(feats) for feats in features] # 獲得各類別數據的下標 labels = [y == value for value in range(len(cat_counter))] # 利用下標獲取記錄按類別分開後的輸入數據的數組 labelled_x = [x[ci].T for ci in labels] # 更新模型的各個屬性 self._x, self._y = x, y self._labelled_x, self._label_zip = labelled_x, list( zip(labels, labelled_x)) self._cat_counter, self._feat_dics, self._n_possibilities = cat_counter, feat_dics, n_possibilities self.label_dic = label_dic self.feed_sample_weight(sample_weight)
然後利用上一章我們定義的框架的話、只需定義核心訓練函數即可:
def _fit(self, lb): n_dim = len(self._n_possibilities) n_category = len(self._cat_counter) p_category = self.get_prior_probability(lb) data = [[] for _ in range(n_dim)] for dim, n_possibilities in enumerate(self._n_possibilities): data[dim] = [[ (self._con_counter[dim][c][p] + lb) / ( self._cat_counter[c] + lb * n_possibilities) for p in range(n_possibilities)] for c in range(n_category)] self._data = [np.array(dim_info) for dim_info in data] def func(input_x, tar_category): input_x = np.atleast_2d(input_x).T rs = np.ones(input_x.shape[1]) for d, xx in enumerate(input_x): rs *= self._data[d][tar_category][xx] return rs * p_category[tar_category] return func
最後,我們需要定義一個將測試數據轉化為模型所需的、數值化數據的方法:
def _transfer_x(self, x): for i, sample in enumerate(x): for j, char in enumerate(sample): x[i][j] = self._feat_dics[j][char] return x
至此,離散型樸素貝葉斯就全部實現完畢了(鼓掌!)
評估與可視化
可以拿 UCI 上一個比較出名(簡單)的「蘑菇數據集(MushroomData Set)」來評估一下我們的模型。該數據集的大致描述如下:它有 8124 個樣本、22 個屬性,類別取值有兩個:「能吃」或「有毒」;該數據每個單一樣本都佔一行、屬性之間使用逗號隔開。選擇該數據集的原因是它無需進行額外的數據預處理、樣本量和屬性量都相對合適、二類分類問題也相對來說具有代表性。更重要的是,它所有維度的特徵取值都是離散的、從而非常適合用來測試我們的MultinomialNB 模型。
完整的數據集可以參見這裡(第一列數據是類別),我們的模型在其上的表現如下圖所示:
其中第一、二行分別是訓練集、測試集上的準確率,接下來三行則分別是建立模型、評估模型和總花費時間的記錄
當然,僅僅看一個結果沒有什麼意思、也完全無法知道模型到底幹了什麼。為了獲得更好的直觀,我們可以進行一定的可視化,比如說將極大似然估計法得到的條件概率畫出(如第零章所示的那樣)。可視化的代碼實現如下:
# 導入 matplotlib 庫以進行可視化import matplotlib.pyplot as plt# 進行一些設置使得 matplotlib 能夠顯示中文from pylab import mpl# 將字體設為「仿宋」mpl.rcParams["font.sans-serif"] = ["FangSong"]mpl.rcParams["axes.unicode_minus"] = False# 利用 MultinomialNB 搭建過程中記錄的變數獲取條件概率data = nb["data"]# 定義顏色字典,將類別 e(能吃)設為天藍色、類別 p(有毒)設為橙色colors = {"e": "lightSkyBlue", "p": "orange"}# 利用轉換字典定義其「反字典」,後面可視化會用上_rev_feat_dics = [{_val: _key for _key, _val in _feat_dic.items()} for _feat_dic in self._feat_dics]# 遍歷各維度進行可視化# 利用 MultinomialNB 搭建過程中記錄的變數,獲取畫圖所需的信息for _j in range(nb["x"].shape[1]): sj = nb["n_possibilities"][_j] tmp_x = np.arange(1, sj+1) # 利用 matplotlib 對 LaTeX 的支持來寫標題,兩個 $ 之間的即是 LaTeX 語句 title = "$j = {}; S_j = {}$".format(_j+1, sj) plt.figure() plt.title(title) # 根據條件概率的大小畫出柱狀圖 for _c in range(len(nb.label_dic)): plt.bar(tmp_x-0.35*_c, data[_j][_c, :], width=0.35, facecolor=colors[nb.label_dic[_c]], edgecolor="white", label="class: {}".format(nb.label_dic[_c])) # 利用上文定義的「反字典」將橫坐標轉換成特徵的各個取值 plt.xticks([i for i in range(sj + 2)], [""] + [_rev_dic[i] for i in range(sj)] + [""]) plt.ylim(0, 1.0) plt.legend() # 保存畫好的圖像 plt.savefig("d{}".format(j+1))
由於蘑菇數據一共有 22 維,所以上述代碼會生成 22 張圖,從這些圖可以非常清晰地看出訓練數據集各維度特徵的分布。下選出幾組有代表性的圖片進行說明。
一般來說,一組數據特徵中會有相對「重要」的特徵和相對「無足輕重」的特徵,通過以上實現的可視化可以比較輕鬆地辨析出在離散型樸素貝葉斯中這兩者的區別。比如說,在離散型樸素貝葉斯里、相對重要的特徵的表現會如下圖所示(左圖對應第 5 維、右圖對應第 19 維):
可以看出,蘑菇數據集在第 19 維上兩個類別各自的「優勢特徵」都非常明顯、第 5 維上兩個類別各自特徵的取值更是基本沒有交集。可以想像,即使只根據第 5 維的取值來進行類別的判定、最後的準確率也一定會非常高
那麼與之相反的、在 MultinomialNB 中相對沒那麼重要的特徵的表現則會形如下圖所示(左圖對應第 3 維、右圖對應第 16 維):
可以看出,蘑菇數據集在第 3 維上兩個類的特徵取值基本沒有什麼差異、第 16 維數據更是似乎完全沒有存在的價值。像這樣的數據就可以考慮直接剔除掉總結
……感覺沒啥好總結的了(趴)
看到這裡的觀眾老爺如果再回過頭去看上一章所講的框架、想必會有些新的體會吧 ( σ"ω")σ
希望觀眾老爺們能夠喜歡~
(猛戳我進入下一章! ( σ"ω")σ )
推薦閱讀:
※圖像識別:基於位置的柔性注意力機制
※Kaggle HousePrice : LB 0.11666(前15%), 用搭積木的方式(3.實踐-訓練、調參和Stacking)
※softmax函數計算時候為什麼要減去一個最大值?