From a5097c3a976bd9cbfa9901f90d67263b02397d3a Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 4 Jul 2026 07:33:37 +0000 Subject: [PATCH] fix(tradein/estimator): rebase ground-floor hedonic term onto current main (#2002, #2382) (#2391) --- tradein-mvp/backend/app/core/config.py | 1 + tradein-mvp/backend/app/services/estimator.py | 5 ++ .../tests/fixtures/backtest_baseline.json | 52 +++++++-------- .../backend/tests/test_estimator_hedonic.py | 63 ++++++++++++++++++- 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 582f41cb..0f97a1df 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -158,6 +158,7 @@ class Settings(BaseSettings): 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 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/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 00d9e2fd..b432480f 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2636,10 +2636,15 @@ def _price_from_inputs( and area_m2 > 0 ): _yr = ((target_year - 2000) / 20.0) if target_year else 0.0 + # #2002: ground-floor (floor==1) term — engine overestimates 1st-floor + # units ~12%; floor is int|None, so floor==1 leaves None/other floors + # byte-identical to the prior 2-term factor. + _first = settings.estimate_hedonic_first_floor_coef if floor == 1 else 0.0 _factor = math.exp( settings.estimate_hedonic_b0 + settings.estimate_hedonic_year_coef * _yr + settings.estimate_hedonic_larea_coef * math.log(area_m2) + + _first ) _factor = max( settings.estimate_hedonic_factor_min, diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index 7763a6c5..9abac124 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -27,48 +27,48 @@ "expected_sold": { "overall": { "mape_pct": 13.23, - "median_bias_pct": -2.87, + "median_bias_pct": -3.44, "n": 277, "n_no_analogs": 0, - "p25_pct": -14.43, - "p75_pct": 12.51 + "p25_pct": -15.49, + "p75_pct": 10.07 }, "per_rooms": { "0": { "label": "студия", - "mape_pct": 19.38, - "median_bias_pct": 18.1, + "mape_pct": 18.1, + "median_bias_pct": 16.96, "n": 37, "n_no_analogs": 0, - "p25_pct": 1.66, + "p25_pct": 1.37, "p75_pct": 33.53 }, "1": { "label": "1к", - "mape_pct": 10.97, - "median_bias_pct": -3.47, + "mape_pct": 11.06, + "median_bias_pct": -4.61, "n": 93, "n_no_analogs": 0, - "p25_pct": -12.48, + "p25_pct": -14.43, "p75_pct": 6.98 }, "2": { "label": "2к", - "mape_pct": 17.39, - "median_bias_pct": -11.71, + "mape_pct": 18.26, + "median_bias_pct": -12.2, "n": 74, "n_no_analogs": 0, - "p25_pct": -23.37, + "p25_pct": -24.35, "p75_pct": -0.36 }, "3": { "label": "3к", - "mape_pct": 9.77, + "mape_pct": 9.34, "median_bias_pct": -3.08, "n": 43, "n_no_analogs": 0, "p25_pct": -10.26, - "p75_pct": 5.77 + "p75_pct": 4.68 }, "4": { "label": "4+", @@ -76,24 +76,24 @@ "median_bias_pct": 8.54, "n": 30, "n_no_analogs": 0, - "p25_pct": -3.78, + "p25_pct": -6.86, "p75_pct": 23.54 } }, "per_segment": { "бизнес": { - "mape_pct": 12.87, - "median_bias_pct": -10.19, + "mape_pct": 14.65, + "median_bias_pct": -10.54, "n": 46, - "p25_pct": -22.6, + "p25_pct": -22.93, "p75_pct": -1.31 }, "комфорт": { - "mape_pct": 11.61, - "median_bias_pct": -4.07, + "mape_pct": 11.82, + "median_bias_pct": -4.35, "n": 104, - "p25_pct": -15.64, - "p75_pct": 7.38 + "p25_pct": -16.35, + "p75_pct": 6.94 }, "премиум": { "mape_pct": 68.92, @@ -103,11 +103,11 @@ "p75_pct": -68.92 }, "эконом": { - "mape_pct": 15.17, - "median_bias_pct": 4.28, + "mape_pct": 13.96, + "median_bias_pct": 3.39, "n": 120, - "p25_pct": -6.59, - "p75_pct": 26.09 + "p25_pct": -9.57, + "p75_pct": 25.7 }, "элит": { "mape_pct": 33.2, diff --git a/tradein-mvp/backend/tests/test_estimator_hedonic.py b/tradein-mvp/backend/tests/test_estimator_hedonic.py index aae27612..c54f755f 100644 --- a/tradein-mvp/backend/tests/test_estimator_hedonic.py +++ b/tradein-mvp/backend/tests/test_estimator_hedonic.py @@ -51,6 +51,7 @@ def _price( target_year: int | None, ratio: float, ppm2: float = 100_000.0, + floor: int | None = 5, ) -> estimator.PricingResult: """Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio.""" @@ -62,7 +63,7 @@ def _price( area_m2=area_m2, rooms=2, repair_state=None, - floor=5, + floor=floor, total_floors=10, target_year=target_year, analog_tier="W", @@ -85,14 +86,20 @@ def _price( ) -def _expected_factor(area_m2: float, target_year: int | None) -> float: - """Reproduce the production factor from the live settings (no hard-coding).""" +def _expected_factor(area_m2: float, target_year: int | None, floor: int | None = 5) -> float: + """Reproduce the production factor from the live settings (no hard-coding). + + ``floor`` mirrors the production gate: the ground-floor term is added only when + ``floor == 1`` (default 5 ⇒ no first-floor term, the year+area factor). + """ s = estimator.settings yr = ((target_year - 2000) / 20.0) if target_year else 0.0 + first = s.estimate_hedonic_first_floor_coef if floor == 1 else 0.0 raw = math.exp( s.estimate_hedonic_b0 + s.estimate_hedonic_year_coef * yr + s.estimate_hedonic_larea_coef * math.log(area_m2) + + first ) return max(s.estimate_hedonic_factor_min, min(s.estimate_hedonic_factor_max, raw)) @@ -189,3 +196,53 @@ def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPat assert res.expected_sold_price is not None # 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline. assert res.expected_sold_price > res.median_price + + +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). + + 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). + ``floor`` only feeds the (empty) same-building anchor here, so the hedonic-OFF + base expected_sold is identical for floor=1 and floor=3 — the only delta is the + ground-floor coefficient in the factor. + """ + # OFF baseline (no factor at all) — floor-independent in the radius-only spine. + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) + off = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=3) + # ON. + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + ground = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=1) + upper = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=3) + + f_ground = _expected_factor(50.0, 2010, floor=1) + 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) + 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 + assert f_upper < estimator.settings.estimate_hedonic_factor_max + # expected_sold tracks each factor vs the shared OFF baseline (ratio<1 → le_asking + # re-clamp is a no-op, no confound). + assert off.expected_sold_price is not None + assert ground.expected_sold_price == round(off.expected_sold_price * f_ground) + assert upper.expected_sold_price == round(off.expected_sold_price * f_upper) + assert ground.expected_sold_per_m2 == round(off.expected_sold_per_m2 * f_ground) + # the headline mechanic: a ground-floor unit sells below the upper-floor estimate. + assert upper.expected_sold_price is not None + assert ground.expected_sold_price < upper.expected_sold_price + + +def test_non_ground_floors_unchanged_vs_year_area_factor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """floor in {None, 2, 3, 99} stays byte-identical to the prior 2-term factor.""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + ref = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=5) + for fl in (None, 2, 3, 99): + other = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=fl) + assert other.expected_sold_price == ref.expected_sold_price + assert other.expected_sold_per_m2 == ref.expected_sold_per_m2