Была классическая задача: по табличным данным предсказать некое событие — случится или нет. И как бы я к этим данным ни подбирался, с какого ракурса ни смотрел, результат, увы, не впечатлял. Данных было мало, а то, что было, обладало слабой предсказательной силой. Хотя казалось, что что-то вытащить все-таки можно.

И вот, просматривая отдельные деревья решений, меня осенило — попробую-ка я обрезать все деревья, используемые в Random Forest, до одной, но самой эффективной ветки. И — о чудо! — заметно выросла как точность (precision), так и полнота (recall). И особенно полнота выросла на высоких уровнях точности.

Проверил этот способ на других задачах. И везде при 100% точности заметно выростала полнота. Что же я сделал?

Идея

Обрезка или стрижка (pruning) дерева — это способ борьбы с переобучением. Обычно сводится к указанию критерия остановки роста (максимальная глубина дерева, минимальное количество объектов в листе и т. д.). Это так называемая предобрезка (pre-pruning), а по сути, просто ограничение роста.

Но есть ещё и _в_самом_делешная_ обрезка построенного дерева (post-pruning). Например, техника minimal cost complexity pruning. В моём случае она не давала каких-либо улучшений.

Эффект же дал совсем простой вариант, и даже скорее не обрезки, а именно выдергивания одной ветки из каждого дерева.

Пример дерева в моей задаче:

Пример дерева решений
Пример дерева решений

Видно, что объекты класса, который нужно предсказать, хорошо выделяются в том числе по самой правой ветке (листья синего цвета). Такая картина почти на всех деревьях. На тот момент у меня уже были обучены модели на других данных, и в качестве признаков я использовал и их вперемешку с исходными. Поэтому ожидаемо, что в листе правой ветки доминирует положительный класс почти всегда.

Интуитивно мне казалось, что в таком случае переобучение будет в меньшей степени приходиться на правую ветку и в большей степени — на оставшееся дерево. И что неплохо было бы собрать кучу из этих правых веток без каких-либо ограничений на их рост, пусть там в конце будет даже один объект, главное — побольше собрать таких веток. 

И добавлять в кучу, конечно, не все ветки, а те, в которых нужный класс заметно доминирует (по опыту, его доля должна быть больше 80%). И не на данных для построения дерева, а на новых для дерева данных, этакий внутренний механизм перекрестной проверки. 

И когда у нас уже будет куча отобранных таким образом веток, можно предсказывать на тестовых данных: чем в большем количестве веток новый объект поучаствует, тем выше вероятность отнесения его к целевому классу.

Вот так просто!

В итоге получил вот такие кривые точности и полноты для Random Forest и «кучи веток»:

Кривые точности и полноты для Random Forest и Random Branch
Кривые точности и полноты для Random Forest и Random Branch

Способ хорошо работает именно для увеличения полноты на высоких уровнях точности, т. е. в левой верхней области графика. Оно и понятно, ведь самим алгоритмом обрезки мы концентрируемся на этой области. Но будет ли работать данный алгоритм с обычными признаками, а не только как стэкинг выходов других моделей, как в моем случае?

Оказалось, что да! Причем, как и в случае с Random Forest, будет работать из коробки, в чем мы сейчас и убедимся.

Но сначала подробнее разберем сам алгоритм обрезки.

Random Branch

Класс RandomBranchClassifier можно использовать для бинарной классификации также, как RandomForestClassifier.

Код класса RandomBranchClassifier
import re
import numpy as np
import pandas as pd
from sklearn import tree
from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.metrics import precision_score
from sklearn.model_selection import train_test_split
from rich.progress import track

class RandomBranchClassifier(BaseEstimator, ClassifierMixin):
    def __init__(self, n_estimators = 4000, max_features = 14, dataset_size = None,
                 pruning_type = 'max_right', leaf_threshold = 0.8,
                 include_left = False, min_positive = 0,
                 tree_apply = 'cv', cv_size = 0.5, cv_stratify = False, cv_prec_threshold = 0,
                 criterion = 'entropy', splitter = 'best', min_samples_leaf = 1, max_depth = None,
                 min_samples_split = 2, max_leaf_nodes = None, min_weight_fraction_leaf = 0,
                 plot_trees = False, feature_names = None, remove_duplicates = 'n'):
        
        self.n_estimators = n_estimators
        self.max_features = max_features
        self.dataset_size = dataset_size
        
        self.pruning_type = pruning_type 
        self.leaf_threshold = leaf_threshold
        
        self.include_left = include_left
        self.min_positive = min_positive
        
        self.tree_apply = tree_apply 
        self.cv_size = cv_size
        self.cv_stratify = cv_stratify
        self.cv_prec_threshold = cv_prec_threshold

        self.criterion = criterion 
        self.splitter = splitter 
        self.min_samples_leaf = min_samples_leaf
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.max_leaf_nodes = max_leaf_nodes
        self.min_weight_fraction_leaf = min_weight_fraction_leaf
        
        self.plot_trees = plot_trees
        self.feature_names = feature_names
        self.remove_duplicates = remove_duplicates

        self.features = []
        self.conditions = []
        self.conditions_a = []
        self.conditions_b = []

        self.tree_children_left = []
        self.tree_children_right = []
        self.tree_feature = []
        self.tree_threshold = []
        
        self.feature_count = []
        self.nbranches = [] 
        
    def fit(self, X, y):
        
        if not isinstance(X, pd.DataFrame):
            X = pd.DataFrame(X)
            
        if not isinstance(y, pd.Series):
            y = pd.Series(y)    
        
        if self.dataset_size != None:
            
            if self.dataset_size < 1:
                self.dataset_size = round(len(y)*self.dataset_size)
            X = X.iloc[:self.dataset_size,:]
            y = y[:self.dataset_size]
        
        if self.feature_names != None:
            self.features = self.feature_names
        else:
            self.features = X.columns 
            
        self.feature_count = [0] * len(self.features)

        for i in track(range(self.n_estimators), description="Processing..."): 
            
            random_features = np.random.choice(len(self.features), size=self.max_features, replace=False)
            selected_features = [self.features[idx] for idx in random_features]
            
            if self.cv_stratify == True:
                x_train, x_cv, y_train, y_cv = train_test_split(
                    X[selected_features], y, test_size=self.cv_size, random_state=i, stratify = y)
            else:
                x_train, x_cv, y_train, y_cv = train_test_split(
                    X[selected_features], y, test_size=self.cv_size, random_state=i)
            
            clf = tree.DecisionTreeClassifier(criterion=self.criterion, min_samples_leaf=self.min_samples_leaf,
                                              max_depth = self.max_depth,
                                              min_weight_fraction_leaf = self.min_weight_fraction_leaf,
                                              max_leaf_nodes = self.max_leaf_nodes,
                                              min_samples_split = self.min_samples_split,
                                              splitter = self.splitter)
            
            clf.fit(x_train, y_train)
            
            if self.tree_apply == 'cv':
                leave_id = clf.apply(x_cv)
                y_for_metrics = y_cv.to_list()
            else:
                leave_id = clf.apply(x_train) 
                y_for_metrics = y_train.to_list()
            
            self.tree_children_left = clf.tree_.children_left
            self.tree_children_right = clf.tree_.children_right
            self.tree_feature = clf.tree_.feature
            self.tree_threshold = clf.tree_.threshold
            
            paths = {}
            for leaf in np.unique(leave_id):
                path_leaf = []
                self.find_path(0, path_leaf, leaf)
                paths[leaf] = np.unique(np.sort(path_leaf))
                
            rules = {}
            for key in paths:
                rules[key] = self.get_rule(paths[key], selected_features)
                            
            y_pred = clf.predict(x_cv)
            cv_prec_score = precision_score(y_cv, y_pred)
                        
            df_metrics = pd.DataFrame({'leave_id': leave_id, 'y': y_for_metrics})
            df_metrics = df_metrics.groupby('leave_id').agg(positive=('y', 'sum'), total=('y', 'count')).reset_index()
            df_metrics['precision'] = df_metrics['positive'] / df_metrics['total']
            df_metrics.loc[df_metrics.positive < self.min_positive,'positive'] = 0
            df_metrics['custome_score'] = (df_metrics['precision'] > self.leaf_threshold).astype(int) * df_metrics['positive']
            
            if self.pruning_type == 'max_right' or self.pruning_type == 'max': 
                
                if cv_prec_score >= self.cv_prec_threshold and df_metrics.iloc[df_metrics['custome_score'].idxmax(), 4] != 0:
                    self.conditions.append(rules[df_metrics.iloc[df_metrics['custome_score'].idxmax(), 0].item()])
                        
            elif self.pruning_type == 'right' or self.pruning_type == 'all':  
                
                for j in range(len(rules)): 

                    leaf_ratio = df_metrics.loc[df_metrics['leave_id'] == list(rules)[j],'precision'].item()
                    leaf_positive_count = df_metrics.loc[df_metrics['leave_id'] == list(rules)[j],'positive'].item()
                    if leaf_positive_count > self.min_positive and leaf_ratio >= self.leaf_threshold and cv_prec_score >= self.cv_prec_threshold:
                        self.conditions.append(rules[list(rules)[j]]) 
            else:
                
                print("Error: please choose correct pruning type")
                return None
                
                        
            if self.plot_trees == True:
                x_size = round(clf.tree_.node_count / 2)
                plt.figure(figsize=(x_size,12)) 
                tree.plot_tree(clf, fontsize=7, feature_names=selected_features, filled = True)
                plt.show()
            
        if self.pruning_type == 'max_right' or self.pruning_type == 'right':  
            
            if self.include_left == True:
                left_conditions = [item for item in self.conditions if ">" not in item]
                self.conditions = [item for item in self.conditions if "<" not in item]
                self.conditions.extend(left_conditions)
            else:
                self.conditions = [item for item in self.conditions if "<" not in item]
        
        for string in self.conditions:
            self.feature_count = [count + res for count, res in zip(
                self.feature_count, self.count_features_in_conditions(self.features, string))] 
                
        self.conditions_a = list(set(self.conditions))
        self.conditions_b = self.remove_duplicates_from_conditions(self.conditions)
        
        if self.remove_duplicates == 'a':
            self.conditions = self.conditions_a
                
        if self.remove_duplicates == 'b':
            self.conditions = self.conditions_b
        
        self.nbranches = len(self.conditions)
        return self
    
    def predict(self, X, threshold = 0.2, remove_duplicates = 'n', exclude = 'use_feature_name'): 

        y_proba = self.predict_proba(X, remove_duplicates = remove_duplicates, exclude = exclude)
        y_pred = np.where(y_proba[:, 1] >= threshold, 1, 0)
        return y_pred

    def predict_proba(self, X, remove_duplicates = 'n', exclude = 'use_feature_name'):
        
        if remove_duplicates == 'a':
            conditions = self.conditions_a
        elif remove_duplicates == 'b': 
            conditions = self.conditions_b
        else:
            conditions = self.conditions
        
        d = 0
        predictions = np.zeros((X.shape[0], 2))
        df = X
        df['count'] = 0
        for i in range(len(conditions)):
            if (len(conditions[i]) > 0) and (exclude not in conditions[i]):
                d += 1
                df.loc[eval(conditions[i]),'count'] += 1

        predictions[:, 1] = df['count'] / d
        predictions[:, 0] = 1 - predictions[:, 1]
        
        return predictions
    
    def find_path(self, node_numb, path, x):
        path.append(node_numb)
        if node_numb == x:
            return True
        left = False
        right = False
        if (self.tree_children_left[node_numb] !=-1):
            left = self.find_path(self.tree_children_left[node_numb], path, x)
        if (self.tree_children_right[node_numb] !=-1):
            right = self.find_path(self.tree_children_right[node_numb], path, x)
        if left or right :
            return True
        path.remove(node_numb)
        return False
    
    def get_rule(self, path, column_names):
        mask = ''
        for index, node in enumerate(path):
            if index!=len(path)-1:
                if (self.tree_children_left[node] == path[index+1]):
                    mask += "(df['{}']<= {}) \t ".format(column_names[self.tree_feature[node]], self.tree_threshold[node])
                else:
                    mask += "(df['{}']> {}) \t ".format(column_names[self.tree_feature[node]], self.tree_threshold[node])
        mask = mask.replace("\t", "&", mask.count("\t") - 1)
        mask = mask.replace("\t", "")
        return mask
    
    def count_features_in_conditions(self, features, condition):
        feature_counts = []

        for feature in features:
            pattern = r'\b{}\b'.format(feature)  
            matches = re.findall(pattern, condition)  
            feature_counts.append(len(set(matches)))  

        return feature_counts
    
    def remove_duplicates_from_conditions(self, lst):
        pattern = r"df\['(.*?)'\]"
        extracted_lst = []
        duplicates = set()
        unique_lst = []
        
        for item in lst:
            match = re.findall(pattern, item)
            extracted_str = " ".join(match)
            extracted_lst.append(extracted_str)
            
            if extracted_str in unique_lst:
                duplicates.add(len(extracted_lst) - 1)
            else:
                unique_lst.append(extracted_str)
        
        filtered_lst = [item for i, item in enumerate(lst) if i not in duplicates]
        return filtered_lst

Три основных параметра — это pruning_type (тип обрезки), leaf_threshold (порог доли положительного класса в листе ветки) и min_positive (минимальное число объектов положительного класса в листе ветки).

Параметр pruning_type может быть right, max_right, max или all.

pruning_type = 'right' — это как раз то, что я описывал выше. Берём все правые ветки (в условиях используется только знак «>»), в листе которых доля положительного класса выше leaf_threshold и объектов класса больше min_positive.

pruning_type = 'max_right' — это тоже самое, что и 'right', только на ветку накладывается дополнительное условие: берем правую ветку, если в её листе больше всего объектов положительного класса среди всех листьев, удовлетворяющих условию доли и минимального наличия объектов. Проще говоря, правая ветка должна быть не только плодовитой на наш класс, но и самой обильной из всех плодовитых в дереве. Этот тип обрезки оказался ещё эффективнее, чем 'right'. Он дополнительно отсекает, пусть и удовлетворяющие критерию доли, но слабые ветки.

Ветка для max_right
Ветка для max_right

pruning_type = 'max' — из всех веток в дереве, в листе которых доля положительного класса выше leaf_threshold, берём ту, в листе которой объектов класса максимальное и больше min_positive. Т.е. это как 'max_right', только без 'right'.

pruning_type = 'all' — это все ветки дерева, в листе которых доля положительного класса выше leaf_threshold и объектов класса больше min_positive. Если leaf_threshold = 0 и min_positive = 0, то это просто все ветки всех деревьев. Но все ветки всех деревьев не делают Random Branch аналогичным Random Forest, так как у Random Branch иной механизм предсказания. Этот тип обрезки работает, если увеличивать значение min_positive.

Использование условия min_positive не аналогично использованию ограничения на рост дерева (min_samples_leaf). Используя min_positive, мы работаем с уже построенным деревом. И не берём ветку, если в её листе меньше объектов положительного класса, чем задано min_positive.

В целом, по опыту нескольких задач, самый эффективный и стабильный вариант — max_right, потом right. Варианты 'max' и 'all' не очень эффективны. Хотя вариант 'all' в сочетании с увеличением min_positive тоже неплох.

Поэтому по дефолту pruning_type = 'max_right', leaf_threshold = 0.8, min_positive = 0.

Другие параметры (в скобках значения по дефолту):

  • n_estimators [ = 4000] — количество создаваемых деревьев

  • max_features [ = 14] — максимальное количество признаков случайно выбираемое каждый раз для создания дерева и поиска лучшего разбиения

  • remove_duplicates [ = 'n' ] — удалять дубликаты выделяемых условий (правил, веток) или нет. n — не удалять; a — удалять точные дубликаты, когда совпадают как последовательность признаков, так и пороговые значения; b — удалять дубликаты по последовательности признаков, когда последовательность признаков совпадает, а вот пороговые значения могут быть разными. Это параметр для GridSearchCV. По факту, можно его не трогать, а когда используете метод predict или predict_proba указать там параметр remove_duplicates

  • dataset_size [ = None] — ограничение размера выборки, встроил для использования с GridSearchCV. Можно указать либо долю от исходного датасета, либо конкретное значение размера

  • include_left [ = False] — включать левые ветки или нет. Только для вариантов обрезки 'right' или 'max_right'. Параметр не влияет на эффективность, но можно снизить количество создаваемых деревьев

  • tree_apply [ = 'cv' ] — важный параметр. Может быть 'cv' или 'train'. Если 'train', то долю нужного класса в листе считать на тех же данных, которые использовались для построения дерева. А если 'cv', то на новых для дерева данных

  • cv_size [ = 0.5 ] — указывается доля от имеющихся данных для перекрестной проверки

  • cv_stratify [ = False ] — если cv_stratify = True, то пропорции классов при разбиении данных на тренировочные и для перекрестной проверки будут одинаковые

  • cv_prec_threshold [ = 0 ] — ещё одно возможное условие для отбора веток. На данных для перекрестной проверки считается точность предсказаний всего дерева. Данный параметр устанавливает порог точности, который дерево должно преодолеть, чтобы из него можно было брать ветки. Использую либо 0, либо минимальный. Параметр особо не влиял, но пока оставил. Фильтр слабых деревьев

  • plot_trees [ = False ] — если True, то в процессе обучения выводит графики с деревьями

А это параметры для построения самого дерева решений, которые по дефолту такие же, как в DecisionTreeClassifier (за исключением criterion, его поменял на entropy):

  • criterion [ = 'entropy' ]

  • splitter [ = 'best' ]

  • min_samples_leaf [ = 1 ]

  • max_depth [ = None ]

  • min_samples_split [ = 2 ]

  • max_leaf_nodes [ = None ]

  • min_weight_fraction_leaf [ = 0 ]

Влияние на результат может оказать параметр splitter, который указывает стратегию разделения на каждом узле. Может быть best или random.

У методов predict и predict_proba есть параметры:

  • remove_duplicates [ = 'n' ] — описал выше, можно не обучать заново модель, а предсказывать, используя отобранные ветки как с дубликатами, так и без

  • exclude — можно указать название признака, чтобы исключить все ветки с ним при предсказании

  • threshold [ = 0.2 ] — у метода predict есть параметр порога отнесения к нужному классу

После обучения модели все условия (правила, ветки) будут в self.conditions (а в self.nbranches — количество таких веток), без полных дубликатов — в self.conditions_a, без дубликатов последовательности признаков — в self.conditions_b.

Обучение модели происходит так:

  1. Среди набора признаков выбираем m (max_features) случайных.

  2. Случайным образом разделяем данные для тренировки и для перекрестной проверки.

  3. Строим дерево, оптимальное разделение ищем среди случайно отобранных признаков.

  4. Применяем данное дерево либо к тем же данным, на которых оно строилось, либо к данным для перекрестной проверки.

  5. Используем функции find_path и get_rule, чтобы извлечь все условия (правила, ветки) из дерева в словарь (rules). Считаем количество объектов положительного класса и их долю для каждого условия (результаты в df_metrics).

  6. Отбираем те правила, которые удовлетворяют всем ограничениям (в self.conditions). 

  7. Повторяем всё n раз (n_estimators ). Да, n_estimators в классе привязан к random_state функции разбиения train_test_split.

Также внутри класса есть функция count_features_in_conditions, которая подсчитывает сколько раз какие признаки встречались в отобранных условиях. Записывает результат в self.feature_count. Потом можно посмотреть частоту использования каждого признака.

Функции find_path и get_rule взял у пользователя vlemaistre (спасибо ему!) на сайте Stack Overflow. А это документация по структуре дерева.

Тестируем!

Для теста возьмем несколько датасетов с Kaggle и сравним Random Branch с базовыми решениями Random Forest. В специфику данных не углублялся.

Первая задача — предсказание оттока клиентов (для скачивания данных нужна регистрация).

Собственно, код для преобразований и параметры для Random Forest взял из этого базового решения:

Код
from sklearn.preprocessing import LabelEncoder
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import precision_recall_curve
from sklearn.model_selection import GridSearchCV
import matplotlib.pyplot as plt


df = pd.read_csv("C:/WA_Fn-UseC_-Telco-Customer-Churn.csv")


def object_to_int(dataframe_series):
    if dataframe_series.dtype == 'object':
        dataframe_series = LabelEncoder().fit_transform(dataframe_series)
    return dataframe_series


df = df.apply(lambda x: object_to_int(x))

x = df.drop(columns=['Churn'])
y = df['Churn'].values

x_train, x_test, y_train, y_test = train_test_split(
    x, y, test_size=0.3, random_state=40, stratify=y)

# Обучение модели Random Forest

model = RandomForestClassifier(n_estimators=1000, oob_score=True, n_jobs=-1,
                               random_state=50,
                               max_leaf_nodes=30)
model.fit(x_train, y_train)

y_pred_proba = model.predict_proba(x_test)[:, 1]

precision_rf, recall_rf, thresholds_rf = precision_recall_curve(
    y_test, y_pred_proba)

fig, ax = plt.subplots()
ax.plot(recall_rf, precision_rf, color='purple')
ax.set_title('Precision-Recall Curve')
ax.set_ylabel('Precision')
ax.set_xlabel('Recall')
plt.show()

 Кривая точности и полноты для Random Forest
Кривая точности и полноты для Random Forest

Теперь попробуем вариант с Random Branch. Но для начала продублируем все признаки со знаком «-», чтобы в случаях отрицательной корреляции с нужным нам классом этот признак тоже мог попасть в правую ветку.

Для этого используем функцию add_rev:

Код
def add_rev(df):
    if not isinstance(df, pd.DataFrame):
        df = pd.DataFrame(df)
        df = df.add_prefix('ft_')

    new_df = df.apply(lambda x: x * -1)
    new_df = new_df.add_prefix('rev_')
    return pd.concat([df, new_df], axis=1)


x_train_rev = add_rev(x_train)
x_test_rev = add_rev(x_test)

Далее обучим Random Branch с дефолтными параметрами, изменил только n_estimators на 10 000. А если использовать include_left = True, то n_estimators можно выставить на 5 000.

Код
model = RandomBranchClassifier(n_estimators=10000, max_leaf_nodes=30)

model.fit(x_train_rev, pd.Series(y_train))

print("Количество условий: ", model.nbranches)
print("Количество условий без полных дубликатов: ", len(model.conditions_a))
print("Количество условий без дубликатов последовательности признаков: ", len(model.conditions_b))

Вот пример дерева, где правая ветка удовлетворяет всем условиям варианта max_right с параметром leaf_threshold = 0.8. Доля больше 80%, а количество объектов положительного класса максимальное среди всех листьев с такой же долей положительного класса.

Пример дерева решений
Пример дерева решений

Теперь предскажем без дубликатов последовательности признаков (вариант b):

Код
y_pred_proba = model.predict_proba(x_test_rev, remove_duplicates='b')[:, 1]

precision_rb, recall_rb, thresholds_rb = precision_recall_curve(
    y_test, y_pred_proba)

fig, ax = plt.subplots()
ax.plot(recall_rf, precision_rf, color='green', label="Random Forest")
ax.plot(recall_rb, precision_rb, color='purple', label="Random Branch")
ax.legend(loc="upper right")
ax.set_title('Precision-Recall Curve')
ax.set_ylabel('Precision')
ax.set_xlabel('Recall')
plt.show()

Кривые точности и полноты для Random Forest и Random Branch
Кривые точности и полноты для Random Forest и Random Branch

При 100% точности заметно подросла полнота. На 10 000 построенных деревьев мы отобрали всего 263 ветки, без дубликатов 228, а без дубликатов последовательности признаков (вариант b) всего 140. В этом ещё одна фишка метода, всего 140 условий, чтобы предсказывать со 100% точностью.

Теперь посмотрим частоту использования признаков:

Код
from IPython.display import display_html 

feature_frequency = pd.DataFrame()
feature_frequency['feature'] = model.features
feature_frequency['counts'] = model.feature_count
feature_frequency['counts'] = feature_frequency['counts'] / model.nbranches

df_rev = feature_frequency[feature_frequency['feature'].str.startswith('rev_')]
df_not_rev = feature_frequency[~feature_frequency['feature'].str.startswith('rev_')]

df1_styler = df_not_rev.style.bar(
    subset=['counts'],
    align='mid',
    color=['coral', 'yellowgreen'],
    vmin=feature_frequency['counts'].min(),
    vmax=feature_frequency['counts'].max()
    ).set_table_attributes("style='display:inline'").set_caption('Direct')

df2_styler = df_rev.style.bar(
    subset=['counts'],
    align='mid',
    color=['coral', 'yellowgreen'],
    vmin=feature_frequency['counts'].min(),
    vmax=feature_frequency['counts'].max()
    ).set_table_attributes("style='display:inline'").set_caption('Reverse') 

display_html(df1_styler._repr_html_()+df2_styler._repr_html_(), raw=True)

Слева исходные, справа продублированные со знаком «-»:

А это пример использования GridSearchCV. Как метрику используем полноту при 100% точности:

Код
model_empty = RandomBranchClassifier()

params = {'n_estimators': [10000],\
          'tree_apply': ['cv'],\
          'cv_size': [0.3,0.5],\
          'pruning_type': ['max_right'],\
          'cv_stratify': [True],\
          'max_leaf_nodes': [30],\
          'leaf_threshold': [0.8],\
          'splitter': ['best'],\
          'remove_duplicates': ['b']}

def recall_prec100_score(model, X, y):
    y_proba = model.predict_proba(X)
    precision, recall, thresholds = precision_recall_curve(y, y_proba[:, 1])
    index = next((idx for idx, val in enumerate(precision) if val == 1), None)
    r_100 = recall[index]
    return r_100

scoring = {"recall_100": recall_prec100_score}

gs = GridSearchCV(model_empty, params, cv = 5, scoring=scoring, verbose = 3, refit="recall_100", n_jobs=-1)

gs.fit(x_train_rev, y_train)

gs.best_params_

Результаты можно отсортировать по метрике, чтобы сразу увидеть топ по параметрам:

Код
cvres = gs.cv_results_
sorted_indices = np.argsort(cvres['mean_test_recall_100'])[::-1]  

for idx in sorted_indices:
    score = cvres['mean_test_recall_100'][idx]
    params = cvres['params'][idx]
    print(round(score, 3), params)

Какие параметры следует смотреть?

  1. 'pruning_type': ['right', 'max_right','max','all']

  2. 'tree_apply': ['train','cv']

  3. 'cv_size': [0.1,0.3,0.5]

  4. 'leaf_threshold': [0.7,0.8,0.9]

  5. 'splitter': ['random','best']

  6. 'remove_duplicates': ['n','a','b']

  7. 'max_features'

  8. 'min_positives'

  9. 'cv_prec_threshold': [0,0.1,0.2...]

  10. 'cv_stratify': [True,False]

  11. 'n_estimators'

Файл с кодом можно скачать тут.

Вторая задача, которую я попробовал, — это предсказание сердечных заболеваний. Базовое решение взял это.

Код приводить уже не буду, проделал то же самое, Random Branch использовал с такими параметрами: n_estimators = 4000, tree_apply = 'cv', pruning_type = 'max_right', cv_stratify = True, leaf_threshold = 0.8. По сути, тоже дефолтные. Предсказание без дубликатов последовательности признаков (вариант b). На 4 000 деревьев получилось 126 условий в варианте b. Почти столько же, сколько в задаче предсказания оттока на 10 000 деревьев.

Кривые точности и полноты для Random Forest и Random Branch
Кривые точности и полноты для Random Forest и Random Branch

Снова выросла полнота при 100% точности.

Random Branch работает с обычными признаками (а не только как стэкинг), потому что зачастую у нас есть связь: чем больше (или меньше) значение признака, тем больше вероятность какого-либо класса. Бывает, что даже один признак способен разделить данные так, что мы будем классифицировать несколько наблюдений со 100% точностью. Сочетая признаки, мы увеличиваем силу классификации.

Но особенность данного алгоритма в том, что мы концентрируемся только на краях пространства признаков, исходя из предположения, что там больше всего наблюдений, которые можно отличить со 100% точностью. И, как показывает практика, это предположение себя оправдывает.

Мне этот способ напоминает снятие пенки.

Комментарии (4)


  1. ValeriyPushkarev
    16.03.2024 01:10

    заметно выросла как точность (precision), так и полнота (recall).


    1. iwtkl Автор
      16.03.2024 01:10

      В исходной задаче, где в числе признаков были выводы других моделей, см. первый график.


      1. ValeriyPushkarev
        16.03.2024 01:10

        Вы используете субоптимальный алгоритм.

        Я как-то раз предлагал сделать улучшения в стиле IsAppliciable - т.е. если вы можете быстро проверить что ваш алгоритм может немного улучшить результат - проверяйте, применяйте.

        Правда, толка почти не будет.


  1. katorovalexey
    16.03.2024 01:10

    Интересный подход. Можно будет потестировать. Еще бы посмотреть на сравнение с бустингами