МЕРА/оценщик: диапазон якоря закреплён тестом на FSD_K, мёртвый гейт gate_thin удалён, недостижимость guardrail под инвариантом (#3590) #3593
5 changed files with 168 additions and 50 deletions
|
|
@ -539,11 +539,10 @@ SB_SMALL_N_THRESHOLD = 10
|
|||
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
|
||||
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
|
||||
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
|
||||
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
|
||||
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
|
||||
# FSD<0.15) проходят без изменений.
|
||||
SB_GATE_MIN_N = 3
|
||||
SB_GATE_MAX_FSD = 0.20
|
||||
# #audit-1: low-confidence гейт якоря — confidence="low" (FSD > 0.20) → fallback на
|
||||
# радиусную медиану. #3590: второе условие «n < 3 И FSD > 0.20» (SB_GATE_MIN_N /
|
||||
# SB_GATE_MAX_FSD) удалено как мёртвое: якорь возвращается только при n ≥ SB_MIN_COMPS,
|
||||
# а FSD > 0.20 и так даёт confidence="low".
|
||||
|
||||
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||
|
|
@ -3757,21 +3756,16 @@ def _price_from_inputs(
|
|||
anchor = None
|
||||
|
||||
# #audit-1: low-confidence gate.
|
||||
if anchor is not None:
|
||||
gate_low = anchor["confidence"] == "low"
|
||||
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
|
||||
if gate_low or gate_thin:
|
||||
logger.info(
|
||||
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
|
||||
" → suppressed (gate_low=%s gate_thin=%s) → radius fallback",
|
||||
anchor_tier,
|
||||
anchor["n"],
|
||||
anchor["fsd"],
|
||||
anchor["confidence"],
|
||||
gate_low,
|
||||
gate_thin,
|
||||
)
|
||||
anchor = None
|
||||
if anchor is not None and anchor["confidence"] == "low":
|
||||
logger.info(
|
||||
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
|
||||
" → suppressed → radius fallback",
|
||||
anchor_tier,
|
||||
anchor["n"],
|
||||
anchor["fsd"],
|
||||
anchor["confidence"],
|
||||
)
|
||||
anchor = None
|
||||
|
||||
# #2661: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три:
|
||||
# _compute_same_building_anchor вернула None (комплов меньше min_comps, в т.ч.
|
||||
|
|
@ -3798,7 +3792,10 @@ def _price_from_inputs(
|
|||
# к пред-clip списку, а не падаем KeyError.
|
||||
anchor_comps_used = anchor.get("comps") or anchor_comps
|
||||
est_ppm2 = anchor["anchor_ppm2"]
|
||||
# PREMIUM GUARDRAIL (hard).
|
||||
# PREMIUM GUARDRAIL (hard). #3590: недостижимо по построению: est_ppm2 ≥ comp_min —
|
||||
# цена якоря есть взвешенное среднее / медиана / p70 того же пула, минимум
|
||||
# которого и есть comp_min_ppm2 (инвариант держит тест в
|
||||
# test_estimator_audit_fixes.py). Защиту оставляем на случай смены формулы якоря.
|
||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
|
||||
if est_ppm2 < floor_ppm2:
|
||||
est_ppm2 = floor_ppm2
|
||||
|
|
|
|||
|
|
@ -50,14 +50,15 @@ _PROD_VALUES: dict[str, tuple[str, float]] = {
|
|||
"SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10),
|
||||
"ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5),
|
||||
"FSD_K": ("estimate_fsd_k", 1.65),
|
||||
"SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3),
|
||||
"SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20),
|
||||
}
|
||||
|
||||
# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены.
|
||||
# Поля, чьи ветки из кода удалены: выключатели, схлопнутые к боевому True, и мёртвые гейты.
|
||||
_REMOVED_SWITCHES = (
|
||||
"estimate_dedup_analogs_enabled", # #2378
|
||||
"estimate_expected_sold_le_asking", # #2380
|
||||
# #3590: мёртвый гейт якоря «n < 3 И FSD > 0.20» удалён вместе с константами.
|
||||
"estimate_sb_gate_min_n",
|
||||
"estimate_sb_gate_max_fsd",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ sys.modules.setdefault("weasyprint", MagicMock())
|
|||
import pytest # noqa: E402
|
||||
|
||||
from app.services.estimator import ( # noqa: E402
|
||||
SB_AREA_SIGMA,
|
||||
SB_FLOOR_SIGMA,
|
||||
SB_MAD_K,
|
||||
SB_MIN_COMPS,
|
||||
SB_ROOMS_MATCH_BOOST,
|
||||
_compute_same_building_anchor,
|
||||
)
|
||||
|
||||
|
|
@ -86,32 +91,116 @@ def test_fix1_healthy_anchor_not_suppressed() -> None:
|
|||
assert anchor["fsd"] < 0.20, f"FSD={anchor['fsd']} ожидается < 0.20"
|
||||
|
||||
|
||||
def test_fix1_thin_n_high_fsd_triggers_gate() -> None:
|
||||
"""n<gate_min_n И FSD>gate_max_fsd → гейт срабатывает (проверяем условие)."""
|
||||
# 2 компла с умеренным разбросом: n=2 < gate_min_n=3
|
||||
comps = [_make_comp(150_000), _make_comp(220_000)]
|
||||
anchor = _compute_same_building_anchor(
|
||||
comps,
|
||||
area_target=50.0,
|
||||
rooms_target=2,
|
||||
tier="C",
|
||||
sigma=0.18,
|
||||
rooms_boost=1.6,
|
||||
min_comps=1, # разрешаем построить якорь
|
||||
)
|
||||
if anchor is None:
|
||||
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
|
||||
from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N
|
||||
def _c(ppm2: float, area: float | None = 50.0, rooms: int | None = 2, **kw: Any) -> dict:
|
||||
return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms, **kw}
|
||||
|
||||
# Проверяем условие gate_thin напрямую
|
||||
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
|
||||
# n=2 < 3 — должен сработать если FSD тоже высокий
|
||||
assert anchor["n"] == 2
|
||||
# FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3;
|
||||
# tier_penalty=0.05 (C); CV = std/mean для 2 элементов
|
||||
# Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20
|
||||
if anchor["fsd"] > SB_GATE_MAX_FSD:
|
||||
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
|
||||
|
||||
# (tier, area_target, rooms_target, floor_target, total_floors_target, комплы)
|
||||
_ANCHOR_POOLS: list[tuple[str, float, int | None, int | None, int | None, list[dict]]] = [
|
||||
# Tier A, топ-юнит дороже соседей по дому → uplift к p70.
|
||||
(
|
||||
"A",
|
||||
120.0,
|
||||
3,
|
||||
None,
|
||||
None,
|
||||
[_c(180_000, 120.0, 3), _c(200_000, 60.0), _c(210_000, 70.0), _c(220_000, 80.0)],
|
||||
),
|
||||
# Tier A, широкий внутридомовой разброс (Хохрякова 48), без uplift.
|
||||
(
|
||||
"A",
|
||||
146.2,
|
||||
4,
|
||||
5,
|
||||
14,
|
||||
[
|
||||
_c(399_478, 153.2, 3),
|
||||
_c(472_298, 110.1, 3),
|
||||
_c(683_995, 146.2, 4),
|
||||
_c(510_000, 125.0, 3),
|
||||
],
|
||||
),
|
||||
# равные цены: взвешенное среднее обязано не уйти ниже минимума.
|
||||
("A", 64.0, 3, None, None, [_c(112_500, 64.0, 3)] * 4),
|
||||
# Tier C, малая выборка: жёсткий MAD-клип срезает 600k.
|
||||
(
|
||||
"C",
|
||||
50.0,
|
||||
2,
|
||||
3,
|
||||
9,
|
||||
[_c(150_000), _c(155_000), _c(160_000), _c(158_000), _c(152_000), _c(600_000)],
|
||||
),
|
||||
# тир вне A/C, широкий разброс → FSD > 0.20.
|
||||
("", 50.0, 2, None, None, [_c(100_000), _c(180_000), _c(260_000), _c(340_000)]),
|
||||
# вес собран на самом дешёвом компле (площадь, комнаты, этаж), площадь не у всех.
|
||||
(
|
||||
"A",
|
||||
40.0,
|
||||
1,
|
||||
2,
|
||||
10,
|
||||
[
|
||||
_c(120_000, 40.0, 1, floor=2, total_floors=10),
|
||||
_c(130_000, 90.0, 3, floor=9, total_floors=10),
|
||||
_c(135_000, None, None),
|
||||
_c(128_000, 70.0, 2),
|
||||
_c(140_000, 85.0, 3),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_fix1_anchor_invariants_make_thin_gate_and_guardrail_dead() -> None:
|
||||
"""#3590: инварианты, на которых держатся удаление gate_thin и недостижимость
|
||||
SB_GUARDRAIL_TOL. Если какой-то покраснел — ветка ожила, вернуть и покрыть.
|
||||
|
||||
1. n ≥ SB_MIN_COMPS: якорь с n < 3 не выходит из функции (гейт «n < 3 И
|
||||
FSD > 0.20» удалён как недостижимый).
|
||||
2. FSD > 0.20 ⇒ confidence == "low": оставшийся гейт по confidence покрывает
|
||||
и второе условие удалённого гейта.
|
||||
3. anchor_ppm2 ≥ comp_min_ppm2: пол comp_min×(1−SB_GUARDRAIL_TOL) не срабатывает.
|
||||
"""
|
||||
results = []
|
||||
for tier, area, rooms, floor, total_floors, comps in _ANCHOR_POOLS:
|
||||
a = _compute_same_building_anchor(
|
||||
comps,
|
||||
area_target=area,
|
||||
rooms_target=rooms,
|
||||
tier=tier,
|
||||
sigma=SB_AREA_SIGMA,
|
||||
rooms_boost=SB_ROOMS_MATCH_BOOST,
|
||||
floor_target=floor,
|
||||
total_floors_target=total_floors,
|
||||
floor_sigma=SB_FLOOR_SIGMA,
|
||||
min_comps=SB_MIN_COMPS,
|
||||
mad_k=SB_MAD_K,
|
||||
)
|
||||
assert a is not None, (tier, comps)
|
||||
assert a["n"] >= SB_MIN_COMPS, a
|
||||
assert a["fsd"] <= 0.20 or a["confidence"] == "low", a
|
||||
assert a["anchor_ppm2"] >= a["comp_min_ppm2"], a
|
||||
results.append((len(comps), a))
|
||||
|
||||
# Пулы проходят ветки, где инвариант мог бы сломаться, а не только тривиальные.
|
||||
assert any(a["used_uplift"] for _, a in results), "нет пула с uplift к p70"
|
||||
assert any(a["n"] < n for n, a in results), "нет пула с MAD-клипом"
|
||||
assert any(a["fsd"] > 0.20 for _, a in results), "нет пула с FSD > 0.20"
|
||||
|
||||
# Три компла — меньше SB_MIN_COMPS: якоря нет вовсе.
|
||||
three = [_c(150_000), _c(300_000), _c(160_000)]
|
||||
assert (
|
||||
_compute_same_building_anchor(
|
||||
three,
|
||||
area_target=50.0,
|
||||
rooms_target=2,
|
||||
tier="C",
|
||||
sigma=SB_AREA_SIGMA,
|
||||
rooms_boost=SB_ROOMS_MATCH_BOOST,
|
||||
min_comps=SB_MIN_COMPS,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -524,6 +524,37 @@ def test_estimate_anchor_gets_prod_weights() -> None:
|
|||
}
|
||||
|
||||
|
||||
def test_estimate_anchor_range_halfwidth_is_fsd_k_times_fsd() -> None:
|
||||
"""#3590: полуширина видимого диапазона якоря = FSD_K × fsd, FSD_K = 1.65.
|
||||
|
||||
Гейт регрессии диапазон по объявлениям не видит (FSD_K 1.5 меняла 857 сделок
|
||||
из 1600 при зелёном гейте). Ожидание — литерал 1.65, а не импорт FSD_K:
|
||||
импорт поехал бы вместе с мутацией и тест остался бы зелёным.
|
||||
Спред комплов нулевой (min = max = якорь), чтобы диапазон задавал только FSD.
|
||||
"""
|
||||
fsd = 0.10
|
||||
fake_anchor = {
|
||||
"anchor_ppm2": 500_000.0,
|
||||
"anchor_sold_ppm2": 480_000.0,
|
||||
"fsd": fsd,
|
||||
"confidence": "medium",
|
||||
"n": len(_SB_COMPS_PREMIUM),
|
||||
"cv": 0.02,
|
||||
"comp_min_ppm2": 500_000.0,
|
||||
"comp_max_ppm2": 500_000.0,
|
||||
"used_uplift": False,
|
||||
"haircut": 0.04,
|
||||
"comps": list(_SB_COMPS_PREMIUM),
|
||||
}
|
||||
with patch("app.services.estimator._compute_same_building_anchor", return_value=fake_anchor):
|
||||
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
|
||||
point = est.median_price_rub
|
||||
assert point == int(500_000 * 146.2) # headline от якоря, без ремонта и сдвигов
|
||||
half = 1.65 * fsd
|
||||
assert abs((point - est.range_low_rub) / point - half) < 1e-6, est.range_low_rub
|
||||
assert abs((est.range_high_rub - point) / point - half) < 1e-6, est.range_high_rub
|
||||
|
||||
|
||||
def test_estimate_anchor_fires_without_radius_analogs() -> None:
|
||||
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
|
||||
same-building комплы есть → якорь обязан сработать и дать median>0.
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@ export interface SourceGroup {
|
|||
* ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок
|
||||
* — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV
|
||||
* / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана
|
||||
* переписывается на `new_median` с весом `estimate_imv_blend_weight`,
|
||||
* переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`,
|
||||
* объяснение дополняется «Оценка скорректирована по…», а `sources_used`
|
||||
* пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной
|
||||
* странице то, чего код не делает.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue