codeforces-cli-0.1.0: src/Codeforces/Virtual/RatingCalculator.hs
--------------------------------------------------------------------------------
-- | Implementation of the Open Codeforces Rating System described in
-- <https://codeforces.com/blog/entry/20762 Mike Mirzayanov's blog post>.
--
module Codeforces.Virtual.RatingCalculator
( calculateContestResults
) where
import Codeforces.Types.Common
import Codeforces.Types.Party hiding ( Contestant )
import Codeforces.Types.Standings
import Codeforces.Virtual.Types
import Control.Monad
import Control.Monad.Trans.State
import Data.Functor ( (<&>) )
import Data.List
import qualified Data.Map as M
import Data.Maybe
import Data.Ord
--------------------------------------------------------------------------------
-- | 'calculateContestResults' @previousRatings updatedRankings@ computes the
-- contest results.
calculateContestResults
:: M.Map Handle Rating -> [RanklistRow] -> ContestResults
calculateContestResults hs rrs = ContestResults sortedCs deltas seeds
where
sortedCs = reassignRanks $ mkContestants hs rrs
(deltas, seeds) = process sortedCs
-- | Constructs a list of contestants from the previous ratings and rankings.
mkContestants :: M.Map Handle Rating -> [RanklistRow] -> [Contestant]
mkContestants prevRatings = map
(\RanklistRow {..} -> Contestant { contestantParty = rrParty
, contestantRank = rrRank
, contestantPoints = rrPoints
, contestantRating = getPartyRating rrParty
}
)
where
getPartyRating = computePartyRating . map lookupRating . partyMembers
lookupRating m = M.findWithDefault initRating (memberHandle m) prevRatings
--------------------------------------------------------------------------------
-- | Initial rating of a member, if they do not already have a rating.
initRating :: Rating
initRating = 0
-- | Calculates the overall rating for a party using the ratings of its team
-- members.
--
-- >>> computePartyRating [1400]
-- 1400
--
-- >>> computePartyRating [1400, 1500, 1600]
-- 1749
--
computePartyRating :: [Rating] -> Rating
computePartyRating ratings = go 20 100 4000
where
go :: Int -> Float -> Float -> Rating
go 0 l r = round $ (l + r) / 2
go i l r | computed > mid = go (i - 1) mid r
| otherwise = go (i - 1) l mid
where
mid = (l + r) / 2
rWinsProbability =
product $ map (getEloWinProbability mid . fromIntegral) ratings
computed = logBase 10 (1 / rWinsProbability - 1) * 400 + mid
--------------------------------------------------------------------------------
-- | Ratings mapped to seed (expected ranking)
type SeedCache = M.Map Rating Seed
-- | Computes each party's rating delta and each rating's seed, given a list of
-- contestants.
process :: [Contestant] -> (M.Map Party Delta, SeedCache)
process [] = (M.empty, M.empty)
process cs = flip runState (precomputeSeeds cs) $ do
ds <- calculateDeltas cs
pure . adjustTopDeltas cs . adjustAllDeltas $ ds
-- | Computes the seed of each contestant.
precomputeSeeds :: [Contestant] -> SeedCache
precomputeSeeds cs =
M.fromList $ map (\c -> (contestantRating c, calculateSeedOf c cs)) cs
-- | Adjusts rating deltas to ensure the total sum of deltas is not more than
-- zero. If it is, the extra amount is distributed between all contestants.
adjustAllDeltas :: M.Map Party Delta -> M.Map Party Delta
adjustAllDeltas ds = M.map (+ inc) ds
where inc = (negate (sum (M.elems ds)) `div` M.size ds) - 1
-- | Adjusts rating deltas to prevent ratings of top competitors becoming
-- inflated.
--
-- /Before/ the round, we choose a group of most highly rated competitors and
-- decide that their /total/ rating shouldn't change. The size of this group is
-- determined by the heuristic:
--
-- \[
-- s = \min(n, 4 \sqrt{n})
-- \]
--
-- The sum of deltas over this group is adjusted to make it 0:
--
-- \[
-- r_i = r_i - \frac{\sum^s d_i}{s}
-- \]
--
adjustTopDeltas :: [Contestant] -> M.Map Party Delta -> M.Map Party Delta
adjustTopDeltas cs ds = M.map (+ inc) ds
where
inc = min 0 $ max (-10) (negate sumTopDeltas `div` zeroSumCount)
sumTopDeltas = sum $ mapMaybe (flip M.lookup ds . contestantParty)
(take zeroSumCount $ sortByRatingDesc cs)
zeroSumCount = min (M.size ds) topCount
topCount = 4 * (round' . sqrt . fromIntegral . M.size) ds
round' = round :: Double -> Int
-- | Computes the rating delta for each party in this contest.
--
-- The input list of contestants must be correctly ordered with 'reassignRanks'
-- prior to using this function.
--
calculateDeltas :: [Contestant] -> State SeedCache (M.Map Party Delta)
calculateDeltas cs = do
deltas <- forM cs $ \c -> calculateDelta c cs <&> (contestantParty c, )
pure $ M.fromList deltas
-- | Sorts and recomputes the rank of each contestant.
--
-- In this assignment, contestants with the same points have the same rank.
-- Repeated ranks are disregarded when assigning ranks to contestants that
-- score lower than them. For example:
--
-- @
-- | Party | Points | Rank |
-- | ----- | ------ | ---- |
-- | A | 2302.0 | 41 |
-- | B | 2302.0 | 41 |
-- | C | 2302.0 | 41 |
-- | D | 2256.0 | 44 | <- rank is not 42, but 44
-- | ... | ... | ... |
-- @
--
-- Reassigning ranks is required because the input list of contestants is not
-- guaranteed to have correct ranks or be in the correct order following the
-- inclusion of the virtual user. E.g. the input list may resemble:
--
-- @
-- | Party | Points | Rank |
-- | ----- | ------ | ---- |
-- | ... | ... | ... |
-- | A | 2302.0 | 41 |
-- | VU* | 2300.0 | 42 |
-- | B | 2266.0 | 42 | <- ranks from here on are incorrect
-- | C | 2256.0 | 43 |
-- | ... | ... | ... |
-- @
--
-- *VU = virtual user
--
reassignRanks :: [Contestant] -> [Contestant]
reassignRanks = go 1 1 . sortByPointsDesc
where
go _ _ [] = []
go _ rank [c ] = [withRank rank c]
go i rank (c1 : c2 : cs) = withRank rank c1 : go (i + 1) nextRank (c2 : cs)
where
nextRank | contestantPoints c2 < contestantPoints c1 = i + 1
| otherwise = rank
withRank r c = c { contestantRank = r }
-- | 'calculateDelta' @c cs@ computes the rating delta for contestant @c@ using
-- a seed computed from all other contestants @cs@.
--
-- The rating change for a participant is the between the rating they require
-- (according to their seed) and their current rating:
--
-- \[
-- d_i = \frac{R - r_i}{2}
-- \]
--
calculateDelta :: Contestant -> [Contestant] -> State SeedCache Delta
calculateDelta c cs = do
mid <- midRank c cs
needRating <- calculateNeedRating cs mid
pure $ (needRating - contestantRating c) `div` 2
-- | The geometric mean of a contestant's seed (expected ranking) and actual
-- ranking.
--
-- This ranking is between the expected and actual ranking.
--
midRank :: Contestant -> [Contestant] -> State SeedCache Seed
midRank c cs = do
seed <- getSeedOf c cs
pure $ sqrt $ fromIntegral (contestantRank c) * seed
-- | Given a list of contestants and this contestant's 'midRank', calculates
-- the rating a contestant should have to achieve their expected ranking, using
-- binary search.
--
-- In other words, a rating:
--
-- \[
-- R : seed_i = m_i
-- \]
--
calculateNeedRating :: [Contestant] -> Float -> State SeedCache Rating
calculateNeedRating cs rank = go 1 8000
where
go l r
| r - l <= 1 = pure l
| otherwise = do
let mid = (l + r) `div` 2
seed <- getSeed mid cs
if seed < rank then go l mid else go mid r
--------------------------------------------------------------------------------
-- Seed calculations and lookups
-- | Looks up the seed for a given rating from the cache. If not found, computes
-- it and updates the cache.
getSeed :: Rating -> [Contestant] -> State SeedCache Seed
getSeed rating cs = do
cache <- get
case M.lookup rating cache of
Nothing -> do
let seed = calculateSeed rating cs
modify $ M.insert rating seed
pure seed
(Just seed) -> pure seed
-- | Like 'getSeed' but takes a contestant and list of /all/ contestants.
getSeedOf :: Contestant -> [Contestant] -> State SeedCache Seed
getSeedOf x ys = getSeed (contestantRating x) (filter (/= x) ys)
-- | Calculates the seed of a contestant with the given rating, using the
-- supplied list of all /other/ contestants.
--
-- \[
-- seed_i = \sum_{j=1, j \ne i}^{n} P_{j,i} + 1
-- \]
--
-- 1 is added to account for 1-based rankings.
--
-- The general idea is to increase the contestant's rating if their actual
-- ranking is better than their seed, and decrease if worse.
--
calculateSeed :: Rating -> [Contestant] -> Seed
calculateSeed rating others =
1 + sum [ getEloWinProbability' (contestantRating x) rating | x <- others ]
-- | Like 'calculateSeed' but takes a contestant and list of /all/ contestants.
calculateSeedOf :: Contestant -> [Contestant] -> Seed
calculateSeedOf x ys = calculateSeed (contestantRating x) (filter (/= x) ys)
-- | Computes the Elo win probability given two ratings.
--
-- This is the probability that the @x@th participant has a better result that
-- the @y@th participant, given by:
--
-- \[
-- P_{i,j} = \frac{1}{1 + 10^\frac{r_j - r_i}{400}}
-- \]
--
-- E.g. if the difference between ratings is 200 then the stronger participant
-- will win with probability ~0.75. If the difference is 400 then the stronger
-- participant will win with probability ~0.9.
--
-- __Note:__ reversing the order of rating arguments reverses the result.
--
-- >>> getEloWinProbability 1400 1200
-- 0.7597469
--
-- >>> getEloWinProbability 1200 1400
-- 0.24025308
--
getEloWinProbability :: Float -> Float -> Float
getEloWinProbability x y = 1 / (1 + 10 ** ((y - x) / 400))
-- | Like 'getEloWinProbability' but takes 'Int's instead of 'Float's.
getEloWinProbability' :: Rating -> Rating -> Float
getEloWinProbability' x = getEloWinProbability (fromIntegral x) . fromIntegral
--------------------------------------------------------------------------------
-- Utility functions
sortByPointsDesc :: [Contestant] -> [Contestant]
sortByPointsDesc = sortOn (Down . contestantPoints)
sortByRatingDesc :: [Contestant] -> [Contestant]
sortByRatingDesc = sortOn (Down . contestantRating)
--------------------------------------------------------------------------------