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’Informe | Mes de Venda | Botiga | Artista | Títol | Quantitat | Cançó/Àlbum | País de Venda | Guanys (USD) |
Març 2024 | Gen 2024 | Instagram/Facebook | osker wyld | krakkar | 704 | Cançó | OU | 0.007483664425 |
Març 2024 | Gen 2024 | Instagram/Facebook | osker wyld | fimmtánda | 9,608 | Cançó | OU | 0.102135011213 |
Març 2024 | Gen 2024 | Tidal | osker wyld | tólfta (fyrir ömmu) | 27 | Cançó | MY | 0.121330264483 |
Març 2024 | Des 2023 | iTunes Match | osker wyld | fyrir Olivia | 1 | Cançó | TW | 0.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")
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"),
)
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
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"))
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:
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
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
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
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ó de streams per país al llarg del temps
Les barres interactives mostren el nombre de paísos amb oients per any.
É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"])
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()
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
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()
Reproduccions necessàries per aconseguir 1 $
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:
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"])
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ó 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
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()
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")
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
df.select(pl.corr("Duration", "USD per stream", method="spearman"))
df.select(pl.corr("Duration", "USD per stream", method="pearson"))
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:
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 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ís | Streams per aconseguir 1 $ |
🇲🇴 Macau | 212 |
🇯🇵 Japó | 220 |
🇬🇧 Regne Unit | 237 |
🇱🇺 Luxemburg | 237 |
🇨🇭 Suïssa | 241 |
Cinc països amb el pitjor pagament mitjà:
País | Streams per aconseguir 1 $ |
🇸🇨 Seychelles | 4.064.268 |
🇱🇸 Lesotho | 4.037.200 |
🇫🇷 Guaiana Francesa | 3.804.970 |
🇱🇮 Liechtenstein | 3.799.514 |
🇲🇨 Mònaco | 3.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ís | Servei | Streams per aconseguir 1 $ |
🇬🇧 Regne Unit | Amazon Unlimited | 60 |
🇮🇸 Islàndia | Apple Music | 67 |
🇺🇸 Estats Units | Tidal | 69 |
🇺🇸 Estats Units | Amazon Unlimited | 79 |
🇳🇱 Països Baixos | Apple Music | 84 |
Cinc combinacions de país-servei amb el pitjor pagament mitjà:
País | Servei | Streams per aconseguir 1 $ |
🇸🇨 Seychelles | Meta | 4.064.268 |
🇱🇸 Lesotho | Meta | 4.037.200 |
🇮🇸 Islàndia | Meta | 3.997.995 |
🇫🇷 Guaiana Francesa | Meta | 3.804.970 |
🇱🇮 Liechtenstein | Meta | 3.799.514 |
Que la taxa de pagament de Meta sigui pràcticament zero no ajuda. Si filtrem aquest servei, obtenim:
País | Servei | Streams per aconseguir 1 $ |
🇬🇭 Ghana | Spotify | 1.000.000 |
🇪🇸 Espanya | Deezer | 652.153 |
🇸🇻 El Salvador | Deezer | 283.041 |
🇰🇿 Kazakhstan | Spotify | 124.572 |
🇪🇬 Egipte | Spotify | 20.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.
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",
)
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()
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.
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)
)
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 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.
Potser unes poques pistes obtenen la majoria de les reproduccions? Coincideix el rànquing de reproduccions i ingressos?
Fes clic per veure el codi
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 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
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