🎶 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.

In [3]:
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)

Your browser does not support the audio element.

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¶

In [7]:
#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¶

In [9]:
#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)¶

In [11]:
#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)¶

In [13]:
#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¶

In [15]:
#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
}
In [16]:
#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¶

In [18]:
#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)
In [19]:
#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
Out[19]:
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¶

In [21]:
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 🔥
In [22]:
#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 🔥
In [23]:
# --- 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)

In [25]:
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¶

In [27]:
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  
In [28]:
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
No description has been provided for this image
In [29]:
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()
No description has been provided for this image
In [30]:
#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.
No description has been provided for this image
Out[30]:
(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}})
In [31]:
#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)
In [32]:
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.
No description has been provided for this image
In [33]:
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.
Out[33]:
({'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}})
In [34]:
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

In [35]:
#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)
In [36]:
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

In [37]:
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
In [38]:
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.
Out[38]:
({'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})
In [39]:
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.
Out[39]:
({'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.

In [41]:
# === 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
In [42]:
# === 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.

In [44]:
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
In [45]:
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.
No description has been provided for this image
In [46]:
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()
In [47]:
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)
No description has been provided for this image
In [48]:
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.
No description has been provided for this image
In [49]:
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.
No description has been provided for this image
In [50]:
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.
No description has been provided for this image
In [51]:
#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.
No description has been provided for this image
In [52]:
#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.
No description has been provided for this image
No description has been provided for this image

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.