Интро
Интерес к теме машинного обучения и искусственного интеллекта неуклонно растет. Ежедневно в новостных сводках мы читаем про победу искусственного интеллекта над человеком. Как правило, описывается решение некоторой сложной задачи (челенджа). От жгучего желания воспроизвести результаты статьи во благо человечества (или своего собственного) в 99% случаев отговаривает отсутствие датасета, деталей реализации алгоритма и мощного железа (порой сотни единиц специализированных устройств для тензорных вычислений).
С другой стороны, есть много статей о решении задач машинного обучения на примере нескольких публичных затертых до дыр датасетов: MNIST, IMDB, ENRON, TITANIC. С ними ситуация обратная — все вершины уже покорены, алгоритмы известны, можно добиться рекордных цифр даже на простеньком ноутбуке. Снова мимо. Гораздо сложнее найти материал о практическим применении МО для решения повседневных задач. Данная статья, как можно догадаться, как раз из этой серии. На подробном практическом примере попробуем выяснить, можно ли собрать личного интеллектуального помощника (пусть и узкоспециализированного), сложно ли это, какие знания нужны и какие проблемы подстерегают на этом пути.
Примечание: в данной статье основное внимание уделено классическому машинному обучению. Лишь небольшой блок содержит сравнение с нейросетевым подходом (BERT).
Идея
Вплотную подойдя к итоговому заданию курса, я задался вопросом — могу ли я сделать что-то более впечатляющее, чем поиск по тексту писем ENRON так называемых POI (points of interest, главных участников исторического мошенничества)? Разумно было выбрать задачу из той же самой области машинного обучения — обработки естественного языка (NLP). Она включает в себя широкий спектр задач, таких как машинный перевод, создание вопросно-ответных систем, чат-ботов, суммаризации, классификации и др. Было решено остановиться на классификации: ее качество очень легко измеряется стандартными метриками (Accuracy, Precision, Recall, F1), а обучение можно начинать с сотен образцов. Примером такой задачи может быть то, с чем читатель наверняка уже сталкивался с точки зрения пользователя — определить, является ли письмо нежелательным (SPAM or HAM); в этой задаче два результирующих класса, бинарный выбор. Стоит заметить, что распространенные алгоритмы машинного обучения, такие какие логистическая регрессия, метод опорных векторов, нейросети и многие другие, не ограничивают количество возможных классов, тем самым позволяя использовать десятки, сотни, а порой и тысячи возможных классов на выходе алгоритма.
Помимо верхнеуровневого выбора задачи необходимо было определиться с источником входных данных. В своей работе в НРД мы много используем RedMine: в нем хранятся заявки, задачи, ошибки, инциденты, тест-кейсы и т.д… Многие сущности сопровождаются детальным описанием, текста в сумме получается в достаточном для экспериментов с ML количестве.
Первая возникшая идея — а что если доверить алгоритму распределение новых задач по команде разработки? Типичная заявка на доработку проходит стадии аналитики, согласования, оценки и декомпозиции на подзадачи, разработки, тестирования, опытной и, наконец, промышленной эксплуатации. На этапе передачи заявки в разработку требуется распределить задачи по команде разработчиков. Эксклюзивными знаниями по особенностям системы в одиночку разработчики не обладают (знания стараемся распространять в командах), однако удачное распределение может повысить продуктивность команды в целом. На вход алгоритма могли бы поступать описания задач, на выходе — целевые разработчики. После первых экспериментов оказалось, что краткого описания задачи недостаточно и результаты неустойчивы. Сохранив идею классификации сущностей RedMine по разработчикам, было решено перейти к дефектам (ошибкам), вместо задач по доработке. Как оказалось, в дефектах при определенной культуре тестирования вполне достаточно информации для определения целевого разработчика. Итак, формальная постановка задачи такова: имея на входе текстовое описание дефекта требуется с определенной долей вероятности определить, кому из разработчиков ей следует заняться.
В НРД очень много внимания уделяется тестированию. Общее количество ошибок (с модульного, функционального, регрессионного, симуляционного тестирования и опытной эксплуатации) за период подготовки релиза к продуктиву достигает нескольких сотен. При заведении ошибки перед тестировщиком встает вопрос выбора ответственного разработчика. На практике выбирается кто-то из разработчиков команды ответственного за доработку вендора. Если же речь идет про ошибку регресса, по которой в этом релизе в принципе ничего не дорабатывалось, то ошибка назначается на руководителя разработки (требуется его экспертное мнение). На практике это означает, что потенциально будет серьезная временная задержка между фактическим моментом заведения ошибки и началом работы над ней. С помощью автоматического распределения ошибок, реализованного в интеллектуальном помощнике, этой проблемы удается избежать.
Примечание: спустя годы вышла статья про то, как MicroSoft задействовала машинное обучение для схожей задачи.
Выбор данных
Было бы логично предположить, что большую часть времени специалист по машинному обучению проводит за питьем смузи разработкой алгоритмов, настройкой параметров и, например, созданием новых архитектур нейросетей. Однако на практике порядка 80% процентов времени уходит на работу с данными — выборку, чистку, преобработку и т.д. В нашем случае источником данных является RedMine (БД развернута на MS SQL Server). Никакого rocket science на данном этапе нет — методично пишется и проверяется запрос, который возвращает данные в удобном для применения в алгоритме виде (плоский CSV-файл):
TEXT | DEVELOPER | ID |
---|---|---|
Сломался выпадающий список... | Иванов И. | 123456 |
Неожиданно завершилось выполнение... | Петров П. | 654321 |
Для обучения алгоритма используется только склеенный текст описания и заголовка дефекта в RM. ID использовался во время разработки для контроля. Целевой переменной (т.н. label) в данном случае является фамилия и имя разработчика. Кстати, тут и далее задача классификации рассматривается в разрезе систем. В частности, будут приводится результаты работы классификатора на одной из них с примерно 3к примерами и 12 классами (разработчиками).
Имеющиеся данные целесообразно разбить на обучающую и тестовую выборки:
train_data, test_data, train_target, test_target, train_rm, test_rm = train_test_split(csv_data[:, 0], csv_data[:, 1], csv_data[:, 2], test_size=0.2, random_state=42)
Примечание: в более поздней версии на части разбивался непосредственно pandas.DataFrame
в разрезе колонок.
Подробнее здесь.
Хронология реализации, улучшение метрик, развитие
Алгоритмы машинного обучения не умеют работать с текстом в сыром виде. Являясь по своей природе сложными математическими функциями (отображениями), они требуют на вход числа. Наиболее простым методом преобразования текста в числа является метод "сумка слов" (bag of words). Он подразумевает сопоставление каждому слову некоторого числа. В каждой строке входного корпуса подсчитывается количество уникальных слов, а так же частоты их возникновения. После применения данного метода входной корпус из N образцов превращается в двумерный массив NxM, где M — количество уникальных слов. Делается это примерно следующим образом:
vectorizer = CountVectorizer(max_features=20000) # max_features ограничивает сверху M из предыдущего абзаца.
train_data_features = vectorizer.fit_transform(train_data).toarray()
test_data_features = vectorizer.transform(test_data).toarray()
После этого текст уже готов к подаче в классификатор. В первой версии использовался GaussianNB — так называемый "наивный" Байесовский классификатор. Он учится находить зависимости между частотами возникновения в обучающем наборе тех или иных слов и целевой переменной (класса, в нашем случае разработчика). Его достоинства: простота, высокая скорость работы и интерпретируемость результатов. Кстати, наивным он называется потому, что не анализирует взаимное расположение слов, а только частоты. Например, он не поймет, что 'Chicago Bulls' это не быки в Чикаго, а название команды, употребляемое в определенных контекстах. Этот недостаток можно сгладить, об этом далее в статье.
cls = GaussianNB()
cls.fit(train_data_features, train_target)
prediction = cls.predict(test_data_features)
Целесообразно проверить, насколько хорошо алгоритм справился со своей задачей. Для этого может использовать код, подобный следующему:
print('Accuracy: ' + str(accuracy_score(prediction, test_target)))
print(confusion_matrix(test_target, prediction))
print(classification_report(test_target, prediction))
Accuracy — это процент верно предсказанных классов, confusion_matrix
дает матрицу разброса предсказаний (чем больше на диагонали, тем лучше алгоритм), а classification_report
формирует детальный отчет по классам в разрезе терх метрик: precision
, recall
и f1 score
.
На момент первого измерения точность была в районе 0.32. Это намного ниже ожидания. Для исправления ситуации был разработан способ очистки входных текстовых данных.
Каждая строка входного текста была обработана процедурой prepare_line
:
stemmer = SnowballStemmer("russian")
russian_stops = set(stopwords.words("russian"))
def prepare_line(raw_line):
# 1. Удаление ненужных символов и цифр.
raw_line = re.sub('[\';:.,<>#*"\-=/?!№\[\]()«»_|\\\\…•+%]', ' ', raw_line)
raw_line = re.sub('\\b\\d+\\b', ' ', raw_line)
# 2. Конвертация в lowercase и разбиение по словам.
words = raw_line.lower().split()
# 3. Удаление stopwords + стэмминг.
meaningful_words = [stemmer.stem(w) for w in words if w not in russian_stops]
# 4. Обратное слияние в строку.
return " ".join(meaningful_words)
В ней применены стразу насколько подходов, а именно:
- удаление незначащих символов, знаков пунктуации и прочего мусора. От чисел в чистом виде так же было решено отказаться. Числа, примыкающие к словам, было решено оставить ввиду специфики предметной области. Например, упоминание кодовых названий
MT103
илиED807
— вполне важная фича в нашей предметной области. - конвертация текста в lowercase. Напрашивается само собой, однако сам по себе CountVectorizer этого не делает.
- удаление стоп-слов (слов, которые не несут смысловой нагрузки, таких как междометия, союзы, частицы)
- использование основ слов (stem) вместо самих слов. Позволяет сократить словарь возможных слов за счет удаления всего многообразия окончаний русского языка. Забегая вперед можно сказать, что стемминг так же повысил стабильность скора классификатора на кроссвалидации. В перспективе возможно использование лемматизатора. В дальнейшем алгоритм был обернут в класс SteamCleanTransformer, который можно использовать в пайплайнах scikit-learn.
class StemCleanTransformer(TransformerMixin):
def __init__(self, column_num=0):
self.stemmer = SnowballStemmer("russian")
self.russian_stops = set(stopwords.words("russian"))
self.column_num = column_num
def prepare_line(self, raw_line):
# 1. Удаление ненужных символов и цифр.
raw_line = re.sub('[\';:.,<>#*"\-=/?!№\[\]()«»_|\\\\…•+%]', ' ', raw_line)
raw_line = re.sub('\\b\\d+\\b', ' ', raw_line)
# 2. Convert to lower case, split into individual words
words = raw_line.lower().split()
# 3. Удаление stopwords + стэмминг.
meaningful_words = [self.stemmer.stem(w) for w in words if w not in self.russian_stops]
# 4. Обратное слияние в строку.
return " ".join(meaningful_words)
def transform(self, X, y=None, **fit_params):
result = np.array(X, copy=True)
if len(result.shape) == 1:
for i, _ in enumerate(result):
result[i] = self.prepare_line(result[i])
else:
for row in result:
row[self.column_num] = self.prepare_line(row[self.column_num])
return result
def fit_transform(self, X, y=None, **fit_params):
self.fit(X, y, **fit_params)
return self.transform(X)
def fit(self, X, y=None, **fit_params):
return self
Использование нового метода очистки подняло accuracy с 0.32 до 0.55, почти двукратный прирост.
Далее была выполнена попытка перейти на метод опорных векторов (в scikit-learn это класс SVC), не давшая значительного прироста. Оборачиваясь назад, можно сказать, что SVC требовал более точной настройки гиперпараметров, которая на тот момент не могла быть проведена из-за незрелости проекта. К нему еще вернемся.
Следующим шагом было использование n-грамм при векторизации. В частности, было выбрано значение 2: если ранее каждое слово представляло собой отдельный токен в словаре, то теперь так же будут учитываться все пары соседних слов. Регулируется параметром ngram_range
в CountVectorizer:
vectorizer = CountVectorizer(ngram_range=(1, 2), max_features=20000)
Возвращаясь к примеру с Chicago Bulls — теперь в словаре будет и Chicago, и Bulls, и Chicago Bulls (как отдельный токен). Это подняло accuracy с 0.55 до 0.63. Весомый прирост для такой простой правки.
Последним значимым шагом по повышению качества оценки до начала фактической эксплуатации был отказ от CountVectorizer в пользу TfidfVectorizer. Это немного более продвинутый алгоритм векторизации, который учитывает количество вхождений того или иного слова в документе, а так же во всем обучающем наборе. Подробнее здесь. Он имеет совместимый с CountVectorizer интерфейс и так же поддерживает n-граммы. Ввиду этого переход на него абсолютно прозрачный:
vectorizer = TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True, max_features=20000) # про sublinear_tf см. документацию
Это позволило повысить метрику оценки качества с 0.63 до 0.68.
Еще одна неудачная попытка — использовать pymystem3 (Яндекс) в качестве лемматизатора (преобразование слова в исходную форму; обычно работает немного лучше стемминга). По странной причине под Windows стемминг одной строки инпута занимает примерно одну секунду при сравнимом качестве. Под Linux таких проблем не было обнаружено.
Эксплуатация
Для получения практической пользы от реализованного классификатора требовалось создать инфраструктурную обвязку. В частности, требовалось наладить взаимодействие с внешним миром. Была создана некоторая обвязка по взаимодействию с БД RM (чтение), а так же с API RM (запись). Было введено разделение по системам: на каждую заводился отдельный инстанс классификатора, обучающийся на собственном наборе данных. Задачи определения ответственного разработчика были поставлены на расписание. Это можно было считать официальным началом функционирования системы. По мере работы, конечно же, вносились изменения. Вот некоторые из них:
В определенный момент возникла идея получать топ Z предсказаний. Причин было несколько — разработчики уходят в отпуск, на больничный, уходят от дел, переключаются между системами и так далее. В таких случаях проще точным алгоритмом выбрать наиболее подходящего кандидата. При реализации вскрылся серьезный недостаток GaussianNB — классификатор не использует сглаживание (Laplassian smoothing), ввиду чего отсутствие или наличие одного единственного слова в тексте может на 100% отвечать за выбор класса. Проблема была решена использованием MultinomialNB. Кстати топ 3 предсказание дает скор в районе 0.95.
Потребовалось хранить некоторые метаданные по каждой ошибке. Базу данных (пусть даже SQLite) для этого заводить было затратно, поэтому задача была решена с помощью shelve:
with shelve.open('filename') as persistent_storage:
persistent_storage[str(rm_number)] = prediction
Идеально подходит для хранения пар ключ-значение в небольшом проекте.
Было решено вернуться к методу опорных векторов (SVC). При подключении каждой новой системы в качестве клиента сервиса определения ошибок выполнялись контрольные замеры метрик. Выяснилось, что в некоторых системах наивный Байесовский алгоритм стремится "нагрузить" некоторых особо часто встречающихся в тестовом наборе разработчиков, оставляя без внимания остальных. Особенно это хорошо было видно в confusion matrix. Практическим путем было выявлено, что метод опорных векторов при должном выборе параметра C дает гораздо более уместную оценку. Кстати, чтобы SVC предсказывал вероятности, требуется выставить специальный параметр probability в True
SVC, в отличие от MultinomialNB, требовал куда больше времени на обучение. Ввиду этого пришла идея сохранять обученные модели на диск. Для этого используется joblib из sklearn.externals (внутри работает через pickle). Примерно так выглядит код сохранения и чтения:
from sklearn.externals import joblib
...
joblib.dump(classifier, filename)
...
joblib.load(filename)
Помимо этого, был создан специальный класс для хранения кэша моделей. Подход стандартный — если модели нет, то создать, сохранить в кэш, вернуть результат; если есть — вернуть из кэша.
- Для повышения удобства работы, стандартизации операций над обучающей и тестовой выборкой, а так же более прозрачного сохранения/загрузки в файл/из файла, был использован специальный класс Pipeline из библиотеки scikit-learn. Он позволяет инкапсулировать в себе несколько шагов обучения с произвольной вложенностью. Иногда строятся очень большие цепочки. Выдержка из реализации:
pipeline = make_pipeline(
StemCleanTransformer(),
TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True, max_features=20000),
SVC(kernel = 'linear', C=10, gamma='auto', probability=True)
)
Очистка, векторизация и предсказание инкапсулированы в одну цепочку.
Всегда есть ложка дегтя. Ввиду сложностей по работе с некоторыми вендорами пришлось предусмотреть т.н. NonML версию алгоритма. Она определяет разработчика по формальным параметрам, решенным задачам у того же родителя и т.д. Всегда интересно, какой алгоритм определения окажется сильнее.
Периодически производится обновление датасетов и переобучение. В общем и целом тренд на рост количества данных, однако бывают и сокращения за счет отключившихся разработчиков.
За несколько лет накоплено больше данных. На той системе, по которой мы изначально измеряли accuracy, значение естественным образом поднялось до 0.76.
Результаты
На момент публикации статьи система функционирует уже 4 года. За это время произошло порядка 5500 назначений на целевого разработчика, что по факту трансформируется в ~5.2 человеко-месяца сэкономленного времени (из расчета: 10 минут в среднем на переключение контекста/чтение описания/переназначение, рабочий день 8 часов, 22 рабочих дня в месяц).
Это серьезная экономия, ведь робот не страдает от переключения контекста и реагирует практически мгновенно. Так же стоит заметить, что примерно пятая часть из назначений робота была выполнена вне рабочего времени (в нашей организации это с 10 утра по 19 вечера). В классификации участвуют 5 внутренних систем с количеством разработчиков от 6 до 27, средний скор находится в районе 0.75 (максимум 0.9, минимум 0.68).
По похожей схеме были автоматизированы и другие процессы, о них могу рассказать отдельно в комментариях.
Сравнение с BERT
Недавно возникла идея проверить более продвинутые и тяжелые NLP-модели на этой задаче. В качестве подопытного был взят мультиязычный BERT из пакета transformers от HuggingFace. Скор по точности получился несколько хуже, чем достигнутый по итогам описанного в статье пути. Это ни в коем случае не означает, что BERT хуже. Скорее вывод в том, что частная задача вполне может решаться классическим методом на околопредельной точности.
Аутро
Итак, в данной статье целиком, от идеи до реализации, был описан ход разработки инструментального средства, использующего методы машинного обучения (обработки естественного языка в частности) для решения повседневных задач руководителя разработки. Показано, что построить классификатор, приносящий реальную практическую пользу, можно без особых временных затрат и требований по объему данных.
Использованные средства: Python 3, scikit-learn, nltk, SQL.
AK74M
Спасибо за статью! Очень интересно узнать, как устроена и работает система, которая автоматически назначает на меня ошибки.