[{"data":1,"prerenderedAt":491},["ShallowReactive",2],{"project-sofascore-rating-prediction-en":3,"titles-en":490},{"project":4,"notes":88},{"id":5,"title":6,"body":7,"cover":69,"date":70,"demo":69,"description":13,"draft":71,"extension":72,"featured":73,"meta":74,"navigation":73,"order":75,"path":76,"repo":77,"seo":78,"status":79,"stem":80,"summary":81,"tags":82,"__hash__":87,"translated":73},"projects\u002Fprojects\u002Fsofascore-rating-prediction.md","Sofascore rating prediction",{"type":8,"value":9,"toc":62},"minimark",[10,14,19,22,45,49,52,56,59],[11,12,13],"p",{},"My undergraduate thesis at FATEC Sorocaba, supervised by Prof. Dr. Maria das Graças J. M. Tomazela. One question: can Random Forest models specialized by position (goalkeeper, defender, midfielder, forward) predict a player's Sofascore rating before the match with lower error than a single generic model?",[15,16,18],"h2",{"id":17},"pipeline","Pipeline",[11,20,21],{},"Three stages, each one reading only the previous stage's output from disk, so any of them can be rerun alone.",[23,24,25,33,39],"ul",{},[26,27,28,32],"li",{},[29,30,31],"strong",{},"Collection."," A polite crawler for Sofascore's internal API: rate limiting, retries with backoff, checkpoint\u002Fresume and Pydantic validation on every row. 11,977 player-match records from all 380 matches of the 2025 Brazilian Série A.",[26,34,35,38],{},[29,36,37],{},"Cleaning."," One function per step. The interesting part is that an empty cell can mean three different things, each with its own rule.",[26,40,41,44],{},[29,42,43],{},"Modelling."," Pre-match features only: home\u002Faway, opponent strength and the player's own history up to the match being predicted. Walk-forward validation over the last 80 matches, bootstrap confidence intervals over whole matches, permutation importance.",[15,46,48],{"id":47},"result","Result",[11,50,51],{},"Specialization didn't reduce the error. The generic model beat a position-average baseline by 6% (MAE 0.374 vs 0.398), and predicting goalkeepers turned out to be hopeless before kickoff. Most of a match rating is decided during the match.",[15,53,55],{"id":54},"notes","Notes",[11,57,58],{},"The story in order: the data traps, the first model that was too good to be true, and what came out once the model could only see the past.",[11,60,61],{},"Stack: Python, httpx, tenacity, Pydantic v2, pandas, scikit-learn, pytest.",{"title":63,"searchDepth":64,"depth":64,"links":65},"",2,[66,67,68],{"id":17,"depth":64,"text":18},{"id":47,"depth":64,"text":48},{"id":54,"depth":64,"text":55},null,"2026-08-27",false,"md",true,{},3,"\u002Fprojects\u002Fsofascore-rating-prediction","https:\u002F\u002Fgithub.com\u002Falmeidaota\u002Fsofascore-rating-prediction",{"title":6,"description":13},"active","projects\u002Fsofascore-rating-prediction","My undergraduate thesis. Can Random Forest models specialized by position predict a player's Sofascore rating before kickoff better than one generic model? The answer was no.",[83,84,85,86],"python","scraping","ml","random-forest","IcTcWXpYn5Kpmy9fqNl7cIFEbUs7Jrm8SMXO46pBHt8",[89,255,381],{"id":90,"title":91,"body":92,"date":244,"description":245,"draft":71,"extension":72,"kind":246,"meta":247,"navigation":73,"path":248,"pinned":71,"project":235,"seo":249,"stem":250,"summary":251,"tags":252,"__hash__":254,"translated":73},"notes\u002Fnotes\u002Fpre-match-ratings.md","Most of a match rating can't be predicted",{"type":8,"value":93,"toc":237},[94,105,188,192,200,204,207,211,214,218,221,225,228],[11,95,96,97,104],{},"The setup from ",[98,99,103],"a",{"href":100,"className":101},"\u002Fnotes\u002Fr2-too-good",[102],"wikilink","When R² is too good",": predict each player's Sofascore rating before kickoff, using only home\u002Faway, opponent strength and the player's own history. Walk-forward validation over the last 80 matches of the 2025 Brasileirão, 2,494 appearances.",[106,107,108,124],"table",{},[109,110,111],"thead",{},[112,113,114,118,121],"tr",{},[115,116,117],"th",{},"Model",[115,119,120],{},"MAE",[115,122,123],{},"R²",[125,126,127,139,150,161,172],"tbody",{},[112,128,129,133,136],{},[130,131,132],"td",{},"Player's last-5 average",[130,134,135],{},"0.412",[130,137,138],{},"-0.06",[112,140,141,144,147],{},[130,142,143],{},"Position average",[130,145,146],{},"0.398",[130,148,149],{},"0.01",[112,151,152,155,158],{},[130,153,154],{},"Player's season average",[130,156,157],{},"0.389",[130,159,160],{},"0.05",[112,162,163,166,169],{},[130,164,165],{},"Position specialists",[130,167,168],{},"0.375",[130,170,171],{},"0.10",[112,173,174,179,184],{},[130,175,176],{},[29,177,178],{},"Generic Random Forest",[130,180,181],{},[29,182,183],{},"0.374",[130,185,186],{},[29,187,171],{},[15,189,191],{"id":190},"specialists-didnt-help","Specialists didn't help",[11,193,194,195,199],{},"The generic model's MAE minus the specialists' MAE was -0.0014, with a 95% confidence interval of ",[196,197,198],"span",{},"-0.0036, 0.0008",". The interval comes from a bootstrap with 2,000 resamples, drawing whole matches instead of single rows, since players in the same game aren't independent. It includes zero, so it's a tie. The decision rule was set before seeing any result: on a tie, keep the generic model, because one model is simpler than four. Specialists didn't win any position.",[15,201,203],{"id":202},"recent-form-is-noise","Recent form is noise",[11,205,206],{},"The player's average over the last 5 games did worse than ignoring the player entirely and guessing the position average. The full season average did better than both. Over five games, a hot or cold streak is mostly noise. Permutation importance agrees: the generic model leaned on two features, home\u002Faway and the player's season-average rating. The histories of the other 61 stats contributed close to nothing.",[15,208,210],{"id":209},"goalkeepers","Goalkeepers",[11,212,213],{},"For goalkeepers, no model meaningfully beat the baseline (MAE 0.559 for the generic model and for the position average alike, R² slightly below zero). A keeper's rating is decided by saves and goals conceded in that match, and before kickoff there's no way to know whether the keeper will face 2 shots or 12.",[15,215,217],{"id":216},"predictions-hug-the-mean","Predictions hug the mean",[11,219,220],{},"Real ratings in the test set ran from 3.0 to 9.7, with a standard deviation of 0.54. The generic model's predictions ran from 6.63 to 7.41, with a standard deviation of 0.16. 108 appearances in the test set were rated 8.0 or higher. The model never got close to any of them. It nudges predictions up for home games and for players with a good season so far, but it can't see an unusually good or bad game coming.",[15,222,224],{"id":223},"the-answer","The answer",[11,226,227],{},"Specialization by position did not reduce the error. The generic model beat the position average by 6.0% and the player's own season average by 3.9%, and that second gap is real (its confidence interval doesn't include zero). Small numbers, but that's the honest size of the signal: most of a match rating is decided during the match.",[11,229,230,231,236],{},"Code and full method: ",[98,232,235],{"href":77,"rel":233},[234],"nofollow","sofascore-rating-prediction",".",{"title":63,"searchDepth":64,"depth":64,"links":238},[239,240,241,242,243],{"id":190,"depth":64,"text":191},{"id":202,"depth":64,"text":203},{"id":209,"depth":64,"text":210},{"id":216,"depth":64,"text":217},{"id":223,"depth":64,"text":224},"2026-10-22","The setup from When R² is too good: predict each player's Sofascore rating before kickoff, using only home\u002Faway, opponent strength and the player's own history. Walk-forward validation over the last 80 matches of the 2025 Brasileirão, 2,494 appearances.","article",{},"\u002Fnotes\u002Fpre-match-ratings",{"title":91,"description":245},"notes\u002Fpre-match-ratings","Position specialists didn't beat a single generic model, recent form was worse than no information at all, and nobody can predict a goalkeeper. The thesis answer was no.",[85,86,253],"football","lRXhmendunPnntPdV0Kyh7MYFU2L_Af49JqWx0-YMFc",{"id":256,"title":103,"body":257,"date":244,"description":261,"draft":71,"extension":72,"kind":246,"meta":375,"navigation":73,"path":100,"pinned":71,"project":235,"seo":376,"stem":377,"summary":378,"tags":379,"__hash__":380,"translated":73},"notes\u002Fnotes\u002Fr2-too-good.md",{"type":8,"value":258,"toc":371},[259,262,270,314,317,321,324,327,330,334,337,348,361,364],[11,260,261],{},"The thesis question: can Random Forest models specialized by position (goalkeeper, defender, midfielder, forward) predict a player's Sofascore rating better than one generic model?",[11,263,264,265,269],{},"The first version fed each model the player's stats from the match itself: shots, passes, duels, tackles, touches, 63 columns in total. The validation was clean. Train on rounds 1 to 30, test on 31 to 38, tune hyperparameters with ",[266,267,268],"code",{},"GroupKFold"," grouped by match, so players from the same game never end up on both sides of a fold. A baseline that just predicts the position's average rating set the floor.",[106,271,272,282],{},[109,273,274],{},[112,275,276,278,280],{},[115,277,117],{},[115,279,120],{},[115,281,123],{},[125,283,284,293,304],{},[112,285,286,288,291],{},[130,287,143],{},[130,289,290],{},"0.391",[130,292],{},[112,294,295,298,301],{},[130,296,297],{},"Generic",[130,299,300],{},"0.203",[130,302,303],{},"0.72",[112,305,306,309,312],{},[130,307,308],{},"Specialists",[130,310,311],{},"0.207",[130,313,303],{},[11,315,316],{},"Forwards reached R² 0.80. Cutting the baseline error in half reads great in a thesis. It also didn't add up: one match's worth of stats shouldn't explain that much of anything in football.",[15,318,320],{"id":319},"not-leakage-circularity","Not leakage. Circularity.",[11,322,323],{},"The first suspect was train\u002Ftest leakage. There wasn't any. The split and the folds kept every match whole, and no test row touched training.",[11,325,326],{},"The problem was the target. The Sofascore rating comes out of a proprietary formula fed by stats that look a lot like those 63 features: goals, passes, duels, tackles. The Random Forest wasn't learning what a good performance is. It was approximating the formula that produced the number. The high R² measured how well the model imitates Sofascore's algorithm, and nothing else.",[11,328,329],{},"That experiment still answered a narrower question (with the match stats in hand, specialists only won for goalkeepers, the most statistically distinct position). But it couldn't answer the one that matters: can you know a rating before the game?",[15,331,333],{"id":332},"the-redesign","The redesign",[11,335,336],{},"The second experiment bans every stat from the match being predicted. Each feature has to exist before kickoff:",[23,338,339,342,345],{},[26,340,341],{},"home or away;",[26,343,344],{},"opponent strength: goals scored and conceded by the opponent in earlier matches;",[26,346,347],{},"the player's own history: season averages of 61 stats, plus season and last-5 averages of rating and minutes, all computed only from matches strictly before this one.",[11,349,350,351,356,357,360],{},"\"Strictly before\" is why the round ordering in ",[98,352,355],{"className":353,"href":354},[102],"\u002Fnotes\u002Fsofascore-data-traps","A season of Sofascore data, and where it lies"," stopped being a detail. Players with no history yet (14.9% of them play 3 games or fewer all season) get ",[266,358,359],{},"NaN",", which scikit-learn's Random Forest handles natively since 1.4. A count of prior matches goes in alongside, so the model can learn to trust 20 games of history more than 1.",[11,362,363],{},"Validation moved to walk-forward: train on everything before a batch of 10 matches, predict that batch, add it to the training set, move on, across the last 80 matches of the season. Hyperparameter search uses time-ordered folds inside the training window, always validating on matches that came after the training ones.",[11,365,366,367,370],{},"R² dropped to 0.10. That's the expected result, not a regression. Explaining a rating that already happened, from stats close to its own formula, is one task. Predicting it before the game is a different and much harder one. ",[98,368,91],{"className":369,"href":248},[102]," covers what came out.",{"title":63,"searchDepth":64,"depth":64,"links":372},[373,374],{"id":319,"depth":64,"text":320},{"id":332,"depth":64,"text":333},{},{"title":103,"description":261},"notes\u002Fr2-too-good","The first model explained 80% of the variance in forwards' ratings. That was the problem: it was rebuilding Sofascore's formula, not predicting football.",[85,86,83],"CP8qnPZk8uwlUZW8_GuAtolxx3ul1AWgwUEW6IlLme8",{"id":382,"title":355,"body":383,"date":244,"description":387,"draft":71,"extension":72,"kind":246,"meta":483,"navigation":73,"path":354,"pinned":71,"project":235,"seo":484,"stem":485,"summary":486,"tags":487,"__hash__":489,"translated":73},"notes\u002Fnotes\u002Fsofascore-data-traps.md",{"type":8,"value":384,"toc":478},[385,388,391,395,402,406,409,441,444,454,458,461,471],[11,386,387],{},"My thesis needed every player rating from the 2025 Brazilian Série A. Sofascore has them, behind the same internal API its website uses. It isn't an official public API, so the crawler was built to be boring: one request every 1.2 to 2.4 seconds, retries with backoff, a checkpoint after every round so a crash resumes instead of starting over, and Pydantic validation on every row before it reaches the CSV.",[11,389,390],{},"That pulled 11,977 player-match records out of all 380 matches, played between March 29 and December 7, 2025. Collecting was the easy part. The data had three traps in it, and none of them raised an error.",[15,392,394],{"id":393},"dates-in-the-wrong-timezone","Dates in the wrong timezone",[11,396,397,398,401],{},"The crawler stored match dates in UTC. Brazilian night games kick off at 21:00 or later, which is already the next day in UTC, so 67 of the 380 matches had the wrong date. The fix was recomputing the date from the kickoff timestamp in ",[266,399,400],{},"America\u002FSao_Paulo",", both in the cleaning step and in the crawler itself.",[15,403,405],{"id":404},"three-kinds-of-missing","Three kinds of missing",[11,407,408],{},"An empty cell meant three different things, and each one got its own rule:",[23,410,411,421,427],{},[26,412,413,416,417,420],{},[29,414,415],{},"Doesn't apply."," Saves for a striker. Those keys never appear for outfield players, so they get ",[266,418,419],{},"-1",", a value no real count can take.",[26,422,423,426],{},[29,424,425],{},"Not measured."," Physical stats (distance, sprints, top speed) exist in only 349 of the 380 matches, ball carries in only 83. These stay missing. Filling them with zero would claim a player didn't run.",[26,428,429,432,433,436,437,440],{},[29,430,431],{},"Zero, omitted."," Sofascore doesn't send stats that are 0. In the raw data, ",[266,434,435],{},"goals"," was empty in 11,124 rows and ",[266,438,439],{},"expected_goals"," in 6,314, exactly the rows where the player took no shot. These become 0.",[11,442,443],{},"Inside the physical and carries groups the rule is per player: if a player has any metric of the group, the missing ones are omitted zeros. If they have none, the group wasn't measured for them.",[11,445,446,447,450,451,453],{},"One edge case: ",[266,448,449],{},"goals_prevented"," can be negative for goalkeepers (from -3.79 to 3.12), so the ",[266,452,419],{}," sentinel collides with real values there. The position column breaks the tie.",[15,455,457],{"id":456},"rounds-out-of-order","Rounds out of order",[11,459,460],{},"The obvious way to sort a season is by round number. It's wrong. Rounds get rescheduled around continental competitions, and most of round 34 was only played in November, after rounds 35 to 37 had been played in October. Across the season, 1,795 of the 72,010 possible pairs of matches (2.49%) have round numbers that contradict their kickoff times.",[11,462,463,464,467,468,236],{},"That stops being trivia as soon as you compute a player's history. Sorting by round instead of kickoff changes the order of matches for 335 of the 686 players (48.8%) with two or more games. For a model that is only allowed to look at past matches, that is leakage: a future game sneaking into the history of a past one. Everything downstream sorts by ",[266,465,466],{},"kickoff_timestamp",", never by ",[266,469,470],{},"round",[11,472,473,474,477],{},"After cleaning, 163 appearances without a rating were dropped, all of them 7 minutes long or shorter. That left 11,814 rows from 732 players, which is what the models in ",[98,475,103],{"className":476,"href":100},[102]," were trained on.",{"title":63,"searchDepth":64,"depth":64,"links":479},[480,481,482],{"id":393,"depth":64,"text":394},{"id":404,"depth":64,"text":405},{"id":456,"depth":64,"text":457},{},{"title":355,"description":387},"notes\u002Fsofascore-data-traps","Scraping every player rating from the 2025 Brasileirão, and the three traps in the data: dates in the wrong timezone, zeros that never get sent, and rounds played out of order.",[83,84,488],"data","eXQcuImFjV98ThxZr0pIfb9o9ar0a4y7Y8iJLRS93iQ",{},1793409193508]