feat(tradein): refine same-building anchor for within-building heterogeneity

Anchor over-priced cheaper/smaller units in flagship buildings (Хохрякова 48
3к/153: real 61M, est 100M). Two conservative levers: (1) tighten premium p70
uplift gate to area>=p66 AND rooms>=building-median (a 3-room in a 4-room-flagship
building no longer inherits the flagship ppm2); (2) floor-similarity Gaussian on
relative position floor/total_floors (estimate_sb_floor_sigma=0.25, 0 disables).
Offline-validated on 55 golden cases: zero coverage flips, elite medAPE 16.6->5.4,
3к/153 overshoot 64%->1.5%; flagships (Хохрякова 4к, Олимп пентхаус) stay covered.
ruff clean; 34 anchor tests + 259 broader green.

Refs #651 #677
This commit is contained in:
lekss361 2026-05-30 03:02:37 +03:00
parent 351ec88104
commit 6939bd11f7
3 changed files with 182 additions and 14 deletions

View file

@ -78,6 +78,13 @@ class Settings(BaseSettings):
estimate_sb_min_comps: int = 2 # стоп на первом тире с ≥ 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²)×(1tol)
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона

View file

@ -1009,7 +1009,8 @@ def _fetch_anchor_comps(
Excludes lots без price_per_m2. is_active=true. Best-effort: ([], None) на ошибке.
Returns (comps, tier) где tier {'A','C', None}. comps list dict с
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None).
ключами 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
@ -1028,7 +1029,7 @@ def _fetch_anchor_comps(
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms
SELECT price_per_m2, area_m2, rooms, floor, total_floors
FROM listings
WHERE is_active = true
AND price_per_m2 > 0
@ -1056,6 +1057,8 @@ def _fetch_anchor_comps(
"price_per_m2": int(r["price_per_m2"]),
"area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None,
"rooms": int(r["rooms"]) if r["rooms"] is not None else None,
"floor": int(r["floor"]) if r["floor"] is not None else None,
"total_floors": int(r["total_floors"]) if r["total_floors"] is not None else None,
}
for r in rows
if r["price_per_m2"]
@ -1077,7 +1080,7 @@ def _fetch_anchor_comps(
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms
SELECT price_per_m2, area_m2, rooms, floor, total_floors
FROM listings
WHERE is_active = true
AND price_per_m2 > 0
@ -1120,6 +1123,8 @@ def _fetch_anchor_comps(
"price_per_m2": int(r["price_per_m2"]),
"area_m2": float(r["area_m2"]) if r["area_m2"] is not None else None,
"rooms": int(r["rooms"]) if r["rooms"] is not None else None,
"floor": int(r["floor"]) if r["floor"] is not None else None,
"total_floors": int(r["total_floors"]) if r["total_floors"] is not None else None,
}
for r in rows
if r["price_per_m2"]
@ -1153,15 +1158,24 @@ def _compute_same_building_anchor(
tier: str,
sigma: float,
rooms_boost: float,
floor_target: int | None = None,
total_floors_target: int | None = None,
floor_sigma: float = 0.0,
) -> dict[str, Any] | None:
"""Чистая (testable без БД) свёртка комплов в anchor-оценку.
1. similarity-weighted mean ppm²: w_i = exp((ln(area_i/area_target))²/(2σ²))
× (rooms_boost если rooms_i==rooms_target). area_i=None area-вес 1.0
(нейтрально: Yandex-листинги без площади судим только по комнатам).
2. PREMIUM uplift (class-free): если target топ-юнит ДОМА (area_target p66
площадей комплов) И tier == 'A' берём weighted ~p70 верхний квантиль comp
ppm² вместо mean (консервативно, только вверх).
× (rooms_boost если rooms_i==rooms_target) × FLOOR-вес. area_i=None
area-вес 1.0 (нейтрально). FLOOR-вес (#680-WB) — Gaussian по ОТНОСИТЕЛЬНОЙ
вертикальной позиции pos=floor/total_floors: exp((pos_ipos_t)²/(2σ_f²)),
σ_f=floor_sigma. Прижимает якорь к комплам с похожим этажом (1-й/последний
и видовые этажи реально отличаются по цене). floor_sigma=0 ИЛИ нет floor у
target/компла вес 1.0 (выключено / нейтрально no regress).
2. PREMIUM uplift (class-free): target топ-юнит ДОМА (area p66 площадей
комплов) И rooms_target медианы комнат комплов И tier == 'A' берём
weighted ~p70 верхний квантиль ppm² (консервативно, только вверх). Условие
по комнатам (#680-WB) не даёт мелкокомнатному юниту во флагман-доме унаследовать
headline-премию флагмана (3к/153 в доме с 4к-флагманом цена флагмана).
3. haircut askingsold (banded по anchor ppm²): anchor_sold = anchor×(1haircut).
4. FSD = 0.07 + 0.25·CV(comp ppm²) + tier_penalty + n_penalty; range полуширина
= k·fsd. confidence-банд по fsd.
@ -1176,7 +1190,12 @@ def _compute_same_building_anchor(
return None
n = len(ppm2)
# 1. similarity weights
# target relative vertical position (None → floor-вес отключён/нейтрален).
target_pos: float | None = None
if floor_sigma > 0 and floor_target and total_floors_target and total_floors_target > 0:
target_pos = floor_target / total_floors_target
# 1. similarity weights (area × rooms × floor-position)
weights: list[float] = []
for c in comps:
a = c.get("area_m2")
@ -1186,19 +1205,35 @@ def _compute_same_building_anchor(
area_w = 1.0 # площадь неизвестна → нейтральный area-вес
rooms_match = rooms_target is not None and c.get("rooms") == rooms_target
rooms_w = rooms_boost if rooms_match else 1.0
weights.append(area_w * rooms_w)
floor_w = 1.0
if target_pos is not None:
cf = c.get("floor")
ctf = c.get("total_floors")
if cf and ctf and ctf > 0:
comp_pos = cf / ctf
floor_w = math.exp(
-((comp_pos - target_pos) ** 2) / (2.0 * floor_sigma * floor_sigma)
)
# компл без floor/total_floors → нейтральный floor-вес 1.0
weights.append(area_w * rooms_w * floor_w)
wsum = sum(weights)
if wsum > 0:
anchor = sum(w * p for w, p in zip(weights, ppm2, strict=True)) / wsum
else:
anchor = _percentile(sorted(ppm2), 0.5)
# 2. premium uplift — топ-юнит дома по площади (≥p66) И Tier A → weighted p70.
# 2. premium uplift — топ-юнит дома (площадь ≥ p66 И комнаты ≥ медианы) И Tier A
# → weighted p70. Условие по комнатам отсекает мелкие юниты во флагман-домах.
used_uplift = False
areas = [c.get("area_m2") for c in comps if c.get("area_m2")]
comp_rooms = [c.get("rooms") for c in comps if c.get("rooms") is not None]
if tier == "A" and areas and area_target > 0:
p66_area = _percentile(sorted(areas), 0.66)
if area_target >= p66_area:
rooms_ok = True
if rooms_target is not None and comp_rooms:
median_rooms = _percentile(sorted(comp_rooms), 0.5)
rooms_ok = rooms_target >= median_rooms
if area_target >= p66_area and rooms_ok:
p70 = _percentile(sorted(ppm2), 0.70)
if p70 > anchor:
anchor = p70
@ -1643,6 +1678,9 @@ async def estimate_quality(
tier=anchor_tier or "",
sigma=settings.estimate_sb_area_sigma,
rooms_boost=settings.estimate_sb_rooms_match_boost,
floor_target=payload.floor,
total_floors_target=payload.total_floors,
floor_sigma=settings.estimate_sb_floor_sigma,
)
if anchor is not None:
est_ppm2 = anchor["anchor_sold_ppm2"]

View file

@ -168,8 +168,20 @@ def test_normalize_no_house_number_returns_none_base() -> None:
# ── Pure anchor compute ─────────────────────────────────────────────────────
def _comp(ppm2: int, area: float | None = None, rooms: int | None = None) -> dict[str, Any]:
return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms}
def _comp(
ppm2: int,
area: float | None = None,
rooms: int | None = None,
floor: int | None = None,
total_floors: int | None = None,
) -> dict[str, Any]:
return {
"price_per_m2": ppm2,
"area_m2": area,
"rooms": rooms,
"floor": floor,
"total_floors": total_floors,
}
def test_anchor_premium_lift_hohryakova48() -> None:
@ -493,3 +505,114 @@ def test_anchor_exposes_comp_max() -> None:
assert res is not None
assert res["comp_max_ppm2"] == 500_000
assert res["comp_min_ppm2"] == 300_000
# ── #680-WB: within-building heterogeneity refine (uplift-gate + floor weight) ──
def test_anchor_uplift_gated_by_rooms_median_hohryakova_3k() -> None:
"""Зеркало Хохрякова 48 3к/153: дом с 4-комн. флагманом (комплы rooms=4 кроме
одного 3к). target 3-комн. 153м² (топ по площади, p66) БЕЗ refine получил бы
p70-uplift и унаследовал цену флагмана. С rooms-gate (3 < медианы=4) uplift НЕ
срабатывает anchor падает к similar-комплам, не к headline-флагману."""
comps = [
_comp(399_477, area=153.2, rooms=4, floor=8, total_floors=8),
_comp(683_995, area=146.2, rooms=4, floor=5, total_floors=14),
_comp(502_846, area=105.4, rooms=3, floor=6, total_floors=9),
_comp(683_994, area=146.2, rooms=4, floor=5, total_floors=15),
]
res = _compute_same_building_anchor(
comps,
area_target=153.2,
rooms_target=3,
tier="A",
sigma=0.18,
rooms_boost=1.6,
floor_target=8,
total_floors_target=8,
floor_sigma=0.25,
)
assert res is not None
# rooms_target 3 < median comp rooms 4 → uplift gated off.
assert res["used_uplift"] is False
# anchor НЕ должен быть на уровне флагмана (684k); прижат к похожим юнитам.
assert res["anchor_ppm2"] < 550_000
def test_anchor_uplift_still_fires_for_genuine_top_unit() -> None:
"""Топ-юнит дома (rooms ≥ медианы И площадь ≥p66) в Tier A → uplift ВСЁ ЕЩЁ
срабатывает, когда дорогой компл area-несимметричен и weighted mean занижает
(do-no-harm для настоящих флагманов). Зеркало test_anchor_premium_uplift_when_*
с rooms_target == медиане комнат комплов (gate проходит)."""
comps = [
_comp(900_000, area=60.0, rooms=3), # дорогой, малая площадь → area-вес низкий
_comp(300_000, area=200.0, rooms=3),
_comp(320_000, area=210.0, rooms=3),
]
res = _compute_same_building_anchor(
comps,
area_target=205.0, # топ по площади (≥p66)
rooms_target=3, # == median comp rooms 3 → rooms-gate проходит
tier="A",
sigma=0.18,
rooms_boost=1.6,
)
assert res is not None
# mean занижен дил. дорогим малометражным → uplift подтягивает к p70.
assert res["used_uplift"] is True
assert res["anchor_ppm2"] >= 500_000
def test_anchor_floor_weight_tilts_toward_similar_floor() -> None:
"""floor_sigma>0 → комплы с похожей вертикальной позицией (floor/total_floors)
весят больше. Дорогой видовой топ-этаж и дешёвый средний этаж той же площади;
target на среднем этаже anchor тянется к среднеэтажному компла (не к видовому).
target НЕ топ-юнит по площади (есть компл крупнее) uplift не маскирует floor."""
comps = [
_comp(300_000, area=60.0, rooms=2, floor=5, total_floors=20), # mid, как target
_comp(600_000, area=60.0, rooms=2, floor=20, total_floors=20), # видовой топ
_comp(310_000, area=120.0, rooms=3, floor=4, total_floors=20), # крупнее target
]
target_kw = dict(
area_target=60.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
floor_target=5,
total_floors_target=20,
)
off = _compute_same_building_anchor(comps, floor_sigma=0.0, **target_kw)
on = _compute_same_building_anchor(comps, floor_sigma=0.25, **target_kw)
assert off is not None and on is not None
assert off["used_uplift"] is False # target не топ по площади
# floor-вес прижимает к среднеэтажному (300k), уводя от видового топа (600k).
assert on["anchor_ppm2"] < off["anchor_ppm2"]
def test_anchor_floor_weight_neutral_when_data_missing() -> None:
"""Нет floor у компла/target → floor-вес 1.0 (нейтрально), не падает,
результат идентичен floor_sigma=0 (no regress на Yandex-листингах без этажа)."""
comps = [_comp(300_000, area=60.0, rooms=2), _comp(340_000, area=62.0, rooms=2)]
a = _compute_same_building_anchor(
comps,
area_target=60.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
floor_target=None,
total_floors_target=None,
floor_sigma=0.25,
)
b = _compute_same_building_anchor(
comps,
area_target=60.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
floor_sigma=0.0,
)
assert a is not None and b is not None
assert abs(a["anchor_ppm2"] - b["anchor_ppm2"]) < 1.0