Классификатор обращений пользователей (1C + python)

от автора

1. Описание задачи

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

2. Общая логика

  1. Собрать исходные данные

  2. Подготовить данные (почистить, привести в нужный формат)

  3. Обучить модель

  4. Подать новое обращение в модель и получить ответ

    Все очень просто! Сам в шоке =)

3. Полезные ссылки

Тут оставлю некоторые ссылки

  1. Лекция Coursera

  2. Статья на Хабре

  3. https://www.youtube.com/watch?v=1vklt6IHeJI

  4. Подробнее про векторное представление документов

  5. Разряженные матрицы

  6. Трансформер

  7. Подробнее про разреженные матрицы

4. Собираем данные

Мы работаем в системе 1С, поэтому, делаем запрос к базе и выбираем все отработанные обращения с начала времен. Будем использовать эти данные для обучения. Результатом запроса будет являться таблица, где в первой колонке будет текст обращений пользователей, а во второй «1» или «0», где «1» — это «Программные разработки», а «0» — «Системные администраторы».

Функция ПолучитьСырыеДанные()  Запрос = Новый Запрос; Запрос.Текст =  "ВЫБРАТЬ |ОбращениеВТехПоддержку.ТекстВопроса КАК Appeal, |ВЫБОР |КОГДА ОбращениеВТехПоддержку.ОтделОбслуживания.Код = 50 |ТОГДА 1 |ИНАЧЕ 0 |КОНЕЦ КАК Prediction |ИЗ |Документ.ОбращениеВТехПоддержку КАК ОбращениеВТехПоддержку |ГДЕ |ОбращениеВТехПоддержку.ДатаОтработки <> ДАТАВРЕМЯ(1, 1, 1)";  Возврат Запрос.Выполнить().Выгрузить();  КонецФункции

Далее я решил почистить данные и оставить только слова без цифр, спец символов и пр… Тут можно по экспериментировать!!!

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

Функция ПочиститьПоле(ПреобразованноеПоле) Экспорт  СимволыДляЗамены = "1234567890"; СимволыДляЗамены = СимволыДляЗамены + "(){}[]:;""'\|<>.,/?"; СимволыДляЗамены = СимволыДляЗамены + "*-+=_"; СимволыДляЗамены = СимволыДляЗамены + "!@#$%^&№";  Для НомерСимвола = 0 По СтрДлина(СимволыДляЗамены) Цикл Символ = Сред(СимволыДляЗамены, НомерСимвола, 1); ПреобразованноеПоле = СтрЗаменить(ПреобразованноеПоле, Символ, ""); КонецЦикла;  ПреобразованноеПоле = СтрЗаменить(ПреобразованноеПоле, """", " "); ПреобразованноеПоле = СтрЗаменить(ПреобразованноеПоле, Символы.ПС, " "); ПреобразованноеПоле = СтрЗаменить(ПреобразованноеПоле, Символы.ВК, " "); ПреобразованноеПоле = СтрЗаменить(ПреобразованноеПоле, Символы.НПП, " "); ПреобразованноеПоле = СтрЗаменить(ПреобразованноеПоле, Символы.ПФ, " ");  Возврат ПреобразованноеПоле;  КонецФункции

Результатом данного этапа должен стать .СSV файл с которым будем дальше работать.

Тут мы как раз данный файл и собираем.

Функция ПреобразоватьТЗвТекстCSV(ТЗ, Разделитель = ",", флЭкспортироватьИменаКолонок = Истина)  ТекстCSV = "";  Если флЭкспортироватьИменаКолонок Тогда  ПодготовленнаяСтрока = "";  Для Каждого Колонка Из ТЗ.Колонки Цикл ПодготовленнаяСтрока = ПодготовленнаяСтрока + Колонка.Имя + Разделитель; КонецЦикла;  ПодготовленнаяСтрока = Лев(ПодготовленнаяСтрока, СтрДлина(ПодготовленнаяСтрока) - 1);  ТекстCSV = ТекстCSV + ПодготовленнаяСтрока + Символы.ПС;  КонецЕсли;  Счетчик = 0;  Для Каждого Строка Из ТЗ Цикл  //Если Счетчик = 10000 Тогда //Прервать; //КонецЕсли;  ПодготовленнаяСтрока = "";  Для Каждого Колонка Из ТЗ.Колонки Цикл  ПреобразованноеПоле = Строка[Колонка.Имя];  Если Колонка.Имя = "Appeal" Тогда ПочиститьПоле(ПреобразованноеПоле); КонецЕсли;  ПодготовленнаяСтрока = ПодготовленнаяСтрока + ПреобразованноеПоле + Разделитель;  КонецЦикла;  ПодготовленнаяСтрока = Лев(ПодготовленнаяСтрока, СтрДлина(ПодготовленнаяСтрока) - 1);  ТекстCSV = ТекстCSV + ПодготовленнаяСтрока + Символы.ПС;  Счетчик = Счетчик + 1;  КонецЦикла;  Возврат ТекстCSV;  КонецФункции 

Файл готов, начинается самое интересное.

5. Обучение модели

# -*- coding: utf-8 -*-  import pickle import pandas as pd  from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score from sklearn import linear_model  #Путь к .csv файлу DATA_PATH           = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\data\train.csv"  #Файл где хранятся данные о точности нашей модели (для информации) MODEL_ACCURACY_PATH = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\model_accuracy.txt"  #Тут мы храним нашу модель MODEL_PATH          = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\model.pickle"  #Тут мы храним наш векторизатор, что бы приводить входящие обращения к нужному виду VECTORIZER_PATH     = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\vectorizer.pickle"   def train_model():  # Вычитываем исходные данные          train_df = pd.read_csv(DATA_PATH)  # Выбираем отдельно данные по двум отделам          train_df_0 = train_df[train_df['Prediction'] == 0]     train_df_1 = train_df[train_df['Prediction'] == 1]      # Смотрю на размер меньшего по колву данных массива, его и берем за основу.          size_0 = train_df_0.shape[0]  # Наша задача с балансировать две эти выборки, поэтому из большей я рандомно выбираю      # такое же кол во данных как есть в меньшей !!!!!!!!          train_df_1 = train_df_1.sample(frac=1).reset_index(drop=True)     train_df_1 = train_df_1[:size_0]  # Собираем две одинаковые по размеру выборки вместе          train_df = pd.concat([train_df_0, train_df_1], ignore_index=True)     train_df.Prediction.value_counts(normalize=True)  # Приводим содержимое в нижний регистр          appeal = list(train_df.Appeal.values)     appeal = [str(l).lower() for l in appeal]  # Для решения задачи классификации необходимо преобразовать каждое обращение     # в вектор. Размерность данного вектора будет равна количеству слов     # используемых во всех обращениях вообще! Каждая координата соответствует     # слову, значение в координате равно количеству раз, слово используется в     # в обращении.          vectorizer = TfidfVectorizer()     tfidfed = vectorizer.fit_transform(appeal)      # Делим выборку на тренировочную и тестовую          X = tfidfed     y = train_df.Prediction.values     X_train, X_test, y_train, y_test = train_test_split(X, y, train_size=0.7, random_state=42)          # Создаем объект классификатора     # С параметрами можно поиграться, может получится настроить еще точнее!!!           clf = linear_model.SGDClassifier(max_iter=10000, random_state=42, loss="log", penalty="l2", alpha=1e-5, eta0=1.0,                                      learning_rate="optimal")          # Обучаем модель          clf.fit(X_train, y_train)  # Пишем данные точности в файлик, в моем случаем 94%      with open(MODEL_ACCURACY_PATH, 'w', encoding='utf-8') as f:         f.write("Train accuracy = %.3f\n" % accuracy_score(y_train, clf.predict(X_train)))         f.write("Test accuracy = %.3f" % accuracy_score(y_test, clf.predict(X_test)))  # В питоне все объект, поэтому мы можем замариновать нашу модель и векторизатор # что бы потом их можно было легко использовать      with open(MODEL_PATH, 'wb') as f:         pickle.dump(clf, f)      with open(VECTORIZER_PATH, 'wb') as f:         pickle.dump(vectorizer, f)   if __name__ == "__main__":     train_model()

Готово, спасибо инженерам, которые делают эти библиотеки !!!!

6. Как использовать?

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

Ответ будет писать во временный файл.

Код ниже рассчитан на обмен с 1С, а именно, дополнительно принимает имя файла через который будет происходить обмен.

# -*- coding: utf-8 -*-  import logging import pickle import sys  MAIN_DIR        = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests" LOG_PATH        = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\log.txt" VECTORIZER_PATH = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\vectorizer.pickle" MODEL_PATH      = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\model.pickle"  # Логер пропускаем, ничего интересного! def create_logger(name, log_level=logging.DEBUG, stdout=False, file=None):     '''     Создает логера, есть возможность создать логера с выводом в stdout или в файл или туда и туда.     '''      logger = logging.getLogger(name)     logger.setLevel(log_level)     formatter = logging.Formatter(fmt='[%(asctime)s] - %(name)s - %(levelname).1s - %(message)s',                                   datefmt='%Y.%m.%d %H:%M:%S')      if file is not None:         fh = logging.FileHandler(file, encoding='utf-8-sig')         fh.setLevel(log_level)         fh.setFormatter(formatter)         logger.addHandler(fh)      if stdout:         ch = logging.StreamHandler()         ch.setLevel(log_level)         ch.setFormatter(formatter)         logger.addHandler(ch)      return logger   def main():      # Принимаем почищеное обращение пользователя (текс)     user_request = sys.argv[1].lower()      # Имя файла обмена который создает 1С и в последствии удалит     pred_path = sys.argv[2]     logger.info(user_request)      # Востанавливаем наш векторизатор     with open(VECTORIZER_PATH, 'rb') as f:         vectorizer = pickle.load(f)      # Востанавливаем нашу модель         with open(MODEL_PATH, 'rb') as f:         model = pickle.load(f)      # Приводим обрашение к вектору         transform_request = vectorizer.transform([user_request])      # Пишем ответ в файл обмена     with open(MAIN_DIR + '\\' + pred_path, 'w') as f:         prediction = str(model.predict(transform_request)[0])         f.write(prediction)      logger.info(prediction)   if __name__ == '__main__':     try:         logger = create_logger("log", file=LOG_PATH)         main()     except Exception as e:         logger.error(e)

На стороне 1С

Функция ПредсказатьОтдел(ТексОбращения) Экспорт  Путьприложения = "\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\";  ОчищеныйЗапрос = ПочиститьПоле(ТекстОбращения);    ИмяФайлаОбмена = СтрЗаменить(СтрЗаменить(СтрЗаменить(Строка(ТекущаяДата()), ".", ""), " ", ""), ":", "") + ".txt";    //внимательно с кавычками !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! КомандаИтог = """" + Путьприложения + "predict\predict.exe""" + " """ + ОчищеныйЗапрос + """" + " """ + ИмяФайлаОбмена + """"; ЗапуститьПриложение(КомандаИтог,, Истина);  Попытка ПутьКФайлуОбмена = Путьприложения + ИмяФайлаОбмена; Предсказание = Новый ЧтениеТекста; Предсказание.Открыть(ПутьКФайлуОбмена);               Ответ = Предсказание.ПрочитатьСтроку(); Предсказание.Закрыть();; УдалитьФайлы(ПутьКФайлуОбмена); Исключение Сообщить(ОписаниеОшибки()); Сообщить("Не удалось автоматически определить отдел обращения!"); КонецПопытки;  Если Ответ = "1" Тогда ПредсказаниыйОтдел = Справочники.Отделы.НайтиПоНаименованию("Программные разработки"); Иначе ПредсказаниыйОтдел = Справочники.Отделы.НайтиПоНаименованию("IT"); КонецЕсли;    Возврат ПредсказанныйОтдел;  КонецФункции 

7. Ускоряемся

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

Флаги компиляции

pyinstaller -F --hidden-import="sklearn" --hidden-import="sklearn.feature_extraction" --hidden-import="sklearn.utils._weight_vector"predict.pyw

так много, что бы библиотека «sklearn» удачно подтянулась.

.pyw — что бы не вылезало консольное окно.

7.1 Оказалось …

В поисках лучшего решения по интеграции с 1С наткнулся на статью https://habr.com/ru/post/332082/, atnes — от души тебе !!!

Итого. Делаем COM объект

class PredictWrapper:      # com spec     _public_methods_ = ['predict', ] # методы объекта     _public_attrs_ = ['version', ] # атрибуты объекта     _readonly_attr_ = []     _reg_clsid_ = '{9cb58c50-2d01-41e9-99d5-07e1fa4baf16}' # uuid объекта     _reg_progid_= 'PredictWrapper' # id объекта     _reg_desc_  = 'COM wrapper for LR_model' # описание объекта      def __init__(self):         self.VECTORIZER_PATH = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\vectorizer.pickle"         self.MODEL_PATH = r"\\app1\1C\work\Вложения\Python_projects\ClassifierOfUserRequests\model.pickle"      def predict(self, user_request):          import sklearn         import pickle          with open(self.VECTORIZER_PATH, 'rb') as f:             vectorizer = pickle.load(f)          with open(self.MODEL_PATH, 'rb') as f:             model = pickle.load(f)          transform_request = vectorizer.transform([user_request])          return str(model.predict(transform_request)[0])   def main():     import win32com.server.register     win32com.server.register.UseCommandLine(PredictWrapper)     print('registred')   if __name__ == '__main__':     main()

А на стороне 1С

обВыбратьОтдел = Новый COMОбъект("PredictWrapper"); Ответ = обВыбратьОтдел.predict(ТекстВопроса);

8. Готово

Схема работает очень хорошо! Проверено.

Как Вы понимаете, по аналогии можно придумать множество вариантов использования … Можете прочекать стихи поэтов и потом выбирать, кому принадлежит авторство, разумеется в формате 1v1 =) Либо Пушкин либо Лермонтов.


ссылка на оригинал статьи https://habr.com/ru/articles/569206/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *