fix(mera/estimate): перефит хедоники поверх area-бакетного ratio — крупное жильё занижалось на 21% (#3255)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m1s
Deploy Trade-In / deploy (push) Successful in 1m34s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 12s

This commit is contained in:
lekss361 2026-08-29 20:19:27 +00:00
parent e523c8949c
commit 5da226a271
3 changed files with 126 additions and 81 deletions

View file

@ -431,10 +431,37 @@ class Settings(BaseSettings):
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
# OFF ⇒ точно старое поведение expected_sold.
estimate_hedonic_correction_enabled: bool = True
estimate_hedonic_b0: float = 0.6146 # fit log(sold/es) ~ year + ln(area), n=2366 (#2002)
estimate_hedonic_year_coef: float = 0.1220 # per (year-2000)/20
estimate_hedonic_larea_coef: float = -0.1603 # per ln(area_m2)
estimate_hedonic_first_floor_coef: float = -0.1248 # floor==1 ground-floor ≈ -12%; #2002 n=2366
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
#
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
# 2026-06-27 (#2002), когда asking→sold ratio ключевался ПО КОМНАТАМ. 2026-08-05
# (#2620) ratio переключили на area-бакеты — то есть под хедонику подставили ДРУГУЮ
# базу, а её саму не пересчитали. Хедоника по определению чинит ОСТАТОК
# log(actual_sold / expected_sold), поэтому её коэффициенты верны только для той
# базы, на которой фитились. Итог: площадь штрафовалась дважды, крупные лоты
# занижались на 21% (bias 4+ комнат = -21.4%).
#
# larea = 0.0 ВЫСТАВЛЕН НАМЕРЕННО, это не «не задан». После #2620 площадь несёт
# area-бакетный ratio, и его форма — перевёрнутая U (факт sold/ask по бакетам:
# 0.786 / 0.829 / 0.899 / 0.954 / 0.864), которую монотонный ln(area) выразить не
# может в принципе: он тянет крупное жильё вниз ровно там, где рынок его не
# дисконтирует. Член стал избыточным и вредным — обнуляем, оставляя код-путь.
#
# Замер вариантов на той же фикстуре (bias по комнатам, median):
# текущие MAPE 13.90 | студия +7.6 1к -0.5 2к -2.8 3к -5.3 4+ -21.4
# OLS все 4 члена MAPE 14.11 | студия -0.9 1к -1.6 2к -0.7 3к -4.0 4+ -14.1
# БЕЗ larea (тут) MAPE 14.28 | студия -3.1 1к -2.3 2к -0.4 3к -1.9 4+ -10.4
# хедоника OFF MAPE 15.88 | студия -1.5 1к +4.4 2к +4.2 3к -2.3 4+ -5.0
# Берём «без larea»: худший перекос вдвое меньше, все прочие классы в пределах
# ±3.1%, цена — +0.38 п.п. общего MAPE (хедоника сжимает разброс за счёт year).
#
# ОСТАТОК -10.4% по 4+ конфигом НЕ закрывается: ratio бакета 4 = 0.8211 при
# фактических sold/ask = 0.8640, т.е. занижен на ~5% ДО всякой хедоники. Это
# пересчёт самой таблицы (app/tasks/asking_to_sold_ratio.py), см. follow-up.
estimate_hedonic_b0: float = -0.0140 # #3248 перефит поверх area-бакетного ratio
estimate_hedonic_year_coef: float = 0.0769 # per (year-2000)/20
estimate_hedonic_larea_coef: float = 0.0 # НАМЕРЕННО 0 — площадь несёт ratio (#2620)
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
estimate_hedonic_factor_min: float = 0.75
estimate_hedonic_factor_max: float = 1.30
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────

View file

@ -1,20 +1,20 @@
{
"calibration": {
"high": {
"coverage_pct": 50.0,
"mape_pct": 27.87,
"coverage_pct": 75.0,
"mape_pct": 25.94,
"n": 8,
"n_covered": 4
"n_covered": 6
},
"low": {
"coverage_pct": 86.68,
"mape_pct": 13.76,
"coverage_pct": 87.41,
"mape_pct": 14.06,
"n": 1562,
"n_covered": 1067
"n_covered": 1076
},
"medium": {
"coverage_pct": 73.33,
"mape_pct": 17.91,
"mape_pct": 18.64,
"n": 30,
"n_covered": 22
}
@ -26,111 +26,111 @@
],
"expected_sold": {
"overall": {
"mape_pct": 13.9,
"median_bias_pct": -0.78,
"mape_pct": 14.28,
"median_bias_pct": -1.61,
"n": 1269,
"n_no_analogs": 0,
"p25_pct": -13.39,
"p75_pct": 14.46
"p25_pct": -14.08,
"p75_pct": 14.6
},
"per_area_bucket": {
"0 <30": {
"mape_pct": 14.55,
"median_bias_pct": 7.59,
"mape_pct": 16.51,
"median_bias_pct": -3.13,
"n": 161,
"p25_pct": -4.16,
"p75_pct": 29.4
"p25_pct": -14.08,
"p75_pct": 22.87
},
"1 30-44": {
"mape_pct": 13.12,
"median_bias_pct": -0.51,
"mape_pct": 14.06,
"median_bias_pct": -2.33,
"n": 503,
"p25_pct": -11.98,
"p75_pct": 14.88
"p25_pct": -14.49,
"p75_pct": 12.58
},
"2 44-62": {
"mape_pct": 12.99,
"median_bias_pct": -2.82,
"mape_pct": 12.48,
"median_bias_pct": -0.39,
"n": 379,
"p25_pct": -14.44,
"p75_pct": 10.26
"p25_pct": -11.23,
"p75_pct": 14.65
},
"3 62-85": {
"mape_pct": 15.32,
"median_bias_pct": -5.33,
"mape_pct": 15.24,
"median_bias_pct": -1.94,
"n": 179,
"p25_pct": -18.99,
"p75_pct": 7.16
"p25_pct": -14.98,
"p75_pct": 15.27
},
"4 >=85": {
"mape_pct": 23.21,
"median_bias_pct": -21.36,
"mape_pct": 20.88,
"median_bias_pct": -10.36,
"n": 47,
"p25_pct": -29.97,
"p75_pct": 6.3
"p25_pct": -24.08,
"p75_pct": 13.38
}
},
"per_rooms": {
"0": {
"label": "студия",
"mape_pct": 14.55,
"median_bias_pct": 7.59,
"mape_pct": 16.51,
"median_bias_pct": -3.13,
"n": 161,
"n_no_analogs": 0,
"p25_pct": -4.16,
"p75_pct": 29.4
"p25_pct": -14.08,
"p75_pct": 22.87
},
"1": {
"label": "1к",
"mape_pct": 13.12,
"median_bias_pct": -0.51,
"mape_pct": 14.06,
"median_bias_pct": -2.33,
"n": 503,
"n_no_analogs": 0,
"p25_pct": -11.98,
"p75_pct": 14.88
"p25_pct": -14.49,
"p75_pct": 12.58
},
"2": {
"label": "2к",
"mape_pct": 12.99,
"median_bias_pct": -2.82,
"mape_pct": 12.48,
"median_bias_pct": -0.39,
"n": 379,
"n_no_analogs": 0,
"p25_pct": -14.44,
"p75_pct": 10.26
"p25_pct": -11.23,
"p75_pct": 14.65
},
"3": {
"label": "3к",
"mape_pct": 15.32,
"median_bias_pct": -5.33,
"mape_pct": 15.24,
"median_bias_pct": -1.94,
"n": 179,
"n_no_analogs": 0,
"p25_pct": -18.99,
"p75_pct": 7.16
"p25_pct": -14.98,
"p75_pct": 15.27
},
"4": {
"label": "4+",
"mape_pct": 23.21,
"median_bias_pct": -21.36,
"mape_pct": 20.88,
"median_bias_pct": -10.36,
"n": 47,
"n_no_analogs": 0,
"p25_pct": -29.97,
"p75_pct": 6.3
"p25_pct": -24.08,
"p75_pct": 13.38
}
},
"per_segment": {
"бизнес": {
"mape_pct": 11.85,
"median_bias_pct": -9.98,
"mape_pct": 16.86,
"median_bias_pct": -16.84,
"n": 144,
"p25_pct": -20.69,
"p75_pct": -1.72
"p25_pct": -25.83,
"p75_pct": -9.36
},
"комфорт": {
"mape_pct": 12.71,
"median_bias_pct": -2.39,
"mape_pct": 12.14,
"median_bias_pct": -5.9,
"n": 425,
"p25_pct": -16.15,
"p75_pct": 9.46
"p25_pct": -16.66,
"p75_pct": 4.9
},
"премиум": {
"mape_pct": null,
@ -140,18 +140,18 @@
"p75_pct": null
},
"эконом": {
"mape_pct": 14.98,
"median_bias_pct": 2.46,
"mape_pct": 15.17,
"median_bias_pct": 6.26,
"n": 697,
"p25_pct": -9.54,
"p75_pct": 26.95
"p25_pct": -6.89,
"p75_pct": 28.97
},
"элит": {
"mape_pct": 18.45,
"median_bias_pct": -18.45,
"mape_pct": 31.18,
"median_bias_pct": -31.18,
"n": 3,
"p25_pct": -40.32,
"p75_pct": -15.7
"p25_pct": -46.94,
"p75_pct": -28.1
}
}
},
@ -162,20 +162,20 @@
},
"range_coverage": {
"overall": {
"coverage_pct": 86.13,
"coverage_pct": 87.0,
"n": 1269,
"n_covered": 1093
"n_covered": 1104
},
"per_confidence": {
"high": {
"coverage_pct": 50.0,
"coverage_pct": 75.0,
"n": 8,
"n_covered": 4
"n_covered": 6
},
"low": {
"coverage_pct": 86.68,
"coverage_pct": 87.41,
"n": 1231,
"n_covered": 1067
"n_covered": 1076
},
"medium": {
"coverage_pct": 73.33,

View file

@ -126,7 +126,15 @@ def test_mid_case_shifts_by_expected_factor(monkeypatch: pytest.MonkeyPatch) ->
def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) -> None:
"""Very large area → raw factor < factor_min → clamped to the floor."""
"""Very large area → raw factor < factor_min → clamped to the floor.
#3248: тест проверяет МЕХАНИЗМ клэмпа, а не конкретную подгонку. С момента
перефита `estimate_hedonic_larea_coef` = 0 (площадь несёт area-бакетный ratio),
поэтому площадь сама по себе фактор вниз больше не гонит. Задаём площадной
коэффициент явно иначе тест молча перестаёт проверять клэмп при каждом
перефите вместо того, чтобы падать.
"""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
off = _price(area_m2=10_000.0, target_year=None, ratio=0.85)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
@ -145,7 +153,14 @@ def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch)
le_asking is held OFF so the raw ceiling factor is observable on the point
(otherwise the re-clamp would cap it at the asking headline).
#3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не
достаёт).
"""
monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220)
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
off = _price(area_m2=15.0, target_year=2025, ratio=0.85)
@ -199,7 +214,7 @@ def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPat
def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None:
"""floor==1 → the extra exp(-0.1248)≈0.88 discount vs floor=3 (#2002).
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.
Same year/area/ratio; only the floor differs. The mid case sits strictly inside
the clamp band so the first-floor term is fully observable (no clamp confound).
@ -219,8 +234,11 @@ def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) ->
f_upper = _expected_factor(50.0, 2010, floor=3)
extra = math.exp(estimator.settings.estimate_hedonic_first_floor_coef)
# ground-floor multiplies the year+area factor by the extra ~0.88 discount.
assert extra == pytest.approx(0.8827, abs=1e-3)
# ground-floor multiplies the year+area factor by the extra discount.
# #3248: сверяем со ЗНАЧЕНИЕМ НАСТРОЙКИ, а не с зашитым 0.8827 — коэффициент
# подгоняемый и меняется при каждом перефите, а проверяем мы связь термина с
# фактором. Границы держат тест осмысленным: скидка, но не обвал.
assert 0.80 < extra < 1.0
assert f_ground == pytest.approx(f_upper * extra)
# both factors strictly inside the clamp band → the term is fully observable.
assert estimator.settings.estimate_hedonic_factor_min < f_ground < f_upper