fix(estimator): DKP corridor uses P10/P90 not min/max (#1520)
All checks were successful
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
All checks were successful
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Replace absolute min/max with robust percentiles so a single outlier ДКП deal cannot shift the soft-bound corridor boundaries. Small samples fall back gracefully via linear interpolation (n=3: P10≈index 0.2, P90≈index 2.8).
This commit is contained in:
parent
ba83c36bf4
commit
6eda4a6d0a
2 changed files with 25 additions and 8 deletions
|
|
@ -1240,11 +1240,16 @@ def _fetch_dkp_corridor(
|
|||
max(factors_applied),
|
||||
SBER_TIME_ADJUST_REGION,
|
||||
)
|
||||
# #1520: используем P10/P90 вместо абсолютных min/max, чтобы коридор был
|
||||
# устойчив к выбросам (один нерыночный ДКП не сдвигает границу).
|
||||
# При маленьких выборках (n < 10) _percentile с линейной интерполяцией
|
||||
# плавно сжимается к первому/последнему элементу — специального fallback
|
||||
# не нужно (n=3: P10 ≈ индекс 0.2, P90 ≈ индекс 2.8 → оба внутри диапазона).
|
||||
return {
|
||||
"count": len(ppm2_values),
|
||||
"low_ppm2": int(ppm2_values[0]),
|
||||
"low_ppm2": int(_percentile(ppm2_values, 0.10)),
|
||||
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
|
||||
"high_ppm2": int(ppm2_values[-1]),
|
||||
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
|
||||
"period_months": period_months,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -177,7 +177,14 @@ def test_dkp_corridor_none_without_street() -> None:
|
|||
|
||||
|
||||
def test_dkp_corridor_aggregates_ppm2() -> None:
|
||||
"""Считает low/median/high ₽/м² по сделкам."""
|
||||
"""Считает low/median/high ₽/м² по сделкам.
|
||||
|
||||
#1520: low/high — P10/P90 (не min/max), устойчивы к выбросам.
|
||||
Для 3 значений [100k, 120k, 140k]:
|
||||
P10 = 100k + (120k-100k)*0.2 = 104k
|
||||
P50 = 120k
|
||||
P90 = 120k + (140k-120k)*0.8 = 136k
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"price_per_m2": 100_000},
|
||||
|
|
@ -188,9 +195,9 @@ def test_dkp_corridor_aggregates_ppm2() -> None:
|
|||
res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0)
|
||||
assert res is not None
|
||||
assert res["count"] == 3
|
||||
assert res["low_ppm2"] == 100_000
|
||||
assert res["low_ppm2"] == 104_000 # P10
|
||||
assert res["median_ppm2"] == 120_000
|
||||
assert res["high_ppm2"] == 140_000
|
||||
assert res["high_ppm2"] == 136_000 # P90
|
||||
|
||||
|
||||
# ── #651: anchor band-guard (WHERE clause is exercised via bind params) ─────────
|
||||
|
|
@ -268,7 +275,12 @@ def test_blend_weight_clamped_below_zero() -> None:
|
|||
|
||||
|
||||
def test_dkp_corridor_count_below_three_still_aggregates() -> None:
|
||||
"""count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а)."""
|
||||
"""count<3 → corridor всё равно вычисляется (sanity-bound branch уже у caller'а).
|
||||
|
||||
#1520: P10/P90 при n=2 [100k, 130k] (интерполяция по диапазону 30k):
|
||||
P10 = 100k + 30k*0.1 = 103k
|
||||
P90 = 100k + 30k*0.9 = 127k
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
mock_db.execute.return_value.mappings.return_value.all.return_value = [
|
||||
{"price_per_m2": 100_000},
|
||||
|
|
@ -278,8 +290,8 @@ def test_dkp_corridor_count_below_three_still_aggregates() -> None:
|
|||
res = _fetch_dkp_corridor(mock_db, address="улица Ленина, 5", rooms=2, area=50.0)
|
||||
assert res is not None
|
||||
assert res["count"] == 2
|
||||
assert res["low_ppm2"] == 100_000
|
||||
assert res["high_ppm2"] == 130_000
|
||||
assert res["low_ppm2"] == 103_000 # P10
|
||||
assert res["high_ppm2"] == 127_000 # P90
|
||||
|
||||
|
||||
# ── #651: expected_sold ↔ blended-median consistency (full estimate path) ───────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue