Spaces:
Sleeping
Sleeping
| """ | |
| Build 64D Player2Vec from Asymmetric On-Ball + Off-Ball Vectors — V2 | |
| V2 Architecture: | |
| - On-ball vectors: 48D (rich on-ball signal) | |
| - Off-ball vectors: 16D (sparser off-ball signal) | |
| - Concatenated: 64D | |
| - GKs zero-padded with 16D zeros for off-ball component | |
| Pipeline: | |
| 1. Load both Doc2Vec models (onball 48D + offball 16D) | |
| 2. For each player: | |
| - Average match-level on-ball vectors → 48D player on-ball | |
| - Average match-level off-ball vectors → 16D player off-ball | |
| - Concatenate → 64D raw player vector | |
| 3. Aggregate position metadata (primary position, distribution, versatility) | |
| 4. Remove dominant direction (PCA, n_components=1) | |
| 5. Normalize → final 64D Player2Vec | |
| Outputs: | |
| - models/gp2/player2vec_64d.npz (player_ids + 64D vectors) | |
| - models/gp2/player_metadata_v2.json (name, team, position info, versatility) | |
| """ | |
| import json | |
| import math | |
| from collections import Counter, defaultdict | |
| from pathlib import Path | |
| import numpy as np | |
| from gensim.models.doc2vec import Doc2Vec | |
| from src.gp2.paths import ( | |
| OFFBALL_MODEL_PATH, | |
| ONBALL_MODEL_PATH, | |
| PLAYER2VEC_PATH, | |
| PLAYER_INFO_PATH, | |
| PLAYER_MATCH_DOCS_PATH, | |
| PLAYER_METADATA_PATH, | |
| ) | |
| # ================================================================================================== | |
| # DIMENSIONS | |
| # ================================================================================================== | |
| ONBALL_DIM = 48 | |
| OFFBALL_DIM = 16 | |
| TOTAL_DIM = ONBALL_DIM + OFFBALL_DIM # 64 | |
| MIN_MATCHES = 5 # minimum matches required to include player | |
| # ================================================================================================== | |
| # DOMINANT VECTOR REMOVAL (unchanged from V1 — proven technique) | |
| # ================================================================================================== | |
| def remove_dominant_direction(vectors, n_components=1): | |
| """ | |
| Remove top-N principal components (Mu et al. 2018, "All-but-the-Top"). | |
| Critical for spreading similarity scores from compressed range. | |
| """ | |
| mean_vec = vectors.mean(axis=0, keepdims=True) | |
| centered = vectors - mean_vec | |
| _, _, Vt = np.linalg.svd(centered, full_matrices=False) | |
| top_components = Vt[:n_components] | |
| for component in top_components: | |
| projection = (centered @ component).reshape(-1, 1) * component.reshape(1, -1) | |
| centered = centered - projection | |
| norms = np.linalg.norm(centered, axis=1, keepdims=True) | |
| norms = np.clip(norms, 1e-8, None) | |
| normalized = centered / norms | |
| return normalized | |
| # ================================================================================================== | |
| # POSITION METADATA AGGREGATION | |
| # ================================================================================================== | |
| def aggregate_position_metadata(docs_path): | |
| """ | |
| Read corpus and aggregate per-player position info across all matches. | |
| Returns dict: player_id → { | |
| primary_position: str, | |
| position_distribution: {position: total_event_count}, | |
| versatility_score: float (Shannon entropy), | |
| num_matches: int, | |
| num_distinct_positions: int # positions with >=15 events total | |
| } | |
| """ | |
| player_positions = defaultdict(Counter) # player_id → {position: total events} | |
| player_match_count = Counter() # player_id → number of matches | |
| with open(docs_path, "r", encoding="utf-8") as f: | |
| for line in f: | |
| doc = json.loads(line.strip()) | |
| pid = doc["player_id"] | |
| player_match_count[pid] += 1 | |
| # Sum event counts across matches | |
| for pos, count in doc.get("position_distribution", {}).items(): | |
| player_positions[pid][pos] += count | |
| # Compute aggregate metadata per player | |
| metadata = {} | |
| for pid, position_counts in player_positions.items(): | |
| total_events = sum(position_counts.values()) | |
| if total_events == 0: | |
| continue | |
| # Primary position = most events | |
| primary_position = position_counts.most_common(1)[0][0] | |
| # Versatility via Shannon entropy | |
| # 0 = single position, higher = more spread across positions | |
| entropy = 0.0 | |
| for count in position_counts.values(): | |
| p = count / total_events | |
| if p > 0: | |
| entropy -= p * math.log(p) | |
| # Distinct positions (with at least 15 events total — meaningful playtime) | |
| distinct = sum(1 for count in position_counts.values() if count >= 15) | |
| metadata[pid] = { | |
| "primary_position": primary_position, | |
| "position_distribution": dict(position_counts), | |
| "versatility_score": round(entropy, 4), | |
| "num_distinct_positions": distinct, | |
| "num_matches": player_match_count[pid], | |
| } | |
| return metadata | |
| # ================================================================================================== | |
| # BUILD 64D PLAYER2VEC | |
| # ================================================================================================== | |
| def build_player2vec(min_matches=MIN_MATCHES, n_components=1): | |
| """Aggregate match vectors into player vectors and clean.""" | |
| # ---------------- LOAD MODELS ---------------- | |
| print("Loading Doc2Vec models...") | |
| onball_model = Doc2Vec.load(str(ONBALL_MODEL_PATH)) | |
| offball_model = Doc2Vec.load(str(OFFBALL_MODEL_PATH)) | |
| # Sanity check dimensions | |
| actual_onball_dim = onball_model.dv[onball_model.dv.index_to_key[0]].shape[0] | |
| actual_offball_dim = offball_model.dv[offball_model.dv.index_to_key[0]].shape[0] | |
| print(f" On-ball model: {len(onball_model.dv):,} doc vectors, dim={actual_onball_dim}") | |
| print(f" Off-ball model: {len(offball_model.dv):,} doc vectors, dim={actual_offball_dim}") | |
| if actual_onball_dim != ONBALL_DIM or actual_offball_dim != OFFBALL_DIM: | |
| raise ValueError( | |
| f"Dimension mismatch! Expected {ONBALL_DIM}+{OFFBALL_DIM}, " | |
| f"got {actual_onball_dim}+{actual_offball_dim}. " | |
| f"Did train_playermatch2vec.py run with the right dimensions?" | |
| ) | |
| # ---------------- GROUP MATCH VECTORS BY PLAYER ---------------- | |
| print("\nGrouping match vectors by player...") | |
| player_onball = defaultdict(list) | |
| player_offball = defaultdict(list) | |
| for doc_tag in onball_model.dv.index_to_key: | |
| player_id = doc_tag.split("_")[0] | |
| player_onball[player_id].append(onball_model.dv[doc_tag]) | |
| for doc_tag in offball_model.dv.index_to_key: | |
| player_id = doc_tag.split("_")[0] | |
| player_offball[player_id].append(offball_model.dv[doc_tag]) | |
| print(f" Players with on-ball data: {len(player_onball):,}") | |
| print(f" Players with off-ball data: {len(player_offball):,}") | |
| # ---------------- LOAD POSITION METADATA ---------------- | |
| print("\nAggregating position metadata across matches...") | |
| position_metadata = aggregate_position_metadata(PLAYER_MATCH_DOCS_PATH) | |
| print(f" Players with position metadata: {len(position_metadata):,}") | |
| # ---------------- LOAD EXISTING PLAYER INFO (name, team) ---------------- | |
| if Path(PLAYER_INFO_PATH).exists(): | |
| with open(PLAYER_INFO_PATH, "r", encoding="utf-8") as f: | |
| player_info = json.load(f) | |
| print(f" Loaded existing player_info: {len(player_info):,} players") | |
| else: | |
| print(f" ⚠ {PLAYER_INFO_PATH} not found — name/team will be missing") | |
| player_info = {} | |
| # ---------------- BUILD 64D VECTORS ---------------- | |
| valid_ids = [] | |
| raw_vectors = [] | |
| excluded_too_few = 0 | |
| gk_zero_padded = 0 | |
| for player_id in player_onball.keys(): | |
| onball_vecs = player_onball[player_id] | |
| if len(onball_vecs) < min_matches: | |
| excluded_too_few += 1 | |
| continue | |
| mean_onball = np.mean(onball_vecs, axis=0) # 48D | |
| # Handle missing off-ball (goalkeepers, mostly) | |
| if player_id not in player_offball: | |
| mean_offball = np.zeros(OFFBALL_DIM) | |
| gk_zero_padded += 1 | |
| else: | |
| offball_vecs = player_offball[player_id] | |
| if len(offball_vecs) < min_matches: | |
| # Has off-ball but too few matches — still zero-pad rather than exclude | |
| mean_offball = np.zeros(OFFBALL_DIM) | |
| gk_zero_padded += 1 | |
| else: | |
| mean_offball = np.mean(offball_vecs, axis=0) # 16D | |
| combined = np.concatenate([mean_onball, mean_offball]) # 64D | |
| valid_ids.append(player_id) | |
| raw_vectors.append(combined) | |
| raw_vectors = np.array(raw_vectors) | |
| print(f"\nPlayers in final Player2Vec: {len(valid_ids):,}") | |
| print(f" Zero-padded off-ball (GKs/few matches): {gk_zero_padded}") | |
| print(f" Excluded (too few on-ball matches): {excluded_too_few}") | |
| print(f" Vector shape: {raw_vectors.shape}") | |
| # ---------------- DOMINANT VECTOR REMOVAL ---------------- | |
| print(f"\nRemoving {n_components} dominant component(s)...") | |
| # Stats before | |
| norms_before = np.linalg.norm(raw_vectors, axis=1, keepdims=True) | |
| normalized_before = raw_vectors / np.clip(norms_before, 1e-8, None) | |
| sims_before = normalized_before @ normalized_before.T | |
| np.fill_diagonal(sims_before, 0) | |
| print(f"\nBefore removal:") | |
| print(f" Mean similarity: {sims_before.mean():.4f}") | |
| print(f" Min similarity: {sims_before.min():.4f}") | |
| print(f" Max similarity: {sims_before.max():.4f}") | |
| cleaned_vectors = remove_dominant_direction(raw_vectors, n_components) | |
| # Stats after | |
| sims_after = cleaned_vectors @ cleaned_vectors.T | |
| np.fill_diagonal(sims_after, 0) | |
| print(f"\nAfter removal:") | |
| print(f" Mean similarity: {sims_after.mean():.4f}") | |
| print(f" Min similarity: {sims_after.min():.4f}") | |
| print(f" Max similarity: {sims_after.max():.4f}") | |
| # ---------------- SAVE PLAYER2VEC ---------------- | |
| player_ids_array = np.array(valid_ids) | |
| np.savez( | |
| PLAYER2VEC_PATH, | |
| player_ids=player_ids_array, | |
| vectors=cleaned_vectors, | |
| ) | |
| print(f"\n✓ Player2Vec ({TOTAL_DIM}D) saved: {PLAYER2VEC_PATH}") | |
| print(f" Shape: {cleaned_vectors.shape}") | |
| # ---------------- SAVE ENRICHED METADATA ---------------- | |
| print(f"\nBuilding enriched metadata...") | |
| enriched_metadata = {} | |
| for pid in valid_ids: | |
| info = player_info.get(pid, {}) | |
| pos_meta = position_metadata.get(pid, {}) | |
| enriched_metadata[pid] = { | |
| "name": info.get("name", "Unknown"), | |
| "team": info.get("team", "Unknown"), | |
| "primary_position": pos_meta.get("primary_position", info.get("position", "Unknown")), | |
| "position_distribution": pos_meta.get("position_distribution", {}), | |
| "versatility_score": pos_meta.get("versatility_score", 0.0), | |
| "num_distinct_positions": pos_meta.get("num_distinct_positions", 1), | |
| "num_matches": pos_meta.get("num_matches", 0), | |
| } | |
| with open(PLAYER_METADATA_PATH, "w", encoding="utf-8") as f: | |
| json.dump(enriched_metadata, f, ensure_ascii=False, indent=2) | |
| print(f"✓ Enriched metadata saved: {PLAYER_METADATA_PATH}") | |
| # ---------------- SANITY CHECKS ---------------- | |
| print("\n" + "=" * 80) | |
| print("SANITY CHECK — Top 5 similar players for sample players") | |
| print("=" * 80) | |
| sample_names = ["Suárez", "Neymar", "Mané", "Messi", "Iniesta"] | |
| name_to_idx = {} | |
| for i, pid in enumerate(valid_ids): | |
| name = enriched_metadata[pid]["name"] | |
| for sample in sample_names: | |
| if sample in name: | |
| name_to_idx[sample] = i | |
| break | |
| for sample, idx in name_to_idx.items(): | |
| target_vec = cleaned_vectors[idx] | |
| sims = cleaned_vectors @ target_vec | |
| sims[idx] = -1 # exclude self | |
| top_indices = np.argsort(sims)[::-1][:5] | |
| target_meta = enriched_metadata[valid_ids[idx]] | |
| print( | |
| f"\n{target_meta['name']} ({target_meta['primary_position']}, " | |
| f"{target_meta['team']}, versatility={target_meta['versatility_score']}):" | |
| ) | |
| for i in top_indices: | |
| sim_meta = enriched_metadata[valid_ids[i]] | |
| print( | |
| f" {sim_meta['name']:<35} " | |
| f"({sim_meta['primary_position']:<25}, {sim_meta['team']:<22}) " | |
| f"→ {sims[i]:.4f}" | |
| ) | |
| # ---------------- VERSATILITY HIGHLIGHTS ---------------- | |
| print("\n" + "=" * 80) | |
| print("VERSATILITY HIGHLIGHTS — Most versatile players (entropy + distinct positions)") | |
| print("=" * 80) | |
| versatile_players = sorted( | |
| enriched_metadata.items(), | |
| key=lambda kv: (kv[1]["num_distinct_positions"], kv[1]["versatility_score"]), | |
| reverse=True, | |
| )[:15] | |
| print(f"\n{'Player':<35} {'Primary Pos':<25} {'Distinct':<10} {'Entropy':<10}") | |
| print("-" * 90) | |
| for pid, meta in versatile_players: | |
| print( | |
| f"{meta['name']:<35} {meta['primary_position']:<25} " | |
| f"{meta['num_distinct_positions']:<10} {meta['versatility_score']:<10.4f}" | |
| ) | |
| # ================================================================================================== | |
| # MAIN | |
| # ================================================================================================== | |
| def main(): | |
| print("=" * 80) | |
| print(f"BUILDING {TOTAL_DIM}D PLAYER2VEC (V2)") | |
| print(f" On-ball: {ONBALL_DIM}D + Off-ball: {OFFBALL_DIM}D") | |
| print("=" * 80) | |
| build_player2vec(min_matches=MIN_MATCHES, n_components=1) | |
| if __name__ == "__main__": | |
| main() | |