Data Science проект от исследования до внедрения на примере Говорящей шляпы

в 11:15, , рубрики: CRISP, crisp-dm, data mining, data science, docker, harry potter, ods, python, Блог компании Open Data Science, искусственный интеллект, машинное обучение

Data Science проект от исследования до внедрения на примере Говорящей шляпы - 1

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

В статье мы сделаем простую ML-модель, которая распределяет людей на факультеты Гарри Поттера в зависимости от их имени и фамилии, пройдя процесс небольшого исследования следуя методологии CRISP. А именно мы:

  • Сформулируем задачу;
  • Исследуем возможные подходы к ее решению и сформулируем требования к данным (Методы решения и данные) ;
  • Соберем необходимые данные (Методы решения и данные);
  • Изучим собранный датасет (Exploratory Research);
  • Извлечем признаки из сырых данных (Feature Engineering);
  • Обучим модель машинного обучения (Model evaluation);
  • Сравним полученные результаты, оценим качество полученных решений и при необходимости повторим пункты 2-6;
  • Упакуем решение в сервис, который можно будет использовать (Продакшн).

Data Science проект от исследования до внедрения на примере Говорящей шляпы - 2

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

Если вы уже погружены в прекрасный и чудесный мир Data Science и постоянно Кэгглите пока никто не видит, или (упаси бог) любите во время встреч с коллегами померяться длиной своего Хадупа, то скорее всего статья покажется вам простой и неинтересной. Более того: качество итоговых моделей — не главная ценность этой статьи. Мы вас предупредили. Поехали.

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

Формулируем задачу

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

По сути дела, мы хотим получить черный ящик:

"Гарри поттер" => [?] => Griffindor

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

Раз уж речь зашла про вероятности, то формализуем задачу в более строгих математических терминах. С точки зрения Data Science, мы решаем задачу классификации, а именно назначения объекту (строке, в виде имени и фамилии) некоторого класса (по факту это просто ярлык, или метка, которая может быть цифрой или 4 переменными, которые имеют значение да/нет). Мы понимаем, что как минимум в случае Гарри будет корректным давать 2 ответа: Гриффиндор и Слизерин, поэтому лучше будет предсказывать не конкретный факультет, на который определяет шляпа, а вероятность того, что человек будет распределен на этот факультет, поэтому наше решение будет иметь в вид некоторой функции

$f (<Имя> <Фамилия>)=(P_{griffindor}; P_{ravenclaw}; P_{hufflpuff}; P_{slitherin})$

Метрики и оценка качества

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

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

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

Бытовое «а давайте выбирать самый лучший» для нас будет ROC AUC. Это именно то, что мы хотим от метрики в данном случае: чем меньше ложных срабатываний и чем точнее фактическое предсказание, тем больше будет ROC AUC.

У идеальной модели ROC AUC равен 1, у идеальной случайной модели, которая определяет классы абсолютно случайно — 0.5.

$ROC AUC in [0.5;1]$

Алгоритмы

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

Вопреки популярному мнению, Data Science не ограничивается одними нейросетями, и для популяризации этой мысли, в данной статье нейронные сети оставлены в качестве упражнения любопытному читателю. Те, кто не проходил ни одного курса по анализу данных (особенно, субъективно лучшего — от ОДС), или просто читали n новостей про машинное обучение или ИИ, которые сейчас выходят даже в журналах «Рыболов-любитель», наверняка встречали названия общих групп алгоритмов: бэггинг, бустинг, метод опорных векторов (SVM), линейная регрессия. Именно их мы и будем использовать для решения нашей задачи.

А если быть более точными, мы сравним между собой:

  • Линейную регрессию
  • Бустинг (XGboost, LightGBM)
  • Решаюшие деревья (строго говоря, это тот же бустинг, но вынесем отдельно: Extra Trees)
  • Бэггинг (Random Forest)
  • SVM

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

Данные

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

Data Science проект от исследования до внедрения на примере Говорящей шляпы - 5

Для тех, кто думает, что Data Scientists вот такие классные ребята, пойду в Data Scientists, пусть меня научат, мы напомним, что существует такой шаг, как очистка и подготовка данных. Скачанные данные нужно вручную отмодерировать, чтобы удалить, например, «Седьмого Префекта Гриффиндора» и полуавтоматически удалить «Неизвестной девушки из Гриффиндора». В реальной работе пропорционально большая часть задачи всегда связана с подготовкой, очисткой и восстановлением пропущенных значений в датасете.

Немного ctrl+c & ctrl+v и на выходе мы получим 4 текстовых файла, в которых находятся имена персонажей на 2 языках: английском и русском.

Изучаем собранные данные ( EDA, Exploratory Data Analysis)

К этому этапу у нас есть 4 файла, содержащие имена учеников факультетов, посмотрим более детально:

$ ls ../input
griffindor.txt hufflpuff.txt  ravenclaw.txt  slitherin.txt

Каждый файл содержит по 1 имени и фамилии (если она имеется) ученика на строчку:

$ wc -l ../input/*.txt

     250 ../input/griffindor.txt
     167 ../input/hufflpuff.txt
     180 ../input/ravenclaw.txt
     254 ../input/slitherin.txt
     851 total

Собранные данные имеют вид:

$ cat ../input/griffindor.txt | head -3 && cat ../input/griffindor.txt | tail -3
Юан Аберкромби
Кэти Белл
Бем
Charlie Stainforth
Melanie Stanmore
Stewart

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

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

Подготовка данных (Feature Engineering)

Признаки (или фичи, от англ. feature — свойство) — это отличительные свойства объекта. Количество раз, которое человек менял работу за последний год, число пальцев на левой руке, объем двигателя автомобиля, превосходит ли пробег машины 100 000 км или нет. Всевозможных классификаций признаков придумано очень большое количество, какой-то единой системы в этом плане нет и быть не может, поэтому приведем примеры, какими могут быть признаки:

  1. Рациональным числом
  2. Категорией (до 12, 12-18 или 18+)
  3. Бинарным значением (Вернул первый кредит или нет)
  4. Датой, цветом, долей, итд.

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

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

  1. 1 и последняя буквы слова — гласная или согласная
  2. Количество удвоенных гласных и согласных
  3. Количество гласных, согласных, глухих, звонких
  4. Длина имени, длина фамилии
  5. ...

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

>> from Phonetic import RussianLetter, EnglishLetter
>> RussianLetter('р').classify()
{'consonant': True,
 'deaf': False,
 'hard': False,
 'mark': False,
 'paired': False,
 'shock': False,
 'soft': False,
 'sonorus': True,
 'vowel': False}
>> EnglishLetter('d').classify()
{'consonant': True,
 'deaf': False,
 'hard': True,
 'mark': False,
 'paired': False,
 'shock': False,
 'soft': False,
 'sonorus': True,
 'vowel': False}

Теперь мы можем определить простые функции для подсчета статистик, например:

def starts_with_letter(word, letter_type='vowel'):
    """
    Проверяет тип буквы, с которой начинается слово.
    :param word: слово
    :param letter_type: 'vowel' или 'consonant'. Гласная или согласная.
    :return: Boolean
    """
    if len(word) == 0:
        return False
    return Letter(word[0]).classify()[letter_type]

def count_letter_type(word):
    """
    Подсчитывает число букв разного типа в слове.
    :param word: слово
    :param debug: флаг для дебага
    :return: :obj:`dict` of :obj:`str` => :int:count
    """
    count = {
         'consonant': 0,
         'deaf': 0,
         'hard': 0,
         'mark': 0,
         'paired': 0,
         'shock': 0,
         'soft': 0,
         'sonorus': 0,
         'vowel': 0
    }
    for letter in word:
        classes = Letter(letter).classify()
        for key in count.keys():
            if classes[key]:
                count[key] += 1
    return count

С помощью этих функций мы можем получить уже первые признаки:

from feature_engineering import *

>> print("Длина имени («Гарри»): ", len("Гарри"))
Длина имени («Гарри»):  5
>> print("Имя («Гарри») начинается с гласной: ", 
      starts_with_letter('Аптека', 'vowel'))
Имя («Гарри») начинается с гласной:  True
>> print("Фамилия («Поттер») начинается с согласной: ", 
      starts_with_letter('Гарри', 'consonant'))
Фамилия («Поттер») начинается с согласной:  True
>> count_Harry = count_letter_type("Гарри")
>> print ("Количество удвоенных согласных в имени («Гарри»): ", count_Harry['paired'])
Количество удвоенных согласных в имени («Гарри»):  1

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

$f (<Имя> <Фамилия>)=> (длина_{имени}, длина_{фамилии}, ..., количество_гласных_{фамилии})$

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

>> from data_loaders import load_processed_data

>> hogwarts_df = load_processed_data()

>> hogwarts_df.head()

Data Science проект от исследования до внедрения на примере Говорящей шляпы - 7

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

>> hogwarts_df[hogwarts_df.columns].dtypes

Полученные признаки

name                              object
surname                           object
is_english                          bool
name_starts_with_vowel              bool
name_starts_with_consonant          bool
name_ends_with_vowel                bool
name_ends_with_consonant            bool
name_length                        int64
name_vowels_count                  int64
name_double_vowels_count           int64
name_consonant_count               int64
name_double_consonant_count        int64
name_paired_count                  int64
name_deaf_count                    int64
name_sonorus_count                 int64
surname_starts_with_vowel           bool
surname_starts_with_consonant       bool
surname_ends_with_vowel             bool
surname_ends_with_consonant         bool
surname_length                     int64
surname_vowels_count               int64
surname_double_vowels_count        int64
surname_consonant_count            int64
surname_double_consonant_count     int64
surname_paired_count               int64
surname_deaf_count                 int64
surname_sonorus_count              int64
is_griffindor                      int64
is_hufflpuff                       int64
is_ravenclaw                       int64
is_slitherin                       int64
dtype: object

Последние 4 колонки являются целевыми — они содержат информацию, на какой факультет зачислен студент.

Обучение алгоритмов

В двух словах, алгоритмы обучаются также, как и люди: совершают ошибки и учатся на них. Для того, чтобы понять, насколько сильно они ошиблись, алгоритмы используют функции ошибок (функции потерь, англ. loss-function).

Как правило, процесс обучения очень прост и он состоит из нескольких шагов:

  1. Сделать предсказание.
  2. Оценить ошибку.
  3. Внести поправку в параметры модели.
  4. Повторять 1-3, пока не будет достигнута цель, не остановится процесс или не закончатся данные.
  5. Оценить качество полученной модели.

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

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

# Копируем данные, чтобы случайно не потерять что-нибудь нужное:
>> data_full = hogwarts_df.drop(
    [
    'name', 
    'surname',
    'is_griffindor',
    'is_hufflpuff',
    'is_ravenclaw'
    ], 
    axis=1).copy()
# Берем данные для обучения, сбросив целевую колонку:
>> X_data = data_full.drop('is_slitherin', axis=1)
# В качестве целевой будет колонка, которая содержит 1 для учеников Слизерина
>> y = data_full.is_slitherin

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

from sklearn.cross_validation import train_test_split
from sklearn.ensemble import RandomForestClassifier

# Фиксируем сид для воспроизводимоси результата
>> seed = 7
# Пропорции разделения датасета
>> test_size = 0.3
>> X_train, X_test, y_train, y_test = train_test_split(X_data, y, test_size=test_size, random_state=seed)

>> rfc = RandomForestClassifier()
>> rfc_model = rfc.fit(X_train, y_train)

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

Посмотреть код

from data_loaders import parse_line_to_hogwarts_df
import pandas as pd

def get_single_student_features (name):
    """
    Возвращает признаки для переданного имени
    :param name: string для имени и фамилии
    :return: pd.DataFrame объект с готовыми признаками
    """
    featurized_person_df = parse_line_to_hogwarts_df(name)

    person_df = pd.DataFrame(featurized_person_df,
        columns=[
         'name', 
         'surname', 
         'is_english',
         'name_starts_with_vowel', 
         'name_starts_with_consonant',
         'name_ends_with_vowel', 
         'name_ends_with_consonant',
         'name_length', 
         'name_vowels_count',
         'name_double_vowels_count',
         'name_consonant_count',
         'name_double_consonant_count',
         'name_paired_count',
         'name_deaf_count',
         'name_sonorus_count',
         'surname_starts_with_vowel', 
         'surname_starts_with_consonant',
         'surname_ends_with_vowel', 
         'surname_ends_with_consonant',
         'surname_length', 
         'surname_vowels_count',
         'surname_double_vowels_count',
         'surname_consonant_count',
         'surname_double_consonant_count',
         'surname_paired_count',
         'surname_deaf_count',
         'surname_sonorus_count',
        ],
                             index=[0]
    )
    featurized_person = person_df.drop(
                        ['name', 'surname'], axis = 1
                        )
    return featurized_person

def get_predictions_vector (model, person):
    """
    Предсказывает вероятности классов 
    :param model: обученная модель
    :param person: string полного имени
    :return: list вероятностей принадлежности классу
    """
    encoded_person = get_single_student_features(person)
    return model.predict_proba(encoded_person)[0]

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

def score_testing_dataset (model):
    """
    Предсказывает результат на искусственном наборе данных.
    :param model: обученная модель
    """
    testing_dataset = [
            "Кирилл Малев", "Kirill Malev",
            "Гарри Поттер", "Harry Potter", 
            "Северус Снейп", "Северус Снегг","Severus Snape",
            "Том Реддл", "Tom Riddle", 
            "Салазар Слизерин", "Salazar Slytherin"]

    for name in testing_dataset: 
        print ("{} — {}".format(name, get_predictions_vector(model, name)[1]))

score_testing_dataset(rfc_model)

Кирилл Малев — 0.5
Kirill Malev — 0.5
Гарри Поттер — 0.0
Harry Potter — 0.0
Северус Снейп — 0.75
Северус Снегг — 0.9
Severus Snape — 0.5
Том Реддл — 0.2
Tom Riddle — 0.5
Салазар Слизерин — 0.2
Salazar Slytherin — 0.3

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

from sklearn.metrics import accuracy_score, roc_auc_score, classification_report
predictions = rfc_model.predict(X_test)
print("Classification report: ")
print(classification_report(y_test, predictions))
print("Accuracy for Random Forest Model: %.2f" 
          % (accuracy_score(y_test, predictions) * 100))
print("ROC AUC from first Random Forest Model: %.2f"
             % (roc_auc_score(y_test, predictions)))

Classification report: 
             precision    recall  f1-score   support

          0       0.66      0.88      0.75       168
          1       0.38      0.15      0.21        89

avg / total       0.56      0.62      0.56       257

Accuracy for Random Forest Model: 62.26
ROC AUC from first Random Forest Model: 0.51

Неудивительно, что результаты получились такими сомнительными — ROC AUC около 0.51 говорит о том, что модель предсказывает незначительно лучше, чем бросок монеты.

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

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

Data Science проект от исследования до внедрения на примере Говорящей шляпы - 8

В этом нет ничего сложного, для каждого алгоритмы мы обучаем 1 со стандартаными настройками, а также обучаем целый набор, перебирая различные варианты опций, которые влияют на качество работы алгоритма. Этот этап называется Model Tuning или Hyperparameter Optimization и его суть очень простая: выбирается тот набор настроек, который дает наилучший результат.

from model_training import train_classifiers
from data_loaders import load_processed_data
import warnings
warnings.filterwarnings('ignore')

# Загружаем данные
hogwarts_df = load_processed_data()

# Оставляем только нужные колонки
data_full = hogwarts_df.drop(
    [
    'name', 
    'surname',
    'is_griffindor',
    'is_hufflpuff',
    'is_ravenclaw'
    ], 
    axis=1).copy()
X_data = data_full.drop('is_slitherin', axis=1)
y = data_full.is_slitherin

# Проводим исследование моделей
slitherin_models = train_classifiers(data_full, X_data, y)
score_testing_dataset(slitherin_models[5])

Кирилл Малев — 0.09437856871661066
Kirill Malev — 0.20820536334902712
Гарри Поттер — 0.07550095601699099
Harry Potter — 0.07683794773639624
Северус Снейп — 0.9414529336862744
Северус Снегг — 0.9293671807790949
Severus Snape — 0.6576783576162999
Том Реддл — 0.18577792617672767
Tom Riddle — 0.8351835484058869
Салазар Слизерин — 0.25930925139546795
Salazar Slytherin — 0.24008788903854789

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

Data Science проект от исследования до внедрения на примере Говорящей шляпы - 9

>> from model_training import train_all_models

# Обучаем модели для каждого факультета
>> slitherin_models, griffindor_models, ravenclaw_models, hufflpuff_models = 
    train_all_models()

Длинный вывод результатов и результаты мультиномиальной регрессии

SVM Default Report
Accuracy for SVM Default: 73.93
ROC AUC for SVM Default: 0.53

Tuned SVM Report
Accuracy for Tuned SVM: 72.37
ROC AUC for Tuned SVM: 0.50

KNN Default Report
Accuracy for KNN Default: 70.04
ROC AUC for KNN Default: 0.58

Tuned KNN Report
Accuracy for Tuned KNN: 69.65
ROC AUC for Tuned KNN: 0.58

XGBoost Default Report
Accuracy for XGBoost Default: 70.43
ROC AUC for XGBoost Default: 0.54

Tuned XGBoost Report
Accuracy for Tuned XGBoost: 68.09
ROC AUC for Tuned XGBoost: 0.56

Random Forest Default Report
Accuracy for Random Forest Default: 73.93
ROC AUC for Random Forest Default: 0.62

Tuned Random Forest Report
Accuracy for Tuned Random Forest: 74.32
ROC AUC for Tuned Random Forest: 0.54

Extra Trees Default Report
Accuracy for Extra Trees Default: 69.26
ROC AUC for Extra Trees Default: 0.57

Tuned Extra Trees Report
Accuracy for Tuned Extra Trees: 73.54
ROC AUC for Tuned Extra Trees: 0.55

LGBM Default Report
Accuracy for LGBM Default: 70.82
ROC AUC for LGBM Default: 0.62

Tuned LGBM Report
Accuracy for Tuned LGBM: 74.71
ROC AUC for Tuned LGBM: 0.53

RGF Default Report
Accuracy for RGF Default: 70.43
ROC AUC for RGF Default: 0.58

Tuned RGF Report
Accuracy for Tuned RGF: 71.60
ROC AUC for Tuned RGF: 0.60

FRGF Default Report
Accuracy for FRGF Default: 68.87
ROC AUC for FRGF Default: 0.59

Tuned FRGF Report
Accuracy for Tuned FRGF: 69.26
ROC AUC for Tuned FRGF: 0.59

SVM Default Report
Accuracy for SVM Default: 70.43
ROC AUC for SVM Default: 0.50

Tuned SVM Report
Accuracy for Tuned SVM: 71.60
ROC AUC for Tuned SVM: 0.50

KNN Default Report
Accuracy for KNN Default: 63.04
ROC AUC for KNN Default: 0.49

Tuned KNN Report
Accuracy for Tuned KNN: 65.76
ROC AUC for Tuned KNN: 0.50

XGBoost Default Report
Accuracy for XGBoost Default: 69.65
ROC AUC for XGBoost Default: 0.54

Tuned XGBoost Report
Accuracy for Tuned XGBoost: 68.09
ROC AUC for Tuned XGBoost: 0.50

Random Forest Default Report
Accuracy for Random Forest Default: 66.15
ROC AUC for Random Forest Default: 0.51

Tuned Random Forest Report
Accuracy for Tuned Random Forest: 70.43
ROC AUC for Tuned Random Forest: 0.50

Extra Trees Default Report
Accuracy for Extra Trees Default: 64.20
ROC AUC for Extra Trees Default: 0.49

Tuned Extra Trees Report
Accuracy for Tuned Extra Trees: 70.82
ROC AUC for Tuned Extra Trees: 0.51

LGBM Default Report
Accuracy for LGBM Default: 67.70
ROC AUC for LGBM Default: 0.56

Tuned LGBM Report
Accuracy for Tuned LGBM: 70.82
ROC AUC for Tuned LGBM: 0.50

RGF Default Report
Accuracy for RGF Default: 66.54
ROC AUC for RGF Default: 0.52

Tuned RGF Report
Accuracy for Tuned RGF: 65.76
ROC AUC for Tuned RGF: 0.53

FRGF Default Report
Accuracy for FRGF Default: 65.76
ROC AUC for FRGF Default: 0.53

Tuned FRGF Report
Accuracy for Tuned FRGF: 69.65
ROC AUC for Tuned FRGF: 0.52

SVM Default Report
Accuracy for SVM Default: 74.32
ROC AUC for SVM Default: 0.50

Tuned SVM Report
Accuracy for Tuned SVM: 74.71
ROC AUC for Tuned SVM: 0.51

KNN Default Report
Accuracy for KNN Default: 69.26
ROC AUC for KNN Default: 0.48

Tuned KNN Report
Accuracy for Tuned KNN: 73.15
ROC AUC for Tuned KNN: 0.49

XGBoost Default Report
Accuracy for XGBoost Default: 72.76
ROC AUC for XGBoost Default: 0.49

Tuned XGBoost Report
Accuracy for Tuned XGBoost: 74.32
ROC AUC for Tuned XGBoost: 0.50

Random Forest Default Report
Accuracy for Random Forest Default: 73.93
ROC AUC for Random Forest Default: 0.52

Tuned Random Forest Report
Accuracy for Tuned Random Forest: 74.32
ROC AUC for Tuned Random Forest: 0.50

Extra Trees Default Report
Accuracy for Extra Trees Default: 73.93
ROC AUC for Extra Trees Default: 0.52

Tuned Extra Trees Report
Accuracy for Tuned Extra Trees: 73.93
ROC AUC for Tuned Extra Trees: 0.50

LGBM Default Report
Accuracy for LGBM Default: 73.54
ROC AUC for LGBM Default: 0.52

Tuned LGBM Report
Accuracy for Tuned LGBM: 74.32
ROC AUC for Tuned LGBM: 0.50

RGF Default Report
Accuracy for RGF Default: 73.54
ROC AUC for RGF Default: 0.51

Tuned RGF Report
Accuracy for Tuned RGF: 73.93
ROC AUC for Tuned RGF: 0.50

FRGF Default Report
Accuracy for FRGF Default: 73.93
ROC AUC for FRGF Default: 0.53

Tuned FRGF Report
Accuracy for Tuned FRGF: 73.93
ROC AUC for Tuned FRGF: 0.50

SVM Default Report
Accuracy for SVM Default: 80.54
ROC AUC for SVM Default: 0.50

Tuned SVM Report
Accuracy for Tuned SVM: 80.93
ROC AUC for Tuned SVM: 0.52

KNN Default Report
Accuracy for KNN Default: 78.60
ROC AUC for KNN Default: 0.50

Tuned KNN Report
Accuracy for Tuned KNN: 80.16
ROC AUC for Tuned KNN: 0.51

XGBoost Default Report
Accuracy for XGBoost Default: 80.54
ROC AUC for XGBoost Default: 0.50

Tuned XGBoost Report
Accuracy for Tuned XGBoost: 77.04
ROC AUC for Tuned XGBoost: 0.52

Random Forest Default Report
Accuracy for Random Forest Default: 77.43
ROC AUC for Random Forest Default: 0.49

Tuned Random Forest Report
Accuracy for Tuned Random Forest: 80.54
ROC AUC for Tuned Random Forest: 0.50

Extra Trees Default Report
Accuracy for Extra Trees Default: 76.26
ROC AUC for Extra Trees Default: 0.48

Tuned Extra Trees Report
Accuracy for Tuned Extra Trees: 78.60
ROC AUC for Tuned Extra Trees: 0.50

LGBM Default Report
Accuracy for LGBM Default: 75.49
ROC AUC for LGBM Default: 0.51

Tuned LGBM Report
Accuracy for Tuned LGBM: 80.54
ROC AUC for Tuned LGBM: 0.50

RGF Default Report
Accuracy for RGF Default: 78.99
ROC AUC for RGF Default: 0.52

Tuned RGF Report
Accuracy for Tuned RGF: 75.88
ROC AUC for Tuned RGF: 0.55

FRGF Default Report
Accuracy for FRGF Default: 76.65
ROC AUC for FRGF Default: 0.50

# Респект тем читателям, которые открывают кат и смотрят на результаты

from sklearn.linear_model import LogisticRegression

clf = LogisticRegression(random_state=0, solver='lbfgs',  multi_class='multinomial') 
hogwarts_df = load_processed_data_multi()

# Оставляем только нужные колонки
data_full = hogwarts_df.drop(
    [
    'name', 
    'surname',
    ], 
    axis=1).copy()
X_data = data_full.drop('faculty', axis=1)
y = data_full.faculty

clf.fit(X_data, y)
score_testing_dataset(clf)

Кирилл Малев — [0.3602361  0.16166944 0.16771712 0.31037733]
Kirill Malev — [0.47473072 0.16051924 0.13511385 0.22963619]
Гарри Поттер — [0.38697926 0.19330242 0.17451052 0.2452078 ]
Harry Potter — [0.40245098 0.16410043 0.16023278 0.27321581]
Северус Снейп — [0.13197025 0.16438855 0.17739254 0.52624866]
Северус Снегг — [0.17170203 0.1205678  0.14341742 0.56431275]
Severus Snape — [0.15558044 0.21589378 0.17370406 0.45482172]
Том Реддл — [0.39301231 0.07397324 0.1212741  0.41174035]
Tom Riddle — [0.26623969 0.14194379 0.1728505  0.41896601]
Салазар Слизерин — [0.24843037 0.21632736 0.21532696 0.3199153 ]
Salazar Slytherin — [0.09359144 0.26735897 0.2742305  0.36481909]

И confusion_matrix:

confusion_matrix(clf.predict(X_data), y)

array([[144,  68,  64,  78],
       [  8,   9,   8,   6],
       [ 22,  18,  31,  20],
       [ 77,  73,  78, 151]])

def get_predctions_vector (models, person):
    predictions = [get_predictions_vector (model, person)[1] for model in models]
    return {
        'slitherin': predictions[0],
        'griffindor': predictions[1],
        'ravenclaw': predictions[2],
        'hufflpuff': predictions[3]
    }

def score_testing_dataset (models):
    testing_dataset = [
            "Кирилл Малев", "Kirill Malev",
            "Гарри Поттер", "Harry Potter", 
            "Северус Снейп", "Северус Снегг","Severus Snape",
            "Том Реддл", "Tom Riddle", 
            "Салазар Слизерин", "Salazar Slytherin"]

    data = []
    for name in testing_dataset: 
        predictions = get_predctions_vector(models, name)
        predictions['name'] = name
        data.append(predictions)

    scoring_df = pd.DataFrame(data, 
                              columns=['name', 
                                       'slitherin', 
                                       'griffindor', 
                                       'hufflpuff', 
                                       'ravenclaw'])
    return scoring_df

#  Data Science — лучший выбор для тех, кто хочет работать с топ моделями
top_models = [
    slitherin_models[3],
    griffindor_models[3], 
    ravenclaw_models[3], 
    hufflpuff_models[3]
]

score_testing_dataset(top_models)

    name    slitherin   griffindor  hufflpuff   ravenclaw
0   Кирилл Малев    0.349084    0.266909    0.110311    0.091045
1   Kirill Malev    0.289914    0.376122    0.384986    0.103056
2   Гарри Поттер    0.338258    0.400841    0.016668    0.124825
3   Harry Potter    0.245377    0.357934    0.026287    0.154592
4   Северус Снейп   0.917423    0.126997    0.176640    0.096570
5   Северус Снегг   0.969693    0.106384    0.150146    0.082195
6   Severus Snape   0.663732    0.259189    0.290252    0.074148
7   Том Реддл   0.268466    0.579401    0.007900    0.083195
8   Tom Riddle  0.639731    0.541184    0.084395    0.156245
9   Салазар Слизерин    0.653595    0.147506    0.172940    0.137134
10  Salazar Slytherin   0.647399    0.169964    0.095450    0.26126

Data Science проект от исследования до внедрения на примере Говорящей шляпы - 10

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

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

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

Важно! Модели, рассмотренные выше были обучены только на 70% данных. Поэтому для использования в продакшне, мы заново обучим 4 модели с использованием всего набора данных и снова оценим результаты.

from model_training import train_production_models
from xgboost import XGBClassifier

best_models = []
for i in range (0,4):
    best_models.append(XGBClassifier(base_score=0.5, booster='gbtree', colsample_bylevel=1,
           colsample_bytree=0.7, gamma=0, learning_rate=0.05, max_delta_step=0,
           max_depth=6, min_child_weight=11, missing=-999, n_estimators=1000,
           n_jobs=1, nthread=4, objective='binary:logistic', random_state=0,
           reg_alpha=0, reg_lambda=1, scale_pos_weight=1, seed=1337, silent=1,
           subsample=0.8))

slitherin_model, griffindor_model, ravenclaw_model, hufflpuff_model = 
    train_production_models(best_models)

top_models = slitherin_model, griffindor_model, ravenclaw_model, hufflpuff_model
score_testing_dataset(top_models)

name    slitherin   griffindor  hufflpuff   ravenclaw
0   Кирилл Малев    0.273713    0.372337    0.065923    0.279577
1   Kirill Malev    0.401603    0.761467    0.111068    0.023902
2   Гарри Поттер    0.031540    0.616535    0.196342    0.217829
3   Harry Potter    0.183760    0.422733    0.119393    0.173184
4   Северус Снейп   0.945895    0.021788    0.209820    0.019449
5   Северус Снегг   0.950932    0.088979    0.084131    0.012575
6   Severus Snape   0.634035    0.088230    0.249871    0.036682
7   Том Реддл   0.426440    0.431351    0.028444    0.083636
8   Tom Riddle  0.816804    0.136530    0.069564    0.035500
9   Салазар Слизерин    0.409634    0.213925    0.028631    0.252723
10  Salazar Slytherin   0.824590    0.067910    0.111147    0.085710

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

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

import pickle

pickle.dump(slitherin_model, open("../output/slitherin.xgbm", "wb"))
pickle.dump(griffindor_model, open("../output/griffindor.xgbm", "wb"))
pickle.dump(ravenclaw_model, open("../output/ravenclaw.xgbm", "wb"))
pickle.dump(hufflpuff_model, open("../output/hufflpuff.xgbm", "wb"))

Продакшн

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

Конечным пользователем любой МЛ модели часто является не конечный пользователь сервиса, а разработчик, который будет это интегрировать в продукт. Даже если эту роль приходится взять на себя, то модель нужно упаковать в удобный интерфейс. Поэтому вспоминаем, что каждый Data Scientist — это немного бекендер и погружаемся в бекенд-разработку.

Основные требования к задаче интеграции:

  • Модель должна работать отдельным сервисом;
  • Принимать данные в виде json-запроса и также отдавать ответ в виде json;
  • Модель должна быть готова к использованию в любой среде без длительной настройки.

Конечно, решение будет упаковано в docker-контейнер, чтобы избавить разработчика от необходимости установки лишних пакетов и настройки python-окружения. Само решение, которое будет принимать данные и возвращать ответ мы сделаем на flask.

Первый вариант

from __future__ import print_function # In python 2.7
import os
import subprocess
import json
import re
from flask import Flask, request, jsonify
from inspect import getmembers, ismethod
import numpy as npb
import pandas as pd
import math
import os
import pickle
import xgboost as xgb
import sys
from letter import Letter
from talking_hat import *
from sklearn.ensemble import RandomForestClassifier
import warnings

def prod_predict_classes_for_name (full_name):
    featurized_person = parse_line_to_hogwarts_df(full_name)

    person_df = pd.DataFrame(featurized_person,
        columns=[
         'name', 
         'surname', 
         'is_english',
         'name_starts_with_vowel', 
         'name_starts_with_consonant',
         'name_ends_with_vowel', 
         'name_ends_with_consonant',
         'name_length', 
         'name_vowels_count',
         'name_double_vowels_count',
         'name_consonant_count',
         'name_double_consonant_count',
         'name_paired_count',
         'name_deaf_count',
         'name_sonorus_count',
         'surname_starts_with_vowel', 
         'surname_starts_with_consonant',
         'surname_ends_with_vowel', 
         'surname_ends_with_consonant',
         'surname_length', 
         'surname_vowels_count',
         'surname_double_vowels_count',
         'surname_consonant_count',
         'surname_double_consonant_count',
         'surname_paired_count',
         'surname_deaf_count',
         'surname_sonorus_count',
        ],
                             index=[0]
    )

    slitherin_model =  pickle.load(open("models/slitherin.xgbm", "rb"))
    griffindor_model = pickle.load(open("models/griffindor.xgbm", "rb"))
    ravenclaw_model = pickle.load(open("models/ravenclaw.xgbm", "rb"))
    hufflpuff_model = pickle.load(open("models/hufflpuff.xgbm", "rb"))

    predictions =  get_predctions_vector([
                        slitherin_model,
                        griffindor_model,
                        ravenclaw_model,
                        hufflpuff_model
                        ], 
                      person_df.drop(['name', 'surname'], axis=1))

    return {
        'slitherin': float(predictions[0][1]),
        'griffindor': float(predictions[1][1]),
        'ravenclaw': float(predictions[2][1]),
        'hufflpuff': float(predictions[3][1])
    }

def predict(params):
    fullname = params['fullname']
    print(params)
    return prod_predict_classes_for_name(fullname)

def create_app():
    app = Flask(__name__)

    functions_list = [predict]

    @app.route('/<func_name>', methods=['POST'])
    def api_root(func_name):
        for function in functions_list:
            if function.__name__ == func_name:
                try:
                    json_req_data = request.get_json()
                    if json_req_data:
                        res = function(json_req_data)
                    else:
                        return jsonify({"error": "error in receiving the json input"})
                except Exception as e:
                    data = {
                        "error": "error while running the function"
                    }
                    if hasattr(e, 'message'):
                        data['message'] = e.message
                    elif len(e.args) >= 1:
                        data['message'] = e.args[0]
                    return jsonify(data)
                return jsonify({"success": True, "result": res})
        output_string = 'function: %s not found' % func_name
        return jsonify({"error": output_string})

    return app

if __name__ == '__main__':
    app = create_app()
    app.run(host='0.0.0.0')

Dockerfile:

FROM datmo/python-base:cpu-py35

# Используем python3-wheel, чтобы не тратить время на сборку пакетов
RUN apt-get update; apt-get install -y python3-pip python3-numpy python3-scipy python3-wheel
ADD requirements.txt /
RUN pip3 install -r /requirements.txt

RUN mkdir /code;mkdir /code/models
COPY ./python_api.py ./talking_hat.py ./letter.py ./request.py /code/
COPY ./models/* /code/models/

WORKDIR /code

CMD python3 /code/python_api.py

Модель собирается очень просто:

docker build -t talking_hat . && docker rm talking_hat && docker run --name talking_hat -p 5000:5000 talking_hat

Тестирование продакшн модели

У решения есть недостаток — скрипт каждый раз тратит время на выгрузку и загрузку модели из памяти. Исправим это, но сначала замерим производительность данного решения при помощи Apache Benchmark. Действительно будет недальновидно провести тестирование модели, но не провести тестирование конечного решения. Тестирование — наше все.

$ ab -p data.json -T application/json -c 50 -n 10000 http://0.0.0.0:5000/predict

Вывод ab

This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 0.0.0.0 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests

Server Software:        Werkzeug/0.14.1
Server Hostname:        0.0.0.0
Server Port:            5000

Document Path:          /predict
Document Length:        141 bytes

Concurrency Level:      50
Time taken for tests:   238.552 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      2880000 bytes
Total body sent:        1800000
HTML transferred:       1410000 bytes
Requests per second:    41.92 [#/sec] (mean)
Time per request:       1192.758 [ms] (mean)
Time per request:       23.855 [ms] (mean, across all concurrent requests)
Transfer rate:          11.79 [Kbytes/sec] received
                        7.37 kb/s sent
                        19.16 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       3
Processing:   199 1191 352.5   1128    3352
Waiting:      198 1190 352.5   1127    3351
Total:        202 1191 352.5   1128    3352

Percentage of the requests served within a certain time (ms)
  50%   1128
  66%   1277
  75%   1378
  80%   1451
  90%   1668
  95%   1860
  98%   2096
  99%   2260
 100%   3352 (longest request)

Теперь приведем решение к варианту, когда модель постоянно загружена в память:

def prod_predict_classes_for_name (full_name):
    <...>
    predictions =  get_predctions_vector([
                        app.slitherin_model,
                        app.griffindor_model,
                        app.ravenclaw_model,
                        app.hufflpuff_model
                        ], 
                      person_df.drop(['name', 'surname'], axis=1))

    return {
        'slitherin': float(predictions[0][1]),
        'griffindor': float(predictions[1][1]),
        'ravenclaw': float(predictions[2][1]),
        'hufflpuff': float(predictions[3][1])
    }

def create_app():
    <...>

    with app.app_context():
        app.slitherin_model =  pickle.load(open("models/slitherin.xgbm", "rb"))
        app.griffindor_model = pickle.load(open("models/griffindor.xgbm", "rb"))
        app.ravenclaw_model = pickle.load(open("models/ravenclaw.xgbm", "rb"))
        app.hufflpuff_model = pickle.load(open("models/hufflpuff.xgbm", "rb"))

    return app

И замерим результаты тестов:

$ docker build -t talking_hat . && docker rm talking_hat && docker run --name talking_hat -p 5000:5000 talking_hat
$ ab -p data.json -T application/json -c 50 -n 10000 http://0.0.0.0:5000/predict

Вывод ab

This is ApacheBench, Version 2.3 <$Revision: 1807734 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 0.0.0.0 (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests

Server Software:        Werkzeug/0.14.1
Server Hostname:        0.0.0.0
Server Port:            5000

Document Path:          /predict
Document Length:        141 bytes

Concurrency Level:      50
Time taken for tests:   219.812 seconds
Complete requests:      10000
Failed requests:        3
   (Connect: 0, Receive: 0, Length: 3, Exceptions: 0)
Total transferred:      2879997 bytes
Total body sent:        1800000
HTML transferred:       1409997 bytes
Requests per second:    45.49 [#/sec] (mean)
Time per request:       1099.062 [ms] (mean)
Time per request:       21.981 [ms] (mean, across all concurrent requests)
Transfer rate:          12.79 [Kbytes/sec] received
                        8.00 kb/s sent
                        20.79 kb/s total

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       2
Processing:   235 1098 335.2   1035    3464
Waiting:      235 1097 335.2   1034    3462
Total:        238 1098 335.2   1035    3464

Percentage of the requests served within a certain time (ms)
  50%   1035
  66%   1176
  75%   1278
  80%   1349
  90%   1541
  95%   1736
  98%   1967
  99%   2141
 100%   3464 (longest request)

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

Заключение

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

Конечно, решение можно улучшить и далее:

  • С точки зрения feature engineering-а можно использовать фонетический поиск (вводная статья на Хабре), в частности, Soundex алгоритм для определения звучания имени.
  • Можно воспользоваться замечательной статьёй в блоге PyTorch и применить рекуррентную нейронную сеть для классификации имен. Эта статья почти готова для наших задач, тк в ней рассматривается определение страны происхождения имени, то есть решается та же самая задач классификации имени.
  • Можно перейти от синхронного flask к асинхронному Quart, который теоретически выглядит пригодным для решения нашей задачи, что сделает решение еще более уже устойчивым к высоким нагрузкам.
  • Добавить в репозиторий телегам-бота или демо-страничку, чтобы решение было удобнее тестировать.

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

Эта статья не была бы опубликована без сообщества Open Data Science, которое объединяет большое количество русскоязычных специалистов в области анализа данных.

Автор: Кирилл Малев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js