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

В этой статье мы хотим показать ML на практическом кейсе — на примере проекта, который мы делали для Актион-пресс (сервис онлайн-подписки). Уверен, описанное в этом примере может пригодиться многим. Почему многим? Да потому, что проблема, которую мы решали, называлась «сортировка и пересылка по адресу огромного количества электронных писем». Проблема гигантской переписки, которую менеджерам приходится сортировать и пересылать в соответствующие отделы, практически универсальная, и проблему эту надо решать современными способами.

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

INFO

Изначально статья называлась Операционализация ML-моделей Python с использованием Azure Functions. Думаю, тебе стоит узнать об этом сейчас, потому что дальше легко не будет. 🙂

 

Модель машинного обучения

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

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

Здесь я поделюсь своим опытом и дам некоторые рекомендации тебе, чтобы ты мог сам поэкспериментировать с этим кодом или повторно использовать его.

WWW

Чтобы сохранить конфиденциальность, исходный набор данных был заменен аналогичным общедоступным набором для классификации отзывов о McDonalds. См. файл data/data.csv.

Сами данные были представлены в файлах CSV с тремя столбцами: Id, Text и Class. И поскольку в NLTK не предусмотрена встроенная поддержка чтения данных из файлов в формате CSV, мы написали собственный модуль, позволяющий читать файлы из папки в виде одного dataframe pandas или извлекать текст в виде списков абзацев, предложений, слов и так далее в формате NLTK.

А вот код для инициализации этого самописного модуля чтения CsvCorpusReader данными клиента. Реализацию класса можно увидеть в файле lib\corpus.py. Настоятельно рекомендую тебе ознакомиться с содержимым файла Experiments\TrainingExperiment.py.

#%% create corpus
corpus = CsvCorpusReader(".\data", ["data.csv"],
                         encoding="utf8",
                         default_text_selector=lambda row: row["Text"])

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

Ниже мы даем экстрактору команду вернуть документы в виде списка слов, отбрасывая структуру абзацев или предложений (см. keep_levels=Levels.Nothing). Затем переводим каждое слово в нижний регистр, отбрасываем любые стоп-слова и выделяем основы слов. На заключительном этапе удаляем низкочастотные слова, предполагая, что это просто опечатки или что они не оказывают существенного влияния на классификацию.

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

#%% tokenize the text
stop_words = ['would', 'like', 'mcdonald']
text_processor = generate_processor(keep_alpha_only=True,
                                    to_lower=True,
                                    stopwords_langs=['english'],
                                    add_stopwords=stop_words,
                                    stemmer_langs=['english'])
docs_factory = lambda: corpus.words(keep_levels=Levels.Nothing, **text_processor)

word_frequencies = Counter((word for doc in docs_factory() for word in doc))
min_word_freq = 3
docs = [
    [
        word
        for word in doc if word_frequencies[word] >= min_word_freq
     ] for doc in docs_factory()
]

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

Мы протестировали несколько разных подходов (включая BoW, TF-IDF, LSI, RP и w2v), но классическая модель LSI с 500 извлеченными топиками дала наилучшие результаты (AUC = 0,98) в нашем случае. Для начала код проверяет наличие существующей сериализованной модели в общей папке. Если модели нет, код обучает новую модель с использованием предварительно подготовленных данных и сохраняет результат на диск. Если модель обнаружена, она просто загружается в память. Затем код преобразовывает набор данных и повторяет поток со следующим вложением.

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

Самая очевидная из них связана с тем, что письма тех типов, которые мы искали, имели предсказуемые и повторяющиеся шаблоны слов, как в случае автоответов (например, «Спасибо за ваше письмо… Меня не будет в офисе до… Если вопрос срочный…»). Поэтому для их обработки вполне достаточно чего-то простого, например TF-IDF. LSI поддерживает общую идеологию, и эту модель можно рассматривать как способ добавления синонимов, подходящих для обработки. В то же время алгоритм word2vec, прошедший обучение на Википедии, вероятно, генерирует ненужный шум из-за сложных синонимичных структур, тем самым «размывая» шаблоны в сообщениях и, следовательно, снижая точность классификации.

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

#%% convert to Bag of Words representation
dictionary_path = os.path.join(preprocessing_path, 'dictionary.bin')
if os.path.exists(dictionary_path):
    dictionary = corpora.Dictionary.load(dictionary_path)
else:
    dictionary = corpora.Dictionary(docs)
    dictionary.save(dictionary_path)

docs_bow = [dictionary.doc2bow(doc) for doc in docs]
nested_partial_print(docs_bow)

#%% convert to tf-idf representation
tfidf_path = os.path.join(preprocessing_path, 'tfidf.bin')
if os.path.exists(tfidf_path):
    model_tfidf = models.TfidfModel.load(tfidf_path)
else:
    model_tfidf = models.TfidfModel(docs_bow)
    model_tfidf.save(tfidf_path)

docs_tfidf = nested_to_list(model_tfidf[docs_bow])

#%% train and convert to LSI representation
lsi_path = os.path.join(preprocessing_path, 'lsi.bin')
lsi_num_topics = 500
if os.path.exists(lsi_path):
    model_lsi = models.LsiModel.load(lsi_path)
else:
    model_lsi = models.LsiModel(docs_tfidf, id2word=dictionary, num_topics=lsi_num_topics)
    model_lsi.save(lsi_path)

docs_lsi = model_lsi[docs_tfidf]

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

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

#%% create target
class_to_find = "SlowService"
df["Target"] = df.apply(lambda row: 1 if class_to_find in row["Class"] else 0, axis=1)
df.groupby(by=["Target"]).count()

#%% create features and targets dataset
features = pd.DataFrame(docs_features, columns=["F" + str(i) for i in range(lsi_num_topics)])
notnul_idx = features.notnull().all(axis=1)
features = features[notnul_idx]
df_notnull = df[notnul_idx]
target = df_notnull[["Target"]]
plot_classes_scatter(features.values, target["Target"].values)

#%% split dataset to train and test
train_idx, test_idx = train_test_split(df_notnull.index.values, test_size=0.3, random_state=56)
df_train = df_notnull.loc[train_idx]
features_train = features.loc[train_idx]
target_train = target.loc[train_idx]
df_test = df_notnull.loc[test_idx]
features_test = features.loc[test_idx]
target_test = target.loc[test_idx]

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

Как ты мог заметить, в приведенном ниже коде мы придерживаемся специального формата имени модели: class_{0}_thresh_{1}.bin. Это необходимо для определения имени класса и соответствующего порогового значения в ходе дальнейшей оценки.

И последнее замечание, прежде чем мы продолжим. В качестве инструмента разработки я выбрал Visual Studio Code. Это простой в использовании легковесный редактор, который даже предоставляет базовые возможности IntelliSense (автозавершение кода и подсказки) для такого динамичного языка, как Python. В то же время расширения Jupyter и Python в сочетании с ядром IPython позволяют выполнять код поячеечно и визуализировать результат без повторного запуска скрипта, что всегда удобно для задач ML. Да, это похоже на стандартный Jupyter, но с IntelliSense и ориентацией на код/git. Я рекомендую тебе попробовать, хотя бы пока ты работаешь с образцом, поскольку для продуктивной разработки тут применяется множество других возможностей, связанных с VS Code.

Что касается кода ниже, строка с plot ROC threshold values — это примеры использования расширения Jupyter. Ты можешь нажать специальную кнопку Run cell (Выполнить ячейку) над ячейкой, чтобы увидеть значения TP и FP и сравнить их с пороговым значением Threshold на панели результатов справа. Мы активно использовали эту диаграмму во время работы, поскольку из-за выраженного дисбаланса в наборе данных оптимальный уровень отсечки всегда был около 0,04 вместо привычных 0,5. Если ты не можешь использовать VS Code для тестирования, можно просто запустить скрипт с помощью стандартных инструментов Python и после просмотра результатов в отдельном окне внести изменения непосредственно в имя файла.

#%% train logistic classifier
classifier = LogisticRegression()
classifier.fit(features_train, target_train)

#%% score on test
scores_test = classifier.predict_proba(features_test)[:, 1]

#%% plot ROC threshold values
pd.DataFrame(nested_to_list(zip(tsh, tp_test, fp_test, fp_test-tp_test)), columns=['Threshold', 'True Positive Rate', 'False Positive Rate', 'Difference']).plot(x='Threshold')
plt.xlim(0, 1)
plt.ylim([0,1])
plt.grid()
plt.show()

#%% save model
threshold = 0.25
model_filename = 'class_{0}_thresh_{1}.bin'.format(class_to_find, threshold)
joblib.dump(classifier, os.path.join(model_path, model_filename))

Теперь настало время скрипта оценки: Score\run.py. Нового в нем очень мало, большую часть кода взяли из первоначального обучающего эксперимента, рассмотренного ранее. Ознакомься с содержимым этого файла в репозитории GitHub.

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

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

model_paths = [path for path in os.listdir(os.path.join('..', 'model')) if path.startswith('class_') ]

for model_path in model_paths:
    model = joblib.load(os.path.join('..', 'model', model_path))
    res = model.predict_proba(features_notnull)[:, 1]

    class_name = model_path.split('_')[1]
    threshold = float(model_path.rsplit('.', 1)[0].split('_')[-1])

    result.loc[:, "class_" + class_name] = res > threshold
    result.loc[:, "class_" + class_name + "_score"] = res

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

  • клонируй репозиторий, следуй инструкциям по развертыванию локальной среды Anaconda и установи Visual Studio Code с нужным расширением;
  • помести свои данные в поддерживаемом формате в файл data\data.csv и открой файл Experiment\TrainingExperiment.py, чтобы обучить модель на любом классе, который ты хочешь оценить;
  • не забудь предварительно удалить всю папку models, поскольку в противном случае код попытается повторно использовать преобразования и модели из образца;
  • перейди к Score\run.py, замени данные в файле Score\debug\input.csv собственными и построчно выполни скрипт с помощью расширения Jupyter.

В VS Code ты даже можешь открыть раздел отладки Debug (Ctrl + Alt + D), выбрать Score (Python) в качестве конфигурации и нажать Start Debugging (Выполнить отладку), чтобы провести построчный анализ кода в редакторе. Когда алгоритмы завершат свою работу, результаты можно будет найти в файлах input.scores.csv и input.unscorable.csv в папке Score\debug.

INFO

Реализовал интересный проект? Думаешь, что он стоит того, чтобы поделиться им с читателями? Пиши нам в Хакер, крутую статью сделаем!

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

Вариант 1. Оформи подписку на «Хакер», чтобы читать все материалы на сайте

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

Вариант 2. Купи один материал

Заинтересовала информация, но нет возможности оплатить подписку? Тогда этот вариант для тебя! Обрати внимание: этот способ покупки доступен только для материалов, опубликованных более двух месяцев назад.


3 комментария

  1. baragoz

    19.12.2017 at 13:58

    У Худобахшова еще статьи крутые на близкие темы: https://xakep.ru/author/hudobakshov/

  2. baragoz

    20.12.2017 at 09:21

    Да, тема МАШИНЛЕРНИНГА раскрыта, но где же БЛОКЧЕЙН И ЭДЖАЙЛ?!

  3. osipov.m.s

    19.01.2018 at 12:16

    Обратились в ООО «Актион-пресс» (http://action-press.ru/) за комментариями по работе описанного механизма. Они ответили, что ничего подобного не используют и никогда не делали )

Оставить мнение

Check Also

Эксплоиты в десятку. Обзор самых интересных докладов с мировых ИБ-конференций

В последние годы мы отучились воспринимать Windows как нечто невероятно дырявое. Эта опера…