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

Для начала я хотел рассмотреть три похожие, но разные задачи:

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

В этой статье почти не будет математики, но будет довольно много кода. (Версия Jupyter). В некоторых примерах будет использоваться набор данных от компании Renthop, который используется в конкурсе Two Sigma Connect: запросы о сдаче в аренду Kaggle. В этой задаче вам необходимо спрогнозировать популярность нового объявления о сдаче в аренду, т. Е. Классифицировать объявление по трем классам: ['low', 'medium' , 'high']. Для оценки решений мы будем использовать метрику потерь журнала (чем меньше, тем лучше). Тем, у кого нет учетной записи Kaggle, придется зарегистрироваться; вам также необходимо принять правила конкурса, чтобы загрузить данные.

import json
import pandas as pd

# Let's load the dataset from Renthop right away
with open('train.json', 'r') as raw_data:
    data = json.load(raw_data)
    df = pd.DataFrame(data)

Краткое содержание статьи

1. Извлечение функций
1.1. Тексты
1.2. Изображения
1.3. Геопространственные данные
1.4. Дата и время
1.5. Временные ряды, сеть и т. Д.

2. Преобразование признаков
2.1. Нормализация и изменение распределения
2.2. Взаимодействия
2.3. Заполнение пропущенных значений

3. Выбор функций
3.1. Статистические подходы
3.2. Отбор моделированием
3.3. Поиск по сетке

1. Извлечение признаков

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

1.1. Тексты

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

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

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

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

from functools import reduce 
import numpy as np

texts = [['i', 'have', 'a', 'cat'], 
        ['he', 'have', 'a', 'dog'], 
        ['he', 'and', 'i', 'have', 'a', 'cat', 'and', 'a', 'dog']]

dictionary = list(enumerate(set(list(reduce(lambda x, y: x + y, texts)))))

def vectorize(text): 
    vector = np.zeros(len(dictionary)) 
    for i, word in dictionary: 
        num = 0 
        for w in text: 
            if w == word: 
                num += 1 
        if num: 
            vector[i] = num 
    return vector

for t in texts: 
    print(vectorize(t))

Ожидаемый результат:

[0. 1. 0. 1. 1. 0. 1.]
[0. 1. 1. 0. 1. 1. 0.]
[2. 1. 1. 1. 2. 1. 1.]

Вот иллюстрация процесса:

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

При использовании таких алгоритмов, как Bag of Words, мы теряем порядок слов в тексте, а это означает, что тексты «у меня нет коров» и «нет, у меня есть коровы» будут отображаться одинаковыми после векторизации, хотя на самом деле они противоположное значение. Чтобы избежать этой проблемы, мы можем вернуться к шагу токенизации и вместо этого использовать N-граммы (последовательность из N последовательных токенов).

In : from sklearn.feature_extraction.text import CountVectorizer

In : vect = CountVectorizer(ngram_range=(1,1))

In : vect.fit_transform(['no i have cows', 'i have no cows']).toarray() 
Out: 
array([[1, 1, 1],
      [1, 1, 1]], dtype=int64)

In : vect.vocabulary_ 
Out: {'cows': 0, 'have': 1, 'no': 2}

In : vect = CountVectorizer(ngram_range=(1,2))

In : vect.fit_transform(['no i have cows', 'i have no cows']).toarray() 
Out: 
array([[1, 1, 1, 0, 1, 0, 1], 
      [1, 1, 0, 1, 1, 1, 0]], dtype=int64)

In : vect.vocabulary_ 
Out: {'cows': 0, 
      'have': 1,
      'have cows': 2,
      'have no': 3,
      'no': 4,
      'no cows': 5,
      'no have': 6}

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

from scipy.spatial.distance import euclidean
from sklearn.feature_extraction.text import CountVectorizer

vect = CountVectorizer(ngram_range=(3,3), analyzer='char_wb')

n1, n2, n3, n4 = vect.fit_transform(['andersen', 'petersen', 'petrov', 'smith']).toarray()

euclidean(n1, n2), euclidean(n2, n3), euclidean(n3, n4)
# (2.8284271247461903, 3.1622776601683795, 3.3166247903554)

Добавление к идее мешка слов: слова, которые редко встречаются в корпусе (во всех документах этого набора данных), но присутствуют в этом конкретном документе, могут быть более важными. Тогда имеет смысл увеличить вес слов, относящихся к предметной области, чтобы отделить их от общих слов. Этот подход называется TF-IDF (термин частота-обратная частота документа), который нельзя записать в несколько строк, поэтому вам следует изучить детали в таких ссылках, как эта вики. Вариант по умолчанию следующий:

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

Используя эти алгоритмы, можно получить рабочее решение простой задачи, которое может служить основой. Однако для тех, кто не любит классику, есть новые подходы. Самый популярный метод в новой волне - Word2Vec, но есть и несколько альтернатив (GloVe, Fasttext и т. Д.).

Word2Vec - это частный случай алгоритмов встраивания слов. Используя Word2Vec и аналогичные модели, мы можем не только векторизовать слова в многомерном пространстве (обычно несколько сотен измерений), но и сравнивать их семантическое сходство. Это классический пример операций, которые можно выполнять над векторизованными концептами: король - мужчина + женщина = королева.

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

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

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

1.2. Изображений

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

В то время, когда графические процессоры были слабее и возрождения нейронных сетей еще не произошло, создание функций из изображений было отдельной сложной областью. Приходилось работать на низком уровне, определяя углы, границы регионов, статистику распределения цветов и так далее. Опытные специалисты в области компьютерного зрения могут провести много параллелей между старыми подходами и нейронными сетями; в частности, сверточные слои в современных сетях похожи на каскады Хаара. Если вам интересно узнать больше, вот пара ссылок на некоторые интересные библиотеки: skimage и SimpleCV.

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

from keras.applications.resnet50 import ResNet50, preprocess_input
from keras.preprocessing import image 
from scipy.misc import face 
import numpy as np

resnet_settings = {'include_top': False, 'weights': 'imagenet'}
resnet = ResNet50(**resnet_settings)

img = image.array_to_img(face())

# What a cute raccoon!

img = img.resize((224, 224))

# In real life, you may need to pay more attention to resizing

x = image.img_to_array(img) 
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)

# Need an extra dimension because model is designed to work with an array of images - i.e. tensor shaped (batch_size, width, height, n_channels)

features = resnet.predict(x)

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

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

Если на изображении есть текст, его можно прочитать, не разбираясь в сложной нейронной сети. Например, посмотрите pytesseract.

In : import pytesseract

In : from PIL import Image

In : import requests

In : from io import BytesIO

In : img = 'http://ohscurrent.org/wp-content/uploads/2015/09/domus-01-google.jpg'

##### Just a random picture from search

In : img = requests.get(img) 
...: img = Image.open(BytesIO(img.content)) 
...: text = pytesseract.image_to_string(img) 
...:

In : text 
Out: 'Google'

Надо понимать, что pytesseract - это не решение всего.

##### This time we take a picture from Renthop

In : img = requests.get('https://photos.renthop.com/2/8393298_6acaf11f030217d05f3a5604b9a2f70f.jpg') 
...: img = Image.open(BytesIO(img.content)) 
...: pytesseract.image_to_string(img) 
...:

Out: 'Cunveztible to 4}»'

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

1.3. Геопространственные данные

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

Геопространственные данные часто представлены в виде адресов или координат (Широта, Долгота). В зависимости от задачи могут потребоваться две взаимно обратные операции: геокодирование (восстановление точки из адреса) и обратное геокодирование (восстановление адреса из точки). На практике обе операции доступны через внешние API из Google Maps или OpenStreetMap. У разных геокодеров есть свои особенности, и качество варьируется от региона к региону. К счастью, существуют универсальные библиотеки, такие как geopy, которые действуют как оболочки для этих внешних служб.

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

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

In : import reverse_geocoder as revgc

In : revgc.search((df.latitude, df.longitude)) 
Loading formatted geocoded file... 
Out: [OrderedDict([('lat', '40.74482'), 
                   ('lon', '-73.94875'), 
                   ('name', 'Long Island City'), 
                   ('admin1', 'New York'), 
                   ('admin2', 'Queens County'), 
                   ('cc', 'US')])]

При работе с геокодированием нельзя забывать о том, что адреса могут содержать опечатки, что делает обязательным этап очистки данных. Координаты содержат меньше опечаток, но их положение может быть неверным из-за шума GPS или плохой точности в таких местах, как туннели, центральные районы и т. Д. Если источником данных является мобильное устройство, геолокация может определяться не GPS, а сетями WiFi в область, которая ведет к дырам в космосе и телепортации. Когда вы путешествуете по Манхэттену, из Чикаго может неожиданно оказаться в точке доступа Wi-Fi.

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

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

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

1.4. Дата и время

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

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

df['dow'] = df['created'].apply(lambda x: x.date().weekday())
df['is_weekend'] = df['created'].apply(lambda x: 1 if x.date().weekday() in (5, 6) else 0)

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

Вопрос: Что общего между китайским Новым годом, нью-йоркским марафоном и инаугурацией Трампа?

О: Все они должны быть внесены в календарь потенциальных аномалий.

Работать с часом (минутой, днем ​​месяца ...) не так просто, как кажется. Если вы используете час как реальную переменную, мы немного противоречим природе данных: 0<23 в то время как 0:00:00 02.01> 01.01 23:00:00. Для некоторых проблем это может быть критичным. В то же время, если вы закодируете их как категориальные переменные, вы создадите большое количество признаков и потеряете информацию о близости - разница между 22 и 23 будет такой же, как разница между 22 и 7.

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

def make_harmonic_features(value, period=24):
    value *= 2 * np.pi / period 
    return np.cos(value), np.sin(value)

Это преобразование сохраняет расстояние между точками, что важно для алгоритмов, оценивающих расстояние (kNN, SVM, k-means ...)

In : from scipy.spatial import distance

In : euclidean(make_harmonic_features(23), make_harmonic_features(1)) 
Out: 0.5176380902050424

In : euclidean(make_harmonic_features(9), make_harmonic_features(11)) 
Out: 0.5176380902050414

In : euclidean(make_harmonic_features(9), make_harmonic_features(21)) 
Out: 2.0

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

1.5. Временные ряды, сеть и т. Д.

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

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

In : ua = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/56.0.2924.76 Chrome/ 
...: 56.0.2924.76 Safari/537.36'

In : import user_agents

In : ua = user_agents.parse(ua)

In : ua.is_bot 
Out: False

In : ua.is_mobile 
Out: False

In : ua.is_pc 
Out: True

In : ua.os.family 
Out: 'Ubuntu'

In : ua.os.version 
Out: ()

In : ua.browser.family 
Out: 'Chromium'

In : ua.os.version 
Out: ()

In : ua.browser.version 
Out: (56, 0, 2924)

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

Помимо операционной системы и браузера, вы можете посмотреть реферер (не всегда доступен), http_accept_language и другую метаинформацию.

Следующая полезная информация - это IP-адрес, из которого вы можете извлечь страну и, возможно, город, провайдера и тип подключения (мобильное / стационарное). Вы должны понимать, что существует множество прокси и устаревших баз данных, поэтому эта функция может содержать шум. Гуру сетевого администрирования могут попытаться извлечь еще более причудливые функции, такие как предложения по использованию VPN. Кстати, данные с IP-адреса хорошо сочетаются с http_accept_language: если пользователь сидит на чилийских прокси и локаль браузера ru_RU, что-то нечистое и стоит посмотреть в соответствующем столбце таблицы (is_traveler_or_proxy_user).

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

2. Преобразование функций

2.1. Нормализация и изменение распределения

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

Есть и чисто инженерные причины: np.log - это способ работы с большими числами, которые не помещаются в np.float64. Это скорее исключение, чем правило; часто это вызвано желанием адаптировать набор данных к требованиям алгоритма. Параметрические методы обычно требуют минимум симметричного и унимодального распределения данных, что не всегда дается в реальных данных. Могут быть более строгие требования; вспомните нашу предыдущую статью о линейных моделях.

Однако требования к данным предъявляются не только параметрическими методами; K ближайших соседей предсказывают полную чушь, если характеристики не нормализованы, например. когда одно распределение находится около нуля и не выходит за пределы (-1, 1), а диапазон другого - порядка сотен тысяч.

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

Самым простым преобразованием является стандартное масштабирование (или нормализация Z-оценки):

Обратите внимание, что стандартное масштабирование не делает распределение нормальным в строгом смысле слова.

In : from sklearn.preprocessing import StandardScaler
In : from scipy.stats import beta
In : from scipy.stats import shapiro
In : data = beta(1, 10).rvs(1000).reshape(-1, 1)
In : shapiro(data) 
Out: (0.8783774375915527, 3.0409122263582326e-27)
# Value of the statistic, p-value
In : shapiro(StandardScaler().fit_transform(data)) 
Out: (0.8783774375915527, 3.0409122263582326e-27)
# With such p-value we'd have to reject the null hypothesis of normality of the data

Но в какой-то мере защищает от выбросов:

In : data = np.array([1, 1, 0, -1, 2, 1, 2, 3, -2, 4, 100]).reshape(-1, 1).astype(np.float64)
In : StandardScaler().fit_transform(data) 
Out: 
array([[-0.31922662], 
       [-0.31922662],
       [-0.35434155],
       [-0.38945648],
       [-0.28411169],
       [-0.31922662],
       [-0.28411169],
       [-0.24899676],
       [-0.42457141],
       [-0.21388184],
       [ 3.15715128]])
In : (data – data.mean()) / data.std() 
Out: 
array([[-0.31922662], 
       [-0.31922662],
       [-0.35434155],
       [-0.38945648],
       [-0.28411169],
       [-0.31922662],
       [-0.28411169],
       [-0.24899676],
       [-0.42457141],
       [-0.21388184],
       [ 3.15715128]])

Другой довольно популярный вариант - MinMax Scaling, при котором все точки помещаются в заданный интервал (обычно (0, 1)).

In : from sklearn.preprocessing import MinMaxScaler
In : MinMaxScaler().fit_transform(data) 
Out: 
array([[ 0.02941176],
       [ 0.02941176],
       [ 0.01960784],
       [ 0.00980392],
       [ 0.03921569],
       [ 0.02941176],
       [ 0.03921569],
       [ 0.04901961],
       [ 0. ],
       [ 0.05882353],
       [ 1. ]])
In : (data – data.min()) / (data.max() – data.min()) 
Out: 
array([[ 0.02941176],
       [ 0.02941176],
       [ 0.01960784],
       [ 0.00980392],
       [ 0.03921569],
       [ 0.02941176],
       [ 0.03921569],
       [ 0.04901961],
       [ 0. ],
       [ 0.05882353],
       [ 1. ]])

StandardScaling и MinMax Scaling имеют схожие приложения и часто более или менее взаимозаменяемы. Однако, если алгоритм включает вычисление расстояний между точками или векторами, по умолчанию выбирается StandardScaling. Но MinMax Scaling полезен для визуализации путем переноса функций в интервал (0, 255).

Если предположить, что некоторые данные не имеют нормального распределения, а описываются логнормальным распределением, их можно легко преобразовать в нормальное распределение:

In : from scipy.stats import lognorm
In : data = lognorm(s=1).rvs(1000)
In : shapiro(data)
Out: (0.05714237689971924, 0.0)
In : shapiro(np.log(data))
Out: (0.9980740547180176, 0.3150389492511749)

Логнормальное распределение подходит для описания заработной платы, цен на ценные бумаги, городского населения, количества комментариев к статьям в Интернете и т. Д. Однако для применения этой процедуры базовое распределение не обязательно должно быть логнормальным; вы можете попробовать применить это преобразование к любому дистрибутиву с тяжелым правым хвостом. Кроме того, можно попробовать использовать другие подобные преобразования, формулируя собственные гипотезы о том, как приблизить доступное распределение к нормальному. Примерами таких преобразований являются преобразование Бокса-Кокса (логарифм является частным случаем преобразования Бокса-Кокса) или преобразование Йео-Джонсона (расширяет область применимости до отрицательных чисел). Кроме того, вы также можете попробовать добавить к функции константу - np.log (x + const).

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

График Q-Q для логнормального распределения

График Q-Q для того же распределения после логарифма

# Let's draw plots!
In : import statsmodels.api as sm
Let's take the price feature from Renthop dataset and filter by hands the most extreme values for clarity
In : price = df.price[(df.price <= 20000) & (df.price > 500)]
In : price_log = np.log(price)
In : price_mm = MinMaxScaler().fit_transform(price.values.reshape(-1, 1).astype(np.float64)).flatten()
A lot of gestures so that sklearn didn't shower us with warnings
In : price_z = StandardScaler().fit_transform(price.values.reshape(-1, 1).astype(np.float64)).flatten()
In : sm.qqplot(price_log, loc=price_log.mean(), scale=price_log.std()).savefig('qq_price_log.png')
In : sm.qqplot(price_mm, loc=price_mm.mean(), scale=price_mm.std()).savefig('qq_price_mm.png')
In : sm.qqplot(price_z, loc=price_z.mean(), scale=price_z.std()).savefig('qq_price_z.png')

Q-Q график начального признака

График Q-Q после StandardScaler. Форма не меняется

График Q-Q после MinMaxScaler. Форма не меняется

График Q-Q после логарифма. Дела налаживаются!

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

2.2. Взаимодействия

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

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

rooms = df["bedrooms"].apply(lambda x: max(x, .5))
# Avoid division by zero; .5 is chosen more or less arbitrarily
df["price_per_bedroom"] = df["price"] / rooms

Вам следует ограничить себя в этом процессе. Если количество функций ограничено, можно сгенерировать все возможные взаимодействия, а затем отсеять ненужные, используя методы, описанные в следующем разделе. Кроме того, не все взаимодействия между функциями должны иметь физический смысл; например, полиномиальные признаки (см. sklearn.preprocessing.PolynomialFeatures) часто используются в линейных моделях и их практически невозможно интерпретировать.

2.3. Заполнение пропущенных значений

Не многие алгоритмы могут работать с пропущенными значениями, а в реальном мире данные часто имеют пробелы. К счастью, это одна из задач, для решения которой не нужно никакого творчества. Обе ключевые библиотеки Python для анализа данных предоставляют простые в использовании решения: pandas.DataFrame.fillna и sklearn.preprocessing.Imputer.

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

  • кодируйте отсутствующие значения отдельным пустым значением, например "n/a" (для категориальных переменных);
  • использовать наиболее вероятное значение признака (среднее или медианное значение для числовых переменных, наиболее распространенное значение для категориальных переменных);
  • или, наоборот, кодировать с некоторым экстремальным значением (хорошо для моделей дерева решений, так как позволяет модели разделить пропущенные и не пропущенные значения);
  • для упорядоченных данных (например, временного ряда) выберите соседнее значение - следующее или предыдущее.

Простые в использовании библиотечные решения иногда предлагают придерживаться чего-то вроде df = df.fillna(0) и не беспокоиться о пробелах. Но это не лучшее решение: подготовка данных занимает больше времени, чем построение моделей, поэтому бездумное заполнение пробелов может скрыть ошибку в обработке и повредить модель.

3. Выбор функции

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

3.1. Статистические подходы

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

In : from sklearn.feature_selection import VarianceThreshold
In : from sklearn.datasets import make_classification
In : x_data_generated, y_data_generated = make_classification()
In : x_data_generated.shape Out: (100, 20)
In : VarianceThreshold(.7).fit_transform(x_data_generated).shape Out: (100, 19)
In : VarianceThreshold(.8).fit_transform(x_data_generated).shape Out: (100, 18)
In : VarianceThreshold(.9).fit_transform(x_data_generated).shape Out: (100, 15)

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

In : from sklearn.feature_selection import SelectKBest, f_classif
In : x_data_kbest = SelectKBest(f_classif, k=5).fit_transform(x_data_generated, y_data_generated)
In : x_data_varth = VarianceThreshold(.9).fit_transform(x_data_generated)
In : from sklearn.linear_model import LogisticRegression
In : from sklearn.model_selection import cross_val_score
In : cross_val_score(LogisticRegression(), x_data_generated, y_data_generated, scoring='neg_log_loss').mean() Out: -0.45367136377981693
In : cross_val_score(LogisticRegression(), x_data_kbest, y_data_generated, scoring='neg_log_loss').mean() Out: -0.35775228616521798
In : cross_val_score(LogisticRegression(), x_data_varth, y_data_generated, scoring='neg_log_loss').mean() Out: -0.44033042718359772

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

3.2. Подбор моделированием

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

# Synthetic example
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_selection import SelectFromModel
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline
x_data_generated, y_data_generated = make_classification()
pipe = make_pipeline(SelectFromModel(estimator=RandomForestClassifier()), LogisticRegression())
lr = LogisticRegression()
rf = RandomForestClassifier()
print(cross_val_score(lr, x_data_generated, y_data_generated, scoring='neg_log_loss').mean())
print(cross_val_score(rf, x_data_generated, y_data_generated, scoring='neg_log_loss').mean())
print(cross_val_score(pipe, x_data_generated, y_data_generated, scoring='neg_log_loss').mean())
# -0.455661299521484
# -0.6817083161800542
# -0.28572375767268404

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

x_data, y_data = get_data() x_data = x_data.values
pipe1 = make_pipeline(StandardScaler(), SelectFromModel(estimator=RandomForestClassifier()), LogisticRegression())
pipe2 = make_pipeline(StandardScaler(), LogisticRegression())
rf = RandomForestClassifier()
print('LR + selection: ', cross_val_score(pipe1, x_data, y_data, scoring='neg_log_loss').mean())
print('LR: ', cross_val_score(pipe2, x_data, y_data, scoring='neg_log_loss').mean())
print('RF: ', cross_val_score(rf, x_data, y_data, scoring='neg_log_loss').mean())
# LR + selection: -0.714208124619
# LR: -0.669572736183
# It got worse!
# RF: -2.13486716798

3.3. Поиск по сетке

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

Поиск всех комбинаций обычно занимает слишком много времени, поэтому вы можете попытаться уменьшить пространство поиска. Исправьте небольшое число N, переберите все комбинации из N функций, выберите лучшую комбинацию, а затем переберите комбинации (N + 1) функций так, чтобы предыдущая лучшая комбинация функций была фиксированной и учитывалась только одна новая функция. . Можно повторять до тех пор, пока мы не достигнем максимального количества характеристик или пока качество модели не перестанет значительно улучшаться. Этот алгоритм называется Последовательный выбор признаков.

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

In : selector = SequentialFeatureSelector(LogisticRegression(), scoring='neg_log_loss', verbose=2, k_features=3, forward=False, n_jobs=-1)
In : selector.fit(x_data_scaled, y_data)
In : selector.fit(x_data_scaled, y_data)
[2017-03-30 01:42:24] Features: 45/3 -- score: -0.682830838803 [2017-03-30 01:44:40] Features: 44/3 -- score: -0.682779463265 [2017-03-30 01:46:47] Features: 43/3 -- score: -0.682727480522 [2017-03-30 01:48:54] Features: 42/3 -- score: -0.682680521828 [2017-03-30 01:50:52] Features: 41/3 -- score: -0.68264297879
[2017-03-30 01:52:46] Features: 40/3 -- score: -0.682607753617 [2017-03-30 01:54:37] Features: 39/3 -- score: -0.682570678346 [2017-03-30 01:56:21] Features: 38/3 -- score: -0.682536314625 [2017-03-30 01:58:02] Features: 37/3 -- score: -0.682520258804 [2017-03-30 01:59:39] Features: 36/3 -- score: -0.68250862986
[2017-03-30 02:01:17] Features: 35/3 -- score: -0.682498213174
...
[2017-03-30 02:21:09] Features: 10/3 -- score: -0.68657335969
[2017-03-30 02:21:18] Features: 9/3 -- score: -0.688405548594
[2017-03-30 02:21:26] Features: 8/3 -- score: -0.690213724719
[2017-03-30 02:21:32] Features: 7/3 -- score: -0.692383588303
[2017-03-30 02:21:36] Features: 6/3 -- score: -0.695321584506
[2017-03-30 02:21:40] Features: 5/3 -- score: -0.698519960477
[2017-03-30 02:21:42] Features: 4/3 -- score: -0.704095390444
[2017-03-30 02:21:44] Features: 3/3 -- score: -0.713788301404
But improvement couldn’t last forever

Посмотрите, как этот подход реализован в одном простом, но элегантном ядре Kaggle.

4. Задание №6.

Полные версии заданий объявляются каждую неделю при новом запуске курса (1 октября 2018 г.). А пока вы можете потренироваться с демо-версией: Kaggle Kernel, nbviewer. У нас нет отдельного задания по разработке функций (это делается в конкурсах Kaggle Inclass), поэтому это демонстрационное задание подлежит регрессу.

Open Data Science желает вам удачи в выполнении задания, а также чистых данных и полезных функций в вашей реальной работе!

Автор: Арсений Кравченко. Перевод и редакция: Кристина Буцко, Юрий Кашницкий, Анна Ларионова, Анастасия Манохина, Евгений Сушко, Егор Полусмак, Юаньюань Пао.