Home Doing CS50's Lab 6: World Cup problem in Ruby
Post
Cancel

Doing CS50's Lab 6: World Cup problem in Ruby

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.py to tournament.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_i to both team’s ratings, the CSV reader reads it as string, we need to convert it to integer.
  • Added .to_f on divisors since Ruby will return an integer instead if we keep it as is when we are expecting a float.
  • Removed return keyword since Ruby will always return the last line.
  • Replaced random.random() with Ruby equivalent rand function.
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
This post is licensed under CC BY 4.0 by the author.

Password protect your Sinatra app with basic HTTP authentication

-