feat(tradein/estimator): ground-floor term in hedonic correction (#2002)
This commit is contained in:
parent
c45445b726
commit
fe69d6e70e
4 changed files with 92 additions and 29 deletions
|
|
@ -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 фикса, каждый за флагом) ──────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue