NDOJ/judge/ratings.py

285 lines
8.5 KiB
Python
Raw Normal View History

2020-01-21 06:35:58 +00:00
from bisect import bisect
2021-12-09 05:52:52 +00:00
from math import pi, sqrt, tanh
2021-05-24 20:18:39 +00:00
from operator import attrgetter, itemgetter
2020-01-21 06:35:58 +00:00
2021-05-25 01:10:26 +00:00
from django.db import transaction
2021-05-24 20:18:39 +00:00
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
2020-01-21 06:35:58 +00:00
from django.utils import timezone
2021-05-24 20:18:39 +00:00
2022-05-14 17:57:27 +00:00
BETA2 = 328.33**2
RATING_INIT = 1200 # Newcomer's rating when applying the rating floor/ceiling
MEAN_INIT = 1400.0
2021-12-09 18:17:26 +00:00
VAR_INIT = 250**2 * (BETA2 / 212**2)
2021-12-09 05:52:52 +00:00
SD_INIT = sqrt(VAR_INIT)
VALID_RANGE = MEAN_INIT - 20 * SD_INIT, MEAN_INIT + 20 * SD_INIT
VAR_PER_CONTEST = 1219.047619 * (BETA2 / 212**2)
2022-05-14 17:57:27 +00:00
VAR_LIM = (
sqrt(VAR_PER_CONTEST**2 + 4 * BETA2 * VAR_PER_CONTEST) - VAR_PER_CONTEST
) / 2
2021-12-09 05:52:52 +00:00
SD_LIM = sqrt(VAR_LIM)
TANH_C = sqrt(3) / pi
2022-05-14 17:57:27 +00:00
def tie_ranker(iterable, key=attrgetter("points")):
2021-05-24 20:18:39 +00:00
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
2020-01-21 06:35:58 +00:00
2021-12-09 05:52:52 +00:00
def eval_tanhs(tanh_terms, x):
return sum((wt / sd) * tanh((x - mu) / (2 * sd)) for mu, sd, wt in tanh_terms)
2020-01-21 06:35:58 +00:00
2021-12-09 05:52:52 +00:00
def solve(tanh_terms, y_tg, lin_factor=0, bounds=VALID_RANGE):
L, R = bounds
Ly, Ry = None, None
while R - L > 2:
x = (L + R) / 2
y = lin_factor * x + eval_tanhs(tanh_terms, x)
if y > y_tg:
R, Ry = x, y
elif y < y_tg:
L, Ly = x, y
2020-01-21 06:35:58 +00:00
else:
2021-12-09 05:52:52 +00:00
return x
# Use linear interpolation to be slightly more accurate.
if Ly is None:
Ly = lin_factor * L + eval_tanhs(tanh_terms, L)
if y_tg <= Ly:
return L
if Ry is None:
Ry = lin_factor * R + eval_tanhs(tanh_terms, R)
if y_tg >= Ry:
return R
ratio = (y_tg - Ly) / (Ry - Ly)
return L * (1 - ratio) + R * ratio
def get_var(times_ranked, cache=[VAR_INIT]):
while times_ranked >= len(cache):
2022-05-14 17:57:27 +00:00
next_var = 1.0 / (1.0 / (cache[-1] + VAR_PER_CONTEST) + 1.0 / BETA2)
2021-12-09 05:52:52 +00:00
cache.append(next_var)
return cache[times_ranked]
def recalculate_ratings(ranking, old_mean, times_ranked, historical_p):
n = len(ranking)
2022-05-14 17:57:27 +00:00
new_p = [0.0] * n
new_mean = [0.0] * n
2021-12-09 05:52:52 +00:00
# Note: pre-multiply delta by TANH_C to improve efficiency.
delta = [TANH_C * sqrt(get_var(t) + VAR_PER_CONTEST + BETA2) for t in times_ranked]
p_tanh_terms = [(m, d, 1) for m, d in zip(old_mean, delta)]
# Calculate performance at index i.
def solve_idx(i, bounds=VALID_RANGE):
r = ranking[i]
y_tg = 0
for d, s in zip(delta, ranking):
2022-05-14 17:57:27 +00:00
if s > r: # s loses to r
y_tg += 1.0 / d
elif s < r: # s beats r
y_tg -= 1.0 / d
2021-12-09 05:52:52 +00:00
# Otherwise, this is a tie that counts as half a win, as per Elo-MMR.
new_p[i] = solve(p_tanh_terms, y_tg, bounds=bounds)
# Fill all indices between i and j, inclusive. Use the fact that new_p is non-increasing.
def divconq(i, j):
if j - i > 1:
k = (i + j) // 2
solve_idx(k, bounds=(new_p[j], new_p[i]))
divconq(i, k)
divconq(k, j)
if n < 2:
new_p = list(old_mean)
new_mean = list(old_mean)
else:
# Calculate performance.
solve_idx(0)
solve_idx(n - 1)
divconq(0, n - 1)
# Calculate mean.
for i, r in enumerate(ranking):
tanh_terms = []
2022-05-14 17:57:27 +00:00
w_prev = 1.0
w_sum = 0.0
2021-12-09 05:52:52 +00:00
for j, h in enumerate([new_p[i]] + historical_p[i]):
2022-05-14 17:57:27 +00:00
gamma2 = VAR_PER_CONTEST if j > 0 else 0
2021-12-09 05:52:52 +00:00
h_var = get_var(times_ranked[i] + 1 - j)
k = h_var / (h_var + gamma2)
w = w_prev * k**2
# Future optimization: If j is around 20, then w < 1e-3 and it is possible to break early.
tanh_terms.append((h, sqrt(BETA2) * TANH_C, w))
w_prev = w
w_sum += w / BETA2
2022-05-14 17:57:27 +00:00
w0 = 1.0 / get_var(times_ranked[i] + 1) - w_sum
2021-12-09 05:52:52 +00:00
p0 = eval_tanhs(tanh_terms[1:], old_mean[i]) / w0 + old_mean[i]
new_mean[i] = solve(tanh_terms, w0 * p0, lin_factor=w0)
# Display a slightly lower rating to incentivize participation.
# As times_ranked increases, new_rating converges to new_mean.
2022-05-14 17:57:27 +00:00
new_rating = [
max(1, round(m - (sqrt(get_var(t + 1)) - SD_LIM)))
for m, t in zip(new_mean, times_ranked)
]
2021-12-09 05:52:52 +00:00
return new_rating, new_mean, new_p
2020-01-21 06:35:58 +00:00
def rate_contest(contest):
from judge.models import Rating, Profile
2024-05-06 03:43:41 +00:00
from judge.models.profile import _get_basic_info
2020-01-21 06:35:58 +00:00
2022-05-14 17:57:27 +00:00
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]), RATING_INIT
),
last_mean=Coalesce(Subquery(rating_sorted.values("mean")[:1]), MEAN_INIT),
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",
"last_rating",
"last_mean",
"times",
)
)
2020-01-21 06:35:58 +00:00
if not contest.rate_all:
users = users.filter(submissions__gt=0)
if contest.rating_floor is not None:
2021-05-24 20:18:39 +00:00
users = users.exclude(last_rating__lt=contest.rating_floor)
2020-01-21 06:35:58 +00:00
if contest.rating_ceiling is not None:
2021-05-24 20:18:39 +00:00
users = users.exclude(last_rating__gt=contest.rating_ceiling)
users = list(users)
2022-05-14 17:57:27 +00:00
participation_ids = list(map(itemgetter("id"), users))
user_ids = list(map(itemgetter("user_id"), users))
ranking = list(tie_ranker(users, key=itemgetter("score", "cumtime", "tiebreaker")))
old_mean = list(map(itemgetter("last_mean"), users))
times_ranked = list(map(itemgetter("times"), users))
2021-12-09 05:52:52 +00:00
historical_p = [[] for _ in users]
user_id_to_idx = {uid: i for i, uid in enumerate(user_ids)}
2022-05-14 17:57:27 +00:00
for h in (
Rating.objects.filter(user_id__in=user_ids)
.order_by("-contest__end_time")
.values("user_id", "performance")
):
idx = user_id_to_idx[h["user_id"]]
historical_p[idx].append(h["performance"])
rating, mean, performance = recalculate_ratings(
ranking, old_mean, times_ranked, historical_p
)
2020-01-21 06:35:58 +00:00
now = timezone.now()
2022-05-14 17:57:27 +00:00
ratings = [
Rating(
user_id=i,
contest=contest,
rating=r,
mean=m,
performance=perf,
last_rated=now,
participation_id=pid,
rank=z,
)
for i, pid, r, m, perf, z in zip(
user_ids, participation_ids, rating, mean, performance, ranking
)
]
2020-01-21 06:35:58 +00:00
with transaction.atomic():
Rating.objects.bulk_create(ratings)
2021-12-09 05:52:52 +00:00
2022-05-14 17:57:27 +00:00
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]
)
)
2024-05-06 03:43:41 +00:00
_get_basic_info.dirty_multi([(uid,) for uid in user_ids])
2022-05-14 17:57:27 +00:00
RATING_LEVELS = [
"Newbie",
"Amateur",
"Expert",
"Candidate Master",
"Master",
"Grandmaster",
"Target",
]
2021-12-09 18:55:58 +00:00
RATING_VALUES = [1000, 1400, 1700, 1900, 2100, 2400, 3000]
2022-05-14 17:57:27 +00:00
RATING_CLASS = [
"rate-newbie",
"rate-amateur",
"rate-specialist",
"rate-expert",
"rate-candidate-master",
"rate-master",
"rate-grandmaster",
"rate-target",
]
2020-01-21 06:35:58 +00:00
def rating_level(rating):
return bisect(RATING_VALUES, rating)
def rating_name(rating):
return RATING_LEVELS[rating_level(rating)]
def rating_class(rating):
return RATING_CLASS[rating_level(rating)]
def rating_progress(rating):
level = bisect(RATING_VALUES, rating)
if level == len(RATING_VALUES):
return 1.0
prev = 0 if not level else RATING_VALUES[level - 1]
next = RATING_VALUES[level]
2022-05-14 17:57:27 +00:00
return (rating - prev + 0.0) / (next - prev)