fix(tradein/estimator): min-width floor ±12% на ценовой диапазон (#2209)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) 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
CI Trade-In / backend-tests (pull_request) Successful in 1m43s

При n=1 аналоге q1==q3==median → asking-диапазон (Q1..Q3 ₽/м²) схлопывался в
ноль ширины: юзеру уходил «диапазон» из одной точки с ложной точностью.
RANGE_MIN_HALFWIDTH_PCT=0.12 + чистая _apply_range_floor, применяемая
ПОСЛЕДНЕЙ в спайне к обоим диапазонам (headline asking + expected_sold)
вокруг их точек. Floor ТОЛЬКО расширяет; точку и confidence не трогает;
не может триггернуть manual_review wide-range (1.273 < 1.9).

Frozen backtest baseline БАЙТ-ИДЕНТИЧЕН до/после (replay проверен):
expected_sold-диапазон строится калиброванным PI-бэндом (rel-width 0.743,
кратно шире floor 0.24) — floor на нём no-op; кусает только вырожденный
asking-хвост (n=1..2), который replay не скорит. Bias/MAPE не сдвинуты.
+10 юнит-тестов (включая доказательство порядка floor-после-IMV-blend).

Refs #2209
This commit is contained in:
bot-backend 2026-07-03 00:07:46 +03:00
parent 4ce0f51bb8
commit a6f073ba9a
2 changed files with 258 additions and 0 deletions

View file

@ -96,6 +96,15 @@ SBER_TIME_FACTOR_MAX = 1.6
# честный пересчёт дескриптора. 1e-4 << шаг бейджа round((1ratio)×100) (0.005). # честный пересчёт дескриптора. 1e-4 << шаг бейджа round((1ratio)×100) (0.005).
_RATIO_DESCRIPTOR_EPS = 1e-4 _RATIO_DESCRIPTOR_EPS = 1e-4
# #2209: минимальная относительная полуширина ценового диапазона. При n=1 аналоге
# q1==q3==median → asking-диапазон (Q1..Q3 ₽/м² аналогов) схлопывается в НОЛЬ ширины,
# и юзеру уходит «диапазон» из одной точки с ложной точностью. Живой бэктест
# 2026-07-02 (engine=full, n=276): MAPE low-confidence bucket 14.8 %, медианная
# относительная ширина диапазона 0.743 — floor ±12 % затрагивает только вырожденно
# узкий хвост (n=1..2 аналога), не трогая типичные оценки. Ширина не может честно
# быть у́же типичной ошибки: floor только РАСШИРЯЕТ, точку не двигает.
RANGE_MIN_HALFWIDTH_PCT = 0.12
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -2577,6 +2586,19 @@ def _price_from_inputs(
"по широкой окрестности)." "по широкой окрестности)."
) )
# ── #2209: min-width floor — ПОСЛЕДНИМ, после всех мутаций (anchor / IMV blend
# / quarter-index / corridor-clamp / radius-floor / hedonic PI), чтобы никакая
# последующая мутация не сузила диапазон обратно. Floor только расширяет и не
# трогает точку (median_price / expected_sold_price). Вырожденный n=1 asking-
# диапазон (Q1==Q3) перестаёт быть точкой с ложной точностью.
range_low, range_high = _apply_range_floor(range_low, range_high, median_price)
if expected_sold_range_low is not None and expected_sold_range_high is not None:
expected_sold_range_low, expected_sold_range_high = _apply_range_floor(
expected_sold_range_low,
expected_sold_range_high,
expected_sold_price if expected_sold_price is not None else 0,
)
return PricingResult( return PricingResult(
median_ppm2=median_ppm2, median_ppm2=median_ppm2,
median_price=median_price, median_price=median_price,
@ -4666,6 +4688,22 @@ def _percentile(sorted_values: list[float], p: float) -> float:
return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac
def _apply_range_floor(low: int, high: int, point: int) -> tuple[int, int]:
"""#2209: min-width floor ценового диапазона вокруг точки оценки.
Если диапазон уже́ 2×RANGE_MIN_HALFWIDTH_PCT×point (вырожденный n=1..2 хвост),
расширяем симметрично вокруг point до floor-полуширины. Floor ТОЛЬКО расширяет
(никогда не сужает уже-широкий диапазон), точку (median/expected_sold) не двигает.
low не опускается ниже 0. Возвращает целые рубли.
"""
if point <= 0:
return low, high
floor_half = round(RANGE_MIN_HALFWIDTH_PCT * point)
if (high - low) < 2 * floor_half:
return max(0, point - floor_half), point + floor_half
return low, high
def _enforce_zero_analog_low( def _enforce_zero_analog_low(
confidence: str, confidence: str,
n_analogs: int, n_analogs: int,

View file

@ -0,0 +1,220 @@
"""Hermetic unit tests for the #2209 min-width range floor.
Two layers:
1. The pure ``_apply_range_floor`` helper (no DB, no imports beyond estimator).
2. Integration through ``_price_from_inputs`` a degenerate n=1 analog sample
(q1==q3==median zero-width asking range) must surface a non-zero ±12 %
range; a naturally-wide range must be left untouched; the floor must run
AFTER the IMV blend (a narrow post-blend range gets widened, never shrunk).
NOTE: importing app.services.estimator pulls app.core.config.Settings which
requires DATABASE_URL. Set it BEFORE importing app modules.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from app.services.estimator import RANGE_MIN_HALFWIDTH_PCT, _apply_range_floor
from app.services.geocoder import GeocodeResult
# ── helpers (mirror tests/test_estimator_price_spine.py) ──────────────────────
def _geo() -> GeocodeResult:
return GeocodeResult(
lat=56.838,
lon=60.597,
full_address="ул. Тестовая, 1",
provider="nominatim",
confidence="approximate",
)
def _lot(ppm2: float, address: str = "ул. Тестовая, 1", source: str = "avito") -> dict:
return {"price_per_m2": ppm2, "address": address, "source": source}
def _lots(ppm2: float, n: int = 7) -> list[dict]:
return [_lot(ppm2, address=f"ул. Тестовая, {i + 1}") for i in range(n)]
def _call(
*,
listings: list[dict],
area_m2: float = 50.0,
rooms: int | None = 2,
imv_anchor: dict | None = None,
ratio: float | None = None,
) -> estimator.PricingResult:
_ratio = ratio
_basis = "per_rooms" if ratio is not None else None
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
return _ratio, _basis if _ratio is not None else None
return estimator._price_from_inputs(
listings=listings,
area_m2=area_m2,
rooms=rooms,
repair_state=None,
floor=5,
total_floors=10,
target_year=None,
analog_tier="W",
fallback_used=False,
area_widened=False,
anchor_comps=[],
anchor_tier_fetched=None,
dkp_raw=None,
imv_anchor=imv_anchor,
imv_eval=None,
yandex_val_present=False,
cian_val_present=False,
ratio_resolver=ratio_resolver,
quarter_index_lookup=lambda q: None,
quarter_indexes_lookup=lambda qs: {},
target_house_cadnum=None,
dadata_coarse=False,
geo=_geo(),
dadata_qc_geo=None,
)
# ── 1. Pure helper ────────────────────────────────────────────────────────────
def test_floor_expands_zero_width_symmetrically() -> None:
"""point=5M, low==high==5M (n=1 collapse) → symmetric ±12 % around point."""
half = round(RANGE_MIN_HALFWIDTH_PCT * 5_000_000) # 600_000
low, high = _apply_range_floor(5_000_000, 5_000_000, 5_000_000)
assert low == 5_000_000 - half
assert high == 5_000_000 + half
# Point is the exact midpoint — floor never moves the point.
assert (low + high) // 2 == 5_000_000
def test_floor_leaves_wide_range_untouched() -> None:
"""A range already wider than 2×12 % of point is returned verbatim."""
# width = 3M, point 5M → rel 0.60 >> 0.24 floor → no-op.
assert _apply_range_floor(3_500_000, 6_500_000, 5_000_000) == (3_500_000, 6_500_000)
def test_floor_clamps_low_to_zero() -> None:
"""When point < floor_half the lower edge clamps at 0 (never negative)."""
# point=1M → floor_half=120_000; 1M-120k=880k stays positive, so pick a tiny point.
half = round(RANGE_MIN_HALFWIDTH_PCT * 100_000) # 12_000
low, _high = _apply_range_floor(50_000, 50_000, 100_000)
assert low == 100_000 - half # 88_000 — still positive here
# Now a point whose floor_half exceeds it → low clamps to 0.
low2, high2 = _apply_range_floor(5, 5, 10)
floor_half2 = round(RANGE_MIN_HALFWIDTH_PCT * 10) # ~1
assert low2 == max(0, 10 - floor_half2)
assert low2 >= 0
assert high2 == 10 + floor_half2
def test_floor_noop_when_point_nonpositive() -> None:
"""point<=0 → range returned unchanged (no division/expansion)."""
assert _apply_range_floor(0, 0, 0) == (0, 0)
assert _apply_range_floor(100, 200, 0) == (100, 200)
def test_floor_only_widens_never_shrinks() -> None:
"""A range exactly at the floor boundary is preserved (not shrunk)."""
point = 1_000_000
half = round(RANGE_MIN_HALFWIDTH_PCT * point) # 120_000
# width == 2*half exactly → NOT < 2*half → untouched.
assert _apply_range_floor(point - half, point + half, point) == (
point - half,
point + half,
)
# ── 2. Integration through _price_from_inputs ────────────────────────────────
def test_single_analog_asking_range_gets_nonzero_width() -> None:
"""n=1 analog: q1==q3==median → zero-width asking range → floored to ±12 %."""
pr = _call(listings=[_lot(100_000)])
assert pr.n_analogs == 1
point = pr.median_price # 100_000 × 50 = 5_000_000
assert point == 5_000_000
half = round(RANGE_MIN_HALFWIDTH_PCT * point)
assert pr.range_low == point - half
assert pr.range_high == point + half
# Non-degenerate: the range is no longer a single point.
assert pr.range_high > pr.range_low
# Point (median) itself is untouched — floor never moves it.
assert pr.median_ppm2 == 100_000.0
def test_wide_analog_range_not_floored() -> None:
"""A radius sample with real spread keeps its Q1..Q3 asking range unchanged."""
# Spread wide enough that (q3-q1)/median > 0.24.
lots = [_lot(p) for p in (60_000, 80_000, 100_000, 120_000, 140_000)]
pr = _call(listings=lots)
point = pr.median_price
half = round(RANGE_MIN_HALFWIDTH_PCT * point)
# Real IQR width exceeds the floor → floor is a no-op here.
assert (pr.range_high - pr.range_low) >= 2 * half
# And the edges are the genuine Q1/Q3 totals, not point±half.
assert pr.range_low != point - half
def test_expected_sold_range_floored_around_expected_point() -> None:
"""Degenerate n=1 with a ratio: expected_sold range also gets ±12 % of its point."""
pr = _call(listings=[_lot(100_000)], ratio=0.90)
assert pr.expected_sold_price is not None
assert pr.expected_sold_range_low is not None
assert pr.expected_sold_range_high is not None
esp = pr.expected_sold_price
# Calibrated PI band (0.649..1.392 of point) is far wider than the 12 % floor,
# so the expected_sold range is NOT degenerate and the floor is a no-op on it —
# but it must still bracket the point and stay non-negative.
assert pr.expected_sold_range_low <= esp <= pr.expected_sold_range_high
assert pr.expected_sold_range_low >= 0
assert (pr.expected_sold_range_high - pr.expected_sold_range_low) >= round(
2 * RANGE_MIN_HALFWIDTH_PCT * esp
)
def test_ppm2_point_consistent_with_floored_range() -> None:
"""Serialized ppm² point stays the analog median; floor only widens rub range.
The response serializes a single ppm² point (median_ppm2 / expected_sold_per_m2),
not a ppm² range, so ценаppm² consistency means the point ppm² must equal
median_price/area even after the range floor widens the rub band.
"""
pr = _call(listings=[_lot(100_000)], area_m2=50.0)
assert pr.median_ppm2 == pr.median_price / 50.0
# Floor widened the rub range but did not touch the ppm² point.
assert pr.median_ppm2 == 100_000.0
def test_floor_applied_after_imv_blend() -> None:
"""Floor runs LAST: a narrow post-IMV-blend asking range is widened, not shrunk.
Uniform lots zero-width Q1..Q3. IMV anchor just above threshold (no higher_price)
blends the median up and lifts range_high to the anchor total, leaving a range that
is still narrower than the 12 % floor. The floor must then widen it symmetrically
around the POST-BLEND point proving the floor sees post-blend values.
"""
imv_anchor = {
"recommended_price": 5_800_000, # > 5M × 1.15 = 5.75M → blends
"lower_price": 5_500_000,
"higher_price": None, # so range_top_candidate == anchor_total (5.8M)
"market_count": 50,
}
pr = _call(listings=_lots(100_000, n=5), imv_anchor=imv_anchor)
# Blend fired: point moved to round(5M×0.5 + 5.8M×0.5) = 5.4M.
point = pr.median_price
assert point == 5_400_000
half = round(RANGE_MIN_HALFWIDTH_PCT * point) # 648_000
# Pre-floor the post-blend range was [5.0M, 5.8M] (width 0.8M < 2×648k) → floored.
assert pr.range_low == point - half # 4_752_000 < 5.0M pre-floor low
assert pr.range_high == point + half # 6_048_000 > 5.8M pre-floor high
# Floor only WIDENED: high moved above the blend's 5.8M, low below the 5.0M q1.
assert pr.range_high > 5_800_000
assert pr.range_low < 5_000_000