Как создать настраиваемую функцию оценки в scikit-learn для оценки набора экземпляров на основе их индивидуальных свойств?

Я пытаюсь выполнить GridSearchCV для оптимизации гиперпараметров моего классификатора, это должно быть сделано путем оптимизации пользовательской функции оценки. Проблема в том, что функция оценки назначается на определенную стоимость, которая отличается для каждого экземпляра (стоимость также является характеристикой каждого экземпляра). Как показано в примере ниже, необходим еще один массив test_amt, который содержит стоимость каждого экземпляра (в дополнение к "нормальной" функции оценки, которая просто получает y и y_pred.

    def calculate_costs(y_test, y_test_pred, test_amt):
    cost = 0

    for i in range(1, len(y_test)):
        y = y_test.iloc[i]
        y_pred = y_test_pred.iloc[i]
        x_amt = test_amt.iloc[i]

        if y == 0 and y_pred == 0:
            cost -= x_amt * 1.1
        elif y == 0 and y_pred == 1:
            cost += x_amt
        elif y == 1 and y_pred == 0:
            cost += x_amt * 1.1
        elif y == 1 and y_pred == 1:
            cost += 0
        else:
            print("ERROR! No cost could be assigned to the instance: " + str(i))
    return cost

Когда я вызываю эту функцию после обучения с тремя массивами, она прекрасно рассчитывает общую стоимость, полученную в результате модели. Однако интегрировать это в GridSearchCV сложно, потому что функция оценки ожидает только два параметра. Хотя есть возможность передавать дополнительные kwargs партнеру, я понятия не имею, как передать подмножество, зависящее от разделения, над которым в данный момент работает GridSearchCV.

Что я пробовал / пока пробовал:

  1. Обертывание всего конвейера в классе с глобально сохраненным объектом pandas.Series, который хранит стоимость каждого экземпляра с индексом. Тогда теоретически можно будет ссылаться на стоимость экземпляра, вызывая его с тем же индексом. К сожалению, это не работает, так как Scikit Learn превращает все в массив.

    def calculate_costs_class(y_test, y_test_pred):
    cost = 0
    for index, _ in y_test.iteritems():
        y = y_test.loc[index]
        y_pred = y_test_pred.loc[index]
        x_amt = self.test_amt.loc[index]
    
        if y == 0 and y_pred == 0:
            cost += (x_amt * (-1)) + 5 + (x_amt * 0.1)  # -revenue, +shipping, +fees
        elif y == 0 and y_pred == 1:
            cost += x_amt  # +revenue
        elif y == 1 and y_pred == 0:
            cost += x_amt + 5 + (x_amt * 0.1) + 5  # +revenue, +shipping, +fees, +charge cost
        elif y == 1 and y_pred == 1:
            cost += 0  # nothing
        else:
            print("ERROR! No cost could be assigned to the instance: " + str(index))
    return cost
    
  2. Создание пользовательского класса PseudoInt, то есть типа данных метки, который наследует все свойства от int, но также может хранить стоимость экземпляра (сохраняя все его свойства для применения логических операций). Хотя даже это будет работать вне Scikit Learn, метод check_classification_targets в scikit learn вызывает ValueError: Unknown type label: error: unknown.

    class PseudoInt(int):
        def __new__(cls, x, cost, *args, **kwargs):
            instance = int.__new__(cls, x, *args, **kwargs)
            instance.cost = cost
            return instance
    
  3. Я не пробовал, но подумал: поскольку стоимость также является функцией в наборе экземпляров X, она также доступна в методе__call__ класса _PredictScorer(_BaseScorer) в scorer.py в Scikit. Если бы я перепрограммировал функцию вызова, чтобы также передать массив затрат как подмножество X в score_func, у меня также была бы стоимость.

  4. Или: я мог бы просто реализовать все сам.

Ребята, у вас есть какие-нибудь "более простые" решения? Спасибо!

1 ответ

Решение

Я нашел способ решить проблему, пройдя по пути 2-го предложенного ответа: передача PseudoInteger в Scikit-Learn, который имеет все те же свойства, что и обычное целое число, при сравнении или выполнении математических операций. Однако он также действует как оболочка для int, и переменные экземпляра (например, стоимость экземпляра) также могут храниться. Как уже говорилось в этом вопросе, это заставляет Scikit-learn распознавать, что значения внутри переданного массива меток фактически являются объектами типа, а не int. Поэтому я просто заменил тест в методе type_of_target(y) файла multiclass.py в Scikit-Learn в строке 273, чтобы он возвращал "двоичный", даже если он не прошел тест. Так что Scikit-Learn просто рассматривает всю проблему (как и должно быть) как проблему бинарной классификации. Поэтому строка 269-273 в методе type_of_target(y) в multiclass.py теперь выглядит следующим образом:

# Invalid inputs
if y.ndim > 2 or (y.dtype == object and len(y) and
                  not isinstance(y.flat[0], string_types)):
    # return 'unknown'  # [[[1, 2]]] or [obj_1] and not ["label_1"]
    return 'binary' # Sneaky, modified to force binary classification.

Мой код выглядит так:

import sklearn
import sklearn.model_selection
import sklearn.base
import sklearn.metrics
import numpy as np
import sklearn.tree
import sklearn.feature_selection
from sklearn.model_selection import GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.metrics.scorer import make_scorer


class PseudoInt(int):
    # Behaves like an integer, but is able to store instance variables
    pass


def grid_search(x, y_normal, x_amounts):
    # Change the label set to a np array containing pseudo ints with the costs associated with the instances
    y = np.empty(len(y_normal), dtype=PseudoInt)
    for index, value in y_normal.iteritems():
        new_int = PseudoInt(value)
        new_int.cost = x_amounts.loc[index]  # Here the cost is added to the label
        y[index] = new_int

    # Normal train test split
    x_train, x_test, y_train, y_test = sklearn.model_selection.train_test_split(x, y, test_size=0.2)

    # Classifier
    clf = sklearn.tree.DecisionTreeClassifier()

    # Custom scorer with the cost function below (lower cost is better)
    cost_scorer = make_scorer(cost_function, greater_is_better=False)  # Custom cost function (Lower cost is better)

    # Define pipeline
    pipe = Pipeline([('clf', clf)])

    # Grid search grid with any hyper parameters or other settings
    param_grid = [
        {'sfs__estimator__criterion': ['gini', 'entropy']}
    ]

    # Grid search and pass the custom scorer function
    gs = GridSearchCV(estimator=pipe,
                      param_grid=param_grid,
                      scoring=cost_scorer,
                      n_jobs=1,
                      cv=5,
                      refit=True)

    # run grid search and refit with best hyper parameters
    gs = gs.fit(x_train.as_matrix(), y_train)
    print("Best Parameters: " + str(gs.best_params_))
    print('Best Accuracy: ' + str(gs.best_score_))

    # Predict with retrained model (with best parameters)
    y_test_pred = gs.predict(x_test.as_matrix())

    # Get scores (also cost score)
    get_scores(y_test, y_test_pred)


def get_scores(y_test, y_test_pred):
    print("Getting scores")

    print("SCORES")
    precision = sklearn.metrics.precision_score(y_test, y_test_pred)
    recall = sklearn.metrics.recall_score(y_test, y_test_pred)
    f1_score = sklearn.metrics.f1_score(y_test, y_test_pred)
    accuracy = sklearn.metrics.accuracy_score(y_test, y_test_pred)
    print("Precision      " + str(precision))
    print("Recall         " + str(recall))
    print("Accuracy       " + str(accuracy))
    print("F1_Score       " + str(f1_score))

    print("COST")
    cost = cost_function(y_test, y_test_pred)
    print("Cost Savings   " + str(-cost))

    print("CONFUSION MATRIX")
    cnf_matrix = sklearn.metrics.confusion_matrix(y_test, y_test_pred)
    cnf_matrix = cnf_matrix.astype('float') / cnf_matrix.sum(axis=1)[:, np.newaxis]
    print(cnf_matrix)


def cost_function(y_test, y_test_pred):
    """
    Calculates total cost based on TP, FP, TN, FN and the cost of a certain instance
    :param y_test: Has to be an array of PseudoInts containing the cost of each instance
    :param y_test_pred: Any array of PseudoInts or ints
    :return: Returns total cost
    """
    cost = 0

    for index in range(len(y_test)):
        # print(index)
        y = y_test[index]
        y_pred = y_test_pred[index]
        x_amt = y.cost

        if y == 0 and y_pred == 0:
            cost -= x_amt # Reducing cot by x_amt
        elif y == 0 and y_pred == 1:
            cost += x_amt  # Wrong classification adds cost
        elif y == 1 and y_pred == 0:
            cost += x_amt + 5 # Wrong classification adds cost and fee
        elif y == 1 and y_pred == 1:
            cost += 0  # No cost
        else:
            raise ValueError("No cost could be assigned to the instance: " + str(index))

    # print("Cost: " + str(cost))
    return cost

ОБНОВИТЬ

Вместо того, чтобы изменять файлы в пакете напрямую (что выглядит довольно грязно), я теперь добавил в первые строки импорта моего проекта:

import sklearn.utils.multiclass

def return_binary(y):
    return "binary"

sklearn.utils.multiclass.type_of_target = return_binary

Это переписывает метод type_of_tartget(y) в sklearn.utils.multiclass, чтобы всегда возвращать двоичный файл. Обратите внимание, что он должен быть впереди всего остального импорта sklearn.

Другие вопросы по тегам