Недавно ANWB запустил новую энергетическую услугу, которая дает членам доступ к рынку энергии по себестоимости. Будучи новым сервисом, ANWB получает много вопросов от наших участников, в том числе в WhatsApp. Чтобы улучшить наши услуги, мы хотим отслеживать любые проблемы или вопросы, которые возникают у наших участников, чтобы мы могли определить приоритеты будущих улучшений.

Один из способов получить информацию из этих чатов — просмотреть их вручную и вести учет различных тем, по которым у наших участников есть вопросы. Очевидно, что это очень трудоемкая деятельность, и в этом заключается возможность для ANWB автоматизировать (частично) это.

Меня как специалиста по данным в ANWB попросили придумать простую в реализации модель для автоматизации (частичной части) этого процесса категоризации. Поскольку на тот момент это все еще делалось вручную, даже небольшое увеличение автоматической категоризации привело бы к экономии времени.

К счастью, бизнес смог предоставить вручную размеченный набор данных примерно из 3300 разговоров. Данные содержали объединенный текст, полученный от клиента (обычно на голландском языке), и тему, которой он был помечен. Я мог бы использовать этот набор данных для обучения модели предсказанию тем чатов.

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

  1. Очистите текст, удалите стоп-слова и любые ненужные данные (электронные письма / номера клиентов и т. д.)
  2. Лемматизация текста с использованием языковой модели spacy для нидерландского языка
  3. Создание объектов с помощью TfidfVectorizer
  4. Обучите модель CatBoostClassifier, используя эти функции
  5. Оценить модель

В конце я также покажу, как вы можете использовать векторизатор TF-IDF для извлечения 10 самых важных ключевых слов по теме.

Ниже я расскажу о шагах, которые я предпринял для очистки данных и создания простой начальной модели классификации тем в Python.

Подготовка данных

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

Есть много разных тем, по которым наши участники связываются с нами, но для простоты нас сейчас интересуют только 10 наиболее часто встречающихся тем. Поэтому мы сохраним эти темы в нашем наборе данных и заменим все остальные темы на «overige» (что означает «другое»).

# Set any topics not in the top 10 to the value 'overige'
df = (
      df.assign(
        topic=lambda df_: df_.topic.where(
          df_.topic.isin(df.query('(topic != "overige") & (topic != "onbekend")').topic.value_counts().nlargest(10).index), 'overige'))
)

В результате количество тем выглядит следующим образом:

overige                      1719
startdatum                    333
meternummers                  288
slimme meter                  162
opzeggen oude leverancier     157
190 euro                      147
annuleren                     144
kosten in de app              130
inloggen                      119
automatische incasso           97
zonnepanelen                   90

Я написал функцию для очистки текста путем удаления любых символов, отличных от ascii, преобразования текста в нижний регистр, удаления стоп-слов и удаления ненужных данных с помощью регулярного выражения. Для стоп-слов я использовал предопределенный список стоп-слов из библиотеки nltk вместе с дополнительным пользовательским списком, специфичным для этого варианта использования.

from nltk.corpus import stopwords
from unidecode import unidecode

stopwords_nl = stopwords.words('dutch')+manual_stopwords_nl


def clean_text(df, stopwords):
    
    regex_rules = {
        # remove linebreaks
        r'\n': ' ', 
        # remove return characters
        r'\r': ' ',  
        # remove postal code
        r'(?<!\d)\d{4}\s?[a-zA-Z]{2}(?![a-zA-Z])': '', 
        # remove email
        r'([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})': '',
        # remove phone numbers 
        r'((\+|00(\s|\s?\-\s?)?)31(\s|\s?\-\s?)?(\(0\)[\-\s]?)?|0)[1-9]((\s|\s?\-\s?)?[0-9])((\s|\s?-\s?)?[0-9])((\s|\s?-\s?)?[0-9])\s?[0-9]\s?[0-9]\s?[0-9]\s?[0-9]\s?[0-9]': '', 
        # Remove bank account info
        r'[a-zA-Z]{2}[\s.-]*[0-9]{2}[\s.-]*[a-zA-Z]{4}[\s.-]*[0-9]{2}[\s.-]*[0-9]{2}[\s.-]*[0-9]{2}[\s.-]*[0-9]{2}[\s.-]*[0-9]{2}[\s.-]*': ' ', 
        # remove URLs
        r'http\S+': '', 
        # remove .,;:-/ if not between digits
        r'(?<!\d)[.,;:\-/](?!\d)': ' ', 
        # remove remaining symbols
        r'[!?#$%&\'()*\+<=>@\\^_`{|}~"\[\]]': ' ', 
        # remove dates (day-month-year)
        r'[0-9]{1,2}[-/\s]([0-9]{1,2}|januari|februari|maart|april|juni|juli|augustus|september|oktober|november|december|jan|feb|mar|mrt|apr|mei|jun|jul|aug|sep|sept|okt|nov|dec)([-/\s][0-9]{2,4})?': ' ', 
        # remove lidmaatschapsnummer / klantnummer / contractnummer etc.
        r'((lid|lidmaatschap(s)*|klant|contract|relatie)\s*(nummer|nr))*\s*[a-z:-]*\s*(anwb)*[\s-]?[0-9]{5,6}': ' ', 
        # remove 'ANWB' in text
        r'\sanwb\s': ' ', 
        # remove any non-numerical characters
        r'[^a-zA-Z0-9]': ' ', 
        # replace multiple spaces by one
        r'\s+': ' ', 
    }

    stopword_pattern = {'|'.join([r'\b{}\b'.format(w) for w in stopwords]): ''}

    return (df
        # convert to lowercase
        .assign(text_cleaned=lambda df_: df_.text.str.lower()) 
        # remove accents from letters and remove any non-ascii characters
        .assign(text_cleaned=lambda df_: 
                  df_.text_cleaned.apply(lambda x: unidecode(x)))
        # remove stopwords
        .assign(text_cleaned=lambda df_: 
                  df_.text_cleaned.replace(stopword_pattern, regex=True))
        # use regex rules to replace text that we are not interested in
        .assign(text_cleaned=lambda df_: 
                  df_.text_cleaned.replace(regex_rules, regex=True))
        )

Применение функции clean_text() оставляет нам дополнительный столбец с очищенным текстом под названием text_cleaned.

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

nlp = spacy.load('nl_core_news_lg', exclude=["parser","ner","textcat","custom"])

def lemmatize_text(df):    
    
    return (df
            # lemmatize text
            .assign(text_lemmatized=lambda df_: df_.text_cleaned.apply(
                lambda x: ' '.join([token.lemma_ for token in nlp(x)])))
            # convert to lowercase
            .assign(text_lemmatized=lambda df_: 
              df_.text_lemmatized.str.lower())
            # fill any empty cells with the empty string
            .assign(text_lemmatized=lambda df_: 
              df_.text_lemmatized.fillna(''))
            )

Убедитесь, что вы уже загрузили предварительно обученный языковой конвейер из spacy, в данном случае голландский, с помощью следующей команды:

python -m spacy download nl_core_news_lg

Выполнение функции lemmatize_text() в нашем фрейме данных возвращает следующие результаты, в которых лемматизированный текст сохраняется в новом столбце text_lemmatized.

Вспомогательная функция ниже объединяет оба шага выше в одну функцию, которая будет использоваться в качестве шага предварительной обработки:

def process_data(df, stopwords):
    return (df
            .pipe(clean_text, stopwords=stopwords)
            .pipe(lemmatize_text)
            )

df = process_data(df, stopwords=stopwords_nl)

Особенности здания

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

from sklearn.model_selection import StratifiedShuffleSplit

# define text column to be used in training
X = df['text_lemmatized']
# define target
y = df['topic']

# split data in train and test set 
sss = StratifiedShuffleSplit(n_splits=1, test_size=0.2)
train_index, test_index = next(sss.split(X,y))

X_train = X.iloc[train_index]
y_train = y.iloc[train_index]

X_test = X.iloc[test_index]
y_test = y.iloc[test_index]

Я собираюсь использовать векторизатор TF-IDF для создания своих функций. Для этого я установил максимальную частоту документов (max_df) 0,75, что означает, что любые слова (или словосочетания), встречающиеся более чем в 75% всех документов, исключаются. Кроме того, я установил минимальную частоту документов (min_df) равной 0,001, что означает, что любые слова (или словосочетания), встречающиеся менее чем в 0,1% всех документов, также исключаются. Векторизатор включает первые 3500 отдельных слов или до 4-го n-грамма.

from sklearn.feature_extraction.text import TfidfVectorizer

# defining the tf-idf vectorizer
tfidf_vectorizer = TfidfVectorizer(
    strip_accents='unicode',
    stop_words=stopwords_nl,
    lowercase=True,
    max_df=0.75,
    min_df=0.001,
    ngram_range=(1,4),
    max_features=3500,
)

Модель

В качестве модели я выбрал CatBoostClassifier. С помощью модели CatBoost вы можете назначать веса каждому классу (теме), что полезно в случае несбалансированного набора данных. Здесь я взвесил каждый класс с инверсией возникновения этого класса. Я буду использовать как векторизатор TF-IDF, так и CatBoostClassifier в качестве шагов внутри конвейера scikit-learn.

from sklearn.utils.class_weight import compute_class_weight
from catboost import CatBoostClassifier
from sklearn.pipeline import Pipeline

# defining our classifier
classes = np.unique(y_train)
weights = compute_class_weight(class_weight='balanced', 
                               classes=classes, 
                               y=y_train)
class_weights = dict(zip(classes, weights))    

clf = CatBoostClassifier(class_weights=class_weights, verbose=False)

# defining our pipeline with a preprocessing step and a classifier step
pipeline = Pipeline(steps=[
                ('vectorizer', tfidf_vectorizer),
                ('classifier', clf)
])

Теперь я могу использовать этот конвейер для обучения модели на реальных данных и использовать ее для прогнозирования.

pipeline.fit(X_train, y_train)
y_pred = pipeline.predict(X_test)

Оценка

Чтобы оценить производительность модели, я использовал тестовый набор для прогнозирования. Я напечатал отчет о классификации ниже. Мы видим средневзвешенную оценку F1 0,70, что для первой модели, я думаю, неплохо. Модель особенно хорошо работает по теме 190 euro, но не очень хорошо по теме opzeggen oude leverancier.

from sklearn.metrics import classification_report

print(classification_report(y_test, y_pred)

Мы также можем визуализировать эти результаты, построив график путаницы. Приведенный ниже график путаницы нормализован по оси Прогноз, что означает, что сумма ячеек по вертикали равна 1. Это означает, что вы можете прочитать точность в каждой ячейке.

import matplotlib.pyplot as plt
from sklearn.metrics import ConfusionMatrixDisplay

disp = ConfusionMatrixDisplay.from_predictions(y_test, y_pred, 
                                               normalize='pred', 
                                               values_format='.1f', 
                                               text_kw={'fontsize':10}
                                               );
plt.title('Confusion plot\nNormalised vertically (precision)')
disp.ax_.get_images()[0].set_clim(0, 1);
plt.xticks(rotation=45, ha='right');

Мы также можем построить график путаницы, нормализованный по оси True (отзыв).

disp = ConfusionMatrixDisplay.from_predictions(y_test, y_pred, 
                                               normalize='true', 
                                               values_format='.1f', 
                                               text_kw={'fontsize':10}
                                               );
plt.title('Confusion plot\nNormalised horizontally (recall)')
disp.ax_.get_images()[0].set_clim(0, 1);
plt.xticks(rotation=45, ha='right');

Из графиков путаницы мы можем заметить пару вещей. Модель имеет высокую точность и полноту для 190 euro. Есть несколько тем, которые модель не может точно различить. startdatum, opzeggen oude leverancier и meternummers часто путают друг с другом. То же самое касается slimme meter и meternummers. Эта путаница понятна, поскольку темы довольно близки друг к другу и могут возникать в тандеме. Можно углубиться в это и либо объединить некоторые из этих тем вместе, собрать больше данных по этим темам, либо использовать бизнес-правила и/или улучшить этап очистки, чтобы улучшить производительность по этим темам.

Следует также отметить, что многие темы помечены как overige, хотя на самом деле у них была другая метка. Для этого варианта использования мы не возражаем против того, чтобы некоторые темы были неправильно помечены как overige, до тех пор, пока мы сводим к минимуму неправильное обозначение других тем. Темы, помеченные как overige, в любом случае будут помечены вручную.

Предсказания

Хотя мы не можем показать реальные тексты с предсказаниями, запуск модели на нашем фиктивном наборе данных дает представление о том, что предсказала модель. Как вы можете видеть, модель правильно предсказала 4 из 5 текстов, неправильно пометив один как overige, что будет означать, что он будет помечен вручную позже.

Бонус: извлечение топ-10 ключевых слов по теме

Одним из интересных аспектов использования векторизатора TF-IDF является то, что можно извлечь первые n наиболее характерных ключевых слов в соответствии со статистикой частотности терминов, обратной частоте документов. Это может дать полезную информацию для нескольких приложений, например, при разработке чат-ботов или когда вы хотите выполнить некоторую классификацию текста с использованием бизнес-правил или своего рода «словарного» списка.

Чтобы напечатать топ-10 наиболее показательных ключевых слов по теме, я сначала применил другой векторизатор TF-IDF, на этот раз для всего набора данных, поскольку я не собираюсь обучать модель, для которой мне нужно отложить тест. набор.

tfidf_vectorizer = TfidfVectorizer(
    strip_accents='unicode',
    stop_words=stopwords.words('dutch')+manual_stopwords_nl,
    lowercase=True,
    max_df=0.75,
    min_df=0.001,
    ngram_range=(1,4),
    max_features=3500,
)

tfidf_vectorizer.fit(X, y)

Затем я использовал этот векторизатор для преобразования набора данных в матрицу с рейтингами TF-IDF. Имена столбцов будут 3500 признаков (словосочетаний) и индексировать наши целевые значения (темы). Затем мы сгруппируем по индексу и просуммируем по строкам и транспонируем результат. Это оставит нас с кадром данных с темами в качестве наших столбцов, ключевыми словами в качестве нашего индекса и в каждой ячейке числовым значением, которое показывает, насколько показательно это ключевое слово для соответствующей темы.

# Get transformed dataframe with correct feature names as column names
tfidf_out = pd.DataFrame.sparse.from_spmatrix(tfidf_vectorizer.transform(X), columns=tfidf_vectorizer.get_feature_names_out())

# set targets to be the index
tfidf_out.index = y
# groupby topic and sum over the rating to get a rating per word per topic
tfidf_out = tfidf_out.groupby(tfidf_out.index).sum().T
tfidf_out

Если мы затем возьмем 10 ключевых слов с самым высоким рейтингом для каждого столбца, мы получим список наиболее показательных ключевых слов по теме.

# choose number of keywords to show
top_n_keywords = 10
n_topics = tfidf_out.columns.shape[0]

# create dataframe with top n most indicative keywords per topic
pd.DataFrame(tfidf_out.index.values[np.argsort(-tfidf_out.values, axis=0)[:top_n_keywords,:n_topics]].copy(), 
             columns=tfidf_out.columns,
             index=[f'keyword_{n}' for n in range(1,top_n_keywords+1)])

Заключение

Используя описанный выше метод, мы смогли предсказать тему разговоров в чате. После внедрения эта модель уже экономит несколько часов ручной маркировки в неделю.

Эта очень простая модель — первый шаг к тому, чтобы сделать наши услуги более автоматизированными. Возможный следующий шаг, который меня особенно интересует, — это применение больших языковых моделей (LLM) к подобным случаям использования. Очень популярным примером этого является ChatGPT, который может вести целые разговоры и обобщать большие фрагменты текста. Было бы очень интересно посмотреть, насколько точно он сможет предсказывать тему разговоров. Это может быть забавной темой для будущей записи в блоге!

Рекомендации

Если вы хотите узнать больше о цепочке методов с использованием Pandas, вот запись в блоге, в которой это очень хорошо объясняется.

Автор: Ялда Мохаммадян | Специалист по данным