Одномерный лес и все прочее
Для кого статья
В первую очередь статья для меня, чтобы сформулировать мои неокрепшие знания в теме и вместе с тем попрактиковаться в написании этих самых деревьев.
Ну и надеюсь кто-то найдет статью интересной, поэтому постараюсь больше показывать и предоставлять реализации, и делать как можно меньше скучных формул)
Что за деревья такие
Дерево принятия решений (также называют деревом классификации или регрессионным деревом) — средство поддержки принятия решений, использующееся в машинном обучении, анализе данных и статистике.
Структура дерева представляет собой «листья» и «ветки». На рёбрах
(«ветках») дерева решения записаны признаки, от которых зависит целевая
функция, в «листьях» записаны значения целевой функции,
а в остальных узлах — признаки, по которым различаются случаи. Чтобы
классифицировать новый случай, надо спуститься по дереву до листа и
выдать соответствующее значение.
Пример дерева решений
Определение с Википедии.
Грубо говоря это просто бинарное дерево, которое в каждом этапе делает выбор на основании входных данных, разбивая входные данные на 2 группы в каждой ветви.
Дойдя до листьев дерево возвращает среднее (либо медианное) обучающих данных.
Принятие решений происходит по формуле
Наша задача в данном случае найти X.
А тут все просто, мы группируем входные данные на интервалы и берем среднее (или медианное) каждого интервала, и пытаемся подставлять вместо X.
Попадая в локальный минимум по loss, оставляем значение как дискриминатор.
Листинг кода
Вспомогательные функции
Для начала потребуется определить функции для выяснения наших функций потерь и метрик:
В формулах нет ошибки, коэффициент 1/N был отброшен намерено за ненадобностью.
def get_mse_loss(model, x,y):
return ((model.predict(x) - y)**2).mean()
def get_mae_loss(model, x,y):
return np.abs(model.predict(x) - y).mean()
def get_accuracy(model, x,y):
count = 0
preds = model.predict(x)
for i in range(len(preds)):
if (preds[i] == y):
count +=1
return count/len(y)
Само дерево
class MyDecisionTree:
@staticmethod
def get_loss(node, x, y, loss_type):
preds = np.array([node.classify(i) for i in x])
if loss_type == "mse":
return ((preds - y)**2).mean()
if loss_type == "mae":
return np.abs(preds - y).mean()
raise NotImplementedError
class NodeTree:
def __init__(self, approx, separator:float = 0, left_node = None, right_node = None,):
self.separator = separator
self.approx = approx
self.left_node = left_node
self.right_node = right_node
#we can divide dataset into intervals with certain step and calculate means of it
@staticmethod
def get_intervals(x,y, step):
means = []
for i in range(len(x)-min(step, len(x))+1):
means.append((x[i:i+min(step, len(x))], y[i:i+min(step, len(x))]))
return means
#creates node with minimal loss
@staticmethod
def create_node(x,y, loss_type, step):
if len(x) == 0:
raise ValueError("empty data")
intervals = MyDecisionTree.NodeTree.get_intervals(x,y, step)
min_loss = 100000000
minNode = None
if len(intervals) < 1:
raise ValueError("inrervals must be more than 0")
for i in intervals:
approx = i[1].mean() if loss_type == "mse" else np.median(i[1])
n = MyDecisionTree.NodeTree(approx, i[0].mean())
loss = MyDecisionTree.get_loss(n, x, y, loss_type)
if loss < min_loss or minNode is None:
min_loss = loss
minNode = n
if minNode is None:
raise ValueError("null node")
return minNode
#this function creates new nodes from leaves
def grow(self, x,y, loss, step, min_data_size = 2):
if(len(x)) < min_data_size:
return
if(len(x)!=len(y)):
raise ValueError
l_x =[]
l_y =[]
r_x =[]
r_y =[]
for i in range(len(x)):
if x[i] < self.separator:
l_x.append(x[i])
l_y.append(y[i])
else:
r_x.append(x[i])
r_y.append(y[i])
if self.left_node is None or self.right_node is None:
if(len(l_x)):
self.left_node = self.create_node(np.array(l_x),np.array(l_y), loss, step)
else:
self.left_node = MyDecisionTree.NodeTree(approx=self.approx)
if(len(r_x)):
self.right_node = self.create_node(np.array(r_x),np.array(r_y), loss, step)
else:
self.right_node = MyDecisionTree.NodeTree(approx=self.approx)
else:
self.left_node.grow(np.array(l_x), np.array(l_y), loss, step, min_data_size)
self.right_node.grow(np.array(r_x), np.array(r_y), loss, step,min_data_size)
def classify(self, x:float):
if self.left_node is None or self.right_node is None:
return self.approx
if(x < self.separator):
return self.left_node.classify(x)
return self.right_node.classify(x)
def __init__(self, loss = "mse", interval_step = 2, min_node_data_size = 2, max_depth = None):
self.loss = loss
self.step = interval_step
self.min_data_size = min_node_data_size
self.max_depth = max_depth
def fit(self, x, y, logging = False):
self.root = MyDecisionTree.NodeTree.create_node(x,y, self.loss, self.step)
iters = 0
while (self.max_depth is None or iters < self.max_depth):
loss = MyDecisionTree.get_loss(self.root, x,y, self.loss)
self.root.grow(x,y, self.loss, self.step, self.min_data_size)
new_loss = MyDecisionTree.get_loss(self.root, x,y, self.loss)
if logging:
print(f"iter {iters}: loss: {loss} new loss: {new_loss}")
iters+=1
if loss <= new_loss and iters > 5:
break
self.depth = iters
def predict(self, x):
return np.array([self.root.classify(i) for i in x])
Пример работы
x = np.arange(0, 100)
y = 5*np.sin(3*x/len(x)* 2*np.pi)
tree = MyDecisionTree(loss="mse")
tree.fit(x[::3],y[::3])
plt.scatter(x,y, color="orange")
x_tree = np.linspace(-5,len(x)+5,len(x)*10)
plt.plot(x_tree, tree.predict(x_tree), "-")
Аппроксимация синуса через дерево
Возможно у вас появился вопрос: «а почему ты передаешь каждый третий отсчет функции, а не каждый?». А я вам отвечу: Потому что… Ну, а вообще особенность дерева заключается в том, что деревья способны запомнить весь датасет и оно так и сделает, дайте ему возможность.
Выводы
Сделаем recap информации на текущий момент:
Деревья разделяют обучающие данные на 2 группы в каждом узле на основании какого-то значения X
Листья дерева аппроксимируют значения к среднему из дошедшей до листа обучающей подвыборки
Деревья аппроксимируют данные в виде ступенчатых функций
Деревья очень сильно подвержены переобучению
Из последних двух следует, что деревья готовы поглощать данные в любом виде практически без первичных обработок
Дерева мало, посадим лес
Деревья — это конечно круто, но если мы можем позволить себе bagging, то почему бы его и не использовать.
Как это работает
Случайный лес — это ансамблевый метод аппроксимации, работающий по следующему алгоритму:
Делим датасет на какие-то случайные куски в n кусков по m элементов в каждом, где n — количество деревьев (если длинна датасета меньше, чем n*m, то будут пересечения, что не плохо)
Скармливаем подвыборки деревьям
Аппроксимируем данные каждым деревом и далее
Если задача классификации — выбираем мажоранту
Если задача регрессии — берем среднее
А собственно и все:-)
Листинг кода
Лес
class RandomForest:
def __init__(self, estimators_count = 100, loss="mse", approx = "regression", max_depth = None, interval_size = 2, min_node_data = 2, cluster_size = None):
self.__forest = [MyDecisionTree(max_depth = max_depth, interval_step= interval_size, min_node_data_size = min_node_data, loss=loss) for _ in range(estimators_count)]
self.__cluster_size = cluster_size
self.__approx = approx
@staticmethod
def __spit_dataset(x,y, cluster_size, splits):
subsets = []
n_x = list(x)
n_y = list(y)
for i in range(splits):
set = ([],[])
for i in range(cluster_size):
ind = r.randint(0, len(n_x)-1)
set[0].append(n_x.pop(ind))
set[1].append(n_y.pop(ind))
if (len(n_x) ==0):
n_x = list(x)
n_y = list(y)
subsets.append(set)
return np.array(subsets)
@staticmethod
def get_loss(forest, x, y, loss_type = "mse"):
pred = forest.predict(x)
if (loss_type == "mse"):
return ((pred - y)**2).mean()
if (loss_type == "mae"):
return np.abs(pred - y).mean()
def fit(self, x, y, logging=False):
if self.__cluster_size is None:
self.__cluster_size = len(x)//3
data = RandomForest.__spit_dataset(x,y, self.__cluster_size, len(self.__forest))
for index_tree in range(len(self.__forest)):
if(logging):
print(f"tree: {index_tree}/{len(self.__forest)}")
self.__forest[index_tree].fit(data[index_tree][0],data[index_tree][1], logging= logging)
def predict(self, x):
predicts = np.array([i.predict(x) for i in self.__forest]).T
if self.__approx == "regression":
return predicts.mean(axis=1)
if self.__approx == "classification":
return st.mode(predicts)
raise ValueError("approximation type error")
Пример работы
forest = RandomForest(cluster_size=60)
forest.fit(x,y, logging=False)
plt.scatter(x,y, color="orange")
x_tree = np.linspace(-5,len(x)+5,len(x)*10)
plt.plot(x_tree, forest.predict(x_tree), "-")
Аппроксимация через случайный лес
Можно заметить, что график стал мягче, благодаря усреднению результатов.
Древесный бустинг
Разобравшись с bagging методом, разберемся с другим ансамблевым методом — boosting.
В отличии от случайного леса, где каждое дерево делает предсказание из одних и тех же данных. В бустинге наши деревья делают предсказание друг за другом, предсказывая ошибку предыдущего.
Отличной аналогией является игра в гольф: первым ударом мы максимально сокращаем расстояние с лункой, а каждым последующим подгоняем шар как можно ближе.
Листинг кода
Бустинг
class ForestBoosting:
def __init__(self, estimators_count = 100, loss = "mse", approx = "regression"):
self.__estimators_count = estimators_count
self.__loss = loss
self.__approx = approx
self.__forest = None
def fit(self, x,y, logging = False):
data = y
trees = []
last_loss = 10000000
for i in range(self.__estimators_count):
tree = MyDecisionTree(loss=self.__loss, interval_step=len(x)//2, max_depth=5)
if (logging):
print("tree:", i)
tree.fit(x, data, logging = logging)
pred = tree.predict(x)
data = (data - pred)
trees.append(tree)
l = (data**2).mean() if self.__loss == "mse" else np.abs(data).mean()
if(logging):
print("err:", l)
if last_loss <= l:
break
last_loss = l
self.__forest = trees
def predict(self, x):
if(self.__forest is None):
raise ValueError("Boosting not fitted")
res = np.zeros(shape=len(x))
for tree in self.__forest:
res += tree.predict(x)
return resy
Пример работы
oscillation_x = np.linspace(0,5,1000)
oscillation_y = np.exp(-oscillation_x)*5*np.sin(10*oscillation_x)
boosting = ForestBoosting(loss="mse")
boosting.fit(oscillation_x, oscillation_y, True)
plt.scatter(oscillation_x,oscillation_y, color="orange")
x_tree = np.linspace(-1,6,1000)
plt.plot(x_tree, boosting.predict(x_tree), "-")
plt.grid(True)
Аппроксимация через бустинг
Заключение
Касательно статьи, то было разобрано 3 варианта аппроксимации на основе решающих деревьев с примером кода и практически без математических выкладок.
Жду ваших замечаний, пожеланий и прочего :-)