Введение в машинное обучение

Два пути¶

Допустим, у нас есть наручный шагомер, который фиксирует перемещения в пространстве. В него встроен акселерометр, который способен фиксировать перемещения по трем осям. На выходе мы получаем сигнал с трёх датчиков.

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

Вариант №1, Классический

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

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

Программу будет сложнее поддерживать из-за большого объема кода в ней. Изменение в одной из частей потребует внесение правок в другой код и т.п.

Вариант №2, Машинное обучение

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

Фактически, мы приближаем реальную функцию $F_{real}$ некоторой функцией $F$: $$\large {F = \sum_i w_{i} x_{i} + b}$$

По сути, модели всё равно, что считать: шаги, сердечный ритм, количество калорий, ударов по клавиатуре и пр. Нет необходимости писать под каждый пример отдельную программу: достаточно собрать данные, и мы сможем решить множество абсолютно разных задач.

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

Задача курса¶

AI, ML, DL¶

Место глубокого обучения и нейронных сетей в ИИ

Искусственный интеллект (AI/ИИ) — область IT/Computer science, связанная с моделированием интеллектуальных или творческих видов человеческой деятельности.

Машинное обучение (ML) — подраздел ИИ, связанный с разработкой алгоритмов и статистических моделей, которые компьютерные системы используют для выполнения задач без явных инструкций.

Глубокое обучение (Deep Learning, DL) — совокупность методов машинного обучения, основанных на искуcственных нейронных сетях и обучении представлениям (feature/representation learning). Данный класс методов автоматически выделяет из необработанных данных необходимые признаки (представления), в отличие от методов ML, в которых признаки создают люди вручную (feature engineering).

Существует множество определений сильного и слабого ИИ, рассуждений о появлении искусственного сознания и восстании машин.

Всё намного приземлённее. Есть набор объектов $X$, набор ответов $Y$. Пары "объект-ответ" составляют обучающую выборку.

Мы будем заниматься восстановлением решающей функции $F$, которая переводит признаки $X$, описывающие объекты, в ответы $Y$.

$$ F: X \xrightarrow\ Y $$

Позже мы уточним постановку задачи и увидим, что функцию восстанавливаем с погрешностью, в каких-то задачах нет ответов $Y$, а где-то мы создаём новые объекты ${\hat X}$ на основе исходных объектов $X$.

Области применения¶

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

  • компьютерное зрение (Computer Vision, CV);
  • распознавание и анализ речи (NLP, извлечение смысла, Speech recognition, машинный перевод).

Связь с наукой¶

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

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

В течение 15-ти лекций мы будем рассказывать теорию и практиковаться, далее плотно займёмся научной работой. Хотя, в целом, её можно начинать уже прямо сейчас.

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

Обзор курса¶

Лекция 1 Введение в Машинное Обучение

Зачем:

  • Показать шаблон решения задач

Что будет:

  • База
  • Инструменты
  • Работа с данными
  • Оценка и валидация результатов
Лекция 2 Линейные модели

Зачем:

  • Сделать базовое решение, применимое для небольшого количества данных

Что будет:

  • Линейная модель — основа для будущих нейронных сетей
  • Градиентный спуск — учимся учить
Лекция 3 Классическое машинное обучение

Зачем:

  • Научиться полностью проходить пайплайн решения задачи с помощью МЛ

Что будет:

  • Разведочный анализ
  • Деревья
  • Бустинги
  • Ансамбли моделей

Лекция 4 Генерация и отбор признаков

Зачем:

  • Научиться выбирать оптимальный набор признаков

Что будет:

  • Анализ важности
  • Генерация признаков
  • Методы понижения размерности
  • Визуализация многомерных данных
Лекция 5 Нейронные сети

Зачем:

  • Научиться писать и учить простые нейросети

Что будет:

  • Основные блоки нейросетей
  • Способы обучения
  • Выбор параметров моделей
Лекция 6 Свёрточные сети

Зачем:

  • Научиться решать задачи компьютерного зрения

Что будет:

  • Всевозможные виды свёрток
  • Выбор параметров моделей
  • Хитрости работы с картинками
Лекция 7 Оптимизация нейронных сетей

Зачем:

  • Научиться учить сети, если они не учатся

Что будет:

  • Поиск проблем и их решений
  • Улучшение сходимости
  • Советы и практика
Лекция 8 Архитектуры нейронных сетей

Зачем:

  • Научиться выбирать оптимальную архитектуру

Что будет:

  • Подходы к построению архитектур
  • Лучшие сети и их особенности
  • Библиотеки моделей
Лекция 9 Рекуррентные нейронные сети

Зачем:

  • Научиться обрабатывать последовательности

Что будет:

  • Обработка временных рядов
  • NLP классический
  • NLP нейросетевой
Source: Deep Learning Model for Selecting Suitable Requirements Elicitation Techniques
Лекция 10 Трансформеры

Зачем:

  • Научиться решать задачи NLP и компьютерного зрения ещё круче

Что будет:

  • Готовые трансформеры
  • Дообучение под задачу
  • Проблемы и пути решения
Source: Transformers: The rise and rise of Hugging Face
Лекция 11 Сегментация и детектирование

Зачем:

  • Научиться решать продвинутые задачи компьютерного зрения

Что будет:

  • Продвинутая работа с изображениями
  • Крутые решения "из коробки"
  • Написание своих архитектур
Лекция 12 Representation learning

Зачем:

  • Научиться решать задачи, когда данных мало, или когда постоянно появляются новые сущности

Что будет:

  • Векторные представления данных
  • Новые архитектурные подходы
  • Векторная арифметика
Source: Generating Large Images from Latent Vectors
Лекция 13 Генеративные сети

Зачем:

  • Научиться создавать новые данные

Что будет:

  • Новые архитектуры и подходы
  • Приложения на практике
  • Работа не с картинками
Лекция 14 Explainability

Зачем:

  • Научиться вскрывать и анализировать "черные ящики"

Что будет:

  • Методы для разных типов данных
  • Готовые решения
  • Практика
Лекция 15 Обучение с подкреплением

Зачем:

  • Научиться ̶з̶а̶х̶в̶а̶т̶ы̶в̶а̶т̶ь̶ ̶м̶и̶р̶ управлять роботами

Что будет:

  • Ключевые подходы к задаче
  • Готовые модули для вашего решения
  • Применение для более классических задач
Source: Learning Acrobatics by Watching YouTube. Xue Bin (Jason) Peng and Angjoo Kanazawa

Задачи¶

Базовые¶

Классификация¶

В общем случае задача классификации выглядит следующим образом.

Классификация — отнесение образца к одному из нескольких попарно не пересекающихся множеств.

В качестве образцов могут выступать различные по своей природе объекты, например:

  • символы текста,
  • изображения,
  • звуки.

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

Регрессия¶

Способности нейронной сети к прогнозированию напрямую следуют из её способности к обобщению и выделению скрытых зависимостей между входными и выходными данными. После обучения сеть способна предсказать будущее значение некой последовательности на основе нескольких предыдущих значений и (или) каких-то существующих в настоящий момент факторов.

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

Кластеризация¶

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

Относится к задачам обучения без учителя.

Комбинированные задачи¶

Существуют работы, которые комбинируют в себе несколько задач разом. Типичным примером является задача Object Detection.

Детектирование = Классификация + Регрессия.

Мы отмечаем координаты рамок (регрессия) и классифицируем объект в рамке.

Source: ИТМО

Комбинирование знаний и навыков

Вашим преимуществом станет комбинирование узкоспециализированных знаний в вашей предметной области и машинного обучения.

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

Работа была посвящена проблеме получения структуры белка, который бы отвечал заранее заданным свойства. Была обучена нейросеть, которая предсказывает расстояния и углы между атомами аминокислот в конечном белке, а также структуру белка в 3D-виде.

[article] 🎓 Jumper, John, et al. "Highly accurate protein structure prediction with AlphaFold." Nature 596.7873 (2021): 583-589

Source: AlphaFold: a solution to a 50-year-old grand challenge in biology.

План исследования¶

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

Сбор и подготовка данных¶

Где можно добыть данные?

  • Эксперименты в вашей лаборатории
  • [doc] 🛠️ Соревнования Kaggle
  • [doc] 🛠️ Google Datasets
  • [article] 🎓 Сайт Papers with Code

Пройдитесь по соседним лабораториям. Напишите письма авторам статей.

Если вы используете данные, скачанные из сети, проверьте, откуда они. Описаны ли они в статье? Если да, посмотрите на документ; убедитесь, что он был опубликован в авторитетном месте, и проверьте, упоминают ли авторы какие-либо ограничения на использованные датасеты.

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

Даже широко распространённые датасеты могут иметь ошибки или какую-то странную специфику. Например, при исследовании ImageNet были обнаружены миллионы изображений темнокожих, которые были помечены как "преступник". В итоге большая часть набора данных ImageNet была удалена.

Source: Label Errors in ML Test Sets

Существуют исследования 🎓[arxiv], которые связывают странное поведение современных нейронных сетей и ошибки в разметке.

Если вы обучаете свою модель на плохих данных, то, скорее всего, у вас получится плохое решение задачи. Существует соответствующий термин: garbage in, garbage out. Всегда начинайте с проверки данных.

Обучение vs применение¶

Как будут выглядеть данные во время инференса модели?

Не окажется ли, что при обучении все кошки были мохнатые, а на инференсе попался сфинкс?

Source: Дзен

Что делать?

  • Добавить целевые данные
  • Попробовать оценить смещение признаков данных и добавить это смещение к данным при обучении
  • Костыли и велосипеды

Подробнее с этим вы познакомитесь в ходе курса.

[blog] ✏️ Обсуждение проблемы

Разведочный анализ¶

Exploratory data analysis, EDA — анализ основных свойств данных, нахождение в них общих закономерностей, распределений и аномалий, построение начальных моделей с использованием инструментов визуализации.

[blog] ✏️ Как наглядно показать Data Science

Source: Choosing the Right Data Visualization Tool: A Comprehensive Guide

Подробнее с этим вы познакомитесь в следующих лекциях.

Примеры:

  • [git] 🐾 Три блокнота с подробным анализом реального датасета.

  • [blog] ✏️ Как избежать «подводных камней» машинного обучения: руководство для академических исследователей.

Baseline¶

Постройте вашу первую систему быстро, а затем итерационно улучшайте.

Возьмите что-то простое, готовое. Ваша сложная модель должна работать не хуже.

Возможно, даже простая модель сможет решить вашу задачу с достаточным качеством.

Учтите нижнюю границу качества. За baseline можно считать известное значение. Например, результат работы классических методов или качество решения задачи человеком.

Метрики¶

А как измерить это качество?

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

Фактически вы оцениваете, какой показатель нужно улучшить и как этот показатель измерить.

Метрика должна отвечать целевой задаче.

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

Обищй совет: используйте однопараметрические метрики.

Так, если у нас есть 2 классификатора, вводя две метрики, тяжело оценить, какой из них лучше — А или В.

Но если эти метрики объединить в одну, провести сравнение будет гораздо проще.

Также не стоит забывать об оптимизационных метриках. Мы можем улучшать не только точность.

Заметьте, что метрика получается не однопараметрическая. Вместо введения формулы типа $$\large F_1 + 0,5*Скорость$$ можно сделать отсечку допустимого времени рассчётов и использовать точность в качестве целевой метрики среди оставшихся моделей.

Построение модели, эксперименты¶

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

Проверка гипотез¶

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

  • на которых ошибка максимальна,
  • на которых возникают пограничные ошибки.

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

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

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

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

Анализ работы модели¶

После того, как модель готова, необходимо вскрыть "чёрный ящик". Об этом будет отдельная лекция.

Source: Object Identification and Localization Using Grad-CAM++

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

Заметки от Эндрю Ына:

[blog] ✏️ Страсть к машинному обучению.

Инструменты¶

Рассмотрим примеры решения задач классификации на различных типах данных.

Будем использовать библиотеки:

  • [doc] 🛠️ NumPy — поддержка больших многомерных массивов и быстрых математических функций для операций с этими массивами.
  • [doc] 🛠️ Scikit-learn — ML алгоритмы, "toy"-датасеты.
  • [doc] 🛠️ Pandas — Удобная работа с табличными данными.

  • [doc] 🛠️ PyTorch — Основной фреймворк машинного обучения, который будет использоваться на протяжении всего курса.

  • [doc] 🛠️ Matplotlib — Основная библиотека для визуализации. Вывод различных графиков.

  • [doc] 🛠️ Seaborn — Удобная библиотека для визуализации статистик. Прямо из коробки вызываются и гистограммы, и тепловые карты, и визуализация статистик по датасету, и многое другое.

Source: What is Seaborn in Python? A Complete Guide For Beginners & REAL-TIME Examples

Данные¶

Связность данных¶

Существуют различные типы данных:

  1. Последовательности (важен порядок данных, время):

    • временные ряды (речь, мозговая активность, котировки);
    • текст.
  2. Пространственно-структурированная информация (преобразуется к векторам чисел):

    • изображения (пиксели);
    • видео (пиксели + время);
    • 3D (воксели).
  3. Статистика:

    • табличные данные (признаки).

Большинство процессов и объектов, с которыми научились работать ML/DL модели, можно отнести к одному из перечисленных типов. Наша задача будет состоять в том, чтобы определить, как данные из вашей предметной области свести к одному из них и представить в виде набора чисел.

Для работы с различными типами данных используют разные типы моделей:

Табличный — классические ML модели либо полносвязные NN;

Последовательности — рекуррентные сети + свёртка;

Изображения/видео — 2,3 .. ND свёрточные сети.

В разных типах данных количество связей между элементами разное и зависит только от типа этих данных. Важно НЕ количество элементов, а СВЯЗИ между ними.

Данные мы можем условно делить по степени связанности. Это степень взаимного влияния между соседними элементами. Например, в таблице, в которой есть определенные параметры (например: рост, вес), данные между собой связаны, но порядок столбцов значения не имеет. Если мы поменяем столбцы местами, то не потеряем никакой важной информации.

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

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

Загрузка и визуализация данных¶

Пример работы с табличными данными. Нам даётся описание вин из учебного датасета Wine 🛠️[doc].

Библиотека sklearn обеспечивает API по работе с датасетами, а также хранит ряд учебных. Посмотрим, как это выглядит.

Source: Article Review: The 7 Steps of Machine Learning

Этот датасет можно загрузить, используя модуль sklearn.datasets библиотеки sklearn 🛠️[doc], чем мы и воспользуемся.

In [1]:
import sklearn
from sklearn.datasets import load_wine

dataset = load_wine(return_X_y=True)

# array 178x13 (178 wine examples each with 41 features)
features = dataset[0]
# array of 178 elements, each element is a number the class: 0,1 2
class_labels = dataset[1]
print("features shape:", features.shape)
print("class_labels shape:", class_labels.shape)
features shape: (178, 13)
class_labels shape: (178,)

Выведем первый элемент массива. Это наш $X_1$ из множества наблюдений $X$. Обратите внимание на размер каждого элемента — это вектора из 13 признаков.

In [2]:
dataset[0][0:1]
Out[2]:
array([[1.423e+01, 1.710e+00, 2.430e+00, 1.560e+01, 1.270e+02, 2.800e+00,
        3.060e+00, 2.800e-01, 2.290e+00, 5.640e+00, 1.040e+00, 3.920e+00,
        1.065e+03]])

А вот так выглядят первые 10 меток $Y$:

In [3]:
dataset[1][0:10]
Out[3]:
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])

Визуализация данных

Чтобы отобразить данные в виде таблицы, загрузим их в формате pandas.DataFrame. В более крупных датасетах у вас могут появиться такие параметры, как доля загрузки датасета и фиксирование сида генератора случайных числе (для повторяемости загрузки).

In [4]:
# Import library to work with tabular data: https://pandas.pydata.org/
import pandas as pd

x, y = load_wine(return_X_y=True, as_frame=True)

x.head(3)
Out[4]:
alcohol malic_acid ash alcalinity_of_ash magnesium total_phenols flavanoids nonflavanoid_phenols proanthocyanins color_intensity hue od280/od315_of_diluted_wines proline
0 14.23 1.71 2.43 15.6 127.0 2.80 3.06 0.28 2.29 5.64 1.04 3.92 1065.0
1 13.20 1.78 2.14 11.2 100.0 2.65 2.76 0.26 1.28 4.38 1.05 3.40 1050.0
2 13.16 2.36 2.67 18.6 101.0 2.80 3.24 0.30 2.81 5.68 1.03 3.17 1185.0

А вот так мы можем посмотреть, какие уникальные классы в нашей выборке.

In [5]:
y.unique()
Out[5]:
array([0, 1, 2])

Можно интерпретировать каждый объект как координаты точки в 13-мерном пространстве. Именно с таким представлением работает большинство алгоритмов машинного обучения.

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

In [6]:
import matplotlib.pyplot as plt

fig, axs = plt.subplots(figsize=(4, 3))
y.hist()
plt.suptitle("Label balance")
plt.show()

Работа с данными и моделью¶

Описание данных¶

Предположим, что мы работаем с тренировочным датасетом CIFAR-10 🛠️[doc] и хотим решить хрестоматийную задачу классификации: определить те картинки из тестового набора данных, которые относятся к классу cat. Эта задача является частным примером общей задачи классификации данных CIFAR-10, разные подходы к решению которой мы ещё неоднократно рассмотрим в ходе курса.

Датасет CIFAR-10 содержит, как следует из названия, 10 различных классов изображений:

Все изображения представляют собой матрицы чисел, которые кодируют цвета отдельных пикселей. Для изображений высоты $H$, ширины $W$ с $C$ цветовыми каналами получаем упорядоченный набор $H \times W \times C$ чисел. В данном разделе пока не будем учитывать, что значения соседних пикселей изображения могут быть значительно связаны, и будем решать задачу классификации для наивного представления изображения в виде точки в $H \times W \times C$-мерном вещественном пространстве.

Датасет CIFAR-10 содержит цветные (трехцветные) изображения размером $32 \times 32$ пикселя. Таким образом, каждое изображение из датасета является точкой в $3072$-мерном ($32 \times 32 \times 3 = 3072$) вещественном пространстве.

Отметим, что помимо RGB встречаются и другие цветовые пространства ✏️[blog], которые однозначно (или приблизительно) переходят друг в друга.

Близость данных согласно метрике¶

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

Известны различные способы задания функции расстояния между парой точек 📚[wiki]). Простейшим примером является широко известная Евклидова ($L_2$) метрика: $$L_2 (X, Y) = \sqrt { \sum_i (X_i - Y_i)^2},$$

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

$L_1$-расстояние (манхэттенская метрика): $$L_1 (X, Y) = \sum_i |X_i - Y_i|,$$

угловое расстояние: $$\text{ang} (X, Y) = \frac{1}{\pi} \arccos \frac{\sum_i X_i Y_i}{\sqrt{\sum_i X_i^2} \sqrt{\sum_i Y_i^2}} ,$$

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

Давайте попробуем вычислить $L_1$-расстояние между несколькими первыми изображениями из тестового набора данных CIFAR-10 с использованием реализованного в пакете sklearn 🛠️[doc] класса sklearn.metrics.DistanceMetric

$$\large L1\text{-distance}:\; d_1(I_1, I_2) = \sum_p|I^p_1 - I^p_2|$$

Загрузим данные:

In [7]:
# Load dataset from torchvision.datasets
from torchvision import datasets

train_set = datasets.CIFAR10("content", train=True, download=True)
val_set = datasets.CIFAR10("content", train=False, download=True)
labels_names = train_set.classes
Downloading https://www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz to content/cifar-10-python.tar.gz
100%|██████████| 170498071/170498071 [00:03<00:00, 52644756.64it/s]
Extracting content/cifar-10-python.tar.gz to content
Files already downloaded and verified

Выберем три изображения из тестового набора данных и одно из валидационного:

In [8]:
import matplotlib.pyplot as plt

img_1 = train_set.data[0]
img_2 = train_set.data[1]
img_3 = train_set.data[2]

fix, ax = plt.subplots(1, 3, figsize=(10, 3))
ax[0].set_title("First image in train data")
ax[0].imshow(img_1)
ax[1].set_title("Second image in train data")
ax[1].imshow(img_2)
ax[2].set_title("Third image in train data")
ax[2].imshow(img_3)
plt.show()
In [9]:
from sklearn.metrics import DistanceMetric

dist = DistanceMetric.get_metric("manhattan")
pairwise_dist = dist.pairwise([img_1.flatten(), img_2.flatten(), img_3.flatten()])
In [10]:
import numpy as np

fig, ax = plt.subplots(figsize=(4, 4))
im = ax.imshow(pairwise_dist)

# Show all ticks and label them with the respective list entries
ax.set_xticks(np.arange(len(pairwise_dist)))
ax.set_yticks(np.arange(len(pairwise_dist)))
ax.set_xticklabels([f"img{i}" for i in range(1, 4)])
ax.set_yticklabels([f"img{i}" for i in range(1, 4)])

# Rotate the tick labels and set their alignment.
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")

# Loop over data dimensions and create text annotations.
for i in range(len(pairwise_dist)):
    for j in range(len(pairwise_dist)):
        text = ax.text(
            j,
            i,
            "{:0.2f}".format(pairwise_dist[i, j]),
            ha="center",
            va="center",
            color="w",
        )

ax.set_title("Pairwise L_1 distance for first 3 images in CIFAR 10 ")
fig.tight_layout()
plt.show()

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

In [11]:
from sklearn.metrics.pairwise import manhattan_distances

# in order to limit computational time
index_limiter = 1000
# convert all (32,32,4) images into (32*32*4) vectors
flattened_images = val_set.data.reshape(val_set.data.shape[0], -1)[:index_limiter]

classwise_distance = np.zeros((len(val_set.classes), len(val_set.classes)))

# iterate over all pair of classes and slice their members
for class_id_i, class_name_i in enumerate(val_set.classes):
    class_i_mask = np.asarray(val_set.targets[:index_limiter]) == class_id_i

    for class_id_j, class_name_j in enumerate(val_set.classes):
        class_j_mask = np.asarray(val_set.targets[:index_limiter]) == class_id_j

        # manhattan_distances returns pairwise distance matrix for samples
        # so in order to get mean distance for classes one should calc mean
        # value over its higher triangle part or simply calc mean over whole matrix
        # and divide by 2.0
        classwise_distance[class_id_i, class_id_j] = (
            np.mean(
                manhattan_distances(
                    flattened_images[class_i_mask], flattened_images[class_j_mask]
                )
            )
            / 2.0
        )

fig, ax = plt.subplots(figsize=(8, 8))
im = ax.imshow(classwise_distance)

# Show all ticks and label them with the respective list entries
ax.set_xticks(np.arange(len(val_set.classes)))
ax.set_yticks(np.arange(len(val_set.classes)))
ax.set_xticklabels(val_set.classes)
ax.set_yticklabels(val_set.classes)

# Rotate the tick labels and set their alignment.
plt.setp(ax.get_xticklabels(), rotation=45, ha="right", rotation_mode="anchor")

ax.set_title("Mean class-wise Мanhattan distance for CIFAR 10")
fig.tight_layout()
fig.colorbar(im)
plt.show()

Как мы видим, среди первых 1000 картинок тестовой части датасета CIFAR-10 есть значительное число обособленных классов, для которых выполняется описанное выше отношение близости. Например, это справедливо для классов "Корабль", "Олень" и "Лягушка". Идея о том, что близость объектов по некоторой метрике и их принадлежность к одному определённому классу связаны, является основой известного алгоритма классификации и регрессии — k-Nearest Neighbors.

Описание модели k-NN¶

Метод k-ближайших соседей 📚[wiki] (англ. k-nearest neighbors algorithm, k-NN) — метрический алгоритм для классификации или регрессии. В случае классификации алгоритм сводится к следующему:

  1. Рассматриваются объекты из обучающей выборки, для которых известно, к какому классу они принадлежат.
  2. Между подлежащими классификации объектами и объектами тренировочной выборки вычисляется матрица попарных расстояний согласно выбранной метрике.
  3. На основе полученной матрицы расстояний для каждого из подлежащих классификации объектов определяются k ближайших объектов тренировочной выборки — k ближайших соседей.
  4. Подлежащим классификации объектам приписывается тот класс, который чаще всего встречается у их k ближайших соседей.

В качестве примера работы с алгоритмом k-NN классифицируем изображение корабля из тестовой выборки CIFAR-10 с использованием реализации алгоритма в scikit-learn 🛠️[doc].

In [12]:
fig, ax = plt.subplots(figsize=(3, 3))
sample_ship_img = val_set.data[18]
ax.set_title("Image in validation data")
plt.imshow(sample_ship_img)
plt.show()

Рассчитаем близость с валидационным изображением согласно трём распространённым расстояниям.

In [13]:
from sklearn.neighbors import KNeighborsClassifier

# in order to limit computational time
index_limiter = 5000
x = train_set.data.reshape(train_set.data.shape[0], -1)[:index_limiter]
y = train_set.targets[:index_limiter]

res = np.empty(shape=(3, 5), dtype=object)
i = 0

for distance_type in ["euclidean", "manhattan", "chebyshev"]:
    for k in range(3, 7, 1):
        knn = KNeighborsClassifier(n_neighbors=k, metric=distance_type)
        knn.fit(x, y)
        result_class_id = knn.predict([sample_ship_img.flatten()])[0]
        result_class = train_set.classes[result_class_id]
        res[i][0] = distance_type
        res[i][k - 2] = result_class
    i += 1
In [14]:
import pandas as pd

pandas_res = pd.DataFrame(res, columns=["distance", "k=3", "k=4", "k=5", "k=6"])
pandas_res.set_index("distance", inplace=True)
pandas_res
Out[14]:
k=3 k=4 k=5 k=6
distance
euclidean automobile ship ship ship
manhattan automobile automobile truck ship
chebyshev ship ship ship ship

Как видим, при k=6 ответы совпадают при всех метриках. Но как выбрать k?

Простейшая метрика¶

Довольно естественно оценивать долю правильных ответов алгоритма:

$$ \large \text{Accuracy} = \frac{P}{N}, $$

где $P$ — количество верно предсказанных классов,

$\quad\ N$ — общее количество тестовых примеров.

Давайте наш алгоритм будет предсказывать как можно больше правильных классов!

In [15]:
from sklearn.metrics import accuracy_score

knn = KNeighborsClassifier(n_neighbors=6, metric="chebyshev")
knn.fit(x, y)
accuracy = accuracy_score(y_pred=knn.predict(x), y_true=y)  # accuracy

print("Accuracy:", f"{accuracy*100}%")
Accuracy: 27.98%

Разделение train-validation-test¶

Самым простым способом научиться чему-либо является "запомнить всё".

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

Если модель "запомнит всё", то она будет идеально работать на данных, которые мы ей показали, но может вообще не работать на любых других данных.

С практической точки зрения важно, как модель будет вести себя именно на незнакомых ей данных, то есть, насколько хорошо она научилась обобщать закономерности, которые в данных присутствовали (если они вообще существуют).

Для оценки этой способности набор данных разделяют на три части:

  • Обучающая выборка (Training set) — выборка из данных, которая используется для обучения алгоритма.
  • Валидационная выборка (Validation set) — выборка данных, которая используется для подбора параметров, выбора признаков и принятия других решений, касающихся обучения алгоритма.
  • Тестовая выборка (Test set) — выборка, которая используется для оценки качества работы алгоритма, при этом никак не используется для обучения алгоритма или подбора используемых при этом обучении параметрам.

В sklearn.model_selection есть модель для разделения массива данных на тренировочную и тестовую часть.

In [16]:
from sklearn.model_selection import train_test_split

# split data to train/test
x_train, x_tmp, y_train, y_tmp = train_test_split(x, y, test_size=0.2)
x_val, x_test, y_val, y_test = train_test_split(x_tmp, y_tmp, test_size=0.2)

print("Train:", np.array(x_train).shape, np.array(y_train).shape)
print("Val:", np.array(x_val).shape, np.array(y_val).shape)
print("Test:", np.array(x_test).shape, np.array(y_test).shape)
print("Total:", np.array(x).shape, np.array(y).shape)
Train: (4000, 3072) (4000,)
Val: (800, 3072) (800,)
Test: (200, 3072) (200,)
Total: (5000, 3072) (5000,)
In [17]:
knn = KNeighborsClassifier(n_neighbors=6, metric="chebyshev")
knn.fit(x_train, y_train)

accuracy_train = accuracy_score(y_pred=knn.predict(x_train), y_true=y_train)
accuracy_val = accuracy_score(y_pred=knn.predict(x_val), y_true=y_val)
accuracy_test = accuracy_score(y_pred=knn.predict(x_test), y_true=y_test)

print("Accuracy train:", f"{accuracy_train*100}%")
print("Accuracy val :", f"{accuracy_val*100}%")
print("Accuracy test :", f"{accuracy_test*100}%")
Accuracy train: 26.775%
Accuracy val : 17.125%
Accuracy test : 12.5%

Параметры и гиперпараметры модели¶

$\displaystyle L1 = d_1(I_1, I_2) = \sum_p|I^p_1-I^p_2| \qquad \qquad \quad L2 = d_2(I_1, I_2) = \sqrt{\sum_p(I^p_1-I^p_2)^2}$

Итого, у нас есть два параметра модели, которые мы можем настраивать:

  • метрика расстояния,
  • количество ближайших соседей k.

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

Обучим k-NN для общей выборки данных при разном значении количества соседей.

In [18]:
num_neighbors = np.arange(1, 31)  # array of the numbers of neighbors from 1 to 30

quality = np.zeros(num_neighbors.shape[0])

for i in range(num_neighbors.shape[0]):  # for all elements
    # create knn for all number of neighbors
    knn = KNeighborsClassifier(n_neighbors=num_neighbors[i])
    knn.fit(x_train, y_train)
    q = accuracy_score(y_pred=knn.predict(x_train), y_true=y_train)  # accuracy
    quality[i] = q  # fill quality

plt.figure(figsize=(8, 4))
plt.title("k-NN on train", size=18)
plt.xlabel("Neighbors", size=12)
plt.ylabel("Accuracy", size=12)
plt.plot(num_neighbors, quality)
plt.xticks(num_neighbors)
plt.show()

Видим, что качество на 1 соседе самое лучшее. Но это и понятно — ближайшим соседом элемента из обучающей выборки будет сам объект. Мы просто запомнили все объекты.

Если теперь мы попробуем взять какой-то новый объект и классифицировать его, у нас скорее всего ничего не получится. В таких случаях мы говорим, что наша модель не умеет обобщать (generalization).

Для того, чтобы знать заранее, обобщает ли наша модель или нет, мы можем разбить все имеющиеся у нас данныe на 2 части. На одной части мы будем обучать классификатор (train set), а на другой — тестировать, насколько хорошо он работает (test set).

In [19]:
num_neighbors = np.arange(
    1, 31
)  # array of the numbers of nearest neigbors from 1 to 30
train_quality = np.zeros(num_neighbors.shape[0])  # quality on train data
test_quality = np.zeros(num_neighbors.shape[0])  # quality on test data

for i in range(num_neighbors.shape[0]):
    knn = KNeighborsClassifier(n_neighbors=num_neighbors[i])
    knn.fit(x_train, y_train)

    # accuracy on train data
    train_quality[i] = accuracy_score(y_pred=knn.predict(x_train), y_true=y_train)

    # accuracy on test data
    test_quality[i] = accuracy_score(y_pred=knn.predict(x_test), y_true=y_test)

# accuracy plot  on train and test data
plt.figure(figsize=(8, 4))
plt.title("k-NN on train vs test", size=18)
plt.plot(num_neighbors, train_quality, label="train")
plt.plot(num_neighbors, test_quality, label="test")
plt.legend()
plt.xticks(num_neighbors)
plt.xlabel("Neighbors", size=12)
plt.ylabel("Accuracy", size=12)
plt.show()

Вот, теперь мы видим, что 1 сосед был "ложной тревогой". Такие случаи мы называем переобучением. Чтобы действительно предсказывать что-то полезное, нам надо выбирать число соседей, начиная минимум с 3.

Стратификация¶

Метки классов в датасете могут быть распределены неравномерно. Для того, чтобы сохранить соотношение классов при разделении на train и test, необходимо указать параметр stratify при разбиении.

Еще одним параметром, используемым при разбиении, является shuffle (значение по умолчанию True). При shuffle = True датасет перед разбиением перемешивается.

Source: What is Stratified sampling and why should you use it

Посмотрим на разбиение датасета Iris 🛠️[doc]. Для наглядности будем делить датасет пополам.

In [20]:
def count_lables(lables):
    lable_count = {}
    for item in lables:
        if item not in lable_count:
            lable_count[item] = 0
        lable_count[item] += 1
    return lable_count


def print_split_stat(x_train, x_test, y_train, y_test):
    # print("Train labels: ", y_train)
    # print("Test labels:  ", y_test)
    print("Train statistics: ", count_lables(y_train))
    print("Test statistics:  ", count_lables(y_test))

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

In [21]:
from sklearn.datasets import load_iris

data, labels = load_iris(return_X_y=True)
print("DataSet labels:\n", labels)
print("DataSet statistics: ", count_lables(labels))
DataSet labels:
 [0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2
 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
 2 2]
DataSet statistics:  {0: 50, 1: 50, 2: 50}

Если мы выключим перемешивание (shuffle=False), то в обучение не попадёт ни один объект класса 2.

In [22]:
x_train, x_test, y_train, y_test = train_test_split(
    data, labels, train_size=0.5, shuffle=False, random_state=42
)

print_split_stat(x_train, x_test, y_train, y_test)
Train statistics:  {0: 50, 1: 25}
Test statistics:   {1: 25, 2: 50}

По умолчанию shuffle=True, однако этого не достаточно. Доли объектов не равны в подвыборках.

In [23]:
x_train, x_test, y_train, y_test = train_test_split(
    data, labels, train_size=0.5, random_state=42
)

print_split_stat(x_train, x_test, y_train, y_test)
Train statistics:  {1: 27, 2: 27, 0: 21}
Test statistics:   {1: 23, 0: 29, 2: 23}

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

In [24]:
x_train, x_test, y_train, y_test = train_test_split(
    data, labels, train_size=0.5, random_state=42, stratify=labels
)

print_split_stat(x_train, x_test, y_train, y_test)
Train statistics:  {0: 25, 1: 25, 2: 25}
Test statistics:   {0: 25, 2: 25, 1: 25}

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

Нормализация¶

Можно иметь сколько угодно хороший алгоритм для классификации, но до тех пор, пока данные на входе — мусор, на выходе из классификатора мы тоже будем получать мусор (garbage in, garbage out). Давайте разберемся, что конкретно надо сделать, чтобы k-NN реально заработал.

Чтобы адекватно сравнить данные между собой, нам следует использовать нормализацию.

Нормализацией называется процедура приведения входных данных к единому масштабу (диапазону) значений.

Преобразование данных к единому числовому диапазону (иногда говорят домену) позволяет считать их равноправными признаками и единообразно передавать их на вход модели. В некоторых источниках данная процедура явно называется масштабирование.

Существует базовый вариант — StandardScaler.

Подробно рассмотрим различные виды нормализации в следующей лекции.

Как применять нормировку

  1. Делим данные на 3 части: train, val, test.
  2. Вычисляем статистики на train.
  3. Применяем к train, val, test.

Нельзя вычислять статистики на всём наборе данных, нормировать, а потом делить на подвыборки. Это ведёт к утечке данных и некорректным результатам.

In [25]:
from sklearn.preprocessing import StandardScaler

np.random.seed(42)  # setting the initialization parameter for random values

x_train_feature = x_train[:, 0].reshape(-1, 1)

plt.figure(1, figsize=(8, 3))
plt.subplot(121)  # set location
plt.scatter(x_train_feature, range(len(x_train_feature)), c=y_train)
plt.ylabel("Num examples", fontsize=15)
plt.xticks(fontsize=15)
plt.yticks(fontsize=15)
plt.title("Non scaled data", fontsize=18)

# scale data  with StandardScaler
scaler = StandardScaler()
scaler.fit(x_train_feature)
x_train_feature_scaled = scaler.transform(x_train_feature)

plt.subplot(122)
plt.scatter(x_train_feature_scaled, range(len(x_train_feature)), c=y_train)
plt.xticks(fontsize=15)
plt.yticks(fontsize=15)
plt.title("StandardScaler", fontsize=18)
plt.show()

Идея StandardScaler заключается в том, что он преобразует данные таким образом, что распределение будет иметь среднее значение $0$ и стандартное отклонение $1$. Большинство значений будет находиться в диапазоне от $-1$ до $1$. Это стандартная трансформация, и она применима во многих ситуациях.

$$\large z_i=\frac{X_i-u}{s},$$

$u$ — среднее значение (или 0 при with_mean=False),

$s$ — стандартное отклонение (или 0 при with_std=False).

Обучим модель на данных без нормировки и с нормировкой для 10-ти соседей.

In [26]:
# split data to train/test
x_train, x_test, y_train, y_test = train_test_split(
    data, labels, random_state=42, test_size=0.5
)

knn = KNeighborsClassifier(n_neighbors=10)
knn.fit(x_train, y_train)

print("Without normalization")
accuracy_train = accuracy_score(y_pred=knn.predict(x_train), y_true=y_train)
print("accuracy_train", round(accuracy_train, 3))
accuracy_test = accuracy_score(y_pred=knn.predict(x_test), y_true=y_test)
print("accuracy_test", round(accuracy_test, 3))
Without normalization
accuracy_train 0.933
accuracy_test 0.947
In [27]:
scaler = StandardScaler()
scaler.fit(x_train)
x_train_norm = scaler.transform(x_train)  # scaling data
x_test_norm = scaler.transform(x_test)  # scaling data

knn = KNeighborsClassifier(n_neighbors=10)
knn.fit(x_train_norm, y_train)

print("With normalization")
accuracy_train = accuracy_score(y_pred=knn.predict(x_train_norm), y_true=y_train)
print("accuracy_train", round(accuracy_train, 3))
accuracy_test = accuracy_score(y_pred=knn.predict(x_test_norm), y_true=y_test)
print("accuracy_test", round(accuracy_test, 3))
With normalization
accuracy_train 0.96
accuracy_test 0.973

k-NN в прикладных задачах¶

На практике метод ближайших соседей для классификации используется редко. Проблема заключается в следующем.

Предположим, что точность классификации нас устраивает. Теперь давайте применим k-NN на больших данных (e.g. миллион картинок). Для определения класса каждой из картинок нам нужно сравнить ее со всеми другими картинками в базе данных, а такие расчеты, даже в существенно оптимизированном виде, занимают много времени. Мы же хотим, чтобы обученная модель работала быстро.

Тем не менее, метод ближайших соседей используется в других задачах, где без него обойтись сложно. Например, в задаче распознавания лиц. Представим, что у нас есть большая база данных с фотографиями лиц (например, по 5 разных фотографий всех сотрудников, которые работают в офисном здании) и есть камера, установленная на входе в это здание. Мы хотим узнать, кто и во сколько пришел на работу. Для того, чтобы понять, кто прошел перед камерой, нам нужно зафиксировать лицо этого человека и сравнить его со всеми фотографиями лиц в базе. В такой формулировке мы не пытаемся определить конкретный класс фотографии, а всего лишь определяем “похож-не похож”. Мы смотрим на k ближайших соседей, и если из k соседей, 5 — это фотографии, например, Джеки Чана, то, скорее всего, под камерой прошел именно он.

Примеры эффективной реализации метода на основе k-NN:

  • [git] 🐾 Facebook AI Research Similarity Search – разработка команды Facebook AI Research для быстрого поиска ближайших соседей и кластеризации в векторном пространстве. Высокая скорость поиска позволяет работать с очень большими данными – до нескольких миллиардов векторов.
  • [arxiv] 🎓 Hierarchical Navigable Small World — алгоритм поиска ближайших соседей.

Кросс-валидация¶

Алгоритм кросс-валидации¶

Давайте все-таки разберемся, как подобрать гиперпараметры.

Результат работы модели будет зависеть от разбиения. Поэкспериментируем с k-NN и датасетом Iris 🛠️[doc] и посмотрим, как результат работы модели зависит от random_state для train_test_split.

In [28]:
import numpy as np
import sklearn.datasets
import matplotlib.pyplot as plt
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split

dataset = sklearn.datasets.load_iris()  # load data
x = dataset.data  # features
y = dataset.target  # labels(classes)

np.random.seed(42)


def split_and_train(x, y, random_state):
    x_train, x_val, y_train, y_val = train_test_split(
        x, y, train_size=0.8, stratify=y, random_state=random_state
    )

    max_neighbors = 30
    num_neighbors = np.arange(1, max_neighbors + 1)  # array of the number of neighbors

    train_accuracy = np.zeros(max_neighbors)
    val_accuracy = np.zeros(max_neighbors)

    for k in num_neighbors:
        knn = KNeighborsClassifier(n_neighbors=k)
        knn.fit(x_train, y_train)

        train_accuracy[k - 1] = accuracy_score(
            y_pred=knn.predict(x_train), y_true=y_train
        )
        val_accuracy[k - 1] = accuracy_score(y_pred=knn.predict(x_val), y_true=y_val)

    # accuracy plot on train and test data
    plt.figure(figsize=(10, 4))
    plt.title(f"KNN on train vs val, seed = {random_state}", size=20)
    plt.plot(num_neighbors, train_accuracy, label="train")
    plt.plot(num_neighbors, val_accuracy, label="val")
    plt.legend()
    plt.xticks(num_neighbors, size=12)
    plt.xlabel("Neighbors", size=14)
    plt.ylabel("Accuracy", size=14)
    plt.show()
In [29]:
split_and_train(x, y, random_state=42)
In [30]:
split_and_train(x, y, random_state=4)

Результат зависит от того, как нам повезло или не повезло с разбиением данных на обучение и тест. Для одного разбиения хорошо выбрать $k=3$, а для другого — $k=13$. Кроме того, фактически мы сами выступаем в роли модели, которая учит гиперпараметры (а не параметры) под видимую ей выборку.

Получается, что если подбирать гиперпараметры модели на train set, то:

  1. Можно переобучитьcя, просто на более "высоком" уровне. Особенно если гиперпараметров у модели много и все они разнообразны.
  2. Нельзя быть уверенным, что выбор параметров не зависит от разбиения на обучение и тест.

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

Такой подход называется K-Fold кросс-валидацией 🛠️[doc].

Берется тренировочная часть датасета, разбивается на части — блоки. Дальше мы будем использовать для проверки первую часть (Fold 1), а на остальных частях будем обучать модель. И так последовательно для всех частей. В результате у нас будет информация о точности для разных фрагментов данных, и уже на основании этого мы сможем понять, насколько значение параметра, который мы проверяем, зависит или не зависит от данных. То есть, если у нас от разбиения точность при одном и том же К меняться не будет, значит, мы подобрали правильный К. Если она будет сильно меняться в зависимости от того, на каком куске данных мы проводим тестирование, значит, надо попробовать другой К, и если ни при каком не получилось, то проблема заключается в данных.

Посмотрим, как работает k-Fold. Обратите внимание, что по умолчанию shuffle = False. Для упорядоченных данных это проблема.

In [31]:
from sklearn.model_selection import KFold

x = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9])
y = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9])

print("index without shuffle")
kf = KFold(n_splits=3)
for train_index, test_index in kf.split(x):
    print("TRAIN:", train_index, "TEST:", test_index)

print("index with shuffle")
kf = KFold(n_splits=3, random_state=42, shuffle=True)
for train_index, test_index in kf.split(x):
    print("TRAIN:", train_index, "TEST:", test_index)
index without shuffle
TRAIN: [3 4 5 6 7 8] TEST: [0 1 2]
TRAIN: [0 1 2 6 7 8] TEST: [3 4 5]
TRAIN: [0 1 2 3 4 5] TEST: [6 7 8]
index with shuffle
TRAIN: [0 2 3 4 6 8] TEST: [1 5 7]
TRAIN: [1 3 4 5 6 7] TEST: [0 2 8]
TRAIN: [0 1 2 5 7 8] TEST: [3 4 6]

Для получения стратифицированного разбиения (когда соотношение классов в частях разбиения сохраняется) нужно использовать StratifiedKFold 🛠️[doc].

Временные ряды

Типичный пример временного ряда
Source: Анализ временных рядов с помощью Python

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

Разбиение данных временных рядов на подвыборки

Оценка результата кросс-валидации¶

Посмотрим на результат кросс-валидации для k-NN.

In [32]:
from sklearn.model_selection import cross_val_score, StratifiedKFold

np.random.seed(42)

dataset = sklearn.datasets.load_iris()  # load data
x = dataset.data  # features
y = dataset.target  # labels(classes)

x_train, x_test, y_train, y_test = train_test_split(
    x, y, train_size=0.8, stratify=y, random_state=42
)

cv = StratifiedKFold(n_splits=5)

knn = KNeighborsClassifier(n_neighbors=3)
accuracy3 = cross_val_score(knn, x_train, y_train, cv=cv, scoring="accuracy")

knn = KNeighborsClassifier(n_neighbors=5)
accuracy5 = cross_val_score(knn, x_train, y_train, cv=cv, scoring="accuracy")
In [33]:
knn_cv = np.vstack(
    (
        np.hstack((accuracy3, accuracy3.mean(), accuracy3.std())),
        np.hstack((accuracy5, accuracy5.mean(), accuracy5.std())),
    )
)
In [34]:
import pandas as pd

table = pd.DataFrame(
    knn_cv, columns=["Fold1", "Fold2", "Fold3", "Fold4", "Fold5", "Mean", "Std"]
)
table = table.set_axis(["Accuracy"] * 2)
In [35]:
table
Out[35]:
Fold1 Fold2 Fold3 Fold4 Fold5 Mean Std
Accuracy 0.916667 0.958333 0.958333 0.958333 1.0 0.958333 0.026352
Accuracy 0.916667 1.000000 0.958333 1.000000 1.0 0.975000 0.033333

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

Типичные ошибки при кросс-валидации¶

Можно ли делать только кросс-валидацию (без теста)?

Нет, нельзя. Кросс-валидация не до конца спасает от подгона параметров модели под выборку, на которой она проводится. Оценка конечного качества модели должно производиться на отложенной тестовой выборке. Если у вас очень мало данных, можно рассмотреть вложенную кросс-валидацию 🛠️[doc]. Речь об этом пойдет в следующих лекциях. Но даже в этом случае придется анализировать поведение модели, чтобы показать, что она учит что-то разумное. Кстати, вложенную кросс-валидацию можно использовать, чтобы просто получить более устойчивую оценку поведения модели на тесте.

GridSearch¶

Для подбора параметров модели используется GridSearchCV.

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

Датасет Iris маловат для подбора параметров, поэтому создадим свой датасет:

In [36]:
from sklearn.datasets import make_moons

x, y = make_moons(n_samples=1000, noise=0.3, random_state=42)

plt.figure(figsize=(10, 5))
plt.scatter(x[:, 0], x[:, 1], c=y)
plt.show()

Отложим test

In [37]:
x_train, x_test, y_train, y_test = train_test_split(
    x, y, train_size=0.8, stratify=y, random_state=42
)

Попробуем подобрать параметры модели

In [38]:
from sklearn.model_selection import GridSearchCV
from warnings import simplefilter

simplefilter("ignore", category=RuntimeWarning)

"""
Parameters for GridSearchCV:
estimator — model
cv — num of fold to cross-validation splitting
param_grid — parameters names
scoring — metrics
n_jobs — number of jobs to run in parallel, -1 means using all processors.
"""

model = GridSearchCV(
    estimator=KNeighborsClassifier(),
    cv=KFold(5, shuffle=True, random_state=42),
    param_grid={
        "n_neighbors": np.arange(1, 31),
        "metric": ["euclidean", "manhattan"],
        "weights": ["uniform", "distance"],
    },
    scoring="accuracy",
    n_jobs=-1,
)
model.fit(x_train, y_train)
Out[38]:
GridSearchCV(cv=KFold(n_splits=5, random_state=42, shuffle=True),
             estimator=KNeighborsClassifier(), n_jobs=-1,
             param_grid={'metric': ['euclidean', 'manhattan'],
                         'n_neighbors': array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16, 17,
       18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]),
                         'weights': ['uniform', 'distance']},
             scoring='accuracy')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
GridSearchCV(cv=KFold(n_splits=5, random_state=42, shuffle=True),
             estimator=KNeighborsClassifier(), n_jobs=-1,
             param_grid={'metric': ['euclidean', 'manhattan'],
                         'n_neighbors': array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16, 17,
       18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]),
                         'weights': ['uniform', 'distance']},
             scoring='accuracy')
KNeighborsClassifier()
KNeighborsClassifier()

Выведем лучшие гиперпараметры для модели, которые подобрали:

In [39]:
print("Metric:", model.best_params_["metric"])
print("Num neighbors:", model.best_params_["n_neighbors"])
print("Weigths:", model.best_params_["weights"])
Metric: euclidean
Num neighbors: 30
Weigths: distance

Объект GridSearchCV можно использовать как обычную модель.

In [40]:
from sklearn.metrics import balanced_accuracy_score

y_pred = model.predict(x_test)
print(
    f"Percent correct predictions {np.round(accuracy_score(y_pred=y_pred, y_true=y_test)*100,2)} %"
)
print(
    f"Percent correct predictions(balanced classes) {np.round(balanced_accuracy_score(y_pred=y_pred, y_true=y_test)*100,2)} %"
)
Percent correct predictions 95.5 %
Percent correct predictions(balanced classes) 95.5 %

Мы можем извлечь дополнительные данные о кросс-валидации и по ключу обратиться к результатам всех моделей:

In [41]:
list(model.cv_results_.keys())
Out[41]:
['mean_fit_time',
 'std_fit_time',
 'mean_score_time',
 'std_score_time',
 'param_metric',
 'param_n_neighbors',
 'param_weights',
 'params',
 'split0_test_score',
 'split1_test_score',
 'split2_test_score',
 'split3_test_score',
 'split4_test_score',
 'mean_test_score',
 'std_test_score',
 'rank_test_score']

Выведем для примера mean_test_score:

In [42]:
plt.figure(figsize=(14, 4))
plt.subplot(121)
plt.plot(model.cv_results_["mean_test_score"])
plt.title("Mean test score", size=20)
plt.xlabel("Num of experiment", size=15)
plt.ylabel("Accuracy", size=15)

plt.subplot(122)
plt.plot(model.cv_results_["param_metric"])
plt.title("Param Metric", size=20)
plt.xlabel("Num of experiment", size=15)

plt.show()

Построим, например, при фиксированных остальных параметрах (равных лучшим параметрам), качество модели на валидации в зависимости от числа соседей

In [43]:
selected_means = []
selected_std = []
num_neighbors = []
for ind, params in enumerate(model.cv_results_["params"]):
    if (
        params["metric"] == model.best_params_["metric"]
        and params["weights"] == model.best_params_["weights"]
    ):
        num_neighbors.append(params["n_neighbors"])
        selected_means.append(model.cv_results_["mean_test_score"][ind])
        selected_std.append(model.cv_results_["std_test_score"][ind])

Построим error bar для сравнения разброса ошибки при разном количестве соседей Neighbors.

Видим, что на самом деле большой разницы в числе соседей, начиная с 11, и нет.

In [44]:
plt.figure(figsize=(10, 4))
plt.title(f"KNN CV, {params['metric']}, {params['weights']}", size=18)
plt.errorbar(num_neighbors, selected_means, yerr=selected_std, fmt="-o")
plt.xticks(num_neighbors, size=13)
plt.ylabel("Mean_test_score", size=15)
plt.xlabel("Neighbors", size=15)

plt.show()

RandomizedSearch¶

Альтернативой GridSearch является RandomizedSearch 🛠️[doc]. Если в GridSearch поиск параметров происходит по фиксированному списку значений, то RandomizedSearch умеет работать с непрерывными значениями, случайно выбирая тестируемые значения, что может привести к более точной настройке гиперпараметров.

Вы в явном виде указываете, сколько точек вы будете семплировать.

In [45]:
from sklearn.model_selection import RandomizedSearchCV

"""
Parameters for RandomizedSearchCV:
estimator — model
cv — num of fold to cross-validation splitting
param_distributions — parameters names
n_iter — number of parameter settings that are sampled. n_iter trades off runtime vs quality of the solution.
scoring — metrics
n_jobs — number of jobs to run in parallel, -1 means using all processors.
"""

model = RandomizedSearchCV(
    estimator=KNeighborsClassifier(),
    n_iter=100,
    cv=KFold(5, shuffle=True, random_state=42),
    param_distributions={
        "n_neighbors": np.arange(1, 31),
        "metric": ["euclidean", "manhattan"],
        "weights": ["uniform", "distance"],
    },
    scoring="accuracy",
    n_jobs=-1,
)
model.fit(x_train, y_train)
Out[45]:
RandomizedSearchCV(cv=KFold(n_splits=5, random_state=42, shuffle=True),
                   estimator=KNeighborsClassifier(), n_iter=100, n_jobs=-1,
                   param_distributions={'metric': ['euclidean', 'manhattan'],
                                        'n_neighbors': array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16, 17,
       18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]),
                                        'weights': ['uniform', 'distance']},
                   scoring='accuracy')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
RandomizedSearchCV(cv=KFold(n_splits=5, random_state=42, shuffle=True),
                   estimator=KNeighborsClassifier(), n_iter=100, n_jobs=-1,
                   param_distributions={'metric': ['euclidean', 'manhattan'],
                                        'n_neighbors': array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15, 16, 17,
       18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30]),
                                        'weights': ['uniform', 'distance']},
                   scoring='accuracy')
KNeighborsClassifier()
KNeighborsClassifier()

Выведем лучшие гиперпараметры для модели, которые подобрали:

In [46]:
print("Metric:", model.best_params_["metric"])
print("Num neighbors:", model.best_params_["n_neighbors"])
print("Weigths:", model.best_params_["weights"])
Metric: manhattan
Num neighbors: 29
Weigths: distance

Как видим, параметры близки к выбранным полным перебором.

Объект RandomizedSearchCV также можно использовать как обычную модель.

In [47]:
y_pred = model.predict(x_test)
print(
    f"Percent correct predictions {np.round(accuracy_score(y_pred=y_pred, y_true=y_test)*100,2)} %"
)
print(
    f"Percent correct predictions(balanced classes) {np.round(balanced_accuracy_score(y_pred=y_pred, y_true=y_test)*100,2)} %"
)
Percent correct predictions 95.0 %
Percent correct predictions(balanced classes) 95.0 %

Точность уменьшилась на 0.5%. Возможно, такое понижение вам не критично.

Метрики¶

Насколько точна ваша модель?<img style: align="center" width="200" src ="https://edunet.kea.su/repo/EduNet-content/dev-2.0/L01/out/how_compute_model_accuracy.png">

Невозможно создать хорошее решение, не определив меру "хорошести". Нужно определиться с тем, как оценивать результат. Очень часто приходится слышать от заказчика вопрос со слайда. Чаще всего ответ “99%” их более чем устраивает.

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

  1. Скорость перемещения машины зависит от дороги: на дорогах бывают пробки, ограничивающие знаки, наконец, дороги бывают очень разного качества.

Всё это влияет на скорость перемещения, порой — радикально.

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

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

  2. Само понятие скорости допускает вариации: речь идет о средней или максимальной скорости? Аналогично и для оценки моделей существует несколько метрик, применение которых, опять же, зависит от целей заказчика и особенностей данных.

«На датасете X модель Y по метрике Z показала 99%».

Accuracy¶

Интуитивно понятной, очевидной и почти неиспользуемой метрикой является уже знакомая нам accuracy — доля правильных ответов алгоритма.

$$ \large \text{Accuracy} = \frac{P}{N}, $$

где $P$ — количество верно предсказанных классов,

$\quad\ N$ — общее количество тестовых примеров.

Какие есть недостатки у такого способа?

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

На рисунке выше мы видим, что при явном количественном преобладании объектов класса airplane модель может классифицировать все объекты как airplane и при этом получить такую же точность, как модель, которая учит все 3 класса, так как количество ошибок будет равно числу объектов классов, в которых меньше представителей (в данном случае в классах automobile и bird по 10 представителей, соответсвенно, 20 ошибок).

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

Precision, Recall¶

Для решения этой проблемы вводятся метрики "точность" и "полнота"

Для численного описания этих метрик необходимо ввести важную концепцию в терминах ошибок классификации — confusion matrix (матрица ошибок). Допустим, у нас есть два класса и алгоритм, предсказывающий принадлежность каждого объекта к одному из классов. Тогда матрица ошибок классификации будет выглядеть следующим образом:

$\large y=1$ $\large y=0$
$\large \widehat{y}=1$ $\large \text{True Positive} \ (TP) $ $\large \text{False Positive} \ (FP) $
$\large \widehat{y}=0$ $\large \text{False Negative} \ (FN)$ $\large \text{True Negative} \ (TN) $

Precision, recall

Для оценки качества работы алгоритма на каждом из классов по отдельности введем метрики precision (точность) и recall (полнота).

$\large \text{precision} = \frac{TP}{TP + FP}$

$\large \text{recall} = \frac{TP}{TP + FN}$

Именно введение precision не позволяет нам записывать все объекты в один класс, так как в этом случае мы получаем рост уровня False Positive. Recall демонстрирует способность алгоритма обнаруживать данный класс вообще, а precision — способность отличать этот класс от других классов.

Accuracy

Accuracy также можно посчитать через матрицу ошибок.

$\text{Accuracy} = \dfrac{TP + TN}{TP + TN + FP + FN}$

Balanced accuracy

В случае дисбаланса классов есть специальный аналог точности – сбалансированная точность.

$\text{Balanced accuracy} = \dfrac{R_1 + R_0}{2} = \dfrac{1}{2} (\dfrac{TP}{TP + FN} + \dfrac{TN}{TN + FP})$

Для сбалансированного и несбалансированного случаев она будет равна $0. 96$ и $0.33$ соответственно.

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

F-мера¶

Ошибки классификации бывают двух видов: False Positive и False Negative. Первый вид ошибок называют ошибкой I-го рода, второй — ошибкой II-го рода. Пусть студент приходит на экзамен. Если он учил и знает, то принадлежит классу с меткой 1, иначе — имеет метку 0 (знающего студента называем «положительным»). Пусть экзаменатор выполняет роль классификатора: ставит зачёт (т.е. метку 1) или отправляет на пересдачу (метку 0). Самое желаемое для студента «не учил, но сдал» соответствует ошибке 1 рода, вторая возможная ошибка «учил, но не сдал» – 2 рода.

Часто в реальной практике стоит задача найти оптимальный баланс между Presicion и Recall. Классическим примером является задача определения оттока клиентов.

F-мера (в общем случае $\ F_\beta$) — среднее гармоническое precision и recall :

$\large \ F_\beta = (1 + \beta^2) \cdot \frac{\text{precision} \cdot \text{recall}}{(\beta^2 \cdot \text{precision}) + \text{recall}}$

$\beta$ в данном случае определяет вес точности в метрике, и при $\beta = 1$ это среднее гармоническое (в случае $\text{precision} = 1$ и $\text{recall} = 1$ получим $\ F_1 = 1$).

F-мера достигает максимума при полноте и точности, равными единице, и близка к нулю, если один из аргументов близок к нулю.

Сбалансированная F-мера, $β=1$:

При перекосе в точность ($β=1/4$):

Более наглядно: низкие значения точности не позволяют метрике F вырасти.

Зависимость F1-меры от полноты при фиксированной точности. При точности 10% F1-мера не может быть больше 20%.

В sklearn есть удобная функция sklearn.metrics.classification_report, возвращающая recall, precision и F-меру для каждого из классов, а также количество экземпляров каждого класса.

In [48]:
from sklearn.metrics import classification_report

y_true = [0, 1, 2, 2, 2]
y_pred = [0, 0, 2, 2, 1]
target_names = ["class 0", "class 1", "class 2"]
print(classification_report(y_true, y_pred, target_names=target_names))
              precision    recall  f1-score   support

     class 0       0.50      1.00      0.67         1
     class 1       0.00      0.00      0.00         1
     class 2       1.00      0.67      0.80         3

    accuracy                           0.60         5
   macro avg       0.50      0.56      0.49         5
weighted avg       0.70      0.60      0.61         5

Многоклассовый случай

In [49]:
import numpy as np
import matplotlib.pyplot as plt
from sklearn import metrics

fig, ax = plt.subplots(1, 2, figsize=(10, 4))
fig.tight_layout(pad=3.0)
plt.rcParams.update({"font.size": 16})
# font = {'size':'21'}
ax[0].set_title("Balanced data")
ax[1].set_title("Unbalanced data")

labels = ["Airplane", "Auto", "Bird"]

# Balanced data
air, auto, bird = 150, 150, 150
actual_b = np.array([0] * air + [1] * auto + [2] * bird)
predicted_b = np.array([0] * (air - 10) + [1] * (auto + 20) + [2] * (bird - 10))

# Unbalanced data
air, auto, bird = 430, 10, 10
actual_ub = np.array([0] * air + [1] * auto + [2] * bird)
predicted_ub = np.array([0] * (air + 20) + [1] * (auto - 10) + [2] * (bird - 10))

metrics.ConfusionMatrixDisplay(
    confusion_matrix=metrics.confusion_matrix(actual_b, predicted_b),
    display_labels=labels,
).plot(ax=ax[0])

metrics.ConfusionMatrixDisplay(
    confusion_matrix=metrics.confusion_matrix(actual_ub, predicted_ub),
    display_labels=labels,
).plot(ax=ax[1])

label_font = {"size": "15"}  # Adjust to fit
ax[0].set_xlabel("Predicted labels", fontdict=label_font)
ax[0].set_ylabel("True labels", fontdict=label_font)
ax[1].set_xlabel("Predicted labels", fontdict=label_font)
ax[1].set_ylabel("True labels", fontdict=label_font)

plt.show()

print(
    "Accuracy Balanced   Data:", round(metrics.accuracy_score(actual_b, predicted_b), 2)
)
print(
    "Accuracy Unbalanced Data:",
    round(metrics.accuracy_score(actual_ub, predicted_ub), 2),
)
Accuracy Balanced   Data: 0.96
Accuracy Unbalanced Data: 0.96
In [50]:
print(
    "Balanced accuracy for Balanced data  :",
    round(metrics.balanced_accuracy_score(actual_b, predicted_b), 2),
)
print(
    "Balanced accuracy for Unbalanced data :",
    round(metrics.balanced_accuracy_score(actual_ub, predicted_ub), 2),
)
Balanced accuracy for Balanced data  : 0.96
Balanced accuracy for Unbalanced data : 0.33

Multiclass Accuracy

В случае многоклассовой классификации термины TP, FP, TN, FN считаются для каждого класса:

$\large \displaystyle \text{Multiclass Accuracy} = \frac{1}{n}\sum_{i=1}^{n} [\text{actual}_{i} == \text{predicted}_{i}] = \frac{\sum_{k=1}^{N} TP_{Ck} }{\sum_{k=1}^{N} (TP_{Ck} + TN_{Ck} + FP_{Ck} + FN_{Ck})}$

AUC-ROC¶

Пусть решается задача бинарной классификации, и необходимо оценить важность признака $j$ для решения именно этой задачи. В этом случае можно попробовать построить классификатор, который использует лишь этот один признак $j$, и оценить его качество. Например, можно рассмотреть очень простой классификатор, который берёт значение признака $j$ на объекте, сравнивает его с порогом $t$, и если значение больше этого порога, то он относит объект к первому классу, если же меньше порога, то к другому, нулевому или минус первому, в зависимости от того, как мы его обозначили. Далее, поскольку этот классификатор зависит от порога $t$, то его качество можно измерить с помощью таких метрик, как площадь под ROC-кривой или Precision-Recall кривой, а затем по данной площади отсортировать все признаки и выбрать лучшие.

Но вначале разберёмся, что такое AUC-ROC.

Построение¶

ROC-кривой (ROC, receiver operating characteristic, кривой ошибок) традиционно называют график кривой, которая характеризует качество предсказаний бинарного классификатора на некоторой фиксированной выборке при всех значениях порога классификации. Площадь под графиком ROC-кривой AUC (area under the curve) является численной характеристикой качества классификатора. Определим, как именно строится ROC-кривая, через рассмотрение примера.

Вывод некоторого бинарного классификатора представлен в табл. 1. Упорядочим строки данной таблицы по убыванию значения вывода нашего бинарного классификатора и запишем результат в табл. 2. Если наш алгоритм справился с задачей классификации, то мы увидим в последней колонке также упорядоченные по убыванию значения (или случайное распределение меток $0$ и $1$ в противном случае).

alttext

Приступим непосредственно к изображению графика ROC-кривой. Начнём с квадрата единичной площади и изобразим на нём прямоугольную координатную сетку, равномерно нанеся $m$ горизонтальных линий и $n$ вертикальных. Число горизонтальных линий $m$ соответствует количеству объектов класса $1$ из рассматриваемой выборки, а число $n$ — количеству объектов класса $0$. В нашем примере $m=3$ и $n=4$. Таким образом, квадрат единичной площади разбился на $m \times n$ прямоугольных блоков (на $12$ штук согласно нашему примеру).

Начиная из точки $(0, 0)$, построим ломаную линию в точку $(1, 1)$ по узлам получившейся решетки по следующему алгоритму:

  • рассмотрим последовательно все строки табл. 2
  • оценка алгоритма для объекта из текущей строки не равна оценке для объекта из следующей:
    • если в строке содержится объект с меткой класса $1$, рисуем линию до следующего узла вертикально вверх
    • если в строке содержится объект с меткой класса $0$, рисуем линию до следующего узла горизонтально направо
  • оценки для объектов в нескольких последующих строках совпадают:
    • нарисовать линию из текущего узла в узел, располагающийся на $k$ углов вертикально выше и на $l$ узлов левее. $k$ и $l$ соответственно равны количеству объектов класса $1$ и $0$ среди группы повторяющихся значений оценок классификатора

(всего потребуется не более $n + m$ шагов — столько же, сколько строк в нашей таблице)

alttext
Рис.1. Построение ROC-кривой.

Справа на рис. 1 показана полученная для нашего примера кривая – эта изображенная на единичном квадрате ломаная линия и называется ROC-кривой.

Вычислим площадь под получившийся кривой — AUC-ROC. В нашем примере AUC-ROC $= 9.5 / 12 ~ 0.79$, и именно это значение является искомой метрикой качества работы нашего бинарного классификатора. (Так как мы начали свое построение с квадрата единичной площади, то AUC-ROC может принимать значения в $[0,1]$).

  1. ROC-кривая абсолютно точного бинарного классификатора имеет вид $(0,0) \rightarrow (1,0) \rightarrow (1,1)$. ROC-AUC для такого идеального классификатора равен площади всего единичного квадрата.
  2. ROC-кривая для всегда ошибающегося бинарного классификатора имеет вид $(0,0) \rightarrow (0,1) \rightarrow (1,1)$. ROC-AUC в этом случае равен нулю.
  3. Если наш бинарный классификатор для всех объектов предскажет одно и то же значение, то его ROC-кривая будет иметь вид $(0,0) \rightarrow (1,1)$
alttext
Рис. 2. ROC-кривые для наилучшего (AUC=1), константного (AUC=0.5) и наихудшего (AUC=0) алгоритма.

Смысл метрики¶

Как можно заметить на рис. 3, координатная сетка, описанная в нашем алгоритме построения ROC кривой, разбила единичный квадрат на столько прямоугольников, сколько существовало пар объектов класс-$0$ — класс-$1$ в исследуемой выборке данных. Если теперь посчитать количество оказавшихся под ROC-кривой прямоугольников, то можно заметить, что оно в точности равно числу верно классифицированных алгоритмом пар объектов, то есть таких пар объектов противоположных классов, для которых алгоритм поставил большую по величине оценку для объекта класса $1$.

alttext
Рис. 3. Каждый блок соответствует паре объектов.

Таким образом, ROC-AUC равен части верно упорядоченных оценкой классификатора пар объектов противоположных классов (в которой объект класса $0$ получил оценку исследуемым классификатором ниже, чем объект класса $1$).

Multilabel¶

Source: Top Machine Learning Algorithms For Business Applications In 2023

Есть ещё один случай — когда объект может принадлежать одновременно нескольким классам — называется multilabel (многометочная) классификация. Такую задачу не стоит сводить к задаче бинарной классификации по каждому классу, ибо метки могут быть не независимыми.

Допустим, у нас есть 3 объекта, и модель предсказала нам 3 набора меток.

In [51]:
# fmt: off
y_true = [[0,1,1,1],
         [0,0,1,0],
         [1,1,0,0]]

y_pred = [[0,1,0,1],
          [0,1,1,1],
          [1,0,1,1]]
# fmt: on

Accuracy

Оценивает точное совпадение векторов классов. Вариант — считать точность по каждому классу независимо.

Confusin Matrix

Специальная функция, которая создаст 4 матрицы, по одной на каждый класс.

In [52]:
from sklearn.metrics import multilabel_confusion_matrix

multilabel_confusion_matrix(y_true, y_pred)
Out[52]:
array([[[2, 0],
        [0, 1]],

       [[0, 1],
        [1, 1]],

       [[0, 1],
        [1, 1]],

       [[0, 2],
        [0, 1]]])

Precision, Recall, F1

Могут применяться независимо к каждой метке, также эти результаты можно объединить различными усреднениями:

  • micro — расчёт идёт без разделения меток по классам;
  • macro — вычисление метрик производится для каждой метки, затем идёт вычисление среднего значения, дисбаланс не берётся в расчёт;
  • weighted — также, только берётся в расчёт дисбаланс объектов;
  • samples — усредение ведётся по каждому объекту.

Classification report

Может быть использован вами ровно так же, как и раньше.

In [53]:
from sklearn.metrics import classification_report

label_names = ["label A", "label B", "label C", "label D"]

print(classification_report(y_true, y_pred, target_names=label_names))
              precision    recall  f1-score   support

     label A       1.00      1.00      1.00         1
     label B       0.50      0.50      0.50         2
     label C       0.50      0.50      0.50         2
     label D       0.33      1.00      0.50         1

   micro avg       0.50      0.67      0.57         6
   macro avg       0.58      0.75      0.62         6
weighted avg       0.56      0.67      0.58         6
 samples avg       0.56      0.72      0.57         6

[colab] 🥨 Блокнот на Kaggle с примерами расчётов Multilabel

Литература

Полезное:

  • Работы выпускников
  • Как писать научные статьи?

Данные:

  • [doc] 🛠️ Соревнования Kaggle
  • [doc] 🛠️ Google Datasets
  • [article] 🎓Сайт Papers with Code
  • [arxiv] 🎓 Поведение нейросетей и ошибки в разметке
  • [blog] ✏️ Обсуждение проблемы различия данных при обучении и на инференсе
  • [doc] 🛠️ Датасет c трафиком из DARPA
  • [doc] 🛠️ CIFAR10

Руководства:

  • [blog] ✏️ Эндрю Ын. Страсть к Машинному обучению

  • [git] 🐾 Три блокнота с подробным анализом реального датасета

  • [blog] ✏️ Как избежать «подводных камней» машинного обучения: руководство для академических исследователей — гайд по типичным ошибкам

Инструменты:

  • [doc] 🛠️ NumPy — массивы и математические функции
  • [doc] 🛠️ Scikit-learn — ML алгоритмы, "toy"-датасеты;
  • [doc] 🛠️ Pandas — табличные данные.

  • [doc] 🛠️ PyTorch — нейросети.

  • [doc] 🛠️ Matplotlib — визуализация.

  • [doc] 🛠️ Seaborn — визуализация статистик

Методы и алгоритмы:

  • [wiki] 📚 Метод k-ближайших соседей
  • [wiki] 📚 Функции расстояния между парой точек)
  • [git] 🐾 Быстрый k-NN Facebook AI Research Similarity Search
  • [arxiv] 🎓 Hierarchical Navigable Small World — алгоритм поиска ближайших соседей

Другое:

  • [blog] ✏️ Цветовые пространства
  • [blog] ✏️ Стратификация