Spaces:
Sleeping
Sleeping
| 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 | |
| } | |
| 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 = [] | |
| 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] | |