fix(mera/estimate): перефит хедоники поверх area-бакетного ratio — крупное жильё занижалось на 21% #3255
3 changed files with 126 additions and 81 deletions
|
|
@ -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 фикса, каждый за флагом) ──────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue