fix(estimator): MAD-clip после similarity-weighting + sigma=0 guard (#audit-4)

sigma=0 guard: safe_area_sigma2/safe_floor_sigma2 предотвращает div/0 при
нулевом sigma из конфига. Post-weight MAD-clip (estimate_sb_clip_after_weight,
дефолт True) выполняется ПОСЛЕ Gaussian weighting — видовые/топ-юниты
с высоким ppm² больше не выкидываются до учёта similarity. За флагом.
This commit is contained in:
bot-backend 2026-06-20 15:01:36 +03:00
parent 17edac532b
commit 5b2a39cd5e

View file

@ -1721,25 +1721,28 @@ def _compute_same_building_anchor(
if floor_sigma > 0 and floor_target and total_floors_target and total_floors_target > 0:
target_pos = floor_target / total_floors_target
# #audit-4: sigma > 0 guard — при sigma=0 Gaussian exp(-x²/0) → div/0/NaN.
# area_sigma=0 (отключено) → нейтральный вес 1.0; floor_sigma=0 уже гейтится выше.
safe_area_sigma2 = 2.0 * sigma * sigma if sigma > 0 else 0.0
safe_floor_sigma2 = 2.0 * floor_sigma * floor_sigma if floor_sigma > 0 else 0.0
# 1. similarity weights (area × rooms × floor-position)
weights: list[float] = []
for c in comps:
a = c.get("area_m2")
if a and area_target > 0:
area_w = math.exp(-((math.log(a / area_target)) ** 2) / (2.0 * sigma * sigma))
if a and area_target > 0 and safe_area_sigma2 > 0:
area_w = math.exp(-((math.log(a / area_target)) ** 2) / safe_area_sigma2)
else:
area_w = 1.0 # площадь неизвестна → нейтральный area-вес
area_w = 1.0 # площадь неизвестна или sigma=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
floor_w = 1.0
if target_pos is not None:
if target_pos is not None and safe_floor_sigma2 > 0:
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_w = math.exp(-((comp_pos - target_pos) ** 2) / safe_floor_sigma2)
# компл без floor/total_floors → нейтральный floor-вес 1.0
weights.append(area_w * rooms_w * floor_w)
wsum = sum(weights)
@ -1748,6 +1751,39 @@ def _compute_same_building_anchor(
else:
anchor = _percentile(sorted(ppm2), 0.5)
# #audit-4: MAD-clip ПОСЛЕ similarity-weighting (за флагом estimate_sb_clip_after_weight).
# Видовые/топ-юниты с высоким ppm² могут быть выкинуты сырым clip'ом ДО weighting —
# они легитимны. После weighting (anchor = weighted mean) ищем выбросы
# относительно self-consistent взвешенного пространства.
# Используем те же effective_mad_k; если после clip < min_comps → anchor=None.
if settings.estimate_sb_clip_after_weight and n >= 2:
post_clip_idx = _mad_clip(ppm2, effective_mad_k)
if len(post_clip_idx) < min_comps:
logger.info(
"anchor post-weight MAD-clip #audit-4: %d comps → %d survived"
" (< min_comps=%d) → fallback",
n,
len(post_clip_idx),
min_comps,
)
return None
if len(post_clip_idx) < n:
logger.info(
"anchor post-weight MAD-clip #audit-4: %d comps → %d after k=%.1f×MAD",
n,
len(post_clip_idx),
effective_mad_k,
)
comps = [comps[i] for i in post_clip_idx]
ppm2 = [ppm2[i] for i in post_clip_idx]
weights = [weights[i] for i in post_clip_idx]
n = len(ppm2)
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. Условие по комнатам отсекает мелкие юниты во флагман-домах.
used_uplift = False