From 49907aba461c12cfd922ee81d0fab5a9bf4445a1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 23:13:13 +0300 Subject: [PATCH] =?UTF-8?q?fix(mera/estimate):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=84=D0=B8=D1=82=20=D1=85=D0=B5=D0=B4=D0=BE=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20area-=D0=B1?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D0=BD=D0=BE=D0=B3=D0=BE=20ratio=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D1=80=D1=83=D0=BF=D0=BD=D0=BE=D0=B5=20=D0=B6?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D1=91=20=D0=B7=D0=B0=D0=BD=D0=B8=D0=B6=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D1=81=D1=8C=20=D0=BD=D0=B0=2021%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Коэффициенты хедоники зафичены 27.06.2026 (#2002), когда asking->sold ratio ключевался ПО КОМНАТАМ. 05.08.2026 (#2620) ratio переключили на area-бакеты, то есть под хедонику подставили другую базу, а её саму не пересчитали. Хедоника по определению чинит остаток log(actual_sold / expected_sold) и верна только для той базы, на которой фитилась. Итог: площадь штрафовалась дважды, bias по 4+ комнатам -21.4%. В выкупе занижение — это потерянные сделки. Перефит на свежей прод-фикстуре (1269 оценённых сделок ЕКБ). larea выставлен в 0.0 НАМЕРЕННО: после #2620 площадь несёт area-бакетный ratio, а его форма — перевёрнутая U (факт sold/ask по бакетам 0.786 / 0.829 / 0.899 / 0.954 / 0.864), которую монотонный ln(area) выразить не может в принципе. Член стал избыточным и тянул крупное жильё вниз ровно там, где рынок его не дисконтирует. Результат на фикстуре: было MAPE 13.90 bias -0.78 | студия +7.6 1к -0.5 2к -2.8 3к -5.3 4+ -21.4 стало MAPE 14.28 bias -1.61 | студия -3.1 1к -2.3 2к -0.4 3к -1.9 4+ -10.4 Худший перекос вдвое меньше, все прочие классы в пределах +-3.1%, покрытие диапазона 86.13% -> 87.00%. Цена — +0.38 п.п. общего MAPE: хедоника сжимает разброс за счёт года, и ослабление площадного члена этот эффект чуть уменьшает. Критерии приёмки из #3248 выполнены частично: «все классы в пределах 12%» — да; «4+ не хуже 8%» и «MAPE не хуже 14.17» — нет. Остаток -10.4% конфигом не закрывается: ratio бакета 4 равен 0.8211 при фактических sold/ask 0.8640, то есть занижен на ~5% ещё до всякой хедоники. Это пересчёт самой таблицы (app/tasks/asking_to_sold_ratio.py) — отдельной задачей. Три теста в test_estimator_hedonic.py развязаны от подгоняемых чисел: два проверяют механизм клэмпов и теперь задают коэффициенты явно через monkeypatch, третий сверяет наземную скидку с настройкой, а не с зашитым 0.8827. Иначе каждый следующий перефит ломал бы тесты, которые про механизм, а не про подгонку. --- tradein-mvp/backend/app/core/config.py | 35 ++++- .../tests/fixtures/backtest_baseline.json | 146 +++++++++--------- .../backend/tests/test_estimator_hedonic.py | 26 +++- 3 files changed, 126 insertions(+), 81 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 8c190c48..039f29cc 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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 фикса, каждый за флагом) ────── diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index d277aaba..4758c633 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -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, diff --git a/tradein-mvp/backend/tests/test_estimator_hedonic.py b/tradein-mvp/backend/tests/test_estimator_hedonic.py index c54f755f..d21d3302 100644 --- a/tradein-mvp/backend/tests/test_estimator_hedonic.py +++ b/tradein-mvp/backend/tests/test_estimator_hedonic.py @@ -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