VibeFinder / src /recommender.py
Abubakar Diallo
Add VibeFinder app
55ed80a
Raw
History Blame Contribute Delete
12.4 kB
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
import csv
# Default scoring weights β€” change these to run experiments
DEFAULT_WEIGHTS: Dict[str, float] = {
"genre": 2.0, # binary match bonus
"mood": 1.0, # binary match bonus
"energy": 1.0, # multiplier on proximity score (0-1 range)
"valence": 0.5, # max possible valence proximity points
"acoustic": 0.5, # max possible acoustic preference points
}
# Scoring mode weights for Challenge 2: Multiple Scoring Modes
MOOD_FIRST_WEIGHTS: Dict[str, float] = {
"genre": 1.0, # reduced β€” mood is now primary
"mood": 2.0, # doubled β€” mood is king
"energy": 0.8,
"valence": 0.5,
"acoustic": 0.3,
}
ENERGY_FOCUSED_WEIGHTS: Dict[str, float] = {
"genre": 1.5,
"mood": 0.5,
"energy": 2.0, # doubled β€” energy dominates
"valence": 1.0,
"acoustic": 0.5,
}
POPULARITY_AWARE_WEIGHTS: Dict[str, float] = {
"genre": 1.8,
"mood": 0.9,
"energy": 0.9,
"valence": 0.5,
"acoustic": 0.5,
"popularity": 1.0, # new feature
"decade": 0.3, # new feature
}
@dataclass
class Song:
"""Represents a song and its audio attributes loaded from the CSV catalog."""
id: int
title: str
artist: str
genre: str
mood: str
energy: float
tempo_bpm: float
valence: float
danceability: float
acousticness: float
popularity: int = 50
release_decade: str = "2020s"
detailed_mood_tags: list = None
def __post_init__(self):
if self.detailed_mood_tags is None:
self.detailed_mood_tags = []
@dataclass
class UserProfile:
"""Represents a listener's taste preferences used by the OOP Recommender."""
favorite_genre: str
favorite_mood: str
target_energy: float
likes_acoustic: bool
class Recommender:
"""OOP recommender that scores and ranks Song objects against a UserProfile."""
def __init__(self, songs: List[Song]):
self.songs = songs
def _score(self, user: UserProfile, song: Song) -> float:
"""
Returns a numeric match score for one song against a user profile.
Scoring breakdown (max ~4.5):
+2.0 genre match (binary β€” strongest signal)
+1.0 mood match (binary β€” secondary signal)
+1.0 energy closeness (1.0 - abs difference)
+0.5 acoustic preference bonus
"""
score = 0.0
if song.genre == user.favorite_genre:
score += 2.0
if song.mood == user.favorite_mood:
score += 1.0
energy_diff = abs(song.energy - user.target_energy)
score += 1.0 - energy_diff
if user.likes_acoustic:
score += song.acousticness * 0.5
else:
score += (1.0 - song.acousticness) * 0.5
return score
def recommend(self, user: UserProfile, k: int = 5) -> List[Song]:
"""Returns the top-k songs ranked highest to lowest by match score."""
return sorted(self.songs, key=lambda s: self._score(user, s), reverse=True)[:k]
def explain_recommendation(self, user: UserProfile, song: Song) -> str:
"""Returns a human-readable string explaining why a song was recommended."""
reasons = []
if song.genre == user.favorite_genre:
reasons.append(f"genre matches your favorite ({song.genre})")
if song.mood == user.favorite_mood:
reasons.append(f"mood matches your preference ({song.mood})")
energy_diff = abs(song.energy - user.target_energy)
if energy_diff < 0.15:
reasons.append(f"energy level is close to your target ({song.energy:.2f})")
if user.likes_acoustic and song.acousticness > 0.6:
reasons.append(f"it has a strong acoustic feel ({song.acousticness:.2f})")
elif not user.likes_acoustic and song.acousticness < 0.3:
reasons.append(f"it has a non-acoustic sound you prefer ({song.acousticness:.2f})")
if not reasons:
reasons.append("it's a reasonable overall fit for your taste")
return "Recommended because: " + ", ".join(reasons) + "."
# ──────────────────────────────────────────────────────────────────────────────
# Functional API β€” used by src/main.py
# ──────────────────────────────────────────────────────────────────────────────
def load_songs(csv_path: str) -> List[Dict]:
"""Reads songs.csv and returns a list of dicts with numeric fields cast to float/int."""
songs = []
with open(csv_path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
songs.append({
"id": int(row["id"]),
"title": row["title"],
"artist": row["artist"],
"genre": row["genre"],
"mood": row["mood"],
"energy": float(row["energy"]),
"tempo_bpm": float(row["tempo_bpm"]),
"valence": float(row["valence"]),
"danceability": float(row["danceability"]),
"acousticness": float(row["acousticness"]),
# Challenge 1: New advanced features
"popularity": int(row.get("popularity", 50)), # 0-100 scale
"release_decade": row.get("release_decade", "2020s"), # e.g., "2020s", "2010s"
"detailed_mood_tags": row.get("detailed_mood_tags", "").split(";"), # semicolon-separated list
})
print(f"Loaded {len(songs)} songs with advanced features.")
return songs
def score_song(
user_prefs: Dict,
song: Dict,
weights: Optional[Dict] = None,
mode: str = "default",
) -> Tuple[float, List[str]]:
"""
Scores one song against user preferences with optional scoring mode.
Modes:
- "default": Original genre-first approach
- "mood_first": Mood is the primary signal (Challenge 2)
- "energy_focused": Energy dominates the score (Challenge 2)
- "popularity_aware": Incorporates song popularity and release decade (Challenge 1)
Returns (score, reasons) tuple.
"""
# Select weights based on mode
if mode == "mood_first":
w = {**MOOD_FIRST_WEIGHTS, **(weights or {})}
elif mode == "energy_focused":
w = {**ENERGY_FOCUSED_WEIGHTS, **(weights or {})}
elif mode == "popularity_aware":
w = {**POPULARITY_AWARE_WEIGHTS, **(weights or {})}
else:
w = {**DEFAULT_WEIGHTS, **(weights or {})}
score = 0.0
reasons: List[str] = []
# ── Genre (binary) ────────────────────────────────────────────────────────
if song["genre"] == user_prefs.get("genre"):
score += w["genre"]
reasons.append(f"genre match (+{w['genre']}): {song['genre']}")
# ── Mood (binary) ─────────────────────────────────────────────────────────
if song["mood"] == user_prefs.get("mood"):
score += w["mood"]
reasons.append(f"mood match (+{w['mood']}): {song['mood']}")
# ── Energy proximity ──────────────────────────────────────────────────────
target_energy = user_prefs.get("energy", 0.5)
energy_diff = abs(song["energy"] - target_energy)
energy_points = round(w["energy"] * (1.0 - energy_diff), 2)
score += energy_points
if energy_diff < 0.15:
reasons.append(f"energy close to target (+{energy_points}): {song['energy']:.2f}")
# ── Valence proximity ─────────────────────────────────────────────────────
target_valence = user_prefs.get("target_valence")
if target_valence is not None:
valence_diff = abs(song["valence"] - target_valence)
valence_points = round(w["valence"] * (1.0 - valence_diff), 2)
score += valence_points
if valence_diff < 0.15:
reasons.append(f"valence close to target (+{valence_points}): {song['valence']:.2f}")
# ── Acoustic preference ───────────────────────────────────────────────────
likes_acoustic = user_prefs.get("likes_acoustic", False)
if likes_acoustic:
acoustic_points = round(song["acousticness"] * w["acoustic"], 2)
score += acoustic_points
if song["acousticness"] > 0.6:
reasons.append(f"strong acoustic feel (+{acoustic_points}): {song['acousticness']:.2f}")
else:
score += (1.0 - song["acousticness"]) * w["acoustic"]
# ── Challenge 1: Popularity scoring (new feature)
if "popularity" in w and mode == "popularity_aware":
pop_score = round(song.get("popularity", 50) / 100.0 * w.get("popularity", 0), 2)
if pop_score > 0.5:
score += pop_score
reasons.append(f"high popularity (+{pop_score})")
# ── Challenge 1: Detailed mood tags (new feature)
user_mood_tags = user_prefs.get("mood_tags", [])
if user_mood_tags and song.get("detailed_mood_tags"):
song_tags = [tag.strip() for tag in song["detailed_mood_tags"]]
matching_tags = [tag for tag in user_mood_tags if tag in song_tags]
if matching_tags:
tag_bonus = round(len(matching_tags) * 0.2, 2)
score += tag_bonus
reasons.append(f"mood tags match (+{tag_bonus}): {','.join(matching_tags)}")
if not reasons:
reasons.append("general fit β€” no single feature matched strongly")
return round(score, 2), reasons
def recommend_songs(
user_prefs: Dict,
songs: List[Dict],
k: int = 5,
weights: Optional[Dict] = None,
mode: str = "default",
diversity_penalty: bool = False,
) -> List[Tuple[Dict, float, List[str]]]:
"""
Scores every song in the catalog and returns the top-k sorted highest to lowest.
Args:
user_prefs: User preference dictionary
songs: List of song dictionaries
k: Number of recommendations to return
weights: Optional custom weights
mode: Scoring mode ("default", "mood_first", "energy_focused", "popularity_aware")
diversity_penalty: If True, penalizes songs from artists already in top results (Challenge 3)
Returns:
List of (song_dict, score, reasons) tuples, sorted by score.
Challenge 3: Diversity Penalty
If an artist appears in the results, subsequent songs from the same artist
get a score penalty to encourage variety.
"""
# Score all songs
scored = [
(song, *score_song(user_prefs, song, weights=weights, mode=mode))
for song in songs
]
# Sort by score
scored = sorted(scored, key=lambda x: x[1], reverse=True)
# Challenge 3: Apply diversity penalty if enabled
if diversity_penalty:
seen_artists = set()
penalized_scored = []
for song, score, reasons in scored:
artist = song.get("artist", "Unknown")
if artist in seen_artists:
# Penalize this song (reduce score by 15%)
penalty = score * 0.15
new_score = round(score - penalty, 2)
reasons.append(f"(diversity penalty: -{penalty:.2f} β€” artist already featured)")
penalized_scored.append((song, new_score, reasons))
else:
seen_artists.add(artist)
penalized_scored.append((song, score, reasons))
scored = sorted(penalized_scored, key=lambda x: x[1], reverse=True)
return scored[:k]