From 97f0417d76e001c80172b84063fab5181a347e55 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 Aug 2025 23:38:20 +0000 Subject: [PATCH 1/2] Checkpoint before follow-up message Co-authored-by: deepshekhardas1234 --- README.md | 36 +++++++- app.py | 159 ++++++++++++++++++++++++++++++++++ requirements.txt | 10 +++ scripts/download_movielens.py | 44 ++++++++++ scripts/smoke_test.py | 38 ++++++++ src/__init__.py | 0 src/cf_model.py | 38 ++++++++ src/content_model.py | 57 ++++++++++++ src/data_loader.py | 47 ++++++++++ src/hybrid.py | 60 +++++++++++++ src/posters.py | 32 +++++++ 11 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 scripts/download_movielens.py create mode 100644 scripts/smoke_test.py create mode 100644 src/__init__.py create mode 100644 src/cf_model.py create mode 100644 src/content_model.py create mode 100644 src/data_loader.py create mode 100644 src/hybrid.py create mode 100644 src/posters.py diff --git a/README.md b/README.md index 58055a0..54c8809 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ -# -deepshekhardas.github.io \ No newline at end of file +# MovieLens Hybrid Recommender (Streamlit) + +A hybrid movie recommendation system combining Collaborative Filtering (SVD) with Content-Based similarity (genres + tags), presented with a modern Streamlit UI. + +## Features +- Hybrid scoring: SVD CF + TF-IDF (genres + tags) +- Enter an existing user ID or pick liked/disliked movies +- Top-N recommendations with posters, genres, and predicted ratings +- Optional analytics: top genres, popular movies +- Optional TMDB poster lookup via `TMDB_API_KEY`; falls back to placeholder + +## Setup +1. Python 3.10+ recommended +2. Install dependencies: +```bash +pip install -r requirements.txt +``` +3. Download MovieLens (ml-latest-small): +```bash +python scripts/download_movielens.py --out_dir data +``` +4. Run the app: +```bash +streamlit run app.py +``` + +## Environment +- Optional: export `TMDB_API_KEY` for posters +```bash +export TMDB_API_KEY=your_key_here +``` + +## Notes +- The SVD model is (re)trained on startup for demo simplicity (small dataset). When you adjust liked/disliked movies, the system may re-fit quickly to include your new ratings. +- This is a demo-oriented implementation prioritizing clarity and presentation. \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..fc5132d --- /dev/null +++ b/app.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import List + +import joblib +import numpy as np +import pandas as pd +import streamlit as st + +from src.data_loader import load_movielens, build_popularity +from src.content_model import fit_content_model +from src.cf_model import train_svd +from src.hybrid import hybrid_recommend +from src.posters import get_poster_url + +ARTIFACTS = Path("artifacts") +DATA_DIR = Path("data/ml-latest-small") + +st.set_page_config(page_title="Hybrid Movie Recommender", page_icon="🎬", layout="wide") + +# Minimal CSS for card-style layout +CARD_CSS = """ + +""" + +st.markdown(CARD_CSS, unsafe_allow_html=True) + +@st.cache_resource(show_spinner=False) +def get_data(): + mld = load_movielens(DATA_DIR) + pop = build_popularity(mld) + return mld, pop + +@st.cache_resource(show_spinner=False) +def get_models(mld): + vectorizer, tfidf, sim = fit_content_model(mld.movies, mld.tags) + cf = train_svd(mld.ratings) + return (vectorizer, tfidf, sim), cf + + +def ensure_dataset(): + if not DATA_DIR.exists(): + st.warning("Dataset not found. Click the button to download MovieLens ml-latest-small.") + if st.button("Download Dataset"): + from scripts.download_movielens import download_and_extract, ML_SMALL_URL + download_and_extract(ML_SMALL_URL, Path("data")) + st.experimental_rerun() + + +def render_cards(df: pd.DataFrame): + cols = st.columns(5) + for i, (_, row) in enumerate(df.iterrows()): + col = cols[i % 5] + with col: + with st.container(border=False): + st.markdown('
', unsafe_allow_html=True) + poster = get_poster_url(row["clean_title"], None if pd.isna(row["year"]) else int(row["year"])) + st.image(poster, use_column_width=True) + st.markdown(f"
{row['clean_title']}
", unsafe_allow_html=True) + st.markdown(f"
{', '.join(row['genres_list'])}
", unsafe_allow_html=True) + st.markdown(f"
Hybrid: {row['hybrid_score']:.3f} | CF: {row['cf_score']:.3f} | Content: {row['content_score']:.3f}
", unsafe_allow_html=True) + st.markdown('
', unsafe_allow_html=True) + + +def main(): + st.title("🎬 Hybrid Movie Recommender") + st.caption("MovieLens ml-latest-small | SVD + TF-IDF (genres + tags)") + + ensure_dataset() + if not DATA_DIR.exists(): + st.stop() + + with st.spinner("Loading data and training models..."): + mld, pop = get_data() + (content_vec, tfidf, sim), cf_model = get_models(mld) + + st.sidebar.header("Input") + mode = st.sidebar.radio("Mode", ["Existing User ID", "Pick Likes/Dislikes"]) + top_n = st.sidebar.slider("Top N", 5, 20, 10) + alpha_cf = st.sidebar.slider("CF weight", 0.0, 1.0, 0.6, 0.05) + alpha_content = 1.0 - alpha_cf + + all_movie_ids = mld.movies["movieId"].tolist() + + liked_ids: List[int] = [] + disliked_ids: List[int] = [] + user_id = None + existing_user_ids = sorted(mld.ratings["userId"].unique().tolist()) + + if mode == "Existing User ID": + user_id = st.sidebar.selectbox("User ID", existing_user_ids, index=0) + user_rated = mld.ratings[mld.ratings["userId"] == user_id]["movieId"].tolist() + st.sidebar.caption(f"User has rated {len(user_rated)} movies") + exclude = user_rated + else: + search_titles = mld.movies.sort_values("clean_title")["clean_title"].tolist() + likes = st.sidebar.multiselect("Liked movies", search_titles[:5000]) + dislikes = st.sidebar.multiselect("Disliked movies", search_titles[:5000]) + # Map back to ids + liked_ids = mld.movies[mld.movies["clean_title"].isin(likes)]["movieId"].tolist() + disliked_ids = mld.movies[mld.movies["clean_title"].isin(dislikes)]["movieId"].tolist() + exclude = liked_ids + disliked_ids + + if st.sidebar.button("Recommend"): + with st.spinner("Scoring recommendations..."): + recs = hybrid_recommend( + movies=mld.movies, + cf_model=cf_model, + content_sim_matrix=sim, + all_movie_ids=all_movie_ids, + user_id=user_id, + liked_movie_ids=liked_ids, + disliked_movie_ids=disliked_ids, + alpha_cf=alpha_cf, + alpha_content=alpha_content, + exclude_rated=exclude, + top_n=top_n, + ) + + st.subheader("Top Recommendations") + render_cards(recs) + + st.divider() + st.subheader("Analytics") + col1, col2, col3 = st.columns([1,1,1]) + with col1: + st.metric("Ratings count", len(mld.ratings)) + st.metric("Users", mld.ratings["userId"].nunique()) + st.metric("Movies", len(mld.movies)) + with col2: + st.write("Model RMSE (cf):", f"{cf_model.rmse:.4f}") + with col3: + top_pop = ( + mld.movies.merge(pop, on="movieId").sort_values(["pop_count", "pop_mean"], ascending=False).head(10) + ) + st.write("Most Rated Movies:") + st.dataframe(top_pop[["clean_title","genres","pop_count","pop_mean"]], hide_index=True, use_container_width=True) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c35f64 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +pandas==2.2.2 +numpy==1.26.4 +scikit-learn==1.5.2 +scikit-surprise==1.1.4 +streamlit==1.37.1 +requests==2.32.3 +plotly==5.23.0 +matplotlib==3.9.2 +scipy==1.13.1 +joblib==1.4.2 \ No newline at end of file diff --git a/scripts/download_movielens.py b/scripts/download_movielens.py new file mode 100644 index 0000000..166e760 --- /dev/null +++ b/scripts/download_movielens.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import argparse +import io +import os +import sys +import zipfile +from pathlib import Path + +import requests + +ML_SMALL_URL = "https://files.grouplens.org/datasets/movielens/ml-latest-small.zip" + + +def download_and_extract(url: str, out_dir: Path) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + zip_path = out_dir / "ml-latest-small.zip" + print(f"Downloading {url} -> {zip_path} ...") + r = requests.get(url, timeout=60) + r.raise_for_status() + zip_path.write_bytes(r.content) + + print("Extracting...") + with zipfile.ZipFile(io.BytesIO(r.content)) as zf: + zf.extractall(out_dir) + + dataset_dir = out_dir / "ml-latest-small" + if not dataset_dir.exists(): + print("Extraction failed: directory not found", file=sys.stderr) + sys.exit(1) + print(f"Dataset available at: {dataset_dir}") + return dataset_dir + + +def main(): + parser = argparse.ArgumentParser(description="Download MovieLens ml-latest-small") + parser.add_argument("--out_dir", type=str, default="data", help="Output directory") + args = parser.parse_args() + + out = Path(args.out_dir) + download_and_extract(ML_SMALL_URL, out) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 0000000..0e07860 --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +from pathlib import Path + +from src.data_loader import load_movielens +from src.content_model import fit_content_model +from src.cf_model import train_svd +from src.hybrid import hybrid_recommend + +DATA_DIR = Path("data/ml-latest-small") + +def main(): + print("Loading data...") + mld = load_movielens(DATA_DIR) + print(f"Movies: {len(mld.movies)}, Ratings: {len(mld.ratings)}, Tags: {len(mld.tags)}") + print("Training content model...") + _, _, sim = fit_content_model(mld.movies, mld.tags) + print("Training SVD model...") + cf = train_svd(mld.ratings) + print(f"CF RMSE: {cf.rmse:.4f}") + + user_id = int(mld.ratings['userId'].sample(1, random_state=42).iloc[0]) + print(f"Generating recommendations for user {user_id}...") + all_movie_ids = mld.movies['movieId'].tolist() + user_rated = mld.ratings[mld.ratings['userId'] == user_id]['movieId'].tolist() + recs = hybrid_recommend( + movies=mld.movies, + cf_model=cf, + content_sim_matrix=sim, + all_movie_ids=all_movie_ids, + user_id=user_id, + exclude_rated=user_rated, + top_n=5, + ) + for i, row in recs.iterrows(): + print(f"- {row['clean_title']} ({row['year']}) -> {row['hybrid_score']:.3f}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cf_model.py b/src/cf_model.py new file mode 100644 index 0000000..6c1445d --- /dev/null +++ b/src/cf_model.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Iterable, List, Tuple + +import numpy as np +import pandas as pd +from surprise import Dataset, Reader, SVD +from surprise.model_selection import train_test_split +from surprise.accuracy import rmse + + +class SVDModel: + def __init__(self, model: SVD, trainset): + self.model = model + self.trainset = trainset + self.rmse = None + + +def train_svd(ratings: pd.DataFrame, n_factors: int = 100, n_epochs: int = 20, random_state: int = 42) -> SVDModel: + reader = Reader(rating_scale=(ratings["rating"].min(), ratings["rating"].max())) + dataset = Dataset.load_from_df(ratings[["userId", "movieId", "rating"]], reader) + trainset, testset = train_test_split(dataset, test_size=0.2, random_state=random_state) + + algo = SVD(n_factors=n_factors, n_epochs=n_epochs, biased=True, random_state=random_state, verbose=False) + algo.fit(trainset) + preds = algo.test(testset) + err = rmse(preds, verbose=False) + wrapper = SVDModel(algo, trainset) + wrapper.rmse = err + return wrapper + + +def predict_for_user(model: SVDModel, user_id: int, candidate_movie_ids: Iterable[int]) -> pd.Series: + algo = model.model + # surprise expects raw ids; it will map internally via trainset + preds = [algo.predict(uid=user_id, iid=mid, r_ui=None, verbose=False) for mid in candidate_movie_ids] + ests = {int(p.iid): p.est for p in preds} + return pd.Series(ests) \ No newline at end of file diff --git a/src/content_model.py b/src/content_model.py new file mode 100644 index 0000000..2c2369a --- /dev/null +++ b/src/content_model.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Tuple, List + +import numpy as np +import pandas as pd +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.metrics.pairwise import cosine_similarity + + +def _build_text_corpus(movies: pd.DataFrame, tags: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]: + # Aggregate tags per movie + tags_agg = ( + tags.groupby("movieId")["tag"].apply(lambda s: " ".join(map(str, s.tolist()))) + if len(tags) > 0 + else pd.Series(dtype=str) + ) + + movies = movies.copy() + movies["tags_text"] = movies["movieId"].map(tags_agg).fillna("") + movies["genres_text"] = movies["genres"].fillna("").str.replace("|", " ") + movies["text"] = (movies["clean_title"].fillna("") + " " + movies["genres_text"] + " " + movies["tags_text"]).str.lower() + return movies, movies["text"].tolist() + + +def fit_content_model(movies: pd.DataFrame, tags: pd.DataFrame) -> Tuple[TfidfVectorizer, np.ndarray, np.ndarray]: + movies_text_df, corpus = _build_text_corpus(movies, tags) + vectorizer = TfidfVectorizer(stop_words="english", max_features=5000, ngram_range=(1,2)) + tfidf = vectorizer.fit_transform(corpus) + # Item-item cosine similarity + sim = cosine_similarity(tfidf) + return vectorizer, tfidf, sim + + +def content_similarity_scores( + liked_movie_ids: List[int], + disliked_movie_ids: List[int], + sim_matrix: np.ndarray, + all_movie_ids: List[int], + alpha_like: float = 1.0, + alpha_dislike: float = 1.0, +) -> pd.Series: + """Compute content similarity scores from liked and disliked movie sets.""" + id_to_index = {mid: idx for idx, mid in enumerate(all_movie_ids)} + scores = np.zeros(len(all_movie_ids), dtype=float) + + if liked_movie_ids: + idxs = [id_to_index[m] for m in liked_movie_ids if m in id_to_index] + if idxs: + scores += alpha_like * np.mean(sim_matrix[idxs, :], axis=0) + + if disliked_movie_ids: + idxs = [id_to_index[m] for m in disliked_movie_ids if m in id_to_index] + if idxs: + scores -= alpha_dislike * np.mean(sim_matrix[idxs, :], axis=0) + + return pd.Series(scores, index=all_movie_ids) \ No newline at end of file diff --git a/src/data_loader.py b/src/data_loader.py new file mode 100644 index 0000000..3734673 --- /dev/null +++ b/src/data_loader.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Tuple + +import pandas as pd + + +@dataclass +class MovieLensData: + movies: pd.DataFrame + ratings: pd.DataFrame + tags: pd.DataFrame + + +def load_movielens(dataset_dir: str | Path) -> MovieLensData: + dataset_path = Path(dataset_dir) + movies_path = dataset_path / "movies.csv" + ratings_path = dataset_path / "ratings.csv" + tags_path = dataset_path / "tags.csv" + + if not movies_path.exists() or not ratings_path.exists(): + raise FileNotFoundError( + f"Could not find movies.csv/ratings.csv in {dataset_path}. Run downloader or check path." + ) + + movies = pd.read_csv(movies_path) + ratings = pd.read_csv(ratings_path) + tags = pd.read_csv(tags_path) if tags_path.exists() else pd.DataFrame(columns=["userId","movieId","tag","timestamp"]) + + # Basic cleanup + movies["year"] = movies["title"].str.extract(r"\((\d{4})\)").astype("Int64") + movies["clean_title"] = movies["title"].str.replace(r"\s*\(\d{4}\)$", "", regex=True) + movies["genres_list"] = movies["genres"].fillna("").apply(lambda g: [x for x in g.split("|") if x != "(no genres listed)"]) + + return MovieLensData(movies=movies, ratings=ratings, tags=tags) + + +def build_popularity(mld: MovieLensData) -> pd.DataFrame: + pop = ( + mld.ratings.groupby("movieId")["rating"] + .agg(pop_count="count", pop_mean="mean") + .reset_index() + ) + return pop \ No newline at end of file diff --git a/src/hybrid.py b/src/hybrid.py new file mode 100644 index 0000000..2fcb98d --- /dev/null +++ b/src/hybrid.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import List, Optional, Tuple + +import numpy as np +import pandas as pd + +from .content_model import content_similarity_scores +from .cf_model import SVDModel, predict_for_user + + +def _minmax(series: pd.Series) -> pd.Series: + if series.max() == series.min(): + return pd.Series(0.5, index=series.index) + return (series - series.min()) / (series.max() - series.min()) + + +def hybrid_recommend( + movies: pd.DataFrame, + cf_model: Optional[SVDModel], + content_sim_matrix, + all_movie_ids: List[int], + user_id: Optional[int] = None, + liked_movie_ids: Optional[List[int]] = None, + disliked_movie_ids: Optional[List[int]] = None, + alpha_cf: float = 0.6, + alpha_content: float = 0.4, + exclude_rated: Optional[List[int]] = None, + top_n: int = 10, +) -> pd.DataFrame: + liked_movie_ids = liked_movie_ids or [] + disliked_movie_ids = disliked_movie_ids or [] + exclude_rated = set(exclude_rated or []) + + # CF scores (if model/user provided) + cf_scores = pd.Series(0.0, index=all_movie_ids) + if cf_model is not None and (user_id is not None or liked_movie_ids): + cf_scores = predict_for_user(cf_model, user_id if user_id is not None else -1, all_movie_ids) + cf_scores = cf_scores.reindex(all_movie_ids).fillna(cf_scores.mean()) + + # Content scores + content_scores = content_similarity_scores(liked_movie_ids, disliked_movie_ids, content_sim_matrix, all_movie_ids) + + # Normalize + cf_norm = _minmax(cf_scores) + content_norm = _minmax(content_scores) + + hybrid = alpha_cf * cf_norm + alpha_content * content_norm + + # Exclude rated/selected movies + for mid in liked_movie_ids + list(disliked_movie_ids) + list(exclude_rated): + if mid in hybrid.index: + hybrid.loc[mid] = -1.0 + + top_ids = hybrid.sort_values(ascending=False).head(top_n).index.tolist() + res = movies[movies["movieId"].isin(top_ids)].copy() + res["hybrid_score"] = res["movieId"].map(hybrid) + res["cf_score"] = res["movieId"].map(cf_scores) + res["content_score"] = res["movieId"].map(content_scores) + return res.sort_values("hybrid_score", ascending=False) \ No newline at end of file diff --git a/src/posters.py b/src/posters.py new file mode 100644 index 0000000..408d1fc --- /dev/null +++ b/src/posters.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os +from typing import Optional + +import requests + +TMDB_SEARCH_URL = "https://api.themoviedb.org/3/search/movie" +TMDB_IMG_BASE = "https://image.tmdb.org/t/p/w342" +PLACEHOLDER = "https://via.placeholder.com/342x513?text=No+Poster" + + +def get_poster_url(title: str, year: Optional[int] = None, api_key: Optional[str] = None) -> str: + api_key = api_key or os.getenv("TMDB_API_KEY") + if not api_key: + return PLACEHOLDER + try: + params = {"api_key": api_key, "query": title} + if year: + params["year"] = int(year) + r = requests.get(TMDB_SEARCH_URL, params=params, timeout=10) + r.raise_for_status() + data = r.json() + results = data.get("results", []) + if not results: + return PLACEHOLDER + poster_path = results[0].get("poster_path") + if not poster_path: + return PLACEHOLDER + return f"{TMDB_IMG_BASE}{poster_path}" + except Exception: + return PLACEHOLDER \ No newline at end of file From bb0f91155ff2528fff81e2e2d4c1f6d7589bc713 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 Aug 2025 23:38:47 +0000 Subject: [PATCH 2/2] Create initial MovieLens hybrid recommender project structure Co-authored-by: deepshekhardas1234 --- movielens_hybrid_recommender.zip | Bin 0 -> 10158 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 movielens_hybrid_recommender.zip diff --git a/movielens_hybrid_recommender.zip b/movielens_hybrid_recommender.zip new file mode 100644 index 0000000000000000000000000000000000000000..bc4422bf41212d43dea582b34f08b400b0d2d705 GIT binary patch literal 10158 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 bL!kGjJsc$6ZhIw|fPTo5F#!210Pz0+JGLAL literal 0 HcmV?d00001