Machine Learning: Групиране на песни от Spotify (Клъстеризация)

Случвало ли ви се е да искате да слушате определени песни в Spotify, но без да се налага да търсите готов плейлист или ръчно да си създавате такъв?

По-лесно би било ако песните автоматично се групират по определени характеристики и след това от тях да се генерират плейлисти. Това е и причината да реша да напиша тази статия.

Чрез практически пример ще ви покажа как можете да групирате песни, които слушате в Spotify, и след това да създадете от тях плейлист. За неговото решение, ще е необходим езикът за програмиране Python и някои негови библиотеки за анализ на данни и машинно обучение.

Преглед на данните

Извадката, с която ще работим, съдържа 1846 произволно избрани песни, които съм слушала в Spotify за периода 28 юни 2015 – 26 януари 2021 година. Данните са предварително изчистени и съдържат 15 числови променливи и 10 категорийни.

Можете да изтеглите файла с първоначалната обработка от тук.

5 случайно избрани реда от данните изглеждат по следния начин:

artisttitlealbumgenreyearaddedlast_listenedlistensbpmnrgydncedBlivevalduracousspchpopadd_dayadd_monthadd_yearll_dayll_monthll_yearll_timecluster
1251Fifth HarmonySqueeze7/27 (Deluxe)dance pop20162016-09-042016-12-25 22:08:00291005864-8831213424434920162512201622:08:001
636LauvFor Now~how i'm feeling~electropop20202020-03-082020-12-29 23:12:006842353-121111189784588320202912202023:12:001
352DallasKSometimesSometimescomplextro20192020-05-082020-05-09 09:08:0011227873-73657183575085202095202009:08:000
981Sabrina CarpenterSmoke and FireSmoke and Firedance pop20162017-10-282017-10-30 07:58:00511708746-47532251954281020173010201707:58:002
638LauvSweatpants~how i'm feeling~electropop20202020-03-082020-12-29 21:22:0041604972-8157319658548320202912202021:22:000

Кои методи за машинно обучение ще използваме?

Ще приложим първо метода на главните компоненти (Principal Component Analysis – PCA), с който можем да редуцираме дименсиите и да визуализираме данните в 2D пространство, запазвайки дисперсията в извадката. След това ще клъстеризираме данните с метода k-средни (k-means clustering), за да открием групи въз основа на определени критерии за подобие на обектите.

PCA и K-means се отнасят към задачите за машинно обучение без контролна извадка и тъй като те изискват входните данни да са числа, е нужно да създадем нов DataFrame само с числови променливи.

Категорийните променливи бихме могли да ги кодираме и използваме също, но нека тях да ги оставим извън данните, за да може като формираме клъстерите, да видим какви песни са попаднали вътре в тях и по какво точно си приличат (жанр, артист или нещо друго).

# Избор само на числови променливи и запазване в нов DataFrame
df_numeric = df.select_dtypes('int64')

5 случайно избрани реда:

yearlistensbpmnrgydncedBlivevalduracousspchpopadd_dayadd_monthadd_yearll_dayll_monthll_year
837201881006053-7133219156126816720191772019
1392201621033953-91242676745324820162152020
7852019361606420-61034335284361112201931122019
501201831423662-728253067842416420201942020
92320159855259-91234260738716620182912020

Някои от колоните обаче са свързани с дати и не са ни полезни за групиране на песните. Тях ще ги премахнем. Нужни са ни само броя слушания на песните, различните аудио характеристики и популярността според скалата на Spotify (от 0 до 100, като 100 означава, че е много популярна песента, а 0 непопулярна).

# Премахване на ненужни колони
df_numeric.drop(['year','add_day','add_month','add_year','ll_day','ll_month','ll_year'], axis='columns', inplace=True)

Така остават 11 променливи, които ще използваме при прилагане на методите за машинно обучение.

5 случайно избрани реда:

listensbpmnrgydncedBlivevalduracousspchpop
139811029246-626322020819
110862938463-4216619035619
479201097570-6127520741230
105651017079-586017516944
132211385759-7133222729519

Най-вероятно забелязвате, че стойностите в някои колони са доста по-високи от тези в други колони. Такава е например колоната с продължителност на песните (dur). Това е проблем за методите, които ще използваме, тъй като PCA се влияе много от дисперсията на променливите, а при K-means се изчисляват разстояния между обектите в пространството от характеристики. Поради тази причина е възможно да се определят тези променливи за по-важни. Нужно е данните да бъдат от еднакъв порядък, иначе получените резултати могат да бъдат с голяма грешка.

Мащабиране на променливите

Ще стандартизираме данните с помощта на StandardScaler().

Формулата, която се прилага, е следната:

\frac{X_{i} - \mu}{\sigma}

Формата на разпределението след трансформацията не се променя, стандартното отклонение става равно на 1, а средната стойност на всички променливи става 0.

# Стандартизиране на данните чрез StandartScaler()
X = np.array(df_numeric)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

5 случайно избрани реда:

listensbpmnrgydncedBlivevalduracousspchpop
85-0.3714910.3060330.460492-0.8131920.0699754-0.735825-1.661481.61726-0.872762-0.6628810.210776
566-0.393265-0.289707-1.25001-2.28932-1.3653-0.658831-1.927991.465881.72132-0.502965-0.062655
1262-0.4585850.656469-0.1935230.66294-0.6476610.496083-0.3289270.532372-0.485586-0.3430490.101403
305-0.436811-0.149533-0.9984641.25339-1.72412-0.504842-0.595437-0.6282080.2113321.09620.101403
7941.784072.05821-0.193523-1.62506-0.288843-0.4278480.115257-1.687871.798765.893680.757636

Прилагане на PCA

Ще използваме метода на главните компоненти, предоставен от библиотеката Scikit-learn. Идеята, която стои зад него, е да се намали броят на използваните характеристики, но в същото време да се запази възможно най-много информация за данните. Това става като се проектират точките от пространството с характеристики върху такова с по-малка размерност така, че разстоянията да са минимални.

# Прилагане на PCA()
pca = PCA()
pca.fit(X_scaled)

Новосъздадените променливи (компоненти) са комбинация от първоначалните характеристики и са такива, че връзката между тях (мултиколинеарността) е минимална, а по-голямата част от дисперсията е в първите компоненти. В нашия случай те са толкова на брой, колкото променливи има първоначално в извадката – 11. За да преценим оптималния брой компоненти, който ни е необходим, ще създадем 2 визуализации. Една стълбовидна диаграма, на която е представена дисперсията, обяснена от всеки от компонентите, и една линейна диаграма, показващата общата дисперсия при конкретен брой компоненти.

# Създаване на празен списък за имената на компонентите
component_names = []

# Добавяне на имената на компонентите в списъка
for i in range(1, len(pca.components_)+1):
    component_names.append('PCA-' + str(i))

# Създаване на фигура
fig = go.Figure()

# Изграждане на стълбовидна диаграма
fig.add_trace(go.Bar(x=component_names, y=pca.explained_variance_ratio_))

# Добавяне на заглавия
fig['layout']['xaxis']['title'] = 'Number of Components'
fig['layout']['yaxis']['title'] = 'Explained Variance Ratio'
fig['layout']['title'] = 'PCA - Explained Variance Ratio for each component'

# Показване на визуализацията
fig.show()

От графиката става ясно, че по-голямата част от дисперсията в данните е обяснена от 1-вия компонент.

# Създаване на фигура
fig = go.Figure()

# Изграждане на линейната диаграма
fig.add_trace(go.Scatter(y=np.cumsum(pca.explained_variance_ratio_)))

# Добавяне на заглавия
fig['layout']['xaxis']['title'] = 'Number of Components'
fig['layout']['yaxis']['title'] = 'Explained Variance Ratio'
fig['layout']['title'] = 'Principal Component Analysis'

# Показване на визуализацията
fig.show()

Нека поставим за критерий компонентите да съдържат над 80% от данните. Тогава оптималния брой ще бъде 7, тъй като първите 7 компонента съдържат в себе си информация за 84.36% от данните.

След това можем да преминем към прилагане на PCA.

# Задаване на брой компоненти
pca.n_components = 7

# Прилагане на PCA
X_reduced = pca.fit_transform(X_scaled)
df_X_reduced = pd.DataFrame(X_reduced, index=df.index)

5 случайно избрани реда от данните след PCA изглеждат по следния начин:

0123456
3710.95619-1.204141.886340.08745640.341647-1.037410.418123
5573.99529-0.461566-0.6906720.462283-0.5515670.3726961.17407
2510.05804810.7174260.553869-1.13996-0.258895-0.492269-0.165832
71.46270.892714-0.815698-1.234310.1450450.456694-0.738249
42-0.676131.002590.9597480.297643-0.520085-0.540276-1.92545

Клъстеризация на данните с метода K-means

K-means е един често използван метод за групиране на данни в клъстери. При него се изчисляват разстояния между всеки обект до центъра на клъстера и се организират данните така, че да има голяма прилика между обектите в клъстера и малка прилика между обектите от съседни клъстери. Този метод изисква предварително да се зададе броя клъстери.

Основни стъпки на алгоритъма:

  1. инициализация на центровете на клъстерите
  2. определяне на точките с минимално разстояние до центъра на клъстера
  3. преизчисляване на центровете на клъстера
  4. точки 2-3 се изпълняват докато центровете не спрат да се изместват значително

Съществуват множество метрики за измерване на разстояние между обектите, но най-често използваните са разстоянията на:

  • Евклид:
dist_{euc} = \sqrt{\sum^N_{j=1}(x_{aj} - x_{bj})^2}
  • Манхатън:
  dist = \sum^N_{j=1}|x_{aj} - x_{bj}|

Какъв е оптималният брой клъстери?

За да отговорим на този въпрос ще създадем една линейна диаграма, която показва изменението на отдалечеността на точките вътре в клъстерите (inertia) в зависимост от броя клъстери.

# Създаване на празен списък за стойностите на intertia
inertias = []

# Добавяне на стойностите на inertia при различен брой клъстери към списъка
for i in range(1, 12):
    kmeans = KMeans(n_clusters=i)
    kmeans.fit(X_reduced)
    kmeans_pred = kmeans.predict(X_reduced)
    inertias.append(kmeans.inertia_)

# Създаване на фигура
fig = go.Figure()

# Изграждане на линейна диаграма
fig.add_trace(go.Scatter(x=np.array(range(1, 12)),
                         y=inertias[1:]))

# Настройки на визуализацията
fig.update_layout(xaxis={'dtick':1, 'title':'Number of Clusters'}, yaxis={'title':'Intertia'})

# Добавяне на заглавие
fig['layout']['title'] = 'Spotify songs - optimal number of clusters '

# Показване на визуализацията
fig.show()

Оптималният брой клъстери според визуализацията е 3 или 4, защото след това наклонът на кривата става по-плавен и при по-големите бройки клъстери разликата в стойностите за отдалечеността на точките вътре в клъстерите е по-малка. Ние ще групираме данните в 3 клъстера обаче, тъй като по време на тест на алгоритъма с единия и след това с другия брой, при 4 клъстера не се получиха толкова добри резултати.

След като сме определили на колко групи да се разделят данните, можем да продължим нататък с прилагане на метода за клъстеризация. Ще използваме KMeans(), предоставен от библиотеката Scikit-learn.

# Прилагане на KMeans()
kmeans = KMeans(n_clusters=3, random_state=3442)
kmeans.fit(X_reduced)
kmeans_pred = kmeans.predict(X_reduced)

След прилагане на KMeans(), всяка от песните в извадката е разпределена в един от 3 клъстера в зависимост от нейните характеристики.

Резултати и създаване на плейлист

За да представим получените резултати от групирането на песните, ще създадем 2 визуализации.

# Създаване на речник с имената на компонентите и процентът дисперсия
labels = {
    str(i): f'PC {i+1} ({var:.1f}%)'
    for i, var in enumerate(pca.explained_variance_ratio_ * 100)
}

# Изчисляване на общата дисперсия
total_var = pca.explained_variance_ratio_.sum() * 100

# Създаване на визуализацията
fig = px.scatter_matrix(
    X_reduced,
    labels=labels,
    dimensions=range(6),
    color=kmeans.labels_,
    width=900,
    height=800,
    title=f'Total Explained Variance: {total_var:.2f}%'
)

# Настройки на визуализацията - скриване на диагонала
fig.update_traces(diagonal_visible=False)

# Показване на визуализацията
fig.show()

Първата включва множество диаграми на разсейването, показващи връзката между отделните компоненти. На нея можем да видим с различни цветове клъстерите и да изберем тези 2 компонента, при които те са разделени най-ясно. В този случай това са 1 и 3.

# Създаване на фигура
fig = go.Figure()

# Визуализиране на точките в клъстерите
fig.add_trace(go.Scatter(x=df_X_reduced[0],
                         y=df_X_reduced[1],
                         text='Song: ' + df['artist'] + ' - ' + df['title'],
                         name='',
                         mode='markers',
                         marker=pgo.scatter.Marker(
                                       size=df_numeric['pop'],
                                       sizemode='diameter',
                                       sizeref=df_numeric['pop'].max()/20,
                                       opacity=0.8,
                                       color=kmeans.labels_),
                         showlegend=False))

# Визуализиране на центровете на клъстера
fig.add_trace(go.Scatter(x=kmeans.cluster_centers_[:, 0],
                         y=kmeans.cluster_centers_[:, 1],
                         name='',
                         mode='markers',
                         marker=pgo.scatter.Marker(symbol='x',
                                           size=12,
                                           color='red'),
                         showlegend=False))

# Добавяне на заглавия
fig['layout']['xaxis']['title'] = 'Principal Component 1'
fig['layout']['yaxis']['title'] = 'Principal Component 2'
fig['layout']['title'] = 'Visualization of Clusters'

# Показване на визуализацията
fig.show()

На тази графика се вижда по-ясно как са разделени песните. Размера на точките е на базата на популярността на конкретната песен. Колкото по-големи са, толкова песента е по-популярна.

Създадените клъстери са следните:

  • Клъстер 0 (Син клъстер) е със 716 песни, предимно такива, които са енергични, бързи и весели, най-вече от жанра k-pop.
  • Клъстер 1 (Розов клъстер) включва 417 песни, които са най-вече от жанра инди и са бавни и тъжни.
  • Клъстер 2 (Жълт клъстер) има 713 песни, предимно такива, на които може да се танцува, предназначени за клубове. Най-много песни в този клъстер са от жанр dance pop и electropop.

От тук нататък можем от всеки клъстер да извадим определен брой песни и да ги запазим във файл с разширение .csv, след което да използваме онлайн платформа като Soundiiz, за да импортираме плейлист в Spotify.

# Запазване на 10 произволни песни от Клъстер 0 в нов DataFrame
playlist_c0 = df[df['cluster'] == 0][['title', 'artist', 'album']].sample(10)

# Запазване на данните в нов csv файл
playlist_c0.to_csv('./Data/playlist.csv', index=False, sep=';')

10 случайно избрани песни от Клъстер 0:

titleartistalbum
1222Don't Wanna Dance AloneFifth HarmonyBetter Together
1630VVITHNU'ESTQ IS.
472Drove You AwayFly By MidnightHappy About Everything Else…
1115RUMORKARDK.A.R.D Project Vol.3 "RUMOR"
1098Your SongRita OraYour Song
1248The LifeFifth Harmony7/27 (Deluxe)
1749The Art Of BreakingThousand Foot KrutchDeja Vu: The TFK Anthology
1176Oh NaNa (Hidden. HUR YOUNG JI)KARDK.A.R.D Project Vol.1 "Oh NaNa"
1650Broken HeartAfter SchoolPLAYGIRLZ
1582Will You Be AlrightBeastHard To Love, How To Love

За да е успешно импортирането на плейлиста в Soundiiz, е необходимо csv файлът ни да съдържа само колоните заглавие (title), изпълнител (artist) и албум (album), а разделителят да е точка и запетая (semicolon).

Като извод можем да кажем, че групирането на песните се е получило доста добре, макар да има и такива, които се различават от останалите в съответния клъстер, но в повечето случаи наистина си приличат. Резултатите от клъстеризацията могат да бъдат подобрени като се правят множество експерименти с различни стойности на параметрите на KMeans().

Цялостния пример можете да изтеглите от тук.

Искате да научите повече за Python?

Включете се в курса по програмиране с Python.

Научете повече

Автор: Десислава Христова