Хотя мы вступили в эпоху больших языковых моделей, которые предполагается применять для решения множества различных задач (см., например, мою статью Прогнозирование временных рядов с помощью ChatGPT), нам по-прежнему нужны классические подходы, такие как регрессионные модели с конструированием признаков для решения множества задач. проблемы, с которыми мы можем столкнуться. В этой короткой серии статей мы проанализируем различные методы обозначения текста, то есть извлечения признаков из текстовых переменных, появляющихся в наборах данных задачи регрессии. В первой части мы начнем с самых основных методов, таких как подсчет вхождений слов, и покажем, как мы можем их настроить. Затем, в следующих статьях, мы плавно перейдем к другим техникам (например, методам, предложенным в библиотеке dirty_cat), закончив препроцессингом текста предварительно обученными LLM.

Набор данных

Регрессия, основанная на текстовых признаках, на самом деле является повсеместной проблемой автоматизированной обработки данных. Предположим, вы пытаетесь оценить стоимость недвижимости на основе необработанного содержания рубричных объявлений или прогноза акций с учетом заголовков новостей. Это также может быть случай из вашей работы, когда у вас есть доступ к некоторым необработанным текстовым данным, и вы задаетесь вопросом, как их использовать. В любом случае, я бы сказал, что этот тип набора данных не самый распространенный, с которым мы можем столкнуться в популярных пакетах (например, наборы данных scikit-learn) или соревнованиях. Несколько примеров доступны в интересном пакете dirty_cat (мы будем тестировать во второй части этой серии), но я решил начать с Открытых данных Airbnb в Нью-Йорке, доступных на Kaggle.

import pandas as pd
data = pd.read_csv('data/AB_NYC_2019.csv')

TARGET = ['price']
CATEGORICAL = ['neighbourhood_group', 'neighbourhood', 'room_type']
FEATURES = ['name', 'minimum_nights'] + CATEGORICAL

# Selecting features and target
data['name'] = data['name'].fillna('')
X, y = data[FEATURES], data[TARGET]

# Splitting dataset into two parts
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.2)

Попробуем спрогнозировать цену, используя другие столбцы в качестве предикторов (признаков). Одной из переменных, которые мы можем использовать, является местоположение имя, фактически являющееся кратким описанием объекта. Это будет переменная, на преобразованиях которой мы сосредоточимся. Анализ этого набора данных уже был сделан, т.е. в этой тетради. Я использовал простое разбиение на поезд-тест ради презентации, но обычно было бы лучше применить перекрестную проверку с большим количеством разбиений.

Мешок слов

Bag-of-words – это самый простой способ представления документов в виде набора независимых слов (токенов). TFIDF — это его расширение, попытка нормализовать количество слов (токенов) с учетом их появления во всем наборе данных и в конкретном документе. Теоретически в некоторых ситуациях имеет значение только внешний вид некоторых конкретных слов. Однако, если мы не хотим возиться с регулярными выражениями для поиска этих ключевых слов, проще всего применить классы из scikit-learn. С первого взгляда виден главный недостаток прямого использования — взрывное количество измерений.

Ниже я представляю пример использования CountVectorizer и TfidfVectorizer в конвейере sklearn с LinearRegression. CountVectorizer делает то, о чем говорит его название, т. е. подсчитывает количество вхождений определенных токенов, в то время как TfidfVectorizer(ярлык для CountVectorizer + TfidfTransformer)дополнительно взвешивает их по алгоритму TFIDF. На первом этапе CountVectorizer (как и TfidfTransformer) по умолчанию преобразует все строки в нижний регистр, поэтому нам не нужно делать это самостоятельно. См. Документацию по API для более подробной информации.

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.metrics import mean_squared_error
from sklearn.compose import ColumnTransformer
from sklearn.linear_model import LinearRegression

# In this version of OneHotEncoder we can define multiple columns 
# we'd like to transform
from category_encoders import OneHotEncoder

# Optionally, we can try to use a custom tokenizer, e. g. from spaCy
#
# import spacy
# nlp = spacy.load('en', disable=['ner', 'parser', 'tagger'])
# def spacy_tokenizer(doc):
#     return [x.orth_ for x in nlp(doc)]
# 
# and then:
# CountVectorizer(tokenizer=spacy_tokenizer)

# Count vectorizer
count_vec_pipeline = Pipeline([
    ('one_hot', OneHotEncoder(cols=CATEGORICAL)),
    ('count_vectorizer', ColumnTransformer([
        ('name', CountVectorizer(stop_words='english'), 'name')
    ])),
    ('linear_regression', LinearRegression())
])
count_vec_pipeline.fit(X_train, y_train)
y_pred_count_vec = count_vec_pipeline.predict(X_test)
print(mean_squared_error(y_test, y_pred_count_vec))

# TFIDF
tfidf_pipeline = Pipeline([
    ('one_hot', OneHotEncoder(cols=CATEGORICAL)),
    ('count_vectorizer', ColumnTransformer([
        ('name_tfidf', TfidfVectorizer(stop_words='english'), 'name')
    ])),
    ('linear_regression', LinearRegression())
])
tfidf_pipeline.fit(X_train, y_train)
y_pred_tfidf = tfidf_pipeline.predict(X_test)
print(mean_squared_error(y_test, y_pred_tfidf))

# https://stackoverflow.com/questions/53885198/using-spacy-as-tokenizer-in-sklearn-pipeline

Помните, что мы можем решить, какая последовательность считается «токеном». Самый простой подход предполагает, что токен — это уникальное слово или подпоследовательность из n уникальных слов. Мы также можем использовать n-граммы на уровне символов или использовать более сложные методы (например, токенизатор spaCy). Чтобы извлечь имена токенов, взгляните на фрагмент кода ниже. При извлечении имен функций из объекта ColumnTransformer к ним добавляется имя шага (первый элемент в кортеже) — в нашем случае: name_tfidf.

# Getting the second object of the pipeline 
# and calling a relevant method to get feature names
feature_names = tfidf_pipeline[1].get_feature_names_out()
tfidf_features = pd.DataFrame(
    tfidf_pipeline[1].transform(X_test).todense(), 
    columns=feature_names
)

# Randomly chosen 20 columns
tfidf_features.loc[:, np.random.choice(tfidf_features.columns, 20)]

Плюсы

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

Минусы

  • проблема с обработкой новых слов (если токен = слово)
  • может быть недостаточно подвержен опечаткам
  • разреженная матрица признаков
  • большое количество дополнительных измерений
  • имеет значение только вхождение слова, его положение не несет никакой информации

Тематическое моделирование

Тематическое моделирование уже является устаревшим методом НЛП юношеского возраста. В этом семействе алгоритмов мы разлагаем (взвешенные) матрицы количества слов, чтобы получить набор значимых тем. Это делается путем добавления еще одного кирпичика к нашему конвейеру мешка слов, алгоритму матричной декомпозиции. Наиболее типичные варианты:

  • SVD (разложение по единственному значению),
  • LDA (скрытое распределение Дирихле)
  • NMF (неотрицательная матричная факторизация),

но ничто не мешает попробовать, например. СПС⁷. Некоторые исследования показывают, что классическое тематическое моделирование по-прежнему конкурирует с тематическим моделированием на основе LLM².

tm_pipeline = Pipeline([
    ('one_hot', OneHotEncoder(cols=CATEGORICAL)),
    ('count_vectorizer', ColumnTransformer([
        ('topic_modeling', make_pipeline(
            CountVectorizer(stop_words='english'), 
            TruncatedSVD(n_components=200, random_state=7)), 'name')
    ])),
    ('linear_regression', LinearRegression())
])
tm_pipeline.fit(X_train, y_train)
y_pred_count_vec = tm_pipeline.predict(X_test)
print(mean_squared_error(y_test, y_pred_count_vec))

Вероятно, самым важным параметром здесь является количество компонентов (n_components). Вас может заинтересовать тонкая настройка модели, например. с помощью библиотеки Оптуна. Еще одна вещь, о которой мы должны знать, — это случайность, которая влияет на результаты экспериментов. Чтобы сделать его полностью воспроизводимым, задайте случайное начальное число с помощью параметра random_state.

# Optimizing n_components with Optuna
from sklearn.model_selection import cross_val_score
import optuna

def score_fun(est, X, y):
    return mean_squared_error(y, est.predict(X))

def objective(trial):
    n_components = trial.suggest_int('n_components', 10, 200)
    
    tm_pipeline = Pipeline([
        ('one_hot', OneHotEncoder(cols=CATEGORICAL)),
        ('count_vectorizer', ColumnTransformer([
            ('topic_modeling', make_pipeline(
                CountVectorizer(stop_words='english'), 
                TruncatedSVD(n_components=n_components, random_state=7)
             ), 'name')
        ])),
        ('linear_regression', LinearRegression())
    ])
    
    scores = cross_val_score(
        tm_pipeline, X_train, y_train, cv=5, scoring=score_fun
    )
    return np.mean(scores)

study = optuna.create_study()
study.optimize(objective, n_trials=100)

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

import matplotlib.pyplot as plt

# This function is a slightly modfied snippet from 
# https://scikit-learn.org/stable/auto_examples/applications/plot_topics_extraction_with_nmf_lda.html
def plot_top_words(model, feature_names, n_top_words, title, n_topics=10):
    fig, axes = plt.subplots(2, 5, figsize=(30, 15), sharex=True)
    axes = axes.flatten()
    for topic_idx, topic in enumerate(model.components_[:n_topics]):
        top_features_ind = topic.argsort()[: -n_top_words - 1 : -1]
        top_features = [feature_names[i] for i in top_features_ind]
        weights = topic[top_features_ind]

        ax = axes[topic_idx]
        ax.barh(top_features, weights, height=0.7)
        ax.set_title(f"Topic {topic_idx +1}", fontdict={"fontsize": 30})
        ax.invert_yaxis()
        ax.tick_params(axis="both", which="major", labelsize=20)
        for i in "top right left".split():
            ax.spines[i].set_visible(False)
        fig.suptitle(title, fontsize=40)

    plt.subplots_adjust(top=0.90, bottom=0.05, wspace=0.90, hspace=0.3)
    plt.show()

# Extracting fitted CountVectorizer and TruncatedSVD
count_vectorizer = tm_pipeline[1].transformers_[0][1][0]
decomposition = tm_pipeline[1].transformers_[0][1][1]

plot_top_words(
    decomposition,
    count_vectorizer.get_feature_names_out(),
    10,
    "Topics in CountVectorizer + NMF"
)

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

# Based on https://dirty-cat.github.io/stable/auto_examples/02_investigating_dirty_categories.html#sphx-glr-auto-examples-02-investigating-dirty-categories-py

def docs_vs_topics(full_encoder, bag_of_words, decomposition, 
                   n_docs=20, n_top_words=5):
    
    # Exctracting topic labels
    feature_names = bag_of_words.get_feature_names_out()
    out_features = full_encoder.get_feature_names_out()
    topic_labels = []
    
    for k in range(len(out_features)):
        labels = out_features[k]       
        topic = decomposition.components_[k]
        top_features_ind = topic.argsort()[: -n_top_words - 1 : -1]
        top_features = [feature_names[i] for i in top_features_ind]
        topic_labels.append(','.join(top_features))
    
    # Plotting matrix
    encoded_labels = full_encoder.transform(X[:n_docs])
    plt.figure(figsize=(8, 10))
    plt.imshow(encoded_labels)
    plt.xlabel("Latent topics", size=12)
    plt.xticks(range(0, 10), labels=topic_labels, rotation=50, ha="right")
    plt.ylabel("Data entries", size=12)
    plt.yticks(range(0, n_docs), labels=X['name'][:n_docs].to_numpy().flatten())
    plt.colorbar().set_label(label="Topic activations", size=12)
    plt.tight_layout()
    plt.show()

full_encoder  = tm_pipeline[1]
bag_of_words  = full_encoder.transformers_[0][1][0]
decomposition = full_encoder.transformers_[0][1][1]

docs_vs_topics(
    full_encoder  = full_encoder, 
    bag_of_words  = bag_of_words,
    decomposition = decomposition,
    n_docs        = 30
)

Плюсы

  • относительно небольшое функциональное пространство
  • интерпретируемый (матрица темы документа)
  • учитывает все токены в документе
  • может обрабатывать документы произвольной длины

Минусы

  • потенциальная проблема с обработкой новых слов (особенно если токен = слово)
  • может быть недостаточно подвержен опечаткам
  • имеет значение только вхождение слова, его положение не несет никакой информации

Хеширование функций

Хеширование признаков можно рассматривать как альтернативу CountVectorizer. Алгоритм начинается с создания большой матрицы, полной нулей. После токенизации i-го документа мы используем эту простую двухэтапную операцию:

  1. Функция хеширования применяется к каждому токену i-го документа.
    В результате мы получаем индекс (j),ссылающийся на j -th столбец матрицы.
  2. Значение по индексу (i, j) увеличивается на значение.

Имейте в виду, что это значение не обязательно должно равняться единице — см. примеры в документации sklearn. На картинке ниже я предполагаю, что это один, но это упрощение, только ради ясности представления.

from sklearn.feature_extraction.text import HashingVectorizer

# Count vectorizer
hash_vec_pipeline = Pipeline([
    ('one_hot', OneHotEncoder(cols=CATEGORICAL)),
    ('count_vectorizer', ColumnTransformer([
        ('hash_vec', HashingVectorizer(), 'name')
    ])),
    ('linear_regression', LinearRegression())
])
hash_vec_pipeline.fit(X_train, y_train)
y_pred_count_vec = hash_vec_pipeline.predict(X_test)
print(mean_squared_error(y_test, y_pred_count_vec))

Если мы хотим интерпретировать вывод HashingVectorizer, мы должны сначала проверить, как каждый токен соответствует какому индексу. В любом случае, у нас нет никаких гарантий, что именно этот токен был/будет назначен данному конкретному столбцу. Чтобы быть уверенным в этом, нам пришлось бы создавать и хранить словарь назначения токенов, что не имеет смысла — тогда мы избавляемся от самого большого преимущества использования HashingVectorizer, то есть его отсутствия состояния. Ниже я представляю фрагмент кода, который мы можем использовать для создания сопоставления токенов и хэшей: для интерпретации вновь созданных столбцов или поиска коллизий хэшей.

# Getting tokenizer and hasher from the HashingVectorizer object
hash_vec = hash_vec_pipeline[1].transformers_[0][1]
tokenizer = hash_vec.build_analyzer()
hasher = hash_vec._get_hasher()

# Preparing list of unique tokens
tokenized = [tokenizer(string) for string in X_train['name']]
unique_tokens = np.unique([token for lst in tokenized for token in lst])

# Hashing unique tokens to get their indices
# We create a dataset having a single, unique token in every column
# Absolute value with argmax is used to detect 
# the non-zero values occuring in each row 
hashes = hasher.transform(np.array([unique_tokens]).T)
token_indices = np.abs(hashes).argmax(axis=1)
token_dict = pd.DataFrame({
    'token': np.array(unique_tokens).ravel().tolist(),
    'idx': token_indices.ravel().tolist()[0]  
})

# Looking for hash collision
colliding_hashes = \
  hash_collision[(hash_collision > 1).token].reset_index().idx
same_hash = token_dict.query('idx in @colliding_hashes').sort_values('idx')

print(same_hash)

Плюсы

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

Минусы

  • разреженное функциональное пространство
  • необратимый
  • не интерпретируется напрямую
  • потенциальные коллизии хэшей
  • мы не можем использовать нормализацию, как TFIDF

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

  1. dirty_cat: машинное обучение с грязными категориями
  2. Чжан З., Фанг М., Чен Л., Намази-Рад М.-Р. (2022) Является ли нейронное тематическое моделирование лучше, чем кластеризация? Эмпирическое исследование кластеризации с контекстными вложениями для тем
  3. Фостер Д. П., Либерман М., Стайн Р. А., (2013) Особенность текста: преобразование текста в предикторы для регрессионного анализа
  4. Хасанзаде Калшани, А., Разави А. и Асади, Р., (2020) Прогнозирование фондового рынка с использованием заголовков ежедневных новостей
  5. Восс К. (2014), Построение тематических моделей на основе ключевых слов
  6. Zhu X. (2010) Уменьшение размерности и модели скрытых тем
  7. Добровольский Х., Кеберле Н. (2017) Анализ основных компонентов в тематическом моделировании коллекций коротких текстовых документов
  8. Макмиллан К. и Уилсон Дж. Д. (2017), Topic Supervised Non-negative Matrix Factorization
  9. Тематическое моделирование: введение, MonkeyLearn
  10. HashingVectorizer против CountVectorizer, Кавита Ганесан
  11. Не дайте себя обмануть трюком с хешированием, Лукас Бернарди