
1. Описание задачи
В нашей компании очень много пользователей и каждый день они шлют массу обращений на самые разные темы. У нас есть два отдела: "Программные разработки" и "Системные администраторы", и что бы облегчить жизнь техподдержке, был написан классификатор, который стыкует обращение пользователя на тот или другой отдел. В основе классификатора лежит логистическая регрессия.
2. Общая логика
Собрать исходные данные
Подготовить данные (почистить, привести в нужный формат)
Обучить модель
Подать новое обращение в модель и получить ответ
Все очень просто! Сам в шоке =)
3. Полезные ссылки
Тут оставлю некоторые ссылки
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 =) Либо Пушкин либо Лермонтов.