Probabilistically pick a candidate
Concept


imho you don't need build prob_space or normalize by max rank == 20
you only need

John: 1 ... 13^2
Joe:  13^2+1 ... 13^2+8^2
Averell: 13^2+8^2+1 ... 13^2+8^2+5^2
William: 13^2+8^2+5^2 ... 13^2+8^2+5^2+2^2
and choice a man having ranint(1, 13^2+8^2+5^2+2^2)

transform list [13**2, 8**2, 5**2, ...] to [13**2, 13**2+8**2, 13**2+8**2+5**2, ...] we can by accumulate
searching in this sorted list [13**2, 13**2+8**2, 13**2+8**2+5**2, ...] we can by binary search algorithm implemented in bisect module
the algorithm:

from collections import namedtuple
from itertools import accumulate
from random import randint
from bisect import bisect_left

Candidate = namedtuple('Candidate', 'name rank')


def pick_random_candidate(candidates):
    assert (len(candidates) &gt; 0) # note: probably we want return None with empty list?
    squared_ranks_accumulated = list(accumulate(c.rank ** 2 for c in candidates))
    random_pick = randint(1, squared_ranks_accumulated[-1])
    return candidates[bisect_left(squared_ranks_accumulated, random_pick)]


if __name__ == "__main__":
    candidates = [Candidate(*t) for t in [('John', 13), ('Joe', 8), ('Averell', 5), ('William', 2)]]
    random_candidate = pick_random_candidate(candidates)
    print(random_candidate)



Implementation


better use from random import randint instead of import random
if you have randint(0, x - 1) you can replace it by randrange(x)
constants like 20 should be defined as variable, e.g. top_rank = 20 or even def pick_candidate(candidates, top_rank=20). Reason: if someone change 20 to 21 in next three months there are high probability that don't change comment (so better when comment explains top_rank not 20)
you can often define structure Candidate as namedtuple (dict is better only sometimes if have some dynamic fields to store)
what's with the empty list of candidates (exception KeyError is expected or something else should be?)