Simulating Tennis Matches with Python or Moneyball for Tennis | by Osho Jha | Towards Data Science

Over the past few months, I’ve thought a lot about sports betting. Sure the regulatory changes help, but mostly it’s hard for me to look at a problem with so much widely available data and not attempt it.
I grew up both playing and watching tennis. When I started really getting into the sport, Pete Sampras’s had more or less retired, Agassi hadn’t quite had his renaissance but was still a competitor, and newer guys like Safin (fresh off a victory against Sampras in the US Open final) had established reputations as dangerous and talented players. Then came Roger Federer. He turned everything on its head — forget the world records, he was putting up some of the most amazing match stats I had ever seen. It was textbook play in terms of winners, winners to errors ratio, and first serve percentage, and break points converted. You didn’t need to see the stats though, you could watch more or less any of his matches and see a near perfect execution of any shot. Later on, when I stopped watching his matches due to the intense anxiety I had (especially when he was playing Rafa), I noticed that you could decipher a lot of how his match was going based on his first serve percentage. It’s still I metric I use to track his performance through tournaments. So anyways, this is some personal background for the simulation system.
Our Objective: We’re interested in predicting tennis matches. While there are several approaches to do this, we want to take a point-by-point approach. Assume that a player’s serve is iid. Let ps1 be the probability that player 1 will win a point on his serve and ps2 the probability be that player 2 will win a point on his serve. If one player wins a point, the other player has to lose a point so the probability of winning a return for player 1 is just pr1 = 1 − ps2. We can therefore forget about return probabilities. While you can work out the probability of winning a match with recursive equations and a bit of combinatorics, we want to simulate this instead. We will assume that all sets at 6 games each end in tiebreaks and we play best of 3 sets.
For added complexity, let’s assume that the probability of winning the point changes on ‘big points’. A big point is defined as a point that can win you a game or set (so includes set points in tiebreaks). So we add the probability ps1,B and ps2,B as the probability that a player will win his point on serve on a big point. Otherwise the probabilities remain ps1and ps2.
We’ll aim to format our scores in the following manner: 40–15 | 6:4 6:7 3:3, i.e. for games in progress and tiebreaks, list the player serving first. For sets (whether in progress or completed), list player 1 first, then player 2. This will help us track the game point-by-point and will make things a bit easier when debugging as any odd score combos will stand out and clue us to possible issues in our logic.
General Outline: For a quick approach, we’ll approach this problem by setting up a series of functions:
a function simulate_set, which tracks games in the set and determines whether or not the set is going to a tiebreaka function called player_server, which increments points for server and returner and keeps track of big pointsa function isBigPoint, which is more of a helper function that determines and an accompanying function getBigPointProbability, which also serves as a helper function and returns the new probabilities if a point is determined to be a big pointa function simulate_tiebreak, which plays tiebreakersa function getScore, which functions as a scoreboardtwo functions printSetMatchSummary and pointsMatchSummary, which print helpful updates such as “Player 1 wins set 1 6 games to 2” and “Player 1 wins the match 3 sets to 1”. These functions aren’t explicitly necessary but I think they offer a nice aesthetic touch
a function simulate_set, which tracks games in the set and determines whether or not the set is going to a tiebreak
a function called player_server, which increments points for server and returner and keeps track of big points
a function isBigPoint, which is more of a helper function that determines and an accompanying function getBigPointProbability, which also serves as a helper function and returns the new probabilities if a point is determined to be a big point
a function simulate_tiebreak, which plays tiebreakers
a function getScore, which functions as a scoreboard
two functions printSetMatchSummary and pointsMatchSummary, which print helpful updates such as “Player 1 wins set 1 6 games to 2” and “Player 1 wins the match 3 sets to 1”. These functions aren’t explicitly necessary but I think they offer a nice aesthetic touch
Build out the functions:
After completing a simulation or any code structure for that matter, going back and writing about it never captures the whole process — all the written and deleted lines, all the “yes!” moments of cracking logic that ultimately lead to “damn! back to the drawing board” run results. So what’s presented below is a full copy of the necessary functions:
Control flow:
The code above has a section which runs all the code. We consider default values for most of the important parameters such as player name, ps1 and ps2, and bigpoint1 and bigpoint2. I liked to think of ps1 and ps2 as first serve percentage but we can do a lot of interesting feature engineering to make the probabilities more insightful. If we wanted to run 10, 100, 1000, or more simulations of this match in order to understand confidence intervals, we can change our control code as follows:
#this control flow module runs 1000 simulations#and stores the winner of each simulation in winner=[]winner = []p1 = "A"p2 = "B"a = 0.64b = 0.62p1_big_point = 0.70p2_big_point = 0.68#run 1000 runs of the simulationfor ii in range(0, 1000):    completed_sets = []    S = 0    gamesMatch = 0    pointsMatch1, pointsMatch2 = 0, 0    setsMatch1, setsMatch2 = 0, 0    pointsTie1, pointsTie2 = 0, 0    pointsGame1, pointsGame2 = 0, 0        while S < 5 and max(setsMatch1, setsMatch2) < 3:        gamesSet1, gamesSet2, gamesMatch, S, pointsMatch1, pointsMatch2 = simulateSet(a, b, gamesMatch, S,                                                                                       pointsMatch1, pointsMatch2, completed_sets)        print()        if gamesSet1 == 6 and gamesSet2 == 6:            pointsTie1, pointsTie2, gamesMatch, pointsMatch1, pointsMatch2 = simulateTiebreaker(p1, p2, a, b, gamesMatch,                                                                                                 pointsMatch1, pointsMatch2, completed_sets)setsMatch1, setsMatch2 = printSetMatchSummary(p1, p2, gamesSet1, gamesSet2, S,                                                       pointsTie1, pointsTie2, setsMatch1, setsMatch2)if gamesSet1 == 6 and gamesSet2 == 6:            if pointsTie1 > pointsTie2:                completed_sets.append([gamesSet1+1, gamesSet2])            else:                completed_sets.append([gamesSet1, gamesSet2+1])        else:            completed_sets.append([gamesSet1, gamesSet2])pointsMatchSummary(p1, p2, setsMatch1, setsMatch2, pointsMatch1, pointsMatch2)
When our code is run the output looks as follows (just a small snippet). Note, at the end of a game the code displays the number of points won by player A vs. player B:
A 0-0|[0-0]A 15-0|[0-0]A 15-15|[0-0]A 30-15|[0-0]game pointA 40-15|[0-0]game pointA 40-30|[0-0]  A: 4, B: 2B 0-0|[0-1]B 0-15|[0-1]B 0-30|[0-1]B 15-30|[0-1]B 15-40|[0-1]B 30-40|[0-1]  B: 2, A: 4 -- A brokeA 0-0|[2-0]A 0-15|[2-0]A 15-15|[2-0]A 15-30|[2-0]A 15-40|[2-0]  A: 1, B: 4 -- B brokeB 0-0|[1-2]B 15-0|[1-2]B 30-0|[1-2]game pointB 40-0|[1-2]game pointB 40-15|[1-2]  B: 4, A: 1
How to use this for betting: Now, I’m not going to give away all my secrets, but tennis betting is a fairly good target for statistical methods. I haven’t used this yet, but instead of betting on full matches (or maybe in addition to) I would use this to bet on next point won. Given the output, we could parse many runs to see how often a player wins on 40–15 or 30–30.
Isn’t this a bit simplistic?
Yes! Most simulations are. In this case, our focus to get some skeleton of game flow going and we narrowed down a whole match into tracking ps1 and ps2.
a. For improvements on the simulation, I had a few thoughts that can be categorized as statistical/model improvements and coding/software engineering improvements:
Statistical:
Originally, we thought we could make a more accurate simulation by incorporating more data instead of just a singular probability for winning a server. However, after more thought, we believe we can still work with a singular probability. In order to set an appropriate starting probability we can take a player’s stats from completed matches, specifically the metrics of importance would be: first serve percent, first serve points won (%), second serve percent, second serve points won (%), break points converted (%), break points defended (%). We can average them to get a comprehensive metric or — continue with our serving focused simulation — and construct a metric that would look something like first serve percent/L2_norm(metrics_listed_above). We can run this for other metrics as the numerator and check against historical matches to see which metric has the most predictive power. The use of the L2 norm puts it in context of the other metrics. We should also implement big points such that break points for a server are recognized as a big point.
This brings up another point about our simulation. It’s focused around players’ points won on serve, but in a real match that fluctuates. We are using a fixed probability and even the best methods of computing that fixed probability don’t really encompass the fact that during a match there are ebbs-and-flows in performance. A more accurate way to model this problem is to use a dynamically calculated probability of winning on serve. A tennis match can be represented as discrete state spaces. This allows us to model a match with a Markov Chain as each point in a game is a state and there is a transition probability for entering the next state and that can be encoded into a transition matrix. As with most Markov models, the implementation would probably be a bit tedious, but it is worth pointing out that it’s do-able and probably a better way to utilize data collected on the metrics we listed above.
Stepping back from the idea of a simulation, if our goal is to forecast the winner, we suspect tennis would be a great domain for classification algorithms. We believe that a classification algorithm could appropriately utilize the rich set of features that can be attributed to players and matches. For example, the metrics of importance that were listed above are only a subset of all the statistics created and generated during a match. Likewise, each match has it’s own features, the simplest of which is weather conditions. We could align these values on a match level i.e. stats computed at the end of a match or even down to the point level in order to have substantially large data mass. Looking at historical matches for each player we could compute a probability for beating a certain type of opponent.
However, all this assumes that player A and player B are real players with match history and not just constructs for the sake of simulation
Engineering:
The code that was put together for this problem has a few separate functions and a main loop that controls the flow between those functions. While this is functional and can serve as production level code, we believe the solution can be better synthesized when approached in an object oriented way. Tennis lends itself to this kind of implementation as each player has distinct properties and matches, sets, and games all have shared properties. To start, we would want to create a Player class, which would hold attributes such as probability of winning a point, probability of winning a big point, sets won, and games won. Each player would be an object of this class. We could implement an addPoint function in this class so that each player object could track its own points. This would help us implement an event log type trigger where events such as tiebreaks or big points would occur if a Player object’s point count hits certain numbers.
Next we would want to create a class Match and classes Set and Game that would extend Match and Set, respectively. Similar to Player class there would be many getter and setter functions to track properties of each Match, Set, and Game. The most important of these would be getScore where Match, Set, and Game would have their own copy through inheritance and since this function would be within it’s own class, no function parameters would be needed. Another interesting advantage of inheritance would be a isTiebreak and playTiebreak function in both Set and Game. isTiebreak would just return a boolean if a game or set has entered a tiebreak and playTiebreak to carry through the logic. The reason this works is because in Tennis a game is more or less an atomic unit through which sets are made. So when two players have a score of 40–40 they are really playing a mini tiebreak except the score oscillates between Deuce (D) and Advantage (A) as opposed to numeric values as is done in a tiebreak at the end of a set. We could theoretically make Game the base class and Set would extend Game and Match would extend Set, but this hasn’t been fully thought out yet.
We believe the above is the best layout for this problem on a production level. This allows for greater flexibility going forward as we can easily add more attributes to each class. The code submitted, however, is a proof of concept. We can run simulations and ultimately understand whether or not there is value to running this regularly. Outside of the scope of this problem, if we were considering testing models for efficacy the function based script approach allows for a quick proof-of-concept. Once we have found validity in our model we can set up production level code in the object oriented way described above while still carrying over the core program flow logic (in this case, parameters for determining a tie break, a big point, scoring changes with respect to Deuce and Advantage) and optimizing our functions.
Conclusion:
As the complexity of the simulation expands, I think it will necessitate the move to the object oriented approach. If you’re not one for sports betting but are interested in coding, this could be a great opportunity to take a functional approach to a simulation and turn it into an object oriented program to better learn the tenets of polymorphism. If you are into sports betting, then get your hands on the publicly available match data and improve the statistical analysis. Tennis is a great sport for algorithmic betting because of the plethora of data and the nature of the sport i.e. 1 vs 1 instead of modeling a team. Have fun, and best of luck!