knitr::include_graphics('Francesco.JPG')
…
This project proposes and seeks to show that song lyrics can be a determing factor of a song’s genre. I do not discount the musical power of harmony, melody, and rhythm to differentiate genre, but it’s not common for musicians, educators and others to classify music limited to a song’s lyrics. I accept this challenge.
Let’s look at potential uses. Businesses such at Pandora and Spotify have been analyzing and classifying music (including songs with lyrics) for their customers, to provide an inclusive listening experience. But, many other business can benefit from music and lyrics analysis.
Let’s look at songwriters, their management, licensing companies such as ASCAP and BMI, and music libraries such as HD Publishing.
For songwriters, there is a sub-group that are only lyricists, meaning someone else composes the music. Composers look for lyrics to compose to. Lyricists look for composers who are known for specific genres. Many haven’t worked together before. Managers often have knowledge of both sides, and will broker deals that combine their talents. Giving songwriters and composers the ability look for each other by matching lyrics with music could be a further democratization of the music industry. It could remove the role of the manager (and associated fees) to connect songwriters with music composers and bands.
Licensing companies deal with both lyrics and songs as legal assets they manage for artists. While this is not the main part of their business, they would benefit from validating the lyric’s genre compared to what the artist might think it is.
When another artist wants to record someone else’s song, they have to pay license fees to a licensing company such as ASCAP. ASCAP has a website that deals with this from a financial aspect, but not from a search and song selection aspect. Having this capability would enhance their service offering to their clients, would be a competitive differentiator; and potentially increase profits through “premium” chargeable services.
The goal of this project is to use data science to analyze a library of popular song lyrics, put the data in a clean and useable form, and build and train a model to classify the words that best characterize the song’s genre. Ultimately, I plan to be able to use song lyrics not in the sample DB to test the model, using lyrics from major artists and others.
I will use the readily available subset of lyrics from The Million Song library. My sample size will be 57,000 songs from multiple genres and artists. From this, I will cut it into training and test data to build and test my model. I will use packages that are designed to be used for Natural Language Processing (NLP) including tidytext, syuzhet(sentiment) and others.
To train the prediction, I will need to give the model the most likely genre for each song. This is known as supervised training. Unfortunately, this is not provided with the freely available data. Due to the huge task to select the genre for each of the 57,000 songs, I will focus on the artist’s best known genre using multiple readily available sources that categorize music. I have done this for 632 artists.
Note: I have combined some genres to make the analysis work better due to similarities in genres.
others…
Read 57,000 song dataset from an online .csv file https://www.researchgate.net/publication/220723656_The_Million_Song_Dataset
Read my artist-to-genre mapping .csv file
Join them together into one dataframe
Clean the data
Make Ready for Next Steps by adding an id variable and dropping the URL link variable which is not needed for analysis.
# head(raw_dat)
# kable(head(raw_dat))
#column_spec(5:7, bold = T) %>%
#row_spec(3:5, bold = T, color = "white", background = "#D7261E")
# table_output(raw_data)
Compute: Number of Words, Letters, and Average Word Length per Song.
dat = raw_dat %>%
mutate(sentiment = get_sentiment(text),
number_of_words = stri_count(text, regex="\\S+"),
number_of_letters = nchar(text),
avg_word_length = number_of_letters / number_of_words)
table_output(raw_dat)
| artist | song | text | genre | id |
|---|---|---|---|---|
| Nickelback | Just To Get High | He was my | Country | 11107 |
| Kirk Franklin | Still In Love | [Verse 1] | Religious | 30279 |
| Alphaville | Faith | Woke up in | Pop | 15763 |
| Olivia Newton-John | Reach Out For Me | When you g | Pop | 33842 |
| Sia | Loved Me Back To Life | I was walk | Pop | 36008 |
| Britney Spears | Mannequin | Always tal | Pop | 1883 |
| David Bowie | Baby Can Dance | I’m rollin | Pop | 20421 |
| Patsy Cline | Pick Me Up On Your Way Down | Once my lo | Country | 34184 |
| Don Henley | Gimme What You Got | Baby picks | Rock | 21406 |
| Bob Dylan | Days Of 49 | I’m old To | Folk | 17684 |
table_output(dat)
| artist | song | text | genre | id | sentiment | number_of_words | number_of_letters | avg_word_length |
|---|---|---|---|---|---|---|---|---|
| Nickelback | Just To Get High | He was my | Country | 11107 | -2.45 | 307 | 1651 | 5.377850 |
| Kirk Franklin | Still In Love | [Verse 1] | Religious | 30279 | 7.75 | 394 | 1988 | 5.045685 |
| Alphaville | Faith | Woke up in | Pop | 15763 | 7.55 | 160 | 845 | 5.281250 |
| Olivia Newton-John | Reach Out For Me | When you g | Pop | 33842 | 1.10 | 233 | 1196 | 5.133047 |
| Sia | Loved Me Back To Life | I was walk | Pop | 36008 | 0.05 | 238 | 1268 | 5.327731 |
| Britney Spears | Mannequin | Always tal | Pop | 1883 | 1.85 | 352 | 1994 | 5.664773 |
| David Bowie | Baby Can Dance | I’m rollin | Pop | 20421 | 0.75 | 285 | 1516 | 5.319298 |
| Patsy Cline | Pick Me Up On Your Way Down | Once my lo | Country | 34184 | 3.10 | 155 | 811 | 5.232258 |
| Don Henley | Gimme What You Got | Baby picks | Rock | 21406 | 3.40 | 316 | 1746 | 5.525316 |
| Bob Dylan | Days Of 49 | I’m old To | Folk | 17684 | -1.05 | 508 | 2412 | 4.748031 |
dat %>%
filter(sentiment != 0) %>%
group_by(genre) %>%
summarize(mean_sentiment = mean(sentiment)) %>%
ggplot(aes(x = fct_reorder(genre, mean_sentiment), y = mean_sentiment, fill=mean_sentiment)) +
geom_col() +
coord_flip() +
labs(x = '', y = 'Sentiment', title = 'Lyric Sentiment by Genre') +
theme(legend.position = 'none')
The genres with the most negative sentiment are: - Hip-Hop-Rap and Metal (just slightly negative).
dat %>%
ggplot(aes(x = sentiment, col = genre, fill = genre)) +
geom_density(alpha = 0.5) +
labs(x = 'Sentiment', y = 'Density', title = 'Distribution of Sentiment by Genre')
All genres seem to have a normal distribution of sentiments, but have a different mean and SD.
Exceeded R’s object capacity when unnesting all 57,000 songs (many million words). Need to break into 4 pieces, unnest into words, and stitch back together
# Break text_dat_raw into 4 parts
text_dat_raw_1 = raw_dat[1:10000,]
text_dat_raw_2 = raw_dat[10001:20000,]
text_dat_raw_3 = raw_dat[20001:30000,]
text_dat_raw_4 = raw_dat[30001:nrow(raw_dat),]
#Unnest all 4 parts and remove stop_words
text_dat_raw_1 = text_dat_raw_1%>%
unnest_tokens(word, text)
text_dat_raw_2 = text_dat_raw_2%>%
unnest_tokens(word, text)
text_dat_raw_3 = text_dat_raw_3%>%
unnest_tokens(word, text)
text_dat_raw_4 = text_dat_raw_4%>%
unnest_tokens(word, text)
# Combine 4 parts back into 1
text_dat_raw = text_dat_raw_1 %>%
bind_rows(text_dat_raw_2) %>%
bind_rows(text_dat_raw_3) %>%
bind_rows(text_dat_raw_4)
text_dat_grouped = text_dat_raw %>%
group_by(genre, id, word) %>%
summarize(n = 1) %>%
group_by(genre, word) %>%
summarize(n = n()) %>%
group_by(word) %>%
mutate(pct_of_total = n / sum(n)) %>%
ungroup() %>%
# Increase n below for fewer columns!
filter(n > 1000,
pct_of_total > 0.1) %>% # must be in +70% favor of # one review type
select(word)
text_dat_spread = text_dat_grouped %>%
left_join(text_dat_raw, by = 'word') %>%
select(id, word) %>%
distinct(id, word) %>%
mutate(n = 1) %>%
spread(key = word, value = n)
text_dat_spread[is.na(text_dat_spread)]=0
final_dat = dat %>% select(-song) %>%
left_join(text_dat_spread, by = 'id') %>%
select(-text, -artist) %>%
janitor::clean_names() %>%
rename(Class = genre) %>%
mutate(Class = as.factor(Class))
final_dat[is.na(final_dat)] = 0
table_output(final_dat)
| Class | id | sentiment | number_of_words | number_of_letters | avg_word_length |
|---|---|---|---|---|---|
| Country | 11107 | -2.45 | 307 | 1651 | 5.377850 |
| Religious | 30279 | 7.75 | 394 | 1988 | 5.045685 |
| Pop | 15763 | 7.55 | 160 | 845 | 5.281250 |
| Pop | 33842 | 1.10 | 233 | 1196 | 5.133047 |
| Pop | 36008 | 0.05 | 238 | 1268 | 5.327731 |
| Pop | 1883 | 1.85 | 352 | 1994 | 5.664773 |
| Pop | 20421 | 0.75 | 285 | 1516 | 5.319298 |
| Country | 34184 | 3.10 | 155 | 811 | 5.232258 |
| Rock | 21406 | 3.40 | 316 | 1746 | 5.525316 |
| Folk | 17684 | -1.05 | 508 | 2412 | 4.748031 |
training_split = 0.75
smp_size = floor(training_split * nrow(final_dat))
dat_index = sample(seq_len(nrow(final_dat)), size = smp_size)
dat_train = as.data.frame(final_dat[dat_index,])
dat_train = upSample(x = dat_train %>% select(-Class),
y = dat_train$Class)
dat_test = as.data.frame(final_dat[-dat_index,])
dat_train = dat_train %>%
select(-id)
table_output(dat_train)
| sentiment | number_of_words | number_of_letters | avg_word_length | Class |
|---|---|---|---|---|
| 8.75 | 178 | 924 | 5.191011 | Blues |
| 5.70 | 263 | 1491 | 5.669201 | Blues |
| 1.35 | 162 | 853 | 5.265432 | Blues |
| 1.95 | 239 | 1210 | 5.062761 | Blues |
| 3.00 | 384 | 2232 | 5.812500 | Blues |
| 5.25 | 248 | 1376 | 5.548387 | Blues |
| 2.15 | 262 | 1321 | 5.041985 | Blues |
| 1.40 | 136 | 814 | 5.985294 | Blues |
| 1.05 | 280 | 1524 | 5.442857 | Blues |
| -0.20 | 126 | 687 | 5.452381 | Blues |
dat_test = dat_test %>%
select(-id)
table_output(dat_test)
| Class | sentiment | number_of_words | number_of_letters | avg_word_length |
|---|---|---|---|---|
| Religious | 7.75 | 394 | 1988 | 5.045685 |
| Pop | 7.55 | 160 | 845 | 5.281250 |
| Pop | 0.75 | 285 | 1516 | 5.319298 |
| Country | 3.45 | 198 | 1031 | 5.207071 |
| Rock | -4.60 | 128 | 790 | 6.171875 |
| R&B | 3.45 | 306 | 1468 | 4.797386 |
| Pop | 1.20 | 236 | 1159 | 4.911017 |
| Jazz | 3.40 | 122 | 682 | 5.590164 |
| Pop | 5.20 | 455 | 2270 | 4.989011 |
| Religious | 4.80 | 108 | 576 | 5.333333 |
train_control = trainControl(method = "cv")
model_rf = train(dat_train %>% select(-Class),
dat_train$Class,
method = "ranger",
num.trees = 50,
importance = "impurity",
trControl = train_control)
predictions_rf = predict(model_rf, dat_test)
confusionMatrix(predictions_rf, as.factor(dat_test$Class))
## Confusion Matrix and Statistics
##
## Reference
## Prediction Blues Country D-DJ-E-D-E-H Folk Hip-Hop-Rap Jazz Metal Pop
## Blues 0 0 0 0 0 0 0 2
## Country 1 1 2 1 0 0 0 8
## D-DJ-E-D-E-H 0 0 0 0 0 0 0 3
## Folk 1 2 2 3 1 1 1 11
## Hip-Hop-Rap 0 0 1 1 9 0 0 8
## Jazz 0 0 0 0 0 0 0 4
## Metal 0 0 0 0 0 0 0 2
## Pop 4 16 7 10 3 3 2 79
## R&B 0 0 0 1 0 2 0 5
## Religious 0 3 0 0 0 2 1 2
## Rock 3 19 2 15 1 3 4 56
## Reference
## Prediction R&B Religious Rock
## Blues 0 0 2
## Country 2 0 19
## D-DJ-E-D-E-H 0 0 3
## Folk 3 1 12
## Hip-Hop-Rap 1 0 2
## Jazz 0 0 4
## Metal 0 0 4
## Pop 6 7 51
## R&B 0 0 1
## Religious 0 2 1
## Rock 1 2 68
##
## Overall Statistics
##
## Accuracy : 0.324
## 95% CI : (0.2831, 0.367)
## No Information Rate : 0.36
## P-Value [Acc > NIR] : 0.9585
##
## Kappa : 0.0801
##
## Mcnemar's Test P-Value : NA
##
## Statistics by Class:
##
## Class: Blues Class: Country Class: D-DJ-E-D-E-H
## Sensitivity 0.0000 0.02439 0.0000
## Specificity 0.9919 0.92810 0.9877
## Pos Pred Value 0.0000 0.02941 0.0000
## Neg Pred Value 0.9819 0.91416 0.9717
## Prevalence 0.0180 0.08200 0.0280
## Detection Rate 0.0000 0.00200 0.0000
## Detection Prevalence 0.0080 0.06800 0.0120
## Balanced Accuracy 0.4959 0.47625 0.4938
## Class: Folk Class: Hip-Hop-Rap Class: Jazz
## Sensitivity 0.09677 0.6429 0.0000
## Specificity 0.92537 0.9733 0.9836
## Pos Pred Value 0.07895 0.4091 0.0000
## Neg Pred Value 0.93939 0.9895 0.9776
## Prevalence 0.06200 0.0280 0.0220
## Detection Rate 0.00600 0.0180 0.0000
## Detection Prevalence 0.07600 0.0440 0.0160
## Balanced Accuracy 0.51107 0.8081 0.4918
## Class: Metal Class: Pop Class: R&B Class: Religious
## Sensitivity 0.0000 0.4389 0.0000 0.1667
## Specificity 0.9878 0.6594 0.9815 0.9816
## Pos Pred Value 0.0000 0.4202 0.0000 0.1818
## Neg Pred Value 0.9838 0.6763 0.9735 0.9796
## Prevalence 0.0160 0.3600 0.0260 0.0240
## Detection Rate 0.0000 0.1580 0.0000 0.0040
## Detection Prevalence 0.0120 0.3760 0.0180 0.0220
## Balanced Accuracy 0.4939 0.5491 0.4908 0.5741
## Class: Rock
## Sensitivity 0.4072
## Specificity 0.6817
## Pos Pred Value 0.3908
## Neg Pred Value 0.6963
## Prevalence 0.3340
## Detection Rate 0.1360
## Detection Prevalence 0.3480
## Balanced Accuracy 0.5444
model_rf$finalModel %>%
# extract variable importance metrics
ranger::importance() %>%
# convert to a data frame
enframe(name = "variable", value = "varimp") %>%
top_n(n = 20, wt = varimp) %>%
# plot the metrics
ggplot(aes(x = fct_reorder(variable, varimp), y = varimp, fill=varimp)) +
geom_col() +
coord_flip() +
labs(x = "Token",
y = "Variable importance (higher is more important)")
a = dat_train %>% as_tibble()
model_rf_data_structure = a[0,]
timestamp = Sys.time()
saveRDS(model_rf_data_structure, paste0('models/model_rf_data_structure - ', timestamp, '.rds'))
saveRDS(model_rf, file = paste0('models/model_rf - ', timestamp, '.rds'))
saveRDS(model_rf_data_structure, paste0('models/model_rf_data_structure - ', '.rds'))
saveRDS(model_rf, file = paste0('models/model_rf - ', '.rds'))
To a resonable degree, lyrics alone can be used to predict a song’s genre. I acheived a 42% prediction accuracy across the ? genres in my statistical prediction model. This is better than pure guessing (which would be closer to ?%). And some genres can be predicted better than others.
Here are the results of the top 11 genres:
Engineered variables including number of words, number of letters, and sentiment led the list of importance in the prediction model. While down, like, ain’t, baby, and love were the most important words.
Most genres have a positive sentiment with the exception of Hip-Hop-Rap, and Metal. More precise analysis could further differentiate sentiment into categories such foul language, cultural meanings, and other categories. For example Raggae music commonly uses words such as ra and wit.
Random Forest is a good prediction model for this Natural Language Processing project. And further tuning might get the accuracy higher, but considerable compute resources are needed to run this model. I was able to run an optional Neural Network model that achieved 48% accuracy, but the results weren’t stable enough for me to chose it for my conclusion. And, I did use a Random Forest pre-processing method for the Neural Networks model, showing that they can work together.
Dealing with Big Data in this project was a challenge, but taught me a great deal about several techniques to help with both the data preparation and model running stages. With Big Data there is no substitute for more compute power and memory. But, even with those, limitations in the R language required techniques to work around them.
Combining my lyrics prediction analysis with existing music analysis would likely produce an even higher prediction accuracy. This will be a personal project for me after I leave this certification training.
On a personal note, Data Science has sparked an interest in me to seek work in this field that combines it with my previous experience.