Конформные прогнозы для классификации машинного обучения — от начала до конца

Точные прогнозы для классификации в машинном обучении — полное руководство

Реализация конформального прогнозирования для классификации без необходимости использования специальных пакетов

Этот блог-пост вдохновлен книгой Криса Молнера — Введение в конформальное прогнозирование на Python. Крис великолепно делает новые методы машинного обучения доступными для других. Я бы также рекомендовал его книги по Объясняемому машинному обучению.

Полный код можно найти в репозитории GitHub по ссылке: Конформальное прогнозирование.

Что такое конформальное прогнозирование?

Конформальное прогнозирование является методом количественной оценки неопределенности и методом классификации объектов (который может быть настроен для классов или подгрупп). Неопределенность передается в виде классификации в наборы потенциальных классов вместо однозначных прогнозов.

Конформальное прогнозирование определяет покрытие, которое задает вероятность покрытия истинного результата прогнозируемой областью. Трактовка прогнозируемых областей в конформальном прогнозировании зависит от задачи. В случае классификации мы получаем наборы прогнозов, в то время как в случае регрессии мы получаем прогнозные интервалы.

Ниже приведен пример разницы между ‘традиционной’ классификацией (основанной на наиболее вероятном классе) и конформальным прогнозированием (наборы клаассов).

Разница между 'традиционной' классификацией на основе наиболее вероятного класса и конформальным прогнозированием, которое создает наборы возможных классов.

Преимущества этого метода:

Гарантированное покрытие: Прогнозные наборы, созданные конформным прогнозированием, обеспечивают гарантию покрытия истинного результата — то есть они обнаружат любой процент истинных значений, который вы установите в качестве минимальной цели покрытия. Конформное прогнозирование не зависит от правильно настроенной модели — единственное, что важно, это то, чтобы, как и для всех методов машинного обучения, новые классифицируемые образцы должны происходить из похожих распределений данных, как у обучающих и калибровочных данных. Покрытие также может быть гарантировано для классов или подгрупп, но для этого требуется дополнительный шаг, который мы рассмотрим.

  • Простота использования: Методы конформного прогнозирования могут быть реализованы с нуля всего за несколько строк кода, как мы сделаем здесь.
  • Не зависит от модели: Конформное прогнозирование работает с любой моделью машинного обучения. Оно использует обычные выходы выбранной вами модели.
  • Независимость от распределений: Конформное прогнозирование не делает никаких предположений о подлежащих распределениях данных; это непараметрический метод.
  • Не требуется повторное обучение: Конформное прогнозирование можно использовать без повторного обучения модели. Это еще один способ рассмотрения и использования результатов модели.
  • Широкое применение: Конформное прогнозирование работает для классификации табличных данных, классификации изображений или временных рядов, регрессии и многих других задач, хотя мы продемонстрируем только классификацию здесь.

Почему нам следует обратить внимание на оценку неопределенности?

Оценка неопределенности является важной во многих ситуациях:

  • Когда мы используем прогнозы модели для принятия решений. Насколько уверены мы в этих прогнозах? Достаточно ли использовать только «наиболее вероятный класс» для нашей задачи?
  • Когда мы хотим передать неопределенность, связанную с нашими прогнозами, заинтересованным сторонам, не упоминая о вероятностях или шансах, или даже о логарифмах шансов!

Альфа в конформальном прогнозировании — описывает покрытие

Покрытие является ключевым понятием в конформном прогнозировании. В случае классификации это обычная область данных, которую занимает определенный класс. Покрытие эквивалентно чувствительности или полноте; это доля наблюдаемых значений, которые идентифицируются в наборах классификации. Мы можем увеличивать или уменьшать область покрытия, регулируя 𝛼 (покрытие = 1 — 𝛼).

Поехали кодить!

Импорт пакетов

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.datasets import make_blobs
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

Создание синтетических данных для классификации

Пример данных будет создан с использованием метода `make_blobs` библиотеки SK-Learn.

n_classes = 3
# Создание обучающего и тестового наборов данных
X, y = make_blobs(n_samples=10000, n_features=2, centers=n_classes, cluster_std=3.75, random_state=42)
# Уменьшение размера первого класса для создания несбалансированного набора данных
# Задание случайного семени numpy
np.random.seed(42)
# Получение индекса, когда y равно классу 0
class_0_idx = np.where(y == 0)[0]
# Получение 30% индексов класса 0
class_0_idx = np.random.choice(class_0_idx, int(len(class_0_idx) * 0.3), replace=False)
# Получение индекса всех остальных классов
rest_idx = np.where(y != 0)[0]
# Объединение индексов
idx = np.concatenate([class_0_idx, rest_idx])
# Перемешивание индексов
np.random.shuffle(idx)
# Разделение данных
X = X[idx]
y = y[idx]
# Разделение на обучающий и тестовый наборы
X_train, X_rest, y_train, y_rest = train_test_split(X, y, test_size=0.5, random_state=42)
# Разделение остальных на калибровочный и тестовый наборы
X_Cal, X_test, y_cal, y_test = train_test_split(X_rest, y_rest, test_size=0.5, random_state=42)
# Задание меток класса
class_labels = ['синий', 'оранжевый', 'зеленый']

# Визуализация данных
fig = plt.subplots(figsize=(5, 5))
ax = plt.subplot(111)
for i in range(n_classes):
    ax.scatter(X_test[y_test == i, 0], X_test[y_test == i, 1], label=class_labels[i], alpha=0.5, s=10)
legend = ax.legend()
legend.set_title("Класс")
ax.set_xlabel("Признак 1")
ax.set_ylabel("Признак 2")
plt.show()
Сгенерированные данные (данные созданы таким образом, чтобы быть несбалансированными — синий класс имеет только около 30% точек данных по сравнению с зеленым или оранжевым классами).

Построение классификатора

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

# Создание и обучение классификатора
classifier = LogisticRegression(random_state=42)
classifier.fit(X_train, y_train)
# Тестирование классификатора
y_pred = classifier.predict(X_test)
accuracy = np.mean(y_pred == y_test)
print(f"Точность: {accuracy:0.3f}")
# Тестирование полноты (полнота для каждого класса)
for i in range(n_classes):
    recall = np.mean(y_pred[y_test == i] == y_test[y_test == i])
    print(f"Полнота для класса {class_labels[i]}: {recall:0.3f}")

Точность: 0.930
Полнота для класса синий: 0.772
Полнота для класса оранжевый: 0.938
Полнота для класса зеленый: 0.969

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

S_i, или непозволительный коэффициент score

В полярной прогнозе непоответствие, часто обозначаемое как s_i, является мерой того, насколько новый экземпляр отклоняется от существующих экземпляров в обучающем наборе. Он используется для определения принадлежности нового экземпляра определенному классу.

В контексте классификации наиболее распространенной мерой непослушания является 1 — вероятность предсказанного класса для данной метки. Таким образом, если предсказанная вероятность принадлежности нового экземпляра к определенному классу высока, то непослушание будет низким, и наоборот.

Для конформального прогнозирования мы получаем оценки s_i для всех классов (обратите внимание: мы смотрим только на выходную модели для истинного класса экземпляра, даже если у него есть более высокая предсказанная вероятность быть другим классом). Затем мы находим порог оценок, содержащий (или охватывает) 95% данных. Классификация затем определит 95% новых экземпляров (при условии, что наши новые данные похожи на наши обучающие данные).

Расчет порога конформального прогнозирования

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

# Получить предсказания для калибровочного набора данных
y_pred = classifier.predict(X_Cal)
y_pred_proba = classifier.predict_proba(X_Cal)
# Показать первые 5 экземпляров
y_pred_proba[0:5]

массив([
    [4.65677826e-04, 1.29602253e-03, 9.98238300e-01],
    [1.73428257e-03, 1.20718182e-02, 9.86193899e-01],
    [2.51649788e-01, 7.48331668e-01, 1.85434981e-05],
    [5.97545130e-04, 3.51642214e-04, 9.99050813e-01],
    [4.54193815e-06, 9.99983628e-01, 1.18300819e-05]
])

Расчет оценок несоответствия:

Мы будем рассчитывать оценки s_i только на основе вероятностей, связанных с исследуемым классом. Для каждого экземпляра мы получим предсказанную вероятность для класса этого экземпляра. Оценка s_i (несоответствие) равна 1 минус вероятность. Чем выше оценка s_i, тем меньше этот пример соответствует этому классу по сравнению с другими классами.

si_scores = []
# Проходим по всем экземплярам калибровочного набора
for i, true_class in enumerate(y_cal):
    # Получаем предсказанную вероятность для наблюдаемого/истинного класса
    predicted_prob = y_pred_proba[i][true_class]
    si_scores.append(1 - predicted_prob)
# Преобразуем в массив NumPy
si_scores = np.array(si_scores)
# Показать первые 5 экземпляров
si_scores[0:5]

массив([
    1.76170035e-03,
    1.38061008e-02,
    2.51668332e-01,
    9.49187344e-04,
    1.63720201e-05
])

Получение порога 95-го процентиля:

Порог определяет охват нашей классификации. Охват относится к доле прогнозов, которые действительно содержат истинный результат.

Порог – это процентиль, соответствующий 1 – 𝛼. Для получения охвата 95% мы устанавливаем 𝛼 равным 0,05.

При использовании в реальной жизни уровень квантиля (на основе 𝛼) требует поправки для расчета соответствующего квантиля 𝑞. Мы умножаем 0,95 на $(n+1)/n$, что означает, что 𝑞𝑙𝑒𝑣𝑒𝑙 будет равен 0,951 для n = 1000.

number_of_samples = len(X_Cal)
alpha = 0.05
qlevel = (1 - alpha) * ((number_of_samples + 1) / number_of_samples)
threshold = np.percentile(si_scores, qlevel*100)
print(f'Порог: {threshold:0.3f}')

Порог: 0.598

Показать диаграмму значений s_i, с порогом отсечения.

x = np.arange(len(si_scores)) + 1
sorted_si_scores = np.sort(si_scores)
index_of_95th_percentile = int(len(si_scores) * 0.95)
# Цвет по отсечке
conform = 'g' * index_of_95th_percentile
nonconform = 'r' * (len(si_scores) - index_of_95th_percentile)
color = list(conform + nonconform)
fig = plt.figure(figsize=((6,4)))
ax = fig.add_subplot()
# Добавить столбцы
ax.bar(x, sorted_si_scores, width=1.0, color = color)
# Добавить линии для 95-го процентиля
ax.plot([0, index_of_95th_percentile],[threshold, threshold],
         c='k', linestyle='--')
ax.plot([index_of_95th_percentile, index_of_95th_percentile], [threshold, 0],
        c='k', linestyle='--')
# Добавить текст
txt = 'Порог соответствия на 95-ом процентиле'
ax.text(5, threshold + 0.04, txt)
# Добавить подписи осей
ax.set_xlabel('Образец экземпляра (сортировано по $s_i$)')
ax.set_ylabel('$S_i$ (несоответствие)')
plt.show()
s_i оценки для всех данных. Порог представляет собой уровень s_i, содержащий 95% всех данных (если 𝛼 установлено на уровне 0,05).

Получить образцы/классы из тестового набора, классифицированные как положительные

Теперь мы можем найти все модельные выводы, которые меньше порога.

У индивидуального примера может не быть значения или может быть более одного значения, ниже порога.

prediction_sets = (1 - classifier.predict_proba(X_test) <= threshold)# Показать первые десять экземпляровprediction_sets[0:10]

array([[ True, False, False],       [False, False,  True],       [ True, False, False],       [False, False,  True],       [False,  True, False],       [False,  True, False],       [False,  True, False],       [ True,  True, False],       [False,  True, False],       [False,  True, False]])

Получить метки набора предсказаний и сравнить с стандартной классификацией.

# Получить стандартные прогнозыy_pred = classifier.predict(X_test)# Функция для получения меток набора предсказанийdef get_prediction_set_labels(prediction_set, class_labels):    # Получить набор меток классов для каждого экземпляра в наборах предсказаний    prediction_set_labels = [        set([class_labels[i] for i, x in enumerate(prediction_set) if x]) for prediction_set in         prediction_sets]    return prediction_set_labels# Собрать прогнозыresults_sets = pd.DataFrame()results_sets['наблюдаемые'] = [class_labels[i] for i in y_test]results_sets['метки'] = get_prediction_set_labels(prediction_sets, class_labels)results_sets['классификации'] = [class_labels[i] for i in y_pred]results_sets.head(10)

   наблюдаемые  метки             классификации0  синий      {синий}           синий1  зеленый    {зеленый}         зеленый2  синий      {синий}           синий3  зеленый    {зеленый}         зеленый4  оранжевый  {оранжевый}       оранжевый5  оранжевый  {оранжевый}       оранжевый6  оранжевый  {оранжевый}       оранжевый7  оранжевый  {синий, оранжевый}синий8  оранжевый  {оранжевый}       оранжевый9  оранжевый  {оранжевый}       оранжевый

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

Построение графика, показывающего экземпляр 7, который, возможно, находится в 2 классах:

# Построение данныхfig = plt.subplots(figsize=(5, 5))ax = plt.subplot(111)for i in range(n_classes):    ax.scatter(X_test[y_test == i, 0], X_test[y_test == i, 1],               label=class_labels[i], alpha=0.5, s=10)# Добавление экземпляра 7набор_меток = results_sets['метки'].iloc[7]ax.scatter(X_test[7, 0], X_test[7, 1], color='k', s=100, marker='*', label=f'Экземпляр 7')legend = ax.legend()legend.set_title("Класс")ax.set_xlabel("Характеристика 1")ax.set_ylabel("Характеристика 2")txt = f"Набор прогнозов для экземпляра 7: {набор_меток}"ax.text(-20, 18, txt)plt.show()
Диаграмма рассеяния, показывающая, как тестовый экземпляр 7 был классифицирован как принадлежащий к двум возможным наборам: {'синий', 'оранжевый'},

Показать покрытие и средний размер набора

Покрытие – это доля наборов предсказаний, действительно содержащих истинный результат.

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

Мы определим несколько функций для расчета результатов.

# Получить количество классов
def get_class_counts(y_test):
    class_counts = []
    for i in range(n_classes):
        class_counts.append(np.sum(y_test == i))
    return class_counts

# Получить покрытие для каждого класса
def get_coverage_by_class(prediction_sets, y_test):
    coverage = []
    for i in range(n_classes):
        coverage.append(np.mean(prediction_sets[y_test == i, i]))
    return coverage

# Получить средний размер набора для каждого класса
def get_average_set_size(prediction_sets, y_test):
    average_set_size = []
    for i in range(n_classes):
        average_set_size.append(
            np.mean(np.sum(prediction_sets[y_test == i], axis=1)))
    return average_set_size     

# Получить взвешенное покрытие (взвешенное по размеру класса)
def get_weighted_coverage(coverage, class_counts):
    total_counts = np.sum(class_counts)
    weighted_coverage = np.sum((coverage * class_counts) / total_counts)
    weighted_coverage = round(weighted_coverage, 3)
    return weighted_coverage

# Получить взвешенный размер набора (взвешенный по размеру класса)
def get_weighted_set_size(set_size, class_counts):
    total_counts = np.sum(class_counts)
    weighted_set_size = np.sum((set_size * class_counts) / total_counts)
    weighted_set_size = round(weighted_set_size, 3)
    return weighted_set_size

Показать результаты для каждого класса.

results = pd.DataFrame(index=class_labels)
results['Количество классов'] = get_class_counts(y_test)
results['Покрытие'] = get_coverage_by_class(prediction_sets, y_test)
results['Средний размер набора'] = get_average_set_size(prediction_sets, y_test)
results

        Количество классов  Покрытие   Средний размер набораblue    241                  0.817427   1.087137orange  848                  0.954009   1.037736green   828                  0.977053   1.016908

Показать общие результаты.

weighted_coverage = get_weighted_coverage(
    results['Покрытие'], results['Количество классов'])
weighted_set_size = get_weighted_set_size(
    results['Средний размер набора'], results['Количество классов'])
print (f'Общее покрытие: {weighted_coverage}')
print (f'Средний размер набора: {weighted_set_size}')

Общее покрытие: 0.947
Средний размер набора: 1.035

ПРИМЕЧАНИЕ: Хотя наше общее покрытие близко к 95%, покрытие различных классов изменяется и самое низкое (83%) для нашего наименьшего класса. Если покрытие отдельных классов важно, мы можем устанавливать пороги для классов независимо друг от друга, что мы и сделаем.

Соответствующая классификация с одинаковым покрытием для всех классов

Когда мы хотим быть уверены в покрытии всех классов, мы можем устанавливать пороги для каждого класса независимо.

Примечание: мы также можем делать это для подгрупп данных, например, чтобы обеспечить равное покрытие для диагностики в различных этнических группах, если мы обнаружим проблемы с покрытием при использовании общего порога.

Получить пороги для каждого класса независимо

# Установить альфа (1 - покрытие)
alpha = 0.05
thresholds = []

# Получить предсказанные вероятности для калибровочного набора
y_cal_prob = classifier.predict_proba(X_Cal)

# Получить 95-й процентиль оценки для s-оценок каждого класса
for class_label in range(n_classes):
    mask = y_cal == class_label
    y_cal_prob_class = y_cal_prob[mask][:, class_label]
    s_scores = 1 - y_cal_prob_class
    q = (1 - alpha) * 100
    class_size = mask.sum()
    correction = (class_size + 1) / class_size
    q *= correction
    threshold = np.percentile(s_scores, q)
    thresholds.append(threshold)

print(thresholds)

[0.9030202125697161, 0.6317149025299887, 0.26033562285411]

Применить классификацию с конкретным порогом к каждому классу

# Получить оценки Si для тестового набора
predicted_proba = classifier.predict_proba(X_test)
si_scores = 1 - predicted_proba

# Для каждого класса проверить, находится ли каждый экземпляр ниже порога
prediction_sets = []
for i in range(n_classes):
    prediction_sets.append(si_scores[:, i] <= thresholds[i])

prediction_sets = np.array(prediction_sets).T

# Получить метки набора предсказаний и показать первые 10
prediction_set_labels = get_prediction_set_labels(prediction_sets, class_labels)

# Получить стандартные предсказания
y_pred = classifier.predict(X_test)

# Объединить предсказания
results_sets = pd.DataFrame()
results_sets['Наблюдаемый'] = [class_labels[i] for i in y_test]
results_sets['Метки'] = get_prediction_set_labels(prediction_sets, class_labels)
results_sets['Классификация'] = [class_labels[i] for i in y_pred]

# Показать первые 10 результатов
results_sets.head(10)

  Наблюдаемый  Метки           Классификация
0 blue          {blue}           blue
1 green         {green}          green
2 blue          {blue}           blue
3 green         {green}          green
4 orange        {orange}         orange
5 orange        {orange}         orange
6 orange        {orange}         orange
7 orange        {blue, orange}  blue
8 orange        {orange}         orange
9 orange        {orange}         orange

Проверка покрытия и установка размеров для всех классов

У нас теперь примерно 95% покрытия для всех классов. Метод конформного предсказания обеспечивает более высокое покрытие для меньшинственного класса, чем стандартный метод классификации.

results = pd.DataFrame(index=class_labels)results['Количество классов'] = get_class_counts(y_test)results['Покрытие'] = get_coverage_by_class(prediction_sets, y_test)results['Средний размер набора'] = get_average_set_size(prediction_sets, y_test)results

        Количество классов  Покрытие   Средний размер наборасиний    241                  0.954357   1.228216оранжевый  848                  0.956368   1.139151зелёный    828                  0.942029   1.006039

взвешенное_покрытие = get_weighted_coverage(    results['Покрытие'], results['Количество классов'])взвешенный_размер_набора = get_weighted_set_size(    results['Средний размер набора'], results['Количество классов'])print (f'Общее покрытие: {взвешенное_покрытие}')print (f'Средний размер набора: {взвешенный_размер_набора}')

Общее покрытие: 0.95Средний размер набора: 1.093

Резюме

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

Когда важно, чтобы все классы были обнаружены с одним и тем же покрытием, порог для классификации экземпляров можно устанавливать отдельно (этот метод также может использоваться для подгрупп данных, например, чтобы гарантировать одинаковое покрытие для разных этнических групп).

Конформное предсказание не изменяет предсказания модели. Оно просто использует их по-другому, чем традиционная классификация. Оно может использоваться вместе с более традиционными методами.

(Все изображения принадлежат автору)