diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 34916dcd..59a86f47 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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