From 5b2a39cd5e7c8ca542f7e35bd783240476e9d3c5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:01:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(estimator):=20MAD-clip=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20similarity-weighting=20+=20sigma=3D0=20guard=20(?= =?UTF-8?q?#audit-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. За флагом. --- tradein-mvp/backend/app/services/estimator.py | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) 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