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

Сможем ли мы предсказать, к какому жанру они принадлежат, используя только лирику?

Обрамление проблемы

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

  • Поп-музыка часто представляет собой романтическое повествование с упрощенной лексикой и повторениями.
  • Рок представляет наибольшее разнообразие тем и более широкий словарный запас, с акцентом на таких чувствах, как боль, печаль и борьба.
  • Хип-хоп использует самый широкий словарный запас из трех и часто адаптирует слова к разговорной речи, подходящей для тем, связанных с бурным образом жизни.

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

Оглавление

  1. Навигация по данным
  2. Обработка текстов
  3. Моделирование данных
  4. Анализ результатов
  5. Выводы, ограничения, улучшения

1. Навигация по данным

Мы нашли наш набор данных на Kaggle, и он основан на данных, взятых с бразильского музыкального портала: Vagalume. Он содержит два файла, один с информацией о музыкальных исполнителях, такой как их имя или общий жанр (жанры), а другой содержит информацию о самих песнях, такую ​​как название, тексты песен и жанр. Два файла вместе содержали данные о 3242 исполнителях и 209522 песнях, в основном на английском или португальском языках, из шести разных жанров: хип-хоп, рок, поп, фанк-кариока, сертанехо и самба.

После объединения файлов мы решили оставить только песни на английском языке и исполнителей, принадлежащих только к одному основному жанру. Это оставляет нам достаточно данных для проведения нашего анализа: это около 80 000 песен и только 3 жанра: хип-хоп, поп и рок.

Представляем вашему вниманию краткий обзор результатов нашего исследования:

1.1 Дисбаланс классов

Общая проблема с нашим окончательным набором данных, которая может иметь последствия для результатов наших моделей, сразу же становится очевидной. Действительно, существует сильный дисбаланс классов в пользу рок-песен, поскольку они составляют около 60% наших данных. Песни в стиле поп и хип-хоп составляют только 25% и 15% всех песен соответственно.

Мы будем иметь это в виду, чтобы решить на этапе моделирования.

1.2 Частота употребления слов и словарный запас

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

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

Далее мы сосредоточимся на обработке наших данных в работоспособном формате.

2. Обработка текстов

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

Начнем с очистки.

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

stpwrds = stopwords.words('english')                                     stpwrds.extend(string.punctuation)
stpwrds.extend(['chorus', 'verse', 'verses', 'choruses'])

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

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

p_stemmer = PorterStemmer()
l_stemmer = LancasterStemmer()
s_stemmer = SnowballStemmer(language='english')

lemma = nltk.wordnet.WordNetLemmatizer()

Используя ранее упомянутые методы, мы очистим каждую песню в наших данных за один раз!

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

Большая часть нашей подготовки следует базовой рутине очистки НЛП, с некоторыми деталями, направленными на то, чтобы попытаться понять смысл художественного выражения в песнях, таких как: yeeaahhh, которое является удлиненной формой yeah, или tryin, которое является сокращенной формой слова попытка. Мы решили эти проблемы, используя простую структуру регулярных выражений.

def regex_clean(txt, regex, sub=' '):        
    return " ".join(re.sub(regex, sub, txt).split())

def prep_data(song, stem='s'):
    assert(stem=='s' or stem=='l' or stem=='p'), '''Input a correct stemming parameter and try again.
The only accepted types are s for Snowball, l for Lancaster or p for Porter. Default is Snowball.'''
    
    ## Pre-token cleaning
    song = song.lower()
    song = regex_clean(song, r'(\w)\1{2,}', r'\1')
    song = regex_clean(song, r"'\s|'\.", r'g')
    song = regex_clean(song, r'(\[.*?\])', r'')
    song = regex_clean(song, r'(\W){2,}', r'\1')
    ## Tokenization    
    song = nltk.wordpunct_tokenize(song)
    ## Post-token changes
    song = [word for word in song if word not in stpwrds]
    song = [globals()['{}_stemmer'.format(stem)].stem(word) for word in song]
    song = [word for word in song if not word.isdigit()]
    song = [word for word in song if len(word)>2]

    return song

Эта очистка была применена к нашим данным с использованием приведенного ниже кода, после чего мы выполнили простое разделение на поезд-тест:

lyrics = [" ".join(prep_data(x)) for x in df_lyrics['Lyric']]
X = lyrics
y = df_lyrics['Genre']
genre = y
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2, random_state = 42, stratify=y)

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

Векторизатор

Этот метод преобразует учебный словарь в вектор, присваивая n, когда слово появляется в песне n раз, включая возможность n=0. То есть: если наш учебный словарь состоит из слов «яблоко», «банан», «автомобиль»; и мы векторизовали стих «Яблоки — это яблоки, автомобили — это машины» → Результатом будет вектор [2,0,1]; поскольку слово «яблоко» (после очистки, см. предыдущий раздел) встречается дважды, банан — нет, а «автомобиль» — один раз.

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

Преимущества метода

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

Недостатки метода

  • Интерпретируемость. Трудно понять динамический механизм, в частности, как добавление/удаление слова повлияет на подход.
  • Потоковая передача. Производительность модели полностью зависит от обучающего словаря и неявного размера данных. Этот подход не предлагает решения для различения двух песен, в которых используются исключительно слова, не входящие в учебный словарь.
vectorizer = CountVectorizer(analyzer = "word",
                             tokenizer = None,
                             preprocessor = None,
                             stop_words = stpwrds,
                             max_features = 1000)
vector_train = vectorizer.fit_transform(X_train)
vector_test = vectorizer.transform(X_test)

Последовательный подход

Этот метод присваивает каждому слову уникальное числовое значение, которое будет отождествляться с заданными данными. Подходят ли они ко всем данным или только к обучающей выборке, не имеет значения, поскольку ранее невидимым словам присваивается следующее по величине неиспользованное значение. Например: если наш учебный словарь «яблоко», «банан», «автомобиль»; им присваивается значение «яблоко» → 1, «банан» → 2 и «автомобиль» → 3. Последовательность предложения «Яблоки — это яблоки, автомобили — это машины» сначала повлечет за собой его токенизацию (см. Раздел выше) в [«яблоко», « яблоко", "автомобиль", "машина"], которое затем преобразуется в [1,1,3,4]. В самом деле, «яблоко» соответствует 1 и появляется дважды, «автомобиль» соответствует 3, а «машина» — это ранее невидимое слово, поэтому оно постоянно сопоставляется с новым значением 4.

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

Преимущества метода

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

Недостатки метода

  • Релевантность числа: модели, основанные на числовой значимости, не могут использовать этот метод, так как трудно отследить присвоение значения. Более того, нет никакой математической причины, по которой одному слову присваивается большее/меньшее значение, чем другому, что создает псевдоощущение упорядоченности.
  • Гибкость параметров. Невозможно настроить этот метод для повышения производительности модели, за исключением изменения токенизации, на которой он основан.
tokenizer = Tokenizer(
    num_words=None,
    filters='!"#$%&()*+,-./:;<=>?@[\\]^_`{|}~\t\n',
    lower=True, split=' ')
tokenizer.fit_on_texts(lyrics)
sequences = tokenizer.texts_to_sequences(lyrics)
sequences_train = tokenizer.texts_to_sequences(X_train)
sequences_test = tokenizer.texts_to_sequences(X_test)

Наконец-то мы готовы попробовать моделировать!

3. Моделирование данных

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

A) LogReg и RF с векторизатором

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

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

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

now=time()
lr_snow = LogisticRegression(max_iter=200, solver='newton-cg',penalty='l2',n_jobs = multiprocessing.cpu_count()-1, C=1)
lr_opt_newt.fit(X_train, y_train)
print("Time elapsed in seconds: {}".format(time()-now))

Мы тестируем производительность нашей модели, используя 4 основных показателя: точность, воспроизводимость и F1-оценка. Поскольку нас не особенно беспокоит какая-либо неправильная классификация по сравнению с другими, мы будем в основном заинтересованы в максимальной точности и сохранении единообразия других метрик друг относительно друга (т. е. без больших разрывов). Мы выводим все показатели в одном измерении следующим образом:

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

B) RNN и MLP с последовательным подходом

РНН

Наши попытки использовать RNN с различной архитектурой в большинстве случаев не увенчались успехом с точки зрения производительности. Были нишевые форматы и пороговые значения, при которых модель работала достаточно хорошо, чтобы оправдать внедрение, но по большей части она не соответствовала требованиям. Мы следовали обычной процедуре, в которой для рекурсивной обработки данных был включен основной слой долгосрочной памяти (LSTM).

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

Дисбаланс классов: как видно из раздела данных, наши данные смещены в пользу класса «Рок» и против «Поп-музыки». Это создает проблемы с глубокими прогнозами, поскольку при выборке шаблоны сталкиваются с сильным смещением.

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

Если бы эти вопросы были решены, мы могли бы рассмотреть возможность пересмотра этого подхода. Однако до тех пор вы можете посмотреть, как RNN реализована в нашей расширенной работе.

МЛП

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

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

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

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

relu=[]
tan=[]
logi=[]
def GridSearchNN(threshold, activ='relu', max_iter=200, hid=(15,10,6,3,3,6,10,15)):
    
    X = sequences
    y = genre
    X_good = X[X.str.len()>threshold]
    idx = X_good.index
    y = y[y.index.isin(idx)].copy()

    X_good = X_good.reset_index(drop=True)
    y = y.reset_index(drop=True)
    
    for i in range(threshold):
        globals()['X_good_{}'.format(i)] = [X_good[j][i] for j in range(len(X_good))]
    for i in range(threshold):
        globals()['X_good_{}'.format(i)] = pd.Series(globals()['X_good_{}'.format(i)])
    
    X_final = pd.concat([globals()['X_good_{}'.format(i)] for i in range(threshold)], axis=1)
    X_train, X_test, y_train, y_test = train_test_split(X_final, y, test_size = 0.2, random_state = 124, stratify=y)
    
    clf = MLPClassifier(random_state=1, max_iter=max_iter, activation=activ, hidden_layer_sizes=hid).fit(X_train, y_train)
    score = round(clf.score(X_test, y_test)*100, 4)
    print(f"For threshold: {threshold} ~~~~~ Activation: {activ} ~~~~~~~\
    Iterations: {max_iter} ~~~~~~~ Score: {score}")
    
    if(activ=='relu'):
        relu.append(score)
    elif(activ=='logistic'):
        logi.append(score)
    else:
        tan.append(score)

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

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

thresh = [1,5,10,20,30,40,50,60,70,80,90,100,110,120,130,140,150,170,180,190,200,210,220,230,240,250,260,270,280,290,300,
         310,320,330,340,350,360,370,380,390,400]
activ = ['logistic', 'relu', 'tanh']
for item in thresh:
    for act in activ:
        GridSearchNN(item, activ=act, max_iter=200)

За результатами!

4. Анализ результатов

Давайте посмотрим на результат нашего GridSearch:

Как и ожидалось, производительность MLP достигает «наилучшего уровня», когда его скорости обучения удается преодолеть расширение словаря, то есть сколько новых слов он должен зарегистрировать при увеличении порога. До тех пор он сталкивается с непрерывным снижением производительности, а затем экспоненциальным улучшением.

Однако (повторим еще раз!) нейронные сети обладают практически ненасытным аппетитом к данным! Мы уже можем обнаружить шаблоны переобучения при пороговых значениях 350–370, но это неучтенное привело к значительному снижению производительности после отметки в 400 слов. Чтобы получить доступ к более математическому объяснению того, почему возникает эта закономерность, см. мою короткую статью по этому вопросу.

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

Наконец, долгожданный момент: давайте посмотрим ансамбль в действии!

На гифке ниже представлено практическое применение нашей работы над рок-песней группы Porcupine Tree. Сам алгоритм следует реализации нашей диаграммы, но он также имеет дополнительный параметр, позволяющий пользователю заставить модель явно использовать MLP. Этот параметр отображается как параметр Neural_only, и по умолчанию для него установлено значение False.

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

В этом конкретном примере оба метода сходятся во мнении, что песня принадлежит к жанру рок, и это правильно.

5. Выводы, ограничения, улучшения

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

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

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

— — — — — — — — — — — — — — — — — — — — — — — — — — — —

Спасибо, что нашли время прочитать эту статью, и мы надеемся, что она была информативной! Не стесняйтесь проверять наш Git-репозиторий на предмет всей работы.

Этот проект был частью нашего тренинга по науке о данных с Digital Futures. Digital Futures помогает людям любого происхождения начать свою карьеру в сфере технологий. Если вам нравится то, что мы делаем, присоединяйтесь к нам на digitalfutures.com.

Контакт: