Spaces:
Sleeping
Sleeping
| """ | |
| Scouting Engine β Reusable Player Replacement / Upgrade Search | |
| Pure engine module: takes inputs, returns ranked candidates. No I/O beyond returns. | |
| Consumed by both the CLI (scout.py) and the future web app. | |
| Public API: | |
| search_replacements(...) β ranked list of candidate dicts | |
| list_available_players(...) β autocomplete-style player search | |
| list_available_upgrades() β upgrade catalog with descriptions | |
| list_available_filters() β filter spec | |
| get_player_summary(...) β quick info card for a player | |
| Design notes (V3 β fixed inference): | |
| - For pure similarity (no upgrades): use the STORED player vector directly. | |
| No re-inference needed. Fast and exact. | |
| - For modifications/upgrades: use Magdaci's per-match approach. Each match is | |
| modified individually, inferred 10 times to smooth Doc2Vec noise, then | |
| vectors are averaged. Tokens stay in their natural per-match documents. | |
| - Multi-source: pool match-level vectors (NOT tokens) across source players. | |
| - Default upgrade intensity is 0.5 β kept moderate to avoid the | |
| offball-saturation bias that surfaces defenders at high values. | |
| - All randomness uses a per-call seed for reproducibility (web app needs this). | |
| Why we changed: The previous implementation pooled all of a player's tokens into | |
| one shuffled, truncated synthetic doc, then ran ONE inference on it. This produced | |
| vectors that didn't live in the same region of vector space as the stored ones, | |
| causing SuΓ‘rez/Benzema searches to return Center Backs. The new approach matches | |
| how Magdaci's reference implementation works. | |
| """ | |
| import json | |
| import random | |
| from collections import defaultdict | |
| from typing import List, Dict, Optional | |
| import numpy as np | |
| from gensim.models.doc2vec import Doc2Vec | |
| from .modify_doc import ( | |
| Intervention, | |
| add_cut_inside, | |
| upgrade_finishing, | |
| add_progression, | |
| add_chance_creation, | |
| enrich_dribbling, | |
| boost_pressing, | |
| aerial_dominance, | |
| ball_winning, | |
| modify_doc, | |
| ) | |
| from src.gp2.paths import ( | |
| DOCS_PATH, | |
| OFFBALL_MODEL_PATH, | |
| ONBALL_MODEL_PATH, | |
| PLAYER2VEC_PATH, | |
| PLAYER_METADATA_PATH, | |
| ) | |
| # ================================================================================================== | |
| # PATHS | |
| # ================================================================================================== | |
| # ================================================================================================== | |
| # CONFIGURATION | |
| # ================================================================================================== | |
| DEFAULT_UPGRADE_INTENSITY = 0.5 | |
| ONBALL_DIM = 48 | |
| OFFBALL_DIM = 16 | |
| # Inference parameters (per Magdaci's approach) | |
| # - INFERENCE_STEPS: epochs per single inference call. More = more accurate but slower. | |
| # - INFERENCE_REPS: how many times to re-infer each doc and average. Smooths Doc2Vec | |
| # stochasticity. Magdaci uses 10. Lower = faster, higher = more stable. | |
| INFERENCE_STEPS = 10 | |
| INFERENCE_REPS = 10 | |
| # Optional cap on matches per source to limit search latency. | |
| # 0 or None = use all matches the player has. | |
| MAX_MATCHES_PER_SOURCE = 25 | |
| # ================================================================================================== | |
| # UPGRADE CATALOG β what users can choose from | |
| # ================================================================================================== | |
| UPGRADE_CATALOG = { | |
| "finishing": { | |
| "label": "More Clinical Finishing", | |
| "description": "Converts more shot attempts into goals. Use when you want a more clinical striker.", | |
| "applies_to": "onball", | |
| "builder": upgrade_finishing, | |
| }, | |
| "cut_inside": { | |
| "label": "Inverted Winger / Cut Inside", | |
| "description": "Adds cut-inside behavior from wide areas. Use for inverted wingers like ManΓ© or Salah.", | |
| "applies_to": "onball", | |
| "builder": add_cut_inside, | |
| }, | |
| "progression": { | |
| "label": "Direct Ball Progression", | |
| "description": "More progressive carries and forward passes. Use when you want a more direct, vertical player.", | |
| "applies_to": "onball", | |
| "builder": add_progression, | |
| }, | |
| "chance_creation": { | |
| "label": "Chance Creation", | |
| "description": "More key passes and final-third deliveries. Use when you need a creator.", | |
| "applies_to": "onball", | |
| "builder": add_chance_creation, | |
| }, | |
| "dribbling": { | |
| "label": "Better Dribbling", | |
| "description": "More successful dribbles, especially in the box. Use for 1v1 specialists.", | |
| "applies_to": "onball", | |
| "builder": enrich_dribbling, | |
| }, | |
| "pressing": { | |
| "label": "Counterpressing Intensity", | |
| "description": "Converts regular pressure into counterpress (Klopp-style gegenpressing).", | |
| "applies_to": "offball", | |
| "builder": boost_pressing, | |
| }, | |
| "aerial_dominance": { | |
| "label": "Aerial Dominance", | |
| "description": "Wins more aerial duels. Use for target strikers, headed-goal threats, or aerially strong center backs.", | |
| "applies_to": "offball", | |
| "builder": aerial_dominance, | |
| }, | |
| "ball_winning": { | |
| "label": "Ball-Winning", | |
| "description": "Wins more ground duels, interceptions, and recoveries. Use for defensive midfielders or pressing midfielders (KantΓ©/Casemiro types).", | |
| "applies_to": "offball", | |
| "builder": ball_winning, | |
| }, | |
| } | |
| # ================================================================================================== | |
| # FILTER CATALOG β supported filters in v1 | |
| # ================================================================================================== | |
| def list_available_filters() -> Dict: | |
| """Returns supported filter spec for UI building.""" | |
| return { | |
| "positions": { | |
| "type": "multi_select", | |
| "description": "Filter by primary position. If empty, all positions allowed.", | |
| "field": "primary_position", | |
| }, | |
| "exclude_teams": { | |
| "type": "multi_select", | |
| "description": "Exclude players from these teams (e.g., your own).", | |
| "field": "team", | |
| }, | |
| "min_versatility": { | |
| "type": "float", | |
| "description": "Minimum versatility score (Shannon entropy across positions).", | |
| "default": 0.0, | |
| }, | |
| "max_versatility": { | |
| "type": "float", | |
| "description": "Maximum versatility score.", | |
| "default": None, | |
| }, | |
| "min_matches": { | |
| "type": "int", | |
| "description": "Minimum number of matches in the dataset.", | |
| "default": 5, | |
| }, | |
| } | |
| # ================================================================================================== | |
| # DATA LOADING β module-level cache | |
| # ================================================================================================== | |
| _cache = { | |
| "player_ids": None, | |
| "vectors": None, | |
| "metadata": None, | |
| "name_to_id": None, | |
| "onball_model": None, | |
| "offball_model": None, | |
| } | |
| def _ensure_loaded(): | |
| """Idempotent: loads everything once, caches in module state.""" | |
| if _cache["player_ids"] is not None: | |
| return | |
| data = np.load(PLAYER2VEC_PATH) | |
| _cache["player_ids"] = list(data["player_ids"]) | |
| _cache["vectors"] = data["vectors"] | |
| with open(PLAYER_METADATA_PATH, "r", encoding="utf-8") as f: | |
| _cache["metadata"] = json.load(f) | |
| # Lowercase name-to-id index for fast lookup | |
| name_to_id = {} | |
| for pid in _cache["player_ids"]: | |
| name = _cache["metadata"].get(pid, {}).get("name", "") | |
| if name: | |
| name_to_id[name.lower()] = pid | |
| _cache["name_to_id"] = name_to_id | |
| _cache["onball_model"] = Doc2Vec.load(str(ONBALL_MODEL_PATH)) | |
| _cache["offball_model"] = Doc2Vec.load(str(OFFBALL_MODEL_PATH)) | |
| # ================================================================================================== | |
| # PLAYER LOOKUP | |
| # ================================================================================================== | |
| def list_available_players(query: str = "", limit: int = 20) -> List[Dict]: | |
| """ | |
| Autocomplete-style player search. Returns matching players with their IDs. | |
| Empty query returns first `limit` players alphabetically. | |
| """ | |
| _ensure_loaded() | |
| metadata = _cache["metadata"] | |
| player_ids = _cache["player_ids"] | |
| query_lower = query.lower().strip() | |
| matches = [] | |
| for pid in player_ids: | |
| meta = metadata.get(pid, {}) | |
| name = meta.get("name", "") | |
| if not query_lower or query_lower in name.lower(): | |
| matches.append({ | |
| "player_id": pid, | |
| "name": name, | |
| "primary_position": meta.get("primary_position", "Unknown"), | |
| "team": meta.get("team", "Unknown"), | |
| "versatility_score": meta.get("versatility_score", 0.0), | |
| "num_matches": meta.get("num_matches", 0), | |
| }) | |
| matches.sort(key=lambda m: m["name"]) | |
| return matches[:limit] | |
| def find_player_id(query: str) -> Optional[str]: | |
| """ | |
| Resolve a single player by name query. Prefers exact match. | |
| Returns None if not found, returns first match if multiple. | |
| """ | |
| _ensure_loaded() | |
| name_to_id = _cache["name_to_id"] | |
| query_lower = query.lower().strip() | |
| if query_lower in name_to_id: | |
| return name_to_id[query_lower] | |
| # Partial match | |
| for name, pid in name_to_id.items(): | |
| if query_lower in name: | |
| return pid | |
| return None | |
| def get_player_summary(player_id_or_name: str) -> Optional[Dict]: | |
| """Quick info card for a player. Accepts ID or name.""" | |
| _ensure_loaded() | |
| metadata = _cache["metadata"] | |
| pid = player_id_or_name if player_id_or_name in metadata else find_player_id(player_id_or_name) | |
| if pid is None or pid not in metadata: | |
| return None | |
| meta = metadata[pid] | |
| return { | |
| "player_id": pid, | |
| "name": meta.get("name"), | |
| "primary_position": meta.get("primary_position"), | |
| "team": meta.get("team"), | |
| "versatility_score": meta.get("versatility_score", 0.0), | |
| "num_distinct_positions": meta.get("num_distinct_positions", 1), | |
| "num_matches": meta.get("num_matches", 0), | |
| "position_distribution": meta.get("position_distribution", {}), | |
| } | |
| # ================================================================================================== | |
| # UPGRADE CATALOG ACCESS | |
| # ================================================================================================== | |
| def list_available_upgrades() -> List[Dict]: | |
| """Returns the upgrade catalog, formatted for UI display.""" | |
| return [ | |
| { | |
| "key": key, | |
| "label": spec["label"], | |
| "description": spec["description"], | |
| "applies_to": spec["applies_to"], | |
| } | |
| for key, spec in UPGRADE_CATALOG.items() | |
| ] | |
| def _build_interventions(upgrades, intensity: float): | |
| """ | |
| Build the actual intervention objects from upgrade specifications. | |
| Args: | |
| upgrades: Either a list of upgrade keys (using uniform `intensity`), | |
| or a dict mapping keys to per-upgrade probabilities. | |
| intensity: Uniform probability used when `upgrades` is a list. | |
| Ignored when `upgrades` is a dict. | |
| Returns: | |
| (onball_interventions, offball_interventions) | |
| """ | |
| onball = [] | |
| offball = [] | |
| if isinstance(upgrades, dict): | |
| upgrade_specs = upgrades.items() # (key, probability) pairs | |
| else: | |
| upgrade_specs = [(key, intensity) for key in upgrades] | |
| for key, prob in upgrade_specs: | |
| if key not in UPGRADE_CATALOG: | |
| raise ValueError(f"Unknown upgrade: {key}. Available: {list(UPGRADE_CATALOG.keys())}") | |
| spec = UPGRADE_CATALOG[key] | |
| built = spec["builder"](probability=prob) | |
| if spec["applies_to"] == "onball": | |
| onball.extend(built) | |
| else: | |
| offball.extend(built) | |
| return onball, offball | |
| # ================================================================================================== | |
| # DOCUMENT LOADING & MODIFICATION | |
| # ================================================================================================== | |
| def _load_player_docs(player_id: str) -> List[dict]: | |
| """Stream the corpus and pick out one player's match documents.""" | |
| docs = [] | |
| with open(DOCS_PATH, "r", encoding="utf-8") as f: | |
| for line in f: | |
| d = json.loads(line.strip()) | |
| if d["player_id"] == player_id: | |
| docs.append(d) | |
| return docs | |
| def _sample_player_matches( | |
| player_id: str, | |
| rng: random.Random, | |
| max_matches: int = MAX_MATCHES_PER_SOURCE, | |
| ) -> List[dict]: | |
| """ | |
| Load all of a player's match docs, optionally cap to max_matches via random sampling. | |
| Each doc keeps its natural per-match structure β we do NOT pool tokens. | |
| """ | |
| docs = _load_player_docs(player_id) | |
| if not docs: | |
| return [] | |
| if max_matches and len(docs) > max_matches: | |
| docs = rng.sample(docs, max_matches) | |
| return docs | |
| # ================================================================================================== | |
| # VECTOR INFERENCE β Magdaci-style per-match approach | |
| # ================================================================================================== | |
| # Key principle: each match document stays intact. We infer one vector PER MATCH, with multiple | |
| # repetitions to smooth Doc2Vec inference noise, then average across matches. | |
| # We never pool tokens across matches into one synthetic super-doc. | |
| # ================================================================================================== | |
| def _estimate_match_vector( | |
| onball_tokens: List[str], | |
| offball_tokens: List[str], | |
| reps: int = INFERENCE_REPS, | |
| steps: int = INFERENCE_STEPS, | |
| ) -> np.ndarray: | |
| """ | |
| Infer the 64D vector for ONE match document. | |
| Runs `reps` inferences and averages to reduce Doc2Vec stochastic noise. | |
| Returns an unnormalized 64D vector. | |
| """ | |
| onball_model = _cache["onball_model"] | |
| offball_model = _cache["offball_model"] | |
| on_reps = [] | |
| off_reps = [] | |
| if onball_tokens: | |
| for _ in range(reps): | |
| on_reps.append(onball_model.infer_vector(onball_tokens, epochs=steps)) | |
| if offball_tokens: | |
| for _ in range(reps): | |
| off_reps.append(offball_model.infer_vector(offball_tokens, epochs=steps)) | |
| mean_on = np.mean(on_reps, axis=0) if on_reps else np.zeros(ONBALL_DIM) | |
| mean_off = np.mean(off_reps, axis=0) if off_reps else np.zeros(OFFBALL_DIM) | |
| return np.concatenate([mean_on, mean_off]) | |
| def _player_vector_via_per_match_inference( | |
| player_id: str, | |
| rng: random.Random, | |
| onball_interventions: list = None, | |
| offball_interventions: list = None, | |
| max_matches: int = MAX_MATCHES_PER_SOURCE, | |
| ) -> Optional[np.ndarray]: | |
| """ | |
| Build a player's 64D vector by re-inferring each of their matches, then averaging. | |
| If interventions are provided, each match's tokens are modified BEFORE inference. | |
| Each match keeps its natural document structure (no token pooling, no shuffling). | |
| Returns the averaged 64D vector (unnormalized), or None if no usable matches. | |
| """ | |
| docs = _sample_player_matches(player_id, rng, max_matches) | |
| if not docs: | |
| return None | |
| match_vectors = [] | |
| for doc in docs: | |
| on_tokens = list(doc.get("onball_tokens") or []) | |
| off_tokens = list(doc.get("offball_tokens") or []) | |
| # Apply interventions per-match if requested (Magdaci-style) | |
| if onball_interventions and on_tokens: | |
| on_tokens = modify_doc(on_tokens, onball_interventions) | |
| if offball_interventions and off_tokens: | |
| off_tokens = modify_doc(off_tokens, offball_interventions) | |
| if not on_tokens: | |
| continue # skip docs with no onball signal at all | |
| match_vec = _estimate_match_vector(on_tokens, off_tokens) | |
| match_vectors.append(match_vec) | |
| if not match_vectors: | |
| return None | |
| return np.mean(match_vectors, axis=0) | |
| # ================================================================================================== | |
| # FILTERING | |
| # ================================================================================================== | |
| def _passes_filters(meta: dict, filters: Dict) -> bool: | |
| """Check if a player's metadata passes the user-specified filters.""" | |
| if not filters: | |
| return True | |
| if "positions" in filters and filters["positions"]: | |
| if meta.get("primary_position") not in filters["positions"]: | |
| return False | |
| if "exclude_teams" in filters and filters["exclude_teams"]: | |
| if meta.get("team") in filters["exclude_teams"]: | |
| return False | |
| if "min_versatility" in filters and filters["min_versatility"] is not None: | |
| if meta.get("versatility_score", 0.0) < filters["min_versatility"]: | |
| return False | |
| if "max_versatility" in filters and filters["max_versatility"] is not None: | |
| if meta.get("versatility_score", 0.0) > filters["max_versatility"]: | |
| return False | |
| if "min_matches" in filters and filters["min_matches"] is not None: | |
| if meta.get("num_matches", 0) < filters["min_matches"]: | |
| return False | |
| return True | |
| # ================================================================================================== | |
| # THE MAIN SEARCH FUNCTION | |
| # ================================================================================================== | |
| def search_replacements( | |
| sources: List[str], | |
| upgrades=None, | |
| upgrade_intensity: float = DEFAULT_UPGRADE_INTENSITY, | |
| top_k: int = 30, | |
| filters: Dict = None, | |
| exclude_sources: bool = True, | |
| seed: int = 42, | |
| ) -> Dict: | |
| """ | |
| Search for replacement / upgrade candidates. | |
| Args: | |
| sources: One or more player names or IDs to use as the search base. | |
| 1 source = single-player upgrade. 2+ = multi-source pooling. | |
| upgrades: Either: | |
| - List[str] of upgrade keys (uniform intensity), e.g. | |
| ["cut_inside", "finishing"] | |
| - Dict[str, float] mapping keys to per-upgrade probabilities, | |
| e.g. {"cut_inside": 0.7, "finishing": 0.5} | |
| - None or empty list = no modification. | |
| upgrade_intensity: Uniform probability when `upgrades` is a list. Default 0.5. | |
| Ignored when `upgrades` is a dict. | |
| top_k: Number of candidates to return. | |
| filters: Optional dict of filters (see list_available_filters()). | |
| exclude_sources: If True, exclude the source players from results. | |
| seed: RNG seed for reproducibility. | |
| Returns: | |
| { | |
| "query": { | |
| "sources": [...resolved player info...], | |
| "upgrades": [...] or {...}, | |
| "upgrade_intensity": 0.5, | |
| "filters": {...}, | |
| }, | |
| "candidates": [ | |
| {"rank": 1, "player_id": ..., "name": ..., "primary_position": ..., | |
| "team": ..., "similarity": ..., "versatility_score": ...}, | |
| ... | |
| ], | |
| "warnings": [...] // any non-fatal issues | |
| } | |
| """ | |
| _ensure_loaded() | |
| rng = random.Random(seed) | |
| if not sources: | |
| raise ValueError("At least one source player is required.") | |
| # Normalize upgrades input | |
| if upgrades is None: | |
| upgrades = [] | |
| filters = filters or {} | |
| warnings = [] | |
| # ---------- Resolve source players ---------- | |
| resolved_sources = [] | |
| source_pids = [] | |
| for src in sources: | |
| pid = src if src in _cache["metadata"] else find_player_id(src) | |
| if pid is None: | |
| warnings.append(f"Source not found: '{src}' β skipping.") | |
| continue | |
| resolved_sources.append(get_player_summary(pid)) | |
| source_pids.append(pid) | |
| if not resolved_sources: | |
| raise ValueError("No valid source players resolved. Aborting.") | |
| # ---------- Build interventions (if any) ---------- | |
| has_upgrades = bool(upgrades) and (len(upgrades) > 0 if isinstance(upgrades, (list, dict)) else False) | |
| if has_upgrades: | |
| onball_interventions, offball_interventions = _build_interventions(upgrades, upgrade_intensity) | |
| else: | |
| onball_interventions, offball_interventions = [], [] | |
| # ---------- Build target vector ---------- | |
| # Two paths: | |
| # (1) No upgrades β use the STORED player vector(s) directly. Skip inference entirely. | |
| # Stored vectors are the result of training; using them is exact and fast. | |
| # (2) With upgrades β use Magdaci's per-match approach. Each source player's matches | |
| # are modified individually, inferred with multiple repetitions, then averaged. | |
| vectors = _cache["vectors"] | |
| player_ids = _cache["player_ids"] | |
| metadata = _cache["metadata"] | |
| if not has_upgrades: | |
| # FAST PATH β use stored vectors. Average across multiple sources if given. | |
| per_source_vectors = [] | |
| for pid in source_pids: | |
| if pid not in player_ids: | |
| warnings.append(f"Source {pid} not in stored player2vec β skipping.") | |
| continue | |
| per_source_vectors.append(vectors[player_ids.index(pid)]) | |
| if not per_source_vectors: | |
| raise ValueError("None of the sources have stored vectors.") | |
| target_vec = np.mean(per_source_vectors, axis=0) | |
| else: | |
| # MODIFICATION PATH β Magdaci-style per-match inference per source, then average. | |
| per_source_vectors = [] | |
| for pid in source_pids: | |
| v = _player_vector_via_per_match_inference( | |
| pid, rng, | |
| onball_interventions=onball_interventions, | |
| offball_interventions=offball_interventions, | |
| max_matches=MAX_MATCHES_PER_SOURCE, | |
| ) | |
| if v is None: | |
| warnings.append(f"No usable docs for source: {pid}") | |
| continue | |
| per_source_vectors.append(v) | |
| if not per_source_vectors: | |
| raise ValueError("None of the sources had usable documents.") | |
| target_vec = np.mean(per_source_vectors, axis=0) | |
| # Normalize target vector (stored vectors are unit-length, so this matches) | |
| target_vec = target_vec / np.clip(np.linalg.norm(target_vec), 1e-8, None) | |
| # ---------- Search ---------- | |
| sims = vectors @ target_vec | |
| # Exclude sources from results | |
| if exclude_sources: | |
| for pid in source_pids: | |
| if pid in player_ids: | |
| sims[player_ids.index(pid)] = -np.inf | |
| # Apply filters by setting non-passing players to -inf | |
| if filters: | |
| for i, pid in enumerate(player_ids): | |
| meta = metadata.get(pid, {}) | |
| if not _passes_filters(meta, filters): | |
| sims[i] = -np.inf | |
| # Rank and assemble candidates | |
| ranked_indices = np.argsort(sims)[::-1] | |
| candidates = [] | |
| for rank, idx in enumerate(ranked_indices[:top_k], 1): | |
| if not np.isfinite(sims[idx]): | |
| break # all remaining are filtered out | |
| pid = player_ids[idx] | |
| meta = metadata[pid] | |
| candidates.append({ | |
| "rank": rank, | |
| "player_id": pid, | |
| "name": meta.get("name"), | |
| "primary_position": meta.get("primary_position"), | |
| "team": meta.get("team"), | |
| "similarity": float(sims[idx]), | |
| "versatility_score": meta.get("versatility_score", 0.0), | |
| "num_matches": meta.get("num_matches", 0), | |
| }) | |
| return { | |
| "query": { | |
| "sources": resolved_sources, | |
| "upgrades": upgrades, | |
| "upgrade_intensity": upgrade_intensity, | |
| "filters": filters, | |
| "seed": seed, | |
| }, | |
| "candidates": candidates, | |
| "warnings": warnings, | |
| } | |