Analyzing lyrics from different music genres with universal sentence encoding | by Sam Ho | Towards Data Science

My enduring love affair with music was ignited in 1992 when on my 12th Christmas, my doting parents stumped up the pennies and bought me an Alba HiFi.
Belt-drive Turntable? Check! Twin Cassette Players? Check!Graphic Equalizer with BASS BOOST? Of course!
I have been listening to all kinds of music ever since.
I’m now almost 40 and in those 28 music-infused years I have deepened my relationship with music. I am a guitarist, a DJ, and a dance music producer. Music has punctuated my life in a multitude of joyous ways — as it has for many of you I would expect.
So though I feel I’m (reasonably) qualified to talk about music genres, I find that it can be quite hard to define them and/or differentiate them from one another.
Boundaries that divide genres are fuzzy and the passing of time augments this even more. The Rolling Stones and the Beatles were initially chastised by some as being the work of the devil* yet nowadays are as mainstream as flat-pack furniture.
*Slayer reign supreme for the Devil’s work FYI. \m/
When commercial interests overshadow authenticity (almost always sadly), ‘genres’ can be manufactured. These contrived labels are used by record companies to target groups of customers for commercial advantage, sometimes in ways that reflect deeper flaws in society.
It is evident that genre classification can be subjective, contentious, and maybe even futile — after all, good music is good music, right?
However, I would say there are at least two valid ways to differentiate genres from one another: a song’s musicality and a song’s lyrics.
Is it...
Live or sequenced? (Rock vs. Techno?)
Loud or quiet? (Dubstep vs. Chillout?)
Dissonant or melodic? (Thrash metal vs. Soul?)
Predictable or unexpected? (Pop vs. Jazz?)
Are the lyrics anchored in certain emotions (e.g. sadness, happiness, anger)?
Are the lyrics talking about topics (love, loss, war, injustice, oppression, death)?
For this project, I want to focus specifically on lyrics and the language that songwriters use in their songs.
Qualitatively we all feel that lyrical content varies by genre. But is there a way we can measure this, quantitatively?
There are differences between the lyrics of different genres and we can quantify and measure this!
We will scrape lyrics from Musixmatch.com, a free website that hosts a tonne of accurate lyric content
We will represent lyrics using high dimensional vectors from Google’s Universal Sentence Encoder
We will apply Principal Component Analysis (PCA) to reduce these high dimensional representations to n=1,2,3 dimensions
We will use Plotly and build a combination of simple plots and more sophisticated 2d and 3d scatter plots to see where there are similarities and differences in lyrics
All notebooks and full raw data are available on my GitHub here
In preparation for this project, I discovered that it’s really hard to find an accurate source of lyrics online. Songs are either incomplete or the volume available is less than comprehensive.
Musixmatch.com seems to be one of a few sites that :
Has a lot of content.Is reasonably accurate.
Has a lot of content.
Is reasonably accurate.
So let’s use that as our data source.
Musixmatch have a developer API that we could use to get lyric data but they only allow you to make 2k calls a day and you can only have about 30% of each song’s lyrics to play with.
Let’s get around this by scraping the site using a combination of requests and HTML parsing. This is an ‘out-of-browser’ process so we can easily implement multi-threading to speed the process up. This means we can get more content in a shorter period of time.
I’ve written a Python class below which contains all the methods to extract lyric content for an artist.
All you need to do is to instantiate the class with a Musixmatch artist URL and a genre label...
scraper = MusixmatchScraper('https://www.musixmatch.com/artist/Jack-Johnson','folk')
..and then call the self.Run() method to start the scraper.
Caveat — if you want to scrape a lot of lyrics, you should rotate your IP address where possible. If you make too many requests to Musixmatch.com your IP will be blocked, albeit temporarily. I find you can get away with scraping c.250 songs before this happens.
MusixmatchScraper() is coded in a way that allows you to scrape the content for one artist. If you want to get multiple artists you could create a loop like so:
The class returns a Pandas DataFrame which looks like this:
Great, now that we have our data. Let’s explore the data to see what we have.
Before we start looking at sentence embedding, let’s fire up some simple plots using Plotly.
I generally prefer Plotly visualizations over anything else. They look nicer, are interactive and you can easily curate and share them using Plotly Chart Studio. The only downside is that it can take quite a lot of code (and a lot of nested dictionaries!) to get something useful out of Plotly which is annoying when all you want to do is to quickly explore the data.
One thing I would recommend is updating Plotly to at least 4.8 as Pandas can now use Plotly as the library for backend plotting as opposed to the ‘aesthetically impaired’ Matplotlib.
In the code block below — we can fire up a simple bar chart from Pandas DataFrame just by adding .plot.bar() to a DataFrame object.
Simple as 123,abc!
pd.DataFrame([1,2,3],['a','b','c']).plot.bar()
You can tweak the look and feel of your figure if you want by using the same parameters you’d normally use with a Plotly Graph Objects, so there is that added familiar flexibility.
Ok back to the project...
I decided to get lyrics from a range of genres, some more extreme than others in order to see if we can see differences but I also chose very overlapping genres to see if there are commonalities.
Let’s look at how many songs in each genre we have.
#plot genresfig_genre = pd.DataFrame(df.genre.value_counts()).plot.bar(template='ggplot2')#title parameterstitle_param = dict(text='<b>Count of Genre</b><br></b>',                         font=dict(size=20))#update layoutfig_genre.update_layout(title=title_param,                  width=1000,                  height=500,                          xaxis = dict(title='Genre'),                  yaxis = dict(title='Count'))#change the colourfig_genre.update_traces(marker_color='rgb(148, 103, 189)')#show plotfig_genre.show()
Each genre is reasonably well represented — enough to make meaningful comparisons at least.
What about artists by genre?
Let’s go to town with some visualization and create a sunburst map of genre and artist. If you want to interact with the plot, the link to the original plot is in the footnote.
I’ve got a range of bands and artists who I feel represent those genres. You might disagree with me. That’s fine, genre definition can be contentious.
Let’s explore lyric length by genre.
#get lyric frequencies for each songdf['lyric_count'] = df['lyrics'].map(lambda x: len(x.split()))
Hip-hop is a very lyric heavy genre — many of the key descriptive metrics that describe lyric count for hip-hop are different from all other genres.
It will be interesting to see if the content of those lyrics are also different when we analyze the content of those lyrics. Which conveniently leads me to the next part of the EDA...Part of Speech (PoS) analysis.
So the next thing which would be interesting to look at is to understand the content of these lyrics. To do this I will use a spaCy model to classify lyrical content into its relevant parts of speech i.e classifying which words in the lyrics are nouns, verbs, adjectives, and so on. This will enable us to understand a little more about the differences in lyrical content across genres and may allude to where similarities and differences may lie.
import spacy#load spacy modelnlp = spacy.load('en_core_web_sm')def pos(string,pos):        """Returns any token that qualifies as a specific part of speech"""        doc = nlp(string) #fit model        return ' '.join(list(set([i.text for i in doc if i.pos_ == pos]))) #return any tokens that qualify
Let’s call the function above in a loop and get new columns containing all the useful parts of speech.
#get nounspos_of_interest = ['NOUNS','VERBS','ADJ','ADV]#loop through each PoSfor pos in pos_of_interest:      df[pos] = df.lyrics.map(lambda x: pos(x,pos))
Below is a function that will count the most common tokens in a string. We can use this to analyze the most common words in a song— including the PoS we have just made.
#call the function on one grammusixmatch_mostcommon(df.lyrics,token=1).head(10)
It will be more useful to find a way to use this function across all genres and by different parts of speech. An effective way to do this is to use Treemap visualizations. Plotly has a good template for this, so I’ve built a function that can do this for us.
Let’s call the function and look at the output
noun_treemap = musixmatch_genremap(df,pos='nouns')
WARNING — some of these lyrics are explicit
If you’re on a desktop I would strongly recommend you visit the link in the treemap footnote (above) in order to interact with it.
Now we get to the really fun part — using vectors to represent our lyrics. We can then use various techniques that can show us which vectors/lyrics are similar or different.
Vector representations of words were a significant step-change for natural language processing. Early methods that attempted to ‘understand’ language usually involved counting words (e.g. a bag of words model or weighted models / TFIDF approaches). These were limited in that they failed to capture the context of language. After all, the sentence ‘kid breaks skateboard’ is very different in meaning to ‘skateboard breaks kid’ ....despite having the same type and number of words.
Word2vec by Tomas Mikolov et al. was game-changer in that words were no longer rudimentarily represented as counts but as high-dimensional vectors. These vectors, or embeddings, were created through the training of a simple neural network on a corpus of text. These word embeddings represent a word’s relationship with a neighboring word and therefore its context. Therefore the vectors for the word ‘walking’ and ‘walked’ would be more similar than the words ‘swimming’ and ‘swam’.
If you want to learn more about this I would recommend reading the original whitepaper or a previous project I did with ASOS review on TrustPilot.
For this project, we will be using Google’s Universal Sentence Encoder, a state of the art encoder that has been trained on millions (possibly billions?) of documents.
It’s like word2vec but on steroids.
The Universal Sentence Encoder encodes text into high dimensional vectors that can be used for text classification, semantic similarity, clustering and other natural language tasks.
The model is trained and optimized for greater-than-word length text, such as sentences, phrases or short paragraphs. It is trained on a variety of data sources and a variety of tasks with the aim of dynamically accommodating a wide variety of natural language understanding tasks.
Source: https://tfhub.dev/google/universal-sentence-encoder/4
If you want to find out more about this model, I would strongly recommend you read the documentation on TensorFlow hub as it covers a lot more detail.
Let’s use Tensorflow Hub to load the model..
#get universal sentence encoderUSE = hub.load("https://tfhub.dev/google/universal-sentence-encoder/4")
And let’s write a short function that can get a high dimensional vector for any string input using the model.
def getUSEEmbed(_string,USE = USE):        """Function takes a string argument and returns its high dimensional vector from USE"""        return np.array(USE([_string])[0])
Let’s get the vector for a simple sentence....
example = getUSEEmbed('Hello how are you?')
As you can see a simple sentence has now been encoded as a high dimensional vector with a shape of (512,)
The cornerstone of this project is to find a way to find similarities and differences in song lyrics. As we are encoding lyrics into vector representations then we need to use techniques that allow us find similarities and differences in vectors
We can do this by:
Using cosine similarityDimension reduction and visualization (using something like Principal Component Analysis)
Using cosine similarity
Dimension reduction and visualization (using something like Principal Component Analysis)
Often when we have continuous, low dimensional data we would use something like Euclidean distance to gauge similarity/difference. We would use this distance metric with something like a KNN classifier or even when clustering dimensionally reduced vectors such as after a PCA.
However, because our vectors from the sentence encoder have very high dimensionality then using cosine similarity is a better proximity metric to use than Euclidean distance, to begin with at least.
A cosine similarity closer to 1 would mean there is minimal variance between the two vectors (the input strings are similar) whereas a cosine similarity closer to 0 would mean that two vectors are very different (the input stings are different)
Let me show you an example with four sentences.
Two of which are about weather..
“The weather is going to be really warm today”
“Today is going to be the sunniest day of the year”
And another two that are about breakfast
“I like my eggs sunny side up”
“Breakfast is my favourite meal”
I’ve specifically chosen examples where the word ‘sunny’/’sunniest’ has been used as a metaphor and as an adjective. Let’s see if the encoding can capture this difference.
#some example sentencesexample_sentences = ["The weather is going to be really warm today",                                          "Today is going to be the sunniest day of the year",                                          "I like my eggs sunny side up",                                          "Breakfast is my favourite meal"]#get embeddings for each example sentenceembed = [getUSEEmbed(i) for i in example_sentences]#set up a dictionary where each sentece is a key and its value is its 512 vector embeddingdic_ = dict(zip(example_sentences,embed))#let's find all the unique pairwise sentence combinationscombo = [list(i) for i in itertools.combinations(example_sentences, 2)]
Now we need a function to calculate similarity.
def cosineSimilarity(vec_x,vec_y):        """Function returns pairwise cosine similarity of two vector arguments"""        return cosine_similarity([vec_x],[vec_y])[0][0]
Let’s make a DataFrame that maps the cosine similarity for each unique sentence pair.
#empty list for datacs = []#lop through each unique sentence pairingfor i in range(len(combo)):        #get cosine similarity for each    cs_ = cosineSimilarity(dic_[combo[i][0]],dic_[combo[i][1]])        #append data to list    cs.append((combo[i][0],combo[i][1],cs_))#construct DataFramecs_df = pd.DataFrame(cs,columns['sent_1','sent_2','cosine_similarity']).\                    sort_values(by='cosine_similarity',ascending=False)
Let’s inspect the DataFrame.
This is so cool. The sentences that are most similar have more similar vectors (cosine similarity of 0.41 / 0.51). Even with the ambiguity of using a word that functions as a metaphor and an adjective, the encoding is sufficiently differentiated. Let’s see if we can apply this with our song lyrics.
First, we need to get the vectors for each song.
#get USE embeddings for each songdf_embed = pd.DataFrame([getUSEEmbed(df.lyrics[i]) for i in range(df.shape[0])],\                        index=[df.song[i] for i in range(df.shape[0])])
Inspect the DataFrame with df_embed.head(5)
Let’s use another data reduction technique — Principal Component Analysis, to reduce the dimensionality of these embeddings whilst retaining as much variance as possible.
I’ve built a comprehensive function to do this.
Let’s call this function and get three new DataFrames.
pc_1 = musixmatch_PCA(df,df_embed,n_components=1)pc_2 = musixmatch_PCA(df,df_embed,n_components=2)pc_3 = musixmatch_PCA(df,df_embed,n_components=3)
Let’s do some grouping to explore which artists have lyrics that are most similar to each other.
As we can see — even with just one component, artists from genres tend to cluster together. Death metal, heavy metal, and rock differ slightly from one another but vastly different to pop and soul. This makes sense.
If you are familiar with these bands as you scroll down you will get a strong sense of how genres evolve and that we can see this in the variance across principal component 1.
Let’s look at the same analysis but grouped by genre.
This averaged view confirms what we suspected above.
Let’s make a more sophisticated visualization that uses scatter plots to show this data, by song.
We can look at this in two dimensions (PC1,PC2) but also in three dimensions (PC1,PC2,PC3)
The function below combines a few functions from above with some more complex Plotly functions to generate scatter plots, either in 2d (PC1 / PC2) or in 3D (PC1 / PC2 / PC3)
Let’s call the function and see what the plot looks like. Again, I would strongly recommend you click on the plot link in the footer for the screengrab below if you want to interact with all the genres and where they lie on the plot.
scatPlot_2 = musixmatch_scatplot(df,df_embed,n_components=2)
We can also create a similar plot but utilize the 3 dimensions of the PCA.
scatPlot_3 = musixmatch_scatplot(df,df_embed,n_components=3)
We have shown that lyrics from different music genres do vary although what is interesting is that there is quite a clear relationship between lyrical content as we move from one genre to another in a stepwise fashion. I think the visualization below quite clearly brings this to life.
The lyrics of different genres aren’t entirely mutually exclusive — unless you compare the extremes. i.e. there is a huge difference between death metal and soul/r&b but little in the way to differentiate pop and pop/rock.
These findings reflect the true nature of music in that music genres are fluid and that for each genre, there is likely to be a ‘neighbouring’ genre that is similar in its lyrical content.
After all, many fans of r&b/soul will also like pop.....but maybe not punk and/or metal?
We have shown that universal sentence encoding/transfer learning is a very powerful and incredibly easy technique that can be used to pull out the differences and similarities in song lyrics across genres.
Its applications on other problems are numerous:
Song/lyric recommender systems — using similarity metrics to find songs with similar lyrics
Segmenting large volumes of text by finding similar documents e.g. emails, customer feedback/reviews
Identifying plagiarism
To be used as features for classification tasks
Thanks for reading and get in touch if you want to talk further! I’ve saved my notebook on my GitHub with all the code ready to go and be used in other projects. Let me know if you do, I’d love to see.
Sam
[1] GitHub (containing Jupyter notebook): https://github.com/kitsamho/songlyrics_univeral_sentence_encoder
[2] Beautiful Soup : https://www.crummy.com/software/BeautifulSoup/bs4/doc/
[3] Google Universal Sentence Encoder: https://tfhub.dev/google/universal-sentence-encoder/4
[4] Plotly: https://plot.ly/python/
[5] Musixmatch.com: https://www.musixmatch.com/