fix(forecasting): _price_overlap zero-width discontinuity at §25.3 (#1224)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m40s
CI / backend-tests (pull_request) Successful in 6m37s
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m40s
CI / backend-tests (pull_request) Successful in 6m37s
Degenerate price band (own_min==own_max или c_lo==c_hi, оба разрешены CHECK 148 и Pydantic) внутри другой вилки возвращали 0.0 вместо 1.0: фильтр w>0 выкидывал нулевую ширину → 0/positive=0.0. Это рвало докстринг 'полное накрытие узкого = 1.0' и давало разрыв: [148k,152k]→1.0 vs [150k,150k]→0.0, занижая среднее каннибализации. Patch: вырожденные ширины обрабатываются ДО нормирования. lo<=hi → точка внутри другой вилки → 1.0, вне → 0.0. +inf-обе-премиум ветка перенесена в начало (избежать inf-inf=nan). +7 новых тестов в TestPriceOverlap. 220 special_indices тестов зелёные. Closes #1224
This commit is contained in:
parent
0c62d0b0c3
commit
582f384a5f
2 changed files with 54 additions and 10 deletions
|
|
@ -442,9 +442,15 @@ def _price_overlap(
|
|||
|
||||
Пересечение интервалов [c_lo,c_hi] и [o_lo,o_hi]; нормируем на УЖЕ узкую вилку
|
||||
(min ширины) → полное накрытие более узкого диапазона = 1.0 (стороны полностью
|
||||
конкурируют в этой ценовой зоне). Любая граница / вилка кандидата неизвестна или
|
||||
обе вилки нулевой ширины → None (ось НЕДОСТУПНА — НЕ фабрикуем 0). Открытая правая
|
||||
граница (премиум, +inf) поддержана: ширина считается от конечной нижней. PURE.
|
||||
конкурируют в этой ценовой зоне). Любая граница / вилка кандидата неизвестна →
|
||||
None (ось НЕДОСТУПНА — НЕ фабрикуем 0). Открытая правая граница (премиум, +inf)
|
||||
поддержана: ширина считается от конечной нижней.
|
||||
|
||||
Вырожденная вилка (point: own_min==own_max или c_lo==c_hi) обрабатывается ДО
|
||||
нормирования: точка ВНУТРИ другой вилки → 1.0 (полное накрытие узкого = вырожденная
|
||||
вилка нулевой ширины полностью «попадает» в широкую — предельный случай 1.0),
|
||||
точка ВНЕ → 0.0. Обе вилки — точки: совпадают → 1.0, различны → 0.0. Это устраняет
|
||||
разрыв [148k,152k]→1.0 vs [150k,150k]→0.0 (см. #1224). PURE.
|
||||
"""
|
||||
if candidate_band is None or own_lo is None or own_hi is None:
|
||||
return None
|
||||
|
|
@ -452,16 +458,20 @@ def _price_overlap(
|
|||
o_lo, o_hi = (own_lo, own_hi) if own_lo <= own_hi else (own_hi, own_lo)
|
||||
lo = max(c_lo, o_lo)
|
||||
hi = min(c_hi, o_hi)
|
||||
overlap = max(0.0, hi - lo)
|
||||
if not math.isfinite(overlap):
|
||||
# Обе вилки уходят в +inf (оба премиум) — считаем полным пересечением.
|
||||
# Обе вилки уходят в +inf (оба премиум) — считаем полным пересечением.
|
||||
# Проверяется ДО арифметики ширин (inf - inf = nan).
|
||||
if math.isinf(c_hi) and math.isinf(o_hi):
|
||||
return 1.0
|
||||
c_width = c_hi - c_lo
|
||||
o_width = o_hi - o_lo
|
||||
widths = [w for w in (c_width, o_width) if math.isfinite(w) and w > 0]
|
||||
if not widths:
|
||||
return None
|
||||
return _clamp01(overlap / min(widths))
|
||||
# Вырожденный случай (≥1 вилка — точка): фильтр w>0 ниже выкинул бы нулевую ширину
|
||||
# и дал бы 0/positive=0.0 даже когда точка ЛЕЖИТ внутри другой вилки. Это разрыв
|
||||
# ([148k,152k]→1.0 vs [150k,150k]→0.0) и противоречит докстрингу «накрытие узкого =
|
||||
# 1.0». Обрабатываем геометрией включения: точка внутри [lo,hi] → 1.0, вне → 0.0.
|
||||
if c_width == 0 or o_width == 0:
|
||||
return 1.0 if lo <= hi else 0.0
|
||||
overlap = max(0.0, hi - lo)
|
||||
return _clamp01(overlap / min(c_width, o_width))
|
||||
|
||||
|
||||
# Канонические ключи квартирографии (комнатность) — общее пространство, в котором
|
||||
|
|
|
|||
|
|
@ -366,6 +366,40 @@ class TestPriceOverlap:
|
|||
assert _price_overlap((120_000.0, 160_000.0), None, 200_000.0) is None
|
||||
assert _price_overlap((120_000.0, 160_000.0), 100_000.0, None) is None
|
||||
|
||||
def test_own_point_inside_candidate_is_one(self) -> None:
|
||||
# #1224: own_min==own_max (нулевая ширина — допустимо CHECK миграции 148 и
|
||||
# Pydantic own_project.py:75) ВНУТРИ вилки кандидата → 1.0, а не 0.0
|
||||
# (которое выдавал старый фильтр w>0). Полное накрытие узкого = 1.0 по
|
||||
# документированной семантике; разрыв [148k,152k]→1.0 vs [150k,150k]→0.0
|
||||
# устранён.
|
||||
assert _price_overlap((120_000.0, 160_000.0), 150_000.0, 150_000.0) == 1.0
|
||||
|
||||
def test_own_point_on_candidate_boundary_is_one(self) -> None:
|
||||
# точка на границе вилки кандидата (lo<=hi включительно) — внутри → 1.0.
|
||||
assert _price_overlap((120_000.0, 160_000.0), 120_000.0, 120_000.0) == 1.0
|
||||
assert _price_overlap((120_000.0, 160_000.0), 160_000.0, 160_000.0) == 1.0
|
||||
|
||||
def test_own_point_outside_candidate_is_zero(self) -> None:
|
||||
# точка ВНЕ вилки кандидата → 0.0 (нет ценовой конкуренции).
|
||||
assert _price_overlap((120_000.0, 160_000.0), 200_000.0, 200_000.0) == 0.0
|
||||
assert _price_overlap((120_000.0, 160_000.0), 100_000.0, 100_000.0) == 0.0
|
||||
|
||||
def test_candidate_point_inside_own_is_one(self) -> None:
|
||||
# симметричный случай: вырожденная вилка кандидата (c_lo==c_hi) ВНУТРИ
|
||||
# вилки нашего проекта → 1.0.
|
||||
assert _price_overlap((150_000.0, 150_000.0), 120_000.0, 160_000.0) == 1.0
|
||||
|
||||
def test_candidate_point_outside_own_is_zero(self) -> None:
|
||||
assert _price_overlap((200_000.0, 200_000.0), 120_000.0, 160_000.0) == 0.0
|
||||
|
||||
def test_both_points_equal_is_one(self) -> None:
|
||||
# обе стороны — одна и та же точка → полное накрытие = 1.0.
|
||||
assert _price_overlap((150_000.0, 150_000.0), 150_000.0, 150_000.0) == 1.0
|
||||
|
||||
def test_both_points_different_is_zero(self) -> None:
|
||||
# обе стороны — точки, но РАЗНЫЕ → 0.0.
|
||||
assert _price_overlap((150_000.0, 150_000.0), 200_000.0, 200_000.0) == 0.0
|
||||
|
||||
|
||||
class TestUnitMixSimilarity:
|
||||
def test_identical_mix_is_one(self) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue