Mis padres me regalaron mi primer teclado de piano cuando tenía cuatro años. Era pequeño, de una sola octava, pero fue suficiente para que empezara a crear.
Años más tarde, descubrí que podía improvisar. Estaba intentando tocar de oído la introducción de Lose Yourself, de Eminem, cuando me di cuenta de que podía seguir tocando los acordes con la mano izquierda y dar libertad a la derecha.
Empecé a grabar estas improvisaciones y las compartí con amigos cercanos y familiares. Mi abuela, muy seria, me dijo que «sería muy egoísta no compartir mi talento con el mundo».
Unos meses después de su muerte, publiqué mi primer álbum. La doceava pista, tólfta (fyrir ömmu), es una improvisación que grabé para ella cuando estaba en el hospital.
Hoy hace siete años ya. Siete años de datos: números de streaming, royalties, oyentes… Tenía curiosidad: ¿a cuántos países ha llegado mi música? ¿Cuántas veces se ha reproducido cada canción y de dónde vienen mis ingresos? ¿Y cuánto pagan Spotify, Apple Music, TikTok e Instagram por cada stream?
Haz click para ver el índice
Los datos
Mi música está disponible prácticamente en todas partes, desde servicios de streaming regionales como JioSaavn (India) o NetEase Cloud Music (China) hasta Amazon Music, Apple Music, Spotify, Tidal… Incluso se puede añadir a vídeos de TikTok e Instagram/Facebook.
Distribuyo mi música a través de DistroKid (enlace de referral), que me permite quedarme con el 100% de los pagos.
Cada dos o tres meses, los servicios (Spotify, Amazon Music…) mandan un «informe de ganancias» al distribuidor. Tras siete años, contaba con 29.551 filas como estas:
Mes de Informe | Mes de Venta | Tienda | Artista | Título | Cantidad | Canción/Álbum | País de Venta | Ganancias (USD) |
Mar 2024 | Ene 2024 | Instagram/Facebook | osker wyld | krakkar | 704 | Canción | OU | 0.007483664425 |
Mar 2024 | Ene 2024 | Instagram/Facebook | osker wyld | fimmtánda | 9,608 | Canción | OU | 0.102135011213 |
Mar 2024 | Ene 2024 | Tidal | osker wyld | tólfta (fyrir ömmu) | 27 | Canción | MY | 0.121330264483 |
Mar 2024 | Dic 2023 | iTunes Match | osker wyld | fyrir Olivia | 1 | Canción | TW | 0.000313712922 |
Las herramientas
Mi primer instinto fue usar Python con un par de librerías: pandas para procesar los datos y seaborn o Plotly para visualizarlos.
Sin embargo, tenía ganas de probar polars, una «librería de dataframes increíblemente rápida» (puedo confirmarlo). Asimismo, buscando software libre para crear visualizaciones interactivas, encontré Vega-Altair, una librería de visualización declarativa basada en Vega-Lite.
Preparando los datos
¡Los datos estaban limpios! Al no haber valores faltantes o no válidos, pude pasar directamente a la preparación de los datos para el análisis.
El conjunto de datos sufrió pequeñas transformaciones: eliminé y renombré columnas, ajusté el nombre de algunas tiendas, e indiqué el tipo de datos de cada columna.
Haz clic para ver el código
df = pl.read_csv("data/distrokid.tsv", separator="\t", encoding="ISO-8859-1")
df = df.drop(
["Artist", "ISRC", "UPC", "Team Percentage", "Songwriter Royalties Withheld"]
)
df = df.filter(col("Song/Album") != "Album")
df = df.filter(~col("Store").str.contains("iTunes"))
df = df.drop(["Song/Album"])
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",
}
)
print(f"Before: {df["Store"].unique().to_list()}")
df = df.with_columns(col("Store").str.replace(" (Streaming)", "", literal=True))
df = df.with_columns(col("Store").str.replace("Facebook", "Meta"))
df = df.with_columns(col("Store").str.replace("Google Play All Access", "Google Play Music"))
df = df.with_columns(
col("Sale").str.to_datetime("%Y-%m"),
col("Reported").str.to_datetime("%Y-%m-%d"),
)
Eliminé las filas pertenecientes a servicios para los que tenía menos de 20 registros.
Haz clic para ver el código
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 términos de ingeniería de atributos —crear variables nuevas a partir de datos existentes—, añadí la columna «Año», recuperé los códigos de país de otro conjunto de datos y calculé los ingresos por stream.
Haz clic para ver el código
countries = pl.read_csv("data/country_codes.csv")
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")
df = df.with_columns(col("Sale").dt.year().alias("Year"))
df = df.with_columns((col("Earnings") / col("Quantity")).alias("USD per stream"))
df = df.with_columns((col("USD per stream") / col("Duration")).alias("USD per second"))
¡Todo listo! Hora de obtener respuestas.
¿Cuántas veces se ha reproducido mi música?
Esta es la primera pregunta que me surgió. Para responderla, sumé la columna de «Cantidad» (reproducciones).
Haz clic para ver el código
df.select(pl.sum("Quantity")).item()
137.053.871. Ciento treinta y siete millones cincuenta y tres mil ochocientos setenta y uno.
Tuve que comprobar varias veces el resultado; no me lo creía. No soy famoso y apenas he promocionado mi música. Al ordenar los datos encontré la respuesta:
Mi música se ha utilizado en vídeos de Instagram y Facebook (ambos propiedad de Meta) con millones de reproducciones. Anonadado me hallo —los pocos vídeos en los que he escuchado mi música ni se acercaban al millón de reproducciones.
¿Cuántas horas (o días) es eso?
Haz clic para ver el código
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 consideramos que cada reproducción equivale a 30 segundos de una persona escuchando mi música, obtenemos un tiempo total de escucha de más de 130 años. Wow.
30 segundos es el tiempo mínimo que Spotify o Apple Music exigen antes de contabilizar una reproducción. Sin embargo, ¿qué pasa con Facebook/Instagram o TikTok? Puede que no haya un tiempo mínimo. De hecho, ¡puede que los usuarios tengan el móvil en silencio!
Si cada reproducción equivale a diez segundos de escucha, todas las reproducciones suman 43 años. Sigo alucinando.
¿En cuántos países se ha escuchado mi música?
Haz clic para ver el código
df.filter(col("Country code") != "OU").select(col("Country")).n_unique()
En total, 171. Es decir, ¡más del 85% de todos los países! ¿Cómo ha crecido este número?
Haz clic para ver el código
width = "container"
height = 350
year_slider = alt.binding_range(
name="Year ",
min=df["Year"].min(),
max=df["Year"].max(),
step=1,
)
year_selection = alt.selection_point(
fields=["Year"],
value=df["Year"].max(),
empty=True, on="click, touchend",
bind=year_slider,
)
hover = alt.selection_point(
empty=False,
on="mouseover",
clear="mouseout",
fields=["Countries"],
)
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)
)
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
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,
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ón de streams por país a lo largo del tiempo
Las barras interactivas muestran el número de países con oyentes por año.
Es un mapa mucho más colorido de lo que esperaba. Me gusta imaginar a una persona de cada país escuchando mi música, aunque sea durante unos pocos segundos. Para nada esperaba que mi música llegara a un público tan amplio.
¿Cuál es el servicio que paga mejor? ¿Y peor?
NOTA Puede que mis datos no sean representativos de todo el sector del streaming. Aunque uno de los principales factores a la hora de determinar la retribución son las decisiones tomadas por altos ejecutivos, también influyen otras variables, como el país, el número de usuarios de pago y el número total de streams durante un mes. Además, dispongo de datos limitados de servicios como Tidal, Snapchat, o Amazon Prime. En el último gráfico se detallan los streams por servicio.
Haz clic para ver el código
mean_usd_per_service.select(["USD per stream", "Store"])
def round_to_n_non_zero_decimal(input_float: float, n: float = 2) -> float:
input_str = f"{input_float:.10f}" split_number = re.split("\\.", input_str)
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)
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")
)
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_), legend=None,
),
)
)
text_chart = bar_chart.mark_text(
align="left",
baseline="middle",
dx=5, 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()
Pago medio por stream
Cifras redondeadas al primer par de decimales distintos de cero.
Cargando visualización…
Unos cuantos ceros.
Esperaba a Tidal en el top, pero no a Amazon Unlimited.
Es interesante la diferencia entre los usuarios de pago de Amazon Music (Amazon Unlimited) y los usuarios «gratuitos» (Amazon Prime). «Gratuito» entre comillas, porque Amazon Prime no es gratis, pero los usuarios no pagan extra por acceder a Amazon Music. Sería interesante comparar entre los usuarios de pago y los usuarios gratuitos de Spotify, pero no tengo acceso a esos datos.
Me sorprendió ver a Snapchat en la mitad superior. Esperaba que TikTok y Meta no pagasen mucho: es más fácil obtener reproducciones, y las ganancias se comparten entre los autores de los videos y los músicos. Sin embargo, tengo la impresión de que hay ceros de más en el caso de Meta, ¿no?
Con tantos decimales, no es fácil entender las diferencias. Veámoslo de otro modo.
¿Cuántos streams necesito para conseguir un dólar?
Haz clic para ver el código
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",
)
)
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)
)
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()
Reproducciones necesarias para conseguir 1 $
Cargando visualización…
Mucho más claro.
El eje horizontal está en escala logarítmica para facilitar la comparación. En un gráfico lineal, las reproducciones de Meta harían desaparecer las otras barras. En una escala logarítmica, la diferencia entre 10 reproducciones y 100 reproducciones tiene la misma distancia visual que la diferencia entre 100 y 1.000 reproducciones.
Un dólar por 100-400 reproducciones no suena muy bien. En el peor de los casos, usando la tasa de pago mediana, necesitamos casi tres millones de reproducciones en Meta para obtener un dólar estadounidense.
¿Quieres saber cuántas reproducciones se necesitan para lograr un salario mínimo? ¿O un millón de dólares? Usando estos datos, he creado una calculadora de royalties de streams. Aquí tienes un pantallazo:
Distribución de pagos por servicio
Las plataformas de streaming no tienen una tasa fija. Factores como la ubicación geográfica del usuario, su tipo de suscripción (de pago o no) y el volumen general de streaming de la región afectan al pago por stream.
Por lo tanto, la tasa media no lo dice todo: veamos la dispersión de los pagos en torno a esta media. ¿Varía en función del servicio?
Haz clic para ver el código
store_payments = df.select(["Store", "USD per stream"])
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_), legend=None,
),
yOffset=alt.Y("jitter:Q", title=None),
)
.transform_calculate(jitter="sqrt(-2*log(random()))*cos(2*PI*random())")
)
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ón de pagos por servicio
Cada círculo representa un único pago. El rombo indica la mediana.
El gráfico excluye el 1% superior e inferior de los pagos por servicio para centrarse en los datos más representativos.
Cargando visualización…
Los rangos de Amazon Unlimited y Apple Music son enormes; incluyen la mediana de más de la mitad de los servicios.
Spotify, Saavn y Meta tienen muchos pagos cercanos a cero dólares por reproducción. De estos tres, Spotify destaca al alcanzar pagos de más de medio céntimo.
Pensándolo bien, procesando los datos eliminé algunas (72) instancias de Spotify y Meta (38) donde pagaban infinitos dólares por reproducción. Eran entradas con 0 reproducciones pero ganancias no nulas —probablemente ajustes de pagos anteriores. En cualquier caso, Spotify y Meta ganan la medalla del rango más grande —infinito.
Comparación de la distribución de pares de servicios
He hecho este pequeño gráfico interactivo para comparar la distribución de las tasas de pago entre dos servicios cualesquiera:
Haz clic para ver el código
store_usd_per_stream = trimmed_df.select(["Store", "USD per stream"])
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()
Esta visualización usa la estimación de densidad kernel para aproximar la distribución de pagos por reproducción. Es como un histograma suavizado.
Los picos indican la concentración de los datos alrededor de ese valor.
Selecciona algunos servicios para comparar la dispersión, las superposiciones y divergencias, y el grosor de las colas (la curtosis). ¿Puedes adivinar alguna política de pagos en base a estos datos?
¿Paga Apple Music 0,01 $ por stream?
En 2021, Apple informó que pagaban, de media, un céntimo de dólar por reproducción.
Los gráficos anteriores muestran que mi tasa media de pago de Apple Music no se acerca a un céntimo por stream. Está más cerca de medio céntimo.
Haz clic para ver el código
apple_music_df = df.filter(col("Store") == "Apple Music")
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()
)
Observando la totalidad de los datos (2017 a 2023) me di cuenta de que:
- Tres cuartos de todas las reproducciones tuvieron una tasa de pago por debajo de 0,006 $ (el percentil 75);
- Menos del 15% de mis pagos superan 0,008 $ (80% de un céntimo).
En mi caso, la respuesta es «no». Sin embargo, una media reportada no es una promesa; habrá otros artistas con una tasa de pago promedio mayor a 0,01 $.
¿Cuánto habría perdido con el nuevo modelo de pago de Spotify?
A partir de este año, el sistema de royalties «modernizado» de Spotify pagará 0 $ por canciones con menos de mil reproducciones al año.
Si este modelo se hubiera implementado hace siete años, estoy seguro de que habría perdido una gran parte de los pagos de Spotify.
Haz clic para ver el código
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."
)
Efectivamente. El nuevo sistema me habría privado de 112,01 dólares. Esto es el 78,7% de mis pagos de Spotify hasta la fecha.
Estas ganancias —generadas por mis canciones— en lugar de llegarme a mí, se habrían distribuido entre los demás «tracks elegibles».
¿Las canciones más largas pagan más?
Haz clic para ver el código
df.select(pl.corr("Duration", "USD per stream", method="spearman"))
df.select(pl.corr("Duration", "USD per stream", method="pearson"))
No. En mis datos, la duración de la canción no tiene correlación con la tasa de pago por stream.
CONSIDERACIÓN Todas mis improvisaciones son más bien breves; no hay mucho rango en términos de duración. Mi canción más corta, tíunda, dura 44 segundos. La más «larga», sextánda, dura 3 minutos y 19 segundos.
¿Existe una relación entre el número de reproducciones y la tasa de pago, en las redes sociales?
Recuerdo leer que el modelo de pago de TikTok recompensaba más generosamente el uso de una canción en varios vídeos que un único vídeo con muchas reproducciones.
Para comprobar si esto era así en TikTok y Meta, calculé la correlación entre dos pares de variables: número de reproducciones y tasa de pago, y número de reproducciones y ganancias totales.
Si lo que leí es cierto, lo esperable es que la tasa de pago decrezca a medida que aumenta el número de visualizaciones (correlación negativa), y que haya una baja correlación entre el total de reproducciones y las ganancias.
Haz clic para ver el código 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:
pearson_corr = filtered_data.select(
pl.corr(metrics_pair[0], metrics_pair[1], method="pearson")
).item()
spearman_corr = filtered_data.select(
pl.corr(metrics_pair[0], metrics_pair[1], method="spearman")
).item()
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)}")
En el caso de TikTok, contra más reproducciones, mayores son las ganancias totales (correlación lineal positiva muy fuerte). En el caso de Meta, aunque existe una correlación robusta, es menos lineal.
La tasa de pago de TikTok parece ser independiente del número de reproducciones. En el caso de Meta, parece haber una correlación no lineal moderada.
En conclusión, el número total de reproducciones es el factor relevante a la hora de determinar las ganancias, por encima del número de vídeos que utilizan una canción.
¿Cuáles son los países con la tasa de pago más alta y más baja?
Haz clic para ver el código
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())
Cinco países con el mayor pago promedio:
País | Reproducciones para alcanzar 1 $ |
🇲🇴 Macao | 212 |
🇯🇵 Japón | 220 |
🇬🇧 Reino Unido | 237 |
🇱🇺 Luxemburgo | 237 |
🇨🇭 Suiza | 241 |
Cinco países con el menor pago promedio:
País | Reproducciones para alcanzar 1 $ |
🇸🇨 Seychelles | 4.064.268 |
🇱🇸 Lesotho | 4.037.200 |
🇫🇷 Guayana Francesa | 3.804.970 |
🇱🇮 Liechtenstein | 3.799.514 |
🇲🇨 Mónaco | 3.799.196 |
Otros factores como el servicio podrían estar afectando los resultados: ¿y si la mayoría de los usuarios de los países con mejor tasa de pago resultan estar usando un servicio que paga mejor, y viceversa?
Sería interesante construir un modelo lineal jerárquico para estudiar la variabilidad de cada nivel (servicio y país). Idea para otro día.
Por ahora, la estratificación bastará. Agrupé los datos por servicio y país, obteniendo las cinco combinaciones de país-servicio con tasas de pago más altas y bajas.
Haz clic para ver el código
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())
Cinco combinaciones de país-servicio con el mayor pago promedio:
País | Servicio | Reproducciones para alcanzar 1 $ |
🇬🇧 Reino Unido | Amazon Unlimited | 60 |
🇮🇸 Islandia | Apple Music | 67 |
🇺🇸 Estados Unidos | Tidal | 69 |
🇺🇸 Estados Unidos | Amazon Unlimited | 79 |
🇳🇱 Países Bajos | Apple Music | 84 |
Cinco combinaciones de país-servicio con el menor pago promedio:
País | Servicio | Reproducciones para alcanzar 1 $ |
🇸🇨 Seychelles | Meta | 4.064.268 |
🇱🇸 Lesotho | Meta | 4.037.200 |
🇮🇸 Islandia | Meta | 3.997.995 |
🇫🇷 Guayana Francesa | Meta | 3.804.970 |
🇱🇮 Liechtenstein | Meta | 3.799.514 |
Que la tasa de pago de Meta sea prácticamente cero no ayuda. Si filtramos este servicio, obtenemos:
País | Servicio | Reproducciones para alcanzar 1 $ |
🇬🇭 Ghana | Spotify | 1.000.000 |
🇪🇸 España | Deezer | 652.153 |
🇸🇻 El Salvador | Deezer | 283.041 |
🇰🇿 Kazajistán | Spotify | 124.572 |
🇪🇬 Egipto | Spotify | 20.833 |
Es importante notar que el precio de la suscripción a servicios como Spotify es diferente en cada país. Spotify Premium cuesta ~1,30 $ en Ghana y unos 16 $ en Dinamarca.
En un mundo menos desigual, esta comparación tendría menos sentido.
¿Cómo ha cambiado la distribución de streams e ingresos entre servicios a lo largo del tiempo?
Haz clic para ver el código
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",
)
music_streaming_checkbox = alt.binding_checkbox(name=filter_social_media_label)
checkbox_selection = alt.selection_point(
bind=music_streaming_checkbox, fields=["IsMusicStreaming"], value=False
)
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()
Porcentaje de streams e ingresos por servicio a lo largo del tiempo
Cargando visualización…
¡Cuántos colores! Puedes filtrar los servicios haciendo clic en la leyenda. Al pasar el mouse sobre un color, verás más detalles como el servicio que representa, la fecha y el porcentaje de reproducciones e ingresos asociados.
Me parece interesante que, a partir de julio de 2019, Meta empieza a dominar en términos de reproducciones, pero no en ingresos. Durante unos meses, a pesar de representar consistentemente más del 97% de las reproducciones, genera menos del 5% de los ingresos. No es hasta abril de 2022 que comienza a equilibrarse.
Al filtrar los datos de redes sociales (Facebook, Instagram, TikTok y Snapchat) usando la casilla bajo los gráficos, vemos un patrón similar pero menos obvio con NetEase a partir de mediados de 2020.
¿Cómo han evolucionado las reproducciones e ingresos totales por servicio a lo largo del tiempo?
Centrémonos ahora en los números brutos. Aquí tenemos un par de gráficos de barras clásicos análogos a los gráficos de área de la sección anterior.
Haz clic para ver el código
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)
)
quantity_bar_chart = quantity_bar_chart.properties(
height=height,
width=width,
)
earnings_bar_chart = earnings_bar_chart.properties(
height=height,
width=width,
)
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 e ingresos totales por servicio a lo largo del tiempo
Haz clic en la leyenda para mostrar/ocultar servicios específicos.
Cargando visualización…
De nuevo se hace evidente la divergencia entre reproducciones e ingresos de Meta.
La magnitud de los números provenientes de Instagram/Facebook hace que parezca que haya cero reproducciones antes de julio de 2019. Excluir los datos de redes sociales actualiza el eje vertical y cambia la historia.
Estos gráficos muestran algo que no podíamos ver antes: el crecimiento bruto.
En febrero de 2022 lancé mi segundo álbum, II. Poco después, las reproducciones en Meta se dispararon; una de las improvisaciones de este álbum, hvítur, ganó popularidad allí.
El aumento de reproducciones después del lanzamiento del álbum es menos evidente en los servicios de streaming de música.
¿Cómo es la distribución de reproducciones e ingresos entre las canciones?
¿Quizás unas pocas pistas obtienen la mayoría de las reproducciones? ¿Coincide el ranking de reproducciones e ingresos?
Haz clic para ver el código
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)
)
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)
)
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 y ganancias por canción
Cargando visualización…
Para mí, esta es una de las visualizaciones más interesantes; está llena de información.
- Tan solo dos de las cinco canciones más reproducidas coinciden entre los datos de redes sociales y los servicios de streaming de música.
- hvítur, la canción con el mayor número de reproducciones (¡más de 34 millones!) es número uno gracias a Meta. Si filtramos las reproducciones de redes sociales, ni siquiera está en el top 20.
- El ranking de reproducciones no encaja consistentemente con el ranking de ingresos. Como ejemplo claro, la quinta canción más reproducida, We Don’t —una colaboración con Avstånd (anteriormente Bradycardia)— se encuentra cerca del final en términos de ingresos.
- El color de las barras ayuda a explicar esta divergencia en reproducciones e ingresos. Además, muestra que la popularidad de las improvisaciones difiere por servicio.
Exploremos más de cerca los colores (los servicios).
¿Un pequeño número de servicios es responsable de la mayoría de los ingresos y reproducciones?
Haz clic para ver el código
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()
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)
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))
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)
)
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
),
)
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"))
earnings_per_store_with_labels = earnings_per_store + bars_text
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,
)
earnings_per_store_pareto = (
alt.layer(earnings_per_store_with_labels, pareto_plot)
.resolve_scale(y="independent")
.add_params(show_hide_pareto)
)
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()
sort_order = list(store_quantity_cumulative_percentage["Store"])
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)
)
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
),
)
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"))
quantity_per_store_with_labels = quantity_per_store + bars_text
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
)