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

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

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 $$

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

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

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

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

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

Успехи происходят в тех областях, где производится моделирование человеческой деятельности. Но ведь мы ещё и думаем! Давайте попробуем добавить это в ИИ.

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

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

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

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

Обзор курса¶

Лекция 1 Intro¶

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

Лекция 2 Линейный классификатор¶

Базовые алгоритмы:

  • Линейная регрессия
  • Логистическая регрессия
  • Полиномиальная регрессия

Работа с моделями:

  • Батчи, стохастическое обучение
  • Регуляризация
  • Кросс-энтропия

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

Лекция 3 Классическое машинное обучение¶

Погружаемся в машинное обучение:

  • Деревья решений
  • Леса деревьев
  • Градиентный бустинг
  • Оценка важности признаков
  • Метрики для оценки качества модели

Больше узнаем об алгоритмах на основе деревьев решений. Научимся строить ансамбли моделей. Узнаем, что такое бустинг.

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

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

Посмотрим внимательнее на данные:

  • Отбор признаков
  • Методы понижения размерности
  • Визуализация многомерных данных

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

Лекция 5 Нейронные сети¶

В лекции будет подробно рассказано о том, как учатся нейронные сети. Посмотрим на:

  • Автоматический подсчёт градиентов в PyTorch
  • Функции активации
  • Визуализацию процесса обучения
  • Критерии прекращения обучения
  • Подбор оптимальных парамеров моделей

Так, например, вы научитесь понимать вот такие вот графики функций активации и поймёте, зачем вообще они нужны:

Source: CS231n: Deep Learning for Computer Vision

Лекция 6 Свёрточные нейронные сети¶

Научимся работать с двухмерными данными на примере изображений:

  • Свёртки
  • Свёрточные слои в нейросети, их параметры
  • Построение свёрточных нейросетей (CNN)
  • 1D и 3D свёртки

Посмотрим, как решать реальные задачи с помощью CNN, создавать эмбеддинги. А ещё на то, как понижать размерность данных и делать кластеризацию.

Поиск болезней у растений

Классический пример — автоматизация поиска болезней у растений. Более 40% урожая теряется из-за несвоевременного нахождения больного растения — приходится уничтожать изрядную часть урожая.

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

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

Кадр из к/ф Джентельмены

J.A.R.V.I.S. и помидорки

A Review of Machine Learning Approaches in Plant Leaf Disease Detection and Classification

Лекция 7 Улучшение сходимости нейросетей и борьба с переобучением¶

Научимся учить сети эффективно. Затронем следующие вопросы:

  • Что такое регуляризацию весов и зачем она нужна?
  • Как правильно нормализовать данные на входе?
  • Как заставить отдельные нейроны учить простые паттерны?
  • Как правильно инициализировать начальные веса?
  • Как их оптимизировать в процессе?
  • Как учить глубокую сеть?

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

Лекция 8 Реккурентные нейронные сети¶

Познакомимся с новым типом нейронных сетей, который умеет учитывать зависимости в последовательностях данных. В таких, как котировки акций или видеоряды. А ещё с помощью них можно переводить тексты! И не только.

Узнаете, что означают такие слова, как:

  • RNN
  • LSTM
  • GRU

А ещё поговорим о том, как анализировать работу таких сетей применительно к текстам и сигналам.

Трансформеры

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

  • Трансформеры для текста
  • Трансформеры для изображений
  • Трансформеры для других задач

А как можно научить свой трансформер в Colab? А как использовать уже готовый для своей задачи? А как анализировать его работу?

С помощью Трансформеров решаются задачи в совершенно разных доменах. Например, детектирование землетрясений.

Earthquake transformer—an attentive deep-learning model for simultaneous earthquake detection and phase picking

Лекция 9 Архитектуры CNN¶

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

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

Лекция 10 Explainability¶

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

Изучим методы Explainability для методов ML и DL, включающие такие методики, как визуализация весов и карт активаций нейросети, вывод отдельных частей изображения, соответствующих различным классам, и многое другое.

  • SHAP
  • LIME
  • Grad-Cam

Например, изучим, как именно нейросети определяют то, что изображено на картинке.

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

Лекция 11 Обучение на реальных данных¶

Столкнёмся с жестоким реальным миром несбалансированных данных и узнаем ответы на следующие вопросы:

  • Как быть, если данных мало?
  • Как быть, если данных совсем-совсем мало?
  • Как обрабатывать данные с разной модальностью?

Покажем, что ответы разнообразны. Можно:

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

Это и многое другое мы и будем обсуждать на лекции.

Source: The secrets of small data: How machine learning finally reached the enterprise

Лекция 12 Сегментация и детектирование¶

Научимся решать новые задачи: выделять отдельные сегменты в данных и детектировать объекты.

  • Варианты постановки задач
  • Наборы данных и структуры датасетов
  • Специальные метрики для этих задач
  • Типичные архитектуры
  • А что делать, если несколько объектов разом на картинке?
  • А как оценить качество?

Детектирование

Source: U-Net: нейросеть для сегментации изображений

Лекция 13 Автоэнкодеры¶

Рассмотрим архитектуры автокодировщика и где их применяют:

  • очистка данных от шумов;
  • понижение размерности данных;
  • извлечение зависимостей из данных.

Коснёмся проблем при их обучении и развития архитектур для решения разнообразных задач.

Поиск аномалий

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

Supervised learning

alttext

Unsupervised learning

alttext

Очистка от шумов

Лекция 14 Генеративные сети¶

Эта лекция будет сильно связана с предыдущей.

  • Что нам сделать, чтобы создать новые данные, которых ранее не существовало?
  • Или перенести стиль с одной картинки на другую?
  • А если мы хотим генерировать объекты с заданными свойствами?

Также мы рассмотрим альтернативные генераторы данных.

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

Лекция 15 Обучение с подкреплением¶

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

Например, в эту область входит такая задача: какую статегию нужно выбрать студенту МГУ в этом семестре? Пойти ли на курс по нейросетям в науке или пойти работать в криптостартап? А может быть, лучше посвятить этот семестр медитациям и чтению "Капитала"? А что будет эффективнее на промежутке в 5 лет?

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

Source: Learning Acrobatics by Watching YouTube. Xue Bin (Jason) Peng and Angjoo Kanazawa

Базовые задачи¶

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

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

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

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

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

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

Регрессия¶

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

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

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

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

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

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

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

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

Именно поэтому над проблемой получения структуры белка бьются многие научные (и не только) группы. Авторы AlphaFold2 обучают нейросеть, которая предсказывает расстояния и углы между атомами аминокислот в конечном белке, а также предсказывает структуру белка в 3D-виде.

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

AlphaFold¶

Source: AlphaFold: Using AI for scientific discovery
Подробнее про AlphaFold¶

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

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

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

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

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

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

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

А эти знания уже применяются в области производства лекарств. Например, есть база веществ с известной структурой. Имея структуру белка, можно предсказать, в каком месте и с какой формой белка они могут и должны соединяться. Соответственно, по базе данных можно найти вещества, которые будут с этим белком связаны, и уже подобрать из имеющихся “претендентов” тот, который наилучшим образом будет работать.

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

Improved protein structure prediction

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

Source: AlphaFold: Using AI for scientific discovery

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

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

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

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

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

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

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

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

Контейнеры¶

Мы будем работать с массивами 3 типов, которые переходят друг в друга:

  • list — стандартный тип в Python
  • numpy — массив
  • torch.tensor

Где используются:

  • ML: list, numpy
  • DL: torch.tensor

List¶

In [ ]:
python_list = [[1, 2, 3], [4, 5, 6]]
python_list_various = ["a", 15, 123.8, [99, "I love you"], [True, True, False]]

print(python_list_various)
print(python_list)
['a', 15, 123.8, [99, 'I love you'], [True, True, False]]
[[1, 2, 3], [4, 5, 6]]

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

NumPy¶

  • Массив может содержать данные только одного типа;
  • Размер данных во всех измерениях, кроме 0-го, должен совпадать
In [ ]:
import numpy as np

numpy_arr = np.array(python_list, dtype=float)
print(numpy_arr)

# This code will cause an error
# invalid_numpy_arr = np.array([[1,2,3],[4,5]],dtype = float)
[[1. 2. 3.]
 [4. 5. 6.]]

Благодаря этому над numpy-массивами можно выполнять различные математические операции.

In [ ]:
vector = np.array([1, 0, 0])
row_diff = numpy_arr - vector
print("Substract row from array", row_diff)

scalar_product = numpy_arr.dot(vector)
print("Scalar product", scalar_product)
Substract row from array [[0. 2. 3.]
 [3. 5. 6.]]
Scalar product [1. 4.]

Torch.Tensor¶

    'is a multi-dimensional matrix containing elements of a single data type.'

С точки зрения ограничений и функционала torch.Tensor эквивалентен numpy-массиву. Но дополнительно этот объект поддерживает две важных операции:

  • Перенос данных на видеокарту (my_tensor.to('cuda:0'))
  • Автоматический расчет градиентов (my_tensor.backward())

Эти возможности понадобятся нам в дальнейшем, поэтому надо разобраться, как работать с данными в этом формате. Тем более, что torch.Tensor легко преобразуется в numpy-массив и обратно.

In [ ]:
import torch

my_tensor = torch.tensor(numpy_arr)
print("torch.Tensor\n", my_tensor, "\nshape =", my_tensor.shape)

squared_numpy = my_tensor.pow(2).numpy()
print("Numpy\n", squared_numpy, "\nshape =", squared_numpy.shape)
torch.Tensor
 tensor([[1., 2., 3.],
        [4., 5., 6.]], dtype=torch.float64) 
shape = torch.Size([2, 3])
Numpy
 [[ 1.  4.  9.]
 [16. 25. 36.]] 
shape = (2, 3)

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

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

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

  • Эксперименты в вашей лаборатории
  • Соревнования Kaggle
  • Google Datasets
  • Сайт Papers with Code

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

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

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

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

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

Source: Label Errors in ML Test Sets

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

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

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

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

Отдельным пунктом необходимо отметить, что помимо "содержания" важна и "форма" данных. Формат хранения ваших данных повлияет на скорость, с которой вы сможете завершить свое исследование. Например, у вас есть массив, который называется ID, и в нем хранятся следующие данные [1,30,111,221,234] в формате float64. Проверьте, а точно ли тут нужен float64? Возможно, ваши данные представлены целыми положительными числами, и для их хранения будет достаточно формата uint32 или даже uint16 (подробный обзор форматов данных в Understanding Data Types).

Разберем на конкретном примере Скачаем датасет: "Когда и где кого-то покусала собака в NYC" и загрузим его в pandas. Подробно посмотрим только на 2 признака. Подробный анализ смотри тут.

In [ ]:
# Download dataset
# !wget -q https://data.cityofnewyork.us/api/views/rsgh-akpg/rows.csv?accessType=DOWNLOAD -O dogs.csv
!wget -q https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/dogs.csv -O dogs.csv

Посмотрим на содержание.

In [ ]:
import pandas as pd


# Load into pandas and display a sample
dataset = pd.read_csv("dogs.csv")

dataset.head(3)
Out[ ]:
UniqueID DateOfBite Species Breed Age Gender SpayNeuter Borough ZipCode
0 1 January 01 2018 DOG UNKNOWN NaN U False Brooklyn 11220
1 2 January 04 2018 DOG UNKNOWN NaN U False Brooklyn NaN
2 3 January 06 2018 DOG Pit Bull NaN U False Brooklyn 11224

Проверим, есть ли дубликаты:

In [ ]:
if len(dataset) == len(dataset.drop_duplicates()):
    print("Очевидных дубликатов нет")
else:
    print(
        "%.2f процентов данных являются дубликатами"
        % len(dataset.drop_duplicates())
        / len(dataset)
        * 100
    )
Очевидных дубликатов нет

UniqueID

Мы ожидаем, что в этой колонке каждому объявлению был присвоен уникальный ID. Судя по сэмплу, это просто порядковый номер, начинающийся с 1. Можем визуализировать эту колонку, чтобы убедиться, что там нет никаких сюрпризов.

In [ ]:
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(len(dataset))
plt.xlabel("Index")
plt.ylabel("UniqueID")
plt.scatter(x, dataset["UniqueID"], s=0.1)
plt.show()

Можно заметить, что уникальных идентификаторов меньше, чем строк в датафрейме. Давайте убедимся:

In [ ]:
dataset["UniqueID"].max(), len(dataset["UniqueID"])
Out[ ]:
(12383, 22663)

То есть, ID повторяются? Судя по всему, в какой-то момент времени нумерация была запущена заново. А значит, ID совсем даже не unique, следовательно, использовать эту колонку как уникальный идентификатор мы не можем.

В каком формате хранятся данные в этой колонке?

In [ ]:
dataset["UniqueID"].dtype
Out[ ]:
dtype('int64')

В int64 можно записывать целые числа в диапазоне от -9223372036854775808 до 9223372036854775807. Мы уже по графику видим, что знак нам не нужен и что наше максимальное значение явно меньше. Определим, какой у нас максимум.

In [ ]:
dataset["UniqueID"].min(), dataset["UniqueID"].max()
Out[ ]:
(1, 12383)

Значит, нам подойдет uint16 — целое число без знака в диапазоне от 0 до 65535.

In [ ]:
dataset_filtered = dataset.copy()
dataset_filtered["UniqueID"] = dataset["UniqueID"].astype("uint16")

Сколько памяти мы выиграли?

In [ ]:
def resources_gain(
    column="UniqueID", orig_dataset=dataset, filtered_dataset=dataset_filtered
):
    original_memory = orig_dataset[column].memory_usage(deep=True)
    memory_after_conversion = filtered_dataset[column].memory_usage(deep=True)
    gain = original_memory / memory_after_conversion
    print(f"Gain: {round(gain, 2)}")


resources_gain(
    column="UniqueID", orig_dataset=dataset, filtered_dataset=dataset_filtered
)
Gain: 3.99

Теперь колонка UniqueID занимает в 4 раза меньше места (а значит, и обрабатывается быстрее).

DateOfBite

В DateOfBite, судя по всему, записано время укуса, но в формате str. Нам было бы удобнее работать с timestamps.

In [ ]:
dataset_filtered["DateOfBite"] = pd.to_datetime(dataset["DateOfBite"])

Оценим выигрыш в ресурсах

In [ ]:
resources_gain(
    column="DateOfBite", orig_dataset=dataset, filtered_dataset=dataset_filtered
)
Gain: 8.87

Теперь проверим, нет ли каких-то странных дат.

In [ ]:
dataset_filtered["DateOfBite"].hist()
plt.show()

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

Извлечение закономерностей¶

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

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

Сейчас появилась технология, которая может это делать вместо человека.

Это ML

ML — это технология, которая позволяет выявлять закономерности в данных и обобщать их.

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

Для этого нужно две вещи: данные и валидация результата.

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

Законы Ньютона не сформулированы для яблок. Для описания закономерностей в науке используются абстракции: сила, масса, ускорение.

Данные для ML моделей тоже должны быть подготовлены. Типичная форма такой абстракции — вектор или n-мерный массив чисел.

Именно с такой формой представления данных работает большинство современных моделей.

Валидация результата¶

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

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

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

Пример ML задачи¶

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

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

Вариант №1¶

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

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

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

Вариант №2¶

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

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

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

Данные¶

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Табличные данные¶

Пример работы с табличными данными. Классифицируем вина из датасета Wine.

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

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

Производитель №1 (class_1) 59 бутылок Производитель №2 (class_2) 71 бутылка Производитель №3 (class_3) 48 бутылок

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

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

# https://scikit-learn.org/stable/modules/generated/sklearn.datasets.load_wine.html#sklearn.datasets.load_wine

# Download dataset
dataset = load_wine(
    return_X_y=True
)  # also we can get data in Bunch (dictionary) or pandas DataFrame

features = dataset[0]  # array 178x13 (178 bottles each with 13 features)
class_labels = dataset[
    1
]  # array of 178 elements, each element is a number the class: 0,1 2
print("features shape:", features.shape)
print("class_labels shape:", class_labels.shape)
features shape: (178, 13)
class_labels shape: (178,)
In [ ]:
dataset
Out[ ]:
(array([[1.423e+01, 1.710e+00, 2.430e+00, ..., 1.040e+00, 3.920e+00,
         1.065e+03],
        [1.320e+01, 1.780e+00, 2.140e+00, ..., 1.050e+00, 3.400e+00,
         1.050e+03],
        [1.316e+01, 2.360e+00, 2.670e+00, ..., 1.030e+00, 3.170e+00,
         1.185e+03],
        ...,
        [1.327e+01, 4.280e+00, 2.260e+00, ..., 5.900e-01, 1.560e+00,
         8.350e+02],
        [1.317e+01, 2.590e+00, 2.370e+00, ..., 6.000e-01, 1.620e+00,
         8.400e+02],
        [1.413e+01, 4.100e+00, 2.740e+00, ..., 6.100e-01, 1.600e+00,
         5.600e+02]]),
 array([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, 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, 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]))

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

Если параметр

return_X_y == False

то данные вернутся не в виде массива, а в объекте Bunch.

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

Чтобы отобразить данные в виде таблицы, преобразуем их в формат pandas.DataFrame.

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

dataset_bunch = load_wine(return_X_y=False)
print(dataset_bunch.keys())

df = pd.DataFrame(dataset_bunch.data, columns=dataset_bunch.feature_names)
df.head()
dict_keys(['data', 'target', 'frame', 'target_names', 'DESCR', 'feature_names'])
Out[ ]:
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
3 14.37 1.95 2.50 16.8 113.0 3.85 3.49 0.24 2.18 7.80 0.86 3.45 1480.0
4 13.24 2.59 2.87 21.0 118.0 2.80 2.69 0.39 1.82 4.32 1.04 2.93 735.0

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

Изображения¶

Загрузка

Загрузим датасет CIFAR-10. Он состоит из 60000 цветных изображений размером 32x32. На картинках объекты 10 классов.

Для его загрузки используем библиотеку torchvision.

Пакет torchvision входит в число предустановленных в Google Colab.

Датасеты из torcvision изначально поддерживают механизм transforms и разбивку на тестовые и проверочные подмножества. Нам не придется добавлять их вручную.

In [ ]:
from IPython.display import clear_output
from torchvision import datasets

train_set = datasets.CIFAR10("content", train=True, download=True)
val_set = datasets.CIFAR10("content", train=False, download=True)

clear_output()

Выведем несколько картинок вместе с метками.

In [ ]:
import pickle
from matplotlib import pyplot as plt

plt.rcParams["figure.figsize"] = (20, 10)

# load labels names for visualization
with open("content/cifar-10-batches-py/batches.meta", "rb") as infile:
    cifar_meta = pickle.load(infile)
labels_name = cifar_meta["label_names"]

for j in range(10):
    img, label = train_set[j]
    plt.subplot(1, 10, j + 1)
    plt.imshow(img)
    plt.axis("off")
    plt.title(labels_name[label])

Посмотрим, в каком виде хранятся картинки в памяти:

In [ ]:
train_set[0]
Out[ ]:
(<PIL.Image.Image image mode=RGB size=32x32 at 0x7A5CAE613B50>, 6)

Оказывается, в формате PIL.

Чтобы обучать модель, нам придётся преобразовать их в тензоры. Используем для этого transforms и Dataloder.

Выведем размеры получившихся тензоров:

In [ ]:
from torch.utils.data import DataLoader
from torchvision import transforms

val_set.transform = transforms.Compose(
    [transforms.ToTensor()]
)  # PIL Image to Pytorch tensor
val_loader = DataLoader(val_set, batch_size=8, shuffle=False)

for batch in val_loader:
    imgs, labels = batch
    print(len(batch))
    print("Images: ", imgs.shape)
    print("Labels: ", labels.shape)
    print(labels)
    break
2
Images:  torch.Size([8, 3, 32, 32])
Labels:  torch.Size([8])
tensor([3, 8, 8, 0, 6, 6, 1, 6])

Разберемся с размерностями:

На каждой итерации dataloader возвращает кортеж из двух элементов.

  • Первый элемент — это изображения;
  • Второй — метки классов.

Количество элементов в каждом равно batch_size, в данном примере — 8.

Изображение: 3 — C, каналы (в отличие от PIL и OpenCV они идут сначала); 32 — H, высота; 32 — W, ширина.

Метки: числа от 0 до 9 по количеству классов.

Создадим модель-заглушку

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

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

In [ ]:
import torch


class FakeModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.train_data = None
        self.train_labels = None

    def fit(self, x, y):
        # Simple store all data
        self.train_data = (
            torch.vstack((self.train_data, x)) if self.train_data != None else x
        )
        self.train_labels = (
            torch.hstack((self.train_labels, y)) if self.train_labels != None else y
        )

    def forward(self, x):
        # x is a batch, not a single sample!
        # Return random number instead of predictions
        class_count = torch.unique(self.train_labels).shape[0]
        # https://pytorch.org/docs/stable/generated/torch.randint.html#torch-randint
        # size is shape of output tensor
        label = torch.randint(low=0, high=class_count - 1, size=(x.shape[0],))
        return label

Запустим процесс "обучения"

In [ ]:
train_set.transform = transforms.Compose(
    [
        transforms.ToTensor(),
    ]
)  # PIL Image to PyTorch tensor
train_loader = DataLoader(train_set, batch_size=1024, shuffle=True)

model = FakeModel()

for img_batch, labels_batch in train_loader:
    model.fit(img_batch, labels_batch)

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

In [ ]:
img_batch, label_batch = next(iter(val_loader))
predicted_labels = model(img_batch)

for i, predicted_label in enumerate(predicted_labels):
    img = img_batch[i].permute(1, 2, 0).numpy() * 255
    plt.subplot(1, len(predicted_labels), i + 1)
    plt.imshow(img.astype(int))
    plt.axis("off")
    plt.title(labels_name[int(predicted_label)])

Посчитаем точность:

In [ ]:
from sklearn.metrics import accuracy_score

accuracy = []
for img_batch, labels_batch in val_loader:
    predicted = model(img_batch)
    batch_accuracy = accuracy_score(labels_batch, predicted)
    accuracy.append(batch_accuracy)

print("Accuracy", torch.tensor(accuracy).mean())
Accuracy tensor(0.0980, dtype=torch.float64)

Будем повышать точность. В ходе выполнения практического задания заменим заглушку в методе predict реальным алгоритмом. Используем алгоритм K-Nearest Neighbors.

Алгоритм k-NN¶

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

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

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

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

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

Предположим, что мы работаем с тренировочным датасетом CIFAR-10 и хотим решить хрестоматийную задачу классификации: определить те картинки из тестового набора данных, которые относятся к классу 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$) вещественном пространстве.

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

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

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

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

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

угловое расстояние: $$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 класса sklearn.metrics.DistanceMetric

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

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

In [ ]:
from IPython.display import clear_output
from torchvision import datasets

# Load dataset from torchvision.datasets
train_set = datasets.CIFAR10("content", train=True, download=True)
val_set = datasets.CIFAR10("content", train=False, download=True)
labels_names = train_set.classes
clear_output()

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

In [ ]:
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=(12, 4))
ax[0].set_title("First image in CIFAR10 train data")
ax[0].imshow(img_1)
ax[1].set_title("Second image in CIFAR10 train data")
ax[1].imshow(img_2)
ax[2].set_title("Third image in CIFAR10 train data")
ax[2].imshow(img_3)
plt.show()
In [ ]:
sample_ship_img = val_set.data[18]
plt.figure(figsize=(4, 4))
plt.imshow(sample_ship_img)
plt.show()
In [ ]:
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]

for metric_type in ["euclidean", "manhattan", "chebyshev"]:
    print()
    for k in range(3, 7, 1):
        knn = KNeighborsClassifier(n_neighbors=k, metric=metric_type)
        knn.fit(x, y)
        result_class_id = knn.predict([sample_ship_img.flatten()])[0]
        result_class = train_set.classes[result_class_id]
        print(f"{k}-NN with {metric_type} metric\npredicted class is: {result_class}\n")
3-NN with euclidean metric
predicted class is: automobile

4-NN with euclidean metric
predicted class is: ship

5-NN with euclidean metric
predicted class is: ship

6-NN with euclidean metric
predicted class is: ship


3-NN with manhattan metric
predicted class is: automobile

4-NN with manhattan metric
predicted class is: automobile

5-NN with manhattan metric
predicted class is: truck

6-NN with manhattan metric
predicted class is: ship


3-NN with chebyshev metric
predicted class is: ship

4-NN with chebyshev metric
predicted class is: ship

5-NN with chebyshev metric
predicted class is: ship

6-NN with chebyshev metric
predicted class is: ship

Нормализация данных¶

Загрузим датасет с образцами здоровой и раковой ткани. Датасет состоит из 569 примеров, где каждой строчке из 30 признаков соответствует класс 1 злокачественной (malignant) или 0 доброкачественной (benign) ткани. Задача состоит в том, чтобы по 30 признакам обучить модель определять тип ткани (злокачественная или доброкачественная).

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

In [ ]:
import sklearn.datasets

cancer = sklearn.datasets.load_breast_cancer()  # load data

x = cancer.data  # features
y = cancer.target  # labels(classes)
print(f"x shape: {x.shape}, y shape: {y.shape}")
print(f"x[0]: \n {x[0]}")
print(f"y[0]: \n {y[0]}")
x shape: (569, 30), y shape: (569,)
x[0]: 
 [1.799e+01 1.038e+01 1.228e+02 1.001e+03 1.184e-01 2.776e-01 3.001e-01
 1.471e-01 2.419e-01 7.871e-02 1.095e+00 9.053e-01 8.589e+00 1.534e+02
 6.399e-03 4.904e-02 5.373e-02 1.587e-02 3.003e-02 6.193e-03 2.538e+01
 1.733e+01 1.846e+02 2.019e+03 1.622e-01 6.656e-01 7.119e-01 2.654e-01
 4.601e-01 1.189e-01]
y[0]: 
 0

Посмотрим, сколько данных в классе 0 и сколько данных в классе 1

In [ ]:
plt.figure(figsize=(6, 4))  # set figure size
plt.bar(1, y[y == 1].shape, label=cancer.target_names[0])  # 1 label
plt.bar(0, y[y == 0].shape, label=cancer.target_names[1])  # 0 label
plt.title("Class balance")
plt.ylabel("Num examples")
plt.xticks(ticks=[1, 0], labels=["1", "0"])
plt.legend(loc="upper left")
plt.show()

Теперь давайте посмотрим на сами данные. У нас есть 569 строк, в каждой из которых по 30 колонок. Такие колонки называют признаками или features. Попробуем математически описать все эти признаки (mean, std, min и тд)

In [ ]:
import pandas as pd

pd.DataFrame(x).describe()
Out[ ]:
0 1 2 3 4 5 6 7 8 9 ... 20 21 22 23 24 25 26 27 28 29
count 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 ... 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000 569.000000
mean 14.127292 19.289649 91.969033 654.889104 0.096360 0.104341 0.088799 0.048919 0.181162 0.062798 ... 16.269190 25.677223 107.261213 880.583128 0.132369 0.254265 0.272188 0.114606 0.290076 0.083946
std 3.524049 4.301036 24.298981 351.914129 0.014064 0.052813 0.079720 0.038803 0.027414 0.007060 ... 4.833242 6.146258 33.602542 569.356993 0.022832 0.157336 0.208624 0.065732 0.061867 0.018061
min 6.981000 9.710000 43.790000 143.500000 0.052630 0.019380 0.000000 0.000000 0.106000 0.049960 ... 7.930000 12.020000 50.410000 185.200000 0.071170 0.027290 0.000000 0.000000 0.156500 0.055040
25% 11.700000 16.170000 75.170000 420.300000 0.086370 0.064920 0.029560 0.020310 0.161900 0.057700 ... 13.010000 21.080000 84.110000 515.300000 0.116600 0.147200 0.114500 0.064930 0.250400 0.071460
50% 13.370000 18.840000 86.240000 551.100000 0.095870 0.092630 0.061540 0.033500 0.179200 0.061540 ... 14.970000 25.410000 97.660000 686.500000 0.131300 0.211900 0.226700 0.099930 0.282200 0.080040
75% 15.780000 21.800000 104.100000 782.700000 0.105300 0.130400 0.130700 0.074000 0.195700 0.066120 ... 18.790000 29.720000 125.400000 1084.000000 0.146000 0.339100 0.382900 0.161400 0.317900 0.092080
max 28.110000 39.280000 188.500000 2501.000000 0.163400 0.345400 0.426800 0.201200 0.304000 0.097440 ... 36.040000 49.540000 251.200000 4254.000000 0.222600 1.058000 1.252000 0.291000 0.663800 0.207500

8 rows × 30 columns

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

In [ ]:
import seaborn as sns

plt.figure(figsize=(10, 5))
ax = sns.boxenplot(data=pd.DataFrame(x), orient="h", palette="Set2")
ax.set(xscale="log", xlim=(1e-4, 1e4), xlabel="Values", ylabel="Features")
plt.show()

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

Нормализация, выбор Scaler

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

$$\text{scaling map} \; : \text{some arbitrary feature domain} \rightarrow \text{definite domain} $$

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

$$\text{standartization map} : f_i \rightarrow (f_i - \text{mean} (\{f_i\})) \cdot \frac{1}{\text{std} (\{f_i\})}$$

Рассмотрим небольшой пример. Пусть у нас есть данные о некоторой группе людей, содержащие два признака: возраст (в годах) и размер дохода (в рублях). Возраст может измениться в диапазоне от 18 до 70 ( интервал 70-18 = 52). А доход от 30 000 р до 500 000 р (интервал 500 000 - 30 000 = 470 000). В таком варианте разница в возрасте имеет меньшее влияние, чем разница в доходе. Получается, что доход становится более важным признаком, изменения в котором влияют больше при сравнении схожести двух людей.

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

Осталось определиться с выбором инструмента. Часто используют следующие варианты: MinMaxScaler, StandardScaler, RobustScaler.

Сравним MinMaxScaler, StandardScaler, RobustScaler для признака data[:,0]. Обратите внимание на ось X

In [ ]:
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler


test = x[:, 0].reshape(-1, 1)

plt.figure(1, figsize=(24, 5))
plt.subplot(141)  # set location
plt.scatter(test, range(len(test)), c=y)
plt.ylabel("Num examples", fontsize=15)
plt.xticks(fontsize=15)
plt.yticks(fontsize=15)
plt.title("Non scaled data", fontsize=18)

# scale data with MinMaxScaler
test_scaled = MinMaxScaler().fit_transform(test)
plt.subplot(142)
plt.scatter(test_scaled, range(len(test)), c=y)
plt.xticks(fontsize=15)
plt.yticks(fontsize=15)
plt.title("MinMaxScaler", fontsize=18)

# scale data  with StandardScaler
test_scaled = StandardScaler().fit_transform(test)
plt.subplot(143)
plt.scatter(test_scaled, range(len(test)), c=y)
plt.xticks(fontsize=15)
plt.yticks(fontsize=15)
plt.title("StandardScaler", fontsize=18)

# scale data  with RobustScaler
test_scaled = RobustScaler().fit_transform(test)
plt.subplot(144)
plt.scatter(test_scaled, range(len(test)), c=y)
plt.xticks(fontsize=15)
plt.yticks(fontsize=15)
plt.title("RobustScaler", fontsize=18)
plt.show()

Идея MinMaxScaler заключается в том, что он преобразует данные из имеющегося диапазона значений в диапазон от 0 до 1. Может быть полезно, если нужно выполнить преобразование, в котором отрицательные значения не допускаются (например, масштабирование RGB пикселей).

$$z_i=\frac{X_i-X_{min}}{X_{max}-X_{min}}$$

$z_i$ — масштабированное значение, $X_i$ — текущее значение, $X_{min}$ и $X_{max}$ — минимальное и максимальное значения имеющихся данных.

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

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

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

И StandardScaler, и MinMaxScaler очень чувствительны к наличию выбросов. RobustScaler использует медиану и основан на процентилях. k-й процентиль – это величина, равная или не превосходящая k процентов чисел во всем имеющемся распределении. Например, 50-й процентиль (медиана) распределения таково, что 50% чисел из распределения не меньше данного числа. Соответственно, RobustScaler не зависит от небольшого числа очень больших предельных выбросов (outliers). Следовательно, результирующий диапазон преобразованных значений признаков больше, чем для предыдущих скэйлеров и, что более важно, примерно одинаков.

$$z_i=\frac{X_i-X_{median}}{IQR}$$

$X_{median}$ — значение медианы, $IQR$ — межквартильный диапазон равный разнице между 75-ым и 25-ым процентилями

In [ ]:
x_norm = StandardScaler().fit_transform(x)  # scaled data
In [ ]:
pd.DataFrame(x_norm).describe()
Out[ ]:
0 1 2 3 4 5 6 7 8 9 ... 20 21 22 23 24 25 26 27 28 29
count 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 ... 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02 5.690000e+02
mean -3.153111e-15 -6.568462e-15 -6.993039e-16 -8.553985e-16 6.081447e-15 -1.136369e-15 -2.997017e-16 1.023981e-15 -1.860648e-15 -1.504752e-15 ... -2.297713e-15 1.742016e-15 -1.198807e-15 6.118909e-16 -5.094929e-15 -2.122887e-15 6.118909e-16 -1.998011e-16 -2.422589e-15 2.497514e-15
std 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 ... 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00 1.000880e+00
min -2.029648e+00 -2.229249e+00 -1.984504e+00 -1.454443e+00 -3.112085e+00 -1.610136e+00 -1.114873e+00 -1.261820e+00 -2.744117e+00 -1.819865e+00 ... -1.726901e+00 -2.223994e+00 -1.693361e+00 -1.222423e+00 -2.682695e+00 -1.443878e+00 -1.305831e+00 -1.745063e+00 -2.160960e+00 -1.601839e+00
25% -6.893853e-01 -7.259631e-01 -6.919555e-01 -6.671955e-01 -7.109628e-01 -7.470860e-01 -7.437479e-01 -7.379438e-01 -7.032397e-01 -7.226392e-01 ... -6.749213e-01 -7.486293e-01 -6.895783e-01 -6.421359e-01 -6.912304e-01 -6.810833e-01 -7.565142e-01 -7.563999e-01 -6.418637e-01 -6.919118e-01
50% -2.150816e-01 -1.046362e-01 -2.359800e-01 -2.951869e-01 -3.489108e-02 -2.219405e-01 -3.422399e-01 -3.977212e-01 -7.162650e-02 -1.782793e-01 ... -2.690395e-01 -4.351564e-02 -2.859802e-01 -3.411812e-01 -4.684277e-02 -2.695009e-01 -2.182321e-01 -2.234689e-01 -1.274095e-01 -2.164441e-01
75% 4.693926e-01 5.841756e-01 4.996769e-01 3.635073e-01 6.361990e-01 4.938569e-01 5.260619e-01 6.469351e-01 5.307792e-01 4.709834e-01 ... 5.220158e-01 6.583411e-01 5.402790e-01 3.575891e-01 5.975448e-01 5.396688e-01 5.311411e-01 7.125100e-01 4.501382e-01 4.507624e-01
max 3.971288e+00 4.651889e+00 3.976130e+00 5.250529e+00 4.770911e+00 4.568425e+00 4.243589e+00 3.927930e+00 4.484751e+00 4.910919e+00 ... 4.094189e+00 3.885905e+00 4.287337e+00 5.930172e+00 3.955374e+00 5.112877e+00 4.700669e+00 2.685877e+00 6.046041e+00 6.846856e+00

8 rows × 30 columns

In [ ]:
plt.figure(figsize=(10, 5))
ax = sns.boxenplot(data=pd.DataFrame(x_norm), orient="h", palette="Set2")
ax.set(xlabel="Values", ylabel="Features")
plt.show()

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

In [ ]:
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from sklearn.neighbors import KNeighborsClassifier


# split data to train/test
x_train, x_test, y_train, y_test = train_test_split(x, y, random_state=25)

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.946
accuracy_test 0.909
In [ ]:
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.979
accuracy_test 0.951

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

Продолжим с классификацией методом ближайших соседей (k-NN).

k-NN для классификации¶

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

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

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

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

Переобучение k-NN¶

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

In [ ]:
import numpy as np


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_norm, y_train)
    q = accuracy_score(y_pred=knn.predict(x_test_norm), y_true=y_test)  # accuracy
    quality[i] = q  # fill quality

plt.figure(figsize=(10, 5))
plt.title("KNN on train", size=20)
plt.xlabel("Neighbors", size=10)
plt.ylabel("Accuracy", size=10)
plt.plot(num_neighbors, quality)
plt.xticks(num_neighbors)
plt.show()

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

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

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

In [ ]:
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_norm, y_train)

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

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

# accuracy plot  on train and test data
plt.figure(figsize=(10, 5))
plt.title("KNN on train vs test", size=20)
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

Метрики¶

Насколько точна ваша модель?

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

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

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

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

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

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

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

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

Accuracy¶

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

$$ \large 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 True Positive (TP) $ $\large False Positive (FP) $
$\large \widehat{y}=0$ $\large False Negative (FN)$ $\large True Negative (TN) $

Precision, recall

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

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

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

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

Accuracy

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

$\large accuracy = \frac{TP + TN}{TP + TN + FP + FN}$

Balanced accuracy

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

$\ BA = \frac{R_1 + R_0}{2} = \frac{1}{2} (\frac{TP}{TP + FN} + \frac{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{precision \cdot recall}{(\beta^2 \cdot precision) + recall}$

$\beta$ в данном случае определяет вес точности в метрике, и при $\beta = 1$ это среднее гармоническое (с множителем 2, чтобы в случае precision = 1 и recall = 1 иметь $\ F_1 = 1$). F-мера достигает максимума при полноте и точности, равными единице, и близка к нулю, если один из аргументов близок к нулю.

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

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

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

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

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

In [ ]:
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 [ ]:
import matplotlib.pyplot as plt
import numpy as np
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 [ ]:
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 считаются для каждого класса:

\

$\displaystyle \large Multiclass Accuracy = \frac{1}{n}\sum_{i=1}^{n} [actual_{i}==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$). Это явно записывается формулой:

$$\text{ROC-AUC} = \frac{\sum_{i=1}^{N} \sum_{j=1}^{N} I[y_{i} < y_{j}]I'[a_{i} < a_{j}] } {\sum_{i=1}^{N} \sum_{j=1}^{N} I[y_{i} < y_{j}]} $$\begin{equation*} I'[a_{i}< a_{j}] = \begin{cases} 0, & \quad a_{i} > a_{j}, \\ 0.5, & \quad a_{i} = a_{j}, \\ 1, & \quad a_{i} < a_{j}. \end{cases} \end{equation*}\begin{equation*} I[y_{i}< y_{j}] = \begin{cases} 0, & \quad y_{i} \geq y_{j}, \\ 1, & \quad y_{i} < y_{j}. \end{cases} \end{equation*}

$ a_{i} $ — выходное значение классификатора на $i$-м объекте, $ y_{i} $ — априорно верная метка класса для того же объекта, $N$ — полное число объектов.

Данное определение можно обобщить на задачу классификации непрерывного множества объектов. Пусть мы взяли два случайных объекта разных классов: $x_i$ класса $0$ и $x_j$, принадлежащий классу $1$. Тогда метрика ROC-AUC равна вероятности того, что в такой паре объектов объект класса $1$ получил оценку выше, нежели объект класса $0$:

$$\text{ROC-AUC}(a) = P(a(x_i) < a(x_j) | y_i=0, y_j=1)$$

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

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

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

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

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

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

  • train — данные, на которых модель учится;
  • validation/test — данные, на которых идет проверка.

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

Посмотрим, как генерируется синтетический датасет. Сделаем зашумлённую синусоиду.

In [ ]:
import numpy as np


def generate_wave_set(n_support=1000, n_train=25, std=0.3):
    data = {}
    # Select a certain number of points from the interval from 0 to 2*pi
    data["support"] = np.linspace(0, 2 * np.pi, num=n_support)
    # For each point calculate the value sin (x) + 1
    data["values"] = np.sin(data["support"]) + 1
    #  Sample features - a certain number of points with a return
    data["x_train"] = np.sort(
        np.random.choice(data["support"], size=n_train, replace=True)
    )
    # target variable - calculate sin(x) + 1 and add noise
    data["y_train"] = (
        np.sin(data["x_train"])
        + 1
        + np.random.normal(0, std, size=data["x_train"].shape[0])
    )
    return data


data = generate_wave_set(1000, 250)
In [ ]:
# Split data
from sklearn.model_selection import train_test_split

x_train, x_test, y_train, y_test = train_test_split(
    data["x_train"], data["y_train"], test_size=0.2
)  # 80% training and 20% test

print("x_train shape:", x_train.shape)
print("x_test shape:", x_test.shape)
x_train shape: (200,)
x_test shape: (50,)

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

Примеры ошибок в данных и при разбиении¶

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

Утечка данных¶

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

Самый простой пример утечки данных — это дублирование одних и тех же объектов в train и test выборках.

Дублирование данных¶

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

Для примера возьмем 10 картинок из CIFAR-10. Будем считать это train данными.

In [ ]:
from torchvision import datasets
from IPython.display import clear_output

np.random.seed(42)

dataset = datasets.CIFAR10("content", train=True, download=True)

clear_output()

data, _, labels, _ = train_test_split(
    dataset.data / 255,  # normalize
    np.array(dataset.targets),
    train_size=10,  # get only 10 imgs
    random_state=42,
    stratify=dataset.targets,
)
print("Data shape:", data.shape)
Data shape: (10, 32, 32, 3)
In [ ]:
import matplotlib.pyplot as plt

fig, axs = plt.subplots(nrows=2, ncols=5, figsize=(10, 5))
for i in range(10):
    axs[i // 5][i % 5].imshow(data[i])
    axs[i // 5][i % 5].set_title(labels[i])
plt.show()

Предположим, картинка из train оказалась в test. Выберем картинку из этих 10 и применим алгоритм k-nearest neighbors (k-NN, k=1).

In [ ]:
x_test = data[3]


# L1 distance
def compute_L1(a, b):
    return np.sum(np.abs(a - b))


# distance calculation
distances = []
for i in range(10):
    l1 = compute_L1(x_test, data[i])
    distances.append(l1)

distances = np.array(distances)
print(distances)
[ 666.98039216  675.2        1027.75294118    0.          826.68235294
  897.95686275  940.43529412 1264.40784314  729.96470588  717.65098039]
In [ ]:
neighbor_index = np.argmin(distances)  # index of nearest neighbor
print(neighbor_index)
3
In [ ]:
data_test, _, labels_test, _ = train_test_split(
    dataset.data / 255,  # normalize
    np.array(dataset.targets),
    train_size=10,  # get only 10 imgs
    random_state=24,
    stratify=dataset.targets,
)

Ближайшим соседом для картинки, просочившейся в test, стала эта же картинка.

Если все данные из test будут присутствовать в train, то мы просто будем искать эту же картинку в train, с чем алгоритм k-nearest neighbors с $k=1$ справляется идеально. Итогом станет $accuracy = 1$ на выходе. Но с применением на незнакомой картинке результат будет хуже.

In [ ]:
fig, axs = plt.subplots(nrows=2, ncols=5, figsize=(10, 5))
for i in range(10):
    axs[i // 5][i % 5].imshow(data_test[i])
    axs[i // 5][i % 5].set_title(labels_test[i])
plt.show()
In [ ]:
x_test = data_test[1]

# distance calculation
distances = []
for i in range(10):
    l1 = compute_L1(x_test, data[i])
    distances.append(l1)

distances = np.array(distances)
print(distances)

neighbor_index = np.argmin(distances)
print(labels[neighbor_index])
[ 700.03137255  737.02745098  853.63529412  710.8627451   913.35686275
 1000.6627451   995.59607843 1353.4745098   864.71764706  722.45098039]
6

Смотрим картинки с train. Ближайшим соседом для кота стала лягушка.

Если вы получили $accuracy = 1$, то, скорее всего, вы что-то делаете не так!

Утечка, спрятанная в признаках¶

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

Самый простой пример: таблица со столбцом — порядковым номером строки row_number, в которую сначала записали все данные, принадлежащие классу -1, а потом все данные, принадлежащие классу +1. Если не удалить этот столбец из данных, то вместо выделения сложных закономерностей модель будет искать решение в виде if row_number > N. В реальных данных записи не будут упорядочены.

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

In [ ]:
from sklearn.datasets import load_iris

iris = load_iris()
print(iris.target)
[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]

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

Иногда “подсказки” спрятаны внутри признаков, поэтому важно понимать с какими данными вы работаете и какую задачу решаете.

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

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

Перемешивание данных¶

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

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

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

In [ ]:
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 [ ]:
data, labels = load_iris(return_X_y=True)
print("DataSet labels: ", 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}
In [ ]:
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 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]
Test labels:   [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]
Train statistics:  {0: 50, 1: 25}
Test statistics:   {1: 25, 2: 50}
In [ ]:
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 labels:  [1 2 1 0 1 2 0 0 1 1 0 2 0 0 1 1 2 1 2 2 1 0 0 2 2 0 0 0 1 2 0 2 2 0 1 1 2
 1 2 0 2 1 2 1 1 1 0 1 1 0 1 2 2 0 1 2 2 0 2 0 1 2 2 1 2 1 1 2 2 0 1 2 0 1
 2]
Test labels:   [1 0 2 1 1 0 1 2 1 1 2 0 0 0 0 1 2 1 1 2 0 2 0 2 2 2 2 2 0 0 0 0 1 0 0 2 1
 0 0 0 2 1 1 0 0 1 2 2 1 2 1 2 1 0 2 1 0 0 0 1 2 0 0 0 1 0 1 2 0 1 2 0 2 2
 1]
Train statistics:  {1: 27, 2: 27, 0: 21}
Test statistics:   {1: 23, 0: 29, 2: 23}
In [ ]:
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 labels:  [0 1 2 1 0 2 0 0 0 2 1 0 2 2 2 0 1 0 1 0 0 2 2 0 0 0 1 1 2 0 2 1 2 1 2 2 0
 1 1 0 1 0 2 2 0 1 2 1 0 1 1 0 2 0 1 0 2 1 2 2 0 2 2 2 1 1 2 1 2 1 0 1 1 1
 0]
Test labels:   [0 0 0 0 2 0 1 2 1 2 1 0 1 1 0 0 0 2 1 1 2 2 2 1 2 2 1 0 1 1 2 0 0 1 0 2 2
 2 1 1 1 0 0 1 2 0 1 0 1 1 1 1 2 0 1 2 2 2 1 0 1 2 2 0 0 2 0 0 2 0 1 0 2 2
 2]
Train statistics:  {0: 25, 1: 25, 2: 25}
Test statistics:   {0: 25, 2: 25, 1: 25}

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

Данные из различных источников¶

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

Пример: вы анализируете данные ЭКГ на предмет патологий. У вас есть три источника данных:

  • аппарат ЭКГ в кардиологическом отделении (много патологий),
  • аппарат ЭКГ, который используют на медосмотрах (мало патологий),
  • аппарат ЭКГ из приемного покоя больницы (среднее число патологий).

Каждый прибор имеет свои особенности: характерные шумы, точность измерения и т.п. Если модель научится определять, с какого прибора пришли данные, она получит “подсказку”, которой не будет при поступлении данных с “незнакомого” прибора. Хорошим решением будет оставить данные с аппарата ЭКГ из приемного покоя больницы для test, а обучаться только на данных с аппарата ЭКГ в кардиологическом отделении и аппарата ЭКГ, который используют на медосмотрах. Это позволит оценить, как обученная модель работает с “незнакомым" прибором.

Подбор гиперпараметров на тестовой выборке¶

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

В домашнем задании вы будете решать задачу классификации изображений CIFAR-10 (набор фотографий, разделенный на 10 классов) методом ближайших соседей k-NN с использованием расстояний L1 (Manhatten distance — сумма абсолютных разностей между пикселями) и L2 (Euclidian distance).

$\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}$

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

Другим параметром модели, который вы будете варьировать, будет количество ближайших соседей k.

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

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

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

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

Как решать проблему подбора гиперпараметров? Первое, что приходит в голову: давайте посчитаем accuracy для тестовой выборки для множества гиперпараметров и выберем лучший. Каждый раз, когда мы заглядываем в test, чтобы изменить параметры, мы подстраиваемся под test, и это плохо!

Интуицию о том, что это плохо, можно получить, сравнив Public и Private Score в Leaderbord соревнования на Kaggle. Kaggle — это платформа для соревнований по анализу данных и машинному обучению. Чаще всего соревнование проводится следующим образом: участникам предоставляются датасеты, разделенные на train с целевой разметкой target и test, для которого необходимо сделать предсказание predict в нужном формате. Отправленное предсказание predict делится на две части: Public и Private. По этим частям считаются очки Score, характеризующие качество результата, и формируются таблицы лидеров Leaderboard. Public Leaderboard доступен всем желающим в любое время. Private Leaderboard доступен только по окончании соревнования, и именно по нему раздают призы. Частая ошибка новичка — начать подстраивать модель по Public Leaderboard. Часто в Private Leaderboard можно видеть участника, который сдвинулся на десятки строчек вниз.

Leaderbord соревнования

Чтобы понять, что происходит, попробуем смоделировать ситуацию, когда мы просто пытаемся угадать predict, не анализируя данные, но ориентируясь на Public Score. Предположим, у нас есть соревнование по бинарной классификации (два класса 1 и 0), со следующим target.

In [ ]:
import random

random.seed = 42
target = [random.randint(0, 1) for _ in range(200)]

Разделим на Public и Private.

In [ ]:
target_public = target[:100]
target_private = target[100:]

Для подбора ответа будем использовать следующий алгоритм:

  1. Генерируем случайный вектор.
  2. Считаем Public accuracy
  3. Если это Public accuracy лучше предыдущего, сохраняем вектор, как predict.
In [ ]:
from sklearn.metrics import accuracy_score

public_accuracy_list = []
private_accuracy_list = []
best_public_accuracy = 0

for _ in range(1000):
    ans = [random.randint(0, 1) for _ in range(200)]

    public_accuracy = accuracy_score(target_public, ans[:100])
    private_accuracy = accuracy_score(target_private, ans[100:])

    if public_accuracy > best_public_accuracy:
        predict = ans
        best_public_accuracy = public_accuracy
        best_private_accuracy = private_accuracy

    public_accuracy_list.append(best_public_accuracy)
    private_accuracy_list.append(best_private_accuracy)
In [ ]:
plt.figure(figsize=(10, 4))
plt.plot(range(1000), public_accuracy_list, label="Public accuracy")
plt.plot(range(1000), private_accuracy_list, label="Private accuracy")
plt.legend()
plt.show()

Таким образом, мы можем случайно подстроиться под Public, завысив оценку целевой метрики (что видно на Private).

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

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

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

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

In [ ]:
import numpy as np
import matplotlib.pyplot as plt
import sklearn.datasets
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("KNN on train vs val", 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 [ ]:
split_and_train(x, y, random_state=42)
In [ ]:
split_and_train(x, y, random_state=4)

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

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

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

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

Часто применяется следующий подход, называемый K-Fold кросс-валидацией:

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

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

In [ ]:
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

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

Типичный пример временного ряда
Source: Статья на хабре об анализе временных рядов на Python

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

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

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

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

In [ ]:
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)
accuracy = cross_val_score(knn, x_train, y_train, cv=cv, scoring="accuracy")
print("3NN accuracy: ", accuracy)
print(
    "%0.2f accuracy with a standard deviation of %0.2f"
    % (accuracy.mean(), accuracy.std())
)

knn = KNeighborsClassifier(n_neighbors=5)
accuracy = cross_val_score(knn, x_train, y_train, cv=cv, scoring="accuracy")
print("5NN accuracy: ", accuracy)
print(
    "%0.2f accuracy with a standard deviation of %0.2f"
    % (accuracy.mean(), accuracy.std())
)
3NN accuracy:  [0.91666667 0.95833333 0.95833333 0.95833333 1.        ]
0.96 accuracy with a standard deviation of 0.03
5NN accuracy:  [0.91666667 1.         0.95833333 1.         1.        ]
0.97 accuracy with a standard deviation of 0.03

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

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

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

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

Кросс-валидация для научных исследований: на что обратить внимание¶

При кросс-валидации, чтобы получить адекватную оценку метрик, следует соблюдать те же правила, что и при разбиении на train и test, а именно:

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

GridSearch¶

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

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

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

In [ ]:
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 [ ]:
x_train, x_test, y_train, y_test = train_test_split(
    x, y, train_size=0.8, stratify=y, random_state=42
)

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

In [ ]:
from sklearn.model_selection import GridSearchCV

"""
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[ ]:
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 [ ]:
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 [ ]:
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 [ ]:
list(model.cv_results_.keys())
Out[ ]:
['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 [ ]:
plt.figure(figsize=(10, 4))
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.show()

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

In [ ]:
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 [ ]:
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. Если в GridSearch поиск параметров происходит по фиксированному списку значений, то RandomizedSearch умеет работать с непрерывными значениями, случайно выбирая тестируемые значения, что может привести к более точной настройке гиперпараметров.