Notes sobre notes: analitzant set anys de dades de streaming de música

Punts clau

• La meva música ha arribat a més de 170 països i ha acumulat més de 137 milions de reproduccions (!)
• Necessito més de 200.000 reproduccions a Instagram/Facebook per guanyar un dòlar.
• Amazon Unlimited i Tidal ofereixen les millors taxes de pagament.
• El sistema de royalties “modernitzat” de Spotify perjudicarà els artistes emergents.

Els meus pares em van regalar el meu primer teclat de piano quan tenia quatre anys. Era petit, d’una sola octava, però va ser suficient perquè comencés a crear.

Anys més tard vaig descobrir que podia improvisar. Estava intentant tocar d’oïda la introducció de Lose Yourself, d’Eminem, quan em vaig adonar que podia continuar tocant els acords amb la mà esquerra i donar llibertat a la dreta.

Vaig començar a gravar aquestes improvisacions i les vaig compartir amb amics propers i familiars. La meva àvia, molt seriosa, em va dir que «seria molt egoista no compartir el meu talent amb el món».

Uns mesos després de la seva mort, vaig publicar el meu primer àlbum. La dotzena pista, tólfta (fyrir ömmu), és una improvisació que vaig gravar per a ella quan estava a l’hospital.

Avui fa set anys ja. Set anys de dades: xifres de streaming, royalties, oients… Tenia curiositat: a quants països ha arribat la meva música? Quantes vegades s’ha reproduït cada cançó i d’on venen els meus ingressos? I quant paguen Spotify, Apple Music, TikTok i Instagram per cada stream?

Fes clic per veure l'índex

Les dades

La meva música està disponible pràcticament a tot arreu, des de serveis de streaming regionals com JioSaavn (Índia) o NetEase Cloud Music (Xina) fins a Amazon Music, Apple Music, Spotify, Tidal… Fins i tot es pot afegir a vídeos de TikTok i Instagram/Facebook.

Distribueixo la meva música a través de DistroKid (enllaç de referral), que em permet quedar-me amb el 100% dels pagaments.

Cada dos o tres mesos, els serveis (Spotify, Amazon Music…) envien un «informe de guanys» al distribuïdor. Després de set anys, comptava amb 29.551 files com aquestes:

Mes d’InformeMes de VendaBotigaArtistaTítolQuantitatCançó/ÀlbumPaís de VendaGuanys (USD)
Març 2024Gen 2024Instagram/Facebookosker wyldkrakkar704CançóOU0.007483664425
Març 2024Gen 2024Instagram/Facebookosker wyldfimmtánda9,608CançóOU0.102135011213
Març 2024Gen 2024Tidalosker wyldtólfta (fyrir ömmu)27CançóMY0.121330264483
Març 2024Des 2023iTunes Matchosker wyldfyrir Olivia1CançóTW0.000313712922

Les eines

El meu primer instint va ser utilitzar Python amb un parell de llibreries: pandas per processar les dades i seaborn o Plotly per visualitzar-les.

Però tenia ganes de provar polars, una «llibreria de dataframes increïblement ràpida» (puc confirmar-ho). Tanmateix, buscant programari lliure per crear visualitzacions interactives, vaig trobar Vega-Altair, una llibreria de visualització declarativa basada en Vega-Lite.

Preparant les dades

Les dades estaven netes! Vaig poder passar directament a la preparació de les dades per a l’anàlisi.

Vaig eliminar i renombrar columnes, vaig ajustar el nom d’algunes botigues, i vaig indicar el tipus de dades de cada columna.

Fes clic per veure el codi
df = pl.read_csv("data/distrokid.tsv", separator="\t", encoding="ISO-8859-1")

# Drop columns.
df = df.drop(
    ["Artist", "ISRC", "UPC", "Team Percentage", "Songwriter Royalties Withheld"]
)
# I'm only interested in streams, so I'll drop all "Album" rows…
df = df.filter(col("Song/Album") != "Album")
# …and all Stores matching "iTunes*" (iTunes, iTunes Match and iTunes Songs).
df = df.filter(~col("Store").str.contains("iTunes"))
# Now we can drop the "Song/Album" column.
df = df.drop(["Song/Album"])

# Rename columns.
countries = countries.rename({"Numeric code": "Country numeric code"})
df = df.rename(
    {
        "Sale Month": "Sale",
        "Reporting Date": "Reported",
        "Country of Sale": "Country code",
        "Earnings (USD)": "Earnings",
        "Title": "Song",
    }
)

# Rename stores.
print(f"Before: {df["Store"].unique().to_list()}")
# Rename "Amazon $SERVICE (Streaming)" to "Amazon $SERVICE"
df = df.with_columns(col("Store").str.replace(" (Streaming)", "", literal=True))
# Rename Facebook to Meta (as it's really FB and IG).
df = df.with_columns(col("Store").str.replace("Facebook", "Meta"))
# More renaming…
df = df.with_columns(col("Store").str.replace("Google Play All Access", "Google Play Music"))

# Set the proper data types.
df = df.with_columns(
    col("Sale").str.to_datetime("%Y-%m"),
    col("Reported").str.to_datetime("%Y-%m-%d"),
)

Vaig eliminar les files que pertanyien a serveis pels quals tenia menys de 20 registres.

Fes clic per veure el codi
store_datapoints = df.group_by("Store").len().sort("len")
min_n = 20
stores_to_drop = list(
    store_datapoints.filter(col("len") < 20).select("Store").to_series()
)
df = df.filter(~col("Store").is_in(stores_to_drop))

En termes d’enginyeria de característiques —crear variables noves a partir de dades existents—, vaig afegir la columna «Any», vaig recuperar els codis de país d’un altre conjunt de dades i vaig calcular els ingressos per stream.

Fes clic per veure el codi
# Country from country code.
countries = pl.read_csv("data/country_codes.csv")
# DistroKid (or Facebook?) uses "OU" for "Outside the United States".
missing_codes = pl.DataFrame(
    {
        "Country": ["Outside the United States"],
        "Alpha-2": ["OU"],
        "Alpha-3": [""],
        "Country numeric code": [None],
    }
)
countries = countries.vstack(missing_codes)
df = df.join(countries, how="left", left_on="Country code", right_on="Alpha-2")

# Year when the streams happened.
df = df.with_columns(col("Sale").dt.year().alias("Year"))

# Earnings per unit (stream).
df = df.with_columns((col("Earnings") / col("Quantity")).alias("USD per stream"))

# Earnings per second (of full track length).
df = df.with_columns((col("USD per stream") / col("Duration")).alias("USD per second"))

Tot a punt! Hora d’obtenir respostes.

Quantes vegades s’ha reproduït la meva música?

Aquesta és la primera pregunta que em va sorgir. Per respondre-la, vaig sumar la columna «Quantitat» (reproduccions).

Fes clic per veure el codi
df.select(pl.sum("Quantity")).item()

137.053.871. Cent trenta-set milions cinquanta-tres mil vuit-cents setanta-un.

Vaig haver de comprovar diverses vegades el resultat; no m’ho creia. No soc famós i gairebé no he promocionat la meva música. En ordenar les dades vaig trobar la resposta:

ServeiQuantitatCançó
Meta8.171.864áttunda
Meta6.448.952nostalgía
Meta6.310.620áttunda
Meta3.086.232þriðja
Meta2.573.362hvítur

La meva música s’ha utilitzat en vídeos d’Instagram i Facebook (tots dos propietat de Meta) amb milions de reproduccions. Encara no m’ho crec —els pocs vídeos on he escoltat la meva música ni s’apropaven al milió de reproduccions.

Quantes hores (o dies) és això?

Fes clic per veure el codi
# Somewhat conservative approach: 30 seconds per stream
# It's the minimum required by Spotify/Apple Music, and average length of TikTok videos and probably Instagram reels.
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta

seconds_per_stream = 30

start_date = datetime(1, 1, 1)
end_date = start_date + timedelta(seconds=total_quantity * seconds_per_stream)

relativedelta(end_date, start_date)

Si considerem que cada reproducció equival a 30 segons d’una persona escoltant la meva música, obtenim un temps total d’escolta de més de 130 anys. Wow.

30 segons és el temps mínim que Spotify o Apple Music exigeixen abans de comptabilitzar una reproducció. No obstant això, què passa amb Facebook/Instagram o TikTok? Pot ser que no hi hagi un temps mínim. De fet, pot ser que els usuaris tinguin el mòbil en silenci!

Si cada reproducció equival a deu segons d’escolta, totes les reproduccions sumen 43 anys. Segueixo al·lucinant.

En quants països s’ha escoltat la meva música?

Fes clic per veure el codi
# DistroKid (or Meta?) uses "OU" for "Outside the United States".
df.filter(col("Country code") != "OU").select(col("Country")).n_unique()

En total, 171. És a dir, més del 85% de tots els paísos! Com ha crecut aquesta xifra?

Fes clic per veure el codi
# Barplot with year/number of countries.
width = "container"
height = 350

# HTML slider to control the year.
year_slider = alt.binding_range(
    name="Year ",
    min=df["Year"].min(),
    max=df["Year"].max(),
    step=1,
)

# Selecting a year through slider/clicking on the bars will update the map.
year_selection = alt.selection_point(
    fields=["Year"],
    value=df["Year"].max(),
    empty=True,  # Upon reset shows all countries (though with the Quantity of their first appearance)
    on="click, touchend",
    bind=year_slider,
)

# Hovering over a bar will add a stroke.
hover = alt.selection_point(
    empty=False,
    on="mouseover",
    clear="mouseout",
    fields=["Countries"],
)

# The actual barplot.
num_countries_per_year = (
    alt.Chart(unique_countries_per_year)
    .mark_bar(color="teal", cursor="pointer")
    .encode(
        x=alt.X("Year:O", title=None, axis=alt.Axis(labelAngle=0)),
        y=alt.Y("Countries:Q", axis=None, scale=alt.Scale(domain=[0, 800])),
        opacity=alt.condition(year_selection, alt.value(1), alt.value(0.5)),
        strokeWidth=alt.condition(hover, alt.value(2), alt.value(0)),
        stroke=alt.condition(hover, alt.value("black"), alt.value(None)),
    )
    .properties(width="container")
    .add_params(year_selection, hover)
)

# Add text with the number of countries at the end of the bars.
text_num_countries_per_year = num_countries_per_year.mark_text(
    align="center",
    baseline="middle",
    dy=-10,
    fontSize=20,
    font="Monospace",
    fontWeight="bold",
    color="teal",
    strokeOpacity=0,
).encode(text="Countries:Q")

bars_with_numbers = num_countries_per_year + text_num_countries_per_year

# Background for the bars for map layer.
unique_years = unique_countries_per_year["Year"].unique()
background_data = {"Year": unique_years, "Value": [120] * len(unique_years)}
background_df = pl.DataFrame(background_data)
background_white_bars = (
    alt.Chart(background_df)
    .mark_area(color="white")
    .encode(
        x=alt.X("Year:O", title=None, axis=None, scale=alt.Scale(padding=0)),
        y=alt.Y("Value:Q", title=None, axis=None, scale=alt.Scale(domain=[0, 800])),
    )
    .properties(width="container")
)

num_countries_chart = alt.layer(background_white_bars, bars_with_numbers).resolve_scale(
    x="independent"
)

plays_per_year_country = (
    df.group_by(["Country numeric code", "Country", "Year"])
    .agg(col("Quantity").sum())
    .sort("Quantity", descending=True)
)

hover_country = alt.selection_point(on="mouseover", empty=False, fields=["Country"])

source = alt.topo_feature(data.world_110m.url, "countries")
projection = "equirectangular"

base = (
    alt.Chart(source)
    .mark_geoshape(fill="lightgray", stroke="white", strokeWidth=0.5)
    .properties(width="container", height=height)
    .project(projection)
)

color_scale = alt.Scale(
    domain=(
        0,
        # Not using `max()` for protection against outliers.
        plays_per_year_country["Quantity"].quantile(0.98),
    ),
    scheme="teals",
    type="linear",
)

legend = alt.Legend(
    title=None,
    titleFontSize=14,
    labelFontSize=12,
    orient="none",
    gradientLength=height / 3,
    gradientThickness=10,
    direction="vertical",
    fillColor="white",
    legendX=0,
    legendY=130,
    format="0,.0~s",
    tickCount=1,
)

choropleth = (
    alt.Chart(plays_per_year_country)
    .mark_geoshape(stroke="white")
    .encode(
        color=alt.Color("Quantity:Q", scale=color_scale, legend=legend),
        tooltip=[
            alt.Tooltip("Country:N", title="Country"),
            alt.Tooltip("Quantity:Q", title="Plays", format=",.0s"),
        ],
        strokeWidth=alt.condition(hover_country, alt.value(2), alt.value(0.5)),
    )
    .transform_filter(year_selection)
    .transform_lookup(
        lookup="Country numeric code",
        from_=alt.LookupData(source, "id", ["type", "properties", "geometry"]),
    )
    .project(type=projection)
    .properties(width="container", height=height)
    .add_params(hover_country)
)

map_with_bars = base + choropleth + num_countries_chart

map_with_bars = configure_chart(
    chart=map_with_bars,
)

map_with_bars.display()
Evolució de streams per país al llarg del temps

Les barres interactives mostren el nombre de paísos amb oients per any.

Carregant visualització…

És un mapa molt més colorit del que esperava. M’agrada imaginar a una persona de cada país escoltant la meva música, encara que sigui durant uns pocs segons. Ni de broma esperava que la meva música arribés a un públic tan ampli.

Quin és el servei que paga millor? I pitjor?

NOTA

Pot ser que les meves dades no siguin representatives de tot el sector del streaming. Tot i que un dels principals factors a l’hora de determinar la retribució són les decisions preses per alts executius, també influeixen altres variables, com el país, el nombre d’usuaris de pagament i el nombre total de streams durant un mes. A més, disposo de dades limitades de serveis com Tidal, Snapchat, o Amazon Prime. En l’últim gràfic es detallen els streams per servei.

Fes clic per veure el codi
mean_usd_per_service.select(["USD per stream", "Store"])

# Function to round to the first two non-zero decimal numbers.
def round_to_n_non_zero_decimal(input_float: float, n: float = 2) -> float:
    input_str = f"{input_float:.10f}"  # Use scientific notation to handle very small or large numbers.
    split_number = re.split("\\.", input_str)

    # If there's no decimal part or it's all zeros, return the input as it's already rounded.
    if len(split_number) == 1 or not split_number[1].strip("0"):
        return input_float

    integer, decimals = split_number
    n_decimal_zeroes = re.search("[1-9]", decimals).start()
    non_zero_decimals = decimals[n_decimal_zeroes:]
    scaled_decimal_for_rounding = int(non_zero_decimals) / 10 ** (
        len(non_zero_decimals) - n
    )
    new_non_zero_decimals_float = round(scaled_decimal_for_rounding, n)
    new_non_zero_decimals_str = str(new_non_zero_decimals_float).split(".")[0]
    new_zeroes = "".join((["0"] * n_decimal_zeroes))[
        len(new_non_zero_decimals_str) - n :
    ]
    new_decimals = new_zeroes + new_non_zero_decimals_str
    rounded_number_str = integer + "." + new_decimals
    return float(rounded_number_str)

# Apply rounding to USD per stream.
mean_usd_per_service = mean_usd_per_service.with_columns(
    col("USD per stream")
    .map_elements(round_to_n_non_zero_decimal)
    .alias("Rounded USD per stream")
)

# Graph.
bar_chart = (
    alt.Chart(mean_usd_per_service)
    .mark_bar(cornerRadiusTopRight=15, cornerRadiusBottomRight=15)
    .encode(
        x=alt.X(
            "USD per stream:Q",
            axis=alt.Axis(
                title=None,
                tickCount=5,
                labelExpr="datum.value === 0 ? '$0' : format(datum.value, '0,.5~f')",
            ),
            scale=alt.Scale(
                type="linear",
            ),
        ),
        y=alt.Y("Store:N", axis=alt.Axis(title=None), sort="-x"),
        color=alt.Color(
            "Store:N",
            scale=alt.Scale(domain=domain, range=range_),  # Use custom colours.
            legend=None,
        ),
    )
)

# Add total number next to the bar.
text_chart = bar_chart.mark_text(
    align="left",
    baseline="middle",
    dx=5,  # Move the text a bit to the right.
    fontSize=20,
    font="Monospace",
    fontWeight="bold",
    color="white",
).encode(text=alt.Text("Rounded USD per stream:Q", format=","))

average_pay_per_stream = bar_chart + text_chart

year_credits_text = create_year_credits_text(x=-170, y=570)
average_pay_per_stream = alt.layer(average_pay_per_stream, year_credits_text)

average_pay_per_stream = configure_chart(
    chart=average_pay_per_stream,
).properties(
    width="container",
    height=550,
    padding={"left": 0, "top": 0, "right": 30, "bottom": 0},
)

average_pay_per_stream.display()
Pagament mitjà per stream

Xifres arrodonides al primer parell de decimals diferents de zero.

Carregant visualització…

Uns quants zeros.

Esperava a Tidal al capdamunt, però no a Amazon Unlimited.

És interessant la diferència entre els usuaris de pagament d’Amazon Music (Amazon Unlimited) i els usuaris «gratuïts» (Amazon Prime). «Gratuït» entre cometes, perquè Amazon Prime no és gratis, però els usuaris no paguen extra per accedir a Amazon Music. Seria interessant comparar entre els usuaris de pagament i els usuaris gratuïts de Spotify, però no tinc accés a aquestes dades.

Em va sorprendre veure a Snapchat a la meitat superior. Esperava que TikTok i Meta no paguessin gaire: és més fàcil obtenir reproduccions, i els ingressos es comparteixen entre els autors dels vídeos i els músics. Tot i això, tinc la impressió que hi ha zeros de més en el cas de Meta, no?

Amb tants decimals, no és fàcil entendre les diferències. Vegem-ho d’una altra manera.

Quants streams necessito per aconseguir un dòlar?

Fes clic per veure el codi
# Melt is used to allow selecting mean/median in Vega-Altair.
streams_for_a_dollar = (
    df.group_by("Store")
    .agg(
        [
            streams_for_one_usd(pl.mean("USD per stream")).alias("mean"),
            streams_for_one_usd(pl.median("USD per stream")).alias("median"),
        ]
    )
    .melt(
        id_vars=["Store"],
        value_vars=["mean", "median"],
        value_name="Value",
        variable_name="Metric",
    )
)

# Graph.
metric_radio = alt.binding_radio(
    options=["mean", "median"], name="Calculation based on"
)
metric_select = alt.selection_point(fields=["Metric"], bind=metric_radio, value="mean")

bar_chart = (
    alt.Chart(streams_for_a_dollar)
    .mark_bar(cornerRadiusTopRight=15, cornerRadiusBottomRight=15)
    .encode(
        x=alt.X(
            "Value:Q",
            title="Streams to reach $1",
            axis=alt.Axis(title=None, format="s"),
            scale=alt.Scale(type="log"),
        ),
        y=alt.Y(
            "Store:N",
            title=None,
            sort=alt.EncodingSortField(field="Value", op="median", order="ascending"),
        ),
        opacity=alt.condition(metric_select, alt.value(1), alt.value(0)),
        color=alt.Color(
            "Store:N",
            scale=alt.Scale(domain=domain, range=range_),
            legend=None,
        ),
    )
    .add_params(metric_select)
)

# Add total number next to the bar.
text_chart = bar_chart.mark_text(
    align="left",
    baseline="middle",
    dx=5,
    fontSize=20,
    font="Monospace",
    fontWeight="bold",
).encode(
    text=alt.Text("Value:Q", format=","),
)

plays_for_one_dollar = bar_chart + text_chart

year_credits_text = create_year_credits_text(x=-170, y=570)
plays_for_one_dollar = alt.layer(plays_for_one_dollar, year_credits_text)

plays_for_one_dollar = configure_chart(
    chart=plays_for_one_dollar,
).properties(width=789, height=550)

plays_for_one_dollar.display()
Reproduccions necessàries per aconseguir 1 $

Escala logarítmica.

Carregant visualització…

Molt més clar.

L’eix horitzontal està en escala logarítmica per facilitar la comparació. En un gràfic lineal, les reproduccions de Meta farien desaparèixer les altres barres. En una escala logarítmica, la diferència entre 10 reproduccions i 100 reproduccions té la mateixa distància visual que la diferència entre 100 i 1.000 reproduccions.

Un dòlar per 100-400 reproduccions no sona gaire bé. En el pitjor dels casos, usant la taxa de pagament mitjana, necessitem gairebé tres milions de reproduccions en Meta per obtenir un dòlar estatunidenc.

Vols saber quantes reproduccions es necessiten per aconseguir el salari mínim? O un milió de dòlars? Amb aquestes dades he creat una calculadora de royalties de streams. Aquí tens una captura de pantalla:

Captura de pantalla de la calculadora de royalties per streaming Captura de pantalla de la calculadora de royalties per streaming

Distribució de pagaments per servei

Les plataformes de streaming no tenen una tarifa fixa. Factors com la ubicació geogràfica de l’usuari, el tipus de subscripció (de pagament o no) i el volum general de streaming de la regió afecten la taxa pagament.

Per tant, la mitjana no ho diu tot: vegem la dispersió dels pagaments al voltant d’aquesta mitjana. Varia en funció del servei?

Fes clic per veure el codi
store_payments = df.select(["Store", "USD per stream"])

# Drop top and bottom 1% of pay rate per store.
lower_quantile = 0.01
upper_quantile = 0.99
percentiles = df.group_by("Store").agg(
    [
        pl.col("USD per stream").quantile(lower_quantile).alias("lower_threshold"),
        pl.col("USD per stream").quantile(upper_quantile).alias("upper_threshold"),
    ]
)

trimmed_df = (
    df.join(percentiles, on="Store", how="left")
    .filter(
        (pl.col("USD per stream") >= pl.col("lower_threshold"))
        & (pl.col("USD per stream") <= pl.col("upper_threshold"))
    )
    .drop(["lower_threshold", "upper_threshold"])
)

jittered_scatter_plot = (
    alt.Chart(trimmed_df)
    .mark_circle(size=60, opacity=0.2)
    .encode(
        x=alt.X(
            "USD per stream:Q",
            axis=alt.Axis(
                title=None,
                labelExpr="datum.value === 0 ? '$0' : format(datum.value, '0,.5~f')",
            ),
        ),
        y=alt.Y(
            "Store:N",
            sort=alt.EncodingSortField(
                field="USD per stream", op="median", order="descending"
            ),
            axis=alt.Axis(title=None),
        ),
        color=alt.Color(
            "Store:N",
            scale=alt.Scale(domain=domain, range=range_),  # Use custom colours.
            legend=None,
        ),
        yOffset=alt.Y("jitter:Q", title=None),
    )
    .transform_calculate(jitter="sqrt(-2*log(random()))*cos(2*PI*random())")
)

# Add indicators for each store's median.
median_dots = (
    alt.Chart(trimmed_df)
    .mark_point(opacity=1, shape="diamond", filled=False, size=120)
    .encode(
        x="median(USD per stream):Q",
        y=alt.Y(
            "Store:N",
            sort=alt.EncodingSortField(
                field="USD per stream", op="median", order="descending"
            ),
            axis=None,
        ),
        color=alt.value("black"),
        tooltip=[
            alt.Tooltip("Store", title="Service"),
            alt.Tooltip("median(USD per stream)", title="Median pay rate"),
        ],
    )
)

final_plot_with_jitter = alt.layer(jittered_scatter_plot, median_dots).resolve_scale(
    y="independent"
)

year_credits_text = create_year_credits_text(x=-170, y=570)
final_plot_with_jitter = alt.layer(final_plot_with_jitter, year_credits_text)

final_plot_with_jitter = configure_chart(
    chart=final_plot_with_jitter,
).properties(width="container", height=550)

final_plot_with_jitter.display()
Distribució de pagaments per servei

Cada cercle representa un únic pagament. El rombe indica la mediana.

El gràfic exclou l'1% superior i inferior dels pagaments per servei per centrar-se en les dades més representatives.

Carregant visualització…

Els rangs d’Amazon Unlimited i Apple Music són enormes; inclouen la mediana de més de la meitat dels serveis.

Spotify, Saavn i Meta tenen molts pagaments propers a zero dòlars per reproducció. D’aquests tres, Spotify destaca en assolir pagaments de més de mig cèntim.

Pensant-ho bé, processant les dades vaig eliminar algunes (72) instàncies de Spotify i Meta (38) on pagaven infinits dòlars per reproducció. Eren entrades amb 0 reproduccions però ingressos no nuls —probablement ajustos de pagaments anteriors. En qualsevol cas, Spotify i Meta guanyen la medalla del rang més gran —infinit.

Comparació de la distribució de parells de serveis

He fet aquest petit gràfic interactiu per comparar la distribució de les taxes de pagament entre serveis:

Fes clic per veure el codi
# Prepare the data.
store_usd_per_stream = trimmed_df.select(["Store", "USD per stream"])

# Graph.
dropdown_store_1 = alt.binding_select(options=unique_stores, name="Service B ")
dropdown_store_2 = alt.binding_select(options=unique_stores, name="Service A ")
selection_store_1 = alt.selection_point(
    fields=["Store"], bind=dropdown_store_1, value="Spotify"
)
selection_store_2 = alt.selection_point(
    fields=["Store"], bind=dropdown_store_2, value="Apple Music"
)

min_usd_per_stream = trimmed_df["USD per stream"].min()
max_usd_per_stream = trimmed_df["USD per stream"].max()

base_density_chart = alt.Chart(store_usd_per_stream).transform_density(
    density="USD per stream",
    bandwidth=0.0004,
    extent=[min_usd_per_stream, max_usd_per_stream],
    groupby=["Store"],
    as_=["USD per stream", "Density"],
)

density_plot_1 = (
    base_density_chart.transform_filter(selection_store_1)
    .mark_area(opacity=0.6)
    .encode(
        x=alt.X(
            "USD per stream:Q",
            title=None,
            axis=alt.Axis(
                grid=False,
                labelExpr="datum.value === 0 ? '$0' : format(datum.value, '0,.5~f')",
            ),
        ),
        y=alt.Y("Density:Q", title=None, axis=None),
        color=alt.Color(
            "Store:N", legend=None, scale=alt.Scale(domain=domain, range=range_)
        ),
        tooltip=[alt.Tooltip("Store", title="Service")],
    )
    .add_params(selection_store_1)
)
density_plot_2 = (
    base_density_chart.transform_filter(selection_store_2)
    .mark_area(opacity=0.6)
    .encode(
        x=alt.X("USD per stream:Q", title=None),
        y=alt.Y("Density:Q", title=None, axis=None),
        color=alt.Color(
            "Store:N", legend=None, scale=alt.Scale(domain=domain, range=range_)
        ),
        tooltip=[alt.Tooltip("Store", title="Service")],
    )
    .add_params(selection_store_2)
)

overlaid_density_plots = alt.layer(density_plot_1, density_plot_2)

compare_distributions = configure_chart(
    chart=overlaid_density_plots,
).properties(width="container", height=200)

compare_distributions.display()
Carregant visualització…

Aquesta visualització utilitza l’estimació de densitat kernel per aproximar la distribució de pagaments per reproducció. És com un histograma suavitzat.

Els pics indiquen la concentració de les dades al voltant d’aquest valor.

Selecciona alguns serveis per comparar la dispersió, les superposicions i divergències, i el gruix de les cues (la curtosi). Pots esbrinar alguna política de pagaments?

Paga Apple Music 0,01 $ per stream?

El 2021, Apple va informar que pagaven, de mitjana, un cèntim de dòlar per reproducció.

Els gràfics anteriors mostren que la meva taxa mitjana de pagament d’Apple Music no s’apropa a un cèntim per stream. Està més a prop de mig cèntim.

Fes clic per veure el codi
apple_music_df = df.filter(col("Store") == "Apple Music")
# Percentiles y otras estadísticas.
apple_music_df.select("USD per stream").describe()

total_apple_music_streams = apple_music_df.count().select("Quantity").item()
streams_over_80_percent_apple_music = (
    apple_music_df.filter(col("USD per stream") > 0.008)
    .count()
    .select("Quantity")
    .item()
)

Observant la totalitat de les dades (2017 a 2023) em vaig adonar que:

  • Tres quarts de totes les reproduccions van tenir una taxa de pagament per sota de 0,006 $ (el percentil 75);
  • Menys del 15% dels meus pagaments superen 0,008 $ (80% d’un cèntim).

En el meu cas, la resposta és «no». No obstant això, una mitjana reportada no és una promesa; hi haurà altres artistes amb una taxa de pagament promig superior a 0,01 $.

Quant hauria perdut amb el nou sistema de royalties de Spotify?

A partir d’aquest any, el sistema de royalties «modernitzat» de Spotify pagarà 0 $ per cançons amb menys de mil reproduccions a l’any.

Si aquest model s’hagués implementat fa set anys, estic segur que hauria perdut una gran part dels pagaments de Spotify.

Fes clic per veure el codi
spotify_df = df.filter(col("Store") == "Spotify")
grouped_spotify = (
    spotify_df.group_by(["Year", "Song"])
    .sum()
    .select(["Year", "Song", "Quantity", "Earnings"])
    .sort("Quantity", descending=False)
)

spotify_per_year = (
    grouped_spotify.group_by("Year")
    .sum()
    .select(["Year", "Quantity", "Earnings"])
    .sort("Year")
)

unpaid_streams = grouped_spotify.filter(col("Quantity") < 1000)
unpaid_streams_per_year = (
    unpaid_streams.group_by("Year")
    .sum()
    .select(["Year", "Quantity", "Earnings"])
    .sort("Year")
)

spotify_yearly_earnings = spotify_per_year.join(
    other=unpaid_streams_per_year,
    on="Year",
    how="left",
    suffix="_unpaid",
)

unpaid_spotify = spotify_yearly_earnings.with_columns(
    (col("Quantity_unpaid") / col("Quantity") * 100).alias("Unpaid streams %"),
    (col("Earnings_unpaid") / col("Earnings") * 100).alias("Unpaid USD %"),
)

unpaid_spotify_dollars = unpaid_spotify.select(pl.sum("Earnings_unpaid")).item()
print(
    f'Had the "modernized" royalty system been applied for the last {spotify_per_year.height} years, I would\'ve missed out on USD {round(unpaid_spotify_dollars, 2)},'
)
print(
    f"which represents {round(unpaid_spotify_dollars / spotify_per_year.select(pl.sum('Earnings')).item() * 100, 2)}% of my Spotify earnings."
)

Efectivament. El nou sistema m’hauria privat de 112,01 dòlars. Això és el 78,7% dels meus pagaments de Spotify.

Aquests ingressos —generats per les meves cançons— en lloc d’arribar-me a mi, s’haurien distribuït entre els altres «tracks elegibles».

Les cançons més llargues paguen més?

Fes clic per veure el codi
# Spearman correlation.
df.select(pl.corr("Duration", "USD per stream", method="spearman"))
# Returns -0.013554

# Pearson correlation.
df.select(pl.corr("Duration", "USD per stream", method="pearson"))
# Returns 0.011586

No. En les meves dades, la durada de la cançó no té correlació amb la taxa de pagament per stream.

CONSIDERACIÓ

Totes les meves improvisacions són més aviat breus; no hi ha gaire rang en termes de durada. La meva cançó més curta, tíunda, dura 44 segons. La més «llarga», sextánda, dura 3 minuts i 19 segons.

Existeix una relació entre el nombre de reproduccions i la taxa de pagament, a les xarxes socials?

Recordo llegir que el model de pagament de TikTok recompensava més generosament l’ús d’una cançó en diversos vídeos que un únic vídeo amb moltes reproduccions.

Per comprovar si això era així en TikTok i Meta, vaig calcular la correlació entre dos parells de variables: nombre de reproduccions i taxa de pagament, i nombre de reproduccions i ingressos totals.

Si el que vaig llegir és cert, l’esperable és que la taxa de pagament decreixi a mesura que augmenta el nombre de visualitzacions (correlació negativa), i que hi hagi una baixa correlació entre el total de reproduccions i els ingressos.

Fes clic per veure el codi and correlation coefficients
metrics_pairs = [("Quantity", "USD per stream"), ("Quantity", "Earnings")]
stores = ["Meta", "TikTok"]

correlation_results = {}

for store in stores:
    filtered_data = df.filter(pl.col("Store") == store)
    for metrics_pair in metrics_pairs:
        # Calculate Pearson correlation.
        pearson_corr = filtered_data.select(
            pl.corr(metrics_pair[0], metrics_pair[1], method="pearson")
        ).item()
        # Calculate Spearman correlation.
        spearman_corr = filtered_data.select(
            pl.corr(metrics_pair[0], metrics_pair[1], method="spearman")
        ).item()

        # Store the results.
        correlation_results[f"{store} {metrics_pair[0]}-{metrics_pair[1]} Pearson"] = (
            pearson_corr
        )
        correlation_results[f"{store} {metrics_pair[0]}-{metrics_pair[1]} Spearman"] = (
            spearman_corr
        )

for key, value in correlation_results.items():
    print(f"{key}: {round(value, 2)}")

# Returns:
# Meta Quantity-USD per stream Pearson: -0.01
# Meta Quantity-USD per stream Spearman: 0.5
# Meta Quantity-Earnings Pearson: 0.43
# Meta Quantity-Earnings Spearman: 0.93
# TikTok Quantity-USD per stream Pearson: 0.02
# TikTok Quantity-USD per stream Spearman: -0.06
# TikTok Quantity-Earnings Pearson: 0.98
# TikTok Quantity-Earnings Spearman: 0.85

En el cas de TikTok, com més reproduccions, més grans són els ingressos totals (correlació lineal positiva molt forta). En el cas de Meta, també existeix una correlació robusta, però menys lineal.

La taxa de pagament de TikTok sembla ser independent del nombre de reproduccions. En el cas de Meta, sembla haver-hi una correlació no lineal moderada.

En conclusió, el nombre total de reproduccions és el factor rellevant a l’hora de determinar els ingressos, per sobre del nombre de vídeos que utilitzen una cançó.

Quins són els països amb la taxa de pagament més alta i més baixa?

Fes clic per veure el codi
n = 5
col_of_interest = "Country"

mean_usd_per_stream_per_country = (
    df.group_by(col_of_interest)
    .mean()
    .select(["USD per stream", col_of_interest])
    .with_columns(
        streams_for_one_usd(col("USD per stream")).alias("Streams to reach $1")
    )
    .sort("USD per stream", descending=True)
)

print(f"Top {n} best paying countries:")
display(mean_usd_per_stream_per_country.head(n))
print(f"Top {n} worst paying countries:")
display(mean_usd_per_stream_per_country.tail(n).reverse())

Cinc països amb el millor pagament mitjà:

PaísStreams per aconseguir 1 $
🇲🇴 Macau212
🇯🇵 Japó220
🇬🇧 Regne Unit237
🇱🇺 Luxemburg237
🇨🇭 Suïssa241

Cinc països amb el pitjor pagament mitjà:

PaísStreams per aconseguir 1 $
🇸🇨 Seychelles4.064.268
🇱🇸 Lesotho4.037.200
🇫🇷 Guaiana Francesa3.804.970
🇱🇮 Liechtenstein3.799.514
🇲🇨 Mònaco3.799.196

Altres factors com el servei podrien estar afectant els resultats: i si la majoria dels usuaris dels països amb millor taxa de pagament resulten estar usant un servei que paga millor, i viceversa?

Seria interessant construir un model lineal jeràrquic per estudiar la variabilitat de cada nivell (servei i país). Idea per un altre dia.

Per ara, l’estratificació ens servirà. Vaig agrupar les dades per servei i país, obtenint les cinc combinacions de país-servei amb taxes de pagament més altes i baixes.

Fes clic per veure el codi
mean_usd_per_stream_per_country_service = (
    df.group_by(["Country", "Store"])
    .mean()
    .select(["USD per stream", "Country", "Store"])
    .with_columns(
        streams_for_one_usd(col("USD per stream")).alias("Streams to reach $1")
    )
    .sort("USD per stream", descending=True)
)

print(f"Top {n} best paying countries and services:")
display(mean_usd_per_stream_per_country_service.head(n))

print(f"Top {n} worst paying countries and services:")
display(mean_usd_per_stream_per_country_service.tail(n).reverse())

Cinc combinacions de país-servei amb el millor pagament mitjà:

PaísServeiStreams per aconseguir 1 $
🇬🇧 Regne UnitAmazon Unlimited60
🇮🇸 IslàndiaApple Music67
🇺🇸 Estats UnitsTidal69
🇺🇸 Estats UnitsAmazon Unlimited79
🇳🇱 Països BaixosApple Music84

Cinc combinacions de país-servei amb el pitjor pagament mitjà:

PaísServeiStreams per aconseguir 1 $
🇸🇨 SeychellesMeta4.064.268
🇱🇸 LesothoMeta4.037.200
🇮🇸 IslàndiaMeta3.997.995
🇫🇷 Guaiana FrancesaMeta3.804.970
🇱🇮 LiechtensteinMeta3.799.514

Que la taxa de pagament de Meta sigui pràcticament zero no ajuda. Si filtrem aquest servei, obtenim:

PaísServeiStreams per aconseguir 1 $
🇬🇭 GhanaSpotify1.000.000
🇪🇸 EspanyaDeezer652.153
🇸🇻 El SalvadorDeezer283.041
🇰🇿 KazakhstanSpotify124.572
🇪🇬 EgipteSpotify20.833

És important notar que el preu de la subscripció a serveis com Spotify és diferent en cada país. Spotify Premium costa ~1,30 $ a Ghana i uns 16 $ a Dinamarca.

En un món menys desigual, aquesta comparació tindria menys sentit.

Com ha canviat la distribució de streams i ingressos entre serveis al llarg del temps?

Fes clic per veure el codi
width = 896
height = 200
mouseover_highlight = alt.selection_point(
    fields=["Store"],
    on="mouseover",
    clear="mouseout",
)
filter_selection = alt.selection_point(
    fields=["Store"],
    bind="legend",
    toggle="true",
)
# To hide non-music-streaming services.
music_streaming_checkbox = alt.binding_checkbox(name=filter_social_media_label)
checkbox_selection = alt.selection_point(
    bind=music_streaming_checkbox, fields=["IsMusicStreaming"], value=False
)

# First and last date, to keep the X axis fixed when filtering.
min_sale, max_sale = map(str, (df["Sale"].min(), df["Sale"].max()))

base_chart = (
    alt.Chart(sales_per_store_time)
    .transform_filter(
        filter_selection & (checkbox_selection | (alt.datum.IsMusicStreaming == True))
    )
    .transform_joinaggregate(
        TotalQuantity="sum(Quantity)", TotalEarnings="sum(Earnings)", groupby=["Sale"]
    )
    .transform_calculate(
        PercentageQuantity="datum.Quantity / datum.TotalQuantity",
        PercentageEarnings="datum.Earnings / datum.TotalEarnings",
    )
    .add_params(filter_selection, mouseover_highlight, checkbox_selection)
)

areachart_quantity = (
    base_chart.mark_area()
    .encode(
        x=alt.X(
            "yearmonth(Sale):T", axis=None, scale=alt.Scale(domain=(min_sale, max_sale))
        ),
        y=alt.Y(
            "PercentageQuantity:Q",
            stack="center",
            axis=alt.Axis(
                title="Streams",
                labelExpr="datum.value === 1 ? format(datum.value * 100, '') + '%' : format(datum.value * 100, '')",
                titleFontSize=16,
                titlePadding=15,
                titleColor="gray",
            ),
        ),
        color=alt.Color("Store:N", scale=alt.Scale(domain=domain, range=range_)),
        fillOpacity=alt.condition(mouseover_highlight, alt.value(1), alt.value(0.4)),
        tooltip=[
            alt.Tooltip("Store:N", title="Service"),
            alt.Tooltip("yearmonth(Sale):T", title="Date", format="%B %Y"),
            alt.Tooltip("PercentageQuantity:Q", title="% of streams", format=".2%"),
            alt.Tooltip("PercentageEarnings:Q", title="% of payments", format=".2%"),
        ],
    )
    .properties(width=width, height=height)
)

areachart_earnings = (
    base_chart.mark_area()
    .encode(
        x=alt.X(
            "yearmonth(Sale):T",
            axis=alt.Axis(
                domain=False, format="%Y", tickSize=0, tickCount="year", title=None
            ),
            scale=alt.Scale(domain=(min_sale, max_sale)),
        ),
        y=alt.Y(
            "PercentageEarnings:Q",
            stack="center",
            axis=alt.Axis(
                title="Earnings",
                labelExpr="datum.value === 1 ? format(datum.value * 100, '') + '%' : format(datum.value * 100, '')",
                tickCount=4,
                titleFontSize=16,
                titlePadding=15,
                titleColor="gray",
            ),
        ),
        color=alt.Color("Store:N", scale=alt.Scale(domain=domain, range=range_)),
        fillOpacity=alt.condition(mouseover_highlight, alt.value(1), alt.value(0.4)),
        tooltip=[
            alt.Tooltip("Store:N", title="Service"),
            alt.Tooltip("yearmonth(Sale):T", title="Date", format="%B %Y"),
            alt.Tooltip("PercentageQuantity:Q", title="% of streams", format=".2%"),
            alt.Tooltip("PercentageEarnings:Q", title="% of payments", format=".2%"),
        ],
    )
    .properties(width=width, height=height)
)

combined_areachart = alt.vconcat(
    areachart_quantity, areachart_earnings, spacing=10
).resolve_scale(x="shared")

combined_areachart = configure_chart(
    chart=combined_areachart,
    legend_position="top",
    legend_columns=7,
)

combined_areachart.display()
Percentatge de streams i ingressos per servei al llarg del temps
Carregant visualització…

Quants colors! Pots filtrar els serveis fent clic a la llegenda. En passar el ratolí sobre un color, veuràs més detalls com el servei que representa, la data i el percentatge de reproduccions i ingressos associats.

Em sembla interessant que, a partir de juliol de 2019, Meta comença a dominar en termes de streams, però no en ingressos. Durant uns mesos, malgrat representar consistentment més del 97% dels streams, genera menys del 5% dels ingressos. No és fins a abril de 2022 que comença a equilibrar-se.

En filtrar les dades de xarxes socials (Facebook, Instagram, TikTok i Snapchat) usant la casella sota els gràfics, veiem un patró similar però menys obvi amb NetEase a partir de mitjans de 2020.

Com han evolucionat les reproduccions i ingressos totals per servei al llarg del temps?

Centrem-nos ara en els nombres bruts. Aquí tenim un parell de gràfics de barres clàssics anàlegs als gràfics d’àrees de la secció anterior.

Fes clic per veure el codi
height = 200
width = 893

base_chart = (
    alt.Chart(sales_per_store_time)
    .transform_filter(
        filter_selection & (checkbox_selection | (alt.datum.IsMusicStreaming == True))
    )
    .transform_filter(filter_selection)
    .transform_window(
        TotalQuantity="sum(Quantity)", TotalEarnings="sum(Earnings)", groupby=["Sale"]
    )
    .transform_calculate(
        USD_per_Stream="datum.Earnings / datum.Quantity",
        PercentOfStreams="datum.Quantity / datum.TotalQuantity",
        PercentOfEarnings="datum.Earnings / datum.TotalEarnings",
    )
)

quantity_bar_chart = (
    base_chart.mark_bar()
    .encode(
        x=alt.X(
            "yearmonth(Sale):T", axis=None, scale=alt.Scale(domain=(min_sale, max_sale))
        ),
        y=alt.Y(
            "Quantity:Q",
            axis=alt.Axis(
                format="0,.2~s",
                title="Streams",
                tickCount=4,
                titleFontSize=16,
                titlePadding=15,
                titleColor="gray",
            ),
        ),
        color=alt.Color("Store:N", scale=alt.Scale(domain=domain, range=range_)),
        opacity=alt.condition(mouseover_highlight, alt.value(1), alt.value(0.4)),
        tooltip=[
            alt.Tooltip("Store:N", title="Service"),
            alt.Tooltip("yearmonth(Sale):T", title="Date", format="%B %Y"),
            alt.Tooltip("Quantity:Q", title="Streams", format="0,.2~f"),
            alt.Tooltip("Earnings:Q", title="Earnings", format="$0,.2~f"),
            alt.Tooltip("USD_per_Stream:Q", title="$ per stream", format="$,.2r"),
            alt.Tooltip("PercentOfStreams:Q", title="% of streams", format=".2%"),
            alt.Tooltip("PercentOfEarnings:Q", title="% of payments", format=".2%"),
        ],
    )
    .add_params(filter_selection, mouseover_highlight, checkbox_selection)
)

earnings_bar_chart = (
    base_chart.mark_bar()
    .encode(
        x=alt.X(
            "yearmonth(Sale):T",
            axis=alt.Axis(
                format="%Y", tickCount=alt.TimeInterval("year"), title=None, grid=False
            ),
            scale=alt.Scale(domain=(min_sale, max_sale)),
        ),
        y=alt.Y(
            "Earnings:Q",
            axis=alt.Axis(
                format="0,.2~f",
                title="Earnings (USD)",
                tickCount=4,
                titleFontSize=16,
                titlePadding=15,
                titleColor="gray",
            ),
        ),
        color=alt.Color("Store:N", scale=alt.Scale(domain=domain, range=range_)),
        opacity=alt.condition(mouseover_highlight, alt.value(1), alt.value(0.4)),
        tooltip=[
            alt.Tooltip("Store:N", title="Service"),
            alt.Tooltip("yearmonth(Sale):T", title="Date", format="%B %Y"),
            alt.Tooltip("Quantity:Q", title="Streams", format="0,.2~f"),
            alt.Tooltip("Earnings", title="Paid", format="$0,.2~f"),
            alt.Tooltip("USD_per_Stream:Q", title="$ per stream", format="$,.2r"),
            alt.Tooltip("PercentOfStreams:Q", title="% of streams", format=".2%"),
            alt.Tooltip("PercentOfEarnings:Q", title="% of payments", format=".2%"),
        ],
    )
    .add_params(filter_selection, mouseover_highlight, checkbox_selection)
)

# Set dimensions.
quantity_bar_chart = quantity_bar_chart.properties(
    height=height,
    width=width,
)
earnings_bar_chart = earnings_bar_chart.properties(
    height=height,
    width=width,
)

# Join the graphs.
streams_and_earnings_by_service_over_time = alt.vconcat(
    quantity_bar_chart,
    earnings_bar_chart,
    spacing=20,
)

streams_and_earnings_by_service_over_time = configure_chart(
    chart=streams_and_earnings_by_service_over_time,
    legend_position="top",
    legend_columns=7,
)

streams_and_earnings_by_service_over_time.display()
Streams i ingressos totals per servei al llarg del temps

Fes clic a la llegenda per mostrar/ocultar serveis específics.

Carregant visualització…

De nou es fa evident la divergència entre reproduccions i ingressos de Meta.

La magnitud dels números provinents d’Instagram/Facebook fa que sembli que hi hagi zero reproduccions abans de juliol de 2019. Excloure les dades de xarxes socials actualitza l’eix vertical i canvia la història.

Aquests gràfics mostren una cosa que no podíem veure abans: el creixement brut.

El febrer de 2022 vaig llançar el meu segon àlbum, II. Poc després, les reproduccions a Meta es van disparar; una de les improvisacions d’aquest àlbum, hvítur, va guanyar popularitat allà.

L’augment de reproduccions després del llançament de l’àlbum és menys evident en els serveis de streaming de música.

Com és la distribució de reproduccions i ingressos entre les cançons?

Potser unes poques pistes obtenen la majoria de les reproduccions? Coincideix el rànquing de reproduccions i ingressos?

Fes clic per veure el codi
# Prepare the data for the graph.
song_quantity_per_store = (
    df.group_by(["Song", "Store"])
    .agg(col("Quantity").sum(), col("IsMusicStreaming").first())
    .sort("Quantity", descending=True)
)

default_show_n = 10
num_unique_songs = len(df["Song"].unique())

element_slider = alt.binding_range(
    min=1, max=num_unique_songs, step=1, name="Show only top "
)
slider_selection = alt.selection_point(
    fields=["N"], bind=element_slider, value=default_show_n
)

song_streams_chart = (
    alt.Chart(song_quantity_per_store)
    .mark_bar()
    .transform_filter(
        filter_selection & (checkbox_selection | (alt.datum.IsMusicStreaming == True))
    )
    .transform_joinaggregate(TotalStreamsPerSong="sum(Quantity)", groupby=["Song"])
    .transform_window(
        rank="dense_rank()",
        sort=[alt.SortField("TotalStreamsPerSong", order="descending")],
    )
    .transform_filter(alt.datum.rank <= slider_selection.N)
    .encode(
        x=alt.X(
            "Song:N",
            sort="-y",
            axis=alt.Axis(title=None, labelLimit=85),
        ),
        y=alt.Y(
            "sum(Quantity):Q",
            axis=alt.Axis(
                format="0,.2~s",
                title="Streams",
                tickCount=4,
                titleFontSize=16,
                titlePadding=15,
                titleColor="gray",
            ),
        ),
        color=alt.Color("Store:N", scale=alt.Scale(domain=domain, range=range_)),
        tooltip=[
            alt.Tooltip("Song:N", title="Song"),
            alt.Tooltip("TotalStreamsPerSong:Q", title="Total plays", format="0,.4~s"),
            alt.Tooltip("Store:N", title="Service"),
            alt.Tooltip("sum(Quantity):Q", title="Service plays", format="0,.4~s"),
        ],
    )
    .add_params(filter_selection, checkbox_selection, slider_selection)
)

# Prepare the data.
song_earnings_per_store = (
    df.group_by(["Song", "Store"])
    .agg(col("Earnings").sum(), col("IsMusicStreaming").first())
    .sort("Earnings", descending=True)
)
song_earnings_per_store.head(2)

song_earnings_chart = (
    alt.Chart(song_earnings_per_store)
    .mark_bar()
    .transform_filter(
        filter_selection & (checkbox_selection | (alt.datum.IsMusicStreaming == True))
    )
    .transform_joinaggregate(TotalStreamsPerSong="sum(Earnings)", groupby=["Song"])
    .transform_window(
        rank="dense_rank()",
        sort=[alt.SortField("TotalStreamsPerSong", order="descending")],
    )
    .transform_filter(alt.datum.rank <= slider_selection.N)
    .encode(
        x=alt.X(
            "Song:N",
            sort="-y",
            axis=alt.Axis(title=None, labelLimit=85),
        ),
        y=alt.Y(
            "sum(Earnings):Q",
            axis=alt.Axis(
                title="Earnings (USD)",
                tickCount=4,
                titleFontSize=16,
                titlePadding=15,
                titleColor="gray",
            ),
        ),
        color=alt.Color("Store:N", scale=alt.Scale(domain=domain, range=range_)),
        tooltip=[
            alt.Tooltip("Song:N", title="Song"),
            alt.Tooltip(
                "TotalStreamsPerSong:Q", title="Total earnings", format="$0,.2~f"
            ),
            alt.Tooltip("Store:N", title="Service"),
            alt.Tooltip("sum(Earnings):Q", title="Service earnings", format="$0,.2~f"),
        ],
    )
    .add_params(filter_selection, checkbox_selection, slider_selection)
)

# Stack 'em.
width = 905
height = 200
song_streams_chart = song_streams_chart.properties(width=width, height=height)
song_earnings_chart = song_earnings_chart.properties(width=width, height=height)
total_song_earnings_streams_chart = alt.vconcat(song_streams_chart, song_earnings_chart)

total_song_earnings_streams_chart = configure_chart(
    chart=total_song_earnings_streams_chart,
    legend_position="top",
    legend_columns=7,
)

total_song_earnings_streams_chart.display()
Total de streams i ingressos per cançó
Carregant visualització…

Per a mi, aquesta és una de les visualitzacions més interessants; està plena d’informació.

  • Només dues de les cinc cançons més reproduïdes coincideixen entre les dades de xarxes socials i els serveis de streaming de música.
  • hvítur, la cançó amb més de reproduccions (més de 34 milions!) és número u gràcies a Meta. Si filtrem les reproduccions de xarxes socials, ni tan sols està al top 20.
  • El rànquing de reproduccions no encaixa consistentment amb el rànquing d’ingressos. Com a exemple clar, la cinquena cançó més reproduïda, We Don’t —una col·laboració amb Avstånd (anteriorment Bradycardia)— es troba a prop del final en termes d’ingressos.
  • El color de les barres ajuda a explicar aquesta divergència en reproduccions i ingressos. A més, mostra que la popularitat de les improvisacions difereix per servei.

Explorem més de prop els colors (els serveis).

Un petit nombre de serveis és responsable de la majoria dels ingressos i reproduccions?

Fes clic per veure el codi
# Prepare the data for plotting (and pareto checking).
store_earnings = (
    df.group_by("Store").agg(col("Earnings").sum()).sort("Earnings", descending=True)
)
total_earnings = store_earnings["Earnings"].sum()
store_earnings_cumulative_percentage = store_earnings.with_columns(
    (col("Earnings").cum_sum() / total_earnings).alias("Cumulative percentage")
)
store_earnings_cumulative_percentage.head()

# Explicit sort order for the plots.
sort_order = list(store_earnings_cumulative_percentage["Store"])

num_unique_stores = len(df["Store"].unique())
element_slider = alt.binding_range(
    min=1, max=num_unique_stores, step=1, name="Show only top "
)
slider_selection = alt.selection_point(fields=["N"], bind=element_slider, value=5)

# Show/hide pareto part of the chart.
pareto_checkbox = alt.binding_checkbox(name="Pareto chart")
show_hide_pareto = alt.param(name="show_pareto", bind=pareto_checkbox, value=False)
pareto_elements_opacity = alt.condition(show_hide_pareto, alt.value(1), alt.value(0))

# Filter data for the charts.
filtered_data = (
    alt.Chart(store_earnings_cumulative_percentage)
    .transform_window(
        rank="dense_rank()",
        sort=[alt.SortField("Earnings", order="descending")],
    )
    .transform_filter((alt.datum.rank <= slider_selection.N))
    .encode(
        x=alt.X(
            "Store:N",
            sort=sort_order,
            axis=alt.Axis(
                title=None,
            ),
        )
    )
    .add_params(slider_selection)
)

# Bar chart.
earnings_per_store = filtered_data.mark_bar().encode(
    y=alt.Y(
        "Earnings:Q",
        axis=alt.Axis(
            title="Earnings (USD)",
            format="0,.2~f",
            tickCount=2,
            titleFontSize=16,
            titlePadding=15,
            titleColor="gray",
        ),
        scale=alt.Scale(type="log"),
    ),
    color=alt.Color(
        "Store:N", scale=alt.Scale(domain=domain, range=range_), legend=None
    ),
)

# Create the text labels based on the filtered data.
bars_text = earnings_per_store.mark_text(
    align="center",
    baseline="bottom",
    dy=-5,
    fontSize=20,
    font="Monospace",
    fontWeight="bold",
).encode(text=alt.Text("Earnings:Q", format="$,.2f"))

# Combine the bar chart with the text labels.
earnings_per_store_with_labels = earnings_per_store + bars_text

# Pareto line.
pareto_colour = "#1e2933"
cumulative_percentage_line = filtered_data.mark_line(
    color=pareto_colour, size=3
).encode(
    y=alt.Y(
        "Cumulative percentage:Q",
        axis=None,
        scale=alt.Scale(domain=[0, 1]),
    ),
    opacity=pareto_elements_opacity,
)

cumulative_percentage_points = filtered_data.mark_circle(
    size=42, color=pareto_colour
).encode(y=alt.Y("Cumulative percentage:Q", axis=None), opacity=pareto_elements_opacity)

pareto_text = filtered_data.mark_text(
    align="center",
    baseline="bottom",
    dy=-12,
    fontSize=18,
    font="Monospace",
    fontWeight="bold",
    color=pareto_colour,
).encode(
    y=alt.Y("Cumulative percentage:Q", title=None),
    text=alt.Text("Cumulative percentage:Q", format=".1%"),
    opacity=pareto_elements_opacity,
)

pareto_plot = alt.layer(
    cumulative_percentage_line,
    cumulative_percentage_points,
    pareto_text,
)

# Combine charts.
earnings_per_store_pareto = (
    alt.layer(earnings_per_store_with_labels, pareto_plot)
    .resolve_scale(y="independent")
    .add_params(show_hide_pareto)
)

# Prepare the data for plotting (and pareto checking).
store_quantity = (
    df.group_by("Store").agg(col("Quantity").sum()).sort("Quantity", descending=True)
)
total_quantity = store_quantity["Quantity"].sum()
store_quantity_cumulative_percentage = store_quantity.with_columns(
    (col("Quantity").cum_sum() / total_quantity).alias("Cumulative percentage")
)
store_quantity_cumulative_percentage.head()

# Explicit sort order for the plots.
sort_order = list(store_quantity_cumulative_percentage["Store"])

# Filter data for the charts.
filtered_data = (
    alt.Chart(store_quantity_cumulative_percentage)
    .transform_window(
        rank="dense_rank()",
        sort=[alt.SortField("Quantity", order="descending")],
    )
    .transform_filter((alt.datum.rank <= slider_selection.N))
    .encode(x=alt.X("Store:N", sort=sort_order, title=None))
    .add_params(slider_selection)
)

# Bar chart.
quantity_per_store = filtered_data.mark_bar().encode(
    y=alt.Y(
        "Quantity:Q",
        axis=alt.Axis(
            title="Streams",
            format="s",
            tickCount=4,
            labelLimit=85,
            titleFontSize=16,
            titlePadding=15,
            titleColor="gray",
        ),
        scale=alt.Scale(type="log"),
    ),
    color=alt.Color(
        "Store:N", scale=alt.Scale(domain=domain, range=range_), legend=None
    ),
)

# Create the text labels based on the filtered data.
bars_text = quantity_per_store.mark_text(
    align="center",
    baseline="bottom",
    dy=-5,
    fontSize=20,
    font="Monospace",
    fontWeight="bold",
).encode(text=alt.Text("Quantity:Q", format="0,.2~f"))

# Combine the bar chart with the text labels.
quantity_per_store_with_labels = quantity_per_store + bars_text

# Pareto line.
pareto_colour = "#1e2933"
cumulative_percentage_line = filtered_data.mark_line(
    color=pareto_colour, size=3
).encode(
    y=alt.Y(
        "Cumulative percentage:Q",
        title=None,
        scale=alt.Scale(domain=[0, 1]),
    ),
    opacity=pareto_elements_opacity,
)

cumulative_percentage_points = filtered_data.mark_circle(
    size=42, color=pareto_colour
).encode(y=alt.Y("Cumulative percentage:Q", axis=None), opacity=pareto_elements_opacity)

pareto_text = filtered_data.mark_text(
    align="center",
    baseline="bottom",
    dy=-12,
    fontSize=18,
    font="Monospace",
    fontWeight="bold",
    color=pareto_colour,
).encode(
    y=alt.Y("Cumulative percentage:Q", title=None),
    text=alt.Text("Cumulative percentage:Q", format=".1%"),
    opacity=pareto_elements_opacity,
)

pareto_plot = alt.layer(
    cumulative_percentage_line,
    cumulative_percentage_points,
    pareto_text,
)

# Combine charts.
streams_per_store_pareto = (
    alt.layer(quantity_per_store_with_labels, pareto_plot)
    .resolve_scale(y="independent")
    .add_params(show_hide_pareto)
)

# Stack the two plots.
width = 896
height = 200

earnings_per_store_pareto = earnings_per_store_pareto.properties(
    width=width, height=height
)
streams_per_store_pareto = streams_per_store_pareto.properties(
    width=width, height=height
)

total_streams_earnings_pareto_stacked = alt.vconcat(
    streams_per_store_pareto, earnings_per_store_pareto
)

total_streams_earnings_pareto_stacked = configure_chart(
    chart=total_streams_earnings_pareto_stacked,
)

total_streams_earnings_pareto_stacked.display()
Distribució de reproduccions i ingressos: una anàlisi de Pareto

Escala logarítmica.

Carregant visualització…

Marcar la casella de “Diagrama de Pareto” superposa línies corresponents al percentatge acumulat de reproduccions i ingressos totals. Això ens permet veure quins serveis generen la majoria dels resultats.

Les visualitzacions anteriors ens donaven una pista, però aquesta reforça el desequilibri entre les taxes de pagament. Malgrat estar en primera posició en ambdós gràfics, Meta té un avantatge importantíssim en termes de nombre de reproduccions (>99%), però no en pagaments (~52%). En contraposició, Apple Music ocupa el quart lloc en reproduccions, però el segon en ingressos.

Un únic servei, Meta, és responsable de més del 99% de les reproduccions. En termes de pagaments, aproximadament el 20% de les botigues (Meta, Apple Music i Spotify) generen aproximadament el 90% dels ingressos, acostant-se més al principi de Pareto o regla dels «pocs vitals, molts trivials».


Creixement i gratitud

He après molt amb aquesta anàlisi. He gaudit jugant amb polars i Vega-Altair. polars ha brillat pel seu rendiment i per la claredat de la seva sintaxi. Vega-Alair m’ha permès crear visualitzacions atractives que expliquen la història darrere les xifres, revelant l’abast global de la meva música alhora que il·luminava una mica l’economia de la indústria del streaming.

Ara ja tinc una resposta concreta per quan els meus amics em preguntin «quant paga Spotify?». «Necessites 400 streams per guanyar un dòlar», respondré.

Més enllà de l’aprenentatge adquirit, em sento profundament agraït.

D’un teclat d’una sola octava a descobrir que podia improvisar, a set anys compartint la meva música en més de 170 països i acumulant més de 130 milions de reproduccions.

Si, set anys enrera, algú m’hagués dit que la meva música arribaria a tantes persones, no l’hauria cregut.

La meva àvia potser sí.