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_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_year_coef: float = 0.1220 # per (year-2000)/20
|
||||||
estimate_hedonic_larea_coef: float = -0.1603 # per ln(area_m2)
|
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_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 фикса, каждый за флагом) ──────
|
||||||
|
|
|
||||||
|
|
@ -2519,10 +2519,15 @@ def _price_from_inputs(
|
||||||
and area_m2 > 0
|
and area_m2 > 0
|
||||||
):
|
):
|
||||||
_yr = ((target_year - 2000) / 20.0) if target_year else 0.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(
|
_factor = math.exp(
|
||||||
settings.estimate_hedonic_b0
|
settings.estimate_hedonic_b0
|
||||||
+ settings.estimate_hedonic_year_coef * _yr
|
+ settings.estimate_hedonic_year_coef * _yr
|
||||||
+ settings.estimate_hedonic_larea_coef * math.log(area_m2)
|
+ settings.estimate_hedonic_larea_coef * math.log(area_m2)
|
||||||
|
+ _first
|
||||||
)
|
)
|
||||||
_factor = max(
|
_factor = max(
|
||||||
settings.estimate_hedonic_factor_min,
|
settings.estimate_hedonic_factor_min,
|
||||||
|
|
|
||||||
|
|
@ -27,48 +27,48 @@
|
||||||
"expected_sold": {
|
"expected_sold": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"mape_pct": 13.23,
|
"mape_pct": 13.23,
|
||||||
"median_bias_pct": -2.87,
|
"median_bias_pct": -3.44,
|
||||||
"n": 277,
|
"n": 277,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -14.43,
|
"p25_pct": -15.49,
|
||||||
"p75_pct": 12.51
|
"p75_pct": 10.07
|
||||||
},
|
},
|
||||||
"per_rooms": {
|
"per_rooms": {
|
||||||
"0": {
|
"0": {
|
||||||
"label": "студия",
|
"label": "студия",
|
||||||
"mape_pct": 19.38,
|
"mape_pct": 18.1,
|
||||||
"median_bias_pct": 18.1,
|
"median_bias_pct": 16.96,
|
||||||
"n": 37,
|
"n": 37,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": 1.66,
|
"p25_pct": 1.37,
|
||||||
"p75_pct": 33.53
|
"p75_pct": 33.53
|
||||||
},
|
},
|
||||||
"1": {
|
"1": {
|
||||||
"label": "1к",
|
"label": "1к",
|
||||||
"mape_pct": 10.97,
|
"mape_pct": 11.06,
|
||||||
"median_bias_pct": -3.47,
|
"median_bias_pct": -4.61,
|
||||||
"n": 93,
|
"n": 93,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -12.48,
|
"p25_pct": -14.43,
|
||||||
"p75_pct": 6.98
|
"p75_pct": 6.98
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"label": "2к",
|
"label": "2к",
|
||||||
"mape_pct": 17.39,
|
"mape_pct": 18.26,
|
||||||
"median_bias_pct": -11.71,
|
"median_bias_pct": -12.2,
|
||||||
"n": 74,
|
"n": 74,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -23.37,
|
"p25_pct": -24.35,
|
||||||
"p75_pct": -0.36
|
"p75_pct": -0.36
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "3к",
|
"label": "3к",
|
||||||
"mape_pct": 9.77,
|
"mape_pct": 9.34,
|
||||||
"median_bias_pct": -3.08,
|
"median_bias_pct": -3.08,
|
||||||
"n": 43,
|
"n": 43,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -10.26,
|
"p25_pct": -10.26,
|
||||||
"p75_pct": 5.77
|
"p75_pct": 4.68
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"label": "4+",
|
"label": "4+",
|
||||||
|
|
@ -76,24 +76,24 @@
|
||||||
"median_bias_pct": 8.54,
|
"median_bias_pct": 8.54,
|
||||||
"n": 30,
|
"n": 30,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -3.78,
|
"p25_pct": -6.86,
|
||||||
"p75_pct": 23.54
|
"p75_pct": 23.54
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"per_segment": {
|
"per_segment": {
|
||||||
"бизнес": {
|
"бизнес": {
|
||||||
"mape_pct": 12.87,
|
"mape_pct": 14.65,
|
||||||
"median_bias_pct": -10.19,
|
"median_bias_pct": -10.54,
|
||||||
"n": 46,
|
"n": 46,
|
||||||
"p25_pct": -22.6,
|
"p25_pct": -22.93,
|
||||||
"p75_pct": -1.31
|
"p75_pct": -1.31
|
||||||
},
|
},
|
||||||
"комфорт": {
|
"комфорт": {
|
||||||
"mape_pct": 11.61,
|
"mape_pct": 11.82,
|
||||||
"median_bias_pct": -4.07,
|
"median_bias_pct": -4.35,
|
||||||
"n": 104,
|
"n": 104,
|
||||||
"p25_pct": -15.64,
|
"p25_pct": -16.35,
|
||||||
"p75_pct": 7.38
|
"p75_pct": 6.94
|
||||||
},
|
},
|
||||||
"премиум": {
|
"премиум": {
|
||||||
"mape_pct": 68.92,
|
"mape_pct": 68.92,
|
||||||
|
|
@ -103,11 +103,11 @@
|
||||||
"p75_pct": -68.92
|
"p75_pct": -68.92
|
||||||
},
|
},
|
||||||
"эконом": {
|
"эконом": {
|
||||||
"mape_pct": 15.17,
|
"mape_pct": 13.96,
|
||||||
"median_bias_pct": 4.28,
|
"median_bias_pct": 3.39,
|
||||||
"n": 120,
|
"n": 120,
|
||||||
"p25_pct": -6.59,
|
"p25_pct": -9.57,
|
||||||
"p75_pct": 26.09
|
"p75_pct": 25.7
|
||||||
},
|
},
|
||||||
"элит": {
|
"элит": {
|
||||||
"mape_pct": 33.2,
|
"mape_pct": 33.2,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ def _price(
|
||||||
target_year: int | None,
|
target_year: int | None,
|
||||||
ratio: float,
|
ratio: float,
|
||||||
ppm2: float = 100_000.0,
|
ppm2: float = 100_000.0,
|
||||||
|
floor: int | None = 5,
|
||||||
) -> estimator.PricingResult:
|
) -> estimator.PricingResult:
|
||||||
"""Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio."""
|
"""Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio."""
|
||||||
|
|
||||||
|
|
@ -62,7 +63,7 @@ def _price(
|
||||||
area_m2=area_m2,
|
area_m2=area_m2,
|
||||||
rooms=2,
|
rooms=2,
|
||||||
repair_state=None,
|
repair_state=None,
|
||||||
floor=5,
|
floor=floor,
|
||||||
total_floors=10,
|
total_floors=10,
|
||||||
target_year=target_year,
|
target_year=target_year,
|
||||||
analog_tier="W",
|
analog_tier="W",
|
||||||
|
|
@ -85,14 +86,20 @@ def _price(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _expected_factor(area_m2: float, target_year: int | None) -> float:
|
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)."""
|
"""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
|
s = estimator.settings
|
||||||
yr = ((target_year - 2000) / 20.0) if target_year else 0.0
|
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(
|
raw = math.exp(
|
||||||
s.estimate_hedonic_b0
|
s.estimate_hedonic_b0
|
||||||
+ s.estimate_hedonic_year_coef * yr
|
+ s.estimate_hedonic_year_coef * yr
|
||||||
+ s.estimate_hedonic_larea_coef * math.log(area_m2)
|
+ s.estimate_hedonic_larea_coef * math.log(area_m2)
|
||||||
|
+ first
|
||||||
)
|
)
|
||||||
return max(s.estimate_hedonic_factor_min, min(s.estimate_hedonic_factor_max, raw))
|
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
|
assert res.expected_sold_price is not None
|
||||||
# 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline.
|
# 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline.
|
||||||
assert res.expected_sold_price > res.median_price
|
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