What Makes a Hit Song?¶
A data story by Dr. Teresa Vasquez
Every record label is chasing danceable tracks: the median song on the list of 2023's most-streamed songs scores 69 out of 100 on danceability, against 57.6 for the median track in a catalog of tens of thousands. Two questions follow for any A&R team (artists and repertoire, the people who find and sign artists): do more danceable songs get more streams, and could we have predicted a hit before release?
The data comes from two public files, defined and cleaned in the data notebook:
songs_2023.csv, the songs on the list of 2023's most-streamed songs, and catalog.csv, a wider
catalog of tracks.
SIDE A · THE ANALYSIS · SHINING AND GLEANINGShining and gleaningThe data arrives defined, cleaned and graded in the data notebook. Now we test the hunches and see what the data has to say.¶
Two notebooks, two jobs: one for defining and cleaning, the other for shining and gleaning. The data notebook defined and cleaned; this one shines and gleans: every hypothesis, figure and finding.
Show the code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.compose import make_column_transformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, roc_curve, silhouette_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
SIDE A · INTROThe dataTwo files: the 949 songs everyone streamed in 2023, and a catalog of nearly 90,000 tracks to hold them up against.¶
Show the code
# - Load data/songs_2023.csv as songs (parse the release_date column) and data/catalog.csv as catalog.
# - Make tracks: one row per track_id, leaving out the tracks whose danceability is blank (the data notebook
# set it to blank where Spotify's rhythm analysis returned nothing).
# - Show, as one small table, the number of songs, catalog rows, unique tracks in the catalog, and
# tracks whose sound was measured.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms # one style for every table and figure
ms.use_theme() # the theme theme.json names
SEED = 6120 # every random step in the notebook uses this seed
songs = pd.read_csv("data/songs_2023.csv", parse_dates=["release_date"], low_memory=False)
catalog = pd.read_csv("data/catalog.csv", low_memory=False)
# One row per track, keeping its first genre, minus the tracks Spotify could not measure.
tracks = catalog.drop_duplicates("track_id").dropna(subset=["danceability_pct"]).reset_index(drop=True)
assert catalog["danceability_pct"].isna().sum() == 157 # the data notebook's missing rhythm analyses
display(ms.table(pd.Series({
"songs on the 2023 list": len(songs),
"catalog rows": len(catalog),
"unique tracks in the catalog": catalog["track_id"].nunique(),
"tracks whose sound was measured": len(tracks),
}, name="count").to_frame(), index_label="what we counted"))
| what we counted | count |
|---|---|
| songs on the 2023 list | 949 |
| catalog rows | 113,999 |
| unique tracks in the catalog | 89,740 |
| tracks whose sound was measured | 89,583 |
The catalog lists a track once for every genre search that found it. tracks keeps one row per
track (with the first genre it was listed under) and leaves out the 157 tracks whose rhythm analysis
returned nothing (the data notebook, missing values); use it for questions about tracks and how they sound. Use
catalog, every row, for questions about genres.
The data arrives clean. the data notebook pulled both files, audited them, repaired what evidence could settle
(71 song names, including Señorita, Don't Blame Me and ROSALÍA), and graded them in its data card.
Every repaired song keeps its original text in track_in_file and artists_in_file, and says what changed
in fixes. The evidence and approvals are in data/data_fixes.csv and data/name_repairs.csv.
SIDE A · TRACK 01The observationEveryone says the hits are danceable. Are they really more danceable than music in general?¶
The belief across the music business is that danceable songs get streamed. Start from what can be observed: how danceable are the year's most-streamed songs, compared with tracks in general? Danceability is Spotify's measure, from 0 to 100, of how suitable a track is for dancing, based on its tempo, how steady its rhythm is, and how strong its beat is. Compare the distribution of danceability on the 2023 list with its distribution across the catalog, and the median of each.
Show the code
# Meet the measure before testing it: hear the scale, then see where genres, artists and the list's music sit.
from mixtape import danceability_primer
display(danceability_primer(songs, tracks, catalog))
Show the code
# The median danceability of the 2023 list and of every track, as one small table.
medians = pd.Series({
"2023 list": songs["danceability_pct"].median(),
"every track in the catalog": tracks["danceability_pct"].median(),
}, name="median danceability (0-100)")
assert medians.round(1).tolist() == [69.0, 57.6] # the numbers the introduction quotes
display(ms.table(medians.round(1).to_frame(), index_label="group"))
| group | median danceability (0-100) |
|---|---|
| 2023 list | 69 |
| every track in the catalog | 57.6 |
Show the code
# Figure: danceability on the 2023 list compared with every track in the
# catalog, from songs and tracks.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
bins = np.arange(0, 101, 5)
# Shares of each group, not counts: the catalog has about 94 times as many tracks as the list.
share = lambda s: np.ones(len(s)) / len(s)
ax.hist(tracks["danceability_pct"], bins=bins, weights=share(tracks), color=ms.CONTEXT)
ax.hist(songs["danceability_pct"], bins=bins, weights=share(songs), histtype="step", linewidth=2.2,
color=ms.HIGHLIGHT)
ax.yaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.grid(axis="y")
ax.set_axisbelow(True)
top = ax.get_ylim()[1] * 1.12
ax.set_ylim(0, top)
# Each group's median as a labeled line over the bars, solid for the list and dashed for the catalog.
for group, data, color, style, side in [
("Catalog", tracks, ms.INK, "--", "right"),
("2023 list", songs, ms.HIGHLIGHT, "-", "left"),
]:
m = data["danceability_pct"].median()
ax.axvline(m, color=color, linestyle=style, linewidth=1.4, zorder=3)
ax.annotate(f"{group} median: {m:.1f}", xy=(m, top), xytext=(-6 if side == "right" else 6, -4),
textcoords="offset points", ha=side, va="top", fontsize=10, color=color,
fontweight="bold")
# Direct labels in place of a legend.
ax.annotate(f"2023 list\n{len(songs):,} songs", xy=(87, 0.10), ha="left", fontsize=10,
color=ms.HIGHLIGHT)
ax.annotate(f"Every track in\nthe catalog\n{len(tracks):,} tracks", xy=(3, 0.06), ha="left",
fontsize=10, color=ms.INK)
ax.set_xlim(0, 100)
ax.set_xlabel("Danceability (0–100)")
ax.set_ylabel("Share of the group (%)")
gap = songs["danceability_pct"].median() - tracks["danceability_pct"].median()
ms.finish(ax, f"The 2023 hits' median is {gap:.1f} points more danceable than the catalog's",
f"Danceability in 5-point bins · share of each group · {len(songs):,} songs, "
f"{len(tracks):,} catalog tracks",
"Source: data/songs_2023.csv, data/catalog.csv (one row per track; the 157 tracks with no "
"rhythm analysis are left out)", tag=ms.tag(1, 17))
ms.save(fig, n=1, slug="danceability-list-vs-catalog")
plt.show()
🎧 How to read this chart
- 📌 What it's about: how danceable 2023's hits are, next to nearly 90,000 everyday tracks.
- ↕️ Up the side: the share of each group that lands in each danceability band. Each group adds up to 100%, which lets 949 hits and 89,583 tracks be compared fairly despite their sizes.
- ↔️ Along the bottom: danceability, from 0 (hard to dance to) to 100 (made for the dance floor), in bands of 5.
- ▮ The shapes: grey bars are the catalog, the pink outline is the 2023 list. The two vertical lines mark each group's median, its middle song when all are lined up by danceability.
- 💡 What it tells us: the whole pink shape sits to the right. The hits' middle song scores 69, the catalog's 57.6.
What we found. Yes, the year's biggest songs are more danceable than music in general. Line up every song on the 2023 list from least to most danceable, and the one in the middle scores 69 out of 100. Do the same for the whole catalog of nearly 90,000 tracks, and the middle one scores 57.6. That's an 11.4-point gap, and the chart shows the whole hits curve shifted toward the dance floor, not just a few songs pulling it over.
Show the code
# Play the list's five most-streamed songs.
from mixtape import mixtape
display(mixtape(songs.nlargest(5, "streams_millions"), "The 2023 list's 5 most-streamed songs", "danceability_pct"))
SIDE A · INTERLUDEHypothesesThe label's hunch, turned into six claims that could each turn out wrong.¶
Question 1 - Do more danceable songs get more streams?
- More danceable songs have more streams.
- More upbeat songs, those with higher valence, have more streams.
- Something other than how a song sounds explains its streams.
Question 2 - Could we have predicted a hit before release?
- Danceability alone would have singled out the hits.
- All the audio features together would have singled out the hits.
- Adding genre, which is known before release, to the audio features would have singled out the hits.
My prediction for question 1: Danceability will not explain how much a song is streamed. The observation shows that the year's most-streamed songs are more danceable than music in general (a median of 69 against 57.6 for the whole catalog), so danceability may help a song reach the list. Once a song is there, though, I expect its streams to depend more on how people listen than on how it sounds. With so much music available, a large share of streaming is habitual listening in the background: I stream lo-fi, a genre made to stay calm rather than to dance to, for more than 20 hours a day. So I predict that neither danceability nor valence will track streams. Something beyond the sound likely matters more, though I'm not yet sure what it is.
Show the code
# Make your calls before any test runs; each verdict below shows your call beside the data's.
from IPython.display import HTML
CALLS = "hit-song" # where this notebook's calls are saved in the browser
display(HTML(ms.predictions(CALLS, [
("Q1·H1", "More danceable songs have more streams."),
("Q1·H2", "More upbeat songs, those with higher valence, have more streams."),
("Q1·H3", "Something other than how a song sounds explains its streams."),
("Q2·H1", "Danceability alone would have singled out the hits."),
("Q2·H2", "All the audio features together would have singled out the hits."),
("Q2·H3", "Adding genre, known before release, to the audio features would have singled out the hits."),
])))
SIDE A · TRACK 02Hypothesis 1 · Streams by danceabilityIf danceable songs are what people play, the most danceable songs should have the most streams. Do they?¶
A song's streams are the total number of times it has been played on Spotify since its release,
in millions. Split the 2023 list into four equal-sized groups (quarters) by danceability, with
pd.qcut, and compare the median streams of each; the median is the middle song, so a few
billion-stream songs cannot pull it up. Then look at every song: fit the regression line of streams on
danceability, the straight line that best fits the songs. Its slope is the change in streams for
each extra point of danceability; r, the correlation, says how closely the songs follow the line,
from -1 (a perfect downhill line) through 0 (no straight-line pattern) to +1 (a perfect uphill line).
If the hypothesis holds, median streams rise from the lowest quarter to the highest, and the slope and r are positive.
Show the code
# - The median streams in each quarter of the list by danceability: group songs by
# pd.qcut(songs["danceability_pct"], 4) (with observed=True) and take the median of streams.
# Call it dance, and show it as a table.
# - The slope (np.polyfit) and r (.corr) of streams on danceability across every song with a stream
# count, as a small table.
def quarter_medians(df, x, y):
"""Median of y in each of four equal-sized groups of df by x, with each group's size."""
groups = df.groupby(pd.qcut(df[x], 4), observed=True)[y]
return groups.median(), groups.count()
def slope_and_r(df, x, y):
"""Slope (np.polyfit) and r (.corr) of y on x, over the rows where both are known."""
known = df.dropna(subset=[x, y])
slope = np.polyfit(known[x], known[y], 1)[0]
return pd.Series({"slope": slope, "r": known[x].corr(known[y]), "songs": len(known)})
dance, dance_n = quarter_medians(songs, "danceability_pct", "streams_millions")
display(ms.table(pd.DataFrame({"median streams (millions)": dance.round(1), "songs": dance_n})))
display(ms.table(slope_and_r(songs, "danceability_pct", "streams_millions").to_frame("streams on danceability").round(3), index_label="measure"))
| danceability_pct | median streams (millions) | songs |
|---|---|---|
| (22.999, 57.0] | 366.8 | 244 |
| (57.0, 69.0] | 286.4 | 235 |
| (69.0, 78.0] | 302.1 | 238 |
| (78.0, 96.0] | 253.9 | 230 |
| measure | streams on danceability |
|---|---|
| slope | -4.145 |
| r | -0.107 |
| songs | 947 |
🔬 What we ran, and whyQuarters, medians, a line and r
- Quarters (
pd.qcut): four groups of about 237 songs each, from least to most danceable, so each group is a fair sample. - Medians, not averages: a handful of billion-stream songs would drag an average up; the middle song doesn't move.
- The regression line (
np.polyfit) and r (.corr) use every song, not four summaries: the slope says how many streams each danceability point is worth, and r how tightly songs follow that line.
Show the code
# Figure: the test of hypothesis 1, from dance and songs.
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4.5))
# Left: the median streams of each danceability quarter, with each bar's median and number of songs.
known = songs.dropna(subset=["streams_millions"])
per_quarter = known.groupby(pd.qcut(songs["danceability_pct"], 4), observed=True).size()
ranges = [f"{int(np.floor(q.left)) + 1}–{int(q.right)}" for q in dance.index]
bars = left.bar(ranges, dance.values, color=ms.CONTEXT, width=0.65)
for bar, median, n in zip(bars, dance.values, per_quarter.values):
middle = bar.get_x() + bar.get_width() / 2
left.annotate(f"{median:.1f}", xy=(middle, median), xytext=(0, 4), textcoords="offset points",
ha="center", va="bottom", fontsize=10, color=ms.INK, fontweight="bold")
left.annotate(f"n = {n}", xy=(middle, 0), xytext=(0, 6), textcoords="offset points",
ha="center", va="bottom", fontsize=9, color=ms.on(ms.CONTEXT))
left.set_ylim(0, dance.max() * 1.18)
left.set_xlabel("Danceability quarter (0–100)")
left.set_ylabel("Median streams (millions)")
left.set_title("Median streams by quarter", color=ms.MUTED, fontsize=10)
# Right: every song with a stream count, and the regression line of streams on danceability.
slope, intercept, r = ms.regression(known["danceability_pct"], known["streams_millions"])
right.scatter(known["danceability_pct"], known["streams_millions"], s=12, color=ms.CONTEXT,
alpha=0.6, linewidths=0)
x = np.array([known["danceability_pct"].min(), known["danceability_pct"].max()])
right.plot(x, intercept + slope * x, color=ms.HIGHLIGHT, linewidth=2.2)
right.annotate(f"Regression line\nslope = {slope:.2f} million streams per point\nr = {r:.2f}",
xy=(0.03, 0.90), xycoords="axes fraction", ha="left", va="top", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold")
right.set_ylim(0, known["streams_millions"].max() * 1.05)
right.yaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
right.set_xlabel("Danceability (0–100)")
right.set_ylabel("Streams (millions)")
right.set_title(f"Every song ({len(known):,})", color=ms.MUTED, fontsize=10)
for ax in (left, right):
ax.grid(axis="y")
ax.set_axisbelow(True)
ms.finish([left, right],
f"More danceable songs do not get more streams: the least danceable quarter leads, r = {r:.2f}",
f"Streams since release · millions · {len(known):,} songs with a stream count, in four "
"equal-sized quarters by danceability",
f"Source: data/songs_2023.csv ({len(songs) - len(known)} of the {len(songs):,} songs have no "
"stream count and are left out)", tag=ms.tag(2, 17))
ms.save(fig, n=2, slug="streams-by-danceability")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether more danceable songs get more streams.
- ↕️ Up the side: streams in millions. On the left, the typical (middle) song of each group; on the right, every song.
- ↔️ Along the bottom: on the left, four equal groups from least to most danceable (23–57 up to 79–96); on the right, danceability 0–100.
- ▮ The marks: each bar is one group's middle song, and n says how many songs are in it. Each dot is one song; the pink line is the regression line, the straight line that fits the dots best.
- 💡 What it tells us: the least danceable group leads (366.8 million) and the line tilts slightly down, the correlation is r = −0.11 (r runs from 0, no straight-line pattern, to ±1, a perfect one). Blinding Lights, the most-streamed song of all, scores just 50.
What we found. No. More danceable songs do not get more streams, so hypothesis 1 does not hold. Split the list into four equal groups, from least to most danceable. The least danceable group's typical song has 366.8 million streams. The most danceable group's has 253.9 million, the lowest of the four. Look at all 947 songs one by one and the pattern is close to flat, tilting slightly downhill. Each extra point of danceability goes with about 4 million fewer streams, and the correlation is r = −0.11 (0 means no pattern at all, 1 means a perfect one). Blinding Lights, the most-streamed song on the list, scores just 50 on danceability.
Show the code
# Reveal: your call on Q1·H1 beside the data.
display(HTML(ms.reveal(CALLS, "Q1·H1", holds=False, verdict="The least danceable quarter leads (366.8 million vs 253.9), and r = −0.11.")))
Show the code
# Play the songs this test evaluated.
from mixtape import mixtape, span
quarter = pd.qcut(songs["danceability_pct"], 4)
for q, which in [(quarter.cat.categories[0], "Least"), (quarter.cat.categories[-1], "Most")]:
picks = songs[quarter == q].nlargest(5, "streams_millions")
display(mixtape(picks, f"{which} danceable quarter ({span(q)}): its 5 most-streamed songs", "danceability_pct"))
SIDE A · TRACK 03Hypothesis 2 · Streams by valenceDanceability didn't do it. Maybe songs that sound happier get played more?¶
Valence is Spotify's measure, from 0 to 100, of how positive a track sounds: near 0 sounds sad or angry, near 100 happy or euphoric. Same test, with valence in place of danceability. If the hypothesis holds, median streams rise from the lowest quarter to the highest, and the slope and r are positive.
Show the code
# The same computations with valence in place of danceability. Call the medians upbeat.
upbeat, upbeat_n = quarter_medians(songs, "valence_pct", "streams_millions")
display(ms.table(pd.DataFrame({"median streams (millions)": upbeat.round(1), "songs": upbeat_n})))
display(ms.table(slope_and_r(songs, "valence_pct", "streams_millions").to_frame("streams on valence").round(3), index_label="measure"))
| valence_pct | median streams (millions) | songs |
|---|---|---|
| (3.999, 32.0] | 304.1 | 239 |
| (32.0, 51.0] | 307.4 | 237 |
| (51.0, 70.0] | 290.8 | 239 |
| (70.0, 97.0] | 261.4 | 232 |
| measure | streams on valence |
|---|---|
| slope | -0.98 |
| r | -0.041 |
| songs | 947 |
🔬 What we ran, and whyThe same test, with valence
Exactly the hypothesis 1 recipe (quarters, medians, line and r) with valence in place of danceability, so the two answers compare directly.
Show the code
# Figure: the test of hypothesis 2, from upbeat and songs.
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4.5))
# Left: the median streams of each valence quarter, with each bar's median and number of songs.
known = songs.dropna(subset=["streams_millions"])
ranges = [f"{int(np.floor(q.left)) + 1}–{int(q.right)}" for q in upbeat.index]
bars = left.bar(ranges, upbeat.values, color=ms.CONTEXT, width=0.65)
for bar, median, n in zip(bars, upbeat.values, upbeat_n.values):
middle = bar.get_x() + bar.get_width() / 2
left.annotate(f"{median:.1f}", xy=(middle, median), xytext=(0, 4), textcoords="offset points",
ha="center", va="bottom", fontsize=10, color=ms.INK, fontweight="bold")
left.annotate(f"n = {n}", xy=(middle, 0), xytext=(0, 6), textcoords="offset points",
ha="center", va="bottom", fontsize=9, color=ms.on(ms.CONTEXT))
left.set_ylim(0, upbeat.max() * 1.18)
left.set_xlabel("Valence quarter (0–100)")
left.set_ylabel("Median streams (millions)")
left.set_title("Median streams by quarter", color=ms.MUTED, fontsize=10)
# Right: every song with a stream count, and the regression line of streams on valence.
slope, intercept, r = ms.regression(known["valence_pct"], known["streams_millions"])
right.scatter(known["valence_pct"], known["streams_millions"], s=12, color=ms.CONTEXT,
alpha=0.6, linewidths=0)
x = np.array([known["valence_pct"].min(), known["valence_pct"].max()])
right.plot(x, intercept + slope * x, color=ms.HIGHLIGHT, linewidth=2.2)
right.annotate(f"Regression line\nslope = {slope:.2f} million streams per point\nr = {r:.2f}",
xy=(0.03, 0.90), xycoords="axes fraction", ha="left", va="top", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold")
right.set_ylim(0, known["streams_millions"].max() * 1.05)
right.yaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
right.set_xlabel("Valence (0–100, sad to happy)")
right.set_ylabel("Streams (millions)")
right.set_title(f"Every song ({len(known):,})", color=ms.MUTED, fontsize=10)
for ax in (left, right):
ax.grid(axis="y")
ax.set_axisbelow(True)
ms.finish([left, right],
f"More upbeat songs do not get more streams: the most upbeat quarter is lowest, r = {r:.2f}",
f"Streams since release · millions · {len(known):,} songs with a stream count, in four "
"equal-sized quarters by valence",
f"Source: data/songs_2023.csv ({len(songs) - len(known)} of the {len(songs):,} songs have no "
"stream count and are left out)", tag=ms.tag(3, 17))
ms.save(fig, n=3, slug="streams-by-valence")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether happier-sounding songs get more streams.
- ↕️ Up the side: streams in millions: each group's middle song on the left, every song on the right.
- ↔️ Along the bottom: valence, how happy a song sounds, from 0 (sad or angry) to 100 (euphoric); in four equal groups on the left.
- ▮ The marks: bars are each group's middle song with its n; dots are songs; the pink line is the regression line, the straight line that fits them best.
- 💡 What it tells us: the happiest group is the lowest (261.4 million), and the correlation, r = −0.04, is about as close to no pattern as data gets. Blinding Lights scores 38 on valence.
What we found. No. Happier-sounding songs do not get more streams either, so hypothesis 2 does not hold. Valence measures how happy a song sounds, from 0 (sad or angry) to 100 (euphoric). The two moodiest groups' typical songs have 304.1 and 307.4 million streams, and the happiest group's has 261.4 million, the lowest. Across every song, the correlation is r = −0.04, about as close to "no pattern" as data gets. How a song feels doesn't explain how much it gets played.
Show the code
# Reveal: your call on Q1·H2 beside the data.
display(HTML(ms.reveal(CALLS, "Q1·H2", holds=False, verdict="The happiest quarter is lowest (261.4 million), and r = −0.04.")))
Show the code
# Play the songs this test evaluated.
from mixtape import mixtape, span
quarter = pd.qcut(songs["valence_pct"], 4)
for q, which in [(quarter.cat.categories[0], "Least"), (quarter.cat.categories[-1], "Most")]:
picks = songs[quarter == q].nlargest(5, "streams_millions")
display(mixtape(picks, f"{which} upbeat quarter ({span(q)}): its 5 most-streamed songs", "valence_pct"))
The other 3 songs
- 03 · Believer · Imagine Dragons · valence 74 · 2,594M streams · ▶ play
- 04 · Señorita · Shawn Mendes, Camila Cabello · valence 77 · 2,485M streams · ▶ find “Señorita” on YouTube
- 05 · Can't Hold Us (feat. Ray Dalton) · Ray Dalton, Ryan Lewis, Macklemore · valence 88 · 1,954M streams · ▶ play
SIDE A · INTERLUDEHypothesis 3 · Something else?Neither sound explains streams. So what does a stream count actually measure?¶
Neither measure of how a song sounds explains its streams. Before looking for another cause, look at what a stream count is: the total number of plays since the song came out, read once, in mid-2023.
SIDE A · TRACK 04Digging deeper · Streams by release yearA stream count is a running total, and older songs have had years longer to run it up.¶
The list holds songs released as far back as 1930. Group them by when they came out: one group per year from 2021, wider groups before that, because the list holds few old songs. The table gives the number of songs in each group. Do the songs released in 2021, 2022 and 2023 have different median streams from older songs?
Show the code
# Group the songs by release year with pd.cut into a column called released: before 2000, 2000-09,
# 2010-14, 2015-18, 2019-20, 2021, 2022 and 2023. The next section uses the same groups.
# Each edge is the last year of a group: 1930-1999, 2000-2009, ..., then one group per year.
RELEASE_EDGES = [1929, 1999, 2009, 2014, 2018, 2020, 2021, 2022, 2023]
RELEASE_GROUPS = ["before 2000", "2000-09", "2010-14", "2015-18", "2019-20", "2021", "2022", "2023"]
songs["released"] = pd.cut(songs["release_year"], bins=RELEASE_EDGES, labels=RELEASE_GROUPS)
assert songs["released"].notna().sum() == songs["release_year"].notna().sum() # every known year has a group
display(ms.table(songs["released"].value_counts(sort=False).rename("songs").to_frame()))
| released | songs |
|---|---|
| before 2000 | 48 |
| 2000-09 | 20 |
| 2010-14 | 53 |
| 2015-18 | 62 |
| 2019-20 | 73 |
| 2021 | 118 |
| 2022 | 399 |
| 2023 | 175 |
🔬 What we ran, and whyRelease groups
pd.cut on fixed year edges: wide groups for older years, where the list holds few songs, and one group per year from 2021, where it holds most. A stream count is a running total, so songs must be compared with songs of about the same age.
Show the code
# - The median streams of each release group. Call it by_release.
# - A table of each group's number of songs and median streams.
release = songs.groupby("released", observed=True)
by_release = release["streams_millions"].median()
display(ms.table(pd.DataFrame({"songs": release.size(), "median streams (millions)": by_release.round(1)})))
| released | songs | median streams (millions) |
|---|---|---|
| before 2000 | 48 | 663.8 |
| 2000-09 | 20 | 1,229.3 |
| 2010-14 | 53 | 1,131.1 |
| 2015-18 | 62 | 948.4 |
| 2019-20 | 73 | 807 |
| 2021 | 118 | 505.3 |
| 2022 | 399 | 222.1 |
| 2023 | 175 | 107.8 |
🔬 What we ran, and whyMedian streams per release group
The median of each group, with its size: if age drives streams, the medians fall as the songs get newer.
Show the code
# Figure: median streams by release group, from by_release.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
# The bar panel is wider so its eight year ranges fit side by side.
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4.5), width_ratios=[1.85, 1])
# Left: the median streams of each release group, with the 2021-2023 releases highlighted and each
# bar's median and number of songs.
known = songs.dropna(subset=["streams_millions"])
per_group = known.groupby("released", observed=True).size()
recent = ["2021", "2022", "2023"]
colors = [ms.HIGHLIGHT if group in recent else ms.CONTEXT for group in by_release.index]
# Each tick names the group's real years (the oldest song is from 1930) and its number of songs.
first_year = int(songs["release_year"].min())
years = [f"{first_year}–99" if g == "before 2000" else g.replace("-", "–") for g in by_release.index]
ticks = [f"{y}\nn = {n}" for y, n in zip(years, per_group.values)]
bars = left.bar(ticks, by_release.values, color=colors, width=0.7)
for bar, median in zip(bars, by_release.values):
left.annotate(f"{median:,.1f}", xy=(bar.get_x() + bar.get_width() / 2, median), xytext=(0, 4),
textcoords="offset points", ha="center", va="bottom", fontsize=9, color=ms.INK,
fontweight="bold")
# A bracket over the three newest groups, so the highlight is named in words, not only in color.
first, last = bars[-3], bars[-1]
x0, x1 = first.get_x(), last.get_x() + last.get_width()
top = by_release[recent].max() * 1.3
left.plot([x0, x0, x1, x1], [top * 0.95, top, top, top * 0.95], color=ms.HIGHLIGHT, linewidth=1.2)
left.annotate("released 2021–23", xy=((x0 + x1) / 2, top), xytext=(0, 4), textcoords="offset points",
ha="center", va="bottom", fontsize=10, color=ms.HIGHLIGHT, fontweight="bold")
left.set_ylim(0, by_release.max() * 1.15)
left.yaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
left.tick_params(axis="x", labelsize=9)
left.set_xlabel("Release year (n = songs in the group)")
left.set_ylabel("Median streams (millions)")
left.set_title("Median streams by release group", color=ms.MUTED, fontsize=10)
# Right: every song with a stream count, and the regression line of streams on release year.
slope, intercept, r = ms.regression(known["release_year"], known["streams_millions"])
right.scatter(known["release_year"], known["streams_millions"], s=12, color=ms.CONTEXT, alpha=0.6,
linewidths=0)
x = np.array([known["release_year"].min(), known["release_year"].max()])
right.plot(x, intercept + slope * x, color=ms.HIGHLIGHT, linewidth=2.2)
right.annotate(f"Regression line\nslope = {slope:.2f} million\nstreams per year\nr = {r:.2f}",
xy=(0.03, 0.90), xycoords="axes fraction", ha="left", va="top", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold")
right.set_ylim(0, known["streams_millions"].max() * 1.05)
right.yaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
right.set_xlabel("Release year")
right.set_ylabel("Streams (millions)")
right.set_title(f"Every song ({len(known):,})", color=ms.MUTED, fontsize=10)
for ax in (left, right):
ax.grid(axis="y")
ax.set_axisbelow(True)
ms.finish([left, right],
f"Newer songs have far fewer streams: the typical 2023 release has "
f"{by_release['2023']:,.1f} million",
f"Streams since release · millions · {len(known):,} songs with a stream count, grouped by "
"release year",
f"Source: data/songs_2023.csv ({len(songs) - len(known)} of the {len(songs):,} songs have no "
"stream count and are left out; streams were read once, in mid-2023)", tag=ms.tag(4, 17))
# The two-line ticks push the axis label down, so the caption moves below it.
left.texts[-1].xyann = (0, -58)
ms.save(fig, n=4, slug="streams-by-release-year")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether songs released recently have fewer streams than older songs, because they've had less time to collect them.
- ↕️ Up the side: streams in millions. On the left, the typical (middle) song of each group; on the right, every song.
- ↔️ Along the bottom: the year the song came out. On the left, in eight groups from 1930–99 to 2023, with n, the number of songs in each; on the right, as a year from 1930 to 2023.
- ▮ The marks: each bar is one group's middle song (its median), and the pink bars are 2021, 2022 and 2023. Each dot is one song; the pink line is the regression line, the straight line that fits the dots best.
- 💡 What it tells us: the median drops every year as songs get newer: 807.0 million for 2019–20, 505.3 for 2021, 222.1 for 2022 and 107.8 for 2023. The line slopes down, r = −0.23 (0 means no pattern, −1 a perfect downhill line). Even the biggest 2023 release, Miley Cyrus's Flowers, has 1,317 million streams, about a third of Blinding Lights (2019) at 3,704 million.
What we found. Here's the twist: age, not sound, drives a stream count. A stream count is a running total, so the longer a song has been out, the more it has piled up. Songs from 2000–09 have a typical 1,229.3 million streams. Songs from 2023 have just 107.8 million, because they'd only been out a few weeks or months when the counts were read. That pattern holds group after group: newer means fewer streams.
Show the code
# Play the most-streamed song from each release group, oldest first.
from mixtape import mixtape
picks = songs.sort_values("streams_millions", ascending=False).drop_duplicates("released").sort_values("released")
display(mixtape(picks, "The biggest song of each era on the 2023 list", "release_year", limit=8))
The other 6 songs
- 03 · Sweater Weather · The Neighbourhood · release year 2012 · 2,283M streams · ▶ play
- 04 · Shape of You · Ed Sheeran · release year 2017 · 3,563M streams · ▶ play
- 05 · Blinding Lights · The Weeknd · release year 2019 · 3,704M streams · ▶ play
- 06 · STAY (with Justin Bieber) · Justin Bieber, The Kid Laroi · release year 2021 · 2,665M streams · ▶ play
- 07 · As It Was · Harry Styles · release year 2022 · 2,513M streams · ▶ play
- 08 · Flowers · Miley Cyrus · release year 2023 · 1,317M streams · ▶ play
SIDE A · TRACK 05Digging deeper · Danceability by release yearIf the newest songs are also the most danceable, danceability was set up to look bad.¶
The same comparison, with danceability in place of streams, and the same groups. The table adds the share of the list each group holds.
Show the code
# - The median danceability of each release group. Call it dance_by_release.
# - A table of each group's median danceability and its share of the list.
dance_by_release = release["danceability_pct"].median()
display(ms.table(pd.DataFrame({
"median danceability (0-100)": dance_by_release,
"share of the list": (release.size() / len(songs)).round(3),
})))
| released | median danceability (0-100) | share of the list |
|---|---|---|
| before 2000 | 60 | 0.051 |
| 2000-09 | 62.5 | 0.021 |
| 2010-14 | 61 | 0.056 |
| 2015-18 | 59.5 | 0.065 |
| 2019-20 | 67 | 0.077 |
| 2021 | 71.5 | 0.124 |
| 2022 | 71 | 0.42 |
| 2023 | 71 | 0.184 |
🔬 What we ran, and whyMedian danceability per release group
The same groups, measured on danceability, with each group's share of the list. If the newest songs are both the most danceable and the most numerous, they tilt the whole list.
Show the code
# Figure: median danceability by release group, from dance_by_release.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
# The bar panel is wider so its eight year ranges fit side by side.
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4.5), width_ratios=[1.75, 1])
# Left: the median danceability of each release group, with the 2021-2023 releases highlighted and
# each bar's median and number of songs.
known = songs.dropna(subset=["danceability_pct"])
per_group = known.groupby("released", observed=True).size()
recent = ["2021", "2022", "2023"]
colors = [ms.HIGHLIGHT if group in recent else ms.CONTEXT for group in dance_by_release.index]
# Each tick names the group's real years (the oldest song is from 1930) and its number of songs.
first_year = int(songs["release_year"].min())
years = [f"{first_year}–99" if g == "before 2000" else g.replace("-", "–")
for g in dance_by_release.index]
ticks = [f"{y}\nn = {n}" for y, n in zip(years, per_group.values)]
bars = left.bar(ticks, dance_by_release.values, color=colors, width=0.7)
for bar, median in zip(bars, dance_by_release.values):
left.annotate(f"{median:.1f}", xy=(bar.get_x() + bar.get_width() / 2, median), xytext=(0, 4),
textcoords="offset points", ha="center", va="bottom", fontsize=9, color=ms.INK,
fontweight="bold")
# A bracket over the three newest groups, so the highlight is named in words, not only in color.
first, last = bars[-3], bars[-1]
x0, x1 = first.get_x(), last.get_x() + last.get_width()
# Raised clear of the 80 gridline, so the bracket does not merge with it.
top = dance_by_release[recent].max() * 1.17
left.plot([x0, x0, x1, x1], [top * 0.97, top, top, top * 0.97], color=ms.HIGHLIGHT, linewidth=1.2)
left.annotate("released 2021–23", xy=((x0 + x1) / 2, top), xytext=(0, 4), textcoords="offset points",
ha="center", va="bottom", fontsize=10, color=ms.HIGHLIGHT, fontweight="bold")
left.set_ylim(0, 100)
left.tick_params(axis="x", labelsize=9)
left.set_xlabel("Release year (n = songs in the group)")
left.set_ylabel("Median danceability (0–100)")
left.set_title("Median danceability by release group", color=ms.MUTED, fontsize=10)
# Right: every song, and the regression line of danceability on release year.
slope, intercept, r = ms.regression(known["release_year"], known["danceability_pct"])
right.scatter(known["release_year"], known["danceability_pct"], s=12, color=ms.CONTEXT, alpha=0.6,
linewidths=0)
x = np.array([known["release_year"].min(), known["release_year"].max()])
right.plot(x, intercept + slope * x, color=ms.HIGHLIGHT, linewidth=2.2)
right.annotate(f"Regression line\nslope = {slope:.2f} points per year\nr = {r:.2f}",
xy=(0.03, 0.06), xycoords="axes fraction", ha="left", va="bottom", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold")
right.set_ylim(0, 100)
right.set_xlabel("Release year")
right.set_ylabel("Danceability (0–100)")
right.set_title(f"Every song ({len(known):,})", color=ms.MUTED, fontsize=10)
for ax in (left, right):
ax.grid(axis="y")
ax.set_axisbelow(True)
older = dance_by_release.drop(recent)
ms.finish([left, right],
f"The newest songs are the most danceable: {dance_by_release[recent].min():.0f}+ for "
f"2021–23, {older.min():.1f}–{older.max():.1f} before",
f"Danceability · Spotify's 0–100 score · {len(known):,} songs, grouped by release year",
"Source: data/songs_2023.csv", tag=ms.tag(5, 17))
# The two-line ticks push the axis label down, so the caption moves below it.
left.texts[-1].xyann = (0, -58)
ms.save(fig, n=5, slug="danceability-by-release-year")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether the newest songs on the list are also the most danceable. If they are, danceability gets tangled up with song age, and age cuts streams.
- ↕️ Up the side: danceability, Spotify's 0–100 score. On the left it's the typical (middle) song of each group; on the right it's every song.
- ↔️ Along the bottom: the year the song came out. The left panel splits it into eight groups from 1930–99 to 2023, with n, the number of songs in each. The right panel shows it as a year from 1930 to 2023.
- ▮ The marks: each bar is one group's middle song (its median), and the pink bars are 2021, 2022 and 2023. Each dot is one song, and the pink line is the regression line, the straight line that fits the dots best.
- 💡 What it tells us: the three newest groups have the highest medians: 71.5 for 2021, 71.0 for 2022 and 71.0 for 2023. Every older group falls between 59.5 and 67.0. The line slopes up, r = 0.19 (0 means no pattern, 1 a perfect uphill line). Bad Bunny's Me Porto Bonito (2022) scores 91, and Queen's Bohemian Rhapsody (1975) scores 41. The newest songs are the most danceable and have had the least time to collect streams, so danceability looks worse than it is.
What we found. And the newest songs are the most danceable. Songs from 2021, 2022 and 2023 have a typical danceability of 71–71.5, while every older group sits between 59.5 and 67.0. That's the trap in hypothesis 1: the most danceable songs are mostly the newest, and the newest have had the least time to collect streams. 2022 and 2023 alone make up 60% of the list.
Show the code
# Play the most-streamed songs released 2021-23, the newest and most danceable group.
from mixtape import mixtape
recent = songs[songs["release_year"] >= 2021].nlargest(5, "streams_millions")
display(mixtape(recent, "Released 2021–23: the 5 most-streamed", "danceability_pct"))
SIDE A · TRACK 06Digging deeper · Songs released in 2023 onlyLine up songs of the same age, and danceability finally gets a fair test.¶
Compare songs that have had about the same time to collect streams: the ones released in 2023. Same test as hypothesis 1. If danceability brings streams, median streams rise from the lowest quarter to the highest, and r is positive, among songs of the same age.
Show the code
# Hypothesis 1's computations again, on the songs released in 2023 only. Call those songs
# released_2023, and their medians dance_2023.
released_2023 = songs[songs["release_year"] == 2023]
dance_2023, dance_2023_n = quarter_medians(released_2023, "danceability_pct", "streams_millions")
display(ms.table(pd.DataFrame({"median streams (millions)": dance_2023.round(1), "songs": dance_2023_n})))
display(ms.table(slope_and_r(released_2023, "danceability_pct", "streams_millions").to_frame("2023 releases").round(3), index_label="measure"))
| danceability_pct | median streams (millions) | songs |
|---|---|---|
| (42.999, 62.0] | 117.7 | 47 |
| (62.0, 71.0] | 118 | 46 |
| (71.0, 79.0] | 84.8 | 42 |
| (79.0, 93.0] | 112.7 | 40 |
| measure | 2023 releases |
|---|---|
| slope | 0.171 |
| r | 0.013 |
| songs | 175 |
🔬 What we ran, and whyThe fair test: songs of the same age
Hypothesis 1's recipe on the 175 songs released in 2023 only. They had roughly the same time to collect streams, so age can't explain any difference left.
Show the code
# Figure: the test of hypothesis 1 on the 2023 releases, from dance_2023 and
# released_2023.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4.5))
# Left: the median streams of each danceability quarter, with each bar's median and number of songs.
known = released_2023.dropna(subset=["streams_millions"])
per_quarter = known.groupby(pd.qcut(released_2023["danceability_pct"], 4), observed=True).size()
ranges = [f"{int(np.floor(q.left)) + 1}–{int(q.right)}" for q in dance_2023.index]
bars = left.bar(ranges, dance_2023.values, color=ms.CONTEXT, width=0.65)
for bar, median, n in zip(bars, dance_2023.values, per_quarter.values):
middle = bar.get_x() + bar.get_width() / 2
left.annotate(f"{median:.1f}", xy=(middle, median), xytext=(0, 4), textcoords="offset points",
ha="center", va="bottom", fontsize=10, color=ms.INK, fontweight="bold")
left.annotate(f"n = {n}", xy=(middle, 0), xytext=(0, 6), textcoords="offset points",
ha="center", va="bottom", fontsize=9, color=ms.on(ms.CONTEXT))
left.set_ylim(0, dance_2023.max() * 1.18)
left.set_xlabel("Danceability quarter (0–100)")
left.set_ylabel("Median streams (millions)")
left.set_title("Median streams by quarter", color=ms.MUTED, fontsize=10)
# Right: every 2023 release with a stream count, and the regression line of streams on danceability.
slope, intercept, r = ms.regression(known["danceability_pct"], known["streams_millions"])
right.scatter(known["danceability_pct"], known["streams_millions"], s=14, color=ms.CONTEXT,
alpha=0.7, linewidths=0)
x = np.array([known["danceability_pct"].min(), known["danceability_pct"].max()])
right.plot(x, intercept + slope * x, color=ms.HIGHLIGHT, linewidth=2.2)
right.annotate(f"Regression line\nslope = {slope:.2f} million streams per point\nr = {r:.2f}",
xy=(0.03, 0.90), xycoords="axes fraction", ha="left", va="top", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold")
right.set_ylim(0, known["streams_millions"].max() * 1.05)
right.yaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
right.set_xlabel("Danceability (0–100)")
right.set_ylabel("Streams (millions)")
right.set_title(f"Every 2023 release ({len(known):,})", color=ms.MUTED, fontsize=10)
for ax in (left, right):
ax.grid(axis="y")
ax.set_axisbelow(True)
ms.finish([left, right],
f"Among songs of the same age, danceability still does not bring streams: r = {r:.2f}",
f"Streams since release · millions · {len(known):,} songs released in 2023, in four "
"equal-sized quarters by danceability",
f"Source: data/songs_2023.csv (the {len(known):,} of the {len(songs):,} songs on the list "
"that were released in 2023)", tag=ms.tag(6, 17))
ms.save(fig, n=6, slug="streams-by-danceability-2023")
plt.show()
🎧 How to read this chart
- 📌 What it's about: the danceability test again, but only among songs released in 2023, so every song has had about the same time to collect streams.
- ↕️ Up the side: streams in millions: each group's middle song on the left, every 2023 release on the right.
- ↔️ Along the bottom: danceability, in four equal groups on the left (43–62 up to 80–93) and 0–100 on the right.
- ▮ The marks: bars are each group's middle song with its n; each dot is one of the 175 songs; the pink line is the regression line, the straight line that fits them best.
- 💡 What it tells us: the regression line is flat, and the correlation is r = 0.01. With age taken out, danceability tells you nothing about streams. Flowers, the year's biggest new song at 1.3 billion streams, scores 71, right in the middle.
What we found. With age taken out, danceability still doesn't bring streams. Among the 175 songs released in 2023, the four danceability groups have typical counts of 117.7, 118.0, 84.8 and 112.7 million, with no rise from least to most danceable, and the correlation is r = 0.01, flat. Flowers, the year's biggest new song at 1.3 billion streams, scores 71, right in the middle. So hypothesis 1 still does not hold, even in a fair test.
Show the code
# Play the songs this test evaluated: 2023's most-streamed new releases.
from mixtape import mixtape
display(mixtape(released_2023.nlargest(5, "streams_millions"), "Released in 2023: the 5 most-streamed", "danceability_pct"))
SIDE A · TRACK 07Digging deeper · What the list leaves outA list of hits can't show what makes a hit. The catalog holds hits and misses alike.¶
Every song on the list was one of 2023's most-streamed. A list of hits cannot show what separates a hit from a song that did not become one. The catalog holds both, measured by popularity, Spotify's own score from 0 to 100, based mostly on how many plays a track has had and how recent they are. Find the list's songs in the catalog, and see where they sit among every track and against the popularity of the catalog's top 10%.
The two files share no ID, so match on the track name and first artist, in lower case.
Show the code
# - A matching name for every song and every track: the track name plus the first artist (split
# the artists on "," or ";"), in lower case, with spaces trimmed.
# - hit_line: the popularity of the catalog's top 10%, the 0.9 quantile of the tracks' popularity.
def match_name(track, artists):
"""Track name plus first artist, lower case, trimmed: the key shared by both files."""
first_artist = artists.str.split(r"[,;]").str[0]
return (track.str.strip() + " | " + first_artist.str.strip()).str.lower()
songs["match_name"] = match_name(songs["track"], songs["artists"])
tracks["match_name"] = match_name(tracks["track"], tracks["artists"])
hit_line = tracks["popularity"].quantile(0.9)
display(ms.table(pd.Series({"hit_line: popularity of the catalog's top 10%": hit_line}, name="popularity"),
index_label="measure"))
| measure | popularity |
|---|---|
| hit_line: popularity of the catalog's top 10% | 60 |
🔬 What we ran, and whyMatching the two files, and the hit line
- The files share no ID, so the match is on track name + first artist, lower case, trimmed. Its blind spot: 36 songs list the same artists in a different order and go unmatched (flagged in the data notebook).
- hit_line is the popularity only the catalog's top 10% reach (the 0.9 quantile): a fixed bar for "hit".
Show the code
# - Give each list song the highest popularity among the tracks with its matching name; the songs
# that get one are the ones found in the catalog. Call their popularity found.
# - A table: songs on the list, how many have a damaged name (the character "\ufffd"), how many were
# found, the median popularity of the found songs and of every track, hit_line, and the share of
# found songs at or above it.
best_popularity = tracks.groupby("match_name")["popularity"].max()
songs["popularity"] = songs["match_name"].map(best_popularity)
found = songs.dropna(subset=["popularity"])
damaged = songs["track"].str.contains("\ufffd") | songs["artists"].str.contains("\ufffd")
damaged_in_file = songs["track_in_file"].str.contains("\ufffd") | songs["artists_in_file"].str.contains("\ufffd")
display(ms.table(pd.Series({
"songs on the list": len(songs),
"songs with a damaged name in the file": damaged_in_file.sum(),
"songs with a damaged name after the approved repairs": damaged.sum(),
"songs found in the catalog": len(found),
"not found: same artists in the catalog, in a different order (flagged in the data notebook)":
(songs["artist_order_differs_from_catalog"] & songs["popularity"].isna()).sum(),
"median popularity of the found songs": found["popularity"].median(),
"median popularity of every track": tracks["popularity"].median(),
"hit_line": hit_line,
"share of found songs at or above hit_line": round((found["popularity"] >= hit_line).mean(), 3),
}, name="value"), index_label="measure"))
| measure | value |
|---|---|
| songs on the list | 949 |
| songs with a damaged name in the file | 109 |
| songs with a damaged name after the approved repairs | 40 |
| songs found in the catalog | 308 |
| not found: same artists in the catalog, in a different order (flagged in the data notebook) | 36 |
| median popularity of the found songs | 84 |
| median popularity of every track | 33 |
| hit_line | 60 |
| share of found songs at or above hit_line | 0.925 |
Show the code
# Figure: the popularity of the list songs found in the catalog compared with
# every track, from found, tracks and hit_line.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
bins = np.arange(0, 101, 5)
# Shares of each group, not counts: the catalog has hundreds of times as many tracks as were found.
share = lambda s: np.ones(len(s)) / len(s)
ax.hist(tracks["popularity"], bins=bins, weights=share(tracks), color=ms.CONTEXT)
ax.hist(found["popularity"], bins=bins, weights=share(found), histtype="step", linewidth=2.2,
color=ms.HIGHLIGHT)
ax.yaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.grid(axis="y")
ax.set_axisbelow(True)
top = ax.get_ylim()[1] * 1.22
ax.set_ylim(0, top)
# The hit line and each group's median as labeled lines, each label just left of its own line.
above = (found["popularity"] >= hit_line).mean()
ax.axvline(hit_line, color=ms.MUTED, linestyle=":", linewidth=1.6, zorder=3)
ax.annotate(f"Top 10% line: {hit_line:.0f}\n{above:.0%} of found\nsongs at or above",
xy=(hit_line, top * 0.80), xytext=(-6, 0), textcoords="offset points", ha="right",
va="top", fontsize=10, color=ms.MUTED, fontweight="bold")
for group, data, color, style in [
("Catalog median", tracks, ms.INK, "--"),
("Found median", found, ms.HIGHLIGHT, "-"),
]:
m = data["popularity"].median()
ax.axvline(m, color=color, linestyle=style, linewidth=1.4, zorder=3)
ax.annotate(f"{group}: {m:.0f}", xy=(m, top), xytext=(-6, -4), textcoords="offset points",
ha="right", va="top", fontsize=10, color=color, fontweight="bold")
# Direct labels in place of a legend.
ax.annotate(f"List songs\nfound in\nthe catalog\n{len(found):,} songs", xy=(63, 0.12), ha="left",
va="bottom", fontsize=10, color=ms.HIGHLIGHT)
ax.annotate(f"Every track in\nthe catalog\n{len(tracks):,} tracks", xy=(2, top * 0.55), ha="left",
va="top", fontsize=10, color=ms.INK)
ax.set_xlim(0, 100)
ax.set_xlabel("Spotify popularity (0–100)")
ax.set_ylabel("Share of the group (%)")
ms.finish(ax, f"The hits sit far above the catalog: {above:.0%} clear its top-10% line",
f"Popularity in 5-point bins · share of each group · {len(found):,} found list songs, "
f"{len(tracks):,} catalog tracks",
f"Source: data/songs_2023.csv, data/catalog.csv (only the {len(found):,} of {len(songs):,} "
"list songs matched by track name and first artist)", tag=ms.tag(7, 17))
ms.save(fig, n=7, slug="popularity-found-vs-catalog")
plt.show()
🎧 How to read this chart
- 📌 What it's about: where 2023's most-streamed songs land on Spotify's popularity score, next to every track in the catalog.
- ↕️ Up the side: the share of each group that lands in each popularity band. Each group adds up to 100%, so 308 songs and 89,583 tracks can be compared fairly despite their sizes.
- ↔️ Along the bottom: Spotify popularity, its own 0–100 score built mostly from how many plays a track has had and how recent they are, in bands of 5.
- ▮ The shapes: grey bars are the catalog, the pink outline is the list songs found in it. The dashed and solid lines mark each group's median, its middle track when all are lined up by popularity. The dotted line is the hit line, the score only the catalog's top 10% reach (60).
- 💡 What it tells us: the pink shape sits almost entirely past the hit line: the found songs' middle score is 84, the catalog's 33, and 93% of the found songs reach 60 or more. Unholy (Sam Smith, Kim Petras) tops them at 100. The small pink bump near 0 is hits like Taylor Swift's cardigan, whose only matching copy in the catalog scores 0, a re-release rather than the song people stream.
What we found. The list songs really are hits by the catalog's own measure. Matching by title and first artist found 308 of the 949 list songs in the catalog (repairing the damaged names found 27 more; 36 others are in the catalog with their artists in a different order, which this match can't see). Their median, the middle song, scores 84 on Spotify's popularity, against 33 for the catalog's middle track. And 92.5% of them clear the hit line, popularity 60, a score only the catalog's top 10% reach. That matters: the catalog holds both hits and misses, so it can show what separates them, which the list alone never could.
Show the code
# Play the 5 most popular list songs found in the catalog.
from mixtape import mixtape
display(mixtape(found.nlargest(5, "popularity"), "The list's 5 most popular songs in the catalog", "popularity"))
SIDE A · TRACK 08Digging deeper · Danceability across the whole catalogDanceability's fairest test yet: every track, hits and misses, on one popularity scale.¶
Hypothesis 1's test again, now on every track whose sound was measured, hits and misses alike. Popularity takes the place of streams. If danceability brings success, median popularity rises from the lowest quarter to the highest, and r is positive.
Show the code
# Hypothesis 1's computations on every track, with popularity in place of streams. Call the medians
# dance_catalog.
dance_catalog, dance_catalog_n = quarter_medians(tracks, "danceability_pct", "popularity")
display(ms.table(pd.DataFrame({"median popularity (0-100)": dance_catalog, "tracks": dance_catalog_n})))
display(ms.table(slope_and_r(tracks, "danceability_pct", "popularity").rename({"songs": "tracks"})
.to_frame("popularity on danceability").round(3), index_label="measure"))
| danceability_pct | median popularity (0-100) | tracks |
|---|---|---|
| (5.129, 45.0] | 28 | 22,401 |
| (45.0, 57.6] | 35 | 22,401 |
| (57.6, 69.2] | 36 | 22,406 |
| (69.2, 98.5] | 35 | 22,375 |
| measure | popularity on danceability |
|---|---|
| slope | 0.078 |
| r | 0.066 |
| tracks | 89,583 |
🔬 What we ran, and whyHypothesis 1 across the whole catalog
The same recipe on all 89,583 measured tracks, hits and misses alike, with popularity in place of streams, because the catalog has no stream counts.
Show the code
# Figure: the test of hypothesis 1 on every track in the catalog, from
# dance_catalog and tracks.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4.5))
# Left: the median popularity of each danceability quarter, with each bar's median and number of tracks.
known = tracks.dropna(subset=["popularity"])
per_quarter = known.groupby(pd.qcut(tracks["danceability_pct"], 4), observed=True).size()
ranges = [f"{int(np.floor(q.left)) + 1}–{int(q.right)}" for q in dance_catalog.index]
# The lowest quarter starts at the lowest score in the catalog.
ranges[0] = f"{int(tracks['danceability_pct'].min())}–{int(dance_catalog.index[0].right)}"
bars = left.bar(ranges, dance_catalog.values, color=ms.CONTEXT, width=0.65)
for bar, median, n in zip(bars, dance_catalog.values, per_quarter.values):
middle = bar.get_x() + bar.get_width() / 2
left.annotate(f"{median:.0f}", xy=(middle, median), xytext=(0, 4), textcoords="offset points",
ha="center", va="bottom", fontsize=10, color=ms.INK, fontweight="bold")
# n on two lines, so five-digit counts leave room between neighboring bars.
left.annotate(f"{n:,}\ntracks", xy=(middle, 0), xytext=(0, 6), textcoords="offset points",
ha="center", va="bottom", fontsize=9, color=ms.on(ms.CONTEXT))
left.set_ylim(0, 100)
left.set_xlabel("Danceability quarter (0–100)")
left.set_ylabel("Median popularity (0–100)")
left.set_title("Median popularity by quarter", color=ms.MUTED, fontsize=10)
# Right: every track, and the regression line of popularity on danceability. The dots are small and
# faint so the dense middle still shows where most tracks sit.
slope, intercept, r = ms.regression(known["danceability_pct"], known["popularity"])
right.scatter(known["danceability_pct"], known["popularity"], s=3, color=ms.CONTEXT, alpha=0.15,
linewidths=0, rasterized=True)
x = np.array([known["danceability_pct"].min(), known["danceability_pct"].max()])
right.plot(x, intercept + slope * x, color=ms.HIGHLIGHT, linewidth=2.4)
right.annotate(f"Regression line\nslope = {slope:.2f} points\nper danceability point\nr = {r:.2f}",
xy=(0.03, 0.97), xycoords="axes fraction", ha="left", va="top", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold",
bbox=dict(facecolor=ms.BG, edgecolor="none", alpha=0.9, pad=3))
right.set_ylim(0, 100)
right.set_xlim(0, 100)
right.set_xlabel("Danceability (0–100)")
right.set_ylabel("Popularity (0–100)")
right.set_title(f"Every track ({len(known):,})", color=ms.MUTED, fontsize=10)
for ax in (left, right):
ax.grid(axis="y")
ax.set_axisbelow(True)
ms.finish([left, right],
f"Across the whole catalog, danceability barely moves popularity: r = {r:.2f}",
f"Spotify popularity · 0–100 · {len(known):,} catalog tracks whose sound was measured, in four "
"equal-sized quarters by danceability",
f"Source: data/catalog.csv (one row per track; the {catalog['danceability_pct'].isna().sum()} "
"rows with no rhythm analysis are left out)", tag=ms.tag(8, 17))
ms.save(fig, n=8, slug="popularity-by-danceability-catalog")
plt.show()
🎧 How to read this chart
- 📌 What it's about: hypothesis 1's test on the whole catalog, hits and misses alike, with Spotify's popularity score in place of streams.
- ↕️ Up the side: popularity, Spotify's 0–100 score. On the left, the typical (middle) track of each group; on the right, every track.
- ↔️ Along the bottom: danceability, in four equal groups on the left (5–45 up to 70–98) and 0–100 on the right.
- ▮ The marks: each bar is one group's middle track (its median), with the number of tracks inside it. Each faint dot is one of the 89,583 tracks; the pink line is the regression line, the straight line that fits them best.
- 💡 What it tells us: only the least danceable group falls behind (28); the other three sit together at 35, 36 and 35, with no climb from the middle to the top. The line rises just 0.08 points of popularity per point of danceability, and the correlation is r = 0.07 (0 means no straight-line pattern, 1 a perfect one). The catalog's three most popular tracks score 71, 62 and 56 on danceability: Unholy, at the top with 100, is danceable but nowhere near the most danceable group's ceiling of 98.
What we found. Across the whole catalog, hits and misses together, danceability barely moves popularity. The least danceable quarter's middle track scores 28; the other three score 35, 36 and 35, with no climb from there. The correlation is r = 0.07, close to no straight-line pattern at all. Unholy, the catalog's most popular track at 100, sits at danceability 71, in the middle of the pack. On the fairest test yet, danceability doesn't bring success.
Show the code
# Play the most popular tracks in the least and the most danceable quarters of the catalog.
from mixtape import mixtape, span
quarter = pd.qcut(tracks["danceability_pct"], 4)
for q, which in [(quarter.cat.categories[0], "Least"), (quarter.cat.categories[-1], "Most")]:
picks = tracks[quarter == q].nlargest(5, "popularity")
display(mixtape(picks, f"{which} danceable quarter ({span(q)}): its 5 most popular tracks", "danceability_pct"))
SIDE A · TRACK 09Digging deeper · Popularity by genreIf how a track sounds doesn't separate hits from misses, does the kind of music it is?¶
The catalog has a genre for every row: the genre search that found the track. Does genre separate tracks more than danceability does? Compare the mean popularity of the genres; the table lists the five at each end.
Show the code
# - The mean popularity of each genre, from every catalog row, from least to most popular. Call it
# by_genre.
# - Show the five least and five most popular genres.
by_genre = catalog.groupby("genre")["popularity"].mean().sort_values()
ends = pd.concat([by_genre.head(5), by_genre.tail(5)]).round(1)
display(ms.table(ends.rename("mean popularity (0-100)").to_frame()
.assign(end=["least popular"] * 5 + ["most popular"] * 5), index_label="genre"))
# To compare like with like, the danceability quarters' popularity as means too (the test above used
# medians, as planned).
dance_catalog_means = tracks.groupby(pd.qcut(tracks["danceability_pct"], 4), observed=True)["popularity"].mean()
display(ms.table(dance_catalog_means.round(1).rename("mean popularity (0-100)").to_frame(),
index_label="danceability quarter"))
| genre | mean popularity (0-100) | end |
|---|---|---|
| iranian | 2.2 | least popular |
| romance | 3.2 | least popular |
| latin | 8.3 | least popular |
| detroit-techno | 11.2 | least popular |
| chicago-house | 12.3 | least popular |
| grunge | 49.6 | most popular |
| sad | 52.4 | most popular |
| chill | 53.7 | most popular |
| k-pop | 57 | most popular |
| pop-film | 59.3 | most popular |
| danceability quarter | mean popularity (0-100) |
|---|---|
| (5.129, 45.0] | 30.4 |
| (45.0, 57.6] | 34.4 |
| (57.6, 69.2] | 34.4 |
| (69.2, 98.5] | 33.6 |
🔬 What we ran, and whyMean popularity by genre
The mean of every catalog row in each genre. Each genre has about 1,000 rows, so no single track sways it much.
Show the code
# Figure: the mean popularity of every genre, from by_genre.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
# One named bar per genre: horizontal, and tall enough (0.16 inch a bar) that every name fits.
fig, ax = plt.subplots(figsize=(8, 0.16 * len(by_genre) + 1.6))
# Every genre from least popular (bottom) to most popular (top), with the five at each end (the ten
# in the table above) highlighted, and each bar's mean printed at its tip.
ends = list(by_genre.index[:5]) + list(by_genre.index[-5:])
colors = [ms.HIGHLIGHT if genre in ends else ms.CONTEXT for genre in by_genre.index]
rows = np.arange(len(by_genre))
bars = ax.barh(rows, by_genre.values, color=colors, height=0.72)
for bar, genre, mean in zip(bars, by_genre.index, by_genre.values):
ax.annotate(f"{mean:.1f}", xy=(mean, bar.get_y() + bar.get_height() / 2), xytext=(3, 0),
textcoords="offset points", ha="left", va="center", fontsize=9,
color=ms.HIGHLIGHT if genre in ends else ms.MUTED,
fontweight="bold" if genre in ends else "normal", zorder=4,
# A background patch, so the dashed mean line never runs through a number.
bbox=dict(boxstyle="square,pad=0.05", facecolor=ms.BG, edgecolor="none"))
ax.set_yticks(rows, by_genre.index, fontsize=9)
ax.set_ylim(-0.8, len(by_genre) - 0.2)
# The range of the four danceability quarters' means, shaded behind the bars, so the comparison the
# section asks about (does genre spread popularity more than danceability?) is on the chart.
low, high = dance_catalog_means.min(), dance_catalog_means.max()
band = ms.palette(2)[1] # the theme's second color, so the band is not read as a highlight
ax.axvspan(low, high, color=band, alpha=0.2, zorder=0.5, linewidth=0)
ax.annotate(f"Danceability quarters' means:\n{low:.1f} to {high:.1f}", xy=(high, 10), xytext=(6, 0),
textcoords="offset points", ha="left", va="center", fontsize=10, color=band,
fontweight="bold")
# The mean over every catalog row as a labeled reference line.
overall = catalog["popularity"].mean()
ax.axvline(overall, color=ms.INK, linestyle="--", linewidth=1.2, zorder=3)
ax.annotate(f"Mean over every row: {overall:.1f}", xy=(overall, 20), xytext=(6, 0),
textcoords="offset points", ha="left", va="center", fontsize=10, color=ms.INK,
fontweight="bold")
# The highlight named in words beside each end.
ax.annotate("Five most\npopular", xy=(by_genre.iloc[-1], len(by_genre) - 3), xytext=(32, 0),
textcoords="offset points", ha="left", va="center", fontsize=10, color=ms.HIGHLIGHT,
fontweight="bold")
# On two lines, so it ends well short of the dashed mean line.
ax.annotate("Five least\npopular", xy=(by_genre.iloc[4], 2), xytext=(28, 0),
textcoords="offset points", ha="left", va="center", fontsize=10, color=ms.HIGHLIGHT,
fontweight="bold")
ax.set_xlim(0, 75)
ax.set_xlabel("Mean popularity (0–100)")
ax.grid(axis="x")
ax.grid(axis="y", visible=False)
ax.set_axisbelow(True)
per_genre = catalog.groupby("genre").size()
ms.finish(ax,
f"Genre splits popularity wide: means run from {by_genre.min():.0f} to {by_genre.max():.0f}",
f"Mean Spotify popularity of each of {len(by_genre)} genres · 0–100 · {len(catalog):,} "
f"catalog rows, {per_genre.min():,}–{per_genre.max():,} per genre",
"Source: data/catalog.csv (one row per track and genre search; a track found by two "
"searches counts in both genres)", tag=ms.tag(9, 17))
ms.save(fig, n=9, slug="popularity-by-genre")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether genre separates popular tracks from unpopular ones, using every row of the catalog.
- ↕️ Up the side: the 114 genres, each named, from least popular at the bottom to most popular at the top. Each holds about 1,000 rows, so the bars compare like with like.
- ↔️ Along the bottom: popularity, Spotify's 0–100 score, as each genre's mean (the average: add up every track's score and divide by the number of tracks).
- ▮ Each bar is one genre's mean popularity, printed at its tip. The pink bars are the five at each end, the ten in the table above; the dashed line is the mean over every row, 33.2; the blue band is the range of the four danceability quarters' means, 30.4 to 34.4.
- 💡 What it tells us: genre spreads popularity far more than danceability did. Genre means run from 2.2 (iranian) to 59.3 (pop-film), a gap of about 57 points, while the four danceability quarters' means fit inside the blue band, a 4-point spread from 30.4 to 34.4. A pop-film track averages 59, about 26 points above the catalog mean; an iranian track averages barely 2.
What we found. Genre separates tracks far more than danceability does. Genre averages run from 2.2 (iranian) to 59.3 (pop-film), a 57-point spread. Measured the same way, as means, the four danceability quarters span only 4 points (30.4 to 34.4). Pop-film, k-pop, chill, sad and grunge lead; iranian, romance, latin, detroit-techno and chicago-house trail. One caution to carry forward: every genre has 999 or 1,000 rows, a number the collector chose, so the Question 2 section checks what that does to genre as a predictor.
Show the code
# Play the most popular track of each of the five most and five least popular genres.
from mixtape import mixtape
for genres, which in [(by_genre.index[::-1][:5], "most"), (by_genre.index[:5], "least")]:
picks = pd.concat([catalog[catalog["genre"] == g].nlargest(1, "popularity") for g in genres])
display(mixtape(picks, f"The top track of each of the five {which} popular genres", "genre"))
SIDE B · TRACK 10Digging deeper · Do tracks group by sound?Genre is a label people give. Set it aside: does sound alone sort tracks into natural groups?¶
Genre is a label people assign. Put the labels aside: if tracks are grouped only by how they sound, do natural groups appear? K-means clustering sorts tracks into k groups: it places each track in the group whose center (the average of the group's members) is nearest, and repeats until the groups stop changing. The six features are on different spreads, so standardize them first (each becomes its distance from the mean in standard deviations); otherwise the widest one decides the groups.
Two measures help choose k. The within-cluster spread (the total squared distance of tracks from their group's center) always falls as k grows; look for an elbow, where it stops falling fast. The silhouette score runs from -1 to 1 and says how much closer a track is to its own group than to the next one: near 1, groups far apart; near 0, groups that overlap. (It is computed on a sample of 10,000 tracks to keep it quick.) If tracks form natural groups by sound, the spread has a clear elbow and the silhouette is high at that k.
Show the code
# - The six sound features: danceability, energy, acousticness, valence, speechiness and
# instrumentalness. Standardize them with StandardScaler.
# - Fit k-means for k = 2 to 8 (n_init=10, random_state=6120). For each k, keep the within-cluster
# spread (the fit's inertia_) and the silhouette score, computed on a random sample of 10,000
# tracks (np.random.default_rng(6120)).
# - Show both as a table indexed by k. Call it choosing_k.
SOUND = ["danceability_pct", "energy_pct", "acousticness_pct", "valence_pct", "speechiness_pct",
"instrumentalness_pct"]
sound = StandardScaler().fit_transform(tracks[SOUND]) # each feature in standard deviations from its mean
sample = np.random.default_rng(SEED).choice(len(sound), 10_000, replace=False)
fits, rows = {}, {}
for k in range(2, 9):
fits[k] = KMeans(n_clusters=k, n_init=10, random_state=SEED).fit(sound)
rows[k] = {"within-cluster spread": fits[k].inertia_,
"silhouette": silhouette_score(sound[sample], fits[k].labels_[sample])}
choosing_k = pd.DataFrame(rows).T.rename_axis("k")
display(ms.table(choosing_k.round({"within-cluster spread": 0, "silhouette": 3}), index_label="k"))
| k | within-cluster spread | silhouette |
|---|---|---|
| 2 | 405,247 | 0.285 |
| 3 | 336,671 | 0.233 |
| 4 | 285,604 | 0.247 |
| 5 | 247,364 | 0.25 |
| 6 | 211,683 | 0.273 |
| 7 | 192,076 | 0.279 |
| 8 | 176,983 | 0.254 |
🔬 What we ran, and whyK-means for 2 to 8 groups, and two ways to judge them
- Standardized first: each feature becomes its distance from the mean in standard deviations, so no single feature's scale decides the groups.
- K-means places each track in the group whose center is nearest, then moves the centers, until nothing changes (best of 10 starts).
- Within-cluster spread always falls as k grows; an elbow, where it stops falling fast, suggests a natural k. Silhouette (−1 to 1) says how much closer tracks sit to their own group than the next; near 0 means groups overlap.
Show the code
# Figure: the within-cluster spread and the silhouette score for each k, from
# choosing_k.
fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4.5), sharex=True)
ks = choosing_k.index.to_numpy()
spread, silhouette = choosing_k["within-cluster spread"], choosing_k["silhouette"]
picked = 4 # the fit the next section uses, highlighted in both panels
# Left: the within-cluster spread at each k, in thousands, with each point's value printed above
# and to the right, clear of the falling line (the last one centered below, so it stays in the panel).
left.plot(ks, spread / 1_000, color=ms.CONTEXT, linewidth=2, marker="o", markersize=7, zorder=3)
left.plot(picked, spread[picked] / 1_000, "o", color=ms.HIGHLIGHT, markersize=11, zorder=4)
for k, value in spread.items():
last = k == ks.max()
left.annotate(f"{value:,.0f}", xy=(k, value / 1_000), xytext=(0, -12) if last else (7, 6),
textcoords="offset points", ha="center" if last else "left",
va="top" if last else "bottom", fontsize=9,
color=ms.HIGHLIGHT if k == picked else ms.MUTED,
fontweight="bold" if k == picked else "normal")
left.set_ylim(0, spread.max() / 1_000 * 1.18)
left.set_ylabel("Within-cluster spread (thousands)")
left.set_title("Spread falls steadily, with no sharp elbow", color=ms.MUTED, fontsize=10)
# The highlight named in words, in the open space under the line.
left.annotate(f"k = {picked}, the fit used next", xy=(picked, spread[picked] / 1_000),
xytext=(0, -58), textcoords="offset points", ha="center", va="top", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold",
arrowprops=dict(arrowstyle="-", color=ms.HIGHLIGHT, linewidth=1))
# Right: the silhouette score at each k, on an axis tall enough that its low level shows, with a
# labeled reference line at 0.5, a common bar for clearly separate groups.
right.plot(ks, silhouette, color=ms.CONTEXT, linewidth=2, marker="o", markersize=7, zorder=3)
right.plot(picked, silhouette[picked], "o", color=ms.HIGHLIGHT, markersize=11, zorder=4)
for k, value in silhouette.items():
right.annotate(f"{value:.3f}", xy=(k, value), xytext=(0, 9), textcoords="offset points",
ha="center", va="bottom", fontsize=9,
color=ms.HIGHLIGHT if k == picked else ms.MUTED,
fontweight="bold" if k == picked else "normal")
right.axhline(0.5, color=ms.INK, linestyle="--", linewidth=1.2)
right.annotate("0.5: a common bar for clearly separate groups", xy=(ks.max(), 0.5), xytext=(0, 5),
textcoords="offset points", ha="right", va="bottom", fontsize=10, color=ms.INK,
fontweight="bold")
right.set_ylim(0, 0.6)
right.set_ylabel("Silhouette score (-1 to 1)")
right.set_title("Silhouette stays near 0.25 at every k", color=ms.MUTED, fontsize=10)
for ax in (left, right):
ax.set_xticks(ks)
ax.set_xlim(ks.min() - 0.5, ks.max() + 0.7)
ax.set_xlabel("Number of groups, k")
ax.grid(axis="y")
ax.grid(axis="x", visible=False)
ax.set_axisbelow(True)
ms.finish([left, right],
"Tracks do not fall into clear sound groups at any k",
f"K-means on six standardized sound features · k = 2 to 8 · {len(tracks):,} tracks "
f"(silhouette on a sample of {len(sample):,})",
"Source: data/catalog.csv, one row per track (duplicates across genre searches dropped)",
tag=ms.tag(10, 17))
ms.save(fig, n=10, slug="choosing-k")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether the 89,583 tracks fall into natural groups by sound alone, judged two ways for every number of groups k from 2 to 8.
- ↕️ Up the side: on the left, the within-cluster spread (the total squared distance of every track from its group's center, in thousands; lower means tighter groups). On the right, the silhouette score, which runs from -1 to 1 and says how much closer a track sits to its own group than to the next one.
- ↔️ Along the bottom: k, the number of groups k-means was asked to make, the same in both panels.
- ▮ Each dot is one k-means fit, with its value printed beside it. The pink dot is k = 4, the fit the next section uses; the dashed line on the right is 0.5, a common bar for groups that are clearly apart.
- 💡 What it tells us: the spread falls at every step, from 405,247 at k = 2 to 176,983 at k = 8, shrinking a little less each time with no sharp bend, so there is no clear elbow. The silhouette never gets near 0.5: it stays between 0.233 and 0.285 (highest at k = 2). At k = 4 it is 0.247, so a typical track is only about 25% closer to the tracks in its own group than to those in the nearest other group.
What we found. Sound alone doesn't sort tracks into clean groups. The within-cluster spread falls steadily from k = 2 to 8 with no sharp elbow, and the silhouette, how much closer tracks sit to their own group than to the next, stays between 0.23 and 0.29 at every k, far under the 0.5 that marks clearly separate groups. Music blends: most tracks sit between sounds, not inside one.
SIDE B · TRACK 11Digging deeper · The sound groups, popularity, and genreFour groups by sound: what each sounds like, how popular each is, and whether genres live in one.¶
Use the four-group fit to ask three questions. What does each group sound like, how popular is it, and do the groups line up with genres? The table gives each group's median on every feature, its median popularity, and its number of tracks.
Show the code
# - Label each track with its group from the k = 4 fit, numbered 1 to 4.
# - For each group: the median of each sound feature and of popularity, and its number of tracks.
# Call it profile, and show it as a table.
tracks["sound_group"] = fits[4].labels_ + 1
by_group = tracks.groupby("sound_group")
profile = by_group[SOUND + ["popularity"]].median().round(1).assign(tracks=by_group.size())
display(ms.table(profile, index_label="sound group"))
| sound group | danceability_pct | energy_pct | acousticness_pct | valence_pct | speechiness_pct | instrumentalness_pct | popularity | tracks |
|---|---|---|---|---|---|---|---|---|
| 1 | 63.7 | 66.8 | 38 | 50.7 | 39.9 | 0 | 26 | 3,884 |
| 2 | 68.2 | 72.3 | 17.3 | 70.8 | 5.1 | 0 | 35 | 36,196 |
| 3 | 46.5 | 29.4 | 83.2 | 27.4 | 3.8 | 0.1 | 30 | 21,198 |
| 4 | 49.1 | 83.9 | 1 | 29 | 5.4 | 0.3 | 33 | 28,305 |
🔬 What we ran, and whyThe four groups' profiles
Each group's median on every sound feature and on popularity, with its size: what each group sounds like, and whether sound goes with success.
Show the code
# Figure: the median popularity of each sound group, from profile.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
# Each group named by its number and by what sets it apart: how far its median on each feature sits
# from the median of the four groups, in standard deviations over every track, so features with
# different spreads compare fairly. Up to two features more than half a standard deviation above,
# and the one more than half below.
WORDS = {"danceability_pct": "danceable", "energy_pct": "energetic", "acousticness_pct": "acoustic",
"valence_pct": "upbeat", "speechiness_pct": "wordy", "instrumentalness_pct": "instrumental"}
apart = (profile[SOUND] - profile[SOUND].median()) / tracks[SOUND].std()
sounds = []
for g in profile.index:
highs = [WORDS[f] for f in apart.loc[g].nlargest(2).index if apart.loc[g, f] > 0.5]
lows = [f"not {WORDS[f]}" for f in apart.loc[g].nsmallest(1).index if apart.loc[g, f] < -0.5]
sounds.append(",\n".join(highs + lows))
ticks = [f"Group {g}\n{s}" for g, s in zip(profile.index, sounds)] # one word a line, so neighbors keep apart
# One series, so one color: no group is singled out before the data speak.
overall = tracks["popularity"].median()
bars = ax.bar(ticks, profile["popularity"], color=ms.CONTEXT, width=0.62)
for bar, median, n in zip(bars, profile["popularity"], profile["tracks"]):
middle = bar.get_x() + bar.get_width() / 2
# A bar whose top sits just under the dashed median line gets its number inside the bar, so the
# line never runs into it; every other number sits on top of its bar.
inside = overall - 6 < median < overall
ax.annotate(f"{median:.0f}", xy=(middle, median), xytext=(0, -4 if inside else 4),
textcoords="offset points", ha="center", va="top" if inside else "bottom",
fontsize=11, color=ms.on(ms.CONTEXT) if inside else ms.INK, fontweight="bold",
zorder=4)
ax.annotate(f"{n:,}\ntracks", xy=(middle, 0), xytext=(0, 6), textcoords="offset points",
ha="center", va="bottom", fontsize=9, color=ms.on(ms.CONTEXT))
# The median over every track as a labeled reference line, its label in open space past the last bar.
ax.axhline(overall, color=ms.INK, linestyle="--", linewidth=1.2, zorder=3)
ax.annotate(f"Median of\nevery track: {overall:.0f}", xy=(len(profile) - 0.55, overall), xytext=(0, 0),
textcoords="offset points", ha="left", va="center", fontsize=10, color=ms.INK,
fontweight="bold", bbox=dict(boxstyle="square,pad=0.2", facecolor=ms.BG, edgecolor="none"))
ax.set_xlim(-0.55, len(profile) + 0.45)
ax.set_ylim(0, 100)
ax.set_xlabel("Sound group from k-means, k = 4 (what sets its sound apart)")
ax.set_ylabel("Median popularity (0–100)")
ax.grid(axis="y")
ax.grid(axis="x", visible=False)
ax.set_axisbelow(True)
low, high = profile["popularity"].min(), profile["popularity"].max()
ms.finish(ax, f"How a track sounds moves its popularity only a little: medians run {low:.0f} to {high:.0f}",
f"Median Spotify popularity of each sound group · 0–100 · {profile['tracks'].sum():,} tracks",
"Source: data/catalog.csv, one row per track (duplicates across genre searches dropped); "
"groups from k-means on six standardized sound features", tag=ms.tag(11, 17))
# The three-line ticks push the axis label down, so the caption moves below it.
ax.texts[-1].xyann = (0, -70)
ms.save(fig, n=11, slug="popularity-by-sound-group")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether the four sound groups that k-means found differ in how popular their tracks are.
- ↕️ Up the side: the median Spotify popularity of each group, on Spotify's 0–100 scale. The median is the middle value when every track in the group is lined up from least to most popular, so a few huge hits can't drag it.
- ↔️ Along the bottom: the four sound groups, each named by what sets its sound apart from the other three: a feature where its median sits more than half a standard deviation (the typical distance of a track from the average) above or below the middle of the four groups.
- ▮ Each bar is one sound group, with its median popularity at its top and its number of tracks at its base. Group 1 is small and wordy: songs with a lot of talking or rapping, like Caile by Luar La L, which scores 44.8 on speechiness when the other groups' medians sit near 5. Group 3 is the quiet, acoustic group, like Joji's Glimpse of Us (acousticness 89.1). The dashed line is the median of every track, 33.
- 💡 What it tells us: the group medians run from 26 (Group 1, wordy) to 35 (Group 2, upbeat and danceable): a 9-point spread on a 100-point scale, and every group sits within 7 points of the overall 33.
For each genre, what share of its tracks falls in its most common sound group? A genre that is one sound would put nearly all its tracks in one group. The table lists the three genres least and most concentrated, and the median genre.
Show the code
# For each genre, the share of its tracks in its most common sound group (pd.crosstab with
# normalize="index", then the largest share in each row). Show the three lowest, the three highest,
# and the median genre.
genre_rows = catalog.merge(tracks[["track_id", "sound_group"]], on="track_id") # every row: one genre search
top_share = pd.crosstab(genre_rows["genre"], genre_rows["sound_group"], normalize="index").max(axis=1).sort_values()
middle = top_share.index[len(top_share) // 2]
display(ms.table(pd.concat([top_share.head(3), top_share[[middle]], top_share.tail(3)]).round(3)
.rename("share in its most common sound group").to_frame()
.assign(where=["least concentrated"] * 3 + ["median genre"] + ["most concentrated"] * 3),
index_label="genre"))
| genre | share in its most common sound group | where |
|---|---|---|
| german | 0.369 | least concentrated |
| brazil | 0.393 | least concentrated |
| anime | 0.402 | least concentrated |
| goth | 0.606 | median genre |
| black-metal | 0.959 | most concentrated |
| death-metal | 0.978 | most concentrated |
| grindcore | 0.98 | most concentrated |
🔬 What we ran, and whyDoes a genre live in one sound group?
pd.crosstab with normalize="index" gives each genre's split across the four groups; its largest share says how much the genre is one sound. Near 1: one sound. Near 0.25: spread across all four.
What we found. The four groups are easy to hear: talky (speechiness 40, mostly rap and spoken word), upbeat (the most danceable, 68, and happiest, 71), acoustic and quiet (acousticness 83, energy 29), and loud (energy 84, acousticness 1). But their median popularity spans only 26 to 35, a 9-point range, against 57 points between genres. Some genres are one sound: death-metal (98%), grindcore (98%) and black-metal (96%) sit almost entirely in the loud group. Others spread out: german (37%), brazil (39%) and anime (40%). The median genre, goth, keeps 60.6% of its tracks in one group. Genre and sound overlap, but genre isn't just sound, and neither is a strong guide to popularity on its own.
Show the code
# Hear the four sound groups: the most popular track in each.
from mixtape import mixtape
for group, picks in tracks.sort_values("popularity", ascending=False).groupby("sound_group").head(2).groupby("sound_group"):
display(mixtape(picks, f"Sound group {group}: its 2 most popular tracks", "popularity", limit=2))
SIDE B · INTERLUDESynthesis · What stood in for danceabilityEvery finding so far, side by side: what really goes with streams and success?¶
What we found. Danceability was never the engine. Here's what stood in for it, finding by finding:
- The hits are more danceable than music in general: the list's median is 69, the catalog's 57.6. That much of the belief is true.
- But within the list, more danceable doesn't mean more streams: the least danceable quarter leads, and r = −0.11. Happier songs don't win either (r = −0.04).
- Age does the work. A stream count is a running total: songs from 2000–09 have a typical 1,229 million streams, and 2023 releases 108 million. The newest songs are also the most danceable (71+), so danceability looked bad partly because it rides with newness.
- Hold age fixed and danceability tells you nothing: among 2023 releases, r = 0.01.
- Across the whole catalog, hits and misses alike, danceability barely moves popularity (r = 0.07). Sound groups overlap (silhouette near 0.25) and differ by only 9 popularity points, while genres differ by 57.
Hypothesis 3 holds: something other than how a song sounds explains its streams, first of all how long it has been out. Genre separates popularity far more than sound does, and Question 2 tests whether that, or anything known before release, could have picked the hits.
Show the code
# Reveal: your call on Q1·H3 beside the data.
display(HTML(ms.reveal(CALLS, "Q1·H3", holds=True, verdict="Age stands in for sound: 2000–09 songs have 1,229M streams, 2023 releases 108M; with age held fixed, r = 0.01.")))
SIDE B · INTERLUDEQuestion 2 · Could we have predicted a hit before release?The label can't wait for streams to pile up. Could anything known on release day have picked the hits?¶
A hit here is a track in the catalog's top 10% by popularity. Every test below uses only what is known before a song comes out: how it sounds and its genre. Hold back 30% of the tracks as a test set: every model is fit on the other 70% (the training set) and judged only on tracks it has never seen, so its score shows how it would do on new songs.
What counts as singling out the hits. A label can only promote a handful of songs, so judge each model by its 15 top picks: the 15 test tracks it rates most likely to be hits. A model singles out the hits if most of its 15 top picks, 8 or more, are hits.
Show the code
# - A hit column: 1 when a track's popularity is at or above hit_line, 0 otherwise.
# - Split tracks into train (70%) and test (30%) with train_test_split (random_state=6120,
# stratified on hit). Show the hit line, the size of each set, and the share of test tracks that
# are hits.
N_PICKS = 15 # a label can promote a handful of songs: each model is judged by its 15 top picks
tracks["hit"] = (tracks["popularity"] >= hit_line).astype(int)
train, test = train_test_split(tracks, test_size=0.3, random_state=SEED, stratify=tracks["hit"])
display(ms.table(pd.Series({
"hit_line (popularity)": hit_line,
"training tracks (70%)": len(train),
"test tracks (30%)": len(test),
"share of test tracks that are hits": round(test["hit"].mean(), 4),
}, name="value"), index_label="measure"))
| measure | value |
|---|---|
| hit_line (popularity) | 60 |
| training tracks (70%) | 62,708 |
| test tracks (30%) | 26,875 |
| share of test tracks that are hits | 0.1094 |
🔬 What we ran, and whyA hit, and a fair exam
- A hit is a track at or above hit_line: the catalog's top 10% by popularity.
- 70/30 split (
train_test_split, stratified on hit, so both sides keep the same 11% share of hits): models learn on 70% and are judged only on tracks they've never seen, like songs not yet released.
SIDE B · TRACK 12Question 2 · The yardstick: 15 tracks picked at randomBefore judging any model, measure what luck alone achieves.¶
Before judging any model, find what no model at all achieves: pick 15 test tracks at random, as a label might fill a playlist without any model, and count the hits. Repeat it 10,000 times. Draw without replacement: a playlist does not hold the same track twice. Count how often each number of hits comes up.
Show the code
# - 10,000 times, pick 15 test tracks at random without replacement and count the hits among them
# (use np.random.default_rng(6120)). Call the counts random_picks.
# - Show the average count, the chance of 5 or more, and the chance of 8 or more.
rng = np.random.default_rng(SEED)
test_hits = test["hit"].to_numpy()
random_picks = np.array([test_hits[rng.choice(len(test_hits), N_PICKS, replace=False)].sum()
for _ in range(10_000)])
assert round(random_picks.mean(), 2) == 1.65 # the luck yardstick
display(ms.table(pd.Series({
"average hits among 15 random picks": round(random_picks.mean(), 2),
"chance of 5 or more": round((random_picks >= 5).mean(), 4),
"chance of 8 or more": round((random_picks >= 8).mean(), 4),
}, name="value"), index_label="measure"))
| measure | value |
|---|---|
| average hits among 15 random picks | 1.65 |
| chance of 5 or more | 0.0177 |
| chance of 8 or more | 0.0002 |
🔬 What we ran, and whyA yardstick from luck alone
Draw 15 test tracks at random, without replacement (a playlist holds a track once), count the hits, and repeat 10,000 times. The spread of counts shows what no model at all achieves, so any model can be compared with it.
Show the code
# Figure: how often each number of hits came up in the 10,000 random picks, from
# random_picks.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
# One bar for every possible count, 0 to 15 hits, so the empty right side shows as empty.
counts = np.bincount(random_picks, minlength=N_PICKS + 1)
hits = np.arange(N_PICKS + 1)
ax.bar(hits, counts, color=[ms.HIGHLIGHT if k >= 8 else ms.CONTEXT for k in hits], width=0.6)
top = counts.max() * 1.3
ax.set_ylim(0, top)
mean = random_picks.mean()
for k, c in zip(hits, counts):
if c:
# A label next to the dashed average line steps away from it, so the two never touch.
nudge = 0 if abs(k - mean) > 0.6 else (10 if k > mean else -10)
ax.annotate(f"{c:,}", xy=(k, c), xytext=(nudge, 4), textcoords="offset points", ha="center",
va="bottom", fontsize=9, color=ms.INK, fontweight="bold", zorder=4,
# A background patch, so the dashed average line breaks around a number.
bbox=dict(boxstyle="square,pad=0.3", facecolor=ms.BG if k < 8 else "none",
edgecolor="none"))
# The zone a model must reach (8 or more hits), shaded and named with its chance by luck.
eight = (random_picks >= 8).mean()
lucky = int((random_picks >= 8).sum())
ax.axvspan(7.5, N_PICKS + 0.5, color=ms.GRID, alpha=0.35, zorder=0, linewidth=0)
ax.annotate(f"8 or more hits: a model that\nsingles out the hits\nBy luck: {lucky} of 10,000 picks ({eight:.2%})",
xy=(11.5, top * 0.55), ha="center", va="center", fontsize=10, color=ms.HIGHLIGHT,
fontweight="bold")
# The average count as a labeled reference line.
ax.axvline(mean, color=ms.MUTED, linestyle="--", linewidth=1.4, zorder=3)
# The label sits between gridlines, so none runs through it.
ax.annotate(f"Average: {mean:.2f} hits", xy=(mean, counts.max() * 1.17), xytext=(6, 0),
textcoords="offset points", ha="left", va="center", fontsize=10, color=ms.MUTED, fontweight="bold")
ax.set_xlim(-0.6, N_PICKS + 0.5)
ax.set_xticks(hits)
ax.set_xlabel(f"Hits among {N_PICKS} randomly picked test tracks")
ax.set_ylabel("Number of picks (of 10,000)")
ax.yaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
ax.grid(axis="y")
ax.grid(axis="x", visible=False)
ax.set_axisbelow(True)
ms.finish(ax, f"By luck, 15 picks reach 8 hits only {lucky} times in 10,000",
f"Hits among {N_PICKS} test tracks picked at random, 10,000 times · count of picks · "
f"{len(test):,} test tracks, {test['hit'].mean():.0%} hits",
"Source: data/catalog.csv, test set (30% of tracks); a hit is a track in the top 10% by "
"popularity; picks without replacement, seed 6120", tag=ms.tag(12, 17))
ms.save(fig, n=12, slug="random-picks")
plt.show()
🎧 How to read this chart
- 📌 What it's about: how many hits you would land if you picked 15 test tracks blind, with no model at all. It's the yardstick a model has to beat.
- ↕️ Up the side: how many of the 10,000 random picks ended with that many hits (a count of picks, out of 10,000).
- ↔️ Along the bottom: the number of hits among the 15 tracks in one pick, from 0 to 15. A hit is a track in the top 10% by popularity, and about 11% of the 26,875 test tracks are hits.
- ▮ Each bar is: one possible number of hits, as tall as the number of picks that landed on it. The dashed line is the average, the sum of all counts divided by 10,000: 1.65 hits. The shaded pink zone is 8 or more hits, the result of a model that really singles out hits.
- 💡 What it tells us: luck stacks up at 1 or 2 hits (3,224 and 2,737 picks) and fades fast to the right. Only 2 of 10,000 random picks (0.02%) reached 8 or more hits: one with 8 and one with 10. That's the bar a model has to clear to show it is doing more than guessing.
What we found. Luck is a weak picker. Fifteen test tracks chosen at random hold 1.65 hits on average, because only 11% of tracks are hits. Five or more happens in 1.8% of tries, and the bar a model must clear, 8 or more, happened just 2 times in 10,000 (0.02%). So a model that gets 8 of its 15 top picks right is doing something luck almost never does.
SIDE B · TRACK 13Question 2 · The strongest predictor we cannot useOne column tracks streams far better than any sound. It just arrives too late.¶
On the 2023 list, one column tracks streams far more closely than any audio feature: the number of Spotify playlists a song is on. Fit the regression line of every song's streams on its playlist count.
Show the code
# - The list songs with a stream count. Call them listed.
# - The slope (np.polyfit) and r of streams on Spotify playlists, as a small table.
listed = songs.dropna(subset=["streams_millions"])
display(ms.table(slope_and_r(listed, "spotify_playlists", "streams_millions")
.to_frame("streams on Spotify playlists").round(4), index_label="measure"))
| measure | streams on Spotify playlists |
|---|---|
| slope | 0.0567 |
| r | 0.7903 |
| songs | 947 |
🔬 What we ran, and whyStreams on playlists
The same line and r as hypothesis 1, with the number of Spotify playlists as the predictor. Playlists are added after release, partly because a song is already streaming, so this measures a signal we'd love to have but can't use to pick hits in advance.
Show the code
# Figure: streams against Spotify playlists, from listed.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
# Every song with a stream count, and the regression line of streams on playlists.
slope, intercept, r = ms.regression(listed["spotify_playlists"], listed["streams_millions"])
ax.scatter(listed["spotify_playlists"] / 1_000, listed["streams_millions"], s=12, color=ms.CONTEXT,
alpha=0.6, linewidths=0)
x = np.array([listed["spotify_playlists"].min(), listed["spotify_playlists"].max()])
ax.plot(x / 1_000, intercept + slope * x, color=ms.HIGHLIGHT, linewidth=2.2)
# The slope and r in the empty top-left corner, clear of the dots.
ax.annotate(f"Regression line\nslope = {slope * 1_000:.1f} million streams\nper 1,000 playlists\n"
f"r = {r:.2f}", xy=(0.03, 0.97), xycoords="axes fraction", ha="left", va="top",
fontsize=10, color=ms.HIGHLIGHT, fontweight="bold")
# Two real songs named: the most streamed, and the one on the most playlists.
# The second label hangs below its dot, clear of the dots above it.
for song, offset, va in [(listed.loc[listed["streams_millions"].idxmax()], (-8, 8), "center"),
(listed.loc[listed["spotify_playlists"].idxmax()], (-4, -4), "top")]:
ax.annotate(f"{song['track'].split(' - ')[0]}\n{song['spotify_playlists']:,} playlists, "
f"{song['streams_millions']:,.0f}M streams",
xy=(song["spotify_playlists"] / 1_000, song["streams_millions"]), xytext=offset,
textcoords="offset points", ha="right", va=va, fontsize=9, color=ms.INK)
ax.set_xlim(0, listed["spotify_playlists"].max() / 1_000 * 1.04)
ax.set_ylim(0, listed["streams_millions"].max() * 1.08)
ax.xaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
ax.yaxis.set_major_formatter(lambda v, _: f"{v:,.0f}")
ax.set_xlabel("Spotify playlists the song is on (thousands)")
ax.set_ylabel("Streams (millions)")
ax.grid(axis="y")
ax.grid(axis="x", visible=False)
ax.set_axisbelow(True)
ms.finish(ax, f"Songs on more playlists have far more streams, r = {r:.2f}",
f"Streams since release · millions · against Spotify playlists · {len(listed):,} songs "
"with a stream count",
f"Source: data/songs_2023.csv ({len(songs) - len(listed)} of the {len(songs):,} songs have "
"no stream count and are left out)", tag=ms.tag(13, 17))
ms.save(fig, n=13, slug="streams-by-playlists")
plt.show()
🎧 How to read this chart
- 📌 What it's about: whether songs on more Spotify playlists also have more streams.
- ↕️ Up the side: streams since release, in millions.
- ↔️ Along the bottom: the number of Spotify playlists the song is on, in thousands.
- ▮ The marks: each dot is one of the 947 songs with a stream count. The pink line is the regression line, the straight line that fits the dots best; its slope says how many more streams go with each extra 1,000 playlists.
- 💡 What it tells us: the dots climb with the line: about 56.7 million more streams for every 1,000 more playlists, and r = 0.79 (r runs from 0, no straight-line pattern, to ±1, a perfect one). Not every song sits on the line: Get Lucky is on the most playlists (52,898) but has 934 million streams, while Blinding Lights tops the streams (3,704 million) on 43,899.
What we found. Playlists track streams far more closely than any sound: r = 0.79, and each extra 1,000 playlists goes with about 57 million more streams. Get Lucky sits on the most playlists (52,898); Blinding Lights has the most streams. The five most-playlisted songs are older classics (Get Lucky, Mr. Brightside, Wake Me Up, Smells Like Teen Spirit, Take On Me): playlists pile up with time, just like streams. And a song lands on playlists after it's released, partly because it's already streaming, so this signal can't pick a hit before release. It shows what a label wishes it knew on release day, and sets up the real test: could anything known before release do the job?
Show the code
# Play the most-playlisted songs on the list.
from mixtape import mixtape
display(mixtape(listed.nlargest(5, "spotify_playlists"), "The list's 5 most-playlisted songs", "spotify_playlists"))
The next three tests fit a logistic regression on the training tracks: a model that turns the features into a score between 0 and 1, the estimated chance that a track is a hit. Each is scored on the test tracks with two numbers:
- Hits among its 15 top picks, against 1.65 for a random pick; 8 or more singles out the hits.
- AUC (area under the curve): pick one hit and one non-hit at random; AUC is the chance the model gives the hit the higher score. 0.5 is a coin flip; 1.0 is perfect. The ROC curve (receiver operating characteristic) is drawn by sorting the test tracks by the model's score and flagging them one at a time from the top. After each track, the share of all hits flagged so far is plotted against the share of all non-hits flagged so far. A model no better than a coin flip flags hits and non-hits at the same rate.
The scaling happens inside the model, so it is learned from the training tracks only.
Show the code
# Write a function fit_and_score(title, numeric, categorical) that:
# - fits a logistic regression of hit on train, in a pipeline that standardizes the numeric columns
# and one-hot encodes the categorical ones (make_column_transformer, make_pipeline);
# - scores every test track (predict_proba(test)[:, 1]);
# - returns (1) a Series named with title, holding the AUC on the test set (roc_auc_score) and the
# number of hits among the 15 test tracks with the highest scores, and (2) the ROC curve as a
# DataFrame of the share of non-hits flagged and the share of hits flagged (roc_curve).
# Also make audio, the ten audio features: danceability, energy, valence, acousticness,
# instrumentalness, liveness and speechiness (the _pct columns), loudness_db, tempo_bpm and duration_s.
def fit_and_score(title, numeric, categorical):
"""Fit a logistic regression of hit on train; score every test track; return its scores and ROC curve."""
parts = [(StandardScaler(), numeric)] + ([(OneHotEncoder(handle_unknown="ignore"), categorical)] if categorical else [])
model = make_pipeline(make_column_transformer(*parts), LogisticRegression(max_iter=1000))
model.fit(train, train["hit"])
score = model.predict_proba(test)[:, 1] # each test track's estimated chance of being a hit
top_picks = test.assign(score=score).nlargest(N_PICKS, "score")
false_share, true_share, _ = roc_curve(test["hit"], score)
scores = pd.Series({"AUC": roc_auc_score(test["hit"], score),
f"hits among its {N_PICKS} top picks": top_picks["hit"].sum()}, name=title)
roc = pd.DataFrame({"share of non-hits flagged": false_share, "share of hits flagged": true_share})
return scores, roc
audio = ["danceability_pct", "energy_pct", "valence_pct", "acousticness_pct", "instrumentalness_pct",
"liveness_pct", "speechiness_pct", "loudness_db", "tempo_bpm", "duration_s"]
🔬 What we ran, and whyLogistic regression, judged two ways
- Logistic regression turns the features into a score from 0 to 1: each track's estimated chance of being a hit. It learns on the training tracks only; scaling and the genre columns are learned inside the pipeline, so nothing leaks from the test tracks.
- AUC: pick one hit and one non-hit at random; AUC is the chance the model scores the hit higher (0.5 = coin flip, 1 = perfect).
- Hits among its 15 top picks: what a label would act on. Luck averages 1.65; 8 or more singles out the hits.
SIDE B · TRACK 14Question 2 · Hypothesis 1 · Danceability aloneIf the label's belief were right, danceability alone should pick the hits.¶
A model with one feature, danceability. If the hypothesis holds, 8 or more of its 15 top picks are hits.
Show the code
# Score a model with danceability as its only feature. Call its results dance_scores and
# dance_roc.
dance_scores, dance_roc = fit_and_score("danceability alone", ["danceability_pct"], [])
display(ms.table(dance_scores.round(3).to_frame(), index_label="measure"))
| measure | danceability alone |
|---|---|
| AUC | 0.552 |
| hits among its 15 top picks | 1 |
Show the code
# Figure: the ROC curve of the danceability model, from dance_roc and dance_scores.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
auc = dance_scores["AUC"]
top_hits = int(dance_scores[f"hits among its {N_PICKS} top picks"])
are_hits = "is a hit" if top_hits == 1 else "are hits"
# The coin-flip diagonal as context, the model's curve as the highlight.
ax.plot([0, 1], [0, 1], color=ms.CONTEXT, linestyle="--", linewidth=1.4)
ax.annotate("Coin flip\nAUC = 0.50", xy=(0.80, 0.80), xytext=(10, -6), textcoords="offset points",
ha="left", va="top", fontsize=10, color=ms.MUTED)
ax.plot(dance_roc["share of non-hits flagged"], dance_roc["share of hits flagged"],
color=ms.HIGHLIGHT, linewidth=2.2)
# The deciding numbers in the empty top-left corner, clear of both lines.
ax.annotate(f"Danceability model\nAUC = {auc:.3f}\n{top_hits} of its {N_PICKS} top picks {are_hits}\n"
"(8 or more would single out the hits)", xy=(0.03, 0.97), xycoords="axes fraction",
ha="left", va="top", fontsize=10, color=ms.HIGHLIGHT, fontweight="bold")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.02)
ax.xaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.yaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.set_xlabel("Share of all non-hits flagged so far (%)")
ax.set_ylabel("Share of all hits flagged so far (%)")
ax.grid(axis="y")
ax.grid(axis="x", visible=False)
ax.set_axisbelow(True)
ms.finish(ax, f"Danceability alone barely beats a coin flip at spotting hits, AUC = {auc:.2f}",
f"ROC curve · test tracks flagged from the highest score down · {len(test):,} tracks, "
f"{int(test['hit'].sum()):,} of them hits",
"Source: data/catalog.csv (test set: the 30% of tracks the model never saw in training)",
tag=ms.tag(14, 17))
ms.save(fig, n=14, slug="danceability-roc")
plt.show()
🎧 How to read this chart
- 📌 What it's about: how well a model that hears only danceability ranks hits above non-hits, against a coin flip.
- ↕️ Up the side: the share of all 2,940 test hits flagged so far (%).
- ↔️ Along the bottom: the share of all 23,935 test non-hits flagged so far (%). The model sorts the 26,875 test tracks by its score, most danceable first, and flags them one at a time from the top.
- ▮ Each line is: the pink line is the model's ROC curve (receiver operating characteristic): each point says, after flagging some tracks, what share of the hits and of the non-hits has been caught. The dashed grey diagonal is a coin flip, which catches hits and non-hits at the same rate; the higher a curve rides above it, the better the ranking. For example, by the time 20% of non-hits are flagged, 25.4% of hits are, a little more than the 20% a coin flip would catch.
- 💡 What it tells us: the pink line hugs the diagonal, so the area under it, the AUC (the chance the model scores a random hit above a random non-hit), is 0.552, just over a coin flip's 0.50. Its 15 top picks are the most danceable tracks, like Medicaid Baby by That Girl Lay Lay (danceability 98.4, popularity 10), and 1 of the 15 is a hit: CoComelon's Polly Put the Kettle On (popularity 61, just over the hit line of 60).
What we found. No. Danceability alone would not have singled out the hits. Its ROC curve hugs the coin-flip line (AUC = 0.552), and only 1 of its 15 top picks is a hit, fewer than luck's 1.65. Its favorite tracks are the most danceable ones in the catalog, and most of them aren't hits.
Show the code
# Reveal: your call on Q2·H1 beside the data.
display(HTML(ms.reveal(CALLS, "Q2·H1", holds=False, verdict="AUC 0.552, and 1 of its 15 top picks is a hit (luck: 1.65; the bar: 8).")))
SIDE B · TRACK 15Question 2 · Hypothesis 2 · All the audio featuresMaybe one measure isn't enough. Does everything Spotify measures about the sound do better?¶
A model with all ten audio features in audio. If the hypothesis holds, 8 or more of its 15 top
picks are hits.
Show the code
# Score a model with all ten audio features. Call its results audio_scores and audio_roc.
audio_scores, audio_roc = fit_and_score("all ten audio features", audio, [])
display(ms.table(audio_scores.round(3).to_frame(), index_label="measure"))
| measure | all ten audio features |
|---|---|
| AUC | 0.644 |
| hits among its 15 top picks | 3 |
Show the code
# Figure: the ROC curve of the audio-features model, from audio_roc and audio_scores.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
auc = audio_scores["AUC"]
top_hits = int(audio_scores[f"hits among its {N_PICKS} top picks"])
are_hits = "is a hit" if top_hits == 1 else "are hits"
# The coin-flip diagonal as context, the model's curve as the highlight.
ax.plot([0, 1], [0, 1], color=ms.CONTEXT, linestyle="--", linewidth=1.4)
ax.annotate("Coin flip\nAUC = 0.50", xy=(0.80, 0.80), xytext=(10, -6), textcoords="offset points",
ha="left", va="top", fontsize=10, color=ms.MUTED)
ax.plot(audio_roc["share of non-hits flagged"], audio_roc["share of hits flagged"],
color=ms.HIGHLIGHT, linewidth=2.2)
# The deciding numbers in the empty top-left corner, clear of both lines.
ax.annotate(f"Audio-features model\nAUC = {auc:.3f}\n{top_hits} of its {N_PICKS} top picks {are_hits}\n"
"(8 or more would single out the hits)", xy=(0.03, 0.97), xycoords="axes fraction",
ha="left", va="top", fontsize=10, color=ms.HIGHLIGHT, fontweight="bold")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.02)
ax.xaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.yaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.set_xlabel("Share of all non-hits flagged so far (%)")
ax.set_ylabel("Share of all hits flagged so far (%)")
ax.grid(axis="y")
ax.grid(axis="x", visible=False)
ax.set_axisbelow(True)
ms.finish(ax, f"All ten audio features rank hits better than a coin flip, AUC = {auc:.2f}",
f"ROC curve · test tracks flagged from the highest score down · {len(test):,} tracks, "
f"{int(test['hit'].sum()):,} of them hits",
"Source: data/catalog.csv (test set: the 30% of tracks the model never saw in training)",
tag=ms.tag(15, 17))
ms.save(fig, n=15, slug="audio-features-roc")
plt.show()
🎧 How to read this chart
- 📌 What it's about: how well a model that hears all ten audio features ranks hits above non-hits, against a coin flip.
- ↕️ Up the side: the share of all 2,940 test hits flagged so far (%).
- ↔️ Along the bottom: the share of all 23,935 test non-hits flagged so far (%). The model sorts the 26,875 test tracks by its score, the one it thinks most likely to be a hit first, and flags them one at a time from the top.
- ▮ Each line is: the pink line is the model's ROC curve: each point says, after flagging some tracks, what share of the hits and of the non-hits has been caught. The dashed grey diagonal is a coin flip, which catches both at the same rate. For example, by the time 20% of non-hits are flagged, 35.3% of hits are, well above the 20% a coin flip would catch and the 25.4% danceability alone caught.
- 💡 What it tells us: the pink line rides clearly above the diagonal, so the AUC (the chance the model scores a random hit above a random non-hit) is 0.644, up from 0.552 for danceability alone. Its 15 top picks lean toward danceable, quiet-energy, non-instrumental tracks: Lil Peep's lil jeep (popularity 68) is a hit, but kids' song Wear Pajamas All Day by Blake Rules (popularity 9) is not, and 3 of the 15 are hits.
What we found. No. All ten audio features together do better than danceability, but not nearly enough: AUC = 0.644, and 3 of its 15 top picks are hits. How a track sounds holds a little signal about popularity, far short of picking the hits.
Show the code
# Reveal: your call on Q2·H2 beside the data.
display(HTML(ms.reveal(CALLS, "Q2·H2", holds=False, verdict="AUC 0.644, and 3 of its 15 top picks are hits (the bar: 8).")))
SIDE B · TRACK 16Question 2 · Hypothesis 3 · The audio features and genreGenre is known before release, and it split popularity 57 points. Does it pick the hits?¶
The same ten features plus genre, which is known before a song comes out. Genre is a category, not a number, so the model gets one 0/1 column per genre (one-hot encoding). If the hypothesis holds, 8 or more of its 15 top picks are hits.
Show the code
# Score a model with the ten audio features plus genre, as a category. Call its results
# genre_scores and genre_roc.
genre_scores, genre_roc = fit_and_score("audio features and genre", audio, ["genre"])
display(ms.table(pd.concat([dance_scores, audio_scores, genre_scores], axis=1).round(3), index_label="measure"))
| measure | danceability alone | all ten audio features | audio features and genre |
|---|---|---|---|
| AUC | 0.552 | 0.644 | 0.855 |
| hits among its 15 top picks | 1 | 3 | 10 |
Show the code
# Figure: the ROC curve of the audio-features-and-genre model, from genre_roc and genre_scores.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
fig, ax = plt.subplots(figsize=(8, 4.5))
auc = genre_scores["AUC"]
top_hits = int(genre_scores[f"hits among its {N_PICKS} top picks"])
are_hits = "is a hit" if top_hits == 1 else "are hits"
# The coin-flip diagonal as context, the model's curve as the highlight.
ax.plot([0, 1], [0, 1], color=ms.CONTEXT, linestyle="--", linewidth=1.4)
ax.annotate("Coin flip\nAUC = 0.50", xy=(0.80, 0.80), xytext=(10, -6), textcoords="offset points",
ha="left", va="top", fontsize=10, color=ms.MUTED)
ax.plot(genre_roc["share of non-hits flagged"], genre_roc["share of hits flagged"],
color=ms.HIGHLIGHT, linewidth=2.2)
# The deciding numbers in the empty space below the diagonal, between gridlines and clear of both lines.
ax.annotate(f"Audio-features-and-genre model\nAUC = {auc:.3f}\n{top_hits} of its {N_PICKS} top picks {are_hits}\n"
"(8 or more would single out the hits)", xy=(0.97, 0.22), xycoords="axes fraction",
ha="right", va="bottom", fontsize=10, color=ms.HIGHLIGHT, fontweight="bold")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.02)
ax.xaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.yaxis.set_major_formatter(lambda v, _: f"{v:.0%}")
ax.set_xlabel("Share of all non-hits flagged so far (%)")
ax.set_ylabel("Share of all hits flagged so far (%)")
ax.grid(axis="y")
ax.grid(axis="x", visible=False)
ax.set_axisbelow(True)
ms.finish(ax, f"Adding genre ranks hits far better than a coin flip, AUC = {auc:.2f}",
f"ROC curve · test tracks flagged from the highest score down · {len(test):,} tracks, "
f"{int(test['hit'].sum()):,} of them hits",
"Source: data/catalog.csv (test set: the 30% of tracks the model never saw in training)",
tag=ms.tag(16, 17))
ms.save(fig, n=16, slug="audio-genre-roc")
plt.show()
🎧 How to read this chart
- 📌 What it's about: how well a model that hears the ten audio features plus each track's genre ranks hits above non-hits, against a coin flip.
- ↕️ Up the side: the share of all 2,940 test hits flagged so far (%).
- ↔️ Along the bottom: the share of all 23,935 test non-hits flagged so far (%). The model sorts the 26,875 test tracks by its score, the one it thinks most likely to be a hit first, and flags them one at a time from the top.
- ▮ Each line is: the pink line is the model's ROC curve: each point says, after flagging some tracks, what share of the hits and of the non-hits has been caught. The dashed grey diagonal is a coin flip, which catches both at the same rate. For example, by the time 20% of non-hits are flagged, 71.8% of hits are, about double the 35.3% the audio features alone caught.
- 💡 What it tells us: the pink line bows far above the diagonal, so the AUC (the chance the model scores a random hit above a random non-hit) is 0.855, up from 0.644 for the audio features alone. Its 15 top picks are mostly metal, pop, pop-film and k-pop tracks, such as Slipknot's Psychosocial (popularity 66, a hit). The model can only hear the song and its genre, so it gives Ed Sheeran's Perfect the same score twice: once as a hit (popularity 87) and once as a release nobody plays (popularity 3). In all, 10 of the 15 are hits.
What we found. On this test, yes. Adding genre lifts the model to AUC = 0.855, and 10 of its 15 top picks are hits, past the bar of 8 that luck reaches 2 times in 10,000. Most of its top picks come from a handful of genres: pop, pop-film, k-pop and metal. The next section checks why those genres score so well.
Show the code
# Reveal: your call on Q2·H3 beside the data.
display(HTML(ms.reveal(CALLS, "Q2·H3", holds=True, verdict="AUC 0.855, and 10 of its 15 top picks are hits; but the next section shows how the catalog's collection inflates genre.")))
SIDE B · TRACK 17Question 2 · How the catalog was collectedBefore trusting genre, check how the genres got into the data.¶
Before trusting genre as a predictor, recall how the catalog was built (the data notebook, audit item 6): the collector searched Spotify for each genre and kept 1,000 results. For each genre's 1,000 rows, find the share that are hits, and compare it with 10%, the share of hits over all tracks.
Show the code
# - For each genre, the share of its catalog rows that are hits (popularity at or above hit_line).
# Call it hit_share.
# - Show the rows per genre, how many genres have no hits, and which have hits in over half their
# rows.
rows_per_genre = catalog["genre"].value_counts()
hit_share = (catalog["popularity"] >= hit_line).groupby(catalog["genre"]).mean().sort_values()
display(ms.table(pd.Series({
"rows per genre (fewest to most)": f"{rows_per_genre.min():,} to {rows_per_genre.max():,}",
"genres with no hits": int((hit_share == 0).sum()),
"genres with hits in over half their rows": ", ".join(hit_share[hit_share > 0.5].index),
"share of hits over all tracks": f"{tracks['hit'].mean():.1%}",
}, name="value"), index_label="measure"))
| measure | value |
|---|---|
| rows per genre (fewest to most) | 999 to 1,000 |
| genres with no hits | 8 |
| genres with hits in over half their rows | k-pop, pop-film, pop |
| share of hits over all tracks | 10.9% |
Show the code
# Figure: the share of each genre's rows that are hits, compared with 10%, the
# share of hits over all tracks, from hit_share.
import sys; sys.path.append(".claude/skills/fresh-figures/scripts")
import fresh_style as ms
ms.use_theme()
# One named bar per genre: horizontal, and tall enough (0.16 inch a bar) that every name fits.
fig, ax = plt.subplots(figsize=(8, 0.16 * len(hit_share) + 1.6))
# Every genre from fewest hits (bottom) to most (top). The genres with no hits and the genres with
# hits in over half their rows (the table above) are highlighted, and each bar's share is printed
# at its tip, so the genres with no bar at all still read "0%" (shares under 1% get a decimal, so
# they don't read as 0% too).
no_hits = hit_share.index[hit_share == 0]
over_half = hit_share.index[hit_share > 0.5]
ends = set(no_hits) | set(over_half)
colors = [ms.HIGHLIGHT if genre in ends else ms.CONTEXT for genre in hit_share.index]
rows = np.arange(len(hit_share))
bars = ax.barh(rows, hit_share.values * 100, color=colors, height=0.72)
for bar, genre, share in zip(bars, hit_share.index, hit_share.values):
label = f"{share:.1%}" if 0 < share < 0.01 else f"{share:.0%}"
ax.annotate(label, xy=(share * 100, bar.get_y() + bar.get_height() / 2), xytext=(3, 0),
textcoords="offset points", ha="left", va="center", fontsize=9,
color=ms.HIGHLIGHT if genre in ends else ms.MUTED,
fontweight="bold" if genre in ends else "normal",
# Above the dashed line, on a background patch, so the line never runs
# through a number (R7).
zorder=5,
bbox=dict(boxstyle="square,pad=0.05", facecolor=ms.BG, edgecolor="none"))
ax.set_yticks(rows, hit_share.index, fontsize=9)
ax.set_ylim(-0.8, len(hit_share) - 0.2)
# The share of hits over all tracks as a labeled reference line.
overall = tracks["hit"].mean()
ax.axvline(overall * 100, color=ms.INK, linestyle="--", linewidth=1.2, zorder=3)
ax.annotate(f"Hits over all tracks: {overall:.1%}", xy=(overall * 100, len(hit_share) * 0.27),
xytext=(30, 0), textcoords="offset points", ha="left", va="center", fontsize=10,
color=ms.INK, fontweight="bold",
bbox=dict(boxstyle="square,pad=0.15", facecolor=ms.BG, edgecolor="none"),
arrowprops=dict(arrowstyle="-", color=ms.INK, linewidth=1))
# The highlight named in words beside each end.
ax.annotate(f"{len(over_half)} genres with hits\nin over half their rows",
xy=(hit_share.max() * 100, len(hit_share) - len(over_half) / 2 - 0.5), xytext=(40, 0),
textcoords="offset points", ha="left", va="center", fontsize=10, color=ms.HIGHLIGHT,
fontweight="bold")
ax.annotate(f"{len(no_hits)} genres with no hits at all", xy=(0, len(no_hits) / 2 - 0.5),
xytext=(60, 0), textcoords="offset points", ha="left", va="center", fontsize=10,
color=ms.HIGHLIGHT, fontweight="bold")
ax.set_xlim(0, 100)
ax.xaxis.set_major_formatter(lambda v, _: f"{v:.0f}%")
ax.set_xlabel(f"Share of the genre's rows that are hits, popularity {hit_line:g} or more (%)")
ax.grid(axis="x")
ax.grid(axis="y", visible=False)
ax.set_axisbelow(True)
ms.finish(ax,
f"Hit shares run from {hit_share.min():.0%} to {hit_share.max():.0%} by genre: "
f"{len(no_hits)} have none, {len(over_half)} are mostly hits",
f"Share of hits in each of {len(hit_share)} genres · % · {len(catalog):,} catalog rows, "
f"{rows_per_genre.min():,}–{rows_per_genre.max():,} per genre",
"Source: data/catalog.csv (one row per track and genre search; the overall share counts "
"each track once)", tag=ms.tag(17, 17))
ms.save(fig, n=17, slug="hit-share-by-genre")
plt.show()
🎧 How to read this chart
- 📌 What it's about: how many of each genre's search results are hits, set beside the share of hits over all tracks.
- ↕️ Up the side: all 114 genres, one bar each, from the fewest hits at the bottom to the most at the top.
- ↔️ Along the bottom: the share of the genre's rows (about 1,000 each) with popularity 60 or more, which this notebook calls a hit (%).
- ▮ Each bar is: one genre's share of hits, printed at its tip. Pink marks the two ends from the table above: genres with no hits, and genres with hits in over half their rows. The dashed line is the share of hits over all tracks, 10.9%, counting each track once. If genre made no difference, every bar would sit near that line. For example, pop's 1,000 rows hold 644 hits, led by Sam Smith and Kim Petras's Unholy (popularity 100), while honky-tonk's best tracks, George Jones songs like He Stopped Loving Her Today, stop at 56, so its bar is empty.
- 💡 What it tells us: the bars spread from 0% to 64%, far from one shared 10.9%: 8 genres have no hits and 3 (pop, pop-film, k-pop) are mostly hits.
What we found. Genre's power is partly how the catalog was built. The collector searched Spotify one genre at a time and kept 1,000 rows per genre. Searches for some genres returned mostly popular tracks: 64% of pop's rows are hits, 53% of pop-film's and 50% of k-pop's, against 10.9% over all tracks, while 8 genres returned no hits at all. A model that knows the genre is partly learning which search a track came from. So hypothesis 3 holds on this test, but the test can't say whether genre would pick hits among songs actually released next year. That needs data collected the way releases happen, not by genre search.
SIDE B · OUTROLimits · What this analysis cannot tell usEvery finding has edges. Here they are, so nobody leans on a number harder than it can hold.¶
- One reading of every stream count. Streams were read once, sometime between 14 July and 26 August 2023, and the publisher never says when. A running total mixes how appealing a song is with how long it has been out, and one reading can't pull those apart.
- A list of hits can't explain hits. All 949 songs on the 2023 list succeeded. The catalog holds misses too, but it measures popularity (Spotify's 0–100 score, weighted to recent plays), not streams. Findings about one measure don't transfer straight to the other.
- Matching by name misses songs. The match (title + first artist) found 308 of 949 list songs. 36 more are in the catalog with their artists in a different order, and 40 names stay damaged even after the evidence-based repairs. The songs that were found may not be typical of the list.
- Genre partly reflects how the catalog was built. Every genre has 1,000 rows from one genre search, and hit shares run from 0% to 64%. A model that knows the genre partly knows which search found the track, so its 10 of 15 can't be expected on next year's releases.
- Sound is Spotify's estimate, and it can't be re-measured. Danceability, valence and the rest are Spotify's own scores, and Spotify stopped giving new apps access to them in 2024. One likely wrong credit is documented but not changed (traitor, credited to Juan Cruz Toledo but matching Olivia Rodrigo's song).
- Patterns, not causes. Playlists track streams closely (r = 0.79), but each feeds the other, and nothing here says why listeners pick a song.
- One split, one seed. Each model was judged on one 30% test set. A different split would move its 15 top picks by a hit or two.
SIDE B · OUTROThe memo · To the Head of A&RBoth answers on one page: what we found, what to do, and what to get next.¶
Bottom line. Danceable songs don't get more streams. Among 2023's most-streamed songs, the least danceable quarter leads, and once songs of the same age are compared, danceability tells you nothing (r = 0.01); how long a song has been out explains far more. And nothing about how a song sounds would have picked the hits before release: all ten audio features found 3 hits among their 15 top picks. Adding genre found 10, but that result leans on how the catalog was collected.
Evidence.
- The hits are more danceable than music in general (median 69 vs 57.6), which is why the belief feels true.
- Within the list, the least danceable quarter has a typical 366.8 million streams and the most danceable 253.9 million (r = −0.11). Happier-sounding songs don't win either (r = −0.04).
- Age does the work: songs from 2000–09 have a typical 1,229 million streams, 2023 releases 108 million, and the newest songs are the most danceable (71+). Among 2023 releases alone, r = 0.01.
- Sound barely predicts success: danceability alone AUC 0.552 (1 hit of 15), all audio 0.644 (3 of 15). Luck finds 1.65; the bar was 8.
Recommendation. Stop treating danceability as a hit signal. Compare a song only with songs released around the same time (streams per week since release, not all-time totals), and treat genre as a lead to test, not a rule, until it's checked on data that wasn't collected by genre.
Limits. One reading of stream counts with no date; a list made only of hits; popularity in the catalog instead of streams; 36 songs the name match can't see; genre shares set by how the catalog was built (see Limits above).
Next step. Request every song released in a recent window (hits and misses), with weekly streams since release, playlist adds with their dates, and what was known before release (the artist's past streams, label, marketing). That's the data that can tell what really picks a hit.
SIDE B · OUTRORecap · What each hypothesis showedSix claims, six verdicts, each with the number that decides it.¶
| Question | Hypothesis | What the data showed |
|---|---|---|
| 1 | More danceable songs have more streams. | Does not hold. The least danceable quarter leads (366.8 million vs 253.9 million), r = −0.11; among same-age songs, r = 0.01. |
| 1 | More upbeat songs, those with higher valence, have more streams. | Does not hold. The happiest quarter is lowest (261.4 million), r = −0.04. |
| 1 | Something other than how a song sounds explains its streams. | Holds. Age: 2000–09 songs have 1,229 million streams, 2023 releases 108 million; the newest songs are the most danceable. |
| 2 | Danceability alone would have singled out the hits. | Does not hold. AUC 0.552; 1 of its 15 top picks is a hit (luck: 1.65; the bar: 8). |
| 2 | All the audio features together would have singled out the hits. | Does not hold. AUC 0.644; 3 of 15 top picks are hits. |
| 2 | Adding genre, which is known before release, to the audio features would have singled out the hits. | Holds on this test, not proven. AUC 0.855; 10 of 15 top picks are hits, but genres' hit shares (0% to 64%) come partly from how the catalog was collected. |

























