fix(tradein/location-coef): re-center coefficient band so a strong central location ~= 1.0
All checks were successful
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 52s

The normalization used an unreachable all-POI-at-0m maximum, compressing real
scores into ~0.975-0.98 so every address read as "location reduces value".
Normalize against a realistic reference distance instead; strong central ~ 1.0,
poor locations stay below. Informational only (estimator does not use it).
Kept the uncalibrated-heuristic caveat. LOW audit R2 (#7a).
This commit is contained in:
bot-backend 2026-07-13 01:07:32 +03:00
parent 348be445e3
commit 7f06bd5608
2 changed files with 80 additions and 5 deletions

View file

@ -53,11 +53,35 @@ CATEGORY_WEIGHTS: dict[str, float] = {
DEFAULT_RADIUS_M = 1200
DEFAULT_TOP_N = 7
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0):
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100.
# Теоретический максимум суммы весов top-7 POI.
#
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
#
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
#
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
#
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
_REF_DISTANCE_M: float = 100.0
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
_REF_DISTANCE_M + 100.0
) # ≈ 0.1575 (было 0.315 при d=0)
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).

View file

@ -40,9 +40,21 @@ def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
def test_top7_weight_sum_matches_ptica() -> None:
"""Same category set as Site Finder → identical top-7 normalization constant (31.5)."""
"""Same category set as Site Finder → identical top-7 weight-sum constant (31.5)."""
assert lc._TOP7_WEIGHT_SUM == 31.5
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 1e-9
def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None:
"""Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max.
Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 the theoretical max with all
top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a
strong central address) into the bottom third of the range. The new normalization halves
the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic
scores instead.
"""
assert lc._REF_DISTANCE_M == 100.0
assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9
def test_score_to_coef_bounds() -> None:
@ -142,6 +154,45 @@ def test_compute_location_coef_weights_and_ranks_top_n() -> None:
assert 0.95 <= result.coef <= 1.05
def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None:
"""Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98.
Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy,
but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at
the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 coef ~0.978
(verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M
it should land close to 1.0 (within the documented ±0.01 calibration target).
"""
rows = [
{"name": "Остановка", "category": "bus_stop", "distance_m": 80.0},
{"name": "Аптека", "category": "pharmacy", "distance_m": 120.0},
{"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0},
{"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0},
{"name": "Школа №10", "category": "school", "distance_m": 350.0},
{"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0},
{"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0},
]
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
assert result.geo_source == "osm_poi_ekb"
assert len(result.factors) == 7
assert abs(result.coef - 1.0) <= 0.01
def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None:
"""A sparse/poor location (only far, low-weight POI) must still stay well below 1.0."""
rows = [
{"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0},
{"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0},
]
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
assert result.geo_source == "osm_poi_ekb"
assert result.coef < 0.98
def test_compute_location_coef_limits_to_top_n() -> None:
"""More than top_n candidates → only top_n factors surface in the response."""
rows = [