Spaces:
Sleeping
Sleeping
Commit ·
a3d10df
0
Parent(s):
First release to Hugging Face
Browse files- Dockerfile +23 -0
- __pycache__/api.cpython-314.pyc +0 -0
- __pycache__/data_loader.cpython-314.pyc +0 -0
- __pycache__/feature_engineer.cpython-314.pyc +0 -0
- __pycache__/predict.cpython-314.pyc +0 -0
- __pycache__/rule_based_model.cpython-314.pyc +0 -0
- __pycache__/test_api.cpython-314.pyc +0 -0
- __pycache__/train.cpython-314.pyc +0 -0
- api.py +67 -0
- artifacts/rule_based_baseline.pkl +0 -0
- data_loader.py +67 -0
- feature_engineer.py +136 -0
- predict.py +95 -0
- requirements.txt +9 -0
- rule_based_model.py +73 -0
- test_api.py +36 -0
- train.py +16 -0
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Set up a working directory
|
| 4 |
+
WORKDIR /code
|
| 5 |
+
|
| 6 |
+
# Install requirements
|
| 7 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 8 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 9 |
+
|
| 10 |
+
# Copy the entire python/model directory into the container
|
| 11 |
+
COPY . /code
|
| 12 |
+
|
| 13 |
+
# Set permissions for Hugging Face Spaces (runs as user 1000)
|
| 14 |
+
RUN useradd -m -u 1000 user
|
| 15 |
+
USER user
|
| 16 |
+
ENV HOME=/home/user \
|
| 17 |
+
PATH=/home/user/.local/bin:$PATH
|
| 18 |
+
|
| 19 |
+
WORKDIR $HOME/app
|
| 20 |
+
COPY --chown=user . $HOME/app
|
| 21 |
+
|
| 22 |
+
# Hugging Face Spaces require the app to listen on port 7860
|
| 23 |
+
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
|
__pycache__/api.cpython-314.pyc
ADDED
|
Binary file (4.15 kB). View file
|
|
|
__pycache__/data_loader.cpython-314.pyc
ADDED
|
Binary file (3.6 kB). View file
|
|
|
__pycache__/feature_engineer.cpython-314.pyc
ADDED
|
Binary file (7.28 kB). View file
|
|
|
__pycache__/predict.cpython-314.pyc
ADDED
|
Binary file (4.51 kB). View file
|
|
|
__pycache__/rule_based_model.cpython-314.pyc
ADDED
|
Binary file (3.51 kB). View file
|
|
|
__pycache__/test_api.cpython-314.pyc
ADDED
|
Binary file (1.93 kB). View file
|
|
|
__pycache__/train.cpython-314.pyc
ADDED
|
Binary file (836 Bytes). View file
|
|
|
api.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from predict import get_predictions
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="2026 World Cup Prediction API", version="1.0.0")
|
| 10 |
+
|
| 11 |
+
app.add_middleware(
|
| 12 |
+
CORSMiddleware,
|
| 13 |
+
allow_origins=["*"], # Allow all origins for local dev; can restrict later
|
| 14 |
+
allow_credentials=True,
|
| 15 |
+
allow_methods=["*"],
|
| 16 |
+
allow_headers=["*"],
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
class PredictionModel(BaseModel):
|
| 20 |
+
match_id: str
|
| 21 |
+
home_team_id: str
|
| 22 |
+
away_team_id: str
|
| 23 |
+
prob_home_win: float
|
| 24 |
+
prob_draw: float
|
| 25 |
+
prob_away_win: float
|
| 26 |
+
manual_features_applied: bool = False
|
| 27 |
+
|
| 28 |
+
class SkippedMatchModel(BaseModel):
|
| 29 |
+
match_id: str
|
| 30 |
+
home_team_id: Optional[str] = None
|
| 31 |
+
away_team_id: Optional[str] = None
|
| 32 |
+
reason: str
|
| 33 |
+
|
| 34 |
+
class PredictionResponse(BaseModel):
|
| 35 |
+
predictions: List[PredictionModel]
|
| 36 |
+
skipped: List[SkippedMatchModel]
|
| 37 |
+
predictions_count: int
|
| 38 |
+
skipped_count: int
|
| 39 |
+
|
| 40 |
+
@app.get("/api/predict", response_model=PredictionResponse)
|
| 41 |
+
def predict_upcoming():
|
| 42 |
+
"""
|
| 43 |
+
Returns Win/Draw/Loss probabilities for all scheduled/active matches where
|
| 44 |
+
both teams are known.
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
# Resolve artifacts path relative to the current file
|
| 48 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 49 |
+
artifacts_dir = os.path.join(current_dir, "artifacts")
|
| 50 |
+
|
| 51 |
+
data = get_predictions(model_dir=artifacts_dir)
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"predictions": data["predictions"],
|
| 55 |
+
"skipped": data["skipped"],
|
| 56 |
+
"predictions_count": len(data["predictions"]),
|
| 57 |
+
"skipped_count": len(data["skipped"])
|
| 58 |
+
}
|
| 59 |
+
except FileNotFoundError as e:
|
| 60 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 61 |
+
except Exception as e:
|
| 62 |
+
raise HTTPException(status_code=500, detail=f"Internal prediction error: {str(e)}")
|
| 63 |
+
|
| 64 |
+
# Add a simple health check
|
| 65 |
+
@app.get("/api/health")
|
| 66 |
+
def health():
|
| 67 |
+
return {"status": "ok"}
|
artifacts/rule_based_baseline.pkl
ADDED
|
Binary file (315 Bytes). View file
|
|
|
data_loader.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
from sqlalchemy import create_engine
|
| 5 |
+
|
| 6 |
+
load_dotenv('../../.env.local')
|
| 7 |
+
|
| 8 |
+
class DataLoader:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
db_url = os.getenv("SUPABASE_DB_URL")
|
| 11 |
+
if not db_url:
|
| 12 |
+
raise ValueError("SUPABASE_DB_URL not found in .env.local")
|
| 13 |
+
# SQLAlchemy requires postgresql:// instead of postgres:// in some versions,
|
| 14 |
+
# but supabase usually provides postgresql://
|
| 15 |
+
self.engine = create_engine(db_url)
|
| 16 |
+
|
| 17 |
+
def get_rankings(self):
|
| 18 |
+
query = """
|
| 19 |
+
SELECT team_id, rating, rank
|
| 20 |
+
FROM worldcup_team_rankings
|
| 21 |
+
"""
|
| 22 |
+
df = pd.read_sql(query, self.engine)
|
| 23 |
+
return df.set_index('team_id')
|
| 24 |
+
|
| 25 |
+
def get_team_form(self):
|
| 26 |
+
query = """
|
| 27 |
+
SELECT team_id, match_date, opponent_elo, goals_for, goals_against, is_home
|
| 28 |
+
FROM worldcup_team_form
|
| 29 |
+
ORDER BY team_id, match_date DESC
|
| 30 |
+
"""
|
| 31 |
+
return pd.read_sql(query, self.engine)
|
| 32 |
+
|
| 33 |
+
def get_teams(self):
|
| 34 |
+
query = """
|
| 35 |
+
SELECT id as team_id, is_host
|
| 36 |
+
FROM worldcup_teams
|
| 37 |
+
"""
|
| 38 |
+
df = pd.read_sql(query, self.engine)
|
| 39 |
+
return df.set_index('team_id')
|
| 40 |
+
|
| 41 |
+
def get_matches(self):
|
| 42 |
+
query = """
|
| 43 |
+
SELECT id, stage, round, home_team_id, away_team_id,
|
| 44 |
+
home_score_90, away_score_90, status
|
| 45 |
+
FROM worldcup_matches
|
| 46 |
+
WHERE status != 'cancelled'
|
| 47 |
+
"""
|
| 48 |
+
return pd.read_sql(query, self.engine)
|
| 49 |
+
|
| 50 |
+
def get_manual_features(self):
|
| 51 |
+
query = """
|
| 52 |
+
SELECT match_id, odds_1x2_home, odds_1x2_draw, odds_1x2_away,
|
| 53 |
+
injury_impact_home, injury_impact_away,
|
| 54 |
+
lineup_strength_home, lineup_strength_away
|
| 55 |
+
FROM worldcup_manual_features
|
| 56 |
+
"""
|
| 57 |
+
df = pd.read_sql(query, self.engine)
|
| 58 |
+
return df.set_index('match_id')
|
| 59 |
+
|
| 60 |
+
def close(self):
|
| 61 |
+
self.engine.dispose()
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
loader = DataLoader()
|
| 65 |
+
print("Rankings loaded:", len(loader.get_rankings()))
|
| 66 |
+
print("Matches loaded:", len(loader.get_matches()))
|
| 67 |
+
loader.close()
|
feature_engineer.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
class FeatureEngineer:
|
| 5 |
+
def __init__(self, rankings_df, form_df, teams_df, manual_features_df=None):
|
| 6 |
+
self.rankings = rankings_df
|
| 7 |
+
self.form = form_df
|
| 8 |
+
self.teams = teams_df
|
| 9 |
+
self.manual_features = manual_features_df if manual_features_df is not None else pd.DataFrame()
|
| 10 |
+
|
| 11 |
+
self.team_momentum = self._calculate_momentum()
|
| 12 |
+
|
| 13 |
+
def _calculate_momentum(self):
|
| 14 |
+
"""
|
| 15 |
+
Calculate a form momentum score for each team based on their last 10 matches.
|
| 16 |
+
A win against a strong opponent (high Elo) gives more momentum than against a weak one.
|
| 17 |
+
"""
|
| 18 |
+
momentum = {}
|
| 19 |
+
for team_id, group in self.form.groupby('team_id'):
|
| 20 |
+
# Take up to 10 most recent matches
|
| 21 |
+
recent = group.head(10).copy()
|
| 22 |
+
if len(recent) == 0:
|
| 23 |
+
momentum[team_id] = 0
|
| 24 |
+
continue
|
| 25 |
+
|
| 26 |
+
# Points: 3 for win, 1 for draw, 0 for loss
|
| 27 |
+
recent['points'] = np.where(recent['goals_for'] > recent['goals_against'], 3,
|
| 28 |
+
np.where(recent['goals_for'] == recent['goals_against'], 1, 0))
|
| 29 |
+
|
| 30 |
+
# Weight points by opponent Elo (normalized roughly around 1500)
|
| 31 |
+
# E.g., beating an 1800 Elo team gives weight 1.2, a 1200 Elo team gives 0.8
|
| 32 |
+
recent['weight'] = recent['opponent_elo'] / 1500.0
|
| 33 |
+
|
| 34 |
+
# Weighted average points
|
| 35 |
+
score = (recent['points'] * recent['weight']).sum() / recent['weight'].sum()
|
| 36 |
+
momentum[team_id] = score
|
| 37 |
+
|
| 38 |
+
return pd.Series(momentum, name='momentum')
|
| 39 |
+
|
| 40 |
+
def engineer_match_features(self, match_id, home_team_id, away_team_id):
|
| 41 |
+
"""
|
| 42 |
+
Given match_id and home/away team IDs, return a feature dictionary.
|
| 43 |
+
"""
|
| 44 |
+
# Elo rating
|
| 45 |
+
if home_team_id not in self.rankings.index:
|
| 46 |
+
raise ValueError(f"Missing ranking for home team: {home_team_id}")
|
| 47 |
+
if away_team_id not in self.rankings.index:
|
| 48 |
+
raise ValueError(f"Missing ranking for away team: {away_team_id}")
|
| 49 |
+
|
| 50 |
+
home_rating = self.rankings.loc[home_team_id, 'rating']
|
| 51 |
+
away_rating = self.rankings.loc[away_team_id, 'rating']
|
| 52 |
+
|
| 53 |
+
if pd.isna(home_rating) or pd.isna(away_rating):
|
| 54 |
+
raise ValueError(f"NaN rating for {home_team_id} or {away_team_id}")
|
| 55 |
+
|
| 56 |
+
# Momentum
|
| 57 |
+
home_mom = self.team_momentum.get(home_team_id, 1.0)
|
| 58 |
+
away_mom = self.team_momentum.get(away_team_id, 1.0)
|
| 59 |
+
|
| 60 |
+
# Host advantage
|
| 61 |
+
home_host = 1 if (home_team_id in self.teams.index and self.teams.loc[home_team_id, 'is_host']) else 0
|
| 62 |
+
away_host = 1 if (away_team_id in self.teams.index and self.teams.loc[away_team_id, 'is_host']) else 0
|
| 63 |
+
|
| 64 |
+
# In World Cup, if home team is host, they have home advantage.
|
| 65 |
+
# Often neither is host, meaning neutral venue.
|
| 66 |
+
home_advantage = 1 if home_host else 0
|
| 67 |
+
|
| 68 |
+
# Manual Features Extraction
|
| 69 |
+
injury_impact_home = 0.0
|
| 70 |
+
injury_impact_away = 0.0
|
| 71 |
+
lineup_strength_home = 1.0
|
| 72 |
+
lineup_strength_away = 1.0
|
| 73 |
+
odds_implied_home_prob = 0.0
|
| 74 |
+
odds_implied_away_prob = 0.0
|
| 75 |
+
|
| 76 |
+
if match_id in self.manual_features.index:
|
| 77 |
+
m_feat = self.manual_features.loc[match_id]
|
| 78 |
+
injury_impact_home = m_feat.get('injury_impact_home') or 0.0
|
| 79 |
+
injury_impact_away = m_feat.get('injury_impact_away') or 0.0
|
| 80 |
+
lineup_strength_home = m_feat.get('lineup_strength_home') or 1.0
|
| 81 |
+
lineup_strength_away = m_feat.get('lineup_strength_away') or 1.0
|
| 82 |
+
|
| 83 |
+
odds_h = m_feat.get('odds_1x2_home')
|
| 84 |
+
odds_d = m_feat.get('odds_1x2_draw')
|
| 85 |
+
odds_a = m_feat.get('odds_1x2_away')
|
| 86 |
+
|
| 87 |
+
# Simple implied probability from odds (1/odds)
|
| 88 |
+
if pd.notna(odds_h) and pd.notna(odds_d) and pd.notna(odds_a):
|
| 89 |
+
margin = (1/odds_h) + (1/odds_d) + (1/odds_a)
|
| 90 |
+
odds_implied_home_prob = (1/odds_h) / margin
|
| 91 |
+
odds_implied_away_prob = (1/odds_a) / margin
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
'elo_diff': home_rating - away_rating,
|
| 95 |
+
'momentum_diff': home_mom - away_mom,
|
| 96 |
+
'home_advantage': home_advantage,
|
| 97 |
+
'injury_impact_home': float(injury_impact_home),
|
| 98 |
+
'injury_impact_away': float(injury_impact_away),
|
| 99 |
+
'lineup_strength_home': float(lineup_strength_home),
|
| 100 |
+
'lineup_strength_away': float(lineup_strength_away),
|
| 101 |
+
'odds_implied_home_prob': float(odds_implied_home_prob),
|
| 102 |
+
'odds_implied_away_prob': float(odds_implied_away_prob)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
def build_dataset(self, matches_df):
|
| 106 |
+
"""
|
| 107 |
+
Build a training dataset from a matches dataframe.
|
| 108 |
+
"""
|
| 109 |
+
features = []
|
| 110 |
+
labels = []
|
| 111 |
+
|
| 112 |
+
for _, row in matches_df.iterrows():
|
| 113 |
+
if pd.isna(row['home_team_id']) or pd.isna(row['away_team_id']):
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
# Extract features
|
| 118 |
+
f = self.engineer_match_features(row['id'], row['home_team_id'], row['away_team_id'])
|
| 119 |
+
# Track the original index to match back easily
|
| 120 |
+
f['match_id'] = row['id']
|
| 121 |
+
features.append(f)
|
| 122 |
+
|
| 123 |
+
# Determine label (0: Away Win, 1: Draw, 2: Home Win)
|
| 124 |
+
if not pd.isna(row['home_score_90']) and not pd.isna(row['away_score_90']):
|
| 125 |
+
if row['home_score_90'] > row['away_score_90']:
|
| 126 |
+
labels.append(2)
|
| 127 |
+
elif row['home_score_90'] == row['away_score_90']:
|
| 128 |
+
labels.append(1)
|
| 129 |
+
else:
|
| 130 |
+
labels.append(0)
|
| 131 |
+
else:
|
| 132 |
+
labels.append(None) # Unplayed
|
| 133 |
+
except ValueError as e:
|
| 134 |
+
print(f"Skipping match {row['id']}: {e}")
|
| 135 |
+
|
| 136 |
+
return pd.DataFrame(features), pd.Series(labels)
|
predict.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import joblib
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from data_loader import DataLoader
|
| 5 |
+
from feature_engineer import FeatureEngineer
|
| 6 |
+
from rule_based_model import RuleBasedModel
|
| 7 |
+
|
| 8 |
+
def get_predictions(model_dir='artifacts'):
|
| 9 |
+
loader = DataLoader()
|
| 10 |
+
rankings = loader.get_rankings()
|
| 11 |
+
form = loader.get_team_form()
|
| 12 |
+
teams = loader.get_teams()
|
| 13 |
+
matches = loader.get_matches()
|
| 14 |
+
try:
|
| 15 |
+
manual_features = loader.get_manual_features()
|
| 16 |
+
except Exception as e:
|
| 17 |
+
print(f"WARNING: Could not load manual features: {e}")
|
| 18 |
+
manual_features = None
|
| 19 |
+
loader.close()
|
| 20 |
+
|
| 21 |
+
engineer = FeatureEngineer(rankings, form, teams, manual_features)
|
| 22 |
+
|
| 23 |
+
# Select upcoming matches
|
| 24 |
+
upcoming = matches[matches['status'].isin(['scheduled', 'active'])].copy()
|
| 25 |
+
if len(upcoming) == 0:
|
| 26 |
+
return {"predictions": [], "skipped": []}
|
| 27 |
+
|
| 28 |
+
X_pred, _ = engineer.build_dataset(upcoming)
|
| 29 |
+
|
| 30 |
+
# Load model
|
| 31 |
+
model_path = os.path.join(model_dir, 'rule_based_baseline.pkl')
|
| 32 |
+
if not os.path.exists(model_path):
|
| 33 |
+
raise FileNotFoundError(f"Model not found at {model_path}. Run train.py first.")
|
| 34 |
+
|
| 35 |
+
clf = joblib.load(model_path)
|
| 36 |
+
|
| 37 |
+
# Predict probabilities
|
| 38 |
+
probs = clf.predict_proba(X_pred)
|
| 39 |
+
|
| 40 |
+
X_pred['prob_away_win'] = probs[:, 0]
|
| 41 |
+
X_pred['prob_draw'] = probs[:, 1]
|
| 42 |
+
X_pred['prob_home_win'] = probs[:, 2]
|
| 43 |
+
|
| 44 |
+
# Merge back to upcoming by match_id
|
| 45 |
+
upcoming = upcoming.merge(X_pred[['match_id', 'prob_away_win', 'prob_draw', 'prob_home_win']], left_on='id', right_on='match_id', how='left')
|
| 46 |
+
|
| 47 |
+
results = []
|
| 48 |
+
skipped = []
|
| 49 |
+
|
| 50 |
+
for _, row in upcoming.iterrows():
|
| 51 |
+
if pd.isna(row['prob_home_win']):
|
| 52 |
+
# Determine reason
|
| 53 |
+
reason = "unknown"
|
| 54 |
+
if pd.isna(row['home_team_id']) or pd.isna(row['away_team_id']):
|
| 55 |
+
reason = "missing_team_placeholder"
|
| 56 |
+
else:
|
| 57 |
+
reason = "missing_features"
|
| 58 |
+
|
| 59 |
+
skipped.append({
|
| 60 |
+
"match_id": row['id'],
|
| 61 |
+
"home_team_id": row['home_team_id'] if not pd.isna(row['home_team_id']) else None,
|
| 62 |
+
"away_team_id": row['away_team_id'] if not pd.isna(row['away_team_id']) else None,
|
| 63 |
+
"reason": reason
|
| 64 |
+
})
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
# Check if manual features were applied
|
| 68 |
+
applied = False
|
| 69 |
+
if manual_features is not None and row['id'] in manual_features.index:
|
| 70 |
+
applied = True
|
| 71 |
+
|
| 72 |
+
results.append({
|
| 73 |
+
"match_id": row['id'],
|
| 74 |
+
"home_team_id": row['home_team_id'],
|
| 75 |
+
"away_team_id": row['away_team_id'],
|
| 76 |
+
"prob_home_win": float(row['prob_home_win']),
|
| 77 |
+
"prob_draw": float(row['prob_draw']),
|
| 78 |
+
"prob_away_win": float(row['prob_away_win']),
|
| 79 |
+
"manual_features_applied": applied
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
"predictions": results,
|
| 84 |
+
"skipped": skipped
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
def run_predictions():
|
| 88 |
+
data = get_predictions()
|
| 89 |
+
print("\nUpcoming Match Predictions:")
|
| 90 |
+
for p in data['predictions']:
|
| 91 |
+
print(f"Match {p['match_id']} | {p['home_team_id']} vs {p['away_team_id']} "
|
| 92 |
+
f"| 1: {p['prob_home_win']:.2f} X: {p['prob_draw']:.2f} 2: {p['prob_away_win']:.2f}")
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
run_predictions()
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pandas>=2.1.4
|
| 2 |
+
numpy>=1.26.2
|
| 3 |
+
psycopg2-binary>=2.9.9
|
| 4 |
+
SQLAlchemy>=2.0.0
|
| 5 |
+
python-dotenv>=1.0.0
|
| 6 |
+
joblib==1.3.2
|
| 7 |
+
fastapi>=0.100.0
|
| 8 |
+
uvicorn>=0.20.0
|
| 9 |
+
httpx>=0.24.1
|
rule_based_model.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
|
| 3 |
+
class RuleBasedModel:
|
| 4 |
+
"""
|
| 5 |
+
A simple baseline model using Bradley-Terry curve for Elo differences.
|
| 6 |
+
"""
|
| 7 |
+
def __init__(self):
|
| 8 |
+
self.classes_ = np.array([0, 1, 2])
|
| 9 |
+
|
| 10 |
+
def fit(self, X, y):
|
| 11 |
+
pass # No training required for rule-based baseline
|
| 12 |
+
|
| 13 |
+
def predict_proba(self, X):
|
| 14 |
+
# Base Elo difference
|
| 15 |
+
# We assume home advantage is roughly +50 Elo
|
| 16 |
+
# Momentum diff is scaled and added to Elo diff
|
| 17 |
+
|
| 18 |
+
# Apply injury impact: injury_impact is in [-0.1, 0]. Assume -0.1 = -100 Elo points.
|
| 19 |
+
home_injury_penalty = X['injury_impact_home'] * 1000
|
| 20 |
+
away_injury_penalty = X['injury_impact_away'] * 1000
|
| 21 |
+
|
| 22 |
+
# Apply lineup strength: lineup_strength is in [0.8, 1.2].
|
| 23 |
+
# A 1.2 multiplier represents a much stronger squad than normal.
|
| 24 |
+
# Let's say +0.1 = +100 Elo points.
|
| 25 |
+
home_lineup_bonus = (X['lineup_strength_home'] - 1.0) * 1000
|
| 26 |
+
away_lineup_bonus = (X['lineup_strength_away'] - 1.0) * 1000
|
| 27 |
+
|
| 28 |
+
# Calculate modified Elo diff
|
| 29 |
+
base_elo_diff = X['elo_diff'] + (X['home_advantage'] * 50) + (X['momentum_diff'] * 100)
|
| 30 |
+
|
| 31 |
+
# Add home adjustments and subtract away adjustments
|
| 32 |
+
modified_elo_diff = base_elo_diff + home_injury_penalty + home_lineup_bonus - away_injury_penalty - away_lineup_bonus
|
| 33 |
+
|
| 34 |
+
# Expected win rate for home team (Bradley-Terry curve)
|
| 35 |
+
home_expected = 1.0 / (1.0 + 10.0 ** (-modified_elo_diff / 400.0))
|
| 36 |
+
|
| 37 |
+
# Empirical draw probability in football is roughly 25-30% on evenly matched teams,
|
| 38 |
+
# dropping off as teams become mismatched.
|
| 39 |
+
prob_draw = 0.28 * np.exp(-(modified_elo_diff ** 2) / (2 * 400**2))
|
| 40 |
+
|
| 41 |
+
# The remainder is split between home and away based on the expected score
|
| 42 |
+
remaining = 1.0 - prob_draw
|
| 43 |
+
prob_home = remaining * home_expected
|
| 44 |
+
prob_away = remaining * (1.0 - home_expected)
|
| 45 |
+
|
| 46 |
+
# Blend with odds implied probability if available
|
| 47 |
+
has_odds = (X['odds_implied_home_prob'] > 0) & (X['odds_implied_away_prob'] > 0)
|
| 48 |
+
|
| 49 |
+
# If odds exist, we do a 50/50 blend between our modified model and bookmaker odds
|
| 50 |
+
# Bookmakers don't explicitly give draw probability in the engineered features directly,
|
| 51 |
+
# but we can deduce it as 1 - odds_home - odds_away
|
| 52 |
+
odds_prob_home = X['odds_implied_home_prob']
|
| 53 |
+
odds_prob_away = X['odds_implied_away_prob']
|
| 54 |
+
odds_prob_draw = 1.0 - odds_prob_home - odds_prob_away
|
| 55 |
+
|
| 56 |
+
# Ensure we don't have negative probabilities due to floating point inaccuracies
|
| 57 |
+
odds_prob_draw = np.maximum(odds_prob_draw, 0.0)
|
| 58 |
+
|
| 59 |
+
final_prob_home = np.where(has_odds, 0.5 * prob_home + 0.5 * odds_prob_home, prob_home)
|
| 60 |
+
final_prob_away = np.where(has_odds, 0.5 * prob_away + 0.5 * odds_prob_away, prob_away)
|
| 61 |
+
final_prob_draw = np.where(has_odds, 0.5 * prob_draw + 0.5 * odds_prob_draw, prob_draw)
|
| 62 |
+
|
| 63 |
+
# Normalize just to be safe
|
| 64 |
+
total = final_prob_home + final_prob_away + final_prob_draw
|
| 65 |
+
final_prob_home /= total
|
| 66 |
+
final_prob_away /= total
|
| 67 |
+
final_prob_draw /= total
|
| 68 |
+
|
| 69 |
+
return np.column_stack([final_prob_away, final_prob_draw, final_prob_home])
|
| 70 |
+
|
| 71 |
+
def predict(self, X):
|
| 72 |
+
probs = self.predict_proba(X)
|
| 73 |
+
return np.argmax(probs, axis=1)
|
test_api.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
from api import app
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
client = TestClient(app)
|
| 6 |
+
|
| 7 |
+
def test_health():
|
| 8 |
+
response = client.get("/api/health")
|
| 9 |
+
assert response.status_code == 200
|
| 10 |
+
assert response.json() == {"status": "ok"}
|
| 11 |
+
|
| 12 |
+
def test_predict_upcoming():
|
| 13 |
+
response = client.get("/api/predict")
|
| 14 |
+
assert response.status_code == 200
|
| 15 |
+
|
| 16 |
+
data = response.json()
|
| 17 |
+
assert "predictions" in data
|
| 18 |
+
assert "skipped" in data
|
| 19 |
+
|
| 20 |
+
# We expect 12 valid scheduled matches right now based on the DB state
|
| 21 |
+
predictions = data["predictions"]
|
| 22 |
+
assert len(predictions) == 12, f"Expected 12 predictions, got {len(predictions)}"
|
| 23 |
+
|
| 24 |
+
# Check skipped count
|
| 25 |
+
assert data["predictions_count"] == 12
|
| 26 |
+
assert data["skipped_count"] >= 0
|
| 27 |
+
|
| 28 |
+
for p in predictions:
|
| 29 |
+
# Sum of probabilities should be ~1
|
| 30 |
+
total_prob = p["prob_home_win"] + p["prob_draw"] + p["prob_away_win"]
|
| 31 |
+
assert math.isclose(total_prob, 1.0, rel_tol=1e-5), f"Match {p['match_id']} prob sum is {total_prob}"
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
test_health()
|
| 35 |
+
test_predict_upcoming()
|
| 36 |
+
print("Smoke test passed: API returns 12 valid predictions summing to 1.")
|
train.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import joblib
|
| 3 |
+
|
| 4 |
+
from rule_based_model import RuleBasedModel
|
| 5 |
+
|
| 6 |
+
def train_baseline_model():
|
| 7 |
+
print("Initializing Rule-Based Baseline Model (avoiding time leakage from current matches)...")
|
| 8 |
+
clf = RuleBasedModel()
|
| 9 |
+
|
| 10 |
+
# 6. Save Model
|
| 11 |
+
os.makedirs('artifacts', exist_ok=True)
|
| 12 |
+
joblib.dump(clf, 'artifacts/rule_based_baseline.pkl')
|
| 13 |
+
print("Model saved to artifacts/rule_based_baseline.pkl")
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
train_baseline_model()
|