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:
parent
17edac532b
commit
5b2a39cd5e
1 changed files with 43 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue