Введение

В этой статье рассматривается пример использования DistilBERT и трансферного обучения для анализа настроений. Статья начинается с постановки цели, разработки плана и анализа данных, прежде чем перейти к обучению модели, и, наконец, охватывает некоторый анализ результатов. Идея состоит в том, чтобы проследить проект от начала до конца, чтобы проиллюстрировать весь процесс науки о данных. Как известно многим специалистам по данным, машинное обучение составляет около 10% от реального машинного обучения и на 90% от другого. Я надеюсь, что это подробное описание моего проекта иллюстрирует этот момент. Я также хочу предоставить подробный пример трансферного обучения с использованием Keras и DistilBERT для обработки естественного языка с кодом для всех, кто хочет начать работу в этой области.

Цель этого проекта

Когда я не очищаю данные, не анализирую данные, не изучаю данные или не мечтаю о них, мне нравится проводить время, занимаясь скалолазанием. К счастью для меня, есть отличный сайт MountainProject.com, он-лайн ресурс для альпинистов. Основная цель Mountain Project - служить онлайн-путеводителем, где каждый маршрут восхождения имеет описание, а также информацию о качестве, сложности и типе восхождения. Существуют также форумы, на которых альпинисты могут задавать вопросы, изучать новые техники, находить партнеров, хвастаться недавними приключениями в скалолазании и пересматривать альпинистское снаряжение.

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

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

План

К сожалению для меня, форумы, посвященные обзору снаряжения Mountain Project, не имеют маркировки. Форумы - это собрание мыслей и мнений, но они не имеют никакого числового значения. Под этим я подразумеваю, что могу написать, что я думаю о предмете снаряжения, но я не ставлю ему звезду. Это исключает возможность прямого обучения с учителем. (Да, конечно, я мог бы пойти и обозначить более 100 тысяч форумов вручную, но где в этом веселье? Нигде, это звучит ужасно.)

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

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

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

Данные

Чем ближе ваша начальная задача к финальной, тем эффективнее будет трансферное обучение. Итак, мне нужно найти помеченный набор данных, относящийся к альпинистскому и альпинистскому снаряжению. Мои поиски привели меня к двум местам. Сначала на Trailspace.com. Trailspace - это веб-сайт, на котором любители активного отдыха могут писать отзывы о своем снаряжении и (что наиболее важно) оставлять звездный рейтинг. Это казалось идеальным, но, к сожалению, было всего ~ 1000 отзывов, связанных с альпинистским снаряжением.

Этот недостаток данных привел меня ко второму набору данных с пометкой: маршрутам в Mountain Project. У каждого маршрута есть описание и рейтинг в звездах, а на сайте около 116 000 маршрутов. Это много данных, но это не совсем то, что мне нужно, потому что то, как альпинисты говорят о маршрутах, отличается от того, как альпинисты говорят о снаряжении. Например, я бы не назвал снаряжение «забавным» и не назвал бы маршрут «полезным».

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

Шаг 1. Очистите данные

Мой план изложен, и теперь пришло время действительно собрать некоторые данные. Для этого мне нужно было создать парсер для Mountain Project и Trailspace. До начала этого проекта я понятия не имел, как выполнять парсинг веб-страниц. Итак, я посмотрел это чрезвычайно полезное видео на YouTube о парсинге веб-страниц в Python с помощью BeautifulSoup. К счастью для меня, форумы Trailspace и Mountain Project было довольно легко очистить. Код для очистки Trailspace приведен ниже в качестве примера:

from urllib.request import urlopen as uReq
from bs4 import BeautifulSoup as soup
import os
import time
%%capture
from tqdm import tqdm_notebook as tqdm
tqdm().pandas()
# Manually gather list of main page URLs
all_urls = ["https://www.trailspace.com/gear/mountaineering-boots/",
           "https://www.trailspace.com/gear/mountaineering-boots/?page=2",
           "https://www.trailspace.com/gear/mountaineering-boots/?page=3",
           "https://www.trailspace.com/gear/approach-shoes/",
           "https://www.trailspace.com/gear/approach-shoes/?page=2",
           "https://www.trailspace.com/gear/climbing-shoes/",
           "https://www.trailspace.com/gear/climbing-shoes/?page=2",
           "https://www.trailspace.com/gear/climbing-protection/",
           "https://www.trailspace.com/gear/ropes/",
           "https://www.trailspace.com/gear/carabiners-and-quickdraws/",
           "https://www.trailspace.com/gear/belay-rappel/",
           "https://www.trailspace.com/gear/ice-and-snow-gear/",
           "https://www.trailspace.com/gear/big-wall-aid-gear/",
           "https://www.trailspace.com/gear/harnesses/",
           "https://www.trailspace.com/gear/climbing-helmets/",
           "https://www.trailspace.com/gear/climbing-accessories/"]
def get_gear_subpages(main_url):
    '''Function to grab all sub-URLs from main URL'''
    # Get HTML info
    uClient = uReq(main_url) # request the URL
    page_html = uClient.read() # Read the html
    uClient.close() # close the connection
    gear_soup = soup(page_html, "html.parser")


    item_urls = []
    items = gear_soup.findAll("a", {"class":"plProductSummaryGrid"})
    for a_tag in items:
        href = a_tag.attrs.get("href")
        if href == "" or href is None:
            continue
        else:
            item_urls.append("https://www.trailspace.com"+href)

    return item_urls
# Get a lit of all sub-URLs
all_sub_urls = []
for main_url in tqdm(all_urls):
    all_sub_urls += get_gear_subpages(main_url)
def get_gear_comments(gear_url):
    '''Function to extract all comments from each sub-URL'''
    # Get HTML info
    uClient = uReq(gear_url) # request the URL
    page_html = uClient.read() # Read the html
    uClient.close() # close the connection
    review_soup = soup(page_html, "html.parser")
    
    all_reviews = review_soup.find("div", {"id":"reviews"})
    
    review_dict = dict()
    
    try:
        for this_review in all_reviews.findAll("div", {"class": "reviewOuterContainer"}):
            # Get review rating
            try:
                rating = float(str(this_review.find_next('img').find_next("img")).split("rated ")[1].split(" of")[0])
            except:
                rating = float(str(this_review.find("img").find_next("img").find_next("img")).split("rated ")[1].split(" of")[0])
            # Get review text
            review_summary = this_review.find("div",{"class":"review summary"}).findAll("p")
            review_text = ""
            for blurb in review_summary:
                review_text += "   " + blurb.text.replace("\n", "   ").replace("\r", "   ")

            review_dict[review_text] = rating
    except:
        pass
    
    return review_dict
# Extract information from all URLs and save to file:
t0 = time.time()

filename = "trailspace_gear_reviews.csv"
f = open(filename, "w")
headers = "brand, model, rating, rating_text\n"
f.write(headers)
   
for url in tqdm(all_sub_urls):
    brand = url.split("/")[4]
    model = url.split("/")[5]
    info = get_gear_comments(url)
    for review in info.keys():
        rating_text = review.replace(",", "~")
        rating = info[review]

        f.write(brand  +","+ 
                model  +","+ 
                str(rating) +","+ 
                rating_text  + "\n")
        
f.close()
t1 = time.time()  
t1-t0

Маршруты оказались намного сложнее. В Mountain Project маршруты отсортированы по «области› подрайон ›маршрут». Но иногда есть несколько подобластей, поэтому это выглядит как «область› большая-подобласть ›средняя-подобласть› малая-подобласть ›маршрут». Моя основная проблема заключалась в переборе всех маршрутов, чтобы убедиться, что я собрал данные обо всех из них, даже если они не организованы единообразно.

К счастью, у Mountain Project был другой способ обойти это. В Mountain Project вы можете искать маршруты и сортировать их по сложности, а затем по названию. Затем он выведет прекрасный CSV-файл, который включает URL-адрес для каждого маршрута в результатах поиска. К сожалению, максимальное количество маршрутов поиска составляет 1000, так что вы не можете найти их все за один присест. Чтобы меня не отпугнуло такое небольшое неудобство, я кропотливо прошел через каждую область и подобласть, захватил 1000 маршрутов за раз и сохранял файлы на свой компьютер, пока все 116000 маршрутов не были сохранены в отдельных файлах csv на моем компьютере. Когда у меня были все файлы csv, я объединил их с помощью этого кода:

import os
import glob
import pandas as pd
from progress.bar import Bar
import time
import tqdm
# Combine CSVs that I got directly from Mountain Project
extension = 'csv'
all_filenames = [i for i in glob.glob('*.{}'.format(extension))]

#combine all files in the list
combined_csv = pd.concat([pd.read_csv(f) for f in all_filenames ])

#export to csv
combined_csv.to_csv( "all_routes.csv", index=False, encoding='utf-8-sig')
routes = pd.read_csv("all_routes.csv")
routes.drop_duplicates(subset = "URL", inplace = True)

# remove routes with no rating
routes = routes[routes["Avg Stars"]!= -1]

На данный момент у меня есть большой CSV-файл с URL-адресами всех маршрутов Mountain Project. Теперь мне нужно перебрать каждый URL-адрес и очистить нужную мне информацию, а затем добавить ее обратно в этот csv. Маршруты с пустыми описаниями, описаниями не на английском языке или менее 10 голосов ** были удалены. Это сократило количество примеров маршрутов примерно до 31000.

def description_scrape(url_to_scrape, write = True):
    """Get description from route URL"""

    # Get HTML info
    uClient = uReq(url_to_scrape) # request the URL
    page_html = uClient.read() # Read the html
    uClient.close() # close the connection
    route_soup = soup(page_html, "html.parser")
    
    # Get route description headers
    heading_container = route_soup.findAll("h2", {"class":"mt-2"})
    heading_container[0].text.strip()
    headers = ""
    for h in range(len(heading_container)):
        headers += "&&&" + heading_container[h].text.strip()
    headers = headers.split("&&&")[1:]
    
    # Get route description text
    route_soup = soup(page_html, "html.parser")
    desc_container = route_soup.findAll("div", {"class":"fr-view"})
    words = ""
    for l in range(len(desc_container)):
        words += "&&&" + desc_container[l].text
    words = words.split("&&&")[1:]
    
    # Combine into dictionary
    route_dict = dict(zip(headers, words))
    
    # Add URL to dictionary
    route_dict["URL"] = url_to_scrape
    
    # Get number of votes on star rating and add to dictionary
    star_container = route_soup.find("span", id="route-star-avg")
    num_votes = int(star_container.span.span.text.strip().split("from")[1].split("\n")[0].replace(",", ""))
    route_dict["star_votes"] = num_votes
    
    if write == True:
        # Write to file:
        f.write(route_dict["URL"] +","+ 
                route_dict.setdefault("Description", "none listed").replace(",", "~") +","+
                route_dict.setdefault("Protection", "none listed").replace(",", "~") +","+
                str(route_dict["star_votes"]) + "\n")
    else:
        return route_dict
# Get URLs from large route.csv file
all_route_urls = list(routes["URL"])
# Open a new file
filename = "route_desc.csv"
f = open(filename, "w")
headers = "URL, desc, protection, num_votes\n"
f.write(headers)
# Scrape all the routes
for route_url in tqdm(all_route_urls):
    description_scrape(route_url)
    time.sleep(.05)

t1 = time.time()
t1-t0

f.close()
# Merge these dataframes:
merged = routes.merge(route_desc, on='URL')
merged.to_csv("all_routes_and_desc.csv", index=False)
df = pd.read_csv("all_routes_and_desc.csv")
##### CLEANING STEPS #####
# Drop column that shows my personal vote
df.drop(["Your Stars"], axis = 1, inplace=True)
# Removes whitespace around column names
df_whole = df.rename(columns=lambda x: x.strip()) 
# Combine text columns and select needed columns
df_whole["words"] = df_whole["desc"] + " " + df_whole["protection"]
df = df_whole[["words", "num_votes", "Avg Stars"]]
# Remove rows with no description
bad_df = df[df.words.apply(lambda x: len(str(x))<=5)]
new_df = df[~df.words.isin(bad_df.words)]
print(len(df), len(bad_df), len(new_df), len(df)-len(bad_df)==len(new_df))
df = new_df
# Remove non-english entries... takes a few minutes...
from langdetect import detect
def is_english(x):
    try:
        return detect(x)
    except:
        return None

df["english"] = df['words'].apply(lambda x: is_english(x) == 'en')
df = df[df.english]
df = df[["words", "num_votes", "Avg Stars"]]
# Now remove rows with less than 10 votes
few_votes = np.where(df.num_votes <= 9)[0]
for vote in few_votes:
    try:
        df.drop(vote, inplace = True)
    except:
        pass
df_small = df.drop(few_votes)
df = df_small
# Save it
df.to_csv('data/words_and_stars_no_ninevotes.csv', index=False, header=True)

Теперь у меня есть три набора данных, с которыми я могу работать: обзоры снаряжения Trailspace.com, маршруты Mountain Project и форумы по обзору снаряжения Mountain Project.

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

Шаг 2. Постройте модель

Если вы зашли так далеко, радуйтесь, потому что пришло время для настоящего машинного обучения! Что ж, почти пора. Прежде чем я смогу приступить к построению модели, мне нужно иметь какую-то метрику для измерения качества моей модели. Это означает, что мне пришлось взять на себя ужасную задачу пометить некоторые форумы вручную. Я вручную пометил 4000 образцов форумов как положительные (2), отрицательные (0) или нейтральные (1), чтобы я мог оценить свою модель. На это ушло около четырех часов.

Конечно, я хочу построить наилучшую модель для моей задачи. У меня есть два набора данных, которые помогут мне создать эту модель. Данные Trailspace невелики, но более актуальны. Данные маршрута большие, но менее актуальные. Какой из них больше поможет моей модели? Или я должен использовать их комбинацию? И, что важно, обеспечивают ли дополнительные данные лучшую производительность, чем простая модель DistilBERT?

Решил сделать сравнение четырех моделей:

  1. Только модель с DistilBERT
  2. Модель с DistilBERT и информацией о маршруте
  3. Модель с информацией о DistilBERT и Trailspace
  4. Модель с DistilBERT и обоими наборами данных

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

После множества экспериментов и настройки параметров я обнаружил, что лучший способ обучить модель DistilBERT only - это метод, представленный ниже:

from transformers import pipeline
from transformers import AutoTokenizer, TFAutoModelForSequenceClassification
from transformers import DistilBertTokenizer, DistilBertModel, DistilBertConfig, TFAutoModelWithLMHead, TFAutoModel, AutoModel

from sklearn.model_selection import train_test_split

import tensorflow as tf

import pandas as pd
import numpy as np

classifier = pipeline('sentiment-analysis')

import random
random.seed(42)
##### SET UP THE MODEL #####
save_directory = "distilbert-base-uncased"
config = DistilBertConfig(dropout=0.2, attention_dropout=0.2)
config.output_hidden_states = False
transformer_model = TFAutoModel.from_pretrained(save_directory, from_pt=True, config = config)

input_ids_in = tf.keras.layers.Input(shape=(128,), name='input_token', dtype='int32')
input_masks_in = tf.keras.layers.Input(shape=(128,), name='masked_token', dtype='int32') 
# Build model that will go on top of DistilBERT
embedding_layer = transformer_model(input_ids_in, attention_mask=input_masks_in)[0]
X = tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(50, return_sequences=True, dropout=0.1, recurrent_dropout=0.1))(embedding_layer)
X = tf.keras.layers.GlobalMaxPool1D()(X)
X = tf.keras.layers.Dense(50, activation='relu')(X)
X = tf.keras.layers.Dropout(0.2)(X)
X = tf.keras.layers.Dense(3, activation='sigmoid')(X)
tf.keras.layers.Softmax(axis=-1)
model = tf.keras.Model(inputs=[input_ids_in, input_masks_in], outputs = X)

for layer in model.layers[:3]:
    layer.trainable = False
    
model.compile(optimizer="Adam", loss=tf.keras.losses.CategoricalCrossentropy(), metrics=["acc"])

##### LOAD THE TEST DATA #####
df = pd.read_csv('data/labeled_forum_test.csv')
X_train, X_test, y_train, y_test = train_test_split(df["text"], df["sentiment"], test_size=0.20, random_state=42)
# Create X values
tokenizer = AutoTokenizer.from_pretrained(save_directory)
X_train = tokenizer(
     list(X_train),
     padding=True,
     truncation=True,
     return_tensors="tf",
     max_length = 128
 )

X_test = tokenizer(
     list(X_test),
     padding=True,
     truncation=True,
     return_tensors="tf",
     max_length = 128
 )
# Create Y values
y_train = pd.get_dummies(y_train)
y_test = pd.get_dummies(y_test)
#### TRAIN THE MODEL ####
history = model.fit([X_train["input_ids"],   X_train["attention_mask"]], 
          y_train, 
          batch_size=128, 
          epochs=8, 
          verbose=1, 
          validation_split=0.2)
#### SAVE WEIGHTS FOR LATER ####
model.save_weights('models/final_models/bert_only2/bert_only2')

Приведенный выше код создает базовую модель, только DistilBERT. Теперь я настрою DistilBERT с данными, которые я сохранил в «data / words_and_stars_no_ninevotes.csv».

from transformers import pipeline
from transformers import AutoTokenizer, TFAutoModelForSequenceClassification

from transformers import DistilBertTokenizer, DistilBertModel, DistilBertConfig, TFAutoModelWithLMHead, TFAutoModel, AutoModel

import tensorflow as tf
import numpy as np

classifier = pipeline('sentiment-analysis')
##### LOAD DATA THAT WILL TUNE DISTILBERT #####
df = pd.read_csv('data/words_and_stars_no_ninevotes.csv')
df.replace(4,3.9999999) # prevents errors
#### TUNE DISTILBERT #####
# normalize star values
df["norm_star"] = df["Avg Stars"]/2
df.head()

# drop null entries
print(len(np.where(pd.isnull(df["words"]))[0])) # 288 null entries
df.dropna(inplace = True)

model_name = "distilbert-base-uncased"
tf_model = TFAutoModelForSequenceClassification.from_pretrained(model_name)
tokenizer = DistilBertTokenizer.from_pretrained('distilbert-base-uncased')
model = DistilBertModel.from_pretrained('distilbert-base-uncased')

tf_batch = tokenizer(
     list(df["words"]),
     padding=True,
     truncation=True,
     return_tensors="tf"
 )

tf_outputs = tf_model(tf_batch, labels = tf.constant(list(df["norm_star"]), dtype=tf.float64))

loss = [list(df["norm_star"])[i]-float(tf_outputs[0][i]) for i in range(len(df))]
star_diff = (sum(loss)/1000)*4
star_diff

# Save the tuned DistilBERT so you can use it later
save_directory = "models/route_model"
tokenizer.save_pretrained(save_directory)
model.save_pretrained(save_directory)

Отсюда код для создания модели, в основе которой настроен DistilBERT, такой же, как код, использованный для создания модели только для DistilBERT, за исключением:

save_directory = "distilbert-base-uncased"

использовать:

save_directory = "models/route_model"

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

Модель DistilBERT с данными о маршруте и снаряжении обеспечила наилучшую точность испытаний на уровне 81,6% для трехсторонней классификации и будет использоваться для обозначения форумов Mountain Project.

Шаг третий: анализ модели

Маршрут и модель снастей обеспечили точность испытаний 81,6%. Возникает вопрос: что происходит в остальных 18,4%? Может быть, неточность связана с длиной сообщения?

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

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

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

Когда идет большая распродажа оборудования, люди часто пишут об этом на форумах. Когда я маркировал данные, если в сообщении было просто «25% скидка на камеры на сайте website.com», я считал его нейтральным. Камеры дорогие, они часто поступают в продажу, а альпинистам их нужно много, поэтому распродажи камер публикуются часто; все эти сообщения были отмечены как нейтральные. Также ведется много споров о лучших типах камер, что может привести к предложениям с множеством настроений, из-за чего ярлык выглядит нейтральным. Кроме того, люди нейтрально говорят о кулачках, когда они рекомендуют экипировку для конкретного подъема. Я думаю, что эти вещи заставили мою модель поверить в то, что кулачки почти всегда нейтральны.

В общем, моя модель сбивает с толку в следующих случаях:

  1. Когда упоминается распродажа: «Скидка 25% на камеры Black Diamond, лучшие на рынке» верно: 2, метка: 1
  2. Когда сообщение не имеет прямого отношения к скалолазанию: «Республиканская партия не поддерживает использование нами государственных земель» true: 0, label: 1 (я подозреваю, что это потому, что моя модель не обучена этому )
  3. Когда упоминаются параллельные трещины: «Кулачки хороши для параллельных трещин» true: 2, label: 0 (я подозреваю, что это потому, что кулачки - единственный вид механизма, который хорошо работает в параллельных трещинах. Большинство сообщений говорят такие вещи, как «трикамы хороши, если только это не параллельная трещина»).
  4. Когда упоминаются гексы: «Гексы подходят для вашей первой стойки». true: 2, label: 0

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

У меня есть дополнительные данные о маршруте (маршруты с 9 или менее голосами). Хотя я подозреваю, что эти данные могут быть менее надежными, они могут быть полезны в последующих экспериментах. Я мог тренировать модели только на данных маршрутов разного размера, вплоть до 116700 примеров, которые я скопировал, а затем сравнил. Это подскажет мне, была ли дополнительная точность связана исключительно с большим количеством данных или помогла специфика набора данных для малых шестерен.

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

Шаг четвертый: анализ результатов

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

rom transformers import pipeline
from transformers import AutoTokenizer, TFAutoModelForSequenceClassification
from transformers import DistilBertTokenizer, DistilBertModel, DistilBertConfig, TFAutoModelWithLMHead, TFAutoModel, AutoModel
from transformers import PreTrainedModel

from sklearn.model_selection import train_test_split

import tensorflow as tf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

classifier = pipeline('sentiment-analysis')

import random
random.seed(42)

%matplotlib inline
#### RERUN YOUR MODEL #####
save_directory = "models/route_model"
config = DistilBertConfig(dropout=0.2, attention_dropout=0.2)
config.output_hidden_states = False
transformer_model = TFAutoModel.from_pretrained(save_directory, from_pt=True, config = config)

input_ids_in = tf.keras.layers.Input(shape=(128,), name='input_token', dtype='int32')
input_masks_in = tf.keras.layers.Input(shape=(128,), name='masked_token', dtype='int32')
# Build model that will go on top of DistilBERT
embedding_layer = transformer_model(input_ids_in, attention_mask=input_masks_in)[0]
X = tf.keras.layers.Bidirectional(tf.keras.layers.LSTM(50, return_sequences=True, dropout=0.1, recurrent_dropout=0.1))(embedding_layer)
X = tf.keras.layers.GlobalMaxPool1D()(X)
X = tf.keras.layers.Dense(50, activation='relu')(X)
X = tf.keras.layers.Dropout(0.2)(X)
X = tf.keras.layers.Dense(3, activation='sigmoid')(X)
tf.keras.layers.Softmax(axis=-1)
model = tf.keras.Model(inputs=[input_ids_in, input_masks_in], outputs = X)

for layer in model.layers[:3]:
    layer.trainable = False
    
model.compile(optimizer="Adam", loss=tf.keras.losses.CategoricalCrossentropy(), metrics=["acc"])

#### LOAD THE WEIGHTS THAT YOU TRAINED BEFORE AND PREP DATA #####
model.load_weights('models/final_models/route_only2/route_only2')
# read in data
df = pd.read_csv('data/all_forums.csv')
# Create X values
tokenizer = AutoTokenizer.from_pretrained(save_directory)
X = tokenizer(
     list(df["text"]),
     padding=True,
     truncation=True,
     return_tensors="tf",
     max_length = 128
    )
preds = model.predict([X["input_ids"], X["attention_mask"]])

#### ADD PREDICTIONS TO THE DATAFRAME ####
# Start with the first 5000, then replace the first n rows of the df
# For some reason, the merge works better this way.
# Add predicted labels to df
pred_labels = [np.argmax(preds[i], axis = 0) for i in range(len(preds))]
df_small = df.copy()

df_small = df_small[:5000] # remove in full set
df_small["pred_label"] = pred_labels[:5000] # add predicted labels
df_small["text"] = df_small["text"].str.strip().str.lower() # lower and strip whitespace

# remove empty rows
df_small['text'].replace('', np.nan, inplace=True)
df_small.dropna(subset=['text'], inplace=True)

#clean index mess
df_small.reset_index(inplace = True) 
df_small.drop(["index"], axis = 1, inplace = True)

# Get labeled dataframe
labeled_df = pd.read_csv("data/labeled_forum_test.csv")
labeled_df["text"] = labeled_df["text"].str.strip().str.lower()

# Now merge
new_df = df_small.merge(labeled_df, how = 'left', on = "text")
print(len(new_df))
print(len(new_df)-len(df_small))
# Now get big DF and replace the first n rows
# Add predicted labels to df
pred_labels = [np.argmax(preds[i], axis = 0) for i in range(len(preds))]
full_df = df.copy()

full_df["pred_label"] = pred_labels # add predicted labels
full_df["text"] = full_df["text"].str.strip().str.lower() # lower and strip whitespace

# remove empty rows
full_df['text'].replace('', np.nan, inplace=True)
full_df.dropna(subset=['text'], inplace=True)

#clean index mess
full_df.reset_index(inplace = True) 
full_df.drop(["index"], axis = 1, inplace = True)

##### COMBINE THE DATAFRAMES AND SAVE #####
# Combine df_small and full_df[len(new_df):]
df_full = new_df.append(full_df[len(new_df):])
df_full = df_full.rename(columns={"sentiment": "true_label"})
df_full.reset_index(inplace = True) 
df_full.drop(["index"], axis = 1, inplace = True)
df_full.to_csv('data/full_forum_labeled.csv', index = False)

Отсюда можно провести дальнейший анализ, в зависимости от того, что именно вы хотите знать. Ниже приведены несколько примеров того, что вы могли бы сделать дальше. Я немного придираюсь к Mammut, потому что я только что купил рюкзак Mammut по рекомендации Mountain Project.

  • Пишут ли альпинисты в основном положительные, отрицательные или нейтральные отзывы о снаряжении?
df = pd.read_csv('data/full_forum_labeled.csv')
plt.title("Overall Sentiment")
plt.ylabel('Count')
plt.xlabel('Sentiment')
plt.xticks([0,1,2])
plt.hist(df.pred_label)

  • Изменилось ли отношение к Маммуту со временем?
# Generate dataframe
mammut_df = df[df.text.str.contains("mammut").fillna(False)]
mammut_df["post_year"] = [mammut_df.post_date[i][-4:] for i in mammut_df.index]
mammut_grouped = mammut_df.groupby(["post_year"]).mean()
# Create plot
plt.title("Mammut Sentiment Over Time")
plt.ylabel('Average Sentiment')
plt.xlabel('Year')
plt.xticks(rotation=45)
plt.bar(mammut_grouped.index, mammut_grouped.pred_label)

  • Есть ли у альпинистов, которые присоединились к Mountain Project совсем недавно, другие чувства к Маммуту, чем у тех, кто присоединился к нему давным-давно? (Возраст учетной записи используется как показатель количества лет, проведенных альпинистом; у более опытных альпинистов другие предпочтения?)
# Generate dataframe
mammut_df = df[df.text.str.contains("mammut").fillna(False)]
mammut_df["post_year"] = [int(mammut_df.post_date[i][-4:]) for i in mammut_df.index]
# Get join dates if available
join_year_list = []
for i in mammut_df.index:
    try:
        join_year_list.append(int(mammut_df.join_date[i][-4:]))
    except:
        join_year_list.append(-1000)
# Add join year and years as memeber before posting columns, remove missing info
mammut_df["join_year"] = join_year_list
mammut_df["years_as_mem_before_posting"] = mammut_df["post_year"] - mammut_df["join_year"]
mammut_df = mammut_df[mammut_df['years_as_mem_before_posting'] < 900]
# groupby
mammut_grouped = mammut_df.groupby(["years_as_mem_before_posting"]).mean()
# Create plot
plt.title("Mammut Sentiment of Newer vs. Older Accounts")
plt.ylabel('Average Sentiment')
plt.xlabel('Num Years as Member')
plt.xticks(rotation=45)
plt.bar(mammut_grouped.index, mammut_grouped.pred_label)

  • Разница на предыдущем графике связана с меньшим размером выборки старых учетных записей?
# Groupby
mammut_grouby_count = mammut_df.groupby(["years_as_mem_before_posting"]).count()
# Create plot
plt.title("Count of Account Age that Mentions Mammut")
plt.ylabel('Count')
plt.xlabel('Num Years as Member')
plt.bar(mammut_grouby_count.index, mammut_grouby_count.join_year)

Заключение

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

Этот проект, весь код, а также отчет можно найти в моей учетной записи GitHub.

Все эти наборы данных были размещены на Kaggle.

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

Спасибо за внимание!

* Я использую DistilBERT, а не BERT, потому что он меньше по размеру и его легче запускать на моем маленьком локальном компьютере. Хотя точность не так хороша, как у BERT, я предположил, что для этого проекта она будет достаточно хорошей. Изменение базовой модели с DistilBERT на BERT было бы тривиальным делом и при необходимости привело бы к большей точности, но также потребовало бы большей вычислительной мощности.

** Почему маршрут с меньшим количеством оценок может предоставлять недостоверные данные?

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

Во-вторых, первая группа восхождения часто приводит к завышению рейтинга маршрута, потому что прокладывать маршруты дорого с точки зрения времени и денег. Каждый болт на скалолазании стоит 5-10 долларов, поэтому стандартное спортивное восхождение с 10-12 болтами стоит около 100 долларов из личного кармана скалолаза, который его установил. Установка каждого болта может занять от пятнадцати минут до часа, в общей сложности от трех до десяти часов на маршрут. Подъем без болтов по-прежнему может быть большой инвестицией; альпинист-профессионал будет подвергаться большему риску, поскольку он не будет точно знать, какое снаряжение ему понадобится для безопасного завершения восхождения. Также требуется время, чтобы найти потенциальный новый маршрут, а затем очистить его от рыхлых камней, грязи и другого мусора. Могут возникнуть юридические проблемы с получением разрешения на скалолазание на частной или государственной земле. Все эти проблемы делают создание маршрута очень трудоемким. Наградой за первое восхождение будет их имя в путеводителе и на Mountain Project, и они смогут назвать маршрут. Однако многие группы первопроходцев хотят, чтобы другие прошли маршруты, по которым они вкладывают так много усилий. Таким образом, первое восхождение иногда увеличивает звездный рейтинг Mountain Project, чтобы побудить больше альпинистов попробовать его.

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

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