Upload 6 files
Browse files- app.py +157 -0
- mlb.pkl +3 -0
- nn_model.pkl +3 -0
- templates/index.html +143 -0
- train_df.pkl +3 -0
- train_genre_features.pkl +3 -0
app.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, request, render_template, jsonify
|
| 2 |
+
import pickle
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from sklearn.neighbors import NearestNeighbors
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
app = Flask(__name__)
|
| 8 |
+
|
| 9 |
+
# ---------------------------
|
| 10 |
+
# Load saved model components
|
| 11 |
+
# ---------------------------
|
| 12 |
+
with open("train_df.pkl", "rb") as f:
|
| 13 |
+
train_df = pickle.load(f)
|
| 14 |
+
with open("mlb.pkl", "rb") as f:
|
| 15 |
+
mlb = pickle.load(f)
|
| 16 |
+
with open("train_genre_features.pkl", "rb") as f:
|
| 17 |
+
train_genre_features = pickle.load(f)
|
| 18 |
+
# (Optional) Loading nn_model if needed:
|
| 19 |
+
# with open("nn_model.pkl", "rb") as f:
|
| 20 |
+
# nn_model = pickle.load(f)
|
| 21 |
+
|
| 22 |
+
# If train_df does not have a list of genres, create it from the 'genres' column
|
| 23 |
+
if "genre_list" not in train_df.columns:
|
| 24 |
+
train_df["genre_list"] = train_df["genres"].apply(lambda x: x.split("|"))
|
| 25 |
+
|
| 26 |
+
# Prepare a list of all genres (for the dropdown options)
|
| 27 |
+
all_genres = sorted({genre for sublist in train_df["genre_list"] for genre in sublist})
|
| 28 |
+
|
| 29 |
+
# Prepare rating options (0 to 5 in increments of 0.5)
|
| 30 |
+
rating_options = [str(i / 2) for i in range(0, 11)]
|
| 31 |
+
# Prepare recommendation number options (1 to 10)
|
| 32 |
+
recommendation_options = [str(i) for i in range(1, 11)]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------
|
| 36 |
+
# Recommendation function using the training model
|
| 37 |
+
# ---------------------------
|
| 38 |
+
def recommend_movies_train(input_genres, min_rating, max_rating, n_recommendations=5, use_filter=True):
|
| 39 |
+
"""
|
| 40 |
+
Recommend movies using the training set model.
|
| 41 |
+
|
| 42 |
+
Parameters:
|
| 43 |
+
input_genres (str): Comma-separated string of genres (e.g., "Comedy, Drama")
|
| 44 |
+
min_rating (float): Minimum average rating.
|
| 45 |
+
max_rating (float): Maximum average rating.
|
| 46 |
+
n_recommendations (int): Number of recommendations.
|
| 47 |
+
use_filter (bool): Whether to filter the training set by genre string matching.
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
pd.DataFrame: Recommended movies from the training set.
|
| 51 |
+
"""
|
| 52 |
+
# Clean and process the input genres: replace any "|" with commas and split
|
| 53 |
+
cleaned_input = input_genres.replace("|", ",")
|
| 54 |
+
input_genre_list = [g.strip() for g in cleaned_input.split(',') if g.strip()]
|
| 55 |
+
|
| 56 |
+
if not input_genre_list:
|
| 57 |
+
return pd.DataFrame()
|
| 58 |
+
|
| 59 |
+
# Filter training movies by the given rating range
|
| 60 |
+
filtered_train = train_df[(train_df['avg_rating'] >= min_rating) & (train_df['avg_rating'] <= max_rating)]
|
| 61 |
+
|
| 62 |
+
# Optionally filter training movies to keep those that have one of the input genres
|
| 63 |
+
if use_filter:
|
| 64 |
+
genre_pattern = '|'.join(input_genre_list)
|
| 65 |
+
filtered_train = filtered_train[filtered_train['genres'].str.contains(genre_pattern, case=False, na=False)]
|
| 66 |
+
|
| 67 |
+
if filtered_train.empty:
|
| 68 |
+
return pd.DataFrame()
|
| 69 |
+
|
| 70 |
+
# Get indices of the filtered training data relative to the full training set
|
| 71 |
+
filtered_indices = filtered_train.index.to_numpy()
|
| 72 |
+
|
| 73 |
+
# Obtain the corresponding genre features from the training set features
|
| 74 |
+
filtered_features = train_genre_features[[list(train_df.index).index(i) for i in filtered_indices]]
|
| 75 |
+
|
| 76 |
+
# Create the input vector using the same MultiLabelBinarizer
|
| 77 |
+
input_vector = mlb.transform([input_genre_list])
|
| 78 |
+
|
| 79 |
+
# Fit a temporary nearest neighbors model on the filtered training data
|
| 80 |
+
nn_filtered = NearestNeighbors(metric='cosine')
|
| 81 |
+
nn_filtered.fit(filtered_features)
|
| 82 |
+
|
| 83 |
+
n_neighbors = min(n_recommendations, len(filtered_train))
|
| 84 |
+
distances, indices = nn_filtered.kneighbors(input_vector, n_neighbors=n_neighbors)
|
| 85 |
+
|
| 86 |
+
# Map relative indices back to the original training DataFrame indices
|
| 87 |
+
recommended_indices = filtered_indices[indices[0]]
|
| 88 |
+
|
| 89 |
+
return train_df.loc[recommended_indices][['movieId', 'title', 'avg_rating', 'genres']]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ---------------------------
|
| 93 |
+
# Routes
|
| 94 |
+
# ---------------------------
|
| 95 |
+
@app.route("/", methods=["GET", "POST"])
|
| 96 |
+
def index():
|
| 97 |
+
if request.method == "POST":
|
| 98 |
+
# Get form inputs from dropdowns
|
| 99 |
+
selected_genres = request.form.getlist("genres")
|
| 100 |
+
# Join selected genres into a comma-separated string
|
| 101 |
+
input_genres = ", ".join(selected_genres)
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
min_rating = float(request.form.get("min_rating", 0))
|
| 105 |
+
max_rating = float(request.form.get("max_rating", 5))
|
| 106 |
+
n_recommendations = int(request.form.get("n_recommendations", 5))
|
| 107 |
+
except ValueError:
|
| 108 |
+
return render_template("index.html", error="Invalid rating or recommendation number.",
|
| 109 |
+
all_genres=all_genres, rating_options=rating_options,
|
| 110 |
+
recommendation_options=recommendation_options)
|
| 111 |
+
|
| 112 |
+
if min_rating > max_rating:
|
| 113 |
+
return render_template("index.html", error="Minimum rating cannot be greater than maximum rating.",
|
| 114 |
+
all_genres=all_genres, rating_options=rating_options,
|
| 115 |
+
recommendation_options=recommendation_options)
|
| 116 |
+
|
| 117 |
+
# Get recommendations from the model
|
| 118 |
+
recommendations = recommend_movies_train(input_genres, min_rating, max_rating, n_recommendations)
|
| 119 |
+
|
| 120 |
+
if recommendations.empty:
|
| 121 |
+
message = "No movies found for the given criteria."
|
| 122 |
+
return render_template("index.html", message=message,
|
| 123 |
+
all_genres=all_genres, rating_options=rating_options,
|
| 124 |
+
recommendation_options=recommendation_options)
|
| 125 |
+
else:
|
| 126 |
+
# Convert DataFrame to HTML table for display
|
| 127 |
+
rec_html = recommendations.to_html(classes="table table-striped", index=False)
|
| 128 |
+
return render_template("index.html", recommendations=rec_html,
|
| 129 |
+
all_genres=all_genres, rating_options=rating_options,
|
| 130 |
+
recommendation_options=recommendation_options)
|
| 131 |
+
|
| 132 |
+
# GET request: pass dropdown options to template
|
| 133 |
+
return render_template("index.html", all_genres=all_genres, rating_options=rating_options,
|
| 134 |
+
recommendation_options=recommendation_options)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@app.route("/api/recommend", methods=["POST"])
|
| 138 |
+
def api_recommend():
|
| 139 |
+
data = request.get_json()
|
| 140 |
+
input_genres = data.get("genres", "")
|
| 141 |
+
try:
|
| 142 |
+
min_rating = float(data.get("min_rating", 0))
|
| 143 |
+
max_rating = float(data.get("max_rating", 5))
|
| 144 |
+
n_recommendations = int(data.get("n_recommendations", 5))
|
| 145 |
+
except ValueError:
|
| 146 |
+
return jsonify({"error": "Invalid rating or recommendation number."}), 400
|
| 147 |
+
|
| 148 |
+
recommendations = recommend_movies_train(input_genres, min_rating, max_rating, n_recommendations)
|
| 149 |
+
if recommendations.empty:
|
| 150 |
+
return jsonify({"message": "No movies found for the given criteria."})
|
| 151 |
+
|
| 152 |
+
result = recommendations.to_dict(orient="records")
|
| 153 |
+
return jsonify(result)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
app.run(debug=True)
|
mlb.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:128c83e1bf2c6cb5555f256109c1ecbdd84e3acc31c208c0aaea2e8a9a2aa79c
|
| 3 |
+
size 585
|
nn_model.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b9d9eb8d14a4ef1c7492505c2b347537ce3d350700d79270bb36e5257a98ecc4
|
| 3 |
+
size 7558421
|
templates/index.html
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>CineSage - Movie Recommendation System</title>
|
| 7 |
+
|
| 8 |
+
<!-- Bootstrap 5 CSS -->
|
| 9 |
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
| 10 |
+
|
| 11 |
+
<!-- Google Fonts -->
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 13 |
+
|
| 14 |
+
<style>
|
| 15 |
+
body {
|
| 16 |
+
background-color: #f4f6f9;
|
| 17 |
+
font-family: 'Poppins', sans-serif;
|
| 18 |
+
}
|
| 19 |
+
.recommendation-container {
|
| 20 |
+
background-color: white;
|
| 21 |
+
border-radius: 12px;
|
| 22 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
| 23 |
+
padding: 30px;
|
| 24 |
+
margin-top: 20px;
|
| 25 |
+
}
|
| 26 |
+
.form-control {
|
| 27 |
+
border-radius: 8px;
|
| 28 |
+
transition: all 0.3s ease;
|
| 29 |
+
}
|
| 30 |
+
.form-control:focus {
|
| 31 |
+
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
| 32 |
+
border-color: #0d6efd;
|
| 33 |
+
}
|
| 34 |
+
.btn-primary {
|
| 35 |
+
background-color: #0d6efd;
|
| 36 |
+
border: none;
|
| 37 |
+
border-radius: 8px;
|
| 38 |
+
transition: all 0.3s ease;
|
| 39 |
+
}
|
| 40 |
+
.btn-primary:hover {
|
| 41 |
+
background-color: #0b5ed7;
|
| 42 |
+
transform: translateY(-2px);
|
| 43 |
+
}
|
| 44 |
+
.movie-recommendations {
|
| 45 |
+
display: grid;
|
| 46 |
+
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
| 47 |
+
gap: 20px;
|
| 48 |
+
}
|
| 49 |
+
.movie-card {
|
| 50 |
+
background-color: #f8f9fa;
|
| 51 |
+
border-radius: 12px;
|
| 52 |
+
padding: 15px;
|
| 53 |
+
transition: transform 0.3s ease;
|
| 54 |
+
}
|
| 55 |
+
.movie-card:hover {
|
| 56 |
+
transform: scale(1.03);
|
| 57 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 58 |
+
}
|
| 59 |
+
.rating-badge {
|
| 60 |
+
background-color: #28a745;
|
| 61 |
+
color: white;
|
| 62 |
+
padding: 5px 10px;
|
| 63 |
+
border-radius: 20px;
|
| 64 |
+
}
|
| 65 |
+
</style>
|
| 66 |
+
</head>
|
| 67 |
+
<body>
|
| 68 |
+
<div class="container py-5">
|
| 69 |
+
<div class="row justify-content-center">
|
| 70 |
+
<div class="col-lg-8">
|
| 71 |
+
<div class="recommendation-container">
|
| 72 |
+
<h1 class="text-center mb-4">CineSage 🎬 Movie Recommender</h1>
|
| 73 |
+
|
| 74 |
+
<!-- Error and Message Alerts -->
|
| 75 |
+
{% if error %}
|
| 76 |
+
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
| 77 |
+
{{ error }}
|
| 78 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 79 |
+
</div>
|
| 80 |
+
{% endif %}
|
| 81 |
+
|
| 82 |
+
{% if message %}
|
| 83 |
+
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
| 84 |
+
{{ message }}
|
| 85 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 86 |
+
</div>
|
| 87 |
+
{% endif %}
|
| 88 |
+
|
| 89 |
+
<!-- Recommendation Form -->
|
| 90 |
+
<form method="POST" action="/">
|
| 91 |
+
<div class="row g-3">
|
| 92 |
+
<div class="col-md-6">
|
| 93 |
+
<label for="genres" class="form-label">Movie Genres</label>
|
| 94 |
+
<input type="text" class="form-control" name="genres" id="genres"
|
| 95 |
+
placeholder="Comedy, Drama, Sci-Fi" required>
|
| 96 |
+
<small class="form-text text-muted">Separate genres with commas</small>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div class="col-md-3">
|
| 100 |
+
<label for="min_rating" class="form-label">Min Rating</label>
|
| 101 |
+
<input type="number" class="form-control" name="min_rating" id="min_rating"
|
| 102 |
+
min="0" max="5" step="0.5" required>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<div class="col-md-3">
|
| 106 |
+
<label for="max_rating" class="form-label">Max Rating</label>
|
| 107 |
+
<input type="number" class="form-control" name="max_rating" id="max_rating"
|
| 108 |
+
min="0" max="5" step="0.5" required>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<div class="col-12">
|
| 112 |
+
<label for="n_recommendations" class="form-label">Number of Recommendations</label>
|
| 113 |
+
<input type="number" class="form-control" name="n_recommendations"
|
| 114 |
+
id="n_recommendations" min="1" max="20" value="5" required>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div class="col-12">
|
| 118 |
+
<button type="submit" class="btn btn-primary w-100 mt-3">
|
| 119 |
+
Discover Movies 🔍
|
| 120 |
+
</button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</form>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- Recommendations Section -->
|
| 127 |
+
{% if recommendations %}
|
| 128 |
+
<div class="mt-5">
|
| 129 |
+
<h2 class="text-center mb-4">Your Movie Recommendations</h2>
|
| 130 |
+
<div class="movie-recommendations">
|
| 131 |
+
{{ recommendations|safe }}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
{% endif %}
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<!-- Bootstrap 5 JS and Popper.js -->
|
| 140 |
+
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js"></script>
|
| 141 |
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.min.js"></script>
|
| 142 |
+
</body>
|
| 143 |
+
</html>
|
train_df.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:706077a31bac5b3d7da5eacf1f3c527ac3bf203dea5facc59bac57bde4b96d60
|
| 3 |
+
size 3629725
|
train_genre_features.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:08cfe0ced4914ac06c73fca723136d1c3f3ec4da94babe1d3bb7fd0b40ccad2a
|
| 3 |
+
size 7558083
|