Prédire les résultats au foot
Prédiction entre Montpellier et AuxerrePrésentation du projet
L’idée m’est venue après un premier projet de prédiction des élections : pourquoi ne pas appliquer les mêmes techniques au football ? Sport que je suis de près, le foot présente une richesse de données (tactiques, joueurs, forme récente, historique des confrontations) qui en fait un terrain d’expérimentation idéal pour le Machine Learning.
L’objectif initial : prédire le score d’un match retour à partir des résultats des matchs aller, avec un modèle fonctionnel déployé en production. Ce projet a évolué au fil du temps vers une architecture bien plus aboutie, intégrant progressivement des pratiques MLOps professionnelles.
V1 — Proof of Concept
Stratégie
Pour aller vite sur un premier résultat exploitable, j’ai volontairement simplifié le scope :
- Données d’entrée : uniquement les résultats des matchs passés (pas de composition d’équipe, pas de blessés)
- Contrainte budgétaire : utiliser une API football gratuite
- Objectif : valider que la prédiction de score est faisable avec peu de features
Collecte de données
J’ai utilisé football-data.org, qui propose un tier gratuit suffisant pour constituer un dataset exploitable sur plusieurs saisons de Ligue 1 et autres compétitions européennes.
Persistance des données
Pour stocker et rafraîchir les données automatiquement, j’ai développé une application Java Spring Boot qui interroge l’API quotidiennement et persiste les résultats dans une base MySQL.
public void updateMatchList() throws IOException {
MatchApi matchApi = new MatchApi();
LocalDate yesterday = LocalDate.now().minusDays(1);
LocalDate today = LocalDate.now();
List<Match> refreshedMatchList = matchApi.getMatchList(yesterday, today);
for (Match match : refreshedMatchList) {
Optional<Match> matchInBase = matchRepository.findObjbyLegacyid(match.getId());
Team homeTeam = getOrCreateTeam(match.getHomeTeam().getId(), match.getHomeTeam());
Team awayTeam = getOrCreateTeam(match.getAwayTeam().getId(), match.getAwayTeam());
match.setHomeTeam(homeTeam);
match.setAwayTeam(awayTeam);
LocalDate now = LocalDate.now();
matchInBase.ifPresentOrElse(matchinbase -> {
if (!"FINISHED".equals(matchinbase.getStatus())) {
match.setDate_fetched(now);
matchRepository.delete(matchinbase);
matchRepository.save(match);
}
}, () -> {
log.info("match pas en base %d", match.getId());
matchRepository.save(match);
});
}
}
Modélisation
Sur un notebook Python, j’entraîne un modèle de régression pour prédire séparément le nombre de buts à domicile et à l’extérieur.
Définition des features et targets :
from sklearn.model_selection import train_test_split
X = data_encoded.drop(['nbr_goal_full_away', 'nbr_goal_full_home'], axis=1)
y_away = data_encoded['nbr_goal_full_away']
y_home = data_encoded['nbr_goal_full_home']
X_train, X_test, y_away_train, y_away_test, y_home_train, y_home_test = train_test_split(
X, y_away, y_home, test_size=0.2, random_state=42
)
Optimisation des hyperparamètres avec RandomizedSearchCV :
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import RandomizedSearchCV
param_grid = {
'n_estimators': [50, 100, 200, 500],
'max_depth': [None, 10, 20, 30],
'min_samples_split': [2, 5, 10],
'min_samples_leaf': [1, 2, 4],
'max_features': ['auto', 'sqrt', 'log2']
}
rf = RandomForestRegressor(random_state=42)
rf_random_away = RandomizedSearchCV(rf, param_grid, n_iter=100, cv=3,
verbose=2, random_state=42, n_jobs=-1)
rf_random_home = RandomizedSearchCV(rf, param_grid, n_iter=100, cv=3,
verbose=2, random_state=42, n_jobs=-1)
rf_random_away.fit(X_train, y_away_train)
rf_random_home.fit(X_train, y_home_train)
J’ai opté pour la Random Forest après comparaison avec d’autres algorithmes (LinearRegression, GradientBoosting) — elle donnait les meilleurs résultats sur ce dataset avec peu de preprocessing.
Déploiement V1
Le modèle entraîné est sérialisé (joblib.dump) en fichier .pkl,
récupéré automatiquement via le système d’artefacts GitLab CI/CD,
puis copié sur un VPS. Une API consomme ce fichier pour exposer les prédictions,
et un frontend Vue.js permettait à l’utilisateur de taper deux équipes
et d’obtenir un score prédit.
L’application a été mise hors ligne suite à de nouveaux impératifs professionnels — mais ce premier cycle complet ingestion → entraînement → déploiement → exposition a validé le concept.
V2 — Refonte complète vers une architecture MLOps pro
La V1 avait des limites claires : modèle basique, peu de features, pas de monitoring, pas de versioning des données ni des modèles. La V2 est une réécriture complète, Python-first, pensée pour être industrialisable.
Objectifs de la refonte
- Stack 100% Python : suppression du service Java Spring, plus léger et plus cohérent avec l’écosystème data/ML
- Feature engineering enrichi : intégrer des données contextuelles (blessures, forme récente, confrontations directes)
- Pratiques MLOps réelles : versioning des données, tracking des expérimentations, automatisation complète
Nouvelle architecture
API Football
│
▼
Ingestion Python (scheduled)
│
▼
Base MySQL — données brutes
│
▼
Feature Engineering (calcul automatique)
│ ├── Forme récente (5 derniers matchs)
│ ├── Moyenne buts marqués / encaissés
│ ├── Confrontations directes historiques
│ └── Impact blessures (note joueur × importance)
▼
Dataset enrichi
│
▼
Entraînement + Tracking MLflow
│
▼
Modèle versionné (DVC)
│
▼
API FastAPI
│
▼
Frontend Vue.js
Feature Engineering avancé
Contrairement à la V1 qui se basait uniquement sur les scores bruts, la V2 calcule automatiquement un ensemble de features à partir des données historiques en base :
- Forme récente : moyenne de points sur les N derniers matchs pour chaque équipe
- Efficacité offensive/défensive : buts marqués et encaissés en moyenne sur la saison
- Historique des confrontations directes (head-to-head) : taux de victoire, moyenne de buts sur les duels passés entre les deux équipes
- Avantage domicile : modélisé comme feature à part entière car statistiquement significatif
Intégration des blessures
Les données de blessures sont ingérées en brut dans la base, puis le module de feature engineering les affecte automatiquement au match correspondant. Cela permet de conserver la donnée brute tout en produisant une feature agrégée exploitable par le modèle.
La prochaine étape est de scraper les notes des joueurs blessés (style Sofascore / WhoScored) afin de pondérer l’impact d’une blessure par l’importance du joueur dans son équipe — un défenseur central titulaire indisponible n’a pas le même poids qu’un remplaçant.
Analyse des comportements de parieurs (Polymarket)
Une piste exploratoire en cours : analyser les cotes et positions des meilleurs parieurs sur Polymarket pour en extraire un signal de marché. L’hypothèse est que les marchés de prédiction agrègent une information collective parfois plus précise que les features techniques seules — une forme de wisdom of the crowd quantifiable.
Vers une stack MLOps professionnelle
C’est la partie qui me tient le plus à cœur, car c’est exactement ce qu’on attend d’un MLOps Engineer en production.
Versioning des données et des modèles avec DVC
En V1, le modèle .pkl était un artefact GitLab non tracé. En V2, j’intègre
DVC (Data Version Control) pour :
- Versionner les datasets d’entraînement (couplé à Git)
- Reproduire n’importe quel run d’entraînement à partir d’un commit
- Stocker les artefacts sur un remote (S3 ou VPS)
dvc init
dvc add data/matches.csv
dvc run -n train \
-d data/matches.csv -d src/train.py \
-o models/rf_home.pkl -o models/rf_away.pkl \
python src/train.py
Tracking des expérimentations avec MLflow
Chaque run d’entraînement est loggué dans MLflow Tracking : hyperparamètres, métriques (MAE, RMSE), artefacts. Cela permet de comparer les expérimentations et de promouvoir le meilleur modèle en production de manière traçable.
import mlflow
import mlflow.sklearn
with mlflow.start_run():
mlflow.log_params(best_params)
mlflow.log_metric("mae_home", mae_home)
mlflow.log_metric("mae_away", mae_away)
mlflow.sklearn.log_model(rf_home, "rf_home")
mlflow.sklearn.log_model(rf_away, "rf_away")
CI/CD GitLab — pipeline d’entraînement automatisé
stages:
- data
- train
- evaluate
- deploy
fetch_data:
stage: data
script:
- python src/ingestion.py
only:
- schedules
train_model:
stage: train
script:
- python src/train.py
artifacts:
paths:
- models/
evaluate:
stage: evaluate
script:
- python src/evaluate.py
artifacts:
reports:
metrics:
- metrics.json
deploy:
stage: deploy
script:
- scp models/*.pkl user@vps:/app/models/
- ssh user@vps "systemctl restart football-api"
only:
- main
Monitoring du modèle en production
Un point souvent négligé mais critique en MLOps : détecter le drift. Le comportement des équipes évolue (mercato, changement d’entraîneur, blessures longue durée), ce qui dégrade progressivement les performances du modèle. Je prévois d’intégrer un monitoring basique :
- Data drift : comparer la distribution des features en production vs le dataset d’entraînement (avec
evidently) - Performance drift : comparer les prédictions aux résultats réels au fil des journées, et déclencher un réentraînement automatique si le MAE dépasse un seuil défini
Bilan et prochaines étapes
| Aspect | V1 | V2 (en cours) |
|---|---|---|
| Langage principal | Java + Python | Python uniquement |
| Features | Scores bruts | Forme, H2H, blessures, marché |
| Versioning modèle | Artefact GitLab non tracé | DVC + MLflow |
| Tracking expés | Aucun | MLflow |
| Monitoring prod | Aucun | Evidently (prévu) |
| Déploiement | Manuel | CI/CD automatisé |
Ce projet est pour moi un laboratoire concret pour pratiquer les patterns MLOps que je vise en production professionnelle : reproductibilité, observabilité, automatisation. La prochaine milestone est de déployer la V2 avec MLflow et DVC opérationnels, puis d’intégrer le monitoring de drift en production.
