Что объединяет 1 февраля, 22 марта, 3 мая и 14 июня 2023 года? Независимо от их конкретной направленности на классы активов — облигации, акции, иностранную валюту, товары и т. д. — миллионы участников финансового рынка с нетерпением ждут одного и того же события: завершения заседания FOMC.

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

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

В части 2 из 3 мы настроим модель GPT-3 для этой задачи классификации через API OpenAI. Мы также поделимся тем, как мы создаем метод для агрегирования рейтингов тональности на уровне предложений в оценку на уровне утверждений.

Наконец, в части 3 из 3 мы углубимся в то, как мы можем напрямую прогнозировать движение доходности 10-летних государственных облигаций США после публикации заявления FOMC по сравнению с уровнями предыдущего дня.

Подведение итогов набора данных

Заявления FOMC доступны на веб-сайте Федеральной резервной системы в форматах PDF и HTML. Извлечение текстовых данных из PDF и удаление из HTML выходит за рамки данной статьи. Заявления FOMC состоят из предложений; каждое утверждение состоит из различного количества предложений.

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

Приговоры были отмечены четырьмя рецензентами, имеющими опыт работы в области финансов и экономики, в рамках исследовательской работы (Хансен, А. и Казинник, С. (2023)) сотрудников Федеральной резервной системы. Хотя мы не можем поделиться базовым помеченным набором данных (они были предоставлены нам в рамках академического исследования), в нашей следующей статье мы покажем, как мы можем использовать финансовый рынок, чтобы помочь нам пометить наш набор данных; эта статья нацелена на то, чтобы поделиться основными методами НЛП, используемыми при анализе заявлений FOMC.

import pandas as pd
labelled_sentences = pd.read_excel('FOMC Labelled Sentences.xlsx')
labelled_sentences_aug = labelled_sentences.copy() #Create a copy to avoid altering the original data
labelled_sentences_aug

Добавление данных для дополнения ограниченного набора обучающих данных

Как правило, чем больше у нас данных, тем лучше мы можем обучать наши модели. Учитывая ограниченное количество предложений всего в 200, нам нужно искусственно увеличить наш набор обучающих данных. Перед увеличением данных мы должны принять к сведению разделение исходного набора данных на обучающие (80%) и тестовые компоненты (20%); расширение данных следует применять только к обучающему набору данных, а тестовый набор данных должен оставаться в чистом виде. Это сделано для того, чтобы в дальнейшем сохранить целостность точности нашей модели, тестовый набор данных должен состоять из предложений непосредственно из заявлений FOMC.

from sklearn.model_selection import train_test_split

# Keep sentence and score columns; meeting_date is not relevant to this analysis
labelled_sentences_aug['Score'] = labelled_sentences_aug['Score'].astype(str)
labelled_sentences_aug = labelled_sentences_aug[['Sentence', 'Score']]

### Split the original dataset into train-test prior to augmentation ###
train_aug, test_aug = train_test_split(labelled_sentences_aug, test_size=0.2, random_state=23, stratify=labelled_sentences_aug['Score'])

Наш подход к расширению данных включает технику замены на основе лексики Wordnet из библиотеки NLPAug; он определяет синонимы для токена/слов, которые должны быть заменены в исходных предложениях.

import nlpaug.augmenter.word as naw

# Note that this function elects to create 2 sets of augmented data on top of our original data
def augment_dataset(data, augmenter, num_augmented=2):
    data_augmented = data.copy()
    augmented_data = []

    for index, row in data_augmented.iterrows():
        for _ in range(num_augmented):
            augmented_data.append([ ' '.join(augmenter.augment(row['Sentence'])), row['Score']])

    augmented_data_df = pd.DataFrame(augmented_data, columns=['Sentence', 'Score'])

    return augmented_data_df

aug = naw.SynonymAug(aug_src='wordnet') # Initialize the augmenter
augmented_data = augment_dataset(train_aug, aug)
augmented_data

Учитывая, что наш исходный набор данных составляет 200 предложений, наш обучающий набор данных составляет 80% от этого и состоит из 160 предложений. Мы создали расширенный набор данных, в два раза превышающий исходный набор данных для обучения (320 предложений), чтобы дополнить наш исходный набор данных для обучения. Это доводит наш общий расширенный набор данных для обучения до 480 предложений.

def combine_datasets(original_data, augmented_data):
    # Combine the original dataset and the augmented dataset into a single dataframe
    combined_data = pd.concat([original_data, augmented_data], ignore_index=True)
    return combined_data

train_augmented_aug = combine_datasets(train_aug, augmented_data)
train_augmented_aug

Причина, по которой мы преодолеваем все эти трудности, чтобы увеличить набор данных, заключается в том, что это улучшает производительность модели. Эти характеристики точности показывают производительность точно настроенной модели GPT3 Ada с различными методами увеличения данных; Подстановка слов WordNet, которую мы использовали выше, дала наилучшее повышение точности по сравнению с базовой производительностью без увеличения данных (73% против 62%).

Применение модели BERT

Первая модель, которую мы продемонстрируем для использования, — это Представления двунаправленного кодировщика от преобразователей (BERT), где мы будем использовать передачу обучения из модели на основе bert-uncased. Модель BERT была предварительно обучена на BookCorpus, наборе данных из 11 038 неопубликованных книг и английской Википедии. Модель bert-base-uncased, с которой мы работаем, имеет 110 миллионов параметров.

import torch
from torch.utils.data import TensorDataset, DataLoader, RandomSampler, SequentialSampler
from keras.utils import pad_sequences
from sklearn.model_selection import train_test_split
from transformers import BertTokenizer, BertForSequenceClassification, AdamW, get_linear_schedule_with_warmup
import pandas as pd
import numpy as np
from collections import defaultdict
import random
import time
import datetime
from sklearn.metrics import f1_score, balanced_accuracy_score, confusion_matrix, precision_score, recall_score, classification_report
from torch.nn import functional as F

class MCBert(BertForSequenceClassification):
    def train(self):
        self.training = True
    def eval(self):
        self.training = True

class BERTSentimentClassifier:
    def __init__(self, model_name='bert-base-uncased', num_labels=6, device='cuda', max_length=100):
        self.device = torch.device(device if torch.cuda.is_available() else 'cpu')
        self.tokenizer = BertTokenizer.from_pretrained(model_name, do_lower_case=True)
        self.model = MCBert.from_pretrained(
            model_name,
            num_labels = num_labels,
            output_attentions = False,
            output_hidden_states = False
        ).to(self.device)
        self.max_length = max_length

    def tokenize(self, sentences):
        input_ids, attention_masks = [], []
        for sent in sentences:
            encoded_dict = self.tokenizer.encode_plus(
                sent,
                add_special_tokens=True,
                max_length=self.max_length,
                padding='max_length',
                truncation=True,
                return_attention_mask=True,
                return_tensors='pt',
            )
            input_ids.append(encoded_dict['input_ids'])
            attention_masks.append(encoded_dict['attention_mask'])
        input_ids = torch.cat(input_ids, dim=0)
        attention_masks = torch.cat(attention_masks, dim=0)
        return input_ids, attention_masks

    def prepare_data(self, df, test_size=0.2, batch_size=32):
        x_train, x_test, y_train, y_test = train_test_split(df['Sentence'], df['Score'], test_size=test_size, random_state=23)
        score_mapping = {'-1': 0, '-0.5': 1, '0': 2, '0.5': 3, '1': 4, 'Remove': 5}
        y_train = y_train.map(score_mapping)
        y_test = y_test.map(score_mapping)

        train_input_ids, train_attention_mask = self.tokenize(x_train.values.tolist())
        dev_input_ids, dev_attention_mask = self.tokenize(x_test.values.tolist())

        train_labels = torch.tensor(y_train.values)
        # train_labels = F.one_hot(train_labels, num_classes=6)
        dev_labels = torch.tensor(y_test.values)
        # dev_labels = F.one_hot(dev_labels, num_classes=6)

        train_data = TensorDataset(train_input_ids, train_attention_mask, train_labels)
        train_sampler = RandomSampler(train_data, replacement=False, generator=torch.Generator().manual_seed(23))
        self.train_dataloader = DataLoader(train_data, sampler=train_sampler, batch_size=batch_size)
        dev_data = TensorDataset(dev_input_ids, dev_attention_mask, dev_labels)
        dev_sampler = SequentialSampler(dev_data)
        self.dev_dataloader = DataLoader(dev_data, sampler=dev_sampler, batch_size=batch_size)
        return self

    def train(self, epochs, learning_rate=5e-5, eps=1e-8):
        optimizer = AdamW(self.model.parameters(), lr=learning_rate, eps=eps)

        total_steps = len(self.train_dataloader) * epochs

        # Create the learning rate scheduler
        scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=0, num_training_steps=total_steps)

        for epoch_i in range(0, epochs):
            print('\n======== Epoch {:} / {:} ========'.format(epoch_i + 1, epochs))
            t0 = time.time()

            total_train_loss = 0

            self.model.train()

            for step, batch in enumerate(self.train_dataloader):

                b_input_ids = batch[0].to(self.device)
                b_input_mask = batch[1].to(self.device)
                b_labels = batch[2].to(self.device)

                self.model.zero_grad()

                outputs = self.model(b_input_ids,
                                    token_type_ids=None,
                                    attention_mask=b_input_mask,
                                    labels=b_labels)

                loss = outputs.loss

                total_train_loss += loss.item()

                loss.backward()

                torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0)

                optimizer.step()

                scheduler.step()

            avg_train_loss = total_train_loss / len(self.train_dataloader)

            print("\n  Average training loss: {0:.2f}".format(avg_train_loss))
            print("  Training epoch took: {:}".format(self.format_time(time.time() - t0)))

            t0 = time.time()

            self.model.eval()

            total_eval_accuracy = 0
            total_eval_loss = 0

            correct_preds_per_class = defaultdict(int)
            total_preds_per_class = defaultdict(int)

            for batch in self.dev_dataloader:

                b_input_ids = batch[0].to(self.device)
                b_input_mask = batch[1].to(self.device)
                b_labels = batch[2].to(self.device)

                with torch.no_grad():
                    outputs = self.model(b_input_ids,
                                        token_type_ids=None,
                                        attention_mask=b_input_mask,
                                        labels=b_labels)

                loss = outputs.loss
                logits = outputs.logits

                total_eval_loss += loss.item()

                logits = logits.detach().cpu().numpy()
                label_ids = b_labels.to('cpu').numpy()

                pred_flat = np.argmax(logits, axis=1).flatten()

                for label in np.unique(label_ids):
                    correct_preds_per_class[label] += np.sum((pred_flat == label_ids) & (label_ids == label))
                    total_preds_per_class[label] += np.sum(label_ids == label)

                total_eval_accuracy += self.flat_accuracy(logits, label_ids)

            avg_val_accuracy = total_eval_accuracy / len(self.dev_dataloader)
            print("\n  Accuracy: {0:.2f}".format(avg_val_accuracy))

            avg_val_loss = total_eval_loss / len(self.dev_dataloader)

            for label in total_preds_per_class.keys():
                accuracy = correct_preds_per_class[label] / total_preds_per_class[label]
                print(f"Accuracy for class {label}: {accuracy:.2f}")

            print("  Validation Loss: {0:.2f}".format(avg_val_loss))
            print("  Validation took: {:}".format(self.format_time(time.time() - t0)))

        print("\nTraining complete!")

        return self

    def predict(self, test):
        test_input_ids, test_attention_mask = self.tokenize(test['Sentence'].values.tolist())

        # Convert inputs to tensors
        test_inputs = torch.tensor(test_input_ids)
        test_masks = torch.tensor(test_attention_mask)

        # Create DataLoader for the test data
        batch_size = 32

        test_data = TensorDataset(test_inputs, test_masks)
        test_sampler = SequentialSampler(test_data)
        test_dataloader = DataLoader(test_data, sampler=test_sampler, batch_size=batch_size)

        # For the BERT model, we keep it in train mode to enable dropout
        self.model.train()

        predicted_scores = []
        uncertainties = []

        n_mc_samples = 30

        for batch in test_dataloader:
            batch = tuple(t.to(self.device) for t in batch)
            b_input_ids, b_input_mask = batch

            mc_samples = []

            for _ in range(n_mc_samples):
                with torch.no_grad():
                    outputs = self.model(b_input_ids, token_type_ids=None, attention_mask=b_input_mask)

                logits = outputs[0]
                logits = logits.detach().cpu().numpy()
                mc_samples.append(logits)

            mc_samples = np.array(mc_samples)
            predicted_score = mc_samples.mean(axis=0)
            uncertainty = mc_samples.std(axis=0)

            predicted_scores.extend(np.argmax(predicted_score, axis=1).flatten())
            uncertainties.extend(uncertainty.max(axis=1).flatten())

        score_mapping = {'-1': 0, '-0.5': 1, '0': 2, '0.5': 3, '1': 4, 'Remove': 5}
        inverse_class_mapping = {v: k for k, v in score_mapping.items()}

        # Inverse map the classes to their original values
        predicted_scores = np.vectorize(inverse_class_mapping.get)(predicted_scores)

        accuracy = np.mean(predicted_scores == test['Score'].values)
        print(f'Test Accuracy: {accuracy*100:.2f}%')

        # Add the original sentences, their predicted scores and uncertainties to a DataFrame
        predictions_df = pd.DataFrame({'Sentence': test['Sentence'], 'Ground Truth': test['Score'], 'Predicted_Score': predicted_scores, 'Uncertainty': uncertainties})

        return predictions_df

    def predict_scores(self, test):
        test_input_ids, test_attention_mask = self.tokenize(test['Sentence'].values.tolist())

        # Convert inputs to tensors
        test_inputs = test_input_ids.clone().detach()
        test_masks = test_attention_mask.clone().detach()

        # Create DataLoader for the test data
        batch_size = 32

        test_data = TensorDataset(test_inputs, test_masks)
        test_sampler = SequentialSampler(test_data)
        test_dataloader = DataLoader(test_data, sampler=test_sampler, batch_size=batch_size)

        # For the BERT model, we keep it in train mode to enable dropout
        self.model.train()

        predicted_scores = []
        uncertainties = []

        n_mc_samples = 30

        for batch in test_dataloader:
            batch = tuple(t.to(self.device) for t in batch)
            b_input_ids, b_input_mask = batch

            mc_samples = []

            for _ in range(n_mc_samples):
                with torch.no_grad():
                    outputs = self.model(b_input_ids, token_type_ids=None, attention_mask=b_input_mask)

                logits = outputs[0]
                logits = logits.detach().cpu().numpy()
                mc_samples.append(logits)

            mc_samples = np.array(mc_samples)
            predicted_score = mc_samples.mean(axis=0)
            uncertainty = mc_samples.std(axis=0)

            predicted_scores.extend(np.argmax(predicted_score, axis=1).flatten())
            uncertainties.extend(uncertainty.max(axis=1).flatten())

        score_mapping = {'-1': 0, '-0.5': 1, '0': 2, '0.5': 3, '1': 4, 'Remove': 5}
        inverse_class_mapping = {v: k for k, v in score_mapping.items()}

        # Inverse map the classes to their original values
        predicted_scores = np.vectorize(inverse_class_mapping.get)(predicted_scores)

        return predicted_scores

    def compute_metrics(self, y_true, predicted_scores):
        # Convert arrays to string type
        y_true_str = y_true
        predicted_scores_str = predicted_scores

        # Compute F1 score and Balanced Accuracy
        f1 = f1_score(y_true_str, predicted_scores_str, average='weighted')
        balanced_accuracy = balanced_accuracy_score(y_true_str, predicted_scores_str)

        # Compute Precision and Recall
        precision = precision_score(y_true_str, predicted_scores_str, average='weighted')
        recall = recall_score(y_true_str, predicted_scores_str, average='weighted')

        # Compute Confusion Matrix
        confusion_mat = confusion_matrix(y_true_str, predicted_scores_str)

        # Compute classification report
        class_report = classification_report(y_true_str, predicted_scores_str)

        # Print the scores
        print(f'F1 Score: {f1*100:.2f}%')
        print(f'Balanced Accuracy: {balanced_accuracy*100:.2f}%')
        print(f'Precision: {precision*100:.2f}%')
        print(f'Recall: {recall*100:.2f}%')
        print('Classification Report:')
        print(class_report)

        return {
            "F1 Score": f1,
            "Balanced Accuracy": balanced_accuracy,
            "Precision": precision,
            "Recall": recall,
            "Confusion Matrix": confusion_mat,
            "Classification Report": class_report
        }

    @staticmethod
    def flat_accuracy(preds, labels):
        pred_flat = np.argmax(preds, axis=1).flatten()
        labels_flat = labels.flatten()
        return np.sum(pred_flat == labels_flat) / len(labels_flat)

    @staticmethod
    def flat_accuracy_per_class(preds, labels):
        pred_flat = np.argmax(preds, axis=1).flatten()
        labels_flat = labels.flatten()

        correct_per_class = defaultdict(int)
        total_per_class = defaultdict(int)

        for pred, label in zip(pred_flat, labels_flat):
            if pred == label:
                correct_per_class[label] += 1
            total_per_class[label] += 1

        accuracies_per_class = {label: correct / total for label, correct, total in zip(correct_per_class.keys(), correct_per_class.values(), total_per_class.values())}
        return accuracies_per_class

    @staticmethod
    # Function to format time
    def format_time(elapsed):
        elapsed_rounded = int(round((elapsed)))
        return str(datetime.timedelta(seconds=elapsed_rounded))

Приведенный выше фрагмент содержит код класса BERTSentimentClassifier, который мы будем вызывать для обучения нашего корпуса текстов. Осталось только вызвать модель и обучить ее! Обратите внимание, что число меток равно 6, поскольку оно включает +1, +0,5, 0, -0,5, -1 и «неизвестно» для предложений, не содержащих значения тональности.

model_aug = BERTSentimentClassifier(model_name='bert-base-uncased', num_labels=6, device='cuda', max_length=200)
model_aug.prepare_data(train_aug, test_size=0.2, batch_size=32)
model_aug.train(epochs=12, learning_rate=5e-5, eps=1e-8)

После того, как модель будет обучена, мы применим ее к нашему тестовому набору данных, который не подвергается дополнению данных, и модель также не использовалась ранее, что исключает утечку данных. И это все! Точность нашего теста составляет 57,5%. В нашей следующей статье, часть 2 из 3, вы можете увидеть, как тонко настроенный GPT-3 способен значительно превзойти это. На момент написания статьи исходные модели GPT-3 в настоящее время являются единственными, доступными для тонкой настройки.

Это завершает часть 1 из 3, где мы использовали увеличение текстовых данных, чтобы дополнить наш набор обучающих данных, а также использовали трансферное обучение с нашей моделью BERT. Если эта статья принесла вам какую-то пользу, рассмотрите возможность подписаться на меня на Medium, чтобы узнать больше!

Ссылки

Хансен А. и Казинник С. (2023). Может ли ChatGPT расшифровать федеральный язык? Доступно онлайн по адресу https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4399406