Как стать автором
Обновить

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

Время на прочтение8 мин
Количество просмотров4.8K

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 =) Либо Пушкин либо Лермонтов.

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии10

Публикации

Истории

Работа

Data Scientist
60 вакансий
Аналитик 1С
5 вакансий
Консультант 1С
104 вакансии
Python разработчик
136 вакансий
Программист 1С
67 вакансий

Ближайшие события