Цель этой статьи - предоставить практический пример тонкой настройки BERT для задачи регрессии. В нашем случае мы будем прогнозировать цены на объекты недвижимости во Франции.

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

Набор данных

Набор данных, который мы используем сегодня, такой же, как в предыдущей статье. Он включает данные из 788 000 объявлений, взятых с французских веб-сайтов по недвижимости. Наряду с числовыми и категориальными характеристиками, используемыми в эталонной ЛГБМ-модели, мы собрали текстовое описание выставленной на продажу недвижимости.

Эти описания имеют тенденцию повторять по крайней мере часть информации, содержащейся в других функциях, но в форме естественного языка. Информация часто представлена ​​субъективно, выдвигая наиболее привлекательные черты объекта недвижимости. Эти описания могут также содержать детали, относящиеся к собственности, которые не вписываются ни в какой набор общих функций, таких как «отремонтирован архитектором».

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

К счастью для нас, обработка естественного языка - очень активная область исследований. Архитектура преобразователя и механизмы внимания были впервые представлены в документе 2017 года под названием« Внимание - это все, что вам нужно », а в 2018 году Google представил революционную языковую модель на основе преобразователя: BERT.

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

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

BERT и CamemBERT

Как вы, наверное, уже заметили, текстовые данные, которые мы собрали на французском языке… К счастью, исследователи из Facebook и Inria объединились для создания CamemBERT. Нет, не сыр…, а современная языковая модель для французского языка, основанная на« архитектуре RoBERTa , предварительно обученная на французском субкорпусе OSCAR », как указано на их "Веб-сайт".

Библиотека трансформеров

В нашем случае мы будем использовать реализацию CamemBERT библиотеку трансформеров. Библиотека трансформаторов, разработанная HuggingFace, похоже, стала библиотекой для предварительно обученных моделей архитектуры трансформаторов, таких как BERT в PyTorch.

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

Предварительная обработка данных для BERT

Мы будем использовать те же данные для обучения и проверки, что и в предыдущем посте. Чтобы узнать больше о данных и о том, как они проходят предварительную обработку, вы можете ознакомиться с нашим предыдущим постом. Давайте снова импортируем и предварительно обработаем наши наборы данных, и на этот раз сосредоточимся на переменной description.

import pandas as pd
from preprocessing import preprocessing_pipeline
train_path = "train_data.csv"
val_path = "test_data.csv"
train_data = pd.read_csv(train_path, sep=',', index_col=0)
val_data = pd.read_csv(val_path, sep=',', index_col=0)
train_data = preprocessing_pipeline.fit_transform(train_data)
val_data= preprocessing_pipeline.transform(val_data)
df = train_data[['id_annonce', 'description', 'prix']]

Описание составляет в среднем 154 слова и 928 символов. Если не брать в расчет стоп-слова, наиболее часто встречающиеся слова явно присутствуют в лексическом поле недвижимости: дом, спальня, кухня, земля…

Общие подходы к встраиванию в NLP, такие как Word2Vec или FastText, обычно требуют лемматизации и удаления стоп-слов, но это не относится к BERT. Общая структура текста фактически не должна изменяться, поскольку BERT полагается на нее для изучения и интерпретации контекста.

Таким образом, наша предварительная обработка будет ограничена:

  • Преобразование текста в нижний регистр
  • Стандартизация представлений одного и того же объекта, таких как «€», «евро» и «евро» или «m2» и «m²»,
import re
def treat_euro(text):
    text = re.sub(r'(euro[^s])|(euros)|(€)', ' euros', text)
    return text
def treat_m2(text):
    text = re.sub(r'(m2)|(m²)', ' m²', text)
    return text
  • Удаление определенных шаблонов, которые вряд ли будут иметь смысл, таких как URL-адреса, номера телефонов, электронные письма и ссылки на банковские счета.
def filter_ibans(text):
    pattern = r'fr\d{2}[ ]\d{4}[ ]\d{4}[ ]\d{4}[ ]\d{4}[ ]\d{2}|fr\d{20}|fr[ ]\d{2}[ ]\d{3}[ ]\d{3}[ ]\d{3}[ ]\d{5}'
    text = re.sub(pattern, '', text)
    return text
def remove_space_between_numbers(text):
    text = re.sub(r'(\d)\s+(\d)', r'\1\2', text)
    return text
def filter_emails(text):
    pattern = r'(?:(?!.*?[.]{2})[a-zA-Z0-9](?:[a-zA-Z0-9.+!%-]{1,64}|)|\"[a-zA-Z0-9.+!% -]{1,64}\")@[a-zA-Z0-9][a-zA-Z0-9.-]+(.[a-z]{2,}|.[0-9]{1,})'
    text = re.sub(pattern, '', text)
    return text
def filter_ref(text):
    pattern = r'(\(*)(ref|réf)(\.|[ ])\d+(\)*)'
    text = re.sub(pattern, '', text)
    return text
def filter_websites(text):
    pattern = r'(http\:\/\/|https\:\/\/)?([a-z0-9][a-z0-9\-]*\.)+[a-z][a-z\-]*'
    text = re.sub(pattern, '', text)
    return text
def filter_phone_numbers(text):
    pattern = r'(?:(?:\+|00)33[\s.-]{0,3}(?:\(0\)[\s.-]{0,3})?|0)[1-9](?:(?:[\s.-]?\d{2}){4}|\d{2}(?:[\s.-]?\d{3}){2})|(\d{2}[ ]\d{2}[ ]\d{3}[ ]\d{3})'
    text = re.sub(pattern, '', text)
    return text

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

def clean_text(text):
    text = text.lower()
    text = text.replace(u'\xa0', u' ')
    text = treat_m2(text)
    text = treat_euro(text)
    text = filter_phone_numbers(text)
    text = filter_emails(text)
    text = filter_ibans(text)
    text = filter_ref(text)
    text = filter_websites(text)
    text = remove_space_between_numbers(text)
    return text
df['cleaned_description'] = df.description.apply(clean_text)

Токенизация

BERT принимает на вход последовательности одинаковой длины. Таким образом, входные последовательности должны быть дополнены или усечены до заданной длины специальными символами, явно указывающими фактическое начало и конец последовательности. Специальный начальный символ называется токеном «[CLS]». Мы поговорим об этом позже.

Эти входные последовательности затем разделяются на две части:

  • Последовательность «входных идентификаторов», отображающая слова в токены из словаря, с которым была предварительно обучена модель,
  • Двоичная последовательность «масок внимания», которая указывает, является ли «входной идентификатор» в данном индексе словом или заполнением.

Библиотека трансформатора предоставляет модуль токенизатора, который делает все за нас:

from transformers import CamembertTokenizer
tokenizer = CamembertTokenizer.from_pretrained('camembert-base')
encoded_corpus = tokenizer(text=df.cleaned_description.tolist(),
                            add_special_tokens=True,
                            padding='max_length',
                            truncation='longest_first',
                            max_length=300,
                            return_attention_mask=True)
input_ids = encoded_corpus['input_ids']
attention_mask = encoded_corpus['attention_mask']

Максимальная длина входной последовательности, поддерживаемая BERT, составляет 512 токенов, но использование более коротких входных последовательностей имеет тенденцию улучшать и ускорять процесс обучения. Мы возьмем максимальную входную последовательность 300. Чтобы избежать усечения любого описания и поскольку оно касается только небольшой части наблюдений, мы решили отложить описания, которые требовали усечения, учитывая максимальную длину 300.

import numpy as np
def filter_long_descriptions(tokenizer, descriptions, max_len):
    indices = []
    lengths = tokenizer(descriptions, padding=False, 
                     truncation=False, return_length=True)['length']
    for i in range(len(descriptions)):
        if lengths[i] <= max_len-2:
            indices.append(i)
    return indices
short_descriptions = filter_long_descriptions(tokenizer, 
                               df.cleaned_description.tolist(), 300)
input_ids = np.array(input_ids)[short_descriptions]
attention_mask = np.array(attention_mask)[short_descriptions]
labels = df.prix.to_numpy()[short_descriptions]

Форматирование ввода

Наши данные уже разделены на набор для обучения и проверки. Набор для проверки будет использоваться исключительно для оценки производительности окончательной обученной модели. Обучающая выборка - это тот же набор наблюдений 300K, который мы использовали в предыдущем посте. Чтобы контролировать производительность модели во время обучения, еще 10% обучающих данных откладываются в отдельный набор тестов.

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

from sklearn.model_selection import train_test_split
test_size = 0.1
seed = 42
train_inputs, test_inputs, train_labels, test_labels = \
            train_test_split(input_ids, labels, test_size=test_size, 
                             random_state=seed)
train_masks, test_masks, _, _ = train_test_split(attention_mask, 
                                        labels, test_size=test_size, 
                                        random_state=seed)

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

from sklearn.preprocessing import StandardScaler
price_scaler = StandardScaler()
price_scaler.fit(train_labels.reshape(-1, 1))
train_labels = price_scaler.transform(train_labels.reshape(-1, 1))
test_labels = price_scaler.transform(test_labels.reshape(-1, 1))

Теперь давайте преобразуем наборы для обучения и тестирования в форматы ввода, удобные для PyTorch.

Для каждого набора входные идентификаторы, маски и метки преобразуются в тензоры, а затем объединяются в TensorDataset. Затем TensorDataset упаковывается в объект DataLoader, который представляет собой итератор, который представляет входные данные в пакетах из 32 наблюдений для нашей модели при обучении.

import torch
from torch.utils.data import TensorDataset, DataLoader
batch_size = 32
def create_dataloaders(inputs, masks, labels, batch_size):
    input_tensor = torch.tensor(inputs)
    mask_tensor = torch.tensor(masks)
    labels_tensor = torch.tensor(labels)
    dataset = TensorDataset(input_tensor, mask_tensor, 
                            labels_tensor)
    dataloader = DataLoader(dataset, batch_size=batch_size, 
                            shuffle=True)
    return dataloader
train_dataloader = create_dataloaders(train_inputs, train_masks, 
                                      train_labels, batch_size)
test_dataloader = create_dataloaders(test_inputs, test_masks, 
                                     test_labels, batch_size)

Архитектура модели

Теперь о реальной архитектуре нашей модели. BERT состоит из слоя заделки и 12 трансформаторов, установленных друг за другом.

Для каждой входной последовательности вывод BERT представляет собой последовательность векторов одинакового размера. Эти векторы являются окончательными скрытыми состояниями, представляющими каждый входной токен. Каждый из этих векторов состоит из 768 чисел с плавающей запятой.

В статье BERT указывается, что для задач классификации следует использовать только окончательное скрытое состояние первого токена в выходной последовательности. Другими словами, следует использовать только вектор, представляющий маркер [CLS], упомянутый ранее.

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

Реализация модели в PyTorch

Код PyTorch для реализации этой модели на самом деле довольно прост. Наш CamembertRegressor - это PyTorch nn.Module с двумя новыми атрибутами:

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

Метод forward передает токенизированные входные данные через CamembertModel и собирает 768 длинных векторов, соответствующих выходному токену «Метка класса». Затем он передает этот вектор через слой регрессии, который выводит предсказанное значение.

import torch.nn as nn
from transformers import CamembertModel
class CamembertRegressor(nn.Module):
    
    def __init__(self, drop_rate=0.2, freeze_camembert=False):
        
        super(CamembertRegressor, self).__init__()
        D_in, D_out = 768, 1
        
        self.camembert = \
                   CamembertModel.from_pretrained('camembert-base')
        self.regressor = nn.Sequential(
            nn.Dropout(drop_rate),
            nn.Linear(D_in, D_out))
    def forward(self, input_ids, attention_masks):
        
        outputs = self.camembert(input_ids, attention_masks)
        class_label_output = outputs[1]
        outputs = self.regressor(class_label_output)
        return outputs
model = CamembertRegressor(drop_rate=0.2)

Настройка тренировочной среды

Если имеется графический процессор, его следует использовать для ускорения процесса обучения. Этот код должен позволить PyTorch использовать графический процессор, если он доступен, иначе он будет обучать модель на процессоре.

import torch
if torch.cuda.is_available():       
    device = torch.device("cuda")
    print("Using GPU.")
else:
    print("No GPU available, using the CPU instead.")
    device = torch.device("cpu")
model.to(device)

Оптимизатор, планировщик и функция потерь

Мы определим оптимизатор и планировщик скорости обучения для нашего учебного процесса. Мы будем использовать оптимизатор Adam со скоростью обучения 5e-5, как это было сделано в официальной статье BERT.

from transformers import AdamW
optimizer = AdamW(model.parameters(),
                  lr=5e-5,
                  eps=1e-8)

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

from transformers import get_linear_schedule_with_warmup
epochs = 5
total_steps = len(train_dataloader) * epochs
scheduler = get_linear_schedule_with_warmup(optimizer,       
                 num_warmup_steps=0, num_training_steps=total_steps)

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

loss_function = nn.MSELoss()

Цикл обучения

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

  • Распакуйте идентификаторы ввода, маски внимания и соответствующие целевые цены,
  • Загрузите их в GPU или CPU устройство,
  • Сбросьте градиенты предыдущего шага обучения,
  • Вычислить прогноз (прямой проход),
  • Вычислить градиенты (обратное распространение),
  • Обрезайте градиенты, чтобы предотвратить проблемы с взрывом или исчезновением градиента,
  • Обновите параметры модели,
  • Отрегулируйте скорость обучения.

Таким образом, для обучения нашей модели достаточно одного следующего кода:

from torch.nn.utils.clip_grad import clip_grad_norm
def train(model, optimizer, scheduler, loss_function, epochs,       
          train_dataloader, device, clip_value=2):
    for epoch in range(epochs):
        print(epoch)
        print("-----")
        best_loss = 1e10
        model.train()
        for step, batch in enumerate(train_dataloader): 
            print(step)  
            batch_inputs, batch_masks, batch_labels = \
                               tuple(b.to(device) for b in batch)
            model.zero_grad()
            outputs = model(batch_inputs, batch_masks)           
            loss = loss_function(outputs.squeeze(), 
                             batch_labels.squeeze())
            loss.backward()
            clip_grad_norm(model.parameters(), clip_value)
            optimizer.step()
            scheduler.step()
                
    return model
model = train(model, optimizer, scheduler, loss_function, epochs, 
              train_dataloader, device, clip_value=2)

Однако важно регулярно вычислять, сохранять и регистрировать потери при обучении, чтобы контролировать процесс обучения. Для краткости мы не включали такой код в сообщение, но вы можете почерпнуть вдохновение из Руководства Криса Маккормика. Мы вычислили потерю MSE, а также оценку R2 для каждых 20 пакетов обучающих данных.

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

def evaluate(model, loss_function, test_dataloader, device):
    model.eval()
    test_loss, test_r2 = [], []
    for batch in test_dataloader:
        batch_inputs, batch_masks, batch_labels = \
                                 tuple(b.to(device) for b in batch)
        with torch.no_grad():
            outputs = model(batch_inputs, batch_masks)
        loss = loss_function(outputs, batch_labels)
        test_loss.append(loss.item())
        r2 = r2_score(outputs, batch_labels)
        test_r2.append(r2.item())
    return test_loss, test_r2
def r2_score(outputs, labels):
    labels_mean = torch.mean(labels)
    ss_tot = torch.sum((labels - labels_mean) ** 2)
    ss_res = torch.sum((labels - outputs) ** 2)
    r2 = 1 - ss_res / ss_tot
    return r2

Он также пригодится для измерения и регистрации продолжительности этапов обучения. Мы обучили нашу модель с помощью 150K обучающих наблюдений на ноутбуке Google Colab с ускорением на GPU, это заняло около 1 часа 30 минут на эпоху.

Представление

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

def predict(model, dataloader, device):
    model.eval()
    output = []
    for batch in dataloader:
        batch_inputs, batch_masks, _ = \
                                  tuple(b.to(device) for b in batch)
        with torch.no_grad():
            output += model(batch_inputs, 
                            batch_masks).view(1,-1).tolist()[0]
    return output

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

val_set = val_data[['id_annonce', 'description', 'prix']]
val_set['cleaned_description'] = \
                val_set.description.apply(clean_text)
encoded_val_corpus = \
                tokenizer(text=val_set.cleaned_description.tolist(),
                          add_special_tokens=True,
                          padding='max_length',
                          truncation='longest_first',
                          max_length=300,
                          return_attention_mask=True)
val_input_ids = np.array(encoded_val_corpus['input_ids'])
val_attention_mask = np.array(encoded_val_corpus['attention_mask'])
val_labels = val_set.prix.to_numpy()
val_labels = price_scaler.transform(val_labels.reshape(-1, 1))
val_dataloader = create_dataloaders(val_input_ids, 
                         val_attention_mask, val_labels, batch_size)
y_pred_scaled = predict(model, val_dataloader, device)

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

y_test = val_set.prix.to_numpy()
y_pred = price_scaler.inverse_transform(y_pred_scaled)

Одним из недостатков BERT является то, что он относительно медленный для прогнозирования. Тем не менее, ему удается бросить вызов производительности нашей тестовой модели. Он фактически превосходит его с точки зрения среднеквадратичной ошибки и очень близок к нему с точки зрения средней и медианной абсолютной ошибки и достигает аналогичного показателя R2. По абсолютной погрешности в процентах это тоже очень близко.

from sklearn.metrics import mean_absolute_error
from sklearn.metrics import median_absolute_error
from sklearn.metrics import mean_squared_error
from sklearn.metrics import mean_absolute_percentage_error
from sklearn.metrics import r2_score
mae = mean_absolute_error(y_test, y_pred)
mdae = median_absolute_error(y_test, y_pred)
mse = mean_squared_error(y_test, y_pred)
mape = mean_absolute_percentage_error(y_test, y_pred)
mdape = ((pd.Series(y_test) - pd.Series(y_pred))\
         / pd.Series(y_test)).abs().median()
r_squared = r2_score(y_test, y_pred)

Рекомендуемые следующие шаги: объединение числовых, категориальных и текстовых функций

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

Один из подходов - использовать методы встраивания текста для извлечения значимых количественных характеристик из текста. Затем встраиваемые функции могут быть добавлены к другим числовым и категориальным характеристикам и использованы с любой контролируемой регрессионной моделью.

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

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

Вывод

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

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

Благодарности

Я хотел бы выразить особую благодарность Кавтару Захеру, Анн-Мари Хенг, Лоррейн Хиксон и Луи Буланже за их большой вклад в эту статью.