Задача Провести анализ сообщений коммерческого чата на предмет
игнорирования вопроса клиента менеджером компании
На входе: лог чатов с клиентом компании в csv
формате:
Дата отправки
|
Сообщение
|
Кто отправил
|
Номер заявки
|
yyyy-mm-dd hh-mm-ss
|
Текст1
|
Отправитель1
|
Номер1
|
yyyy-mm-dd hh-mm-ss
|
Текст2
|
Отправитель2
|
Номер2
|
yyyy-mm-dd hh-mm-ss
|
Текст3
|
Отправитель3
|
Номер3
|
yyyy-mm-dd hh-mm-ss
|
Текст4
|
Отправитель4
|
Номер4
|
yyyy-mm-dd hh-mm-ss
|
текстN
|
отправительN
|
НомерN
|
План решения:
1. Подготовка данных
2. Выбор инструмента для определения похожих сообщений внутри
каждого чата
3. Анализ полученных результатов
4. Подведение итогов
Подготовка данных
Применяются следующие инструменты:
%matplotlib inlineimport matplotlib.pyplot as pltimport seaborn as snsimport tqdmimport pandas as pdimport numpy as npimport reimport timefrom nltk.tokenize import sent_tokenize, word_tokenizeimport pymorphy2morph = pymorphy2.MorphAnalyzer(lang='ru')from nltk import edit_distanceimport editdistanceimport textdistancefrom jellyfish import levenshtein_distance
Выполнена загрузка CSV файлов в DataFrame. Форматы дат в разных
логах отличаются, поэтому они приведены к единому виду. Сортировка
выполнена по номерам чатов и датам сообщений, также проводиться
сброс/упорядочивание индексов.
df = pd.DataFrame()counter = 1for path_iter in PATH: print(path_iter) df_t = pd.read_csv(path_iter, sep=';', encoding='cp1251', dtype='str', engine = 'python') if counter == 1: df_t['Дата отправки'] = pd.to_datetime(df_t['Дата отправки'], format='%d.%m.%y %H:%M') else: df_t['Дата отправки'] = pd.to_datetime(df_t['Дата отправки'], format= '%Y-%m-%d %H:%M:%S') df = df.append(df_t) counter += 1df.sort_values(by=['Номер заявки', 'Дата отправки'], inplace=True)df.reset_index(drop=True, inplace=True)print('Размер DF, rows =', len(df))df.head()
>>>
Размер DF, rows = 144584
На основании группировки делаем вывод, что основная часть
сообщений чата приходиться на клиентов и консультантов компании.
Изредка наблюдаются автоматические сообщения системы.
df['Кто отправил'].value_counts()>>>['AGENT'] 43137['CONSULTANT'] 33040['USER'] 29463['MANAGER'] 21257[] 13939['BOT'] 3748Name: Кто отправил, dtype: int64
print('Кол-во уникальных чатов', len(set(df['Номер заявки'].tolist())))>>>Количество уникальных чатов 5406
Размер большинства чатов 25 сообщений.
df['Номер заявки'].value_counts().hist(bins = 40)
Проведена обработка текста: нижний регистр, игнорирование
сообщений с одним словом, оставляем только русские буквы, пробел и
тире. Удалены из сообщений тексты о вложениях документов вида
картинка.jpg.
def filter_text(text): ''' принимает текст, на выходе обработанные текст. в тексте остаются только символы русского алфавита, тире, пробел ''' text = text.lower() if len(re.findall(r'[\w]+', text)) == 1: #print(re.findall(r'[\w]+', text), '---', len(re.findall(r'[\w]+', text))) return '' #удаляем из сообщений тексты с вложениями документов вида "картинка.jpg" text = re.sub(r'(.+[.]jpg)|(.+[.]pdf)', '', text) text = [c for c in text if c in 'абвгдеёжзийлкмнопрстуфхцчшщъыьэюя- '] text = ''.join(text) return textdf['work_text'] = df.apply(lambda row: filter_text(row['Сообщение']), axis = 1)
Заменяем несколько пробелов одним пробелом.
df['work_text'] = df.apply(lambda row: re.sub(r'\s+', ' ', row['work_text']) , axis = 1)
Чистим от частотных/не значимых словосочетаний:
STOP_WORDS = [ '-', '--', '-а', '-б', '-в', '-е', '-й', '-к', '-м', '-т', '-у', '-х', '-ю', '-я', 'а', 'а-', 'аа', 'аб', 'ав', 'ад', 'ае', 'аж', 'ак', 'ам', 'ан', 'ао', 'ап', 'ас', 'ау', 'ач', 'б', 'ба', 'бв', 'бд', 'без', 'бы', 'в', 'в-', 'ва', 'вб', 'вв', 'вг', 'ви', 'вм', 'вн', 'во', 'вп', 'вс', 'вт', 'вх', 'вщ', 'вы', 'вю', 'г', 'г-', 'га', 'гв', 'гк', 'гм', 'го', 'гп', 'гу', 'д', 'да', 'дб', 'дв', 'дд', 'де', 'день', 'ди', 'дк', 'для', 'до', 'доброе', 'добрый', 'др', 'дс', 'ду', 'дю', 'дя', 'е', 'еа', 'ев', 'ее', 'ей', 'ен', 'ес', 'ею', 'её', 'ж', 'же', 'жк', 'жр', 'з', 'за', 'здравствуйте', 'зп', 'зу', 'и', 'иа', 'из', 'ик', 'ил', 'или', 'им', 'ин', 'ио', 'ип', 'иу', 'их', 'й', 'к', 'ка', 'как', 'кв', 'кд', 'кк', 'км', 'ко', 'кп', 'кр', 'кс', 'л', 'ла', 'лд', 'ли', 'лк', 'ля', 'м', 'ма', 'мб', 'мв', 'мг', 'мз', 'ми', 'мк', 'мл', 'мм', 'мне', 'мо', 'мр', 'мс', 'мт', 'му', 'мы', 'мэ', 'мю', 'мя', 'н', 'на', 'нв', 'не', 'ни', 'нн', 'но', 'нп', 'нс', 'ну', 'нф', 'ны', 'ня', 'о', 'оа', 'об', 'ов', 'од', 'ое', 'ой', 'ок', 'ом', 'он', 'оо', 'оп', 'ос', 'от', 'ох', 'оч', 'ою', 'п', 'пв', 'пи', 'пк', 'пл', 'пм', 'пн', 'по', 'пожалуйста', 'пп', 'пр', 'при', 'пс', 'пт', 'пф', 'пш', 'пю', 'р', 'ра', 'рб', 'рв', 'рд', 'ри', 'рн', 'ро', 'рп', 'рр', 'рс', 'рт', 'ру', 'рф', 'с', 'са', 'сб', 'св', 'сг', 'се', 'сж', 'ск', 'сл', 'см', 'сн', 'со', 'сп', 'спасибо', 'ср', 'ст', 'су', 'сх', 'сч', 'т', 'т-', 'та', 'тб', 'тв', 'тг', 'тд', 'те', 'ти', 'тк', 'тн', 'то', 'тп', 'тр', 'ту', 'тц', 'тч', 'ты', 'у', 'ув', 'уж', 'ук', 'ул', 'ур', 'утро', 'ух', 'уч', 'уя', 'ф', 'фг', 'фд', 'фз', 'фн', 'фф', 'фц', 'х', 'хг', 'хм', 'хо', 'ц', 'цб', 'цн', 'ч', 'чб', 'че', 'чп', 'чс', 'чт', 'что', 'ш', 'ы', 'ь', 'э', 'эг', 'эл', 'эп', 'эр', 'эт', 'эх', 'ю', 'юа', 'юв', 'юг', 'юл', 'юр', 'юс', 'я', 'яг', 'яя', 'ё', 'я', 'сейчас', 'это', 'ещё', 'понятно', 'отлично', 'извинить','извините','где']#DF только с сообщениями клиентовdf_users = df[df['Кто отправил'] == '''['USER']'''][df_users.work_text.replace(x, '', regex=True, axis = 1, inplace=True) for x in STOP_SENTS]
Приводим слова к нормальной форме, удалим из текста частотные
слова
def normalize_text(text): ''' идем по всем токенам, то что в STOP_WORDS - выкидываем, меняем каждый на нормальную форму, соединяем пробелом, возвращаем текст ''' ls = list() for word in word_tokenize(text, language='russian'): if word not in STOP_WORDS: ls.append(morph.parse(word)[0].normal_form) norm_text = ' '.join([x for x in ls]) return norm_textdf_users['clear_text'] = df_users.work_textdf_users['clear_text'] = df_users.apply(lambda row: normalize_text(row.clear_text), axis = 1)
Выбор инструмента для поиска похожих сообщений внутри
каждого чата
Найдем расстояние Левенштейна всех заявок по всем текстам:
def get_edit_distance(sec_posts, val_leven): ''' sec_posts - list с постами чата val_leven - коэффициент редакционного расстояния по Левенштейну: ratio_ed = editdistance / длину текста ''' ls = [] len_sec_posts = len(sec_posts) sec_posts_iter = 0 for i in sec_posts: sec_posts_iter += 1 #не проходим по тем элементам, которые уже сравнили for k in sec_posts[sec_posts_iter:]: #ed = edit_distance(i, k) #ed = textdistance.levenshtein(i, k) #ed = levenshtein_distance(i, k) ed = editdistance.eval(i, k) if len(k) == 0: ratio_ed = 0 else: ratio_ed = ed / len(k) if len(k) !=0 and len(i) != 0 and ratio_ed <= val_leven: ls.append([i, k, ratio_ed, ed]) #list [post1, post2, ratio_ed, ed] return lsCURRENT_LEV_VALUE = 0.25#Уникальные номера заявок:number_orders = set(df_users[(df_users['Кто отправил'] == '''['USER']''')]['Номер заявки'].tolist())#Найдем расстояния Левенштейна по всем фразам, всех заявок:all_dic = {}for i_order in tqdm.tqdm(number_orders): posts_list = df_users[(df_users['Кто отправил'] == '''['USER']''') & (df_users['Номер заявки'] == i_order)]['clear_text'].tolist() all_dic[i_order] = get_edit_distance(posts_list, CURRENT_LEV_VALUE)
В результате тестирования была обнаружена самая производительная
библиотека для расчета редакционного расстояния Левенштейна
editdistance.
Расчет затраченного времени в секундах на обработку 29463
сообщений чата представлен ниже. В тесте участвовалиimport
edit_distance, import editdistance, import textdistance, from
jellyfish import:

Библиотека editdistance производительнее аналогов от 18 до 31
раза.
Чтобы определить допустимую схожесть текстов используем метрику
CURRENT_LEVEN, которая ограничит допустимое значение отношения
редакционного расстоянию двух сравниваемых текстов к длине первого
текста editdistance (text1, text2)/ длину текста(text1).
Значение параметра CURRENT_LEVEN подбирается опытным путем.
Проведением итерации расчета и добавления стоп-слов. Значение
зависит от средней длины сравниваемых текстов и индивидуально для
каждого исследования. В моем случае рабочий CURRENT_LEVEN составил
0.25.
Анализ полученных результатов
Формируется dataframe из расчетных данных:
df_rep_msg = pd.DataFrame(ls, columns=['id_order', 'clear_msg', 'clear_msg2', 'ratio_dist', 'ed_dist'])df_rep_msg['id_rep_msg'] = df_rep_msg.reset_index()['index'] +1 df_rep_msg.head()
Разворот сообщений в строки:
df1 = df_rep_msg[['id_order','clear_msg','ratio_dist','ed_dist', 'id_rep_msg']]df2 = df_rep_msg[['id_order','clear_msg2','ratio_dist','ed_dist', 'id_rep_msg']]df2.columns = ['id_order','clear_msg','ratio_dist','ed_dist','id_rep_msg']df_rep_msg = pd.concat([df1, df2], axis=0).sort_values(by=['id_order'], ascending=[True])del df1del df2df_rep_msg.drop_duplicates(inplace=True)df_rep_msg.head(10)
Добавляются признаки к датафрейму df_users_rep_msg
df_users_rep_msg = pd.merge( df_users, df_rep_msg, how='left', left_on=['clear_text','Номер заявки'], right_on=['clear_msg','id_order'])df_users_rep_msg[df_users_rep_msg.clear_msg.notnull()][ ['Дата отправки', 'Сообщение', 'Кто отправил', 'Номер заявки', 'clear_msg', 'ratio_dist', 'ed_dist','id_rep_msg']].head()
Посмотрим на сообщения, количество которых повторяются более 6
раз в одном чате
count_ser = df_users_rep_msg[df_users_rep_msg.id_rep_msg.notnull()]['id_rep_msg'].value_counts()filt = count_ser[count_ser > 4]filt
df_users_rep_msg[df_users_rep_msg.id_rep_msg.isin(filt.index)][['Дата отправки','Сообщение','id_order']]
Следует уделить внимание на чаты, в которых присутствуют
частотные сообщения от клиентов, как в примере выше, и выяснить
причину настойчивости и удовлетворённость клиента сервисом.
Разметим основной дата фрейм
df_m = pd.merge( df, df_users_rep_msg[df_users_rep_msg.id_rep_msg.notnull()], how='left', left_on = ['Дата отправки','Кто отправил', 'Номер заявки', 'Сообщение'], right_on =['Дата отправки','Кто отправил', 'Номер заявки', 'Сообщение'])df_m = df_m[['Дата отправки', 'Сообщение', 'Кто отправил', 'Номер заявки','clear_msg', 'ratio_dist', 'ed_dist', 'id_rep_msg']]df_m.loc[18206:18216]
Получим минимальный и максимальный индекс по каждому совпавшему
сообщению
df_temp = df_m[df_m.id_rep_msg.notnull()][['id_rep_msg']]df_temp['id_row'] = df_temp.index.astype('int')df_temp.id_rep_msg = df_temp.id_rep_msg.astype('int')index_arr = df_temp.groupby(by = 'id_rep_msg')['id_row'].agg([np.min, np.max]).valuesindex_arr[0:10]>>>array([[ 36383, 36405], [108346, 108351], [ 12, 43], [ 99376, 99398], [111233, 111235], [121610, 121614], [ 91234, 91252], [ 11963, 11970], [ 7103, 7107], [ 53010, 53016]], dtype=int64)df_m.loc[index_arr[194][0]:index_arr[194][1]]
В примере ниже видно, как обращение перехватил
бот/автоматизированная система, клиент не игнорирован
df_m.loc[index_arr[194][0]:index_arr[194][1]]
Проверяем остальные случаи
#Проверим остальные случаиresults = {'Ответ_получен':0, 'Ответ_проигнорирован':0, 'Всего_групп_повторных_сообщений':0}results['Всего_групп_повторных_сообщений'] = len(index_arr)for i in range(len(index_arr)): if len(set(df_m.loc[index_arr[i][0]:index_arr[i][1]]['Кто отправил'].tolist()) - set(["['USER']"])) > 0: results['Ответ_получен'] += 1 elif len(set(df_m.loc[index_arr[i][0]:index_arr[i][1]]['Кто отправил'].tolist()) - set(["['USER']"])) == 0: results['Ответ_проигнорирован'] += 1print('Доля проигнорированных сообщений:', round(100*(results['Ответ_проигнорирован']/results['Всего_групп_повторных_сообщений']), 2), '%')results
Количество обработанных сообщений:
N = 1anw_yes = (236)anw_no = (103)ind = np.arange(N) width = 0.35p1 = plt.bar(ind, anw_yes, width)p2 = plt.bar(ind, anw_no, width, bottom=anw_yes)plt.ylabel('Повторных_сообщений')plt.title('Доля обработанных сообщений')plt.xticks(ind, (''))plt.yticks(np.arange(0, 500, 50))plt.legend((p1[0], p2[0]), ('Ответ_получен', 'Ответ_проигнорирован'))plt.show()
Заключение
С помощью NLP модели, построенной на измерении редакционного
расстояния по Левенштейну, удалось сократить кол-во проверяемых
чатов с 5406 ед. до 339 ед. Из них определить высоко-рисковые чаты
103 ед. Определить и использовать в расчетах высокопроизводительную
библиотеку для расчета дистанции редактирования между текстами,
позволяющую масштабировать проверку на большие объемы
информации.