Определение популярности музыкального жанра по местоположению и создание интерактивной карты

В прошлом году я прочитал эту статью о Бренте Файязе, певце, который отказался от аванса на четверть миллиона долларов, чтобы остаться независимым. Ему не нужен был лейбл, менеджер Файяза мог общаться с поклонниками и планировать концерты, используя данные слушателей Spotify.

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

В этой статье рассказывается, как я:

  • Сбор данных о популярности музыкального жанра по местоположению с помощью библиотек Python Beautiful Soup и urllib.
  • Создал интерактивную карту городов-рынков с помощью Plotly.

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

Чтобы узнать больше о веб-парсинге, прочтите эту статью или ознакомьтесь с моим кодом на GitHub.

Первой проблемой было определение полезного и бесплатного источника данных. Я просмотрел десятки веб-сайтов, статей, отчетов и рейтингов, пока не наткнулся на Everynoise.com.

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

Я смог подтвердить, что на странице перечислены города, в которых было наибольшее количество слушателей Spotify, сравнив его с другими статьями.

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

В этом проекте я использовал модули Python urllib и Beautiful Soup, которые упростили получение исходного кода HTML для любой страницы, используя всего несколько строк кода.

from bs4 import BeautifulSoup
import urllib.request as urllib
def fresh_soup(url): 
  # when making requests identify as if using the Mozilla Browser
  hdr = {‘User-Agent’: ‘Mozilla/5.0’} 
  # make a url request using the specified browser
  req = urllib.Request(url,headers=hdr)   
  # retrieve the page source
  source = urllib.urlopen(req,timeout=10).read()
  soup = BeautifulSoup(source,”lxml”)   
  return soup

Чтобы собрать остальную часть парсера, я начал перемещаться по веб-сайту и планировать, как он организован.

Я быстро заметил, что когда я нажимаю на название города, он показывает рейтинг самых популярных жанров и меняет URL, добавляя поисковый запрос в конце.

Это означало, что я мог получить самые популярные жанры в каждом месте, составив список с названиями или ссылками на каждый город. Для того, чтобы составить этот список, нужно очистить страницу Every Place at Once.

Для этого я щелкнул правой кнопкой мыши по названию первого города на первой странице и нажал «осмотреть». Это открыло исходный код страницы в моем браузере. Я пролистал несколько строк кода и нашел ссылки для каждого города.

Каждая строка с тегом tr и классом datarow содержала название и ссылку для города. Они были в разделе table.

Используя источник страницы, я собрал список всех table элементов, используя table = soup.find_all('table'), и обнаружил, что вторая таблица была той, которая меня интересовала. Команда table.find_all('tr') дала мне каждую строку в таблице.

В каждой строке информация сохранялась в первом элементе с тегом a. К счастью, Beautiful Soup предоставляет легкий доступ к свойствам HTML. Я использовал a.text для названий городов и a['href'] для необработанных ссылок.

def get_cities():
    # url for a list of every city listed in everynoise
    origin = 'http://everynoise.com/everyplace.cgi?root=all'
    # get the formatted page source
    soup = fresh_soup(origin) 
    # retrieve a list of all table tags and grab the second table
    table = soup.find_all('table')[1]
    links = []
    # loop through every row in the table
    for row in table.find_all('tr'): 
        # extract the ['icon','city name','country code']
        elements = [a.text for a in row.find_all('a')] 
        # there was only ever one tag which had the link in it
        link = [a['href'] for a in row.find_all('a')][0]   
        # save each link in a (city,country,link) tuple
        links.append((elements[1], elements[2], link))                                      
    return links

Я составил свой список названий городов и ссылок, но в некоторых городах использовались неанглийские символы, что приводило к ошибочным URL-адресам. Я создал функцию, которая использовала метод quote из urllib.parse для их исправления.

from urllib.parse import quote
def clean_url(url): 
   non_conformists = [s for s in url if s not in string.printable] 
   for s in non_conformists:
      # and use the quote function to translate them          
      url = url.replace(s,quote(s))   
   
   return url

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

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

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

import pandas as pd
import re 
def genre_popularity(links, filename):
   # create a dataset where you will store everything
   everynoise_popularity = pd.DataFrame()
   
   # each link is a (city, country, link) tuple
   for link in links:   
       soup = fresh_soup(link[2])
       # find the table with the most popular genres
       genres = soup.find_all('div', {'class':'note'})
       # if we find a table we proceed
       if len(genres)>0:
          # get the table
          genres = soup.find_all('div', {'class':'note'})[0]
          # and every row in the table excluding the header
          genres = genres.find_all('div')[1:]
          popularity = []
          genre = [] 
          for element in genres:
             # use the font-size as a proxy for popularity
             popularity.append(re.findall('font-size: ([0-9]+)%;', str(element))[0])
             
             genre.append(element.text)
          df = pd.DataFrame({"Popularity":popularity,"Genre":genre})
          df['City'] = link[0] 
          df['Country Code'] = link[1]
          everynoise_popularity =   everynoise_popularity.append(df,ignore_index=True,sort=False)
   everynoise_popularity.to_csv(filename)                                                 
   return everynoise_popularity

На этом я закончил скребок для Every Noise.

Затем мне нужно было добавить географическую информацию. Я получил долготу и широту для каждого города с simplemaps.com.

Названия городов не полностью совпадали с данными Every Noise, поэтому я использовал библиотеку fuzzymatcher для объединения двух наборов данных (я использовал эту библиотеку, потому что она работала непосредственно с фреймами данных; другие библиотеки, такие как FuzzyWuzzy, могут дать лучшие совпадения. ).

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

Следующей большой задачей было создать разумный способ измерить, насколько важны городские рынки для определенных жанров. Это потребовало выбора целевых жанров из более чем 1500 уникальных поджанров в данных Every Noise.

Чтобы лучше понять доступные жанры, я получил частоту каждого слова в каждом термине в столбце жанр и частоту их появления в уникальных жанрах (верхняя таблица , Freq) и в исходном рейтинге (PopFreq).

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

Определив набор целевых жанров, я приступил к измерению рыночной значимости.

Метрика учитывала три вещи:

  • Сколько релевантных поджанров появилось в одном рейтинге городов.
  • Порядок поджанров в рейтинге городов.
  • Порядок городов в списке доступных городов (относительный размер рынка Spotify).

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

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

Последней задачей было составить карту музыкальных рынков.

Я использовал Mapbox функцию в Plotly , которая была очень простой. Для создания фигуры Plotly требовалось всего два аргумента: data и layout.

Аргумент data был списком графических объектов Plotly (тип графика, настройки графика и необработанные данные). Аргумент layout допускает дополнительные корректировки и элементы рисунка.

Я создал Scattermapbox объект-график для каждого набора данных (рэп и хип-хоп), установив их столбцы lat и long как широту и долготу Scattermapbox .

Размер точки для каждого города был пропорционален рыночной значимости с помощью опции go.scattermapbox.Marker.

Наконец, я добавил аргумент для opacity, чтобы слои не блокировали друг друга.

import plotly.graph_objs as go
from plotly.offline import plot
def map_plot_element(ds, data_name, opacity_value):
   element = go.Scattermapbox(  # the plot points for a Plotly map
      lat=ds['lat'],  # define the latitude data  
      lon=ds['lng'],  # define the longitude data
      mode='markers', # define the point types
      opacity=opacity_value,
      marker=go.scattermapbox.Marker(
         # marker size based on market importance
         size=ds['market_importance']),
      # the dataset name for the legend
      name = data_name,
      # display the city name and top genres when hovering over
      text=ds['city'].str.title()+'<br><br>'+ds['genre'].str.title().str.replace(',','<br>'), 
   )
   
   return element
data = []
data.append(map_plot_element(rap_data, "Rap", 1))
data.append(map_plot_element(hh_data, "Hip Hop", 0.9))

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

layout = dict(
   height = 500,                                            
   margin = dict( t=0, b=0, l=0, r=0 ),      # margins  
   font = dict( color='#FFFFFF', size=11 ),  # set font properties 
   paper_bgcolor = '#000000',   # set the paper's background color 
   mapbox=dict(
      accesstoken=mapbox_access_token,
      bearing=0,
      center=dict(
         lat=0,       # set the mapbox center
         lon=0
      ),
      pitch=0,
      zoom=1.2,
      style='dark'    # set the graph style 
    ),
)

Затем я передал объекты data и layout методу Figure, используя fig = go.Figure(data=data, layout=layout), и построил фигуру, используя plot(fig), в результате чего появилась карта ниже.

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

К счастью, я мог использовать словарь макета для добавления меню, обновляющего карту.

Я создал два меню обновления, первое было раскрывающимся, изменяющим стиль графика между dark, light, satellite и satellite with streets.

drop_down_1 = dict(        # drop down menu dict
   buttons=list([          # buttons are listed manually 
      dict(                # button 1 
         # arguments for the first button 
         args=['mapbox.style', 'dark'],  # set style to dark 
         label='Dark',                   # button text "Dark"
         method='relayout'               # relayout update method
      ),                    
      dict(                # button 2 
         args=['mapbox.style', 'light'], # set style to light
         label='Light',
         method='relayout'
      ),
      dict(                # button 3 
         args=['mapbox.style', 'satellite'], # set style to satelite
         label='Satellite',
         method='relayout'
      ),
      dict(                # button 4
         # set style to satelite with streets
         args=['mapbox.style', 'satellite-streets'], 
         label='Satellite with Streets',
         method='relayout'
      )]),
   direction = 'up',       # the menu opens upwards
   x = 0.75,               # distance from anchors 
   xanchor = 'left',       # anchor direction
   y = 0.05,        
   yanchor = 'bottom',
   bordercolor = '#FFFFFF',
   font = dict(size=11)    # font size 
)

Во втором меню обновления перечислены все страны в наборе данных и увеличен центроид выбранной страны.

Первым шагом было создание значения по умолчанию World с настройками центра и масштаба карты по умолчанию.

Используя широту и долготу центроидов стран с Periscopedata.com, я создал кнопки увеличения для каждой страны и добавил их в список кнопок.

# create the first element of the scroll down list
countries=list([
   dict(
      args=[ { 
         # set the default view to the cetner of the world
         'mapbox.center.lat':0, 
         'mapbox.center.lon':0,
         'mapbox.zoom':1.2,                  # with a high zoom
         'annotations[0].text':'World View'  # titled "World View" 
      } ],
      label='World',                         # give it the layout
      method='relayout'
   )
])
# loop through each country to add their data to the drop-down menu
for idx, row in country_data.iterrows():
   countries.append(
      dict(
         args=[{
            # element centers on the latitude and longitude
            'mapbox.center.lat': row['latitude'],                
            'mapbox.center.lon': row['longitude'],
            # zoom to only see the country
            'mapbox.zoom':3,                                     
            # display the country code, name and preferred sub-genre
            'annotations[0].text':'<br>'.join([row['country'],
                                               row['name'],])
         }],
      label=row['name'],
      method='relayout',
   )
)
drop_down_2 = dict(
   buttons = countries,     # buttons are the countries
   pad = {'r': 0, 't': 10},
   x = 0.03,                # space from x anchor 
   xanchor = 'left',        # anchor it to the left
   y = 1.0,                 # space from y anchor 
   yanchor = 'top',         # anchor to the top 
   bgcolor = '#AAAAAA',     # menu background color 
   active = 99,
   bordercolor = '#FFFFFF',
   font = dict(size=11, color='#000000')
)

Включить раскрывающиеся меню на карту было так же просто, как поместить их в список и установить для клавиши updatemenu в layout словаре: layout['updatemenus'] = [drop_down_1, drop_down_2].

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

Я создал две записные книжки, которые проходят через процессы очистки и сопоставления, описанные в этой статье.

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

Вы можете взаимодействовать с графиками на Datapane.com.