Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
bot-backend
fe69d6e70e feat(tradein/estimator): ground-floor term in hedonic correction (#2002) 2026-06-27 19:21:11 +03:00
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_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 фикса, каждый за флагом) ──────

View file

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

View file

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

View file

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