360 lines
15 KiB
Python
Executable File
360 lines
15 KiB
Python
Executable File
"""
|
||
Player Predictor Engine - V20 Ensemble Component
|
||
Analyzes squad quality, key players, and missing player impact.
|
||
|
||
Weight: 25% in ensemble
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
from typing import Dict, Optional, List
|
||
from dataclasses import dataclass
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||
|
||
from features.squad_analysis_engine import get_squad_analysis_engine
|
||
from features.sidelined_analyzer import get_sidelined_analyzer
|
||
|
||
|
||
@dataclass
|
||
class PlayerPrediction:
|
||
"""Player engine prediction output.
|
||
|
||
IMPORTANT: squad_quality uses the SAME composite formula as
|
||
extract_training_data.py so that inference values match the
|
||
distribution the model was trained on (~3-36 range).
|
||
"""
|
||
home_squad_quality: float = 12.0
|
||
away_squad_quality: float = 12.0
|
||
squad_diff: float = 0.0
|
||
home_key_players: int = 0
|
||
away_key_players: int = 0
|
||
home_missing_impact: float = 0.0
|
||
away_missing_impact: float = 0.0
|
||
home_goals_form: int = 0
|
||
away_goals_form: int = 0
|
||
home_lineup_goals_per90: float = 0.0
|
||
away_lineup_goals_per90: float = 0.0
|
||
home_lineup_assists_per90: float = 0.0
|
||
away_lineup_assists_per90: float = 0.0
|
||
home_squad_continuity: float = 0.5
|
||
away_squad_continuity: float = 0.5
|
||
home_top_scorer_form: int = 0
|
||
away_top_scorer_form: int = 0
|
||
home_avg_player_exp: float = 0.0
|
||
away_avg_player_exp: float = 0.0
|
||
home_goals_diversity: float = 0.0
|
||
away_goals_diversity: float = 0.0
|
||
lineup_available: bool = False
|
||
confidence: float = 0.0
|
||
|
||
|
||
class PlayerPredictorEngine:
|
||
"""
|
||
Player/Squad-based prediction engine.
|
||
|
||
Analyzes:
|
||
- Starting 11 quality
|
||
- Key player availability (top scorers)
|
||
- Missing player impact
|
||
- Recent goalscoring form per player
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.squad_engine = get_squad_analysis_engine()
|
||
self.sidelined_analyzer = get_sidelined_analyzer()
|
||
print("✅ PlayerPredictorEngine initialized")
|
||
|
||
def predict(self,
|
||
match_id: str,
|
||
home_team_id: str,
|
||
away_team_id: str,
|
||
home_lineup: Optional[List[str]] = None,
|
||
away_lineup: Optional[List[str]] = None,
|
||
sidelined_data: Optional[Dict] = None) -> PlayerPrediction:
|
||
"""
|
||
Generate player-based prediction.
|
||
|
||
Args:
|
||
match_id: Match ID for lineup lookup
|
||
home_team_id: Home team ID
|
||
away_team_id: Away team ID
|
||
home_lineup: Optional list of home player IDs
|
||
away_lineup: Optional list of away player IDs
|
||
|
||
Returns:
|
||
PlayerPrediction with squad analysis
|
||
"""
|
||
|
||
# Get squad features
|
||
home_analysis = None
|
||
away_analysis = None
|
||
if home_lineup and away_lineup:
|
||
home_analysis = self.squad_engine.analyze_squad_from_list(
|
||
home_lineup, home_team_id
|
||
)
|
||
away_analysis = self.squad_engine.analyze_squad_from_list(
|
||
away_lineup, away_team_id
|
||
)
|
||
lineup_available = True
|
||
features = {
|
||
"home_starting_11": home_analysis.starting_count or 11,
|
||
"home_goals_last_5": home_analysis.total_goals_last_5,
|
||
"home_assists_last_5": home_analysis.total_assists_last_5,
|
||
"home_key_players": home_analysis.key_players_count,
|
||
"home_forwards": home_analysis.forward_count or 2,
|
||
"away_starting_11": away_analysis.starting_count or 11,
|
||
"away_goals_last_5": away_analysis.total_goals_last_5,
|
||
"away_assists_last_5": away_analysis.total_assists_last_5,
|
||
"away_key_players": away_analysis.key_players_count,
|
||
"away_forwards": away_analysis.forward_count or 2,
|
||
}
|
||
elif match_id:
|
||
try:
|
||
features = self.squad_engine.get_features(
|
||
match_id, home_team_id, away_team_id
|
||
)
|
||
lineup_available = (
|
||
features.get("home_starting_11", 0) >= 11 and
|
||
features.get("away_starting_11", 0) >= 11
|
||
)
|
||
except Exception:
|
||
features = self.squad_engine.get_features_without_match(
|
||
home_team_id, away_team_id
|
||
)
|
||
lineup_available = False
|
||
else:
|
||
features = self.squad_engine.get_features_without_match(
|
||
home_team_id, away_team_id
|
||
)
|
||
lineup_available = False
|
||
|
||
home_goals = int(features.get("home_goals_last_5", 0))
|
||
away_goals = int(features.get("away_goals_last_5", 0))
|
||
home_key = int(features.get("home_key_players", 0))
|
||
away_key = int(features.get("away_key_players", 0))
|
||
home_starting = features.get("home_starting_11", 11)
|
||
away_starting = features.get("away_starting_11", 11)
|
||
home_fwd = features.get("home_forwards", 2)
|
||
away_fwd = features.get("away_forwards", 2)
|
||
|
||
# Squad quality — matches V25 extract_training_data.py:579
|
||
home_quality = home_starting * 0.3 + home_key * 3.0 + home_fwd * 1.5
|
||
away_quality = away_starting * 0.3 + away_key * 3.0 + away_fwd * 1.5
|
||
squad_diff = home_quality - away_quality
|
||
|
||
# Missing player impact
|
||
if sidelined_data:
|
||
home_impact, away_impact = self.sidelined_analyzer.analyze_match(sidelined_data)
|
||
home_missing = min(1.0, max(0.0, home_impact.impact_score))
|
||
away_missing = min(1.0, max(0.0, away_impact.impact_score))
|
||
sidelined_available = True
|
||
else:
|
||
expected_xi = 11
|
||
actual_home_xi = features.get("home_starting_11", 11)
|
||
actual_away_xi = features.get("away_starting_11", 11)
|
||
home_missing = (expected_xi - actual_home_xi) / expected_xi if actual_home_xi < expected_xi else 0
|
||
away_missing = (expected_xi - actual_away_xi) / expected_xi if actual_away_xi < expected_xi else 0
|
||
sidelined_available = False
|
||
|
||
# Player-level features (matches extract_training_data.py:594-650)
|
||
player_feats = self._compute_player_level_features(
|
||
home_lineup or [], away_lineup or [],
|
||
home_team_id, away_team_id,
|
||
home_analysis, away_analysis,
|
||
)
|
||
|
||
confidence = 70.0 if lineup_available else 35.0
|
||
if home_goals + away_goals > 10:
|
||
confidence += 15
|
||
if sidelined_available:
|
||
confidence += self.sidelined_analyzer.config.get("sidelined.confidence_boost", 10)
|
||
if not lineup_available:
|
||
confidence -= 5.0
|
||
|
||
return PlayerPrediction(
|
||
home_squad_quality=home_quality,
|
||
away_squad_quality=away_quality,
|
||
squad_diff=squad_diff,
|
||
home_key_players=home_key,
|
||
away_key_players=away_key,
|
||
home_missing_impact=home_missing,
|
||
away_missing_impact=away_missing,
|
||
home_goals_form=home_goals,
|
||
away_goals_form=away_goals,
|
||
home_lineup_goals_per90=player_feats['home_lineup_goals_per90'],
|
||
away_lineup_goals_per90=player_feats['away_lineup_goals_per90'],
|
||
home_lineup_assists_per90=player_feats['home_lineup_assists_per90'],
|
||
away_lineup_assists_per90=player_feats['away_lineup_assists_per90'],
|
||
home_squad_continuity=player_feats['home_squad_continuity'],
|
||
away_squad_continuity=player_feats['away_squad_continuity'],
|
||
home_top_scorer_form=player_feats['home_top_scorer_form'],
|
||
away_top_scorer_form=player_feats['away_top_scorer_form'],
|
||
home_avg_player_exp=player_feats['home_avg_player_exp'],
|
||
away_avg_player_exp=player_feats['away_avg_player_exp'],
|
||
home_goals_diversity=player_feats['home_goals_diversity'],
|
||
away_goals_diversity=player_feats['away_goals_diversity'],
|
||
lineup_available=lineup_available,
|
||
confidence=max(5.0, confidence)
|
||
)
|
||
|
||
def _compute_player_level_features(
|
||
self,
|
||
home_lineup: List[str],
|
||
away_lineup: List[str],
|
||
home_team_id: str,
|
||
away_team_id: str,
|
||
home_analysis,
|
||
away_analysis,
|
||
) -> Dict[str, float]:
|
||
defaults = {
|
||
'home_lineup_goals_per90': 0.0, 'away_lineup_goals_per90': 0.0,
|
||
'home_lineup_assists_per90': 0.0, 'away_lineup_assists_per90': 0.0,
|
||
'home_squad_continuity': 0.5, 'away_squad_continuity': 0.5,
|
||
'home_top_scorer_form': 0, 'away_top_scorer_form': 0,
|
||
'home_avg_player_exp': 0.0, 'away_avg_player_exp': 0.0,
|
||
'home_goals_diversity': 0.0, 'away_goals_diversity': 0.0,
|
||
}
|
||
conn = self.squad_engine.get_conn()
|
||
if conn is None:
|
||
return defaults
|
||
|
||
try:
|
||
from psycopg2.extras import RealDictCursor
|
||
result = {}
|
||
for prefix, lineup, team_id in [
|
||
('home', home_lineup, home_team_id),
|
||
('away', away_lineup, away_team_id),
|
||
]:
|
||
if not lineup:
|
||
for k in ('lineup_goals_per90', 'lineup_assists_per90',
|
||
'squad_continuity', 'top_scorer_form',
|
||
'avg_player_exp', 'goals_diversity'):
|
||
result[f'{prefix}_{k}'] = defaults[f'{prefix}_{k}']
|
||
continue
|
||
|
||
g90, a90, total_exp = 0.0, 0.0, 0
|
||
best_scorer_total, best_scorer_id = 0, None
|
||
scorers_in_lineup = 0
|
||
|
||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||
for pid in lineup:
|
||
cur.execute("""
|
||
SELECT
|
||
COUNT(*) as starts,
|
||
COALESCE(SUM(CASE WHEN e.event_type = 'goal'
|
||
AND (e.event_subtype IS NULL OR e.event_subtype NOT ILIKE '%%penaltı kaçırma%%')
|
||
THEN 1 ELSE 0 END), 0) as goals,
|
||
COALESCE((SELECT COUNT(*) FROM match_player_events
|
||
WHERE assist_player_id = %s), 0) as assists
|
||
FROM match_player_participation mpp
|
||
LEFT JOIN match_player_events e
|
||
ON e.match_id = mpp.match_id AND e.player_id = mpp.player_id
|
||
WHERE mpp.player_id = %s AND mpp.is_starting = true
|
||
""", (pid, pid))
|
||
row = cur.fetchone()
|
||
if not row or not row['starts']:
|
||
continue
|
||
starts = row['starts']
|
||
goals = row['goals'] or 0
|
||
assists = row['assists'] or 0
|
||
g90 += goals / starts
|
||
a90 += assists / starts
|
||
total_exp += starts
|
||
if goals > 0:
|
||
scorers_in_lineup += 1
|
||
if goals > best_scorer_total:
|
||
best_scorer_total = goals
|
||
best_scorer_id = pid
|
||
|
||
n_st = len(lineup) or 1
|
||
|
||
# Top scorer recent form (goals in last 5 starts)
|
||
top_scorer_form = 0
|
||
if best_scorer_id:
|
||
cur.execute("""
|
||
SELECT COUNT(*) as goals
|
||
FROM match_player_events mpe
|
||
WHERE mpe.player_id = %s AND mpe.event_type = 'goal'
|
||
AND mpe.match_id IN (
|
||
SELECT match_id FROM match_player_participation
|
||
WHERE player_id = %s AND is_starting = true
|
||
ORDER BY match_id DESC LIMIT 5
|
||
)
|
||
""", (best_scorer_id, best_scorer_id))
|
||
tsf_row = cur.fetchone()
|
||
if tsf_row:
|
||
top_scorer_form = tsf_row['goals'] or 0
|
||
|
||
# Squad continuity (overlap with previous match lineup)
|
||
squad_continuity = 0.5
|
||
cur.execute("""
|
||
SELECT mpp.player_id
|
||
FROM match_player_participation mpp
|
||
JOIN matches m ON mpp.match_id = m.id
|
||
WHERE mpp.team_id = %s AND mpp.is_starting = true
|
||
AND m.status = 'FT'
|
||
ORDER BY m.mst_utc DESC
|
||
LIMIT 11
|
||
""", (team_id,))
|
||
prev_starters = {r['player_id'] for r in cur.fetchall()}
|
||
if prev_starters:
|
||
overlap = len(set(lineup) & prev_starters)
|
||
squad_continuity = overlap / n_st
|
||
|
||
result[f'{prefix}_lineup_goals_per90'] = round(g90, 3)
|
||
result[f'{prefix}_lineup_assists_per90'] = round(a90, 3)
|
||
result[f'{prefix}_squad_continuity'] = round(squad_continuity, 3)
|
||
result[f'{prefix}_top_scorer_form'] = top_scorer_form
|
||
result[f'{prefix}_avg_player_exp'] = round(total_exp / n_st, 1)
|
||
result[f'{prefix}_goals_diversity'] = round(scorers_in_lineup / n_st, 3)
|
||
|
||
return result
|
||
except Exception as e:
|
||
print(f"[PlayerPredictor] Player-level features failed: {e}")
|
||
return defaults
|
||
|
||
def get_1x2_modifier(self, prediction: PlayerPrediction) -> Dict[str, float]:
|
||
"""
|
||
Calculate 1X2 probability modifiers based on squad analysis.
|
||
|
||
Returns modifiers to apply to base probabilities.
|
||
squad_diff is in training scale (~-33 to +33), normalize to -1..+1.
|
||
"""
|
||
diff = prediction.squad_diff / 33.0 # training-scale normalisation
|
||
diff = max(-1.0, min(1.0, diff)) # clamp
|
||
|
||
return {
|
||
"home_modifier": 1.0 + (diff * 0.3), # Up to +/-30%
|
||
"away_modifier": 1.0 - (diff * 0.3),
|
||
"draw_modifier": 1.0 - abs(diff) * 0.2 # Less draw if big diff
|
||
}
|
||
|
||
|
||
# Singleton
|
||
_engine: Optional[PlayerPredictorEngine] = None
|
||
|
||
|
||
def get_player_predictor() -> PlayerPredictorEngine:
|
||
global _engine
|
||
if _engine is None:
|
||
_engine = PlayerPredictorEngine()
|
||
return _engine
|
||
|
||
|
||
if __name__ == "__main__":
|
||
engine = get_player_predictor()
|
||
|
||
print("\n🧪 Player Predictor Engine Test")
|
||
print("=" * 50)
|
||
|
||
pred = engine.predict(
|
||
match_id="test_match",
|
||
home_team_id="test_home",
|
||
away_team_id="test_away"
|
||
)
|
||
|
||
print(f"\n📊 Prediction:")
|
||
for k, v in pred.to_dict().items():
|
||
print(f" {k}: {v}")
|