feat(tradein/estimator): ground-floor term in hedonic correction (#2002)

This commit is contained in:
bot-backend 2026-06-27 19:21:11 +03:00
parent c45445b726
commit fe69d6e70e
4 changed files with 92 additions and 29 deletions

View file

@ -123,6 +123,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 фикса, каждый за флагом) ──────

View file

@ -2519,10 +2519,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,

View file

@ -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,

View file

@ -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