add: Custom Command (4) Исправлен и испытан перерасчет рейтинга
This commit is contained in:
@@ -43,8 +43,10 @@ poetry run python hypn0/manage.py rescore --dry-run
|
|||||||
|
|
||||||
4. **Гравитационная модель скоринга (с логарифмом):**
|
4. **Гравитационная модель скоринга (с логарифмом):**
|
||||||
$$\text{Score} = \frac{\ln(1 + \max(0, \text{WeightedLikes})) + \text{Bonus}_{\text{level}}}{(\text{Age in hours} + 2.0)^\gamma}$$
|
$$\text{Score} = \frac{\ln(1 + \max(0, \text{WeightedLikes})) + \text{Bonus}_{\text{level}}}{(\text{Age in hours} + 2.0)^\gamma}$$
|
||||||
- `LEVEL_1_BONUS` = $+1.5$
|
- `CANDIDATE_BONUS` = $+1.0$ (Свежая генерация)
|
||||||
- `LEVEL_2_BONUS` = $+4.0$ (Одобрено Мозговым Слизнем)
|
- `LEVEL_1_BONUS` = $+1.5$ (Плеск бессознательного / прогрета)
|
||||||
|
- `LEVEL_2_BONUS` = $+3.0$ (Одобрено Мозговым Слизнем)
|
||||||
|
- `IMMORTAL_BONUS` = $+5.0$ (Глубокий транс / Золотой фонд)
|
||||||
|
|
||||||
5. **Инерционное сглаживание (EMA — Exponential Moving Average):**
|
5. **Инерционное сглаживание (EMA — Exponential Moving Average):**
|
||||||
- Чтобы исключить резкие скачки рейтинга от разовых всплесков, итоговый `f_score` сглаживается с предыдущим значением:
|
- Чтобы исключить резкие скачки рейтинга от разовых всплесков, итоговый `f_score` сглаживается с предыдущим значением:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class Command(BaseCommand):
|
|||||||
"--shame-claim-ratio",
|
"--shame-claim-ratio",
|
||||||
type=float,
|
type=float,
|
||||||
default=0.35,
|
default=0.35,
|
||||||
help="Доля жалоб среди свежих голосов для детекции шейминга (по умолчанию: 0.35 или 35%)",
|
help="Доля жалоб среди свежих голосов для детекции шейминга (по умолчанию: 0.35 или 35%%)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--warmup-votes-threshold",
|
"--warmup-votes-threshold",
|
||||||
@@ -92,9 +92,11 @@ class Command(BaseCommand):
|
|||||||
MIN_LIKES_FOR_SPIKE = 20 # Минимальное число лайков за окно, необходимое для признания относительного спайка
|
MIN_LIKES_FOR_SPIKE = 20 # Минимальное число лайков за окно, необходимое для признания относительного спайка
|
||||||
COLD_START_SPIKE_LIKES = 50 # Абсолютное число лайков за окно для детекции спайка в режиме "холодного старта"
|
COLD_START_SPIKE_LIKES = 50 # Абсолютное число лайков за окно для детекции спайка в режиме "холодного старта"
|
||||||
|
|
||||||
# 3. Бонусы к рейтингу за модераторский статус (веса числителя формулы)
|
# 3. Бонусы к рейтингу за статус (веса числителя формулы)
|
||||||
|
CANDIDATE_BONUS = 1.0 # Базовый бонус картине со статусом CANDIDATE (свежая генерация)
|
||||||
LEVEL_1_BONUS = 1.5 # Бонус картине со статусом LEVEL_1 (прогрета / замечена модератором)
|
LEVEL_1_BONUS = 1.5 # Бонус картине со статусом LEVEL_1 (прогрета / замечена модератором)
|
||||||
LEVEL_2_BONUS = 4.0 # Бонус картине со статусом LEVEL_2 (одобрено Мозговым Слизнем / шедевр)
|
LEVEL_2_BONUS = 3.0 # Бонус картине со статусом LEVEL_2 (одобрено Мозговым Слизнем / шедевр)
|
||||||
|
IMMORTAL_BONUS = 5.0 # Бонус картине со статусом IMMORTAL (золотой фонд / глубокий транс)
|
||||||
|
|
||||||
# 4. Коэффициент сглаживания инерции (EMA — Exponential Moving Average)
|
# 4. Коэффициент сглаживания инерции (EMA — Exponential Moving Average)
|
||||||
EMA_ALPHA = 0.7 # Доля нового значения в итоговом рейтинге (0.7 = 70% новый расчет + 30% старый f_score)
|
EMA_ALPHA = 0.7 # Доля нового значения в итоговом рейтинге (0.7 = 70% новый расчет + 30% старый f_score)
|
||||||
@@ -145,9 +147,9 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# -----------------------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------------------
|
||||||
# ШАГ 2: Выборка картин для пересчета
|
# ШАГ 2: Выборка картин для пересчета
|
||||||
# Бессмертные картины (Level.IMMORTAL) не требуют скоринга для очистки, но могут участвовать
|
# Включает все картины платформы, включая бессмертные (Level.IMMORTAL)
|
||||||
# -----------------------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------------------
|
||||||
items = TbHypn0Item.objects.exclude(i_level=TbHypn0Item.Level.IMMORTAL).prefetch_related("votes")
|
items = TbHypn0Item.objects.all().prefetch_related("votes")
|
||||||
total_items = items.count()
|
total_items = items.count()
|
||||||
|
|
||||||
if total_items == 0:
|
if total_items == 0:
|
||||||
@@ -177,9 +179,11 @@ class Command(BaseCommand):
|
|||||||
recent_claims_24h += 1
|
recent_claims_24h += 1
|
||||||
|
|
||||||
# 2. Детекция аномалий (шейминг vs спайк накрутки)
|
# 2. Детекция аномалий (шейминг vs спайк накрутки)
|
||||||
|
# Для картин со статусом IMMORTAL уровень зафиксирован и защищен от автосмены
|
||||||
new_level = item.i_level
|
new_level = item.i_level
|
||||||
recent_total_24h = recent_likes_24h + recent_claims_24h
|
recent_total_24h = recent_likes_24h + recent_claims_24h
|
||||||
|
|
||||||
|
if item.i_level != TbHypn0Item.Level.IMMORTAL:
|
||||||
# Проверка аномалий активируется только при наличии минимальной выборки (>= MIN_VOTES_FOR_ANOMALY)
|
# Проверка аномалий активируется только при наличии минимальной выборки (>= MIN_VOTES_FOR_ANOMALY)
|
||||||
if recent_total_24h >= MIN_VOTES_FOR_ANOMALY:
|
if recent_total_24h >= MIN_VOTES_FOR_ANOMALY:
|
||||||
# Проверка на шейминг (резкий наплыв жалоб)
|
# Проверка на шейминг (резкий наплыв жалоб)
|
||||||
@@ -215,12 +219,17 @@ class Command(BaseCommand):
|
|||||||
# 3. Расчет нового f_score (Гравитационная модель + логарифм)
|
# 3. Расчет нового f_score (Гравитационная модель + логарифм)
|
||||||
item_age_hours = max(0.0, (now - item.d_created_at).total_seconds() / 3600.0)
|
item_age_hours = max(0.0, (now - item.d_created_at).total_seconds() / 3600.0)
|
||||||
|
|
||||||
# Бонус от модераторского уровня
|
# Бонус от уровня картины
|
||||||
level_bonus = 0.0
|
if item.i_level == TbHypn0Item.Level.CANDIDATE:
|
||||||
if item.i_level == TbHypn0Item.Level.LEVEL_1:
|
level_bonus = CANDIDATE_BONUS
|
||||||
|
elif item.i_level == TbHypn0Item.Level.LEVEL_1:
|
||||||
level_bonus = LEVEL_1_BONUS
|
level_bonus = LEVEL_1_BONUS
|
||||||
elif item.i_level == TbHypn0Item.Level.LEVEL_2:
|
elif item.i_level == TbHypn0Item.Level.LEVEL_2:
|
||||||
level_bonus = LEVEL_2_BONUS
|
level_bonus = LEVEL_2_BONUS
|
||||||
|
elif item.i_level == TbHypn0Item.Level.IMMORTAL:
|
||||||
|
level_bonus = IMMORTAL_BONUS
|
||||||
|
else:
|
||||||
|
level_bonus = 0.0
|
||||||
|
|
||||||
if weighted_likes <= 0:
|
if weighted_likes <= 0:
|
||||||
raw_score = weighted_likes # Отрицательный или нулевой скор
|
raw_score = weighted_likes # Отрицательный или нулевой скор
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.core.management import call_command
|
||||||
from django.test import Client, TestCase, override_settings
|
from django.test import Client, TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -1012,3 +1013,48 @@ class GalleryArchiveTests(BaseMediaTestCase):
|
|||||||
self.assertEqual(len(resp_p2.context["page_obj"]), 2)
|
self.assertEqual(len(resp_p2.context["page_obj"]), 2)
|
||||||
self.assertContains(resp_p2, "Фаза 2 из 2")
|
self.assertContains(resp_p2, "Фаза 2 из 2")
|
||||||
self.assertContains(resp_p2, "floor=all&sort=likes&page=1")
|
self.assertContains(resp_p2, "floor=all&sort=likes&page=1")
|
||||||
|
|
||||||
|
|
||||||
|
class RescoreCommandTests(BaseMediaTestCase):
|
||||||
|
"""Тестирование management-команды rescore."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.vid = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
|
||||||
|
svg_bytes = b'<svg><circle/></svg>'
|
||||||
|
self.item_immortal = TbHypn0Item(
|
||||||
|
s_title="Бессмертная картина",
|
||||||
|
file_svg=ContentFile(svg_bytes, name="immortal.svg"),
|
||||||
|
i_level=TbHypn0Item.Level.IMMORTAL,
|
||||||
|
f_score=0.0,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
self.item_immortal.save(visitor_uuid_or_fp=self.vid)
|
||||||
|
|
||||||
|
self.item_candidate = TbHypn0Item(
|
||||||
|
s_title="Свежая картина",
|
||||||
|
file_svg=ContentFile(svg_bytes, name="cand.svg"),
|
||||||
|
i_level=TbHypn0Item.Level.CANDIDATE,
|
||||||
|
f_score=0.0,
|
||||||
|
is_public=True,
|
||||||
|
)
|
||||||
|
self.item_candidate.save(visitor_uuid_or_fp=self.vid)
|
||||||
|
|
||||||
|
def test_rescore_includes_immortal_and_applies_bonus(self):
|
||||||
|
# Добавляем голос
|
||||||
|
TbVote.objects.create(
|
||||||
|
k_item=self.item_immortal,
|
||||||
|
s_fingerprint="fp1",
|
||||||
|
i_direction=TbVote.Direction.LIKE,
|
||||||
|
)
|
||||||
|
out = io.StringIO()
|
||||||
|
call_command("rescore", stdout=out)
|
||||||
|
self.item_immortal.refresh_from_db()
|
||||||
|
self.item_candidate.refresh_from_db()
|
||||||
|
|
||||||
|
# Проверяем, что f_score рассчитан (> 0) и для бессмертной, и для кандидата
|
||||||
|
self.assertGreater(self.item_immortal.f_score, 0.0)
|
||||||
|
self.assertGreater(self.item_candidate.f_score, 0.0)
|
||||||
|
# У бессмертной картины бонус выше (5.0 против 1.0)
|
||||||
|
self.assertGreater(self.item_immortal.f_score, self.item_candidate.f_score)
|
||||||
|
# Статус бессмертной картины защищен и не должен измениться
|
||||||
|
self.assertEqual(self.item_immortal.i_level, TbHypn0Item.Level.IMMORTAL)
|
||||||
|
|||||||
Reference in New Issue
Block a user