import pandas as pd import gradio as gr import plotly.express as px import plotly.graph_objects as go from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity from sklearn.preprocessing import MinMaxScaler import difflib import numpy as np import os import time # Load dataset with proper error handling def load_data(file_path='steam.csv', max_rows=10000): try: data = pd.read_csv(file_path, quotechar='"', on_bad_lines='skip', nrows=max_rows) print(f"Successfully loaded {len(data)} games from {file_path}") return data except Exception as e: print(f"Error loading data: {e}") # Return empty DataFrame with expected columns to avoid crashing return pd.DataFrame(columns=['name', 'genres', 'categories', 'steamspy_tags', 'platforms', 'positive_ratings', 'price']) # Load and preprocess data data = load_data() # Only proceed if we have data if len(data) > 0: # Handle missing values more carefully for feature in ['genres', 'categories', 'steamspy_tags', 'platforms', 'positive_ratings', 'price']: if feature not in data.columns: data[feature] = '' elif data[feature].dtype == object: # Only fill string columns with empty strings data[feature] = data[feature].fillna('') else: data[feature] = data[feature].fillna(0) # Fill numeric columns with 0 # Combine features - now including price data['combined_features'] = ( data['genres'].astype(str) + ' ' + data['categories'].astype(str) + ' ' + data['steamspy_tags'].astype(str) + ' ' + data['platforms'].astype(str) + ' ' + data['price'].astype(str) # Add price as a feature ) # Vectorize with error handling try: vectorizer = TfidfVectorizer(stop_words='english', ngram_range=(1, 2), max_features=8000) feature_vectors = vectorizer.fit_transform(data['combined_features']) print(f"Vectorization complete. Shape: {feature_vectors.shape}") except Exception as e: print(f"Vectorization error: {e}") # Create empty feature vectors to avoid crashing feature_vectors = np.zeros((len(data), 1)) # Normalize positive ratings safely if 'positive_ratings' in data.columns and len(data) > 0: scaler = MinMaxScaler() data['positive_ratings_scaled'] = scaler.fit_transform( data[['positive_ratings']].clip(lower=0) # Ensure no negative ratings ) else: data['positive_ratings_scaled'] = 0 # Compute similarity matrix (only if we have enough data) if feature_vectors.shape[0] > 1: try: # Use batched processing for large datasets to reduce memory usage if len(data) > 5000: print("Large dataset detected. Using batched similarity calculation.") batch_size = 1000 similarity_matrix = np.zeros((len(data), len(data))) for i in range(0, len(data), batch_size): end = min(i + batch_size, len(data)) batch = feature_vectors[i:end] similarity_matrix[i:end] = cosine_similarity(batch, feature_vectors) game_similarity = similarity_matrix else: game_similarity = cosine_similarity(feature_vectors) print(f"Similarity matrix created. Shape: {game_similarity.shape}") except Exception as e: print(f"Similarity calculation error: {e}") # Create identity matrix as fallback game_similarity = np.eye(len(data)) else: game_similarity = np.eye(len(data)) list_of_all_titles = data['name'].tolist() else: # Fallbacks for empty data feature_vectors = np.zeros((0, 0)) game_similarity = np.zeros((0, 0)) list_of_all_titles = [] # Cache for storing recommendation results to improve performance recommendation_cache = {} # Platform detection function with improved logic def detect_platforms(platforms_str): platforms_str = str(platforms_str).lower() os_icons = [] # More reliable platform detection if 'windows' in platforms_str: os_icons.append("šŸ–„ļø Windows") if any(mac_term in platforms_str for mac_term in ['mac', 'macos', 'osx']): os_icons.append("šŸŽ macOS") if 'linux' in platforms_str: os_icons.append("🐧 Linux") return os_icons if os_icons else ["ā“ Unknown"] # Get game details for display def get_game_details(game_name): if not game_name or game_name not in list_of_all_titles: return "Game not found in database." try: game_data = data.loc[data['name'] == game_name].iloc[0] # Get genres and format them genres = str(game_data.get('genres', 'Unknown')) genres_list = [g.strip() for g in genres.split(';') if g.strip()] genres_display = ", ".join(genres_list) if genres_list else "Unknown" # Get price and format it price = game_data.get('price', 0) if isinstance(price, (int, float)): price_display = f"${price:.2f}" if price > 0 else "Free to Play" else: price_display = "Price unknown" # Get platforms platforms = game_data.get('platforms', '') os_list = detect_platforms(platforms) platforms_display = " | ".join(os_list) # Format the details details = f"## {game_name}\n\n" details += f"**Price:** {price_display}\n\n" details += f"**Genres:** {genres_display}\n\n" details += f"**Platforms:** {platforms_display}\n\n" # Add rating information if available if 'positive_ratings' in game_data: pos_ratings = int(game_data.get('positive_ratings', 0)) details += f"**Positive Ratings:** {pos_ratings:,}\n\n" if 'negative_ratings' in game_data: neg_ratings = int(game_data.get('negative_ratings', 0)) details += f"**Negative Ratings:** {neg_ratings:,}\n\n" # Calculate approval percentage if both values exist if pos_ratings + neg_ratings > 0: approval_percent = (pos_ratings / (pos_ratings + neg_ratings)) * 100 details += f"**Approval Rate:** {approval_percent:.1f}%\n\n" # Add release date if available if 'release_date' in game_data: release_date = game_data.get('release_date', 'Unknown') details += f"**Release Date:** {release_date}\n\n" # Add developer/publisher if available if 'developer' in game_data: developer = game_data.get('developer', 'Unknown') details += f"**Developer:** {developer}\n\n" if 'publisher' in game_data: publisher = game_data.get('publisher', 'Unknown') details += f"**Publisher:** {publisher}\n\n" return details except Exception as e: return f"Error retrieving game details: {str(e)}" # Generate a radar chart for game comparison def generate_game_comparison_chart(game_name): if not game_name or game_name not in list_of_all_titles: return None try: # Get the game index game_idx = data.loc[data['name'] == game_name].index[0] # Get top 3 similar games similarity_scores = list(enumerate(game_similarity[game_idx])) sorted_similar = sorted(similarity_scores, key=lambda x: x[1], reverse=True)[1:4] # Skip the first one (the game itself) similar_games = [data.iloc[idx]['name'] for idx, _ in sorted_similar] # Create feature vectors for radar chart (using genres as features) features = ['Action', 'Adventure', 'RPG', 'Strategy', 'Simulation', 'Sports', 'Racing'] chart_data = [] # Add main game main_game_data = data.iloc[game_idx] main_genres = str(main_game_data.get('genres', '')).split(';') main_values = [1 if genre in main_genres else 0.2 for genre in features] chart_data.append(go.Scatterpolar( r=main_values, theta=features, fill='toself', name=game_name )) # Add similar games for idx, score in sorted_similar: sim_game = data.iloc[idx] sim_genres = str(sim_game.get('genres', '')).split(';') sim_values = [1 if genre in sim_genres else 0.2 for genre in features] chart_data.append(go.Scatterpolar( r=sim_values, theta=features, fill='toself', name=sim_game['name'] )) fig = go.Figure(data=chart_data) fig.update_layout( polar=dict( radialaxis=dict( visible=True, range=[0, 1] ) ), showlegend=True, title=f"Genre Comparison: {game_name} vs Similar Games" ) return fig except Exception as e: print(f"Error generating comparison chart: {e}") return None # Recommend function with improved error handling and consistent return structure def recommend_games(user_game_name_input): # Check cache first if user_game_name_input in recommendation_cache: return recommendation_cache[user_game_name_input] if not user_game_name_input or not list_of_all_titles: return "Please enter a game name and ensure the dataset is loaded.", [] # Normalize input for better matching user_input_cleaned = user_game_name_input.strip().lower() # First try exact match exact_matches = [title for title in list_of_all_titles if title.lower() == user_input_cleaned] if exact_matches: closest_match = exact_matches[0] else: # Try fuzzy matching if no exact match find_close_match = difflib.get_close_matches(user_game_name_input, list_of_all_titles, n=1, cutoff=0.6) if not find_close_match: return f"No match found for '{user_game_name_input}'. Please try another game name.", [] closest_match = find_close_match[0] try: index_of_the_game = data.loc[data['name'] == closest_match].index[0] # Check for valid index if index_of_the_game >= len(game_similarity): return f"Found match '{closest_match}' but encountered an indexing error.", [] similarity_scores = list(enumerate(game_similarity[index_of_the_game])) # Sort by similarity and then by ratings as a tiebreaker sorted_similar_games = sorted( similarity_scores, key=lambda x: (x[1], data.iloc[x[0]]['positive_ratings_scaled']), reverse=True ) recommendations = [] game_list = [] # Add the searched game as the first entry recommendations.append(f"āœ“ You searched for: {closest_match}") game_list.append(closest_match) # Include the searched game in the list # Process recommendations for i, (index, score) in enumerate(sorted_similar_games[1:21]): if score < 0.2: # Higher threshold for better quality continue game_name = data.iloc[index]['name'] # Get platform info platforms = data.iloc[index].get('platforms', '') os_list = detect_platforms(platforms) os_display = " | ".join(os_list) # Get price info price = data.iloc[index].get('price', 0) price_display = f"${price:.2f}" if isinstance(price, (int, float)) and price > 0 else "Free" if price == 0 else "N/A" # Get genre info for additional context genres = str(data.iloc[index].get('genres', '')).split(';') genres_display = ", ".join(genres[:2]) if len(genres) > 0 and genres[0] else "" # Format recommendation with emoji and more details recommendation = f"{i+1}. {game_name} ({price_display}) - {score*100:.1f}% similar" if genres_display: recommendation += f" [{genres_display}]" recommendation += f" {os_display}" recommendations.append(recommendation) # Add to game list game_list.append(game_name) if len(recommendations) >= 11: # 10 recommendations + original search break result = ("\n".join(recommendations), game_list) recommendation_cache[user_game_name_input] = result # Cache the result return result except Exception as e: return f"Error while finding recommendations: {str(e)}", [] # Improved precision calculation def evaluate_precision(user_game_name_input): if not user_game_name_input or not list_of_all_titles: return 0.0 try: find_close_match = difflib.get_close_matches(user_game_name_input, list_of_all_titles, n=1, cutoff=0.6) if not find_close_match: return 0.0 closest_match = find_close_match[0] index_of_the_game = data.loc[data['name'] == closest_match].index[0] if index_of_the_game >= len(game_similarity): return 0.0 similarity_scores = list(enumerate(game_similarity[index_of_the_game])) sorted_similar_games = sorted( similarity_scores, key=lambda x: (x[1], data.iloc[x[0]]['positive_ratings_scaled']), reverse=True ) # Calculate precision based on genre overlap rather than exact match top_5_indices = [idx for idx, _ in sorted_similar_games[1:6]] original_genres = set(data.iloc[index_of_the_game]['genres'].split(';')) hits = 0 for idx in top_5_indices: rec_genres = set(data.iloc[idx]['genres'].split(';')) # Count as a hit if there's any genre overlap if original_genres.intersection(rec_genres): hits += 1 return round(hits / 5, 2) if top_5_indices else 0.0 except Exception as e: print(f"Precision calculation error: {str(e)}") return 0.0 # Function to generate price distribution chart def generate_price_chart(): try: # Filter for reasonable prices (exclude outliers) price_data = data[data['price'] < 100].copy() # Create price bins price_bins = [0, 5, 10, 15, 20, 30, 50, 100] price_data['price_category'] = pd.cut(price_data['price'], bins=price_bins, right=False) # Count games in each price bin price_counts = price_data['price_category'].value_counts().sort_index() # Create bar chart fig = px.bar( x=[str(cat) for cat in price_counts.index], y=price_counts.values, labels={'x': 'Price Range ($)', 'y': 'Number of Games'}, title='Price Distribution of Steam Games', color_discrete_sequence=['#1DB954'] # Steam-like green ) # Update layout fig.update_layout( xaxis_title='Price Range ($)', yaxis_title='Number of Games', template='plotly_white' ) return fig except Exception as e: print(f"Error generating price chart: {e}") return None # Combined function with progress updates def recommend_and_visualize(user_input): if not user_input or user_input.strip() == "": return "Please enter a game name", [] # Get recommendations recommendations, game_list = recommend_games(user_input) # Calculate precision precision = evaluate_precision(user_input) # Add platform legend and precision info footer = "\n\nšŸ“Š **Recommendation Quality**: " footer += f"Precision@5: {precision*100:.0f}%" if precision > 0 else "Unable to calculate precision" footer += "\n\n**Platform Legend**:\n" footer += "šŸ–„ļø Windows | šŸŽ macOS | 🐧 Linux | ā“ Unknown" return recommendations + footer, game_list # Get details for selected game def display_game_details(game_name): if not game_name: return "Please select a game to view details." return get_game_details(game_name) # Function to create genre distribution chart def create_genre_chart(): try: # Extract all genres all_genres = [] for genres in data['genres'].dropna(): all_genres.extend([g.strip() for g in str(genres).split(';') if g.strip()]) # Get counts genre_counts = pd.Series(all_genres).value_counts().nlargest(10) # Create bar chart fig = px.bar( x=genre_counts.index, y=genre_counts.values, labels={'x': 'Genre', 'y': 'Number of Games'}, title='Top 10 Game Genres on Steam', color_discrete_sequence=['#66c0f4'] # Steam blue ) fig.update_layout( xaxis_title='Genre', yaxis_title='Number of Games', template='plotly_white' ) return fig except Exception as e: print(f"Error creating genre chart: {e}") return None # Improved Gradio UI with added features with gr.Blocks(theme=gr.themes.Soft()) as demo: gr.Markdown("# šŸŽ® Steam Game Recommender") gr.Markdown("Enter the name of a game you like and get recommendations based on similarity!") with gr.Tab("Find Recommendations"): with gr.Row(): with gr.Column(scale=4): input_box = gr.Textbox( label="Your Favorite Game", placeholder="e.g., Portal 2, Half-Life 2, Skyrim", info="Type a game name that exists in the Steam dataset" ) with gr.Column(scale=1): run_button = gr.Button("Find Recommendations", variant="primary") with gr.Row(): with gr.Column(scale=1): output_text = gr.Textbox( label="Recommendations", lines=15, interactive=False ) dropdown = gr.Dropdown( label="Select a Game to View Details", choices=[], interactive=True, info="Choose a game to see its details" ) with gr.Column(scale=1): game_details = gr.Markdown( label="Game Details", value="Select a game from the dropdown to view details." ) with gr.Tab("Statistics"): with gr.Row(): with gr.Column(): gr.Markdown("## Game Price Distribution") price_chart = gr.Plot(value=generate_price_chart()) with gr.Column(): gr.Markdown("## Top Game Genres") genre_chart = gr.Plot(value=create_genre_chart()) with gr.Row(): refresh_stats_button = gr.Button("Refresh Statistics") # Add a tab for help/about with gr.Tab("About"): gr.Markdown(""" ## About This Recommender This Steam game recommender uses **TF-IDF vectorization** and **cosine similarity** to find games similar to your favorites. The recommendation engine analyzes: - Game genres - Categories - User-defined tags - Platforms - Price points The system then ranks games by similarity score and refines results using positive user ratings. ### How to Use 1. Enter the name of a game you enjoy in the search box 2. Click "Find Recommendations" to see similar games 3. Select any game from the dropdown to view detailed information 4. Explore the Statistics tab to see distributions of game prices and genres ### Dataset This system uses a dataset of Steam games with features like: - Game title - Genres - Categories - User tags - Price - Platform compatibility - User ratings ### Limitations - Recommendations depend on data quality and completeness - The system works best with popular titles that have detailed metadata - Very niche or new games may have fewer accurate recommendations """) # Add a search history tab with gr.Tab("Search History"): search_history = gr.Dataframe( headers=["Time", "Search Query", "Top Recommendation"], datatype=["str", "str", "str"], row_count=10, col_count=(3, "fixed"), value=[] ) clear_history_button = gr.Button("Clear History") # Register events search_history_data = [] def update_search_history(user_input): if not user_input or user_input.strip() == "": return search_history_data recommendations, game_list = recommend_games(user_input) # Format timestamp timestamp = time.strftime("%Y-%m-%d %H:%M:%S") # Get top recommendation (if any) top_rec = game_list[1] if len(game_list) > 1 else "No recommendation found" # Add to history search_history_data.append([timestamp, user_input, top_rec]) # Keep only the most recent 10 entries return search_history_data[-10:] def clear_history(): search_history_data.clear() return [] # Combined function to update recommendations and history def recommend_and_update_history(user_input): rec_text, game_list = recommend_and_visualize(user_input) history = update_search_history(user_input) return rec_text, game_list, history run_button.click( fn=recommend_and_update_history, inputs=input_box, outputs=[output_text, dropdown, search_history], show_progress=True ) # Also trigger on Enter key input_box.submit( fn=recommend_and_update_history, inputs=input_box, outputs=[output_text, dropdown, search_history], show_progress=True ) # Display game details when a game is selected dropdown.change( fn=display_game_details, inputs=dropdown, outputs=game_details ) # Clear history button clear_history_button.click( fn=clear_history, inputs=[], outputs=[search_history] ) # Refresh statistics refresh_stats_button.click( fn=lambda: (generate_price_chart(), create_genre_chart()), inputs=[], outputs=[price_chart, genre_chart] ) # Launch the Gradio app if __name__ == "__main__": demo.launch()