import math from operator import attrgetter, itemgetter from django.db import migrations, models from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce from django.utils import timezone def tie_ranker(iterable, key=attrgetter("points")): rank = 0 delta = 1 last = None buf = [] for item in iterable: new = key(item) if new != last: for _ in buf: yield rank + (delta - 1) / 2.0 rank += delta delta = 0 buf = [] delta += 1 buf.append(item) last = key(item) for _ in buf: yield rank + (delta - 1) / 2.0 def rational_approximation(t): # Abramowitz and Stegun formula 26.2.23. # The absolute value of the error should be less than 4.5 e-4. c = [2.515517, 0.802853, 0.010328] d = [1.432788, 0.189269, 0.001308] numerator = (c[2] * t + c[1]) * t + c[0] denominator = ((d[2] * t + d[1]) * t + d[0]) * t + 1.0 return t - numerator / denominator def normal_CDF_inverse(p): assert 0.0 < p < 1 # See article above for explanation of this section. if p < 0.5: # F^-1(p) = - G^-1(p) return -rational_approximation(math.sqrt(-2.0 * math.log(p))) else: # F^-1(p) = G^-1(1-p) return rational_approximation(math.sqrt(-2.0 * math.log(1.0 - p))) def WP(RA, RB, VA, VB): return (math.erf((RB - RA) / math.sqrt(2 * (VA * VA + VB * VB))) + 1) / 2.0 def recalculate_ratings( old_rating, old_volatility, actual_rank, times_rated, is_disqualified ): # actual_rank: 1 is first place, N is last place # if there are ties, use the average of places (if places 2, 3, 4, 5 tie, use 3.5 for all of them) N = len(old_rating) new_rating = old_rating[:] new_volatility = old_volatility[:] if N <= 1: return new_rating, new_volatility ranking = list(range(N)) ranking.sort(key=old_rating.__getitem__, reverse=True) ave_rating = float(sum(old_rating)) / N sum1 = sum(i * i for i in old_volatility) / N sum2 = sum((i - ave_rating) ** 2 for i in old_rating) / (N - 1) CF = math.sqrt(sum1 + sum2) for i in range(N): ERank = 0.5 for j in range(N): ERank += WP( old_rating[i], old_rating[j], old_volatility[i], old_volatility[j] ) EPerf = -normal_CDF_inverse((ERank - 0.5) / N) APerf = -normal_CDF_inverse((actual_rank[i] - 0.5) / N) PerfAs = old_rating[i] + CF * (APerf - EPerf) Weight = 1.0 / (1 - (0.42 / (times_rated[i] + 1) + 0.18)) - 1.0 if old_rating[i] > 2500: Weight *= 0.8 elif old_rating[i] >= 2000: Weight *= 0.9 Cap = 150.0 + 1500.0 / (times_rated[i] + 2) new_rating[i] = (old_rating[i] + Weight * PerfAs) / (1.0 + Weight) if abs(old_rating[i] - new_rating[i]) > Cap: if old_rating[i] < new_rating[i]: new_rating[i] = old_rating[i] + Cap else: new_rating[i] = old_rating[i] - Cap if times_rated[i] == 0: new_volatility[i] = 385 else: new_volatility[i] = math.sqrt( ((new_rating[i] - old_rating[i]) ** 2) / Weight + (old_volatility[i] ** 2) / (Weight + 1) ) if is_disqualified[i]: # DQed users can manipulate TopCoder ratings to get higher volatility in order to increase their rating # later on, prohibit this by ensuring their volatility never increases in this situation new_volatility[i] = min(new_volatility[i], old_volatility[i]) # try to keep the sum of ratings constant adjust = float(sum(old_rating) - sum(new_rating)) / N new_rating = list(map(adjust.__add__, new_rating)) # inflate a little if we have to so people who placed first don't lose rating best_rank = min(actual_rank) for i in range(N): if ( abs(actual_rank[i] - best_rank) <= 1e-3 and new_rating[i] < old_rating[i] + 1 ): new_rating[i] = old_rating[i] + 1 return list(map(int, map(round, new_rating))), list( map(int, map(round, new_volatility)) ) def tc_rate_contest(contest, Rating, Profile): rating_subquery = Rating.objects.filter(user=OuterRef("user")) rating_sorted = rating_subquery.order_by("-contest__end_time") users = ( contest.users.order_by("is_disqualified", "-score", "cumtime", "tiebreaker") .annotate( submissions=Count("submission"), last_rating=Coalesce(Subquery(rating_sorted.values("rating")[:1]), 1200), volatility=Coalesce(Subquery(rating_sorted.values("volatility")[:1]), 535), times=Coalesce( Subquery( rating_subquery.order_by() .values("user_id") .annotate(count=Count("id")) .values("count") ), 0, ), ) .exclude(user_id__in=contest.rate_exclude.all()) .filter(virtual=0) .values( "id", "user_id", "score", "cumtime", "tiebreaker", "is_disqualified", "last_rating", "volatility", "times", ) ) if not contest.rate_all: users = users.filter(submissions__gt=0) if contest.rating_floor is not None: users = users.exclude(last_rating__lt=contest.rating_floor) if contest.rating_ceiling is not None: users = users.exclude(last_rating__gt=contest.rating_ceiling) users = list(users) participation_ids = list(map(itemgetter("id"), users)) user_ids = list(map(itemgetter("user_id"), users)) is_disqualified = list(map(itemgetter("is_disqualified"), users)) ranking = list(tie_ranker(users, key=itemgetter("score", "cumtime", "tiebreaker"))) old_rating = list(map(itemgetter("last_rating"), users)) old_volatility = list(map(itemgetter("volatility"), users)) times_ranked = list(map(itemgetter("times"), users)) rating, volatility = recalculate_ratings( old_rating, old_volatility, ranking, times_ranked, is_disqualified ) now = timezone.now() ratings = [ Rating( user_id=i, contest=contest, rating=r, volatility=v, last_rated=now, participation_id=p, rank=z, ) for i, p, r, v, z in zip( user_ids, participation_ids, rating, volatility, ranking ) ] Rating.objects.bulk_create(ratings) Profile.objects.filter( contest_history__contest=contest, contest_history__virtual=0 ).update( rating=Subquery( Rating.objects.filter(user=OuterRef("id")) .order_by("-contest__end_time") .values("rating")[:1] ) ) # inspired by rate_all_view def rate_tc(apps, schema_editor): Contest = apps.get_model("judge", "Contest") Rating = apps.get_model("judge", "Rating") Profile = apps.get_model("judge", "Profile") with schema_editor.connection.cursor() as cursor: cursor.execute("TRUNCATE TABLE `%s`" % Rating._meta.db_table) Profile.objects.update(rating=None) for contest in Contest.objects.filter( is_rated=True, end_time__lte=timezone.now() ).order_by("end_time"): tc_rate_contest(contest, Rating, Profile) # inspired by rate_all_view def rate_elo_mmr(apps, schema_editor): Rating = apps.get_model("judge", "Rating") Profile = apps.get_model("judge", "Profile") with schema_editor.connection.cursor() as cursor: cursor.execute("TRUNCATE TABLE `%s`" % Rating._meta.db_table) Profile.objects.update(rating=None) # Don't populate Rating class Migration(migrations.Migration): dependencies = [ ("judge", "0117_auto_20211209_0612"), ] operations = [ migrations.RunPython(migrations.RunPython.noop, rate_tc, atomic=True), migrations.AddField( model_name="rating", name="mean", field=models.FloatField(verbose_name="raw rating"), ), migrations.AddField( model_name="rating", name="performance", field=models.FloatField(verbose_name="contest performance"), ), migrations.RemoveField( model_name="rating", name="volatility", field=models.IntegerField(verbose_name="volatility"), ), migrations.RunPython(rate_elo_mmr, migrations.RunPython.noop, atomic=True), ]