Yesterday, I experienced a power outage where I’m left with just a laptop at 50% battery with Ruby interpreter and a mobile phone with limited internet access.
With nothing else to do I tried to do CS50’s Lab 6: World Cup problem, since I have no Python experience at this time, I instead tried to do it in Ruby.
I connected my laptop to my mobile data just to load the problem’s page and to download the zip file.
In the extracted zip file we have the following files:
1
answers.txt 2018m.csv 2019w.csv tournament.py
Creating the Ruby file.
What I did:
- Created a file named
tournament.rb. - Copied the content
tournament.pytotournament.rb. - Commented out codes that Ruby can’t interpret.
- Rewrite functions to Ruby syntax.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# Simulate a sports tournament
# import csv
# import sys
# import random
# Number of simluations to run
N = 1000
def main()
# Ensure correct usage
# if len(sys.argv) != 2:
# sys.exit("Usage: python tournament.py FILENAME")
teams = []
# TODO: Read teams into memory from file
counts = {}
# TODO: Simulate N tournaments and keep track of win counts
# Print each team's chances of winning, according to simulation
# for team in sorted(counts, key=lambda team: counts[team], reverse=True):
# print(f"{team}: {counts[team] * 100 / N:.1f}% chance of winning")
end
def simulate_game(team1, team2)
"""Simulate a game. Return True if team1 wins, False otherwise."""
rating1 = team1["rating"]
rating2 = team2["rating"]
probability = 1 / (1 + 10 ** ((rating2 - rating1) / 600))
# return random.random() < probability
end
def simulate_round(teams)
"""Simulate a round. Return a list of winning teams."""
winners = []
# Simulate games for all pairs of teams
# for i in range(0, len(teams), 2):
# if simulate_game(teams[i], teams[i + 1]):
# winners.append(teams[i])
# else:
# winners.append(teams[i + 1])
return winners
end
def simulate_tournament(teams)
"""Simulate a tournament. Return name of winning team."""
# TODO
end
# if __name__ == "__main__":
# main()
The simulate_game function.
We need to rewrite some parts of this function to Ruby.
What I did:
- Added
.to_ito both team’s ratings, the CSV reader reads it as string, we need to convert it to integer. - Added
.to_fon divisors since Ruby will return an integer instead if we keep it as is when we are expecting a float. - Removed
returnkeyword since Ruby will always return the last line. - Replaced
random.random()with Ruby equivalentrandfunction.
1
2
3
4
5
6
7
def simulate_game(team1, team2)
"""Simulate a game. Return True if team1 wins, False otherwise."""
rating1 = team1['rating'].to_i
rating2 = team2['rating'].to_i
probability = 1 / (1 + 10 ** ((rating2 - rating1) / 600.to_f)).to_f
rand < probability
end
The simulate_round function.
What this function does is given an array of teams, it iterates through teams with a step of 2 then calls simulate_game function for the current team and the next team to get the winner between the 2 teams, the winner team then gets appended to the winners array and returned.
What I did:
Instead of iterating with a step of 2, I grouped the array of teams by twos using .each_slice(2).to_a method.
What it does:
1
2
[1, 2, 3, 4, 5, 6].each_slice(2).to_a
# => [[1, 2], [3, 4], [5, 6]]
Since we only have a pair, we can just use first and last to get the teams.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def simulate_round(teams)
"""Simulate a round. Return a list of winning teams."""
winners = []
teams.each_slice(2).to_a.each do |pair|
if simulate_game(pair.first, pair.last)
winners << pair.first
else
winners << pair.last
end
end
winners
end
Looking at the above code, we can even make it shorter as we don’t even need the winners variable that came from the template.
1
2
3
4
5
6
def simulate_round(teams)
"""Simulate a round. Return a list of winning teams."""
teams.each_slice(2).to_a.map do |pair|
simulate_game(pair.first, pair.last) ? pair.first : pair.last
end
end
Instead of creating a new winners array, we can just manipulate the grouped teams array.
The simulate_tournament function.
This is where the challenge starts as we have to write the code instead of just rewriting.
To successfully simulate a tournament we need to only have one winner. Remember that the simulate_round function already returns an array of winners, but we just need one.
So how do we do that? We can just repeatedly call simulate_round for winners only until there is only one team left.
1
2
3
4
5
6
7
8
9
10
11
12
def simulate_tournament(teams)
"""Simulate a tournament. Return name of winning team."""
winners = teams
loop do
winners = simulate_round(winners)
break if winners.length == 1
end
winners.first
end
I initially assigned teams to winners, I then simulated a round, where the winners gets updated with only the winning teams removing the others, I then checks if we only have 1 team left, if it does then it must be the winner of the tournament, else just keep simulating rounds until 1 team is left.
Since we only have 1 winner we can just return winners.first.
The imports.
Aside from the CSV library I don’t think we need anything else.
We replaced:
1
2
3
import csv
import sys
import random
With just:
1
require 'csv'
Calling the main function.
In Ruby, there really isn’t a main function. All codes written outside class .. end or module .. end are executed in a special main object.
Looking at this question: Should I define a main method in my ruby scripts?, I decided to follow one of the answers.
We simply replaced:
1
2
if __name__ == "__main__":
main()
With:
1
2
3
if __FILE__ == $PROGRAM_NAME
main
end
The main function.
Ensuring the correct usage.
We replaced:
1
2
3
# Ensure correct usage
if len(sys.argv) != 2:
sys.exit("Usage: python tournament.py FILENAME")
With:
1
2
3
4
if ARGV.first.nil?
puts "Usage: ruby tournament.rb FILENAME"
exit
end
In Python sys.argv is an array of strings separated by space that came from the command line arguments. In the case of python tournament.py FILENAME, sys.argv[0] has a value tournament.py, sys.argv[1] has a value FILENAME and so on.
While in Ruby ARGV is almost the same as Python’s but it does not include the current file. In the case of ruby tournament.rb FILENAME, ARGV[0] is FILENAME and not tournament.rb.
Both sys.exit and exit functions defaults in exiting with code 0.
Reading the teams into memory from file.
We can just do it one line in Ruby.
1
teams = CSV.foreach(ARGV.first, :headers => true).map(&:to_h)
The CSV library reads the CSV file from the file path given in the command line arguments. We added :headers => true so that it generates a key-value pair where the key came from the first row of the CSV, then we mapped all the rows to make it a hash.
The teams array should look like this:
1
[{"team"=>"Uruguay", "rating"=>"976"}, {"team"=>"Portugal", "rating"=>"1306"}, ...]
As you can see, rating here is a string that is why we added to_i in simulate_game.
Simulating the tournament N number of times.
1
counts = {}
The counts in Ruby is an instance of Hash where the default value of non-existent keys are nil.
Example:
1
2
counts['non_existent_key']
# => nil
But since we need to dynamically add keys and increment their values, we can’t have nil as the default value for non-existent keys as the following code will raise an error.
1
2
counts['team_name'] += 1
# => undefined method `+' for nil:NilClass (NoMethodError)
We can do the long method:
1
2
3
4
5
if counts['team_name'].nil?
counts['team_name'] = 1
else
counts['team_name'] += 1
end
But we can also do the better method:
1
2
3
4
5
counts = Hash.new(0)
# or
counts = {}
counts.default = 0
The default value is now 0 instead of nil. The previous code will now work as expected.
1
2
counts['team_name'] += 1
# => 1
Integers in Ruby has a times method where it creates a loop N number of times.
We know that the simulate_tournament we created returns a single winner of the tournament and we just have to run it N number of times and then increment their score in the counts object.
1
2
3
4
5
N.times do
winner = simulate_tournament teams
counts[winner['team']] += 1
end
Now that we have their scores after doing N number of simulations we just need to sort it where the teams with the most score first.
1
counts = Hash[counts.sort_by { |key, value| value }.reverse]
Printing each team’s chances of winning, according to simulation
1
2
3
counts.keys.each do |key|
puts "#{key}: #{counts[key] * 100 / N.to_f}% chance of winning"
end
The output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MacBook-Pro:world-cup davidangulo$ ruby tournament.rb 2018m.csv
Belgium: 21.6% chance of winning
Brazil: 18.9% chance of winning
Portugal: 15.3% chance of winning
Spain: 11.6% chance of winning
Switzerland: 10.2% chance of winning
Argentina: 7.9% chance of winning
France: 4.0% chance of winning
England: 3.3% chance of winning
Colombia: 2.2% chance of winning
Denmark: 1.9% chance of winning
Croatia: 1.2% chance of winning
Uruguay: 0.7% chance of winning
Sweden: 0.6% chance of winning
Mexico: 0.6% chance of winning
The full script.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# Simulate a sports tournament
require 'csv'
# Number of simluations to run
N = 1000
def main
# Ensure correct usage
if ARGV.first.nil?
puts "Usage: ruby tournament.rb FILENAME"
exit
end
teams = CSV.foreach(ARGV.first, :headers => true).map(&:to_h)
counts = Hash.new(0)
N.times do
winner = simulate_tournament(teams)
counts[winner['team']] += 1
end
counts = Hash[counts.sort_by { |key, value| value }.reverse]
# Print each team's chances of winning, according to simulation
counts.keys.each do |key|
puts "#{key}: #{counts[key] * 100 / N.to_f}% chance of winning"
end
end
def simulate_game(team1, team2)
"""Simulate a game. Return True if team1 wins, False otherwise."""
rating1 = team1['rating'].to_i
rating2 = team2['rating'].to_i
probability = 1 / (1 + 10 ** ((rating2 - rating1) / 600.to_f)).to_f
rand < probability
end
def simulate_round(teams)
"""Simulate a round. Return a list of winning teams."""
teams.each_slice(2).to_a.map do |pair|
simulate_game(pair.first, pair.last) ? pair.first : pair.last
end
end
def simulate_tournament(teams)
"""Simulate a tournament. Return name of winning team."""
winners = teams
loop do
winners = simulate_round(winners)
break if winners.length == 1
end
winners.first
end
if __FILE__ == $PROGRAM_NAME
main
end