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
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:
parent
e523c8949c
commit
5da226a271
3 changed files with 126 additions and 81 deletions
|
|
@ -431,10 +431,37 @@ class Settings(BaseSettings):
|
||||||
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
|
||||||
# OFF ⇒ точно старое поведение expected_sold.
|
# OFF ⇒ точно старое поведение expected_sold.
|
||||||
estimate_hedonic_correction_enabled: bool = True
|
estimate_hedonic_correction_enabled: bool = True
|
||||||
estimate_hedonic_b0: float = 0.6146 # fit log(sold/es) ~ year + ln(area), n=2366 (#2002)
|
# #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок).
|
||||||
estimate_hedonic_year_coef: float = 0.1220 # per (year-2000)/20
|
#
|
||||||
estimate_hedonic_larea_coef: float = -0.1603 # per ln(area_m2)
|
# Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены
|
||||||
estimate_hedonic_first_floor_coef: float = -0.1248 # floor==1 ground-floor ≈ -12%; #2002 n=2366
|
# 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_min: float = 0.75
|
||||||
estimate_hedonic_factor_max: float = 1.30
|
estimate_hedonic_factor_max: float = 1.30
|
||||||
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
{
|
{
|
||||||
"calibration": {
|
"calibration": {
|
||||||
"high": {
|
"high": {
|
||||||
"coverage_pct": 50.0,
|
"coverage_pct": 75.0,
|
||||||
"mape_pct": 27.87,
|
"mape_pct": 25.94,
|
||||||
"n": 8,
|
"n": 8,
|
||||||
"n_covered": 4
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 86.68,
|
"coverage_pct": 87.41,
|
||||||
"mape_pct": 13.76,
|
"mape_pct": 14.06,
|
||||||
"n": 1562,
|
"n": 1562,
|
||||||
"n_covered": 1067
|
"n_covered": 1076
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 73.33,
|
"coverage_pct": 73.33,
|
||||||
"mape_pct": 17.91,
|
"mape_pct": 18.64,
|
||||||
"n": 30,
|
"n": 30,
|
||||||
"n_covered": 22
|
"n_covered": 22
|
||||||
}
|
}
|
||||||
|
|
@ -26,111 +26,111 @@
|
||||||
],
|
],
|
||||||
"expected_sold": {
|
"expected_sold": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"mape_pct": 13.9,
|
"mape_pct": 14.28,
|
||||||
"median_bias_pct": -0.78,
|
"median_bias_pct": -1.61,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -13.39,
|
"p25_pct": -14.08,
|
||||||
"p75_pct": 14.46
|
"p75_pct": 14.6
|
||||||
},
|
},
|
||||||
"per_area_bucket": {
|
"per_area_bucket": {
|
||||||
"0 <30": {
|
"0 <30": {
|
||||||
"mape_pct": 14.55,
|
"mape_pct": 16.51,
|
||||||
"median_bias_pct": 7.59,
|
"median_bias_pct": -3.13,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"p25_pct": -4.16,
|
"p25_pct": -14.08,
|
||||||
"p75_pct": 29.4
|
"p75_pct": 22.87
|
||||||
},
|
},
|
||||||
"1 30-44": {
|
"1 30-44": {
|
||||||
"mape_pct": 13.12,
|
"mape_pct": 14.06,
|
||||||
"median_bias_pct": -0.51,
|
"median_bias_pct": -2.33,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"p25_pct": -11.98,
|
"p25_pct": -14.49,
|
||||||
"p75_pct": 14.88
|
"p75_pct": 12.58
|
||||||
},
|
},
|
||||||
"2 44-62": {
|
"2 44-62": {
|
||||||
"mape_pct": 12.99,
|
"mape_pct": 12.48,
|
||||||
"median_bias_pct": -2.82,
|
"median_bias_pct": -0.39,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"p25_pct": -14.44,
|
"p25_pct": -11.23,
|
||||||
"p75_pct": 10.26
|
"p75_pct": 14.65
|
||||||
},
|
},
|
||||||
"3 62-85": {
|
"3 62-85": {
|
||||||
"mape_pct": 15.32,
|
"mape_pct": 15.24,
|
||||||
"median_bias_pct": -5.33,
|
"median_bias_pct": -1.94,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"p25_pct": -18.99,
|
"p25_pct": -14.98,
|
||||||
"p75_pct": 7.16
|
"p75_pct": 15.27
|
||||||
},
|
},
|
||||||
"4 >=85": {
|
"4 >=85": {
|
||||||
"mape_pct": 23.21,
|
"mape_pct": 20.88,
|
||||||
"median_bias_pct": -21.36,
|
"median_bias_pct": -10.36,
|
||||||
"n": 47,
|
"n": 47,
|
||||||
"p25_pct": -29.97,
|
"p25_pct": -24.08,
|
||||||
"p75_pct": 6.3
|
"p75_pct": 13.38
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"per_rooms": {
|
"per_rooms": {
|
||||||
"0": {
|
"0": {
|
||||||
"label": "студия",
|
"label": "студия",
|
||||||
"mape_pct": 14.55,
|
"mape_pct": 16.51,
|
||||||
"median_bias_pct": 7.59,
|
"median_bias_pct": -3.13,
|
||||||
"n": 161,
|
"n": 161,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -4.16,
|
"p25_pct": -14.08,
|
||||||
"p75_pct": 29.4
|
"p75_pct": 22.87
|
||||||
},
|
},
|
||||||
"1": {
|
"1": {
|
||||||
"label": "1к",
|
"label": "1к",
|
||||||
"mape_pct": 13.12,
|
"mape_pct": 14.06,
|
||||||
"median_bias_pct": -0.51,
|
"median_bias_pct": -2.33,
|
||||||
"n": 503,
|
"n": 503,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -11.98,
|
"p25_pct": -14.49,
|
||||||
"p75_pct": 14.88
|
"p75_pct": 12.58
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"label": "2к",
|
"label": "2к",
|
||||||
"mape_pct": 12.99,
|
"mape_pct": 12.48,
|
||||||
"median_bias_pct": -2.82,
|
"median_bias_pct": -0.39,
|
||||||
"n": 379,
|
"n": 379,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -14.44,
|
"p25_pct": -11.23,
|
||||||
"p75_pct": 10.26
|
"p75_pct": 14.65
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "3к",
|
"label": "3к",
|
||||||
"mape_pct": 15.32,
|
"mape_pct": 15.24,
|
||||||
"median_bias_pct": -5.33,
|
"median_bias_pct": -1.94,
|
||||||
"n": 179,
|
"n": 179,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -18.99,
|
"p25_pct": -14.98,
|
||||||
"p75_pct": 7.16
|
"p75_pct": 15.27
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"label": "4+",
|
"label": "4+",
|
||||||
"mape_pct": 23.21,
|
"mape_pct": 20.88,
|
||||||
"median_bias_pct": -21.36,
|
"median_bias_pct": -10.36,
|
||||||
"n": 47,
|
"n": 47,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -29.97,
|
"p25_pct": -24.08,
|
||||||
"p75_pct": 6.3
|
"p75_pct": 13.38
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"per_segment": {
|
"per_segment": {
|
||||||
"бизнес": {
|
"бизнес": {
|
||||||
"mape_pct": 11.85,
|
"mape_pct": 16.86,
|
||||||
"median_bias_pct": -9.98,
|
"median_bias_pct": -16.84,
|
||||||
"n": 144,
|
"n": 144,
|
||||||
"p25_pct": -20.69,
|
"p25_pct": -25.83,
|
||||||
"p75_pct": -1.72
|
"p75_pct": -9.36
|
||||||
},
|
},
|
||||||
"комфорт": {
|
"комфорт": {
|
||||||
"mape_pct": 12.71,
|
"mape_pct": 12.14,
|
||||||
"median_bias_pct": -2.39,
|
"median_bias_pct": -5.9,
|
||||||
"n": 425,
|
"n": 425,
|
||||||
"p25_pct": -16.15,
|
"p25_pct": -16.66,
|
||||||
"p75_pct": 9.46
|
"p75_pct": 4.9
|
||||||
},
|
},
|
||||||
"премиум": {
|
"премиум": {
|
||||||
"mape_pct": null,
|
"mape_pct": null,
|
||||||
|
|
@ -140,18 +140,18 @@
|
||||||
"p75_pct": null
|
"p75_pct": null
|
||||||
},
|
},
|
||||||
"эконом": {
|
"эконом": {
|
||||||
"mape_pct": 14.98,
|
"mape_pct": 15.17,
|
||||||
"median_bias_pct": 2.46,
|
"median_bias_pct": 6.26,
|
||||||
"n": 697,
|
"n": 697,
|
||||||
"p25_pct": -9.54,
|
"p25_pct": -6.89,
|
||||||
"p75_pct": 26.95
|
"p75_pct": 28.97
|
||||||
},
|
},
|
||||||
"элит": {
|
"элит": {
|
||||||
"mape_pct": 18.45,
|
"mape_pct": 31.18,
|
||||||
"median_bias_pct": -18.45,
|
"median_bias_pct": -31.18,
|
||||||
"n": 3,
|
"n": 3,
|
||||||
"p25_pct": -40.32,
|
"p25_pct": -46.94,
|
||||||
"p75_pct": -15.7
|
"p75_pct": -28.1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -162,20 +162,20 @@
|
||||||
},
|
},
|
||||||
"range_coverage": {
|
"range_coverage": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"coverage_pct": 86.13,
|
"coverage_pct": 87.0,
|
||||||
"n": 1269,
|
"n": 1269,
|
||||||
"n_covered": 1093
|
"n_covered": 1104
|
||||||
},
|
},
|
||||||
"per_confidence": {
|
"per_confidence": {
|
||||||
"high": {
|
"high": {
|
||||||
"coverage_pct": 50.0,
|
"coverage_pct": 75.0,
|
||||||
"n": 8,
|
"n": 8,
|
||||||
"n_covered": 4
|
"n_covered": 6
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 86.68,
|
"coverage_pct": 87.41,
|
||||||
"n": 1231,
|
"n": 1231,
|
||||||
"n_covered": 1067
|
"n_covered": 1076
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 73.33,
|
"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:
|
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)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
|
||||||
off = _price(area_m2=10_000.0, target_year=None, ratio=0.85)
|
off = _price(area_m2=10_000.0, target_year=None, ratio=0.85)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
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
|
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).
|
(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_expected_sold_le_asking", False)
|
||||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
|
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
|
||||||
off = _price(area_m2=15.0, target_year=2025, ratio=0.85)
|
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:
|
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
|
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).
|
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)
|
f_upper = _expected_factor(50.0, 2010, floor=3)
|
||||||
extra = math.exp(estimator.settings.estimate_hedonic_first_floor_coef)
|
extra = math.exp(estimator.settings.estimate_hedonic_first_floor_coef)
|
||||||
|
|
||||||
# ground-floor multiplies the year+area factor by the extra ~0.88 discount.
|
# ground-floor multiplies the year+area factor by the extra discount.
|
||||||
assert extra == pytest.approx(0.8827, abs=1e-3)
|
# #3248: сверяем со ЗНАЧЕНИЕМ НАСТРОЙКИ, а не с зашитым 0.8827 — коэффициент
|
||||||
|
# подгоняемый и меняется при каждом перефите, а проверяем мы связь термина с
|
||||||
|
# фактором. Границы держат тест осмысленным: скидка, но не обвал.
|
||||||
|
assert 0.80 < extra < 1.0
|
||||||
assert f_ground == pytest.approx(f_upper * extra)
|
assert f_ground == pytest.approx(f_upper * extra)
|
||||||
# both factors strictly inside the clamp band → the term is fully observable.
|
# both factors strictly inside the clamp band → the term is fully observable.
|
||||||
assert estimator.settings.estimate_hedonic_factor_min < f_ground < f_upper
|
assert estimator.settings.estimate_hedonic_factor_min < f_ground < f_upper
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue