🎶 Hit Song Predictor: Amapiano & Afrobeats 🎧¶
This project explores whether a song's audio features and metadata can predict its hit potential. We focus on Amapiano and Afrobeats, genres currently dominating African and global music scenes.
from IPython.display import display, HTML
display(HTML("<p><em> Press play to start the mix if your browser blocked autoplay.</em></p>"))
#Playlist intro
display(HTML("""
<h3>🎧 Now Playing: Amapiano & Afrobeats Mix</h3>
<p><strong>Tracklist:</strong><br>
1. Jealousy – Khalil Harrison (Amapiano)<br>
2. Shake Ah – Tyla (Amapiano)<br>
3. Woman – Rema (Afrobeats)<br>
4. Zenzele – Uncle Waffles (Amapiano)<br>
5. No Competition – Davido ft. Asake (Afrobeats)<br>
6. Bunda – Musa Keys (Amapiano)<br>
7. Laho – Shalipopi (Afrobeats)<br>
8. My Darling – Chella (Afrobeats)
</p>
"""))
#Audio player (autoplay with user controls)
display(HTML("""
<audio autoplay controls>
<source src="Afrobeats and Amapiano mix.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
"""))
Press play to start the mix if your browser blocked autoplay.
🎧 Now Playing: Amapiano & Afrobeats Mix
Tracklist:
1. Jealousy – Khalil Harrison (Amapiano)
2. Shake Ah – Tyla (Amapiano)
3. Woman – Rema (Afrobeats)
4. Zenzele – Uncle Waffles (Amapiano)
5. No Competition – Davido ft. Asake (Afrobeats)
6. Bunda – Musa Keys (Amapiano)
7. Laho – Shalipopi (Afrobeats)
8. My Darling – Chella (Afrobeats)
Purpose of Project¶
Applying machine learning to music has proven insightful: computers can detect qualities of songs that resonate with audiences. Academic resemarch has shown that hit songs often share certain characteristics. For example, a study analyzing 30 years of music found “successful songs are happier, brighter, more party-like, more danceable and less sad than most songs” (Kaplan, 2018).In summary, building a hit song prediction model is important and appealing because it combines scientific rigor with cultural relevance. It helps company stakeholders make smarter decisions.¶
1. Import Libraries and Set Up¶
#Import necessary libraries for Spotify API access, data handling, and file management
import spotipy
from spotipy.oauth2 import SpotifyOAuth
import pandas as pd
import time
import os
#Create a directory called "data" to save all downloaded or processed datasets
#This keeps the project organized and avoids cluttering the main directory
os.makedirs("data", exist_ok=True)
2. Spotify API Authentication¶
#Authenticate with Spotify's API using OAuth 2.0 flow
#This gives access to user-specific data like saved tracks and private playlists
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(
client_id=os.environ["SPOTIPY_CLIENT_ID"],
client_secret= os.environ["SPOTIPY_CLIENT_SECRET"],
redirect_uri="http://127.0.0.1:8891/callback",
scope="playlist-read-private playlist-read-collaborative"
))
#Test connection by pulling a few liked songs from Spotify account
#This confirms that access token works and successfully authenticatedresults = sp.current_user_saved_tracks(limit=5)
results = sp.current_user_saved_tracks(limit=5)
for item in results['items']:
track = item['track']
print(f"{track['name']} by {track['artists'][0]['name']}")
GIRLFRIEND by Tayc For The Night by Tayo J Petit génie by Jungeli Focus On Me (All The Sexy Girls In The Club) by Darkoo That Should Be Me by Justin Bieber
3. Extract Playlists Data from Spotify (looking for popularity score)¶
#Define a dictionary of confirmed working playlists across Afrobeats and Amapiano genres
#Each playlist maps to its unique Spotify ID for reliable API access
working_playlists = {
'Best of Afrobeats 2025': '5FDBAbJobJWaKh1RDiqtyn',
'TikTok Naija': '1H4Ws8FYRXbUCFgpYAZdK3',
'Afrobeats Party': '1U8HSDxH8lXHQ38epJngtG',
'Afrobeat 2025': '7IfWkPjxjtGpHKzvbZd8YV',
'Weekly Top Grooves': '4z8jM6c6NLp0H0szju3flc',
'African Heat': '3Uj9Y8xviyC4BvC9R49reX',
'Best of Amapiano': '1JtkyBr4Is4ni1UQAa9AVg',
'AmaPiano March-April 2025': '74QpABGrU1VAdBlnaJqATL',
'Amapiano New Finds': '7LQ1WmcPCKJtXT5uQl3waU',
'playlist_id' : '5sFUBxyx9qpGDFOCPBEd82'
}
#Authenticate with the Spotify API using secure OAuth credentials
#Scope is limited to playlist access for data collection purposes
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(
client_id=os.environ["SPOTIPY_CLIENT_ID"],
client_secret= os.environ["SPOTIPY_CLIENT_SECRET"],
redirect_uri="http://127.0.0.1:8891/callback",
scope="playlist-read-private playlist-read-collaborative"
))
#Initialize container to hold track-level data collected from all playlists
track_data = []
#Loop through each playlist, fetch up to 100 tracks at a time, and append relevant metadata
for playlist_name, playlist_id in working_playlists.items():
try:
print(f"Fetching from: {playlist_name}")
offset = 0
while True:
results = sp.playlist_items(playlist_id, limit=100, offset=offset)
if not results['items']:
break
for item in results['items']:
track = item['track']
if track: # Make sure it's not None
track_data.append({
'playlist': playlist_name,
'track_name': track['name'],
'artist': track['artists'][0]['name'],
'track_id': track['id'],
'popularity': track['popularity']
})
offset += 100
time.sleep(0.5) #To respect rate limits
except Exception as e:
print(f"Failed to fetch from {playlist_name}: {e}")
#Convert collected data to a structured DataFrame and export to CSV for further analysis
df_tracks = pd.DataFrame(track_data)
# df_tracks.to_csv("playlist_and_all_audio_done.csv", index=False)
Fetching from: Best of Afrobeats 2025 Fetching from: TikTok Naija Fetching from: Afrobeats Party Fetching from: Afrobeat 2025 Fetching from: Weekly Top Grooves Fetching from: African Heat Fetching from: Best of Amapiano Fetching from: AmaPiano March-April 2025 Fetching from: Amapiano New Finds Fetching from: playlist_id
4. Collect Spotify Stream Data for Recent Trending Afrobeats and Amampiano Songs (Will Measure Popularity by Streams Per Day Later)¶
#Stream Data
#📊 Contains total stream counts, genre labels, and release dates for Afrobeats and Amapiano songs
#Data includes music from Nigeria (afrobeats top 30 current spotify songs) and South Africa (top 30 current spotify songs), reflecting regional and cultural relevance
stream_data = {
# --- AFROBEATS songs ---
"my darling": {"streams": 4_924_774, "genre": "Afrobeats", "release_date": "2025-03-27"},
"arike": {"streams": 22_434_165, "genre": "Afrobeats", "release_date": "2025-02-15"},
"are you there?": {"streams": 31_946_737, "genre": "Afrobeats", "release_date": "2024-08-16"},
"awolowo": {"streams": 48_842_828, "genre": "Afrobeats", "release_date": "2024-08-14"},
"beamer": {"streams": 11_580_503, "genre": "Afrobeats", "release_date": "2024-10-14"},
"be there still": {"streams": 6_737_818, "genre": "Afrobeats", "release_date": "2025-02-14"},
"chandelier": {"streams": 17_369_585, "genre": "Afrobeats", "release_date": "2024-11-21"},
"na scra": {"streams": 7_248_693, "genre": "Afrobeats", "release_date": "2025-03-07"},
"doha": {"streams": 27_380_251, "genre": "Afrobeats", "release_date": "2024-07-13"},
"free of charge": {"streams": 1_915_678, "genre": "Afrobeats", "release_date": "2025-03-27"},
"pity this boy (with victony)": {"streams": 21_224_424, "genre": "Afrobeats", "release_date": "2025-02-28"},
"get better": {"streams": 4_857_075, "genre": "Afrobeats", "release_date": "2025-03-21"},
"funds (feat. odumodublvck & chike)": {"streams": 42_455_477, "genre": "Afrobeats", "release_date": "2024-12-06"},
"happy": {"streams": 8_112_951, "genre": "Afrobeats", "release_date": "2025-02-20"},
"hey jago": {"streams": 2_993_456, "genre": "Afrobeats", "release_date": "2025-03-18"},
"joy is coming": {"streams": 37_092_308, "genre": "Afrobeats", "release_date": "2024-12-18"},
"JUJU (feat. Shallipopi)": {"streams": 41_465_826, "genre": "Afrobeats", "release_date": "2024-08-22"},
"kese (dance)": {"streams": 45_694_575, "genre": "Afrobeats", "release_date": "2024-10-15"},
"baby (is it a crime)": {"streams": 35_551_585, "genre": "Afrobeats", "release_date": "2025-02-07"},
"shaolin": {"streams": 12_715_482, "genre": "Afrobeats", "release_date": "2025-02-10"},
"management": {"streams": 6_204_585, "genre": "Afrobeats", "release_date": "2025-01-25"},
"most wanted": {"streams": 1_837_793, "genre": "Afrobeats", "release_date": "2025-04-04"},
"mario kart": {"streams": 8_687_810, "genre": "Afrobeats", "release_date": "2025-02-20"},
"legolas": {"streams": 1_955_624, "genre": "Afrobeats", "release_date": "2025-03-31"},
"trenches luv": {"streams": 4_312_356, "genre": "Afrobeats", "release_date": "2025-02-13"},
"toy girl (with juno & valentino rose)": {"streams": 1_197_925, "genre": "Afrobeats", "release_date": "2025-03-31"},
"venus": {"streams": 26_775_718, "genre": "Afrobeats", "release_date": "2025-02-21"},
"why love": {"streams": 10_384_733, "genre": "Afrobeats", "release_date": "2025-02-12"},
# --- AMAPIANO songs ---
"Sdudla or Slender": {"streams": 2_894_712, "genre": "Amapiano", "release_date": "2025-03-20"},
"Vuma Dlozi Lami (feat. Ancestral Rituals)": {"streams": 9_243_178, "genre": "Amapiano", "release_date": "2024-09-21"},
"Ngisakuthanda": {"streams": 7_241_847, "genre": "Amapiano", "release_date": "2024-09-06"},
"Ngibolekeni (feat. Seun1401, LeeMcKrazy, Blxckie, Pcee, Madumane & Kabelo Sings)": {"streams": 5_852_759, "genre": "Amapiano", "release_date": "2025-01-31"},
"Vuka (feat. Thukuthela)": {"streams": 8_720_251, "genre": "Amapiano", "release_date": "2024-12-15"},
"Uyaphapha Marn (feat. Scotts Maphuma & Kabelo Sings)": {"streams": 4_838_765, "genre": "Amapiano", "release_date": "2025-01-31"},
"Wayengenalutho": {"streams": 4_610_909, "genre": "Amapiano", "release_date": "2025-02-21"},
"Sohlala Sisonke": {"streams": 12_909_636, "genre": "Amapiano", "release_date": "2025-02-14"},
"Bo Gogo (feat. Tracy & Thatohatsi)": {"streams": 6_078_812, "genre": "Amapiano", "release_date": "2025-01-31"},
"HAUSAPIANO - Remix": {"streams": 21_129_492, "genre": "Amapiano", "release_date": "2024-10-31"},
"Uvume Kanjani?": {"streams": 1_289_020, "genre": "Amapiano", "release_date": "2025-03-21"},
"Biri Marung (feat. Sje Konka, Focalistic, DJ Maphorisa, Scotts Maphuma & CowBoii)": {"streams": 23_859_597, "genre": "Amapiano", "release_date": "2024-10-20"},
"Romeo & Juliet": {"streams": 4_674_070, "genre": "Amapiano", "release_date": "2025-01-22"},
"Shapa Bell": {"streams": 1_339_189, "genre": "Amapiano", "release_date": "2025-04-01"},
"Abantwana Bakho (feat. Thatohatsi, Young Stunna & Nkosazana Daughter)": {"streams": 790_174, "genre": "Amapiano", "release_date": "2025-03-28"},
"Malunde (feat. Springle)": {"streams": 864_570, "genre": "Amapiano", "release_date": "2024-12-13"},
"Vulani (feat. Thatohatsi & Tracy)": {"streams": 3_295_409, "genre": "Amapiano", "release_date": "2024-12-09"},
"Skuta Baba - Remix": {"streams": 7_294_010, "genre": "Amapiano", "release_date": "2024-12-06"},
"Ungangilimazi (feat. Frank Mabeat)": {"streams": 4_937_736, "genre": "Amapiano", "release_date": "2024-09-20"},
"All My Life": {"streams": 10_706_356, "genre": "Amapiano", "release_date": "2024-08-30"},
"Awuhlabe Kabili": {"streams": 3_409_948, "genre": "Amapiano", "release_date": "2024-12-06"},
"ZENZELE (feat. Royal MusiQ, Uncool MC, Xduppy, & CowBoii)": {"streams": 2_014_490, "genre": "Amapiano", "release_date": "2025-03-15"},
"Naledi": {"streams": 1_401_369, "genre": "Amapiano", "release_date": "2025-03-10"},
"UYAH! (feat. 2wo Bunnies, Jay Music, & Imbongi Yosizi)": {"streams": 2_223_683, "genre": "Amapiano", "release_date": "2025-02-10"},
"Ngiyakuthanda": {"streams": 3_686_853, "genre": "Amapiano", "release_date": "2025-02-02"},
"Shayi'Moto (feat. Seemah & Yanda Woods)": {"streams": 9_941_374, "genre": "Amapiano", "release_date": "2024-11-01"},
"Wishi Wishi (feat. Scotts Maphuma & Young Stunna)": {"streams":13_211_203, "genre": "Amapiano", "release_date": "2024-09-29"},
"Dear Ex Yami": {"streams": 5_231_036, "genre": "Amapiano", "release_date": "2024-09-22"},
"Ama Gear": {"streams": 11_286_723, "genre": "Amapiano", "release_date": "2023-12-01"},
"Kabza Chant (feat. Young Stunna, Nkosazana Daughter, Mthunzi, Nokwazi, Anzo, Mashudu, Murumba Pitch & Tman Xpress)": {"streams": 7_878_258, "genre": "Amapiano", "release_date": "2024-11-03"},
"Abo Nokthula (feat. The Exclusive SA, Scotts Maphuma, Kabelo Sings, Bontle Smith, 2woshort & Stompiiey)": {"streams": 3_976_467, "genre": "Amapiano", "release_date": "2024-12-28"},
# --- SONG CROSSING BOTH REGIONS (Afrobeats, charted in both NG & SA) ---
"laho": {"streams": 24_981_448, "genre": "Afrobeats", "release_date": "2025-02-21"} # Use the higher NG count
}
5. Extract Tiktok Virality (video uses) Data of Recent Afrobeats and Amapiano Music¶
#TikTok virality classification:
#TikTok virality is calculated as: TikTok uses / days since release, a song is considered a hit if its score is greater than 1,000.
#This rule applies to both Afrobeats and Amapiano songs.
tiktok_trending = {
# --- AFROBEATS ---
"Laho": 1,
"My Darling": 1,
"Arike": 1,
"Are you there?": 0,
"Awolowo": 0,
"Beamer": 0,
"Be There Still": 0,
"Chandelier": 0,
"Na Scra": 1,
"Doha": 0,
"Free of Charge": 0,
"PITY THIS BOY (with Victony)": 0,
"Get Better": 0,
"Funds (feat. ODUMODUBLVCK & Chike)": 1,
"Happy": 1,
"hey jago": 1,
"Joy Is Coming": 1,
"JUJU (feat. Shallipopi)": 1,
"Kese (Dance)": 1,
"Baby (Is it a Crime)": 1,
"Shaolin": 1,
"Management (with BIGKHALID)": 0,
"Most Wanted": 0,
"MARIO KART": 1,
"LEGOLAS": 0,
"Trenches Luv": 1,
"TOY GIRL (with Juno & Valentino Rose)": 0,
"Venus": 1,
"WHY LOVE": 1,
# --- AMAPIANO ---
"Sdudla or Slender": 1,
"Vuma Dlozi Lami (feat. Ancestral Rituals)": 0,
"Ngisakuthanda": 0,
"Ngibolekeni (feat. Seun1401, LeeMcKrazy, Blxckie, Pcee, Madumane & Kabelo Sings)": 1,
"Vuka (feat. Thukuthela)": 1,
"Uyaphapha Marn (feat. Scotts Maphuma & Kabelo Sings)": 1,
"Wayengenalutho": 0,
"Sohlala Sisonke": 1,
"Bo Gogo (feat. Tracy & Thatohatsi)": 1,
"HAUSAPIANO - Remix": 1,
"Uvume Kanjani?": 1,
"Biri Marung (feat. Sje Konka, Focalistic, DJ Maphorisa, Scotts Maphuma & CowBoii)": 1,
"Romeo & Juliet": 1,
"Shapa Bell": 0,
"Abantwana Bakho (feat. Thatohatsi, Young Stunna & Nkosazana Daughter)": 0,
"Malunde (feat. Springle)": 0,
"Vulani (feat. Thatohatsi & Tracy)": 0,
"Skuta Baba - Remix": 1,
"Ungangilimazi (feat. Frank Mabeat)": 0,
"All My Life": 0,
"Awuhlabe Kabili": 0,
"ZENZELE (feat. Royal MusiQ, Uncool MC, Xduppy, & CowBoii)": 0,
"Naledi (w/ Naledi Aphiwe)": 0,
"UYAH! (feat. 2wo Bunnies, Jay Music, & Imbongi Yosizi)": 0,
"Ngiyakuthanda": 0,
"Shayi'Moto (feat. Seemah & Yanda Woods)": 1,
"Wishi Wishi (feat. Scotts Maphuma & Young Stunna)": 1,
"Dear Ex Yami": 1,
"Ama Gear": 0,
"Kabza Chant (feat. Young Stunna, Nkosazana Daughter, Mthunzi, Nokwazi, Anzo, Mashudu, Murumba Pitch & Tman Xpress)": 1,
"Abo Nokthula (feat. The Exclusive SA, Scotts Maphuma, Kabelo Sings, Bontle Smith, 2woshort & Stompiiey)": 0
}
#Create DataFrame just with song names
df = pd.DataFrame({'song': list(tiktok_trending.keys())})
df['viral_on_tiktok'] = df['song'].map(tiktok_trending).fillna(0)
6. Observe Billboard Afrobeat Songs and Flag Recent Trending Songs¶
#Import fuzzy matching
from fuzzywuzzy import fuzz
#Billboard Africa Titles (as is)
billboard_africa_titles = [
"Push 2 Start", "Water", "Move", "Baby (Is it a Crime)", "Shake It To The Max FLY", "Laho", "Get Better", "Piece Of My Heart",
"Why Love", "Update", "Arike", "Be There Still", "Funds", "Joy Is Coming", "PITY THIS BOY with Victony", "SHAOLIN", "Slow",
"Na Scra", "Bad For You", "Kese Dance", "Awake", "Mario Kart", "Trenches Luv", "Hey Jago", "Happy",
"Good Vibes", "Bad Girl", "Who Does That", "Introduction", "Wetego", "Only Fans", "Taxi Driver", "New Taker",
"Macho", "Apres Minuit", "Obimo", "iToro", "Panic", "Louder", "Bend", "JayJay", "Chandelier", "Beamer",
"Going Intro", "Toma Toma", "Break Me Down", "A Million Blessings", "World Best", "lololufe"
]
import re
#Function to clean song titles by removing non-alphanumeric characters
def clean_title(title):
return re.sub(r'[^a-zA-Z0-9]', '', title.lower().strip())
#Cleaned version of Billboard titles
billboard_africa_clean = [clean_title(title) for title in billboard_africa_titles]
#Clean your actual dataframe's song titles (adjust 'song' to your column name if different)
df['song_clean'] = df['song'].apply(clean_title)
from fuzzywuzzy import fuzz
#Function to fuzzily match (akin to a confidence score) song to the Billboard list
def is_billboard_hit(song, threshold=80):
for bb_song in billboard_africa_clean:
score = fuzz.token_sort_ratio(song, bb_song)
if score >= threshold:
return True
return False
#Flag songs that appear in Billboard Africa chart
df['in_billboard_africa'] = df['song_clean'].apply(lambda x: 1 if is_billboard_hit(x) else 0)
#Assign 'Afrobeats' genre only for songs matched to Billboard
df.loc[df['in_billboard_africa'] == 1, 'genre'] = 'Afrobeats'
#Preview Billboard Africa chart hits with genre column
billboard_hits = df[df['in_billboard_africa'] == 1][['song', 'in_billboard_africa', 'genre']]
billboard_hits
| song | in_billboard_africa | genre | |
|---|---|---|---|
| 0 | Laho | 1 | Afrobeats |
| 2 | Arike | 1 | Afrobeats |
| 5 | Beamer | 1 | Afrobeats |
| 6 | Be There Still | 1 | Afrobeats |
| 7 | Chandelier | 1 | Afrobeats |
| 8 | Na Scra | 1 | Afrobeats |
| 11 | PITY THIS BOY (with Victony) | 1 | Afrobeats |
| 12 | Get Better | 1 | Afrobeats |
| 14 | Happy | 1 | Afrobeats |
| 15 | hey jago | 1 | Afrobeats |
| 16 | Joy Is Coming | 1 | Afrobeats |
| 18 | Kese (Dance) | 1 | Afrobeats |
| 19 | Baby (Is it a Crime) | 1 | Afrobeats |
| 20 | Shaolin | 1 | Afrobeats |
| 23 | MARIO KART | 1 | Afrobeats |
| 25 | Trenches Luv | 1 | Afrobeats |
| 28 | WHY LOVE | 1 | Afrobeats |
7. Defining What Makes a 'hit' in Each Category¶
import pandas as pd
from rapidfuzz import fuzz
from IPython.display import display
#Load your playlist CSV
df_tracks = pd.read_csv("playlist_and_all_audio_done.csv")
#Complete genre mapping
target_songs = {
# --- AFROBEATS ---
"My Darling": "Afrobeats",
"Arike": "Afrobeats",
"Are you there?": "Afrobeats",
"Awolowo": "Afrobeats",
"Beamer": "Afrobeats",
"Be There Still": "Afrobeats",
"Chandelier": "Afrobeats",
"Na Scra": "Afrobeats",
"Doha": "Afrobeats",
"Free of Charge": "Afrobeats",
"PITY THIS BOY (with Victony)": "Afrobeats",
"Get Better": "Afrobeats",
"Funds (feat. ODUMODUBLVCK & Chike)": "Afrobeats",
"Happy": "Afrobeats",
"hey jago": "Afrobeats",
"Joy Is Coming": "Afrobeats",
"JUJU (feat. Shallipopi)": "Afrobeats",
"Kese (Dance)": "Afrobeats",
"Baby (Is it a Crime)": "Afrobeats",
"Shaolin": "Afrobeats",
"Management": "Afrobeats",
"Most Wanted": "Afrobeats",
"MARIO KART": "Afrobeats",
"LEGOLAS": "Afrobeats",
"Trenches Luv": "Afrobeats",
"TOY GIRL (with Juno, Valentino Rose)": "Afrobeats",
"Venus": "Afrobeats",
"WHY LOVE": "Afrobeats",
"Laho": "Afrobeats",
# --- AMAPIANO ---
"Sdudla or Slender": "Amapiano",
"Vuma Dlozi Lami (feat. Ancestral Rituals)": "Amapiano",
"Ngisakuthanda": "Amapiano",
"Ngibolekeni (feat. Seun1401, LeeMcKrazy, Blxckie, Pcee, Madumane & Kabelo Sings)": "Amapiano",
"Vuka (feat. Thukuthela)": "Amapiano",
"Uyaphapha Marn (feat. Scotts Maphuma...)": "Amapiano",
"Wayengenalutho": "Amapiano",
"Sohlala Sisonke": "Amapiano",
"Bo Gogo (feat. Tracy & Thathohatsi)": "Amapiano",
"HAUSAPIANO - Remix": "Amapiano",
"Uvume Kanjani?": "Amapiano",
"Biri Marung (feat. Sje Konka, Focalistic, DJ Maphorisa, Scotts Maphuma & CowBoii)": "Amapiano",
"Romeo & Juliet": "Amapiano",
"Shapa Bell": "Amapiano",
"Abantwana Bakho (feat. Thatohatsi, Young Stunna & Nkosazana Daughter)": "Amapiano",
"Malunde (feat. Springle)": "Amapiano",
"Vulani (feat. Thatohatsi & Tracy)": "Amapiano",
"Skuta Baba - Remix": "Amapiano",
"Ungangilimazi (feat. Frank Mabeat)": "Amapiano",
"All My Life": "Amapiano",
"Awuhlabe Kabili": "Amapiano",
"ZENZELE (feat. Royal MusiQ, Uncool MC, Xduppy, & CowBoii)": "Amapiano",
"Naledi": "Amapiano",
"UYAH! (feat. 2wo Bunnies, Jay Music, & Imbongi Yosizi)": "Amapiano",
"Ngiyakuthanda": "Amapiano",
"Shayi'Moto (feat. Seemah & Yanda Woods)": "Amapiano",
"Wishi Wishi (feat. Scotts Maphuma & Young Stunna)": "Amapiano",
"Dear Ex Yami": "Amapiano",
"Ama Gear": "Amapiano",
"Kabza Chant (feat. Young Stunna, Nkosazana Daughter, Mthunzi, Nokwazi, Anzo, Mashudu, Murumba Pitch & Tman Xpress)": "Amapiano",
"Abo Nokthula (feat. The Exclusive SA, Scotts Maphuma, Kabelo Sings, Bontle Smith, 2woshort & Stompiiey)": "Amapiano"
}
# --- Fuzzy Matching Function ---
def fuzzy_match_track(track_name, target_dict, threshold=80):
for key in target_dict.keys():
if fuzz.token_sort_ratio(track_name.lower(), key.lower()) >= threshold:
return key
return None
#Apply fuzzy matching to get matched keys
df_tracks['matched_key'] = df_tracks['track_name'].apply(lambda x: fuzzy_match_track(x, target_songs))
#Filter only matched songs
filtered_df = df_tracks[df_tracks['matched_key'].notna()].copy()
#Map genres
filtered_df['genre'] = filtered_df['matched_key'].map(target_songs)
#Classify popularity
def classify_popularity(row):
if row['genre'] == 'Afrobeats':
if row['popularity'] >= 73:
return "Hit 🔥"
elif row['popularity'] >= 65:
return "——"
else:
return "—"
elif row['genre'] == 'Amapiano':
if row['popularity'] >= 65:
return "Hit 🔥"
elif row['popularity'] >= 45:
return "——"
else:
return "—"
return "Unknown Genre"
filtered_df['popularity_classification'] = filtered_df.apply(classify_popularity, axis=1)
#Drop duplicates by track + genre
filtered_df = filtered_df.drop_duplicates(subset=['track_name', 'genre'])
#Final display
display(filtered_df[['track_name', 'artist', 'genre', 'popularity', 'popularity_classification']])
| track_name | artist | genre | popularity | popularity_classification | |
|---|---|---|---|---|---|
| 0 | Beamer | T.I BLAZE | Afrobeats | 66 | —— |
| 1 | Chandelier | Monaky | Afrobeats | 72 | —— |
| 2 | Doha | Seyi Vibez | Afrobeats | 70 | —— |
| 3 | Hey Jago | Poco Lee | Afrobeats | 69 | —— |
| 4 | LEGOLAS | ODUMODUBLVCK | Afrobeats | 68 | —— |
| 5 | MARIO KART | Seyi Vibez | Afrobeats | 72 | —— |
| 6 | Management | Smur Lee | Afrobeats | 70 | —— |
| 7 | Most Wanted | Zinoleesky | Afrobeats | 68 | —— |
| 8 | My Darling | Chella | Afrobeats | 73 | Hit 🔥 |
| 9 | SHAOLIN | Seyi Vibez | Afrobeats | 74 | Hit 🔥 |
| 10 | TOY GIRL (with Juno & Valentino Rose) | ODUMODUBLVCK | Afrobeats | 65 | —— |
| 11 | Trenches Luv | T.I BLAZE | Afrobeats | 67 | —— |
| 12 | Abantwana Bakho (feat. Thatohatsi, Young Stunn... | DJ Maphorisa | Amapiano | 60 | —— |
| 13 | All My Life | Mawelele | Amapiano | 55 | —— |
| 14 | Awuhlabe Kabili | LIMIT NALA | Amapiano | 60 | —— |
| 15 | Dear Ex Yami | Mduduzi Ncube | Amapiano | 59 | —— |
| 16 | Malunde (feat. Springle) | Shakes & Les | Amapiano | 59 | —— |
| 17 | Naledi | Mawelele | Amapiano | 59 | —— |
| 18 | Ngiyakuthanda | MENZI MUSIC | Amapiano | 60 | —— |
| 19 | Romeo & Juliet | Naledi Aphiwe | Amapiano | 64 | —— |
| 20 | Shapa Bell | Naleboy Young King | Amapiano | 57 | —— |
| 21 | Shayi'Moto (feat. Seemah & Yanda Woods) | Mellow & Sleazy | Amapiano | 64 | —— |
| 22 | Ungangilimazi (feat. Frank Mabeat) | Dj Moscow | Amapiano | 56 | —— |
| 23 | Uvume Kanjani? | LIMIT NALA | Amapiano | 61 | —— |
| 24 | Sdudla or Slender | Shandesh | Amapiano | 66 | Hit 🔥 |
| 25 | Vuma Dlozi Lami (feat. Ancestral Rituals) | Issa sisdoh | Amapiano | 66 | Hit 🔥 |
| 26 | Ngisakuthanda | Zee Nxumalo | Amapiano | 66 | Hit 🔥 |
| 27 | Ngibolekeni (feat. Seun1401, LeeMcKrazy, Blxck... | DJ Maphorisa | Amapiano | 68 | Hit 🔥 |
| 28 | Vuka (feat. Thukuthela) | Oscar Mbo | Amapiano | 67 | Hit 🔥 |
| 30 | Wayengenalutho | MENZI MUSIC | Amapiano | 63 | —— |
| 31 | Sohlala Sisonke | Dlala Thukzin | Amapiano | 65 | Hit 🔥 |
| 32 | Bo Gogo (feat. Tracy & Thatohatsi) | Kelvin Momo | Amapiano | 65 | Hit 🔥 |
| 33 | HAUSAPIANO - Remix | Kvng Vinci | Amapiano | 70 | Hit 🔥 |
| 34 | Biri Marung (feat. Sje Konka, Focalistic, DJ M... | Mr Pilato | Amapiano | 69 | Hit 🔥 |
| 35 | UYAH! (feat. 2wo Bunnies, Jay Music, & Imbongi... | Uncle Waffles | Amapiano | 60 | —— |
| 36 | Vulani (feat. Thatohatsi & Tracy) | Kelvin Momo | Amapiano | 61 | —— |
| 37 | Skuta Baba - Remix | WOODBLOCK DJS | Amapiano | 63 | —— |
| 38 | ZENZELE (feat. Royal MusiQ, Uncool MC, Xduppy,... | Uncle Waffles | Amapiano | 60 | —— |
| 39 | Wishi Wishi (feat. Scotts Maphuma & Young Stunna) | Kabza De Small | Amapiano | 64 | —— |
| 40 | Ama Gear | Dlala Thukzin | Amapiano | 60 | —— |
| 41 | Kabza Chant (feat. Young Stunna, Nkosazana Dau... | Kabza De Small | Amapiano | 52 | —— |
| 42 | Abo Nokthula (feat. The Exclusive SA, Scotts M... | TNK MusiQ | Amapiano | 52 | —— |
| 43 | Arike | Kunmie | Afrobeats | 78 | Hit 🔥 |
| 44 | Are you there? | Ayo Maff | Afrobeats | 71 | —— |
| 45 | Awolowo | Fido | Afrobeats | 74 | Hit 🔥 |
| 46 | Be There Still | Davido | Afrobeats | 72 | —— |
| 47 | Na Scra | Famous Pluto | Afrobeats | 73 | Hit 🔥 |
| 48 | Free of Charge | Joeboy | Afrobeats | 64 | — |
| 49 | PITY THIS BOY (with Victony) | ODUMODUBLVCK | Afrobeats | 75 | Hit 🔥 |
| 50 | Get Better | Zlatan | Afrobeats | 72 | —— |
| 51 | Funds (feat. ODUMODUBLVCK & Chike) | Davido | Afrobeats | 74 | Hit 🔥 |
| 52 | Happy | Seyi Vibez | Afrobeats | 71 | —— |
| 53 | Joy is Coming | Fido | Afrobeats | 75 | Hit 🔥 |
| 54 | JUJU (feat. Shallipopi) | Smur Lee | Afrobeats | 70 | —— |
| 55 | Kese (Dance) | Wizkid | Afrobeats | 71 | —— |
| 56 | Baby (Is it a Crime) | Rema | Afrobeats | 81 | Hit 🔥 |
| 57 | Venus | Faceless | Afrobeats | 77 | Hit 🔥 |
| 58 | WHY LOVE | Asake | Afrobeats | 72 | —— |
| 59 | Laho | Shallipopi | Afrobeats | 74 | Hit 🔥 |
#Function to calculate streaming data, SPD = Total Streams / Days Since Release
#Afrobeats Hit: SPD >= 300,000
#Amapiano Hit: SPD >= 75,000
#Import Datetime
from datetime import datetime
# Snapshot date = the day this data was collected.
# Using a fixed date (not datetime.today()) keeps streams_per_day and
# every hit classification reproducible no matter when the notebook is run.
# NOTE: must be on/after the latest release date in stream_data (2025-04-04).
current_date = datetime(2025, 4, 15)
#Create genre mapping from stream_data
genre_map = {song: details["genre"] for song, details in stream_data.items()}
def reclassify_stricter_thresholds(data):
result = []
for song, details in data.items():
release_date = datetime.strptime(details["release_date"], "%Y-%m-%d")
days_since_release = (current_date - release_date).days
spd = details["streams"] / days_since_release if days_since_release > 0 else details["streams"]
genre = genre_map.get(song, "Unknown")
if genre == "Afrobeats":
if spd >= 300000:
classification = "Hit 🔥"
elif spd >= 100000:
classification = "Potential Hit ⚡"
else:
classification = "Moderate 🌱"
elif genre == "Amapiano":
if spd >= 75000:
classification = "Hit 🔥"
elif spd >= 40000:
classification = "Potential Hit ⚡"
else:
classification = "Moderate 🌱"
else:
classification = "Unknown Genre"
result.append({
"Song": song,
"Genre": genre,
"Streams": details["streams"],
"Release Date": details["release_date"],
"Days Since Release": days_since_release,
"Streams Per Day": round(spd),
"Classification": classification
})
return result
df_hits_stricter = pd.DataFrame(reclassify_stricter_thresholds(stream_data))
#Show all columns and rows
pd.set_option("display.max_columns", None)
pd.set_option("display.max_rows", None)
pd.set_option("display.max_colwidth", None)
#Now display the full DataFrame
display(df_hits_stricter)
| Song | Genre | Streams | Release Date | Days Since Release | Streams Per Day | Classification | |
|---|---|---|---|---|---|---|---|
| 0 | my darling | Afrobeats | 4924774 | 2025-03-27 | 19 | 259199 | Potential Hit ⚡ |
| 1 | arike | Afrobeats | 22434165 | 2025-02-15 | 59 | 380240 | Hit 🔥 |
| 2 | are you there? | Afrobeats | 31946737 | 2024-08-16 | 242 | 132011 | Potential Hit ⚡ |
| 3 | awolowo | Afrobeats | 48842828 | 2024-08-14 | 244 | 200176 | Potential Hit ⚡ |
| 4 | beamer | Afrobeats | 11580503 | 2024-10-14 | 183 | 63281 | Moderate 🌱 |
| 5 | be there still | Afrobeats | 6737818 | 2025-02-14 | 60 | 112297 | Potential Hit ⚡ |
| 6 | chandelier | Afrobeats | 17369585 | 2024-11-21 | 145 | 119790 | Potential Hit ⚡ |
| 7 | na scra | Afrobeats | 7248693 | 2025-03-07 | 39 | 185864 | Potential Hit ⚡ |
| 8 | doha | Afrobeats | 27380251 | 2024-07-13 | 276 | 99204 | Moderate 🌱 |
| 9 | free of charge | Afrobeats | 1915678 | 2025-03-27 | 19 | 100825 | Potential Hit ⚡ |
| 10 | pity this boy (with victony) | Afrobeats | 21224424 | 2025-02-28 | 46 | 461401 | Hit 🔥 |
| 11 | get better | Afrobeats | 4857075 | 2025-03-21 | 25 | 194283 | Potential Hit ⚡ |
| 12 | funds (feat. odumodublvck & chike) | Afrobeats | 42455477 | 2024-12-06 | 130 | 326581 | Hit 🔥 |
| 13 | happy | Afrobeats | 8112951 | 2025-02-20 | 54 | 150240 | Potential Hit ⚡ |
| 14 | hey jago | Afrobeats | 2993456 | 2025-03-18 | 28 | 106909 | Potential Hit ⚡ |
| 15 | joy is coming | Afrobeats | 37092308 | 2024-12-18 | 118 | 314342 | Hit 🔥 |
| 16 | JUJU (feat. Shallipopi) | Afrobeats | 41465826 | 2024-08-22 | 236 | 175703 | Potential Hit ⚡ |
| 17 | kese (dance) | Afrobeats | 45694575 | 2024-10-15 | 182 | 251069 | Potential Hit ⚡ |
| 18 | baby (is it a crime) | Afrobeats | 35551585 | 2025-02-07 | 67 | 530621 | Hit 🔥 |
| 19 | shaolin | Afrobeats | 12715482 | 2025-02-10 | 64 | 198679 | Potential Hit ⚡ |
| 20 | management | Afrobeats | 6204585 | 2025-01-25 | 80 | 77557 | Moderate 🌱 |
| 21 | most wanted | Afrobeats | 1837793 | 2025-04-04 | 11 | 167072 | Potential Hit ⚡ |
| 22 | mario kart | Afrobeats | 8687810 | 2025-02-20 | 54 | 160885 | Potential Hit ⚡ |
| 23 | legolas | Afrobeats | 1955624 | 2025-03-31 | 15 | 130375 | Potential Hit ⚡ |
| 24 | trenches luv | Afrobeats | 4312356 | 2025-02-13 | 61 | 70694 | Moderate 🌱 |
| 25 | toy girl (with juno & valentino rose) | Afrobeats | 1197925 | 2025-03-31 | 15 | 79862 | Moderate 🌱 |
| 26 | venus | Afrobeats | 26775718 | 2025-02-21 | 53 | 505202 | Hit 🔥 |
| 27 | why love | Afrobeats | 10384733 | 2025-02-12 | 62 | 167496 | Potential Hit ⚡ |
| 28 | Sdudla or Slender | Amapiano | 2894712 | 2025-03-20 | 26 | 111335 | Hit 🔥 |
| 29 | Vuma Dlozi Lami (feat. Ancestral Rituals) | Amapiano | 9243178 | 2024-09-21 | 206 | 44870 | Potential Hit ⚡ |
| 30 | Ngisakuthanda | Amapiano | 7241847 | 2024-09-06 | 221 | 32769 | Moderate 🌱 |
| 31 | Ngibolekeni (feat. Seun1401, LeeMcKrazy, Blxckie, Pcee, Madumane & Kabelo Sings) | Amapiano | 5852759 | 2025-01-31 | 74 | 79091 | Hit 🔥 |
| 32 | Vuka (feat. Thukuthela) | Amapiano | 8720251 | 2024-12-15 | 121 | 72068 | Potential Hit ⚡ |
| 33 | Uyaphapha Marn (feat. Scotts Maphuma & Kabelo Sings) | Amapiano | 4838765 | 2025-01-31 | 74 | 65389 | Potential Hit ⚡ |
| 34 | Wayengenalutho | Amapiano | 4610909 | 2025-02-21 | 53 | 86998 | Hit 🔥 |
| 35 | Sohlala Sisonke | Amapiano | 12909636 | 2025-02-14 | 60 | 215161 | Hit 🔥 |
| 36 | Bo Gogo (feat. Tracy & Thatohatsi) | Amapiano | 6078812 | 2025-01-31 | 74 | 82146 | Hit 🔥 |
| 37 | HAUSAPIANO - Remix | Amapiano | 21129492 | 2024-10-31 | 166 | 127286 | Hit 🔥 |
| 38 | Uvume Kanjani? | Amapiano | 1289020 | 2025-03-21 | 25 | 51561 | Potential Hit ⚡ |
| 39 | Biri Marung (feat. Sje Konka, Focalistic, DJ Maphorisa, Scotts Maphuma & CowBoii) | Amapiano | 23859597 | 2024-10-20 | 177 | 134800 | Hit 🔥 |
| 40 | Romeo & Juliet | Amapiano | 4674070 | 2025-01-22 | 83 | 56314 | Potential Hit ⚡ |
| 41 | Shapa Bell | Amapiano | 1339189 | 2025-04-01 | 14 | 95656 | Hit 🔥 |
| 42 | Abantwana Bakho (feat. Thatohatsi, Young Stunna & Nkosazana Daughter) | Amapiano | 790174 | 2025-03-28 | 18 | 43899 | Potential Hit ⚡ |
| 43 | Malunde (feat. Springle) | Amapiano | 864570 | 2024-12-13 | 123 | 7029 | Moderate 🌱 |
| 44 | Vulani (feat. Thatohatsi & Tracy) | Amapiano | 3295409 | 2024-12-09 | 127 | 25948 | Moderate 🌱 |
| 45 | Skuta Baba - Remix | Amapiano | 7294010 | 2024-12-06 | 130 | 56108 | Potential Hit ⚡ |
| 46 | Ungangilimazi (feat. Frank Mabeat) | Amapiano | 4937736 | 2024-09-20 | 207 | 23854 | Moderate 🌱 |
| 47 | All My Life | Amapiano | 10706356 | 2024-08-30 | 228 | 46958 | Potential Hit ⚡ |
| 48 | Awuhlabe Kabili | Amapiano | 3409948 | 2024-12-06 | 130 | 26230 | Moderate 🌱 |
| 49 | ZENZELE (feat. Royal MusiQ, Uncool MC, Xduppy, & CowBoii) | Amapiano | 2014490 | 2025-03-15 | 31 | 64984 | Potential Hit ⚡ |
| 50 | Naledi | Amapiano | 1401369 | 2025-03-10 | 36 | 38927 | Moderate 🌱 |
| 51 | UYAH! (feat. 2wo Bunnies, Jay Music, & Imbongi Yosizi) | Amapiano | 2223683 | 2025-02-10 | 64 | 34745 | Moderate 🌱 |
| 52 | Ngiyakuthanda | Amapiano | 3686853 | 2025-02-02 | 72 | 51206 | Potential Hit ⚡ |
| 53 | Shayi'Moto (feat. Seemah & Yanda Woods) | Amapiano | 9941374 | 2024-11-01 | 165 | 60251 | Potential Hit ⚡ |
| 54 | Wishi Wishi (feat. Scotts Maphuma & Young Stunna) | Amapiano | 13211203 | 2024-09-29 | 198 | 66723 | Potential Hit ⚡ |
| 55 | Dear Ex Yami | Amapiano | 5231036 | 2024-09-22 | 205 | 25517 | Moderate 🌱 |
| 56 | Ama Gear | Amapiano | 11286723 | 2023-12-01 | 501 | 22528 | Moderate 🌱 |
| 57 | Kabza Chant (feat. Young Stunna, Nkosazana Daughter, Mthunzi, Nokwazi, Anzo, Mashudu, Murumba Pitch & Tman Xpress) | Amapiano | 7878258 | 2024-11-03 | 163 | 48333 | Potential Hit ⚡ |
| 58 | Abo Nokthula (feat. The Exclusive SA, Scotts Maphuma, Kabelo Sings, Bontle Smith, 2woshort & Stompiiey) | Amapiano | 3976467 | 2024-12-28 | 108 | 36819 | Moderate 🌱 |
| 59 | laho | Afrobeats | 24981448 | 2025-02-21 | 53 | 471348 | Hit 🔥 |
# --- TikTok Virality Processing Block ---
import difflib
#Clean titles for fuzzy matching
def clean_title(title):
return title.lower().strip().replace("(", "").replace(")", "").replace("&", "and")
def fuzzy_match_tiktok(song_title, tiktok_keys, threshold=80):
matches = difflib.get_close_matches(clean_title(song_title), [clean_title(k) for k in tiktok_keys], n=1, cutoff=threshold / 100)
if matches:
for original_key in tiktok_keys:
if clean_title(original_key) == matches[0]:
return original_key
return None
# Match and map TikTok virality
df_tracks['matched_tiktok_name'] = df_tracks['track_name'].apply(lambda x: fuzzy_match_tiktok(x, tiktok_trending.keys()))
df_tracks['viral_on_tiktok'] = df_tracks['matched_tiktok_name'].map(tiktok_trending).fillna(0).astype(int)
df_tracks['viral_on_tiktok_display'] = df_tracks['viral_on_tiktok'].apply(lambda x: "1 🔥" if x == 1 else "0")
#Assign genre from matched key
df_tracks['genre'] = df_tracks['matched_key'].map(target_songs)
#Flag as a TikTok hit
df_tracks['is_hit_tiktok'] = df_tracks.apply(
lambda row: 1 if row['genre'] in ['Afrobeats', 'Amapiano'] and row['viral_on_tiktok'] == 1 else 0,
axis=1
)
#Filter and de-duplicate using matched_tiktok_name (NOT track_name)
tiktok_hits = df_tracks[df_tracks['is_hit_tiktok'] == 1][['matched_tiktok_name', 'genre', 'viral_on_tiktok_display']]
tiktok_hits = tiktok_hits.drop_duplicates(subset='matched_tiktok_name')
#Display results
from IPython.display import display
display(tiktok_hits.rename(columns={'matched_tiktok_name': 'song'}))
print(f"🎯 Unique TikTok Hits: {len(tiktok_hits)}")
| song | genre | viral_on_tiktok_display | |
|---|---|---|---|
| 3 | hey jago | Afrobeats | 1 🔥 |
| 5 | MARIO KART | Afrobeats | 1 🔥 |
| 8 | My Darling | Afrobeats | 1 🔥 |
| 9 | Shaolin | Afrobeats | 1 🔥 |
| 11 | Trenches Luv | Afrobeats | 1 🔥 |
| 15 | Dear Ex Yami | Amapiano | 1 🔥 |
| 19 | Romeo & Juliet | Amapiano | 1 🔥 |
| 21 | Shayi'Moto (feat. Seemah & Yanda Woods) | Amapiano | 1 🔥 |
| 23 | Uvume Kanjani? | Amapiano | 1 🔥 |
| 24 | Sdudla or Slender | Amapiano | 1 🔥 |
| 27 | Ngibolekeni (feat. Seun1401, LeeMcKrazy, Blxckie, Pcee, Madumane & Kabelo Sings) | Amapiano | 1 🔥 |
| 28 | Vuka (feat. Thukuthela) | Amapiano | 1 🔥 |
| 31 | Sohlala Sisonke | Amapiano | 1 🔥 |
| 32 | Bo Gogo (feat. Tracy & Thatohatsi) | Amapiano | 1 🔥 |
| 33 | HAUSAPIANO - Remix | Amapiano | 1 🔥 |
| 34 | Biri Marung (feat. Sje Konka, Focalistic, DJ Maphorisa, Scotts Maphuma & CowBoii) | Amapiano | 1 🔥 |
| 37 | Skuta Baba - Remix | Amapiano | 1 🔥 |
| 39 | Wishi Wishi (feat. Scotts Maphuma & Young Stunna) | Amapiano | 1 🔥 |
| 41 | Kabza Chant (feat. Young Stunna, Nkosazana Daughter, Mthunzi, Nokwazi, Anzo, Mashudu, Murumba Pitch & Tman Xpress) | Amapiano | 1 🔥 |
| 43 | Arike | Afrobeats | 1 🔥 |
| 47 | Na Scra | Afrobeats | 1 🔥 |
| 51 | Funds (feat. ODUMODUBLVCK & Chike) | Afrobeats | 1 🔥 |
| 52 | Happy | Afrobeats | 1 🔥 |
| 53 | Joy Is Coming | Afrobeats | 1 🔥 |
| 54 | JUJU (feat. Shallipopi) | Afrobeats | 1 🔥 |
| 55 | Kese (Dance) | Afrobeats | 1 🔥 |
| 56 | Baby (Is it a Crime) | Afrobeats | 1 🔥 |
| 57 | Venus | Afrobeats | 1 🔥 |
| 58 | WHY LOVE | Afrobeats | 1 🔥 |
| 59 | Laho | Afrobeats | 1 🔥 |
🎯 Unique TikTok Hits: 30
8. Calculating Songs That Qualify as a hit¶
Afrobeats Hit = Must meet at least 3 out of 4:
✅ Spotify popularity ≥ 73
✅ TikTok viral
✅ Streams/day ≥ 300,000
✅ Appears on Billboard Africa
Amapiano Hit = Must meet all 3:
✅ Spotify popularity ≥ 65
✅ TikTok viral
✅ Streams/day ≥ 75,000
(🚫 Billboard chart not required)
from datetime import datetime
import pandas as pd
import re
# === Setup ===
#Snapshot date = the day this data was collected.
#Using a fixed date (not datetime.today()) keeps streams_per_day and
#every hit classification reproducible no matter when the notebook is run.
#NOTE: must be on/after the latest release date in stream_data (2025-04-04).
current_date = datetime(2025, 4, 15)
hit_records = []
nonhit_records = []
# === Normalize Titles for Consistent Matching ===
def normalize(title):
return title.lower().strip().replace("&", "and").replace("(", "").replace(")", "").replace("feat.", "").replace("with", "").replace(",", "").replace("'", "").replace(".", "").replace("?", "").replace("!", "").replace("-", "").replace("/", "").replace(":", "").replace("–", "")
# Clean all titles in df_tracks
df_tracks['track_name_clean'] = df_tracks['track_name'].apply(normalize)
# Normalize TikTok keys
normalized_tiktok = {normalize(k): v for k, v in tiktok_trending.items()}
# Normalize Billboard hits
normalized_billboard_hits = [normalize(song) for song in billboard_hits['song'].tolist()]
# === Classify Songs ===
for raw_title, details in stream_data.items():
norm_title = normalize(raw_title)
genre = details["genre"]
release_date = datetime.strptime(details["release_date"], "%Y-%m-%d")
days_since = max((current_date - release_date).days, 1)
spd = details["streams"] / days_since
viral = normalized_tiktok.get(norm_title, 0)
in_billboard = 1 if norm_title in normalized_billboard_hits else 0
# Get Spotify popularity
match_row = df_tracks[df_tracks['track_name_clean'] == norm_title]
if match_row.empty:
continue
popularity = match_row['popularity'].values[0]
# === Evaluate Hit Criteria ===
if genre == "Afrobeats":
checks = [
popularity >= 73,
viral == 1,
spd >= 300000,
in_billboard == 1
]
is_hit = sum(checks) >= 3
elif genre == "Amapiano":
checks = [
popularity >= 65,
viral == 1,
spd >= 75000
]
is_hit = sum(checks) == 3
else:
continue # Skip unknown genre
song_data = {
"track_name": raw_title,
"genre": genre,
"popularity": popularity,
"streams_per_day": round(spd),
"viral_on_tiktok": viral,
"in_billboard_africa": in_billboard
}
if is_hit:
hit_records.append(song_data)
elif sum(checks) <= 1: #Only classify as non-hit if 1 or 0 criteria are met
nonhit_records.append(song_data)
# === Display Results ===
df_hits = pd.DataFrame(hit_records).drop_duplicates()
df_nonhits = pd.DataFrame(nonhit_records).drop_duplicates()
from IPython.display import display
display(df_hits)
print(f"🔥 Total Songs Classified as Hits: {len(df_hits)}")
display(df_nonhits)
print(f"❌ Total Songs Classified as Non-Hits: {len(df_nonhits)}")
#Note: Songs close to hitting thresholds (e.g., slightly under TikTok virality or popularity) are
#intentionally excluded from the non-hit category to reduce misclassification.
| track_name | genre | popularity | streams_per_day | viral_on_tiktok | in_billboard_africa | |
|---|---|---|---|---|---|---|
| 0 | arike | Afrobeats | 78 | 380240 | 1 | 1 |
| 1 | na scra | Afrobeats | 73 | 185864 | 1 | 1 |
| 2 | pity this boy (with victony) | Afrobeats | 75 | 461401 | 0 | 1 |
| 3 | funds (feat. odumodublvck & chike) | Afrobeats | 74 | 326581 | 1 | 0 |
| 4 | joy is coming | Afrobeats | 75 | 314342 | 1 | 1 |
| 5 | baby (is it a crime) | Afrobeats | 81 | 530621 | 1 | 1 |
| 6 | shaolin | Afrobeats | 74 | 198679 | 1 | 1 |
| 7 | venus | Afrobeats | 77 | 505202 | 1 | 0 |
| 8 | Sdudla or Slender | Amapiano | 66 | 111335 | 1 | 0 |
| 9 | Ngibolekeni (feat. Seun1401, LeeMcKrazy, Blxckie, Pcee, Madumane & Kabelo Sings) | Amapiano | 68 | 79091 | 1 | 0 |
| 10 | Sohlala Sisonke | Amapiano | 65 | 215161 | 1 | 0 |
| 11 | Bo Gogo (feat. Tracy & Thatohatsi) | Amapiano | 65 | 82146 | 1 | 0 |
| 12 | HAUSAPIANO - Remix | Amapiano | 70 | 127286 | 1 | 0 |
| 13 | Biri Marung (feat. Sje Konka, Focalistic, DJ Maphorisa, Scotts Maphuma & CowBoii) | Amapiano | 69 | 134800 | 1 | 0 |
| 14 | laho | Afrobeats | 74 | 471348 | 1 | 1 |
🔥 Total Songs Classified as Hits: 15
| track_name | genre | popularity | streams_per_day | viral_on_tiktok | in_billboard_africa | |
|---|---|---|---|---|---|---|
| 0 | are you there? | Afrobeats | 71 | 132011 | 0 | 0 |
| 1 | awolowo | Afrobeats | 74 | 200176 | 0 | 0 |
| 2 | beamer | Afrobeats | 66 | 63281 | 0 | 1 |
| 3 | be there still | Afrobeats | 72 | 112297 | 0 | 1 |
| 4 | chandelier | Afrobeats | 72 | 119790 | 0 | 1 |
| 5 | doha | Afrobeats | 70 | 99204 | 0 | 0 |
| 6 | free of charge | Afrobeats | 64 | 100825 | 0 | 0 |
| 7 | get better | Afrobeats | 72 | 194283 | 0 | 1 |
| 8 | JUJU (feat. Shallipopi) | Afrobeats | 70 | 175703 | 1 | 0 |
| 9 | management | Afrobeats | 70 | 77557 | 0 | 0 |
| 10 | most wanted | Afrobeats | 68 | 167072 | 0 | 0 |
| 11 | legolas | Afrobeats | 68 | 130375 | 0 | 0 |
| 12 | toy girl (with juno & valentino rose) | Afrobeats | 65 | 79862 | 0 | 0 |
| 13 | Vuma Dlozi Lami (feat. Ancestral Rituals) | Amapiano | 66 | 44870 | 0 | 0 |
| 14 | Ngisakuthanda | Amapiano | 66 | 32769 | 0 | 0 |
| 15 | Wayengenalutho | Amapiano | 63 | 86998 | 0 | 0 |
| 16 | Uvume Kanjani? | Amapiano | 61 | 51561 | 1 | 0 |
| 17 | Romeo & Juliet | Amapiano | 64 | 56314 | 1 | 0 |
| 18 | Shapa Bell | Amapiano | 57 | 95656 | 0 | 0 |
| 19 | Abantwana Bakho (feat. Thatohatsi, Young Stunna & Nkosazana Daughter) | Amapiano | 60 | 43899 | 0 | 0 |
| 20 | Malunde (feat. Springle) | Amapiano | 59 | 7029 | 0 | 0 |
| 21 | Vulani (feat. Thatohatsi & Tracy) | Amapiano | 61 | 25948 | 0 | 0 |
| 22 | Skuta Baba - Remix | Amapiano | 63 | 56108 | 1 | 0 |
| 23 | Ungangilimazi (feat. Frank Mabeat) | Amapiano | 56 | 23854 | 0 | 0 |
| 24 | All My Life | Amapiano | 55 | 46958 | 0 | 0 |
| 25 | Awuhlabe Kabili | Amapiano | 60 | 26230 | 0 | 0 |
| 26 | ZENZELE (feat. Royal MusiQ, Uncool MC, Xduppy, & CowBoii) | Amapiano | 60 | 64984 | 0 | 0 |
| 27 | Naledi | Amapiano | 59 | 38927 | 0 | 0 |
| 28 | UYAH! (feat. 2wo Bunnies, Jay Music, & Imbongi Yosizi) | Amapiano | 60 | 34745 | 0 | 0 |
| 29 | Ngiyakuthanda | Amapiano | 60 | 51206 | 0 | 0 |
| 30 | Shayi'Moto (feat. Seemah & Yanda Woods) | Amapiano | 64 | 60251 | 1 | 0 |
| 31 | Wishi Wishi (feat. Scotts Maphuma & Young Stunna) | Amapiano | 64 | 66723 | 1 | 0 |
| 32 | Dear Ex Yami | Amapiano | 59 | 25517 | 1 | 0 |
| 33 | Ama Gear | Amapiano | 60 | 22528 | 0 | 0 |
| 34 | Kabza Chant (feat. Young Stunna, Nkosazana Daughter, Mthunzi, Nokwazi, Anzo, Mashudu, Murumba Pitch & Tman Xpress) | Amapiano | 52 | 48333 | 1 | 0 |
| 35 | Abo Nokthula (feat. The Exclusive SA, Scotts Maphuma, Kabelo Sings, Bontle Smith, 2woshort & Stompiiey) | Amapiano | 52 | 36819 | 0 | 0 |
❌ Total Songs Classified as Non-Hits: 36
9. Exploratory Data Analysis/Model Training and Evaluation¶
import pandas as pd
#Load the CSV file
df = pd.read_csv("playlist_and_all_audio_done.csv")
#Preview the first few rows
print(df.head())
track_name artist release_date track_id popularity \ 0 Beamer T.I BLAZE 2024-10-14 4i3wDQa5VBDPUiREGaS44Z 66 1 Chandelier Monaky 2024-11-21 20l4NPs2c9OBKBKUKRjxIy 72 2 Doha Seyi Vibez 2024-07-13 5hphSVebVxTpDfrk09W0hS 70 3 Hey Jago Poco Lee 2025-03-18 4xVj25uTjTZCaHbSFbYwAE 69 4 LEGOLAS ODUMODUBLVCK 2025-03-31 0OWPr4POCQ7iH9BGmTxOZV 68 duration_ms mood Tempo (BPM) Key Beat Strength genre \ 0 166153 Confident 😎🔥 116 F Minor Strong Afrobeats 1 175666 Confident 😎🔥 100 F Minor Strong Afrobeats 2 164317 Confident 😎🔥 204 F Minor Strong Afrobeats 3 125284 Happy 😁🎉 125 C# Minor Strong Afrobeats 4 169285 Confident 😎🔥 61 D Minor Strong Afrobeats streams_per_day viral_on_tiktok in_billboard_africa 0 63981 0 1.0 1 121466 0 1.0 2 99928 0 0.0 3 115133 1 1.0 4 150433 0 0.0
import seaborn as sns
import matplotlib.pyplot as plt
# Clean the columns classify_hit needs
df['in_billboard_africa'] = df['in_billboard_africa'].fillna(0).astype(int)
if df['streams_per_day'].dtype == object:
df['streams_per_day'] = df['streams_per_day'].replace(',', '', regex=True).astype(int)
def classify_hit(row):
g = row['genre'].lower(); m = 0
if g == 'afrobeats':
if row['popularity'] >= 73: m += 1
if row['viral_on_tiktok'] == 1: m += 1
if row['streams_per_day'] >= 300000: m += 1
if row['in_billboard_africa'] == 1: m += 1
return 1 if m >= 3 else 0
elif g == 'amapiano':
if row['popularity'] >= 65: m += 1
if row['viral_on_tiktok'] == 1: m += 1
if row['streams_per_day'] >= 75000: m += 1
return 1 if m >= 3 else 0
return 0
df['is_hit'] = df.apply(classify_hit, axis=1)
df['hit_label'] = df['is_hit'].map({0: 'Non-Hit', 1: 'Hit'})
sns.countplot(data=df, x='genre', hue='hit_label')
plt.title("Hit vs Non-Hit by Genre")
plt.xlabel("Genre"); plt.ylabel("Count")
plt.xticks(rotation=45); plt.tight_layout(); plt.show()
#It appears that afrobeats songs are more likely to become hits than amapiano,
#which ultimately makes sense, the craze for amapiano is new, with celebrities like Tyla making it come to light
genre_hit_rate = df.groupby('genre')['hit_label'].value_counts(normalize=True).unstack().fillna(0)
genre_hit_rate.plot(kind='bar', stacked=True, color=['gray', 'gold'])
plt.title('Proportion of Hits by Genre')
plt.ylabel('Percentage')
plt.xlabel('Genre')
plt.xticks(rotation=45)
plt.legend(title='Song Status')
plt.tight_layout()
plt.show()
#Fill NaN values with 0 and convert to integer
df['in_billboard_africa'] = df['in_billboard_africa'].fillna(0).astype(int)
#Repeat the classification, modeling, and visualization steps as intended
def classify_hit(row):
genre = row['genre'].lower()
conditions_met = 0
if genre == 'afrobeats':
if row['popularity'] >= 73:
conditions_met += 1
if row['viral_on_tiktok'] == 1:
conditions_met += 1
if row['streams_per_day'] >= 300000:
conditions_met += 1
if row['in_billboard_africa'] == 1:
conditions_met += 1
return 1 if conditions_met >= 3 else 0
elif genre == 'amapiano':
if row['popularity'] >= 65:
conditions_met += 1
if row['viral_on_tiktok'] == 1:
conditions_met += 1
if row['streams_per_day'] >= 75000:
conditions_met += 1
return 1 if conditions_met >= 3 else 0
return 0
df['is_hit'] = df.apply(classify_hit, axis=1)
#Create binary label and one-hot encode categorical variables
df_model = df.copy()
df_model['is_hit_binary'] = df_model['is_hit']
df_ml = pd.get_dummies(df_model[['popularity', 'streams_per_day', 'viral_on_tiktok',
'in_billboard_africa', 'genre', 'Beat Strength', 'is_hit_binary']],
columns=['genre', 'Beat Strength'], drop_first=True)
#Train/test split and modeling
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix
X = df_ml.drop('is_hit_binary', axis=1)
y = df_ml['is_hit_binary']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
model = RandomForestClassifier(n_estimators=100, random_state=42)
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
#Feature importance visualization
importances = pd.Series(model.feature_importances_, index=X.columns).sort_values(ascending=True)
import matplotlib.pyplot as plt
import seaborn as sns
plt.figure(figsize=(10,7))
sns.barplot(x=importances, y=importances.index, color='teal')
plt.title('Feature Importance in Hit Prediction')
plt.xlabel('Importance Score')
plt.ylabel('Feature')
plt.tight_layout()
plt.show()
#Feature Importance Bar Charts:Indicate that streams_per_day and popularity are the most influential predictors of a song’s
#success, followed by TikTok virality and Billboard presence.
#Final evaluation
conf_matrix = confusion_matrix(y_test, y_pred)
class_report = classification_report(y_test, y_pred, output_dict=True)
conf_matrix, class_report
#The classification report shows strong performance for hit-song prediction.
#The model achieved perfect recall for hits (1.00), meaning it identified all actual hit songs in the test set.
#Precision for hits was 0.75, indicating that some songs predicted as hits were not actually hits.
#Overall, the hit-class F1-score of 0.857 suggests the model captures useful hit-song patterns, though the small test size should
#be interpreted cautiously.
(array([[14, 1],
[ 0, 3]]),
{'0': {'precision': 1.0,
'recall': 0.9333333333333333,
'f1-score': 0.9655172413793104,
'support': 15.0},
'1': {'precision': 0.75,
'recall': 1.0,
'f1-score': 0.8571428571428571,
'support': 3.0},
'accuracy': 0.9444444444444444,
'macro avg': {'precision': 0.875,
'recall': 0.9666666666666667,
'f1-score': 0.9113300492610837,
'support': 18.0},
'weighted avg': {'precision': 0.9583333333333334,
'recall': 0.9444444444444444,
'f1-score': 0.9474548440065682,
'support': 18.0}})
#Ensure clean integer format in new data set ("playlist_and_all_audio_done.csv")
df['streams_per_day'] = df['streams_per_day'].replace(',', '', regex=True).astype(int)
#Apply classification function
df['is_hit'] = df.apply(classify_hit, axis=1)
import seaborn as sns
import matplotlib.pyplot as plt
#Map hit labels for readability
df['hit_label'] = df['is_hit'].map({0: 'Non-Hit', 1: 'Hit'})
#Set up the figure with 2 plots
fig, axes = plt.subplots(1, 2, figsize=(14, 6), sharey=True)
# --- Plot for Afrobeats ---
sns.boxplot(
data=df[df['genre'] == 'Afrobeats'],
x='hit_label',
hue='hit_label',
y='Tempo (BPM)',
ax=axes[0],
palette='pastel',
legend=False
)
axes[0].set_title("Afrobeats: Tempo by Hit Status")
axes[0].set_xlabel("Song Type")
axes[0].set_ylabel("Tempo (BPM)")
# --- Plot for Amapiano ---
sns.boxplot(
data=df[df['genre'] == 'Amapiano'],
x='hit_label',
y='Tempo (BPM)',
hue='hit_label',
ax=axes[1],
palette='cool',
legend=False
)
axes[1].set_title("Amapiano: Tempo by Hit Status")
axes[1].set_xlabel("Song Type")
axes[1].set_ylabel("") # Shared y-axis
plt.tight_layout()
plt.show()
#Afrobeats: In this sample, Afrobeats hits lean toward slightly lower or mid-tempos. With only ~9 hit songs, though,
#this pattern is suggestive rather than conclusive.
#Amapiano: Tempo appears more consistent between hits and non-hits, with both centered tightly around a median — indicating
#that tempo may be less decisive in hit prediction for this genre.
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report
# Prepare target variable
df['is_hit_binary'] = df['is_hit']
# Split data by genre
df_afro = df[df['genre'] == 'Afrobeats']
df_amap = df[df['genre'] == 'Amapiano']
# One-hot encode features separately by genre
X_afro = pd.get_dummies(
df_afro[['popularity', 'streams_per_day', 'viral_on_tiktok',
'in_billboard_africa', 'Beat Strength']],
columns=['Beat Strength'],
drop_first=True
)
y_afro = df_afro['is_hit']
X_amap = pd.get_dummies(
df_amap[['popularity', 'streams_per_day', 'viral_on_tiktok',
'in_billboard_africa', 'Beat Strength']],
columns=['Beat Strength'],
drop_first=True
)
y_amap = df_amap['is_hit']
# Stratified train-test split to preserve hit/non-hit balance
X_afro_train, X_afro_test, y_afro_train, y_afro_test = train_test_split(
X_afro, y_afro, test_size=0.3, stratify=y_afro, random_state=42
)
X_amap_train, X_amap_test, y_amap_train, y_amap_test = train_test_split(
X_amap, y_amap, test_size=0.3, stratify=y_amap, random_state=42
)
# Fit genre-specific logistic regression models
log_afro = LogisticRegression(max_iter=5000).fit(X_afro_train, y_afro_train)
log_amap = LogisticRegression(max_iter=5000).fit(X_amap_train, y_amap_train)
# Generate classification reports
report_afro = classification_report(
y_afro_test,
log_afro.predict(X_afro_test),
output_dict=True,
zero_division=0
)
report_amap = classification_report(
y_amap_test,
log_amap.predict(X_amap_test),
output_dict=True,
zero_division=0
)
report_afro, report_amap
# === Genre-Specific Logistic Regression Interpretation ===
#Stratified sampling was used to keep the hit/non-hit balance more consistent in each train-test split.
#Afrobeats Model:
#The model achieved perfect precision, recall, and F1-score for both hits and non-hits.
#It correctly classified all 9 test songs, including all 3 hits.
#Because the test set is small, the 100% accuracy should be interpreted cautiously.
#Amapiano Model:
#The model performed well on non-hits but failed to identify hits.
#For hit songs, precision, recall, and F1-score were all 0.00.
#Accuracy was 80%, but this is misleading because the model mostly predicted the majority class.
#Conclusion:
#Logistic regression worked well for Afrobeats in this split, but struggled with Amapiano hit prediction.
#Hit-class recall and F1-score are more important than accuracy because the goal is to identify hit songs.
({'0': {'precision': 1.0, 'recall': 1.0, 'f1-score': 1.0, 'support': 6.0},
'1': {'precision': 1.0, 'recall': 1.0, 'f1-score': 1.0, 'support': 3.0},
'accuracy': 1.0,
'macro avg': {'precision': 1.0,
'recall': 1.0,
'f1-score': 1.0,
'support': 9.0},
'weighted avg': {'precision': 1.0,
'recall': 1.0,
'f1-score': 1.0,
'support': 9.0}},
{'0': {'precision': 0.8,
'recall': 1.0,
'f1-score': 0.8888888888888888,
'support': 8.0},
'1': {'precision': 0.0, 'recall': 0.0, 'f1-score': 0.0, 'support': 2.0},
'accuracy': 0.8,
'macro avg': {'precision': 0.4,
'recall': 0.5,
'f1-score': 0.4444444444444444,
'support': 10.0},
'weighted avg': {'precision': 0.64,
'recall': 0.8,
'f1-score': 0.711111111111111,
'support': 10.0}})
from sklearn.metrics import classification_report
print("Afrobeats Model:")
print(classification_report(
y_afro_test,
log_afro.predict(X_afro_test),
zero_division=0
))
print("\nAmapiano Model:")
print(classification_report(
y_amap_test,
log_amap.predict(X_amap_test),
zero_division=0
))
# === Genre-Specific Model Interpretation ===
#Afrobeats Logistic Regression Model:
#The model achieved perfect precision, recall, and F1-score for both hits and non-hits.
#It correctly classified all 9 test songs, including all 3 hits.
#Because the test set is small, the 100% accuracy should be interpreted cautiously.
#Amapiano Logistic Regression Model:
#The model performed well on non-hits but failed to identify hits.
#For hit songs, precision, recall, and F1-score were all 0.00.
#This means the model predicted no Amapiano songs as hits.
#Conclusion:
#Logistic regression worked well for Afrobeats in this split, but struggled with Amapiano hit prediction.
#Hit-class recall and F1-score matter more than accuracy because the goal is to detect hits.
Afrobeats Model:
precision recall f1-score support
0 1.00 1.00 1.00 6
1 1.00 1.00 1.00 3
accuracy 1.00 9
macro avg 1.00 1.00 1.00 9
weighted avg 1.00 1.00 1.00 9
Amapiano Model:
precision recall f1-score support
0 0.80 1.00 0.89 8
1 0.00 0.00 0.00 2
accuracy 0.80 10
macro avg 0.40 0.50 0.44 10
weighted avg 0.64 0.80 0.71 10
#Test and training data
#Split Afrobeats data
X_afro_train, X_afro_test, y_afro_train, y_afro_test = train_test_split(X_afro, y_afro, test_size=0.3, random_state=42)
log_afro = LogisticRegression(max_iter=5000).fit(X_afro_train, y_afro_train)
#Split Amapiano data
X_amap_train, X_amap_test, y_amap_train, y_amap_test = train_test_split(X_amap, y_amap, test_size=0.3, random_state=42)
log_amap = LogisticRegression(max_iter=5000).fit(X_amap_train, y_amap_train)
from sklearn.linear_model import LogisticRegression
log_model = LogisticRegression(max_iter=5000, solver='lbfgs') # increased from default 100
log_model.fit(X_train, y_train)
print(classification_report(y_test, log_model.predict(X_test)))
precision recall f1-score support
0 0.87 0.87 0.87 15
1 0.33 0.33 0.33 3
accuracy 0.78 18
macro avg 0.60 0.60 0.60 18
weighted avg 0.78 0.78 0.78 18
from sklearn.model_selection import cross_val_score, StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
afro_cv_scores = cross_val_score(LogisticRegression(max_iter=5000), X_afro, y_afro, cv=cv)
print("Afrobeats 5-fold CV accuracy:", afro_cv_scores.mean())
#To ensure that model performance was not the result of a favorable train-test split, a 5-fold stratified cross-validation
#was implemented using StratifiedKFold. This approach preserves the proportion of hit and non-hit songs in each fold,
#offering a more stable estimate of model accuracy under different data splits.
#The high cross-validated accuracy suggests that the model performs consistently well across different partitions of the dataset,
#particularly in identifying non-hits.
#However, given earlier observations of low recall on hits, high accuracy should be interpreted cautiously,
#as it may still reflect performance skewed toward the dominant class (non-hits)
Afrobeats 5-fold CV accuracy: 0.8866666666666667
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import classification_report
#To evaluate the model's ability to detect hit songs under realistic generalization conditions,
#cross_val_predict was used to generate out-of-fold predictions across the entire dataset for both Afrobeats and Amapiano.
#Classification metrics were then computed with a specific focus on class 1 (hit songs).
#Refit logistic regression models using 5-fold stratified CV with predictions
afro_cv_preds = cross_val_predict(LogisticRegression(max_iter=5000), X_afro, y_afro, cv=5)
amap_cv_preds = cross_val_predict(LogisticRegression(max_iter=5000), X_amap, y_amap, cv=5)
#Get classification reports
afro_cv_report = classification_report(y_afro, afro_cv_preds, output_dict=True, digits=3)
amap_cv_report = classification_report(y_amap, amap_cv_preds, output_dict=True, digits=3)
afro_cv_report['1'], amap_cv_report['1'] # Focus only on class "1" (hits)
#For Afrobeats:
#The model shows strong recall, detecting nearly all hits, while maintaining solid precision.
#This indicates a good balance: the model rarely misses hits and most of its hit predictions are correct.
#For Amapiano:
#The model performs with high precision and high recall, indicating a strong and balanced ability to
#detect hit songs in the Amapiano genre. Results suggest consistent model behavior and clear separation
#between hits and non-hits.
#Cross-validation confirms that the genre-specific logistic regression models generalize well, especially in identifying hits.
#Amapiano songs show particularly consistent patterns,
#while Afrobeats may benefit from further feature enrichment to push precision even higher.
({'precision': 0.7777777777777778,
'recall': 0.7777777777777778,
'f1-score': 0.7777777777777778,
'support': 9.0},
{'precision': 0.8333333333333334,
'recall': 0.8333333333333334,
'f1-score': 0.8333333333333334,
'support': 6.0})
from sklearn.ensemble import RandomForestClassifier
#To further improve hit classification performance, especially under class imbalance, a RandomForestClassifier was trained using
#the class_weight='balanced' parameter.
#This ensures the model pays equal attention to the less frequent class (hits) by adjusting its internal loss function.
#Random Forest with class balancing to handle imbalance better
rf_afro = RandomForestClassifier(n_estimators=100, class_weight='balanced', random_state=42)
rf_amap = RandomForestClassifier(n_estimators=100, class_weight='balanced', random_state=42)
#5-fold cross-validated predictions
rf_afro_preds = cross_val_predict(rf_afro, X_afro, y_afro, cv=5)
rf_amap_preds = cross_val_predict(rf_amap, X_amap, y_amap, cv=5)
#Focus on class "1" (hits) only
rf_afro_report = classification_report(y_afro, rf_afro_preds, output_dict=True, digits=3)
rf_amap_report = classification_report(y_amap, rf_amap_preds, output_dict=True, digits=3)
rf_afro_report['1'], rf_amap_report['1']
# === Random Forest with Class Balancing Interpretation ===
# === Afrobeats Hits (Class 1) ===
#Precision: 1.00 → No false positives; all predicted hits were correct
#Recall: 0.89 → Most true hits were correctly identified (8 of 9)
#F1-score: 0.94 → Strong overall performance in identifying hits
#Amapiano Hits:
#Precision was 1.00, meaning all predicted Amapiano hits were correct.
#Recall was 0.83, meaning the model correctly identified 5 out of 6 actual Amapiano hits.
#F1-score was 0.91, showing strong performance despite the small number of Amapiano hits.
#Conclusion:
#Random Forest with class balancing performs much better than Logistic Regression for hit prediction.
#It maintains high precision while improving recall for the minority hit class.
({'precision': 1.0,
'recall': 0.8888888888888888,
'f1-score': 0.9411764705882353,
'support': 9.0},
{'precision': 1.0,
'recall': 0.8333333333333334,
'f1-score': 0.9090909090909091,
'support': 6.0})
11. Lyric Sentiment Enhancement (Genius + VADER)¶
Beyond streams and chart data, the emotional tone of the lyrics may carry signal about
hit potential. Here we pull each song's lyrics from the Genius API and score them with
VADER (a sentiment model tuned for short, social text), producing a lyric_sentiment
value from -1 (negative) to +1 (positive). We then re-train the genre-specific Random
Forests with this feature added and measure whether it improves hit detection.
Reproducibility note: lyrics are fetched once and cached to data/lyrics_cache.csv.
Every later run loads the cache instantly instead of re-hitting the Genius API.
# === Fetch + cache lyrics, then score sentiment with VADER ===
import os, time
import pandas as pd
import lyricsgenius
from tqdm import tqdm
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
GENIUS_TOKEN = "" # <-- Genius Client Access Token
LYRICS_CACHE = "data/lyrics_cache.csv" #lyrics are saved here after the first run
os.makedirs("data", exist_ok=True)
#Songs actually in the model (df), not all 800+ playlist tracks
targets = df[["track_name", "artist"]].drop_duplicates().reset_index(drop=True)
if os.path.exists(LYRICS_CACHE):
# Fast path: reuse cached lyrics (no API calls, runs in seconds)
df_lyrics = pd.read_csv(LYRICS_CACHE)
print(f"Loaded {len(df_lyrics)} cached lyrics from {LYRICS_CACHE}")
else:
#Slow path (first run only): fetch from Genius, then save the cache
genius = lyricsgenius.Genius(GENIUS_TOKEN, timeout=15, retries=3,
remove_section_headers=True, skip_non_songs=True)
genius.verbose = False
def fetch_lyrics(track_name, artist):
try:
song = genius.search_song(track_name, artist)
return song.lyrics if song else None
except Exception as e:
print(f" lyric fetch failed for {track_name}: {e}")
return None
records = []
for _, row in tqdm(targets.iterrows(), total=len(targets), desc="Fetching lyrics"):
records.append({
"track_name": row["track_name"],
"lyrics": fetch_lyrics(row["track_name"], row["artist"]),
})
time.sleep(0.5) # be polite to the Genius API
df_lyrics = pd.DataFrame(records)
df_lyrics.to_csv(LYRICS_CACHE, index=False)
print(f"Fetched and cached {len(df_lyrics)} lyrics to {LYRICS_CACHE}")
#Score sentiment (VADER compound score: -1 = negative, +1 = positive)
analyzer = SentimentIntensityAnalyzer()
def sentiment_score(text):
if not isinstance(text, str) or not text.strip():
return None
return analyzer.polarity_scores(text)["compound"]
df_lyrics["lyric_sentiment"] = df_lyrics["lyrics"].apply(sentiment_score)
#Merge onto the model dataframe on track_name (both come from the same source, so they match)
df = df.merge(df_lyrics[["track_name", "lyric_sentiment"]], on="track_name", how="left")
#Songs with no lyrics found -> fill with that genre's median so they stay in the model
df["lyric_sentiment"] = df.groupby("genre")["lyric_sentiment"].transform(lambda s: s.fillna(s.median()))
found = df_lyrics["lyric_sentiment"].notna().sum()
print(f"Real sentiment scores: {found} of {len(df_lyrics)} songs")
print(df[["track_name", "genre", "lyric_sentiment"]].head())
Loaded 60 cached lyrics from data/lyrics_cache.csv Real sentiment scores: 44 of 60 songs track_name genre lyric_sentiment 0 Beamer Afrobeats -0.8113 1 Chandelier Afrobeats -0.9410 2 Doha Afrobeats 0.9988 3 Hey Jago Afrobeats 0.4815 4 LEGOLAS Afrobeats 0.7882
# === Re-train genre Random Forests with lyric_sentiment, and inspect feature importance ===
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import classification_report
import pandas as pd
feature_cols = ["popularity", "streams_per_day", "viral_on_tiktok",
"in_billboard_africa", "lyric_sentiment", "Beat Strength"]
def build_xy(frame):
X = pd.get_dummies(frame[feature_cols], columns=["Beat Strength"], drop_first=True)
return X, frame["is_hit"]
for genre in ["Afrobeats", "Amapiano"]:
sub = df[df["genre"] == genre]
X, y = build_xy(sub)
rf = RandomForestClassifier(n_estimators=100, class_weight="balanced", random_state=42)
preds = cross_val_predict(rf, X, y, cv=5)
print(f"\n=== {genre} (with lyric_sentiment) ===")
print(classification_report(y, preds, digits=3))
rf.fit(X, y) # fit on full genre data to read feature importances
imp = pd.Series(rf.feature_importances_, index=X.columns).sort_values(ascending=False)
print("Feature importances:\n", imp)
=== Afrobeats (with lyric_sentiment) ===
precision recall f1-score support
0 0.905 0.950 0.927 20
1 0.875 0.778 0.824 9
accuracy 0.897 29
macro avg 0.890 0.864 0.875 29
weighted avg 0.896 0.897 0.895 29
Feature importances:
popularity 0.448484
streams_per_day 0.312577
lyric_sentiment 0.108463
in_billboard_africa 0.060411
viral_on_tiktok 0.045747
Beat Strength_Strong 0.017525
Beat Strength_Subtle 0.006792
dtype: float64
=== Amapiano (with lyric_sentiment) ===
precision recall f1-score support
0 0.962 1.000 0.980 25
1 1.000 0.833 0.909 6
accuracy 0.968 31
macro avg 0.981 0.917 0.945 31
weighted avg 0.969 0.968 0.967 31
Feature importances:
streams_per_day 0.515436
popularity 0.225153
viral_on_tiktok 0.159113
lyric_sentiment 0.054694
Beat Strength_Syncopated 0.021341
Beat Strength_Strong 0.015881
Beat Strength_Subtle 0.008383
in_billboard_africa 0.000000
dtype: float64
Interpretation¶
Adding lyric_sentiment lets us ask whether what a song says helps predict whether it
breaks out, on top of streaming and chart signals. In our runs the lyric-sentiment feature
contributed meaningfully (ranking above TikTok virality, billboard presence, and beat strength for
Afrobeats), while streams-per-day and popularity remained the dominant predictors.
However, we see that Lyric sentiment matters only for Afrobeats (0.11) but not Amapiano (0.05). This likely reflects a tool limitation, not the music: VADER is English-trained, so it reads Afrobeats' English/Pidgin lyrics but can't interpret Amapiano's Zulu/Xhosa lyrics — making those scores near-meaningless.
Caveat (kept on purpose): each genre here has only ~30 songs and very few labelled hits, so the cross-validation warning about small class sizes is expected. These results show a promising signal, not a production-grade model — the natural next step is simply more labelled songs.
emoji_to_label = {
"Confident 😎🔥": "Confident",
"Happy 😁🎉": "Happy",
"Romantic 💋🌹": "Romantic",
"Sad 🎭💔": "Sad",
"Chill 🧘🏾♀️🌊": "Chill"
}
df['mood_label'] = df['mood'].map(emoji_to_label)
#Remove emojis put in the csv file
sns.countplot(data=df, x='genre', hue='mood_label')
plt.title("Mood Distribution by Genre")
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
#Afrobeats aristists makes more confident songs generally, while amapiano artists makes more happy music.
import plotly.express as px
fig = px.scatter(
df,
x='streams_per_day',
y='popularity',
color='genre',
hover_data=['track_name', 'mood'],
title="🔥 Hit Predictor Results"
)
fig.show()
hit_rate_by_mood = df.groupby('mood_label')['is_hit'].mean().sort_values(ascending=False)
hit_rate_by_mood.plot(kind='bar', title="Hit Rate by Mood")
plt.ylabel("Proportion of Hits")
plt.show()
#Sad and confident songs are more likely to be hits (without seperating genres)
corr = df[['popularity', 'streams_per_day', 'in_billboard_africa', 'viral_on_tiktok', 'Tempo (BPM)']].corr()
sns.heatmap(corr, annot=True, cmap='coolwarm')
plt.title("Feature Correlation Matrix")
plt.show()
#The correlation matrix shows strong alignment between popularity and streams per day,
#while TikTok virality and tempo remain largely independent.
#This suggests minimal redundancy and supports keeping all features in the model.
import seaborn as sns
import matplotlib.pyplot as plt
#Popularity distribution
sns.histplot(df['popularity'], bins=20, kde=True)
plt.title('Song Popularity Distribution')
plt.show()
#Visualizes the distribution of song popularity scores in the dataset.
#The distribution appears slightly right-skewed, with most songs clustering between 60 and 75.
#This helps inform threshold decisions when defining what constitutes a "popular" or "hit" song.
import seaborn as sns
import matplotlib.pyplot as plt
#Compute correlation matrix
correlation_matrix = df.corr(numeric_only=True)
#Plot heatmap
plt.figure(figsize=(12, 8))
sns.heatmap(correlation_matrix, annot=True, cmap='coolwarm', fmt=".2f", linewidths=0.5)
plt.title('Feature Correlation Matrix')
plt.show()
#Comprehensive correlation matrix for all numeric features.
#Popularity, streams_per_day, and in_billboard_africa show strong positive correlations with is_hit.
#Viral_on_tiktok is moderately correlated with hit status, while tempo and duration show weak or no correlation.
#Supports feature inclusion and confirms no severe multicollinearity.
#Convert duration from milliseconds to minutes
df['duration_min'] = df['duration_ms'] / 60000
#Map 0/1 to readable labels
df['hit_label'] = df['is_hit'].map({0: 'Non-Hit', 1: 'Hit'})
#Boxplot grouped by genre and hit status
sns.boxplot(x='genre', y='duration_min', hue='hit_label', data=df)
plt.title('Duration by Genre & Hit Status')
plt.xlabel('Genre')
plt.ylabel('Duration (minutes)')
plt.legend(title='Is Hit')
plt.show()
#Amapiano songs tend to be longer overall, with hit songs slightly longer on average than non-hits.
#Afrobeats songs show less variation in duration, and hits appear slightly shorter.
#Histogram for Each Genre Separately for duration_ms
#Amapiano duration distribution
sns.histplot(data=df[df['genre'] == 'Amapiano'], x='duration_min', hue='is_hit', bins=25, kde=True)
plt.title('Amapiano: Duration Distribution by Hit Status')
plt.xlabel('Duration (minutes)')
plt.show()
#Afrobeats duration distribution
sns.histplot(data=df[df['genre'] == 'Afrobeats'], x='duration_min', hue='is_hit', bins=25, kde=True)
plt.title('Afrobeats: Duration Distribution by Hit Status')
plt.xlabel('Duration (minutes)')
plt.show()
#Histograms of song duration by hit status, split by genre.
#Amapiano hits span a wider range and tend to be longer than non-hits.
#Afrobeats durations are more tightly clustered, with hits appearing slightly shorter
#These plots help visualize how duration influences hit potential differently across genres.
10. Conclusion & Future Work¶
This project applied machine learning to analyze and predict hit songs across Afrobeats and Amapiano genres, combining streaming data, audio features, chart performance, and virality metrics. High Spotify popularity and streaming velocity were strongly associated with hits, but the analysis suggests these metrics behave more as symptoms of success than as causes—they describe a song that has already broken out rather than explaining why it did.
In contrast, commonly assumed musical drivers of success—tempo, duration, and beat strength—showed minimal predictive power (near-zero correlation with hit status in the exploratory analysis). This supports the idea that, in today's digital music ecosystem, a song's shareability and visibility often matter more than its audio structure. Audio features were still useful descriptively: Afrobeats hits leaned toward lower tempos, while Amapiano hits clustered around consistent mid-tempo ranges—patterns that diverge from typical U.S. pop hit profiles.
When lyric sentiment (Genius + VADER) was added as a feature, it ranked among the top predictors for Afrobeats—above TikTok virality and Billboard presence—but it did not improve hit detection, and slightly lowered the Afrobeats hit-class F1 (with no change for Amapiano). On a dataset this small (~30 songs per genre, few labelled hits), a one-song shift moves F1 substantially, so this is best read as noise rather than evidence the feature is harmful. The likeliest cause is VADER's English-only training: it cannot meaningfully score Amapiano's Zulu/Xhosa lyrics, and missing scores were imputed with the genre median. The signal is promising for Afrobeats but unproven—more labelled songs and proper multilingual sentiment scoring are needed.
A key limitation is label leakage. Because is_hit is defined partly from streams_per_day and popularity, the high importance of those two features is somewhat circular—the model is partly predicting hits from the same signals used to define them. The more meaningful result is that non-defining features like TikTok virality and Billboard presence outranked musical attributes such as beat strength and tempo, suggesting that success is driven more by distribution and reach than by a song's sonic characteristics. A stronger test of true predictability would forecast hits using only song features and early virality signals, excluding the streaming metrics that overlap with the label.
The best-performing model was a Random Forest classifier with class weighting, evaluated through 5-fold stratified cross-validation. Reporting the hit (minority) class, it achieved:
- Afrobeats: F1 0.94 (precision 1.00, recall 0.89)
- Amapiano: F1 0.91 (precision 1.00, recall 0.83)
These reflect perfect precision and strong recall on the hit class, and they outperform genre-specific logistic regression (hit-class F1 ≈ 0.78 for Afrobeats and ≈ 0.83 for Amapiano)—confirming that the Random Forest is markedly better at detecting the minority hit class. (This is the model without lyric_sentiment, which gave the strongest hit-class F1.)
Future Work¶
To further improve performance and expand applicability:
- Improve lyric sentiment for non-English lyrics, since many Amapiano tracks are in Zulu/Xhosa that VADER scores unreliably.
- Test true predictive power without leakage by forecasting hits from audio features and early virality only, excluding the streaming metrics used to define the label.
- Analyze playlist placement, release timing, and artist reputation as upstream exposure signals.
- Extend the cross-genre modeling to additional African subgenres for broader generalization.
- Explore model stacking or gradient boosting to capture nonlinear feature interactions.
- Grow the labelled dataset, since ~30 songs per genre limits how far any of these conclusions can be pushed.
These findings offer practical insights for artists, producers, and digital marketers aiming to understand or influence the trajectory of songs in the era of algorithmic culture.