import gradio as gr import pandas as pd import numpy as np import joblib import shap import os import base64 import random import tempfile import plotly.graph_objects as go from io import BytesIO # ============================================================================== # 1. KNOWLEDGE BASE & MAPPINGS # ============================================================================== CODE_TO_NAME = { 'ENG': 'England', 'FRA': 'France', 'ESP': 'Spain', 'ITA': 'Italy', 'GER': 'Germany', 'NED': 'Netherlands', 'POR': 'Portugal', 'BRA': 'Brazil', 'ARG': 'Argentina', 'BEL': 'Belgium', 'CRO': 'Croatia', 'URU': 'Uruguay', 'USA': 'USA', 'TUR': 'Türkiye', 'COD': 'Congo DR', 'GAM': 'The Gambia', 'IRN': 'IR Iran', 'KOR': 'Korea Republic', 'CHN': 'China PR', 'KGZ': 'Kyrgyz Republic', 'CIV': 'Côte d\'Ivoire', 'MAR': 'Morocco', 'COL': 'Colombia', 'MEX': 'Mexico', 'JPN': 'Japan', 'SUI': 'Switzerland', 'DEN': 'Denmark', 'SEN': 'Senegal', 'AUS': 'Australia', 'SWE': 'Sweden', 'HUN': 'Hungary', 'TUN': 'Tunisia', 'WAL': 'Wales', 'ALG': 'Algeria', 'POL': 'Poland', 'ECU': 'Ecuador', 'EGY': 'Egypt', 'SRB': 'Serbia', 'SCO': 'Scotland', 'CZE': 'Czechia', 'AUT': 'Austria', 'NOR': 'Norway', 'NGA': 'Nigeria', 'CMR': 'Cameroon', 'CAN': 'Canada', 'GRE': 'Greece', 'MLI': 'Mali', 'CRC': 'Costa Rica', 'KSA': 'Saudi Arabia', 'RSA': 'South Africa', 'GHA': 'Ghana', 'SVK': 'Slovakia', 'FIN': 'Finland', 'IRL': 'Republic of Ireland', 'NIR': 'Northern Ireland', 'BIH': 'Bosnia and Herzegovina', 'ISL': 'Iceland', 'UKR': 'Ukraine', 'SVN': 'Slovenia', 'ALB': 'Albania', 'MKD': 'North Macedonia', 'MNE': 'Montenegro', 'JAM': 'Jamaica', 'QAT': 'Qatar', 'BFA': 'Burkina Faso', 'GUI': 'Guinea', 'GAB': 'Gabon', 'ZAM': 'Zambia', 'HON': 'Honduras', 'ESA': 'El Salvador', 'PAN': 'Panama', 'NZL': 'New Zealand', 'GEO': 'Georgia', 'ROU': 'Romania', 'VEN': 'Venezuela', 'PAR': 'Paraguay', 'CHI': 'Chile', 'PER': 'Peru', 'BOL': 'Bolivia', 'UZB': 'Uzbekistan', 'IRQ': 'Iraq', 'UAE': 'UAE', 'OMA': 'Oman', 'JOR': 'Jordan', 'BHR': 'Bahrain', 'SYR': 'Syria', 'PLE': 'Palestine', 'VIE': 'Vietnam', 'THA': 'Thailand', 'LBN': 'Lebanon', 'IND': 'India', 'TJK': 'Tajikistan', 'SLE': 'Sierra Leone', 'TOG': 'Togo', 'BEN': 'Benin', 'CGO': 'Congo', 'UGA': 'Uganda', 'CPV': 'Cabo Verde', 'GNB': 'Guinea-Bissau', 'EQG': 'Equatorial Guinea', 'ANG': 'Angola', 'MOZ': 'Mozambique', 'NAM': 'Namibia', 'ZIM': 'Zimbabwe', 'MAD': 'Madagascar', 'MWI': 'Malawi', 'KEN': 'Kenya', 'LBY': 'Libya', 'SDN': 'Sudan', 'ETH': 'Ethiopia', 'RWA': 'Rwanda', 'TAN': 'Tanzania', 'BDI': 'Burundi', 'LBR': 'Liberia', 'CTA': 'Central African Republic', 'COM': 'Comoros', 'SEY': 'Seychelles', 'LES': 'Lesotho', 'SWZ': 'Eswatini', 'BOT': 'Botswana', 'MRI': 'Mauritius', 'HAI': 'Haiti', 'TRI': 'Trinidad and Tobago', 'GUA': 'Guatemala', 'SKN': 'St Kitts and Nevis', 'SUR': 'Suriname', 'GUY': 'Guyana', 'DOM': 'Dominican Republic', 'CUB': 'Cuba', 'PUR': 'Puerto Rico', 'GRN': 'Grenada', 'BRB': 'Barbados', 'VIN': 'St Vincent and the Grenadines', 'LCA': 'St Lucia', 'DMA': 'Dominica', 'MSR': 'Montserrat', 'BER': 'Bermuda', 'CYP': 'Cyprus', 'ISR': 'Israel', 'ARM': 'Armenia', 'BLR': 'Belarus', 'KAZ': 'Kazakhstan', 'AZE': 'Azerbaijan', 'EST': 'Estonia', 'LTU': 'Lithuania', 'LVA': 'Latvia', 'FRO': 'Faroe Islands', 'LUX': 'Luxembourg', 'MDA': 'Moldova', 'MLT': 'Malta', 'AND': 'Andorra', 'SMR': 'San Marino', 'GIB': 'Gibraltar', 'LIE': 'Liechtenstein', 'RUS': 'Russia', 'KVX': 'Kosovo', 'KOS': 'Kosovo', 'BUL': 'Bulgaria', 'IDN': 'Indonesia', 'PHI': 'Philippines', 'MAS': 'Malaysia', 'CHA': 'Chad', 'CUW': 'Curaçao' } LEAGUE_MAP = { 'Premier League': 0.8263, 'Serie A': 0.4332, 'Bundesliga': 0.3599, 'La Liga': 0.3448, 'Ligue 1': 0.2680, 'Brazilian Serie A': 0.1337, 'Mexican Liga MX': 0.0813, 'Primeira Liga': 0.0703, 'Saudi Pro League': 0.0639, 'Eredivise': 0.0571, 'Argentine Primera': 0.0418, 'Japanese J1 League': 0.0106, 'Betway Premiership': 0.0000 } try: fifa_scores = pd.read_csv('fifa_scores.csv') NATION_MAP = fifa_scores.set_index('FIFA_Nation')['Total Points'].to_dict() except Exception: NATION_MAP = {'England': 1800.0, 'France': 1845.4} PRETTY_NAMES = { 'Age': 'Player Age', 'GLVS': 'League Prestige', 'FIFA_Points': 'National Team Quality', 'Min': 'Minutes Played', 'Gls_per_90': 'Goals (per 90)', 'Ast_per_90': 'Assists (per 90)', 'xG_per_90': 'Goal Threat (xG)', 'xAG_per_90': 'Playmaking (xAG)', 'npxG_per_90': 'Non-Pen Goal Threat (npxG)', 'PrgP_per_90': 'Prog. Passes (per 90)', 'PrgR_per_90': 'Prog. Runs (per 90)', 'PrgC_per_90': 'Prog. Carries (per 90)', 'TklW_per_90': 'Tackles Won (per 90)', 'Int_per_90': 'Interceptions (per 90)', 'CrdY_per_90': 'Yellow Cards (per 90)', 'CrdR_per_90': 'Red Cards (per 90)', 'Fld_per_90': 'Fouls Drawn (per 90)', 'G+A_per_90': 'Goals + Assists (per 90)', 'Pos_Group_Forwards': 'Position: Forward', 'Pos_Group_Midfielders': 'Position: Midfielder', 'Pos_Group_Defenders': 'Position: Defender', 'Pos_Group_Goalkeepers': 'Position: Goalkeeper', 'Player_Type_De_0': 'Role: Veteran Stopper', 'Player_Type_De_1': 'Role: Dev. Defender', 'Player_Type_De_2': 'Role: Ball-Playing Defender', 'Player_Type_Fo_0': 'Role: Wide Attacker', 'Player_Type_Fo_1': 'Role: Veteran Poacher', 'Player_Type_Fo_2': 'Role: Complete Forward', 'Player_Type_Mi_0': 'Role: Dev. Midfielder', 'Player_Type_Mi_1': 'Role: Deep-Lying Playmaker', 'Player_Type_Mi_2': 'Role: Adv. Playmaker', 'Player_Type_N/A': 'Role: Unclassified' } ROLE_CHOICES = [ "Complete Forward", "Veteran Poacher", "Developmental/Wide Attacker", "Advanced Playmaker", "Deep-Lying Playmaker", "Developmental Midfielder", "Ball-Playing Defender", "Veteran Stopper", "Developmental Defender", "Standard Goalkeeper" ] ROLE_TO_CLUSTER = { 'Complete Forward': 'Fo_2', 'Veteran Poacher': 'Fo_1', 'Developmental/Wide Attacker': 'Fo_0', 'Advanced Playmaker': 'Mi_2', 'Deep-Lying Playmaker': 'Mi_1', 'Developmental Midfielder': 'Mi_0', 'Ball-Playing Defender': 'De_2', 'Veteran Stopper': 'De_0', 'Developmental Defender': 'De_1', 'Standard Goalkeeper': 'N/A' } # ============================================================================== # 2. DYNAMIC LOADING # ============================================================================== try: xgb_pipeline_full = joblib.load('xgb_pipeline_full.joblib') xgb_pipeline_blind = joblib.load('xgb_pipeline_blind.joblib') preprocessor_full = xgb_pipeline_full.named_steps['preprocessor'] xgb_model_full = xgb_pipeline_full.named_steps['regressor'] preprocessor_blind = xgb_pipeline_blind.named_steps['preprocessor'] xgb_model_blind = xgb_pipeline_blind.named_steps['regressor'] PIPELINE_FEATURES = ( list(preprocessor_full.feature_names_in_) if hasattr(preprocessor_full, 'feature_names_in_') else [] ) GLOBAL_SHAP_EXPLAINER = shap.TreeExplainer(xgb_model_full) except Exception: PIPELINE_FEATURES = [] GLOBAL_SHAP_EXPLAINER = None try: db = pd.read_csv('app_database.csv') all_leagues = sorted(list(LEAGUE_MAP.keys())) db_qualified = db[db['Min'] >= 450].copy() if 'Nation' in db.columns: all_nations = sorted(list(set( [CODE_TO_NAME.get(code, code) for code in db['Nation'].dropna().unique()] ))) else: all_nations = sorted(list(NATION_MAP.keys())) if "England" not in all_nations: all_nations.append("England") POS_MEDIANS = {} _median_cols = ['Save%', 'CS%', 'PK_per_90', 'CrdR_per_90', 'Fld_per_90', 'Off_per_90', 'OG_per_90', 'Saves_per_90', 'CS_per_90'] for pos_group in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']: pos_data = db_qualified[db_qualified['Pos_Group'] == pos_group] medians = {} for col in _median_cols: if col in pos_data.columns: val = pd.to_numeric(pos_data[col], errors='coerce').median() medians[col] = val if pd.notna(val) else 0.0 else: medians[col] = 0.0 POS_MEDIANS[pos_group] = medians if not db_qualified.empty and 'Player' in db_qualified.columns: _all_names = db_qualified['Player'].dropna().unique() default_player = str(np.random.choice(_all_names)) player_count = len(_all_names) p_a_pos = db_qualified[db_qualified['Player'] == default_player].iloc[0].get('Pos_Group', 'Forwards') pool_b = db_qualified[(db_qualified['Pos_Group'] == p_a_pos) & (db_qualified['Player'] != default_player)] default_player_b = ( str(np.random.choice(pool_b['Player'].dropna().unique())) if not pool_b.empty else default_player ) else: default_player = "CUSTOM PROFILE" default_player_b = "CUSTOM PROFILE" player_count = 0 except Exception: db = pd.DataFrame() db_qualified = pd.DataFrame() all_nations = ["England", "France", "Brazil"] default_player = "CUSTOM PROFILE" default_player_b = "CUSTOM PROFILE" player_count = 0 POS_MEDIANS = {} # ============================================================================== # 2b. RADAR LIMITS (95th percentile per position) # ============================================================================== RADAR_COLS = ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', 'PrgP_per_90', 'PrgC_per_90', 'PrgR_per_90', 'TklW_per_90', 'Int_per_90'] _RADAR_FALLBACK = [1.0, 0.5, 1.0, 0.5, 10.0, 8.0, 6.0, 4.0, 3.0] POS_RADAR_LIMITS = {} for _pg in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']: _f = db_qualified[db_qualified['Pos_Group'] == _pg] if not db_qualified.empty else pd.DataFrame() POS_RADAR_LIMITS[_pg] = [ max(pd.to_numeric(_f[c], errors='coerce').quantile(0.95), 0.01) if (not _f.empty and c in _f.columns) else fb for c, fb in zip(RADAR_COLS, _RADAR_FALLBACK) ] POS_RADAR_LIMITS['Combined'] = [ max(POS_RADAR_LIMITS[_pg][i] for _pg in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']) for i in range(9) ] # ============================================================================== # 2c. PERCENTILE CACHE (computed once at startup) # ============================================================================== PERCENTILE_COLS = ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', 'PrgP_per_90', 'TklW_per_90', 'Int_per_90'] PERCENTILE_PRETTY = { 'Gls_per_90': 'Goals', 'Ast_per_90': 'Assists', 'xG_per_90': 'xG', 'xAG_per_90': 'xAG', 'PrgP_per_90': 'Prog Passes', 'TklW_per_90': 'Tackles', 'Int_per_90': 'Interceptions' } PERCENTILE_CACHE = {} if not db_qualified.empty: for _pg in ['Forwards', 'Midfielders', 'Defenders', 'Goalkeepers']: PERCENTILE_CACHE[_pg] = {} _subset = db_qualified[db_qualified['Pos_Group'] == _pg] for _col in PERCENTILE_COLS: if _col in _subset.columns: PERCENTILE_CACHE[_pg][_col] = np.sort( pd.to_numeric(_subset[_col], errors='coerce').dropna().values ) else: PERCENTILE_CACHE[_pg][_col] = np.array([]) def get_percentile(value, pos_group, col): arr = PERCENTILE_CACHE.get(pos_group, {}).get(col, np.array([])) if len(arr) == 0: return None return int(np.searchsorted(arr, value) / len(arr) * 100) def build_percentile_html(gls, ast, xg, xag, prgp, tklw, intc, pos_txt): stat_map = { 'Gls_per_90': gls, 'Ast_per_90': ast, 'xG_per_90': xg, 'xAG_per_90': xag, 'PrgP_per_90': prgp, 'TklW_per_90': tklw, 'Int_per_90': intc } # Filter to position-relevant stats pos_relevant = { 'Forwards': ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', 'PrgP_per_90'], 'Midfielders': ['Ast_per_90', 'xAG_per_90', 'PrgP_per_90', 'TklW_per_90', 'Int_per_90'], 'Defenders': ['TklW_per_90', 'Int_per_90', 'PrgP_per_90'], 'Goalkeepers': ['TklW_per_90', 'Int_per_90'], } relevant = pos_relevant.get(pos_txt, list(stat_map.keys())) badges = [] for col in relevant: val = stat_map.get(col) if val is None: continue pct = get_percentile(val, pos_txt, col) if pct is None: continue if pct >= 75: color, bg = '#059669', '#ecfdf5' tier = 'Top 25%' elif pct >= 40: color, bg = '#d97706', '#fffbeb' tier = f'{pct}th %ile' else: color, bg = '#dc2626', '#fef2f2' tier = f'{pct}th %ile' label = PERCENTILE_PRETTY.get(col, col) badges.append( f"
" f"{tier}" f"{label}" f"
" ) if not badges: return "" return ( "
" "
Percentile vs Position Peers
" "
" + "".join(badges) + "
" ) # ============================================================================== # 3. PLAYER LIST & SEARCH # ============================================================================== ALL_PLAYER_NAMES = sorted(db_qualified['Player'].unique().tolist()) if not db_qualified.empty else [] _DROPDOWN_CAP = 20 def filter_players_tab1(query: str) -> list: if not query or not query.strip(): results = ALL_PLAYER_NAMES[:_DROPDOWN_CAP] else: q = query.strip().lower() results = [p for p in ALL_PLAYER_NAMES if q in p.lower()][:_DROPDOWN_CAP] return ["CUSTOM PROFILE"] + results def filter_players_tab2(query: str) -> list: if not query or not query.strip(): return ALL_PLAYER_NAMES[:_DROPDOWN_CAP] q = query.strip().lower() return [p for p in ALL_PLAYER_NAMES if q in p.lower()][:_DROPDOWN_CAP] _init_tab1 = ["CUSTOM PROFILE"] + ALL_PLAYER_NAMES[:_DROPDOWN_CAP] if default_player != "CUSTOM PROFILE" and default_player not in _init_tab1: _init_tab1.append(default_player) _init_tab2 = ALL_PLAYER_NAMES[:_DROPDOWN_CAP] if default_player not in _init_tab2: _init_tab2.append(default_player) if default_player_b not in _init_tab2: _init_tab2.append(default_player_b) # ============================================================================== # 4. HELPER FUNCTIONS # ============================================================================== def fmt(val): if abs(val) < 1_000_000: return f"€{val/1000:.0f}K" return f"€{val/1e6:.1f}M" def image_to_base64(path): try: with open(path, "rb") as f: return f"data:image/png;base64,{base64.b64encode(f.read()).decode()}" except Exception: return None def generate_badges(league, nation_input): nation_clean = CODE_TO_NAME.get(nation_input, nation_input) league_b64 = image_to_base64(os.path.join("assets", "leagues", f"{league}.png")) nation_b64 = image_to_base64(os.path.join("assets", "flags", f"{nation_clean}.png")) s = "height:56px;width:auto;object-fit:contain;" html = '
' if league_b64: html += f'' if nation_b64: html += f'' return html + "
" def safe_val(val, default, cap): if pd.isna(val) or val is None: return default try: return min(float(val), cap) except: return default def get_player_data_tuple(name): _defaults = (24, "Premier League", "England", "Forwards", "Complete Forward", 2000, 0.4, 0.2, 0.4, 0.2, 4.0, 5.0, 3.0, 1.5, 1.0, 0.1) if not name or name == "CUSTOM PROFILE" or db.empty: return _defaults matches = db[db['Player'] == name] if matches.empty: return _defaults p = matches.iloc[0] raw_role = str(p.get('Player_Type', 'N/A')) role_fallback = { 'De_0': 'Veteran Stopper', 'De_1': 'Developmental Defender', 'De_2': 'Ball-Playing Defender', 'Fo_0': 'Developmental/Wide Attacker', 'Fo_1': 'Veteran Poacher', 'Fo_2': 'Complete Forward', 'Mi_0': 'Developmental Midfielder', 'Mi_1': 'Deep-Lying Playmaker', 'Mi_2': 'Advanced Playmaker', 'nan': 'Standard Goalkeeper', 'N/A': 'Standard Goalkeeper' } safe_role = raw_role if raw_role in ROLE_CHOICES else role_fallback.get(raw_role, "Complete Forward") if str(p.get('Pos_Group', '')) == 'Goalkeepers': safe_role = 'Standard Goalkeeper' elif raw_role in ('nan', 'N/A', 'None') or pd.isna(p.get('Player_Type')): safe_role = role_fallback.get('N/A', 'Complete Forward') n_full = CODE_TO_NAME.get(p.get('Nation'), p.get('Nation')) pos_grp = str(p.get('Pos_Group', 'Forwards')) lims = POS_RADAR_LIMITS.get(pos_grp, [1.0, 0.5, 1.0, 0.5, 10.0, 8.0, 6.0, 4.0, 3.0]) return ( int(safe_val(p.get('Age'), 24, 40)), p['league'] if p.get('league') in LEAGUE_MAP else "Premier League", n_full if n_full in all_nations else "England", pos_grp, safe_role, int(safe_val(p.get('Min'), 2000, 3420)), safe_val(p.get('Gls_per_90'), 0.0, lims[0]), safe_val(p.get('Ast_per_90'), 0.0, lims[1]), safe_val(p.get('xG_per_90'), 0.0, lims[2]), safe_val(p.get('xAG_per_90'), 0.0, lims[3]), safe_val(p.get('PrgP_per_90'), 0.0, lims[4]), safe_val(p.get('PrgR_per_90'), 0.0, lims[6]), safe_val(p.get('PrgC_per_90'), 0.0, lims[5]), safe_val(p.get('TklW_per_90'), 0.0, lims[7]), safe_val(p.get('Int_per_90'), 0.0, lims[8]), safe_val(p.get('CrdY_per_90'), 0.0, 2.0) ) def _build_input_row(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): nation_clean = CODE_TO_NAME.get(nation_txt, nation_txt) row = pd.DataFrame(index=[0]) for col in PIPELINE_FEATURES: row[col] = "Unknown" if col in ('Pos_Group','Player_Type','General_Pos','Nation','league') else 0.0 if player_name and player_name != "CUSTOM PROFILE" and not db.empty: matches = db[db['Player'] == player_name] if not matches.empty: p = matches.iloc[0] for col in PIPELINE_FEATURES: if col in p: row.at[0, col] = p[col] if (not player_name or player_name == "CUSTOM PROFILE") and pos_txt in POS_MEDIANS: for col, median_val in POS_MEDIANS[pos_txt].items(): if col in row.columns: row.at[0, col] = median_val row.at[0, 'Age'] = age; row.at[0, 'Min'] = mins row.at[0, 'Gls_per_90'] = gls; row.at[0, 'Ast_per_90'] = ast row.at[0, 'G+A_per_90'] = gls + ast; row.at[0, 'npxG_per_90'] = xg row.at[0, 'xAG_per_90'] = xag; row.at[0, 'PrgP_per_90'] = prgp row.at[0, 'PrgR_per_90'] = prgr; row.at[0, 'PrgC_per_90'] = prgc row.at[0, 'TklW_per_90'] = tklw; row.at[0, 'Int_per_90'] = intc row.at[0, 'CrdY_per_90'] = crdy row.at[0, 'GLVS'] = LEAGUE_MAP.get(league_txt, 0.1) row.at[0, 'FIFA_Points'] = NATION_MAP.get(nation_clean, 1500) row.at[0, 'Pos_Group'] = pos_txt row.at[0, 'Player_Type'] = ROLE_TO_CLUSTER.get(role_txt, 'N/A') for col in PIPELINE_FEATURES: if col not in ('Pos_Group','Player_Type','General_Pos','Nation','league'): row[col] = pd.to_numeric(row[col], errors='coerce').fillna(0.0) return row, nation_clean def fast_predict(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): try: row, _ = _build_input_row(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy) val = np.expm1(xgb_pipeline_full.predict(row)[0]) if val < 1000: val *= 1_000_000 try: blind_cols = list(xgb_pipeline_blind.feature_names_in_) cf = np.expm1(xgb_pipeline_blind.predict(row[blind_cols])[0]) if cf < 1000: cf *= 1_000_000 except Exception: cf = 0 return val, cf except Exception: return 0, 0 # ============================================================================== # 5. PLOTLY CHART BUILDERS # ============================================================================== _PLOTLY_LAYOUT = dict( paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)', font=dict(family="DM Sans, system-ui, sans-serif", color='#334155'), margin=dict(l=20, r=20, t=30, b=20), autosize=True ) def build_radar_plotly(r_labels, r_vals, r_limits, avg_vals, pos_txt, player_name=None): n_vals = [min(v/l, 1.0) for v, l in zip(r_vals, r_limits)] avg_n = [min(v/l, 1.0) for v, l in zip(avg_vals, r_limits)] hover_player = [f"{lab}
{v:.2f} per 90
({n:.0%} of elite)" for lab, v, n in zip(r_labels, r_vals, n_vals)] hover_avg = [f"{lab}
Avg: {v:.2f} per 90" for lab, v in zip(r_labels, avg_vals)] fig = go.Figure() fig.add_trace(go.Scatterpolar( r=avg_n + [avg_n[0]], theta=r_labels + [r_labels[0]], fill='toself', fillcolor='rgba(148,163,184,0.08)', line=dict(color='#94a3b8', width=1.5, dash='dot'), name=f'{pos_txt} Avg', hovertext=hover_avg + [hover_avg[0]], hoverinfo='text' )) fig.add_trace(go.Scatterpolar( r=n_vals + [n_vals[0]], theta=r_labels + [r_labels[0]], fill='toself', fillcolor='rgba(59,130,246,0.15)', line=dict(color='#3b82f6', width=2.5), name=player_name or 'Player', hovertext=hover_player + [hover_player[0]], hoverinfo='text', marker=dict(size=7, color='#1e40af') )) fig.update_layout( **_PLOTLY_LAYOUT, polar=dict( bgcolor='rgba(0,0,0,0)', radialaxis=dict(visible=False, range=[0, 1]), angularaxis=dict( tickfont=dict(size=11, color='#475569', family="DM Sans, system-ui, sans-serif"), gridcolor='#e2e8f0', linecolor='#e2e8f0' ) ), legend=dict(orientation='h', yanchor='bottom', y=-0.15, xanchor='center', x=0.5, font=dict(size=11)), height=380 ) return fig def build_waterfall_plotly(df_p, base_eur, eur_final): colors_list = ['#10b981' if imp >= 0 else '#ef4444' for imp in df_p['impact']] hover_texts = [f"{lab.split(chr(10))[0]}
Impact: {'+' if imp >= 0 else ''}{fmt(imp)}" for lab, imp in zip(df_p['label'], df_p['impact'])] clean_labels = [lab.replace('\n', '
') + '' if '\n' in lab else lab for lab in df_p['label']] fig = go.Figure() running = base_eur for i, (_, r) in enumerate(df_p.iterrows()): imp = r['impact'] fig.add_trace(go.Bar( y=[clean_labels[i]], x=[imp], base=[running], orientation='h', marker=dict(color=colors_list[i], line=dict(width=0), cornerradius=4), hovertext=hover_texts[i], hoverinfo='text', showlegend=False, text=f" {'+' if imp >= 0 else ''}{fmt(imp)} ", textposition='outside', textfont=dict(size=12, color='#1e293b', family="DM Sans, system-ui, sans-serif"), )) running += imp all_x = [base_eur, eur_final] r2 = base_eur for _, r in df_p.iterrows(): all_x += [r2, r2 + r['impact']]; r2 += r['impact'] xmin, xmax = min(all_x), max(all_x); pad = (xmax - xmin) * 0.4 fig.add_vline(x=base_eur, line=dict(color='#94a3b8', width=1.5, dash='solid'), annotation=dict(text=f"Baseline
{fmt(base_eur)}", font=dict(size=10, color='#64748b'), showarrow=False, yshift=10)) fig.add_vline(x=eur_final, line=dict(color='#0f172a', width=2, dash='dash'), annotation=dict(text=f"Prediction
{fmt(eur_final)}", font=dict(size=11, color='#0f172a'), showarrow=False, yshift=10)) fig.update_layout( **_PLOTLY_LAYOUT, xaxis=dict(range=[xmin - pad, xmax + pad], showgrid=True, gridcolor='#f1f5f9', tickfont=dict(size=11), zeroline=False, tickprefix='€', tickformat=',.0f'), yaxis=dict(autorange='reversed', tickfont=dict(size=12)), height=max(300, len(df_p) * 55 + 80), bargap=0.35, ) return fig def build_comparison_radar_plotly(labels, rva, rvb, limits, name_a, name_b): na_ = [min(v/l, 1.0) for v, l in zip(rva, limits)] nb_ = [min(v/l, 1.0) for v, l in zip(rvb, limits)] fig = go.Figure() fig.add_trace(go.Scatterpolar( r=na_ + [na_[0]], theta=labels + [labels[0]], fill='toself', fillcolor='rgba(59,130,246,0.15)', line=dict(color='#3b82f6', width=2.5), name=name_a, marker=dict(size=6, color='#1e40af') )) fig.add_trace(go.Scatterpolar( r=nb_ + [nb_[0]], theta=labels + [labels[0]], fill='toself', fillcolor='rgba(249,115,22,0.15)', line=dict(color='#f97316', width=2.5), name=name_b, marker=dict(size=6, color='#c2410c') )) fig.update_layout( **_PLOTLY_LAYOUT, polar=dict(bgcolor='rgba(0,0,0,0)', gridshape='circular', radialaxis=dict(visible=False, range=[0, 1]), angularaxis=dict(tickfont=dict(size=11, color='#475569'), gridcolor='#e2e8f0', linecolor='#e2e8f0')), legend=dict(orientation='h', yanchor='bottom', y=-0.15, xanchor='center', x=0.5, font=dict(size=12)), height=420 ) return fig # ============================================================================== # 6. PREDICTION FUNCTION # ============================================================================== def predict_valuation(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): row, nation_clean = _build_input_row(player_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy) log_val = xgb_pipeline_full.predict(row)[0] eur_final = np.expm1(log_val) if eur_final < 1000: eur_final *= 1_000_000 lower = np.expm1(log_val - 0.58); upper = np.expm1(log_val + 0.58) if lower < 1000: lower *= 1_000_000 if upper < 1000: upper *= 1_000_000 try: blind_cols = list(xgb_pipeline_blind.feature_names_in_) eur_cf = np.expm1(xgb_pipeline_blind.predict(row[blind_cols])[0]) if eur_cf < 1000: eur_cf *= 1_000_000 gap = eur_final - eur_cf eur_cf_str = fmt(eur_cf) if abs(gap) < 500_000: gap_color, gap_str, gap_exp = "#64748b", "Minimal", "Market and performance valuations are closely aligned." elif gap > 0: gap_color = "#10b981" gap_str = f"+{fmt(gap)}" gap_exp = f"League prestige and national ranking add {fmt(gap)} to this player's market price." else: gap_color = "#ef4444" gap_str = fmt(gap) gap_exp = f"League and national standing suppress this player's market price by {fmt(abs(gap))}." except Exception: eur_cf = eur_final eur_cf_str = "N/A"; gap_color = "#64748b"; gap_str = "N/A"; gap_exp = "Performance-only model unavailable." gap = 0 display_name = player_name if player_name and player_name != "CUSTOM PROFILE" else "Custom Profile" badge_html = generate_badges(league_txt, nation_txt) # ── Percentile badges ── pct_html = build_percentile_html(gls, ast, xg, xag, prgp, tklw, intc, pos_txt) # ── Hero card HTML ── name_html = f"""
Player Report
{display_name}
{pos_txt} · {role_txt} · Age {int(age)}
""" # ── Valuation card ── val_html = f"""
Market Valuation
{fmt(eur_final)}
Range: {fmt(lower)} - {fmt(upper)}
Performance-Only
Excludes league & national ranking
{eur_cf_str}
Structural Bias Gap
{gap_str}
{gap_exp}
{pct_html}""" # ── Radar ── r_labels = ['Goals', 'Assists', 'npxG', 'xAG', 'Prog Passes', 'Prog Carries', 'Prog Runs', 'Tackles Won', 'Interceptions'] r_limits = POS_RADAR_LIMITS.get(pos_txt, _RADAR_FALLBACK) r_vals = [gls, ast, xg, xag, prgp, prgc, prgr, tklw, intc] try: if not db_qualified.empty and pos_txt in db_qualified['Pos_Group'].values: pos_data = db_qualified[db_qualified['Pos_Group'] == pos_txt] radar_db_cols = ['Gls_per_90', 'Ast_per_90', 'xG_per_90', 'xAG_per_90', 'PrgP_per_90', 'PrgC_per_90', 'PrgR_per_90', 'TklW_per_90', 'Int_per_90'] avg = [pd.to_numeric(pos_data[c], errors='coerce').mean() if c in pos_data.columns else 0.0 for c in radar_db_cols] defaults = [0.2, 0.1, 0.3, 0.15, 4.0, 2.0, 1.5, 1.0, 0.8] avg = [a if pd.notna(a) else d for a, d in zip(avg, defaults)] else: avg = [0.2, 0.1, 0.3, 0.15, 4.0, 2.0, 1.5, 1.0, 0.8] except Exception: avg = [0.2, 0.1, 0.3, 0.15, 4.0, 2.0, 1.5, 1.0, 0.8] fig_radar = build_radar_plotly(r_labels, r_vals, r_limits, avg, pos_txt, display_name) # ── SHAP waterfall ── arr = preprocessor_full.transform(row) feat_names = preprocessor_full.get_feature_names_out() tf_df = pd.DataFrame(arr, columns=feat_names) explainer = GLOBAL_SHAP_EXPLAINER or shap.TreeExplainer(xgb_model_full) shap_vals = explainer(tf_df) top_drivers = [] try: base_eur = np.expm1(shap_vals.base_values[0]) if base_eur < 1000: base_eur *= 1_000_000 gap_eur = eur_final - base_eur tot_log = np.sum(shap_vals.values[0]) labels_w, impacts_w = [], [] for i, feat_item in enumerate(feat_names): cf = feat_item.split('__')[-1] if cf == 'GLVS': vs = f"{LEAGUE_MAP.get(league_txt,0):.3f}" elif cf == 'FIFA_Points': vs = f"{NATION_MAP.get(nation_clean,0):.0f}" elif cf == 'Age': vs = f"{int(age)}" elif cf == 'Min': vs = f"{int(mins)}" elif cf in row.columns: vs = f"{float(row.at[0,cf]):.2f}" elif cf.startswith('Pos_Group_'): vs = "Yes" if pos_txt == cf.replace('Pos_Group_','') else "No" elif cf.startswith('Player_Type_'): vs = "Yes" if ROLE_TO_CLUSTER.get(role_txt,'N/A') == cf.replace('Player_Type_','') else "No" else: vs = "N/A" labels_w.append(f"{PRETTY_NAMES.get(cf, cf)}\n({vs})") imp = (gap_eur * shap_vals.values[0][i] / tot_log) if abs(tot_log) > 1e-9 else 0 impacts_w.append(imp) top_drivers.append((PRETTY_NAMES.get(cf, cf), imp)) df_p = (pd.DataFrame({'label': labels_w, 'impact': impacts_w}) .assign(abs_impact=lambda d: d.impact.abs()) .sort_values('abs_impact', ascending=False).head(8) .sort_values('abs_impact', ascending=True)) fig_waterfall = build_waterfall_plotly(df_p, base_eur, eur_final) top_drivers = sorted(top_drivers, key=lambda x: abs(x[1]), reverse=True)[:3] except Exception: fig_waterfall = go.Figure() fig_waterfall.add_annotation(text="Insufficient data for Price Driver Analysis", showarrow=False, font=dict(size=14, color='#94a3b8')) fig_waterfall.update_layout(**_PLOTLY_LAYOUT, height=200) # Store last prediction context for scout report / PDF generation _store_last_prediction( display_name, age, league_txt, nation_txt, pos_txt, role_txt, mins, eur_final, lower, upper, eur_cf, gap, gap_str, gap_exp, top_drivers, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy ) return (name_html, val_html, fig_waterfall, badge_html, fig_radar) def master_load_and_predict(player_name): stats = get_player_data_tuple(player_name) ui_elements = predict_valuation(player_name, *stats) acc_update = gr.Accordion(open=(player_name == "CUSTOM PROFILE")) return (*stats, acc_update, *ui_elements) # ============================================================================== # 6b. LAST PREDICTION STATE STORE # Holds the context needed by the AI report and PDF export without # requiring Gradio State (avoids concurrency issues for a demo app). # ============================================================================== _LAST_PREDICTION = {} def _store_last_prediction(name, age, league, nation, pos, role, mins, eur_final, lower, upper, eur_cf, gap, gap_str, gap_exp, top_drivers, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy): _LAST_PREDICTION.update(dict( name=name, age=age, league=league, nation=nation, pos=pos, role=role, mins=mins, eur_final=eur_final, lower=lower, upper=upper, eur_cf=eur_cf, gap=gap, gap_str=gap_str, gap_exp=gap_exp, top_drivers=top_drivers, gls=gls, ast=ast, xg=xg, xag=xag, prgp=prgp, prgr=prgr, prgc=prgc, tklw=tklw, intc=intc, crdy=crdy )) # ============================================================================== # 7. AI SCOUT REPORT (Google Gemini — free tier, no credit card) # ============================================================================== def generate_scout_report(): """Calls Gemini Flash to write a 3-paragraph scout report from the last prediction.""" api_key = os.environ.get("GEMINI_API_KEY", "") if not api_key: return ( "⚠️ No API key found. Add GEMINI_API_KEY to your Hugging Face Space secrets " "(Settings → Variables and Secrets) and restart the Space." ) if not _LAST_PREDICTION: return "Run a valuation first, then click Generate Scout Report." p = _LAST_PREDICTION drivers_text = "\n".join( [f" • {name}: {'+' if val > 0 else ''}{fmt(val)}" for name, val in p.get('top_drivers', [])] ) or " • Insufficient SHAP data" bias_note = "" gap = p.get('gap', 0) if abs(gap) >= 500_000: direction = "above" if gap > 0 else "below" bias_note = ( f"The bias-blind performance model values this player at {fmt(p['eur_cf'])}, " f"which is {fmt(abs(gap))} {direction} the full market estimate. " f"{'This premium reflects league and passport prestige rather than on-pitch output.' if gap > 0 else 'This discount suggests the market undervalues the player due to league or nationality factors.'}" ) prompt = f"""You are a senior professional football scout writing a concise, data-driven scouting report for a sporting director. Player Profile: - Name: {p['name']} - Position: {p['role']} ({p['pos']}) - Age: {int(p['age'])} | League: {p['league']} | Nationality: {p['nation']} - Minutes played this season: {int(p['mins'])} Model Outputs: - Full pipeline market valuation: {fmt(p['eur_final'])} (95% range: {fmt(p['lower'])} – {fmt(p['upper'])}) - Performance-only (bias-blind) valuation: {fmt(p['eur_cf'])} - Structural bias gap: {p['gap_str']} — {p['gap_exp']} Top price drivers (SHAP analysis): {drivers_text} Key per-90 stats: Goals {p['gls']:.2f} | Assists {p['ast']:.2f} | npxG {p['xg']:.2f} | xAG {p['xag']:.2f} | Prog Passes {p['prgp']:.1f} | Tackles Won {p['tklw']:.2f} | Interceptions {p['intc']:.2f} Bias context: {bias_note if bias_note else 'No significant structural bias detected between market and performance valuations.'} Write exactly 3 paragraphs: 1. Opening summary: player identity, current valuation, and what it reflects about their profile and career stage. 2. Performance analysis: what the price drivers and stats reveal about their strengths and any weaknesses or risks. 3. Transfer recommendation: a concrete recommendation for a sporting director, referencing the bias-adjusted figure where relevant and suggesting the type of club or context where this player would add most value. Write in professional but direct English. Be specific with the numbers provided. Do not invent statistics not given above. Keep each paragraph to 3-4 sentences.""" try: from google import genai client = genai.Client(api_key=api_key) response = client.models.generate_content( model="gemini-2.5-flash", contents=prompt ) return response.text except Exception as e: return f"⚠️ Error generating report: {str(e)}" # ============================================================================== # 8. PDF EXPORT # ============================================================================== def export_pdf(scout_report_text): """Generates a branded PDF scouting report and returns the filepath.""" try: from reportlab.lib.pagesizes import A4 from reportlab.lib import colors from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import mm from reportlab.lib.enums import TA_LEFT, TA_CENTER if not _LAST_PREDICTION: return None p = _LAST_PREDICTION buf = BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, rightMargin=20*mm, leftMargin=20*mm, topMargin=16*mm, bottomMargin=16*mm ) # ── Styles ── styles = getSampleStyleSheet() def sty(name, **kwargs): return ParagraphStyle(name, parent=styles['Normal'], **kwargs) hdr_title = sty('HdrTitle', fontSize=22, fontName='Helvetica-Bold', textColor=colors.HexColor('#0f172a'), spaceAfter=2) hdr_sub = sty('HdrSub', fontSize=10, fontName='Helvetica', textColor=colors.HexColor('#64748b'), spaceAfter=12) sec_label = sty('SecLabel', fontSize=8, fontName='Helvetica-Bold', textColor=colors.HexColor('#94a3b8'), spaceBefore=14, spaceAfter=4) body_style = sty('Body', fontSize=10, fontName='Helvetica', textColor=colors.HexColor('#1e293b'), leading=16, spaceAfter=6) report_sty = sty('Report', fontSize=10, fontName='Helvetica', textColor=colors.HexColor('#1e293b'), leading=17, spaceBefore=4, spaceAfter=8) story = [] # ── Header bar ── header_data = [[ Paragraph(f"{p['name']}", hdr_title), Paragraph("Transfer Valuation Engine
Bias-Aware XGBoost Pipeline", hdr_sub) ]] header_table = Table(header_data, colWidths=[110*mm, 60*mm]) header_table.setStyle(TableStyle([ ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('BACKGROUND', (0,0), (-1,-1), colors.HexColor('#f8fafc')), ('ROUNDEDCORNERS', [8, 8, 8, 8]), ('BOX', (0,0), (-1,-1), 1, colors.HexColor('#e2e8f0')), ('TOPPADDING', (0,0), (-1,-1), 12), ('BOTTOMPADDING', (0,0), (-1,-1), 12), ('LEFTPADDING', (0,0), (-1,-1), 14), ])) story.append(header_table) story.append(Spacer(1, 10)) # ── Profile row ── story.append(Paragraph("PLAYER PROFILE", sec_label)) profile_data = [ ['Position', f"{p['role']} ({p['pos']})", 'Age', str(int(p['age']))], ['League', p['league'], 'Nationality', p['nation']], ['Minutes', str(int(p['mins'])), '', ''], ] pt = Table(profile_data, colWidths=[30*mm, 65*mm, 30*mm, 45*mm]) pt.setStyle(TableStyle([ ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), ('FONTNAME', (0,0), (0,-1), 'Helvetica-Bold'), ('FONTNAME', (2,0), (2,-1), 'Helvetica-Bold'), ('FONTSIZE', (0,0), (-1,-1), 9), ('TEXTCOLOR', (0,0), (0,-1), colors.HexColor('#64748b')), ('TEXTCOLOR', (2,0), (2,-1), colors.HexColor('#64748b')), ('TEXTCOLOR', (1,0), (1,-1), colors.HexColor('#0f172a')), ('TEXTCOLOR', (3,0), (3,-1), colors.HexColor('#0f172a')), ('ROWBACKGROUNDS', (0,0), (-1,-1), [colors.HexColor('#f8fafc'), colors.white]), ('TOPPADDING', (0,0), (-1,-1), 5), ('BOTTOMPADDING',(0,0), (-1,-1), 5), ('LEFTPADDING', (0,0), (-1,-1), 8), ('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), ('INNERGRID', (0,0), (-1,-1), 0.3, colors.HexColor('#e2e8f0')), ])) story.append(pt) story.append(Spacer(1, 8)) # ── Valuation cards ── story.append(Paragraph("VALUATION SUMMARY", sec_label)) val_data = [ [Paragraph('MARKET VALUATION', sty('vc', fontSize=8, fontName='Helvetica-Bold')), Paragraph('PERFORMANCE-ONLY', sty('vc2', fontSize=8, fontName='Helvetica-Bold')), Paragraph('STRUCTURAL BIAS GAP', sty('vc3', fontSize=8, fontName='Helvetica-Bold'))], [Paragraph(f'{fmt(p["eur_final"])}', sty('vv', fontSize=20, fontName='Helvetica-Bold')), Paragraph(f'{fmt(p["eur_cf"])}', sty('vv2', fontSize=20, fontName='Helvetica-Bold')), Paragraph(f' 0 else "#ef4444" if p["gap"] < 0 else "#64748b"}">{p["gap_str"]}', sty('vv3', fontSize=20, fontName='Helvetica-Bold'))], [Paragraph(f'Range: {fmt(p["lower"])} – {fmt(p["upper"])}', sty('vs', fontSize=8, fontName='Helvetica')), Paragraph(f'Excl. league & nationality', sty('vs2', fontSize=8, fontName='Helvetica')), Paragraph(f'{p["gap_exp"]}', sty('vs3', fontSize=8, fontName='Helvetica', leading=10))], ] vt = Table(val_data, colWidths=[57*mm, 57*mm, 56*mm]) vt.setStyle(TableStyle([ ('BACKGROUND', (0,0), (0,-1), colors.HexColor('#eff6ff')), ('BACKGROUND', (1,0), (1,-1), colors.HexColor('#f8fafc')), ('BACKGROUND', (2,0), (2,-1), colors.HexColor('#f8fafc')), ('BOX', (0,0), (-1,-1), 1, colors.HexColor('#e2e8f0')), ('INNERGRID', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), ('TOPPADDING', (0,0), (-1,-1), 8), ('BOTTOMPADDING',(0,0), (-1,-1), 8), ('LEFTPADDING', (0,0), (-1,-1), 10), ('ROUNDEDCORNERS', [6, 6, 6, 6]), ])) story.append(vt) story.append(Spacer(1, 8)) # ── Key stats ── story.append(Paragraph("KEY STATISTICS (PER 90)", sec_label)) stat_rows = [ ['Goals', f"{p['gls']:.2f}", 'Assists', f"{p['ast']:.2f}", 'npxG', f"{p['xg']:.2f}"], ['xAG', f"{p['xag']:.2f}", 'Prog Passes', f"{p['prgp']:.1f}", 'Prog Carries', f"{p['prgc']:.1f}"], ['Tackles Won', f"{p['tklw']:.2f}", 'Interceptions', f"{p['intc']:.2f}", 'Yellow Cards', f"{p['crdy']:.2f}"], ] st = Table(stat_rows, colWidths=[32*mm, 20*mm, 32*mm, 20*mm, 32*mm, 34*mm]) st.setStyle(TableStyle([ ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), ('FONTNAME', (0,0), (0,-1), 'Helvetica-Bold'), ('FONTNAME', (2,0), (2,-1), 'Helvetica-Bold'), ('FONTNAME', (4,0), (4,-1), 'Helvetica-Bold'), ('FONTSIZE', (0,0), (-1,-1), 9), ('TEXTCOLOR', (0,0), (0,-1), colors.HexColor('#64748b')), ('TEXTCOLOR', (2,0), (2,-1), colors.HexColor('#64748b')), ('TEXTCOLOR', (4,0), (4,-1), colors.HexColor('#64748b')), ('ROWBACKGROUNDS', (0,0), (-1,-1), [colors.HexColor('#f8fafc'), colors.white]), ('TOPPADDING', (0,0), (-1,-1), 5), ('BOTTOMPADDING',(0,0), (-1,-1), 5), ('LEFTPADDING', (0,0), (-1,-1), 8), ('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), ('INNERGRID', (0,0), (-1,-1), 0.3, colors.HexColor('#e2e8f0')), ])) story.append(st) story.append(Spacer(1, 8)) # ── Top price drivers ── if p.get('top_drivers'): story.append(Paragraph("TOP PRICE DRIVERS (SHAP)", sec_label)) drv_rows = [[name, f"{'+' if val > 0 else ''}{fmt(val)}"] for name, val in p['top_drivers']] dt = Table(drv_rows, colWidths=[120*mm, 50*mm]) dt.setStyle(TableStyle([ ('FONTNAME', (0,0), (-1,-1), 'Helvetica'), ('FONTSIZE', (0,0), (-1,-1), 9), ('TEXTCOLOR', (0,0), (0,-1), colors.HexColor('#334155')), ('TEXTCOLOR', (1,0), (1,-1), colors.HexColor('#0f172a')), ('FONTNAME', (1,0), (1,-1), 'Helvetica-Bold'), ('ROWBACKGROUNDS', (0,0), (-1,-1), [colors.HexColor('#f8fafc'), colors.white]), ('TOPPADDING', (0,0), (-1,-1), 5), ('BOTTOMPADDING',(0,0), (-1,-1), 5), ('LEFTPADDING', (0,0), (-1,-1), 8), ('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#e2e8f0')), ('INNERGRID', (0,0), (-1,-1), 0.3, colors.HexColor('#e2e8f0')), ])) story.append(dt) story.append(Spacer(1, 8)) # ── AI Scout Report ── if scout_report_text and not scout_report_text.startswith("⚠️") and not scout_report_text.startswith("Run a"): story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'), spaceAfter=8)) story.append(Paragraph("AI SCOUT ASSESSMENT", sec_label)) # Split into paragraphs and render each for para in scout_report_text.strip().split('\n\n'): if para.strip(): story.append(Paragraph(para.strip().replace('\n', ' '), report_sty)) # ── Footer ── story.append(Spacer(1, 12)) story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#e2e8f0'))) story.append(Paragraph( "Generated by Transfer Valuation Engine · Bias-Aware XGBoost Pipeline · For internal use only", sty('footer', fontSize=7, fontName='Helvetica', textColor=colors.HexColor('#94a3b8'), alignment=TA_CENTER, spaceBefore=6) )) doc.build(story) buf.seek(0) safe_name = p['name'].replace(' ', '_').replace('/', '_') tmp = tempfile.NamedTemporaryFile( delete=False, suffix='.pdf', prefix=f"scout_{safe_name}_" ) tmp.write(buf.read()) tmp.close() return tmp.name except Exception as e: print(f"PDF export error: {e}") return None # ============================================================================== # 9. COMPARISON FUNCTION # ============================================================================== def compare_players(player_a_name, player_b_name): if not player_a_name or not player_b_name: e = "
Select two players to compare
" return e, e, None, None, None stats_a = get_player_data_tuple(player_a_name) stats_b = get_player_data_tuple(player_b_name) age_a, lea_a, nat_a, pos_a = stats_a[0], stats_a[1], stats_a[2], stats_a[3] age_b, lea_b, nat_b, pos_b = stats_b[0], stats_b[1], stats_b[2], stats_b[3] va, vba = fast_predict(player_a_name, *stats_a) vb, vbb = fast_predict(player_b_name, *stats_b) ba = generate_badges(lea_a, nat_a) bb = generate_badges(lea_b, nat_b) def card(name, badge, val, blind, age, pos, accent): gap = val - blind gap_sign = "+" if gap > 0 else "" return f"""
{name}
{pos} · Age {age}
{badge}
Market
{fmt(val)}
Performance
{fmt(blind)}
Structural Bias: {gap_sign}{fmt(gap)}
""" sum_a = card(player_a_name, ba, va, vba, age_a, pos_a, '#3b82f6') sum_b = card(player_b_name, bb, vb, vbb, age_b, pos_b, '#f97316') labels = ['Goals', 'Assists', 'npxG', 'xAG', 'Prog Passes', 'Prog Carries', 'Prog Runs', 'Tackles Won', 'Interceptions'] limits = (POS_RADAR_LIMITS.get(pos_a, _RADAR_FALLBACK) if pos_a == pos_b else POS_RADAR_LIMITS.get('Combined', _RADAR_FALLBACK)) rva = [stats_a[6], stats_a[7], stats_a[8], stats_a[9], stats_a[10], stats_a[12], stats_a[11], stats_a[13], stats_a[14]] rvb = [stats_b[6], stats_b[7], stats_b[8], stats_b[9], stats_b[10], stats_b[12], stats_b[11], stats_b[13], stats_b[14]] fig = build_comparison_radar_plotly(labels, rva, rvb, limits, player_a_name, player_b_name) metrics = ['Goals per 90', 'Assists per 90', 'npxG per 90', 'xAG per 90', 'Prog Passes', 'Prog Carries', 'Tackles Won', 'Interceptions', 'Minutes', 'Age'] va_list = [stats_a[6], stats_a[7], stats_a[8], stats_a[9], stats_a[10], stats_a[12], stats_a[13], stats_a[14], stats_a[5], age_a] vb_list = [stats_b[6], stats_b[7], stats_b[8], stats_b[9], stats_b[10], stats_b[12], stats_b[13], stats_b[14], stats_b[5], age_b] advs = [] for i, (a, b) in enumerate(zip(va_list, vb_list)): if i == len(metrics) - 1: advs.append(f"{player_a_name} (-{b-a:.0f} yrs)" if a < b else f"{player_b_name} (-{a-b:.0f} yrs)" if b < a else "Equal") else: if a > b: advs.append(f"{player_a_name} (+{(a-b)/b*100:.0f}%)" if b > 0 else player_a_name) elif b > a: advs.append(f"{player_b_name} (+{(b-a)/a*100:.0f}%)" if a > 0 else player_b_name) else: advs.append("Equal") va_rounded = [round(v, 2) for v in va_list] vb_rounded = [round(v, 2) for v in vb_list] df_cmp = pd.DataFrame({'Metric': metrics, player_a_name: va_rounded, player_b_name: vb_rounded, 'Advantage': advs}) diff = va - vb cmp_html = f"""
Value Comparison
{player_a_name}
{fmt(va)}
vs
{player_b_name}
{fmt(vb)}
Difference: {fmt(abs(diff))} {f' · {player_b_name} is cheaper' if diff > 0 else f' · {player_a_name} is cheaper' if diff < 0 else ''}
""" return sum_a, sum_b, fig, df_cmp, cmp_html # ============================================================================== # 10. FRONTEND # ============================================================================== custom_css = """ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&display=swap'); :root { --slate-50: #f8fafc; --slate-100: #f1f5f9; --slate-200: #e2e8f0; --slate-300: #cbd5e1; --slate-400: #94a3b8; --slate-500: #64748b; --slate-600: #475569; --slate-700: #334155; --slate-800: #1e293b; --slate-900: #0f172a; --emerald: #10b981; --rose: #ef4444; --blue: #3b82f6; } .gradio-container { background-color: var(--slate-50) !important; font-family: 'DM Sans', system-ui, -apple-system, sans-serif !important; max-width: 1400px !important; } h1, h2, h3 { font-family: 'DM Sans', system-ui, sans-serif !important; letter-spacing: -0.5px; } h1 { color: var(--slate-900) !important; font-weight: 800 !important; font-size: 28px !important; border: none !important; padding-bottom: 0 !important; margin-bottom: 8px !important; } .panel, .gr-panel { background: #ffffff !important; border: 1px solid var(--slate-200) !important; border-radius: 16px !important; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.02) !important; padding: 24px !important; } input, select, .wrap { border-radius: 10px !important; } .gr-input, .gr-dropdown { border: 1.5px solid var(--slate-200) !important; border-radius: 10px !important; font-family: 'DM Sans', system-ui, sans-serif !important; transition: border-color 0.2s ease !important; } .gr-input:focus, .gr-dropdown:focus { border-color: var(--blue) !important; box-shadow: 0 0 0 3px rgba(59,130,246,0.1) !important; } input[type="range"] { accent-color: var(--slate-800) !important; } .gr-slider .wrap { padding: 8px 0 !important; } .primary-btn { background: linear-gradient(135deg, var(--slate-800), var(--slate-700)) !important; color: white !important; font-weight: 700 !important; border-radius: 12px !important; padding: 14px 28px !important; font-size: 15px !important; letter-spacing: 0.3px !important; border: none !important; box-shadow: 0 2px 8px rgba(15,23,42,0.15) !important; transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; } .primary-btn:hover { box-shadow: 0 4px 16px rgba(15,23,42,0.25) !important; transform: translateY(-1px) !important; } .ai-btn { background: linear-gradient(135deg, #1e40af, #3b82f6) !important; color: white !important; font-weight: 700 !important; border-radius: 12px !important; padding: 14px 28px !important; font-size: 15px !important; letter-spacing: 0.3px !important; border: none !important; box-shadow: 0 2px 8px rgba(59,130,246,0.25) !important; transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; } .ai-btn:hover { box-shadow: 0 4px 16px rgba(59,130,246,0.4) !important; transform: translateY(-1px) !important; } .export-btn { background: linear-gradient(135deg, #065f46, #10b981) !important; color: white !important; font-weight: 700 !important; border-radius: 12px !important; padding: 14px 28px !important; font-size: 15px !important; letter-spacing: 0.3px !important; border: none !important; box-shadow: 0 2px 8px rgba(16,185,129,0.25) !important; transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; } .export-btn:hover { box-shadow: 0 4px 16px rgba(16,185,129,0.4) !important; transform: translateY(-1px) !important; } .gradio-container .tab-nav { border-bottom: 2px solid var(--slate-200) !important; gap: 4px !important; margin-bottom: 24px !important; } .gradio-container .tab-nav > button { color: var(--slate-500) !important; background: transparent !important; font-weight: 600 !important; font-size: 15px !important; padding: 12px 24px !important; border: none !important; border-bottom: 3px solid transparent !important; border-radius: 0 !important; transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; } .gradio-container .tab-nav > button:hover { color: var(--slate-700) !important; } .gradio-container .tab-nav > button.selected, .gradio-container div.tab-nav > button[aria-selected="true"] { color: var(--slate-900) !important; border-bottom: 3px solid var(--slate-900) !important; font-weight: 800 !important; background: transparent !important; } .control-accordion > button { background: var(--slate-50) !important; border: 1.5px solid var(--slate-200) !important; border-radius: 12px !important; color: var(--slate-600) !important; font-weight: 700 !important; font-size: 13px !important; text-transform: uppercase !important; letter-spacing: 0.8px !important; padding: 14px 18px !important; transition: all 0.2s ease !important; font-family: 'DM Sans', system-ui, sans-serif !important; } .control-accordion > button:hover { border-color: var(--slate-300) !important; background: white !important; } .section-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px; color: var(--slate-400); margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--slate-100); } .search-hint { font-size: 11px; color: var(--slate-400); font-style: italic; margin-top: 2px; margin-bottom: 6px; } /* Scout report text box */ .scout-report textarea { font-family: 'DM Sans', system-ui, sans-serif !important; font-size: 14px !important; line-height: 1.7 !important; color: #1e293b !important; background: #f8fafc !important; border: 1.5px solid #e2e8f0 !important; border-radius: 12px !important; padding: 16px !important; } @media (max-width: 768px) { .gradio-container { padding: 8px !important; } .panel { padding: 16px !important; border-radius: 12px !important; } h1 { font-size: 22px !important; } } .gr-plot { border-radius: 12px !important; overflow: hidden !important; } """ theme = gr.themes.Soft(primary_hue="slate", neutral_hue="slate").set( body_background_fill="#f8fafc", block_background_fill="#ffffff", block_border_color="#e2e8f0", block_radius="16px", input_background_fill="#f8fafc", input_border_color="#e2e8f0", input_border_color_hover="#cbd5e1", input_border_color_focus="#3b82f6", input_radius="10px", slider_color="#1e293b" ) with gr.Blocks(title="Transfer Valuation Audit", css=custom_css, theme=theme) as app: gr.HTML("""
Transfer Valuation Engine
Audited XGBoost Pipeline · Bias-Aware Dual-Model Architecture
""") with gr.Tabs(): # ===================================================================== # TAB 1: PLAYER ANALYSIS # ===================================================================== with gr.Tab("Player Analysis"): with gr.Row(equal_height=False): with gr.Column(scale=1, min_width=320): with gr.Column(variant="panel"): player_count_text = f" ({player_count:,} players)" if player_count > 0 else "" player_search_box = gr.Textbox( placeholder="Type a name to search…", label=f"Search Database{player_count_text}", value="" ) gr.HTML("
Type to filter, then select below
") player_search = gr.Dropdown(choices=_init_tab1, value=default_player, label="Select Player", show_label=False) with gr.Column(variant="panel"): gr.HTML("
Player Profile
") league = gr.Dropdown(choices=sorted(list(LEAGUE_MAP.keys())), value="Premier League", label="League") nation = gr.Dropdown(choices=all_nations, value="England", label="Nationality") with gr.Row(): age = gr.Slider(16, 40, value=24, label="Age", step=1) pos = gr.Dropdown(choices=["Forwards","Midfielders","Defenders","Goalkeepers"], value="Forwards", label="Position") role = gr.Dropdown(choices=ROLE_CHOICES, value="Complete Forward", label="Tactical Role") with gr.Accordion("Stat Override", open=False, elem_classes=["control-accordion"]) as advanced_accordion: with gr.Column(variant="panel"): gr.HTML("
Attacking Output (per 90)
") with gr.Row(): gls = gr.Slider(0, 2.0, value=0.4, label="Goals", info="Elite: 0.7+") ast = gr.Slider(0, 2.0, value=0.2, label="Assists", info="Elite: 0.4+") with gr.Row(): xg = gr.Slider(0, 2.0, value=0.4, label="npxG", info="Elite: 0.6+") xag = gr.Slider(0, 1.0, value=0.2, label="xAG", info="Elite: 0.3+") gr.HTML("
Progression (per 90)
") with gr.Row(): prgp = gr.Slider(0, 15, value=4.0, label="Prog Passes", info="Elite: 8+") prgr = gr.Slider(0, 15, value=5.0, label="Prog Runs", info="Elite: 6+") prgc = gr.Slider(0, 15, value=3.0, label="Prog Carries", info="Elite: 5+") gr.HTML("
Defensive & Availability
") with gr.Row(): tklw = gr.Slider(0, 10.0, value=1.5, label="Tackles Won", info="Elite: 3.5+") intc = gr.Slider(0, 10.0, value=1.0, label="Interceptions", info="Elite: 2.0+") with gr.Row(): mins = gr.Slider(0, 3420, value=2000, label="Minutes", info="Full season: 2500+") crdy = gr.Slider(0, 2.0, value=0.1, label="Yellow Cards", info="Per 90") predict_btn = gr.Button("Recalculate Valuation", elem_classes=["primary-btn"]) # ── Right: Results ── with gr.Column(scale=2, min_width=400): with gr.Column(variant="panel"): with gr.Row(): with gr.Column(scale=2): out_name = gr.HTML("
Player Report
Awaiting Selection
") with gr.Column(scale=1): out_badges = gr.HTML("") out_val = gr.HTML("") with gr.Column(variant="panel"): gr.HTML("
Performance Radar
") out_radar = gr.Plot() with gr.Column(variant="panel"): gr.HTML("""
Price Driver Analysis
Increases value Decreases value · Brackets show actual stat
""") out_plot = gr.Plot() # ── AI Scout Report panel ── with gr.Column(variant="panel"): gr.HTML("""
AI Scout Assessment
Powered by Google Gemini · Synthesises the model outputs into a scout-ready narrative
""") with gr.Row(): scout_btn = gr.Button("✦ Generate Scout Report", elem_classes=["ai-btn"], scale=2) export_btn = gr.Button("⬇ Export PDF Report", elem_classes=["export-btn"], scale=1) out_report = gr.Textbox( label="", lines=9, interactive=False, placeholder="Click 'Generate Scout Report' after running a valuation…", elem_classes=["scout-report"] ) out_pdf = gr.File(label="Download PDF", visible=False) # ===================================================================== # TAB 2: COMPARISON # ===================================================================== with gr.Tab("Compare Players"): with gr.Row(): with gr.Column(): player_a_search_box = gr.Textbox(placeholder="Search Player A…", label="Player A", value="") gr.HTML("
Type to filter, then select below
") player_a_dropdown = gr.Dropdown(choices=_init_tab2, value=default_player, label="Player A", show_label=False) with gr.Column(): player_b_search_box = gr.Textbox(placeholder="Search Player B…", label="Player B", value="") gr.HTML("
Type to filter, then select below
") player_b_dropdown = gr.Dropdown(choices=_init_tab2, value=default_player_b, label="Player B", show_label=False) compare_btn = gr.Button("Compare Players", elem_classes=["primary-btn"], size="lg") with gr.Row(): with gr.Column(): comp_summary_a = gr.HTML() with gr.Column(): comp_summary_b = gr.HTML() with gr.Column(variant="panel"): gr.HTML("
Statistical Overlap
") comp_radar = gr.Plot() with gr.Column(variant="panel"): gr.HTML("
Stat Breakdown
") comp_table = gr.Dataframe() comp_value = gr.HTML() # ========================================================================= # EVENT LISTENERS # ========================================================================= player_search_box.input(lambda q: gr.Dropdown(choices=filter_players_tab1(q)), inputs=[player_search_box], outputs=[player_search]) player_a_search_box.input(lambda q: gr.Dropdown(choices=filter_players_tab2(q)), inputs=[player_a_search_box], outputs=[player_a_dropdown]) player_b_search_box.input(lambda q: gr.Dropdown(choices=filter_players_tab2(q)), inputs=[player_b_search_box], outputs=[player_b_dropdown]) core_inputs = [player_search, age, league, nation, pos, role, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy] core_outputs = [out_name, out_val, out_plot, out_badges, out_radar] data_outputs = [age, league, nation, pos, role, mins, gls, ast, xg, xag, prgp, prgr, prgc, tklw, intc, crdy, advanced_accordion] app.load(master_load_and_predict, inputs=[player_search], outputs=data_outputs + core_outputs) player_search.change(master_load_and_predict, inputs=[player_search], outputs=data_outputs + core_outputs) predict_btn.click(predict_valuation, inputs=core_inputs, outputs=core_outputs) # AI Scout Report button scout_btn.click( fn=generate_scout_report, inputs=[], outputs=[out_report] ) # PDF Export button — uses whatever text is currently in the report box def handle_export(report_text): path = export_pdf(report_text) if path: return gr.File(value=path, visible=True) return gr.File(visible=False) export_btn.click( fn=handle_export, inputs=[out_report], outputs=[out_pdf] ) app.load(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) player_a_dropdown.change(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) player_b_dropdown.change(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) compare_btn.click(compare_players, inputs=[player_a_dropdown, player_b_dropdown], outputs=[comp_summary_a, comp_summary_b, comp_radar, comp_table, comp_value]) if __name__ == "__main__": app.launch(allowed_paths=["assets"])