wihaha commited on
Commit
a3d10df
·
0 Parent(s):

First release to Hugging Face

Browse files
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()