МЕРА: якорь «тот же дом» и IMV-blend — константы движка, не настройки (#2381)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 25s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 2m35s
CI Trade-In / backend-tests (pull_request) Successful in 6m20s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 25s
CI Trade-In / browser-tests (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 / frontend-checks (pull_request) Successful in 2m35s
CI Trade-In / backend-tests (pull_request) Successful in 6m20s
Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей Settings перенесены в estimator.py константами с теми же значениями: IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA 0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05, SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10, ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD 0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая проверка `tier_c_mult > 0` (константа против нуля) убрана. Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma, guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один поведенческий тест и ни гейт, их держит только тест боевых значений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
883c741a63
commit
31c31f8f82
11 changed files with 95 additions and 114 deletions
|
|
@ -384,17 +384,6 @@ class Settings(BaseSettings):
|
|||
dadata_api_token: str | None = None
|
||||
dadata_api_secret: str | None = None
|
||||
|
||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
|
||||
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
|
||||
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
|
||||
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
|
||||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||
|
||||
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
||||
|
|
@ -414,25 +403,6 @@ class Settings(BaseSettings):
|
|||
# False ⇒ прежнее поведение целиком, без релиза.
|
||||
estimate_require_local_evidence: bool = True
|
||||
|
||||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||||
# и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
|
||||
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
|
||||
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
|
||||
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
|
||||
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
|
||||
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
|
||||
estimate_sb_floor_sigma: float = 0.25
|
||||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
||||
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
||||
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
||||
|
|
@ -527,33 +497,7 @@ class Settings(BaseSettings):
|
|||
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||||
estimate_hedonic_factor_min: float = 0.75
|
||||
estimate_hedonic_factor_max: float = 1.30
|
||||
# ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ──
|
||||
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
||||
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
|
||||
estimate_sb_mad_k_small_n: float = 2.5
|
||||
estimate_sb_small_n_threshold: int = 10
|
||||
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
|
||||
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
||||
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
||||
estimate_anchor_tier_c_corridor_mult: float = 1.5
|
||||
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||||
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||
# ratio — не затрагиваются.
|
||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||
|
||||
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
|
||||
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
|
||||
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
|
||||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||||
estimate_sb_gate_min_n: int = 3
|
||||
estimate_sb_gate_max_fsd: float = 0.20
|
||||
|
||||
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
||||
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
|
|||
|
||||
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
||||
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
||||
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
|
||||
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
|
||||
ценовой шкале. None если для дома нет свежей IMV-записи.
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -479,6 +479,44 @@ OUTLIER_TUKEY_K_SMALL = 1.0
|
|||
# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0
|
||||
# для trade-in физически невозможен, это артефакт high-price бакета.
|
||||
|
||||
# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает
|
||||
# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV
|
||||
# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем
|
||||
# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT
|
||||
# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем.
|
||||
IMV_BLEND_WEIGHT = 0.5
|
||||
IMV_BLEND_THRESHOLD = 1.15
|
||||
|
||||
# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная
|
||||
# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт −15-25%. v2
|
||||
# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по
|
||||
# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded
|
||||
# по ppm²), hard guardrail и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов
|
||||
SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||
SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target
|
||||
# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции
|
||||
# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену
|
||||
# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия
|
||||
# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%).
|
||||
SB_FLOOR_SIGMA = 0.25
|
||||
SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
|
||||
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
|
||||
SB_MAD_K_SMALL_N = 2.5
|
||||
SB_SMALL_N_THRESHOLD = 10
|
||||
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
|
||||
# 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
|
||||
|
||||
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
||||
|
|
@ -2655,7 +2693,7 @@ def _fetch_anchor_comps(
|
|||
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
|
||||
floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB).
|
||||
"""
|
||||
min_comps = settings.estimate_sb_min_comps
|
||||
min_comps = SB_MIN_COMPS
|
||||
|
||||
# ── Tier A: same building ────────────────────────────────────────────────
|
||||
street, base_no, letter = _normalize_building_key(address)
|
||||
|
|
@ -3038,12 +3076,8 @@ def _compute_same_building_anchor(
|
|||
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
|
||||
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
|
||||
effective_mad_k = mad_k
|
||||
if (
|
||||
tier != "A"
|
||||
and settings.estimate_sb_mad_k_small_n < mad_k
|
||||
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
|
||||
):
|
||||
effective_mad_k = settings.estimate_sb_mad_k_small_n
|
||||
if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
|
||||
effective_mad_k = SB_MAD_K_SMALL_N
|
||||
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
|
||||
if len(surviving_idx) < min_comps:
|
||||
logger.info(
|
||||
|
|
@ -3654,32 +3688,28 @@ def _price_from_inputs(
|
|||
area_target=area_m2,
|
||||
rooms_target=rooms,
|
||||
tier=anchor_tier or "",
|
||||
sigma=settings.estimate_sb_area_sigma,
|
||||
rooms_boost=settings.estimate_sb_rooms_match_boost,
|
||||
sigma=SB_AREA_SIGMA,
|
||||
rooms_boost=SB_ROOMS_MATCH_BOOST,
|
||||
floor_target=floor,
|
||||
total_floors_target=total_floors,
|
||||
floor_sigma=settings.estimate_sb_floor_sigma,
|
||||
min_comps=settings.estimate_sb_min_comps,
|
||||
mad_k=settings.estimate_sb_mad_k,
|
||||
floor_sigma=SB_FLOOR_SIGMA,
|
||||
min_comps=SB_MIN_COMPS,
|
||||
mad_k=SB_MAD_K,
|
||||
)
|
||||
|
||||
# #1795 шаг 3: гейт Tier C.
|
||||
if (
|
||||
anchor is not None
|
||||
and anchor_tier == "C"
|
||||
and settings.estimate_anchor_tier_c_corridor_mult > 0
|
||||
):
|
||||
if anchor is not None and anchor_tier == "C":
|
||||
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
|
||||
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
|
||||
else:
|
||||
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
|
||||
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult
|
||||
gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT
|
||||
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
|
||||
logger.info(
|
||||
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
|
||||
" → keep radius median (anchor suppressed)",
|
||||
int(anchor["anchor_ppm2"]),
|
||||
settings.estimate_anchor_tier_c_corridor_mult,
|
||||
ANCHOR_TIER_C_CORRIDOR_MULT,
|
||||
int(gate_threshold),
|
||||
)
|
||||
anchor = None
|
||||
|
|
@ -3687,10 +3717,7 @@ def _price_from_inputs(
|
|||
# #audit-1: low-confidence gate.
|
||||
if anchor is not None:
|
||||
gate_low = anchor["confidence"] == "low"
|
||||
gate_thin = (
|
||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
||||
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
|
||||
)
|
||||
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"
|
||||
|
|
@ -3730,13 +3757,13 @@ def _price_from_inputs(
|
|||
anchor_comps_used = anchor.get("comps") or anchor_comps
|
||||
est_ppm2 = anchor["anchor_ppm2"]
|
||||
# PREMIUM GUARDRAIL (hard).
|
||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
|
||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
|
||||
if est_ppm2 < floor_ppm2:
|
||||
est_ppm2 = floor_ppm2
|
||||
new_ppm2 = est_ppm2 * repair_coef
|
||||
point = int(new_ppm2 * area_m2)
|
||||
# FSD-диапазон.
|
||||
half = settings.estimate_fsd_k * anchor["fsd"]
|
||||
half = FSD_K * anchor["fsd"]
|
||||
new_range_low = int(point * max(0.0, 1.0 - half))
|
||||
new_range_high = int(point * (1.0 + half))
|
||||
# Спред комплов.
|
||||
|
|
@ -3893,8 +3920,8 @@ def _price_from_inputs(
|
|||
area=area_m2,
|
||||
anchor_total=anchor_total,
|
||||
anchor_higher=anchor_higher,
|
||||
weight=settings.estimate_imv_blend_weight,
|
||||
threshold=settings.estimate_imv_blend_threshold,
|
||||
weight=IMV_BLEND_WEIGHT,
|
||||
threshold=IMV_BLEND_THRESHOLD,
|
||||
market_count=(
|
||||
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
||||
),
|
||||
|
|
@ -3906,7 +3933,7 @@ def _price_from_inputs(
|
|||
median_price,
|
||||
new_median,
|
||||
anchor_used,
|
||||
settings.estimate_imv_blend_weight,
|
||||
IMV_BLEND_WEIGHT,
|
||||
range_high,
|
||||
new_range_high,
|
||||
)
|
||||
|
|
@ -4277,7 +4304,7 @@ def _price_from_inputs(
|
|||
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
|
||||
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
|
||||
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
|
||||
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
|
||||
# own MAD-clip drops comps below SB_MIN_COMPS), it returns None
|
||||
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
|
||||
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
|
||||
# but the stale tier flag falsely reads as "anchor claimed the headline"
|
||||
|
|
|
|||
|
|
@ -37,6 +37,21 @@ _PROD_VALUES: dict[str, tuple[str, float]] = {
|
|||
"RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8),
|
||||
"OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15),
|
||||
"OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0),
|
||||
# #2381, #651/#652 same-building anchor + IMV-blend
|
||||
"IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5),
|
||||
"IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15),
|
||||
"SB_MIN_COMPS": ("estimate_sb_min_comps", 4),
|
||||
"SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18),
|
||||
"SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6),
|
||||
"SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25),
|
||||
"SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05),
|
||||
"SB_MAD_K": ("estimate_sb_mad_k", 3.5),
|
||||
"SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5),
|
||||
"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-ветки из кода удалены.
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp(
|
|||
|
||||
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
|
||||
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
|
||||
# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
|
||||
# SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
|
||||
_OTHER_COMPS = [
|
||||
_comp(
|
||||
source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5
|
||||
|
|
@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke
|
|||
После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_
|
||||
anchor → est.analogs содержит физлот ровно ОДИН раз (n=1).
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services import estimator
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
||||
m.setattr(estimator, "SB_MIN_COMPS", 1)
|
||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||
|
||||
assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя
|
||||
|
|
@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat
|
|||
(постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2
|
||||
при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но
|
||||
нечестно. После фикса n_analogs=1, синхронно с analogs."""
|
||||
from app.core.config import settings
|
||||
from app.services import estimator
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
||||
m.setattr(estimator, "SB_MIN_COMPS", 1)
|
||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||
|
||||
assert est.n_analogs == len(est.analogs) == 1
|
||||
|
|
|
|||
|
|
@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None:
|
|||
)
|
||||
if anchor is None:
|
||||
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N
|
||||
|
||||
# Проверяем условие gate_thin напрямую
|
||||
gate_thin = (
|
||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
||||
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
|
||||
)
|
||||
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"] > settings.estimate_sb_gate_max_fsd:
|
||||
if anchor["fsd"] > SB_GATE_MAX_FSD:
|
||||
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
|
||||
|
||||
|
||||
|
|
@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
|||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_sb_mad_k_small_n = 2.5
|
||||
mock_settings.estimate_sb_small_n_threshold = 10
|
||||
mock_settings.avito_imv_thin_market_threshold = 10
|
||||
mock_settings.sber_index_max_age_days = 35
|
||||
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = {
|
|||
|
||||
|
||||
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
|
||||
"""N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for
|
||||
"""N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for
|
||||
Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`).
|
||||
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
|
||||
street+floor+area+price physical key) treats them as distinct units, not
|
||||
|
|
@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
|
|||
"period_months": 12,
|
||||
}
|
||||
db = MagicMock()
|
||||
# >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active
|
||||
# >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active
|
||||
# listings are EKB) and necessary for Tier A to actually fire pre-fix.
|
||||
db.execute.side_effect = _fake_anchor_sql_execute(
|
||||
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),
|
||||
|
|
|
|||
|
|
@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None:
|
|||
|
||||
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
|
||||
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
|
||||
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
|
||||
``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
|
||||
дальше по коду он читается как «headline построил якорь».
|
||||
"""
|
||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4
|
||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4
|
||||
radius_median_price = int(100_000 * 50.0)
|
||||
pr = _call(
|
||||
listings=_lots(100_000, n=5),
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [
|
|||
]
|
||||
|
||||
# Same-building комплы Хохрякова 48 (флагман 684k внутри).
|
||||
# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755).
|
||||
# 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755).
|
||||
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [
|
||||
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
|
||||
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
|
||||
|
|
@ -527,7 +527,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
|
|||
|
||||
def test_estimate_economy_no_regression() -> None:
|
||||
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
|
||||
4 компла — satisfies estimate_sb_min_comps=4 (#755).
|
||||
4 компла — satisfies SB_MIN_COMPS=4 (#755).
|
||||
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
|
||||
(все отклонения << 3.5×MAD при tight spread)."""
|
||||
eco_comps = [
|
||||
|
|
@ -596,7 +596,7 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None:
|
|||
def test_estimate_range_covers_same_building_comp_spread() -> None:
|
||||
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
|
||||
вылетает за диапазон — зеркало 8 Марта 204Г view-кейса).
|
||||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
||||
4 компла — satisfies SB_MIN_COMPS=4 (#755)."""
|
||||
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
|
||||
view_comps = [
|
||||
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
|
||||
|
|
@ -663,7 +663,7 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
|
|||
def test_estimate_analogs_pass_through_display_fields() -> None:
|
||||
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
|
||||
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
|
||||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
||||
4 компла — satisfies SB_MIN_COMPS=4 (#755)."""
|
||||
comps_with_display = [
|
||||
{
|
||||
"price_per_m2": 399_478,
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
|||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||
|
|
@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
|||
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||
assert tier is None
|
||||
|
|
@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
|||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||
assert tier is None
|
||||
|
|
@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
|
|||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||
|
|
@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
|||
),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||
|
|
@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
|||
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||
|
|
|
|||
|
|
@ -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