Множители доверительного интервала по регионам (#3540)
All checks were successful
Deploy Trade-In / changes (push) Successful in 17s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m45s
Deploy Trade-In / build-backend (push) Successful in 1m10s
Deploy Trade-In / deploy (push) Successful in 6m59s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s

This commit is contained in:
lekss361 2026-09-16 19:08:49 +00:00
parent c63c48eea2
commit 1c039b358c
8 changed files with 265 additions and 44 deletions

View file

@ -436,6 +436,24 @@ class Settings(BaseSettings):
# (проверено: 80.0% coverage на тех же 2366).
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
# ── региональные множители PI (замер 2026-09-16) ──────────────────────────
# Скаляры выше сняты ТОЛЬКО на Екатеринбурге (обл.66, #1966) и молча
# применялись ко всем регионам — прогон `--engine full --sample 2000
# --since 2025-06-01 --spread scattered --seed 42 --resolve-house-id
# --pi-report` (гедоническая коррекция #2002 уже выключена в main) показал,
# что достигнутое покрытие «80%-й» вилки уезжает по региону:
# регион n p10 p50 p90 факт. покрытие
# 77 1969 0.448 1.043 1.440 71.2%
# 50 1632 0.703 1.117 1.500 74.8%
# 66 1360 0.649 1.143 1.584 66.2%
# Воспроизведено на независимой выборке (seed 777): 77 → 0.453/1.040/1.455,
# 50 → 0.701/1.115/1.514 — числа устойчивы, это не шум сэмпла.
# ВАЖНО: p50 (медиана actual_sold/expected_sold) > 1 во ВСЕХ трёх регионах —
# точка expected_sold систематически занижена на 4-14%. Эта правка чинит
# только ШИРИНУ/покрытие вилки по региону, а НЕ смещение точки. Калибровка
# asking→sold на более коротком (свежем) окне — отдельная незакрытая задача.
estimate_pi_low_mult_by_region: dict[int, float] = {77: 0.448, 50: 0.703, 66: 0.649}
estimate_pi_high_mult_by_region: dict[int, float] = {77: 1.440, 50: 1.500, 66: 1.584}
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
# Диагноз: estimator систематически промахивается по эре дома + размеру —
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366

View file

@ -3311,6 +3311,42 @@ def _analog_word_dative(n: int) -> str:
return "аналогу" if n == 1 else "аналогам"
def pi_multipliers_for_region(region_code: int | None) -> tuple[float, float]:
"""#pi-by-region: (low_mult, high_mult) калиброванного ~80% prediction interval для региона.
Скаляры `settings.estimate_pi_low_mult`/`estimate_pi_high_mult` (#1966) сняты
ТОЛЬКО на Екатеринбурге (обл.66) и молча применялись ко всем регионам. Замер
2026-09-16 (`--engine full --sample 2000 --since 2025-06-01 --spread scattered
--seed 42 --resolve-house-id --pi-report`) показал достигнутое покрытие 66.2%
(обл.66) / 71.2% (77, Москва) / 74.8% (50, Мособласть) вместо заявленных ~80%
те же множители тупее регионального рынка. Регион вне карты (или None)
скаляры-фолбэк + WARNING: ни падения, ни молчаливой подстановки чужого
региона (см. sber_region_series_name выше тот же паттерн).
"""
low = (
settings.estimate_pi_low_mult_by_region.get(region_code)
if region_code is not None
else None
)
high = (
settings.estimate_pi_high_mult_by_region.get(region_code)
if region_code is not None
else None
)
if low is not None and high is not None:
return low, high
logger.warning(
"PI multipliers: region_code=%r нет в estimate_pi_*_mult_by_region — беру "
"скаляры-фолбэк low=%.3f/high=%.3f (откалиброваны на обл.66). Если по "
"региону пошли сделки — сними p10/p90 и заведи в карте, иначе покрытие "
"вилки будет хуже заявленных ~80%%",
region_code,
settings.estimate_pi_low_mult,
settings.estimate_pi_high_mult,
)
return settings.estimate_pi_low_mult, settings.estimate_pi_high_mult
def _price_from_inputs(
*,
listings: list[dict],
@ -3344,6 +3380,11 @@ def _price_from_inputs(
# объяснение называло тот радиус, по которому реально искали. None →
# прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест).
search_radius_m: int | None = None,
# Региональные множители PI (#pi-by-region) — тот же target_region_code, что уже
# скоупит ratio_resolver/ДКП-коридор в estimate_quality (#3051 PR-A). None →
# pi_multipliers_for_region фолбэкается на скаляры (прежнее поведение
# байт-в-байт для оффлайн-вызывающих, у которых региона нет).
region_code: int | None = None,
) -> PricingResult:
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
@ -4102,8 +4143,11 @@ def _price_from_inputs(
# ~80% honest coverage. Старый IQR-производный band (asking-IQR × ratio)
# покрывал лишь ~55% реальных продаж при заявленном «диапазоне оценки».
# Множители low<1<high (дефолты) гарантируют low ≤ point ≤ high.
expected_sold_range_low = round(expected_sold_price * settings.estimate_pi_low_mult)
expected_sold_range_high = round(expected_sold_price * settings.estimate_pi_high_mult)
# #pi-by-region: множители региональные (pi_multipliers_for_region) — те же
# скаляры на всех регионах давали 66.2%/71.2%/74.8% вместо ~80%.
_pi_low_mult, _pi_high_mult = pi_multipliers_for_region(region_code)
expected_sold_range_low = round(expected_sold_price * _pi_low_mult)
expected_sold_range_high = round(expected_sold_price * _pi_high_mult)
else:
# legacy: диапазон производный от IQR аналогов (asking-IQR × ratio).
expected_sold_range_low = round(range_low * effective_ratio)
@ -5160,6 +5204,7 @@ async def estimate_quality(
geo=geo,
dadata_qc_geo=dadata.qc_geo if dadata else None,
search_radius_m=search_radius_m,
region_code=target_region_code,
)
# Unpack pricing result

View file

@ -2230,6 +2230,13 @@ def _predict_full_spine(
return res
pr = m._price_from_inputs(
# #pi-by-region: множители вилки теперь региональные, и живой прогон обязан
# считать ТЕ ЖЕ, что прод — иначе харнесс молча мерил бы старые скаляры и
# показывал покрытие, которого в проде нет. Регион берём из уже посчитанного
# target_region_code (строка выше), он зеркалит резолв estimate_quality.
# Замороженный реплей фикстуры (replay_fixture) сюда НЕ ходит и региона не
# передаёт — его путь остаётся байт-в-байт прежним, регресс-гейт не двигается.
region_code=target_region_code,
listings=listings,
area_m2=deal.area_m2,
rooms=deal.rooms,

View file

@ -273,14 +273,13 @@ def test_expected_sold_applied_when_ratio_present(monkeypatch: pytest.MonkeyPatc
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
# #1966: expected_sold range is now a calibrated ~80% PI around the POINT
# (point × [p10, p90] of sold/expected_sold), not the old asking-IQR × ratio band.
from app.core.config import settings
# #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region
# (region 66, EKB fixture geo), not the raw scalar (66's high != scalar high).
from app.services.estimator import pi_multipliers_for_region
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
_low_mult, _high_mult = pi_multipliers_for_region(66)
assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult)
assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult)
assert (
est.expected_sold_range_low_rub
<= est.expected_sold_price_rub
@ -407,14 +406,12 @@ def test_expected_sold_fires_on_anchor_only_no_radius_comps(
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
# #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio).
from app.core.config import settings
# #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region.
from app.services.estimator import pi_multipliers_for_region
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
_low_mult, _high_mult = pi_multipliers_for_region(66)
assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult)
assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult)
def test_expected_sold_none_when_median_price_zero() -> None:

View file

@ -143,18 +143,16 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
)
# #1966: expected_sold range is now a calibrated ~80% PI around the point
# (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the
# asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high.
# asking-IQR band — the high arm legitimately exceeds range_high.
# The invariant we keep is the calibrated band itself + ordering low ≤ point ≤ high.
from app.core.config import settings
# #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region.
from app.services.estimator import pi_multipliers_for_region
_low_mult, _high_mult = pi_multipliers_for_region(66)
assert est.expected_sold_range_high_rub is not None
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult)
assert est.expected_sold_range_low_rub is not None
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult)
assert (
est.expected_sold_range_low_rub
<= est.expected_sold_price_rub
@ -174,14 +172,12 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
# #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio).
from app.core.config import settings
# #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region.
from app.services.estimator import pi_multipliers_for_region
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
_low_mult, _high_mult = pi_multipliers_for_region(66)
assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult)
assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult)
assert est.expected_sold_price_rub < est.median_price_rub

View file

@ -519,14 +519,12 @@ def test_expected_sold_consistent_with_blended_median() -> None:
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки, не asking-IQR × ratio.
from app.core.config import settings
# #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region.
from app.services.estimator import pi_multipliers_for_region
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
_low_mult, _high_mult = pi_multipliers_for_region(66)
assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult)
assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult)
# Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы.
assert est.expected_sold_price_rub == round(12_000_000 * ratio)

View file

@ -0,0 +1,161 @@
"""Regional prediction-interval multipliers (#1966 follow-up, 2026-09-16).
`estimate_pi_low_mult`/`estimate_pi_high_mult` (#1966) were calibrated ONLY on
Yekaterinburg (region 66, n=2366) and applied blindly to every region. A prod
backtest (`--engine full --sample 2000 --since 2025-06-01 --spread scattered
--seed 42 --resolve-house-id --pi-report`, hedonic correction OFF) showed the
achieved coverage of the "~80%" band drifting by region: 66.2% (66), 71.2%
(77, Moscow), 74.8% (50, Moscow oblast) instead of the claimed ~80%.
`pi_multipliers_for_region` resolves per-region p10/p90 multipliers with a
scalar fallback for regions outside the map mirrors `sber_region_series_name`.
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")
import logging
from app.core.config import settings
from app.services import estimator
from app.services.estimator import pi_multipliers_for_region
from app.services.geocoder import GeocodeResult
# ── helpers (mirror tests/test_estimator_range_floor.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],
ratio: float,
region_code: int | None,
area_m2: float = 50.0,
rooms: int | None = 2,
) -> estimator.PricingResult:
def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
return ratio, "per_rooms"
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=None,
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,
region_code=region_code,
)
# ── 1. Resolver: regional maps differ from Yekaterinburg (region 66) ──────────
def test_moscow_and_moscow_oblast_multipliers_differ_from_ekb() -> None:
ekb_low, ekb_high = pi_multipliers_for_region(66)
msk_low, msk_high = pi_multipliers_for_region(77)
mo_low, mo_high = pi_multipliers_for_region(50)
assert (msk_low, msk_high) != (ekb_low, ekb_high)
assert (mo_low, mo_high) != (ekb_low, ekb_high)
# Sanity against the measured 2026-09-16 backtest table.
assert msk_low == 0.448
assert msk_high == 1.440
assert mo_low == 0.703
assert mo_high == 1.500
assert ekb_low == 0.649
assert ekb_high == 1.584
# ── 2. Region outside the map (or None) falls back to the scalars ─────────────
def test_unknown_region_falls_back_to_scalars(caplog) -> None:
with caplog.at_level(logging.WARNING, logger=estimator.logger.name):
low, high = pi_multipliers_for_region(12345)
assert (low, high) == (settings.estimate_pi_low_mult, settings.estimate_pi_high_mult)
assert any("PI multipliers" in r.getMessage() for r in caplog.records)
def test_none_region_falls_back_to_scalars() -> None:
low, high = pi_multipliers_for_region(None)
assert (low, high) == (settings.estimate_pi_low_mult, settings.estimate_pi_high_mult)
# ── 3. Invariant from #1966: low < 1 < point ⇒ low ≤ point ≤ high ─────────────
def test_low_below_one_high_above_one_for_every_mapped_region() -> None:
all_regions = set(settings.estimate_pi_low_mult_by_region) | set(
settings.estimate_pi_high_mult_by_region
)
assert all_regions, "regional PI map must not be empty"
for region_code in all_regions:
low, high = pi_multipliers_for_region(region_code)
assert low < 1 < high, f"region {region_code}: low={low} high={high}"
# Scalar fallback carries the same invariant.
assert settings.estimate_pi_low_mult < 1 < settings.estimate_pi_high_mult
# ── 4. Integration: _price_from_inputs uses the REGIONAL multipliers ──────────
def test_price_from_inputs_uses_regional_multipliers_for_moscow() -> None:
ratio = 0.9
est = _call(listings=_lots(200_000.0), ratio=ratio, region_code=77)
low_mult, high_mult = pi_multipliers_for_region(77)
assert est.expected_sold_price is not None
assert est.expected_sold_range_low == round(est.expected_sold_price * low_mult)
assert est.expected_sold_range_high == round(est.expected_sold_price * high_mult)
# Same call with region 66 must give a DIFFERENT high arm (regions diverge).
est_ekb = _call(listings=_lots(200_000.0), ratio=ratio, region_code=66)
assert est_ekb.expected_sold_range_high != est.expected_sold_range_high
def test_price_from_inputs_region_none_falls_back_to_scalars() -> None:
ratio = 0.9
est = _call(listings=_lots(200_000.0), ratio=ratio, region_code=None)
assert est.expected_sold_price is not None
assert est.expected_sold_range_low == round(
est.expected_sold_price * settings.estimate_pi_low_mult
)
assert est.expected_sold_range_high == round(
est.expected_sold_price * settings.estimate_pi_high_mult
)

View file

@ -568,13 +568,12 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None:
assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки (point × [p10,p90]
# sold/expected_sold), не asking-IQR × ratio.
# #3xxx: multipliers are regional now — resolve via pi_multipliers_for_region.
from app.services.estimator import pi_multipliers_for_region
assert est.expected_sold_range_high_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_high_mult
)
assert est.expected_sold_range_low_rub == round(
est.expected_sold_price_rub * settings.estimate_pi_low_mult
)
_low_mult, _high_mult = pi_multipliers_for_region(66)
assert est.expected_sold_range_high_rub == round(est.expected_sold_price_rub * _high_mult)
assert est.expected_sold_range_low_rub == round(est.expected_sold_price_rub * _low_mult)
# Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел.
assert est.expected_sold_price_rub < est.median_price_rub
assert est.expected_sold_per_m2 < est.median_price_per_m2