worldcup-api / feature_engineer.py
wihaha's picture
First release to Hugging Face
a3d10df
Raw
History Blame Contribute Delete
6.07 kB
import pandas as pd
import numpy as np
class FeatureEngineer:
def __init__(self, rankings_df, form_df, teams_df, manual_features_df=None):
self.rankings = rankings_df
self.form = form_df
self.teams = teams_df
self.manual_features = manual_features_df if manual_features_df is not None else pd.DataFrame()
self.team_momentum = self._calculate_momentum()
def _calculate_momentum(self):
"""
Calculate a form momentum score for each team based on their last 10 matches.
A win against a strong opponent (high Elo) gives more momentum than against a weak one.
"""
momentum = {}
for team_id, group in self.form.groupby('team_id'):
# Take up to 10 most recent matches
recent = group.head(10).copy()
if len(recent) == 0:
momentum[team_id] = 0
continue
# Points: 3 for win, 1 for draw, 0 for loss
recent['points'] = np.where(recent['goals_for'] > recent['goals_against'], 3,
np.where(recent['goals_for'] == recent['goals_against'], 1, 0))
# Weight points by opponent Elo (normalized roughly around 1500)
# E.g., beating an 1800 Elo team gives weight 1.2, a 1200 Elo team gives 0.8
recent['weight'] = recent['opponent_elo'] / 1500.0
# Weighted average points
score = (recent['points'] * recent['weight']).sum() / recent['weight'].sum()
momentum[team_id] = score
return pd.Series(momentum, name='momentum')
def engineer_match_features(self, match_id, home_team_id, away_team_id):
"""
Given match_id and home/away team IDs, return a feature dictionary.
"""
# Elo rating
if home_team_id not in self.rankings.index:
raise ValueError(f"Missing ranking for home team: {home_team_id}")
if away_team_id not in self.rankings.index:
raise ValueError(f"Missing ranking for away team: {away_team_id}")
home_rating = self.rankings.loc[home_team_id, 'rating']
away_rating = self.rankings.loc[away_team_id, 'rating']
if pd.isna(home_rating) or pd.isna(away_rating):
raise ValueError(f"NaN rating for {home_team_id} or {away_team_id}")
# Momentum
home_mom = self.team_momentum.get(home_team_id, 1.0)
away_mom = self.team_momentum.get(away_team_id, 1.0)
# Host advantage
home_host = 1 if (home_team_id in self.teams.index and self.teams.loc[home_team_id, 'is_host']) else 0
away_host = 1 if (away_team_id in self.teams.index and self.teams.loc[away_team_id, 'is_host']) else 0
# In World Cup, if home team is host, they have home advantage.
# Often neither is host, meaning neutral venue.
home_advantage = 1 if home_host else 0
# Manual Features Extraction
injury_impact_home = 0.0
injury_impact_away = 0.0
lineup_strength_home = 1.0
lineup_strength_away = 1.0
odds_implied_home_prob = 0.0
odds_implied_away_prob = 0.0
if match_id in self.manual_features.index:
m_feat = self.manual_features.loc[match_id]
injury_impact_home = m_feat.get('injury_impact_home') or 0.0
injury_impact_away = m_feat.get('injury_impact_away') or 0.0
lineup_strength_home = m_feat.get('lineup_strength_home') or 1.0
lineup_strength_away = m_feat.get('lineup_strength_away') or 1.0
odds_h = m_feat.get('odds_1x2_home')
odds_d = m_feat.get('odds_1x2_draw')
odds_a = m_feat.get('odds_1x2_away')
# Simple implied probability from odds (1/odds)
if pd.notna(odds_h) and pd.notna(odds_d) and pd.notna(odds_a):
margin = (1/odds_h) + (1/odds_d) + (1/odds_a)
odds_implied_home_prob = (1/odds_h) / margin
odds_implied_away_prob = (1/odds_a) / margin
return {
'elo_diff': home_rating - away_rating,
'momentum_diff': home_mom - away_mom,
'home_advantage': home_advantage,
'injury_impact_home': float(injury_impact_home),
'injury_impact_away': float(injury_impact_away),
'lineup_strength_home': float(lineup_strength_home),
'lineup_strength_away': float(lineup_strength_away),
'odds_implied_home_prob': float(odds_implied_home_prob),
'odds_implied_away_prob': float(odds_implied_away_prob)
}
def build_dataset(self, matches_df):
"""
Build a training dataset from a matches dataframe.
"""
features = []
labels = []
for _, row in matches_df.iterrows():
if pd.isna(row['home_team_id']) or pd.isna(row['away_team_id']):
continue
try:
# Extract features
f = self.engineer_match_features(row['id'], row['home_team_id'], row['away_team_id'])
# Track the original index to match back easily
f['match_id'] = row['id']
features.append(f)
# Determine label (0: Away Win, 1: Draw, 2: Home Win)
if not pd.isna(row['home_score_90']) and not pd.isna(row['away_score_90']):
if row['home_score_90'] > row['away_score_90']:
labels.append(2)
elif row['home_score_90'] == row['away_score_90']:
labels.append(1)
else:
labels.append(0)
else:
labels.append(None) # Unplayed
except ValueError as e:
print(f"Skipping match {row['id']}: {e}")
return pd.DataFrame(features), pd.Series(labels)