機器學習:結點的實現,決策樹程式碼實現(二)
文章目錄
楔子
前面已經實現了各種資訊量的計算,那麼我們劃分的基本有了,那麼我們需要使用這個基本來劃分,來生成決策樹,樹的基本單元是node,這裡的node是一堆資料的集合+他們內在的方法。由於需要處理三種演算法,我們最好能使用基類,該類應該至少包含:
1、選擇劃分的feature;
2、根據基準劃分資料生成新的結點;
3、判斷那些節點可以當成葉子,並歸類;
定義變數:
node的變數是不是有點多,容易糊塗,我們需要抓住重點,首先是一個node,那麼這個node有那些變量了,按照上述方法應該有如下三個變數:self.feature_dim(劃分的feature),self._children(生成的子節點),self.leafs(一顆子節點實際代表一顆子樹,這顆子樹下的所有葉子結點),當然還有資料集(self._x ,self._y),其他的變數根據需要慢慢新增。
# =============================================================================
# 實現完資訊量的計算,下面就考慮決策樹的生成
# 決策樹是一顆x叉樹,需要資料結構node
# 我們需要同時處理ID3,C4.5,CART所以需要建立一個基類
# 類的方法:1、根據離散特徵劃分資料;2、根據連續特徵劃分資料;3、根據當前資料判斷
# 屬於哪一個類別
# =============================================================================
import numpy as np
from Cluster import Cluster
class CvDNode:
def __init__(self,tree=None,base=2,chaos=None ,depth=0,parent=None,is_root=True,pre_feat="Root"):
# 資料集的變數,都是numpy陣列
self._x = self._y = None
# 記錄當前的log底和當前的不確定度
self.base,self.chaos =base,chaos
# 計算該節點的資訊增益的方法
self.criterion = None
# 該節點所屬的類別
self.category = None
# 針對連續特徵和CART,記錄當前結點的左右結點
self.left_child = self.right_child =None
# 該node的所有子節點和所有的葉子結點
self._children,self.leafs = {},{}
# 記錄樣本權重
self.sample_weight =None
# whether continuous記錄各個緯度的特徵是否連續
self.wc = None
# 記錄該node為根的tree
self.tree =tree
# 如果傳入tree的話,初始化
if tree is not None:
# 資料預處理是由tree完成
# 各個features是否連續也是由tree記錄
self.wc = tree.whether_continuous
# 這裡的node變數是Tree中所記錄的所有node的列表
tree.nodes.append(self)
# 記錄該node劃分的相關資訊:
# 記錄劃分選取的feature
self.feature_dim =None
# 記錄劃分二分劃分的標準,針對連續特徵和CART
self.tar = None
# 記錄劃分標準的feature的featureValues
self.feats =[]
# 記錄該結點的父節點和是否為根結點
self.parent =parent
self.is_root = is_root
# 記錄結點的深度
self._depth = depth
# 記錄該節點的父節點的劃分標準feature
self.pre_feat = pre_feat
# 記錄該節點是否使用的CART演算法
self.is_cart =False
# 記錄該node劃分標準feature是否是連續的
self.is_continuous = False
# 記錄該node是否已經被剪掉,後面剪枝會用到
self.pruned = False
# 用於剪枝時,刪除一個node對那些node有影響
self.affected = False
# 過載__getitem__運算子,支援[]運算子
# 過載__getattribute__運算子,支援.運算子
def __getitem__(self,item):
if isinstance(item,str):
return getattr(self,"_" + item)
# 過載__lt__的方法,使node之間可以相互比較,less than,方便除錯
def __lt__(self,other):
return self.pre_feat < other.pre_feat
# 過載__str__和 __repr__為了方便除錯
def __str__(self):
# 該節點的類別屬性
if self.category is None:
return "CvDNode ({}) ({} -> {})".format(self.depth,self.pre_feat,self.feature_dim)
return "CvDNode ({}) ({} -> class:{})".format(self.depth,self.pre_feat,self.tree.label[self.category])
# __repr__ 用於列印,面對程式設計師,__str__用於列印,面對使用者
__repr__ = __str__
# =============================================================================
# 定義幾個property,Python內建的@property裝飾器就是負責把一個方法變成屬性呼叫的
# @property廣泛應用在類的定義中,可以讓呼叫者寫出簡短的程式碼,
# 同時保證對引數進行必要的檢查,這樣,程式執行時就減少了出錯的可能性。
# =============================================================================
# 定義children屬性,主要區分開連續,CART和其餘情況
# 有了該屬性之後所有子節點都不需要區分情況了
@property
def children(self):
return {
"left" : self.left_child, "right" : self.right_child
} if (self.is_cart or self.is_continuous) else self._children
# 遞迴定義高度屬性
# 葉子結點的高度為1,其餘結點高度為子節點最高高度+1
@property
def height(self):
if self.category is not None:
return 1
return 1 + max([_child.height if _child is not None else 0
for _child in self.children.values()])
# 定義info_dic屬性(資訊字典),記錄了該node的主要屬性
# 在更新各個node的葉子結點時,葉子結點self.leafs的屬性就是該節點
@property
def info_dic(self):
return {"chaos" : self.chaos,"y" :self._y}
定義方法
大概回憶一下演算法的大概處理流程:選擇劃分的features,遞迴的劃分生成子節點,滿足停止條件,形成葉子。上述就是整個fit的過程。在實現主要函式fit()前,我們需要定義好一些fit()會使用到的函式。
獲得劃分的feature
這個放在fit()函式裡面,見fit()
生成結點
獲取了劃分的feature之後,就需要根據這標準來生成子樹,或者說子節點。
處理大概過程:我們需要生成一個新結點,需要知道他的資料樣本(通過mask找到),他對應的chaos,還有一些其他的輔助變數,形成新的結點或者子樹,然後遞迴處理這堆資料不斷的生成結點。這裡面需要區分離散、連續和CART3種情況處理。
# =============================================================================
# 生成結點的方法
# =============================================================================
# chaos_lst:[featureValue] = chaos,指定feature下,不同featureValue的不確定度
def _gen_children(self, chaos_lst):
# 獲取當前結點的劃分feature,連續性時:該feature劃分基準為tar
feat, tar = self.feature_dim, self.tar
# 獲取該feature是否是連續的
self.is_continuous = continuous = self.wc[feat]
# 取對應feature的N個數據,這是簡化的寫法,得到該feature的那列,實際是一行
features = self._x[..., feat]
# 當前結點可以使用的features
new_feats = self.feats.copy()
# 連續性二叉處理
if continuous:
# 根據劃分依據tar得到一類的mask
mask = features < tar
# 這個就是分成兩類的mask
masks = [mask, ~mask]
else:
if self.is_cart:
# CART根據劃分依據tar得到一類的mask
mask = features == tar
# 分成兩類的mask
masks = [mask, ~mask]
# 把這個劃分tar從指定feature下featureValue裡移除
self.tree.feature_sets[feat].discard(tar)
else:
# 離散型,沒有mask,直接使用featureValue數量生成子節點
masks = None
# 二分情況處理
if self.is_cart or continuous:
feats = [tar, "+"] if not continuous else ["{:6.4}-".format(tar), "{:6.4}+".format(tar)]
for feat, side, chaos in zip(feats, ["left_child", "right_child"], chaos_lst):
new_node = self.__class__(
self.tree, self.base, chaos=chaos,
depth=self._depth + 1, parent=self, is_root=False, prev_feat=feat)
new_node.criterion = self.criterion
setattr(self, side, new_node)
for node, feat_mask in zip([self.left_child, self.right_child], masks):
if self.sample_weight is None:
local_weights = None
else:
local_weights = self.sample_weight[feat_mask]
local_weights /= np.sum(local_weights)
tmp_data, tmp_labels = self._x[feat_mask, ...], self._y[feat_mask]
if len(tmp_labels) == 0:
continue
node.feats = new_feats
node.fit(tmp_data, tmp_labels, local_weights)
else:
# 離散情況處理
# 可選擇的features裡移除已選擇的feature,子節點就使用這些features尋找劃分
new_feats.remove(self.feature_dim)
# self.tree.feature_sets[self.feature_dim]:對應的是這個特徵的所有特徵值
# chaos_lst:對應的是每個特徵值的不確定度
for feat, chaos in zip(self.tree.feature_sets[self.feature_dim], chaos_lst):
# 這個特徵值的mask
feat_mask = features == feat
# 根據這個mask,找到這個特徵值對應那些資料
tmp_x = self._x[feat_mask, ...]
# 如果這個特徵值沒有資料,就取下一個特徵值,相當於沒有必要生成新結點
if len(tmp_x) == 0:
continue
# 否則的話生成新結點,新結點四個引數比較重要:ent,feature_dim,children,leafs
new_node = self.__class__(
tree=self.tree, base=self.base, chaos=chaos,
depth=self._depth + 1, parent=self, is_root=False, prev_feat=feat)
# 新結點的可選維度就是上述的new_feats
new_node.feats = new_feats
# 更新當前結點的子節點集
self.children[feat] = new_node
if self.sample_weight is None:
local_weights = None
else:
# 帶權重的處理
local_weights = self.sample_weight[feat_mask]
# 需要歸一化
local_weights /= np.sum(local_weights)
# 遞迴的處理新結點,需要把分塊的資料傳入進來
new_node.fit(tmp_x, self._y[feat_mask], local_weights)
停止條件及其處理
什麼時候停止生成子樹?就是什麼時候我們形成葉子,兩種情況,見下述程式碼。形成葉子之後的處理:已經判斷這堆資料可以當作葉子了,我們需要幹什麼:一是這對資料屬於哪一類(少數服從多數),二是更新當前結點的列祖列組的self.leafs,告訴列祖列組我是你們正宗的leaf。停止條件及其處理就是回溯法裡面的限界函式,用於剪枝,實際上這個只是預剪枝。
# =============================================================================
# 定義生成演算法的準備工作:定義停止生成的準則,定義停止後該node的行為
# =============================================================================
# 停止的第一種情況:當特徵緯度為0或者當前node的資料的不確定性小於閾值停止
# 假如指定了樹的最大深度,那麼當node的深度太深時也停止
# 滿足停止條件返回True,否則返回False
def stop1(self,eps):
if (self._x.shape[1] == 0 or (self.chaos is not None and self.chaos < eps)
or (self.tree.max_depth is not None and self._depth >=self.tree.max_depth)
):
# 呼叫停止的方法
self._handle_terminate()
return True
return False
# 當最大資訊增益小於閾值時停止
def stop2(self, max_gain, eps):
if max_gain <= eps:
# 呼叫停止的方法
self._handle_terminate()
return True
return False
# 定義該node所屬類別的方法,假如特徵已經選完了,將此事樣本中最多的類,作為該節點的類
def get_category(self):
return np.argmax(np.bincount(self._y))
# 定義剪枝停止的處理方法,核心思想是:將node結點轉換為葉子結點
def _handle_terminate(self):
# 首先先生成該node的所屬類別
self.category = self.get_category()
# 然後一路回溯,更新該節點的所有祖先的葉子結點資訊
_parent =self.parent
while _parent is not None:
# id(self)獲取當前物件的記憶體地址
_parent.leafs[id(self)] = self.info_dic
_parent = _parent.parent
fit()
下面就到了重點,這個是整個node處理的核心函式,實現前面提到的三個方法的處理流程,每次新結點的處理都是呼叫這個函式來實現:傳入資料集,計算資訊量,得到劃分的feature,是否滿足停止條件,否生成子節點遞迴的處理。
# 挑選出最佳劃分的方法,要注意二分和多分的情況
def fit(self, x, y, sample_weight, eps= 1e-8):
self._x = np.atleast_2d(x)
self._y = np.array(y)
# 如果滿足第一種停止條件,則退出函式體
if self.stop1(eps):
return
# 使用該node的資料例項化Cluster類以便計算各種資訊量
_cluster = Cluster(self._x, self._y, sample_weight, self.base)
# 對於根節點,需要額外計算其資料的不確定性,第一次需要計算,
# 其他時候已經傳入了chaos
if self.is_root:
if self.criterion == "gini":
self.chaos = _cluster.gini()
else:
self.chaos = _cluster.ent()
# 用於存最大增益
_max_gain = 0
# 用於存取最大增益的那個feature,不同的featureValue限制下的不確定度
# 最後[featureValue] = 對應的不確定度
_chaos_lst = []
# 遍歷還能選擇的features
for feat in self.feats:
# 如果是該維度是連續的,或者使用CART,則需要計算二分標準的featureValue
# 的取值集合
if self.wc[feat]:
_samples = np.sort(self._x.T(feat))
# 連續型的featureValue的取值集合
_set = (_samples[:-1] + _samples[1:]) *0.5
else:
_set = self.tree.feature_sets[feat]
# 連續型和CART,需要使用二分類的計算資訊量
if self.wc[feat] or self.is_cart:
# 取一個featureValue
for tar in _set:
_tmp_gain, _tmp_chaos_lst = _cluster.bin_info_gain(
feat, tar, criterion=self.criterion,
get_chaos_lst= True, continuous=self.wc[feat])
if _tmp_gain > _max_gain:
(_max_gain,_chaos_lst),_max_feature,_max_tar =(
_tmp_gain, _tmp_chaos_lst), feat, tar
# 離散資料使用一般的處理
else:
_tmp_gain, _tmp_chaos_lst = _cluster.info_gain(
feat, self.criterion, True, self.tree.feature_sets[feat])
if _tmp_gain > _max_gain:
(_max_gain,_chaos_lst),_max_feature =(
_tmp_gain, _tmp_chaos_lst), feat
# 當所有features裡面最大的不確定都足夠小,退出函式體
if self.stop2(_max_gain, eps):
return
# 更新相關的屬性
# 當前結點劃分的feature
self.feature_dim = _max_feature
# 二叉的處理
if self.is_cart or self.wc[_max_feature]:
self.tar = _max_tar
# 根據劃分進行生成結點
self._gen_children(_chaos_lst)
# 這個是專門針對二叉的處理,是在生成樹到底之後回溯時
# 生成樹同一層兩個結點都生成的話,看能不能剪枝
# 實際這也是後剪枝的一種,但是二叉樹比較好處理,剪枝的策略也比較簡單
# 只是簡單的去重,所以x叉樹沒有實施,後剪枝有更加高效的策略
# 如果左右結點都是葉子結點且他們都屬於同一個分類,可以使用剪枝操作
if (self.left_child.category is not None and
self.left_child.category == self.right_child.category):
self.prune()
# 呼叫tree的方法,剪掉該節點的左右子樹
# 從Tree的記錄所有Node的列表nodes中除去
self.tree.reduce_nodes()
else:
# 離散情況的處理
self._gen_children(_chaos_lst)
# 上述的剪枝策略,只是同一父親的子結點去重,x叉樹不好實現。
# 直接採用更加高效的策略後剪枝
生成樹剪枝
得到一顆生成樹之後,這棵樹沒啥約束或者僅僅只靠資訊增益約束,可能枝繁葉茂,為了使這棵樹在其他資料集上能取得較好的泛化效能,我們需要剪枝,刪除一些葉子結點。
在樹生成完畢或者區域性生成完畢的剪枝稱之為後剪枝,上述fit()實現針對二叉樹的處理,左右結點生成完畢判斷兩個結點是否屬於同一類別剪枝,就是後剪枝的一種,實際後剪枝有一套全域性規劃的策略,下次再講。
post-prune的實現:這種情況的剪枝生成樹已經生成完畢,剪枝相當於把下面所有的葉子看成一個葉子結點,那麼需要把下面所有的葉子從列祖列宗的leafs譜裡除名,然後把當前結點成了葉子加入到列祖列宗的leafs譜裡,完了還需要標記自己和下面的結點為pruned已被剪掉,為什麼自己也需要剪掉,因為葉子的資訊已經在列祖列宗的leafs譜裡。
# 剪枝操作,將當前結點轉化為葉子結點,這裡可能覺得莫名奇妙,前面停止處理的時候不是
# 已經剪枝了嗎,這個地方怎麼還有專門的剪枝函式,而且還要把,把刪除剪枝剪掉的葉子。
# 實際上這是後剪枝,稱之為post-pruning, stop裡面的剪枝稱之為pre-pruning.
def prune(self):
# 呼叫方法計算該node屬於的類別
self.category = self.get_category()
# 當前結點轉化為葉子結點,記錄其下屬結點的葉子結點
_pop_lst = [key for key in self.leafs]
# 然後一路回溯,更新各個parent的屬性葉子
_parent = self.parent
while _parent is not None:
# 刪除由於被剪枝而剪掉的葉子結點
# 由於刪除結點,對父節點產生了影響,用於後剪枝更新新損失函式
_parent.affected = True
for _k in _pop_lst:
_parent.leafs.pop(_k)
# 把當前結點更新進去,因為當前結點變成了葉子
_parent.leafs[id(self)] = self.info_dic
_parent = _parent.parent
# 呼叫mark_pruned函式將所有的子子孫孫標記為已剪掉,pruned屬性為True
self.mark_pruned()
# 重置各個屬性,這個有必要嗎,理論上是要刪除,畢竟現在是葉子了,葉子這些都
# 應該是空的
self.feature_dim = None
self.left_child = self.right_child = None
self._children = {}
self.leafs = {}
# 下面實現mark_pruned函式,self._children放的是子樹
# 為啥把自己也置為True,自己也被剪掉了?還是因為葉子結點資訊已在父親的leafs裡不重要?
def mark_pruned(self):
self.pruned = True
# 這裡使用的children屬性,獲取的是子樹,遞迴的呼叫
for _child in self.children.value():
if _child is not None:
_child.mark_pruned()
至此node類的變數和方法基本實現完畢,為什麼說基本呢,因為真正的後剪枝還沒將,他還需要在node類裡新增一些方法。