From 7f06bd560880a0cdd5cdab42b6e312b1c749c63c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 13 Jul 2026 01:07:32 +0300 Subject: [PATCH] fix(tradein/location-coef): re-center coefficient band so a strong central location ~= 1.0 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). --- .../backend/app/services/location_coef.py | 30 +++++++++- .../tests/services/test_location_coef.py | 55 ++++++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/tradein-mvp/backend/app/services/location_coef.py b/tradein-mvp/backend/app/services/location_coef.py index ad2d11bc..0f1f44c5 100644 --- a/tradein-mvp/backend/app/services/location_coef.py +++ b/tradein-mvp/backend/app/services/location_coef.py @@ -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 — ранжирующая метрика, не ценовой множитель). diff --git a/tradein-mvp/backend/tests/services/test_location_coef.py b/tradein-mvp/backend/tests/services/test_location_coef.py index c97fd4e9..db29025a 100644 --- a/tradein-mvp/backend/tests/services/test_location_coef.py +++ b/tradein-mvp/backend/tests/services/test_location_coef.py @@ -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 = [