fix(forecasting): _price_overlap zero-width discontinuity (#1224) #1277
2 changed files with 54 additions and 10 deletions
|
|
@ -442,9 +442,15 @@ def _price_overlap(
|
||||||
|
|
||||||
Пересечение интервалов [c_lo,c_hi] и [o_lo,o_hi]; нормируем на УЖЕ узкую вилку
|
Пересечение интервалов [c_lo,c_hi] и [o_lo,o_hi]; нормируем на УЖЕ узкую вилку
|
||||||
(min ширины) → полное накрытие более узкого диапазона = 1.0 (стороны полностью
|
(min ширины) → полное накрытие более узкого диапазона = 1.0 (стороны полностью
|
||||||
конкурируют в этой ценовой зоне). Любая граница / вилка кандидата неизвестна или
|
конкурируют в этой ценовой зоне). Любая граница / вилка кандидата неизвестна →
|
||||||
обе вилки нулевой ширины → None (ось НЕДОСТУПНА — НЕ фабрикуем 0). Открытая правая
|
None (ось НЕДОСТУПНА — НЕ фабрикуем 0). Открытая правая граница (премиум, +inf)
|
||||||
граница (премиум, +inf) поддержана: ширина считается от конечной нижней. PURE.
|
поддержана: ширина считается от конечной нижней.
|
||||||
|
|
||||||
|
Вырожденная вилка (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:
|
if candidate_band is None or own_lo is None or own_hi is None:
|
||||||
return 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)
|
o_lo, o_hi = (own_lo, own_hi) if own_lo <= own_hi else (own_hi, own_lo)
|
||||||
lo = max(c_lo, o_lo)
|
lo = max(c_lo, o_lo)
|
||||||
hi = min(c_hi, o_hi)
|
hi = min(c_hi, o_hi)
|
||||||
overlap = max(0.0, hi - lo)
|
# Обе вилки уходят в +inf (оба премиум) — считаем полным пересечением.
|
||||||
if not math.isfinite(overlap):
|
# Проверяется ДО арифметики ширин (inf - inf = nan).
|
||||||
# Обе вилки уходят в +inf (оба премиум) — считаем полным пересечением.
|
if math.isinf(c_hi) and math.isinf(o_hi):
|
||||||
return 1.0
|
return 1.0
|
||||||
c_width = c_hi - c_lo
|
c_width = c_hi - c_lo
|
||||||
o_width = o_hi - o_lo
|
o_width = o_hi - o_lo
|
||||||
widths = [w for w in (c_width, o_width) if math.isfinite(w) and w > 0]
|
# Вырожденный случай (≥1 вилка — точка): фильтр w>0 ниже выкинул бы нулевую ширину
|
||||||
if not widths:
|
# и дал бы 0/positive=0.0 даже когда точка ЛЕЖИТ внутри другой вилки. Это разрыв
|
||||||
return None
|
# ([148k,152k]→1.0 vs [150k,150k]→0.0) и противоречит докстрингу «накрытие узкого =
|
||||||
return _clamp01(overlap / min(widths))
|
# 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), None, 200_000.0) is None
|
||||||
assert _price_overlap((120_000.0, 160_000.0), 100_000.0, None) 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:
|
class TestUnitMixSimilarity:
|
||||||
def test_identical_mix_is_one(self) -> None:
|
def test_identical_mix_is_one(self) -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue