fix(market-metrics): resolve admin→micros for _price_sensitivity (#1211)
Some checks failed
Some checks failed
_price_sensitivity передавал сырое admin-имя ('Кировский') в _elasticity_coef,
который фильтрует objective_corpus_room_month.district по МИКРО-вокабуляру
(Втузгородок, ЖБИ, …) → регрессия получала 0 точек → всегда FALLBACK_ELASTICITY.
§9.2 district-level эластичность молча НЕ считалась в /analyze-пути (только
'Академический' совпадал в обоих вокабулярах случайно).
Fix: вызываем resolve_objective_districts() в _price_sensitivity и передаём
список микро через новый kwarg districts=[…] в _elasticity_coef. Резолвер
None ('не определён' / нет чистых алиасов) → пустой список → EKB-wide
регрессия. _elasticity_coef расширен с back-compat: districts=None →
legacy путь по district_name (другой caller в analytics_queries —
отдельный bug class, вне scope).
5 новых юнит-тестов TestPriceSensitivityDistrictResolution: admin→micros в
SQL bind, None→EKB-wide, regression preserved post-resolve, graceful.
76/76 market_metrics + 156/156 elasticity/sensitivity тестов зелёные.
ruff + psycopg v3 grep clean.
Closes #1211
This commit is contained in:
parent
0a8785d548
commit
4c2f19ace0
3 changed files with 148 additions and 6 deletions
|
|
@ -1802,6 +1802,7 @@ def _elasticity_coef(
|
|||
district_name: str,
|
||||
target_class: str | None,
|
||||
elasticity_window_months: int = 24,
|
||||
districts: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on
|
||||
objective_corpus_room_month observations for the same район+class.
|
||||
|
|
@ -1814,6 +1815,21 @@ def _elasticity_coef(
|
|||
price_avg → deals_total_avg_price_thousand_rub_per_m2.
|
||||
LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope
|
||||
не зависит от единиц (аддитивный сдвиг в LN пространстве).
|
||||
|
||||
District filter (#1211):
|
||||
• ``districts is None`` → legacy: фильтр ``crm.district = :dn`` по
|
||||
``district_name`` (back-compat для caller'ов с admin-vocab источником —
|
||||
напр. ``analytics_queries`` использует ``domrf_kn_objects.district_name``;
|
||||
отдельный bug class).
|
||||
• ``districts=[]`` → EKB-wide (без district-фильтра). Используется когда
|
||||
резолвер вернул None (вход = 'не определён' / нет чистых алиасов).
|
||||
• ``districts=[m1, m2, ...]`` → ``crm.district = ANY(CAST(:districts AS text[]))``.
|
||||
``district_name`` тогда используется только для logging.
|
||||
|
||||
``objective_corpus_room_month.district`` — МИКРО-вокабуляр ЕКБ (тот же, что
|
||||
``objective_lots.district``: 'Втузгородок', 'ЖБИ', …). Передача сюда
|
||||
admin-имени ('Кировский') даёт 0 точек → всегда FALLBACK (silent-correctness
|
||||
bug #1211). Резолв admin→micros — обязанность вызывающего.
|
||||
"""
|
||||
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
|
||||
params: dict[str, Any] = {
|
||||
|
|
@ -1823,6 +1839,18 @@ def _elasticity_coef(
|
|||
if target_class:
|
||||
params["cls"] = target_class
|
||||
_ = region_code # retained for backward compat; objective data covers EKB only
|
||||
|
||||
if districts is None:
|
||||
# Legacy path: один district_name (admin-vocab caller'ы остаются как были).
|
||||
where_district = "AND crm.district = :dn"
|
||||
elif districts:
|
||||
# Резолвнутый набор МИКРО-районов — bind через CAST(:x AS text[]).
|
||||
where_district = "AND crm.district = ANY(CAST(:districts AS text[]))"
|
||||
params["districts"] = list(districts)
|
||||
else:
|
||||
# Пустой список — EKB-wide fallback (резолвер вернул None → нечего сужать).
|
||||
where_district = ""
|
||||
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
|
|
@ -1832,9 +1860,9 @@ def _elasticity_coef(
|
|||
LN(crm.deals_total_count::float8) AS y,
|
||||
LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x
|
||||
FROM objective_corpus_room_month crm
|
||||
WHERE crm.district = :dn
|
||||
WHERE crm.deals_total_count > 0
|
||||
{where_district}
|
||||
{where_class}
|
||||
AND crm.deals_total_count > 0
|
||||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
|
||||
AND crm.report_month >= NOW() - (:ew || ' months')::interval
|
||||
)
|
||||
|
|
|
|||
|
|
@ -528,9 +528,19 @@ def _price_sensitivity(
|
|||
Требует district (регрессия по району). Без district → None (нечего фитить).
|
||||
elasticity-окно отдельно от velocity-окна: регрессии нужно больше истории,
|
||||
поэтому минимум 24 мес (как в recommend_mix).
|
||||
|
||||
#1211 fix: District-вход — admin-имя ЕКБ (из /analyze), но
|
||||
``objective_corpus_room_month.district`` — МИКРО-вокабуляр (как и
|
||||
``objective_lots.district``). Сырое admin-имя в фильтр давало 0 точек
|
||||
регрессии → всегда FALLBACK_ELASTICITY (silent-correctness). Резолвим
|
||||
admin → набор микро через ``resolve_objective_districts`` и передаём
|
||||
списком в ``_elasticity_coef(..., districts=...)``. Резолвер None
|
||||
(нет чистых алиасов / 'не определён') → пустой список → EKB-wide
|
||||
регрессия (лучше, чем 0 точек: получаем агрегированный сигнал).
|
||||
"""
|
||||
if not district:
|
||||
return None, None
|
||||
micros = resolve_objective_districts(db, district)
|
||||
elasticity_window = max(window_months, 24)
|
||||
try:
|
||||
elast = _elasticity_coef(
|
||||
|
|
@ -539,8 +549,13 @@ def _price_sensitivity(
|
|||
district_name=district,
|
||||
target_class=None,
|
||||
elasticity_window_months=elasticity_window,
|
||||
districts=micros if micros is not None else [],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("market_metrics: elasticity reuse failed (district=%s)", district)
|
||||
logger.exception(
|
||||
"market_metrics: elasticity reuse failed (district=%s micros=%s)",
|
||||
district,
|
||||
micros,
|
||||
)
|
||||
return None, None
|
||||
return float(elast["elasticity"]), str(elast["source"])
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from app.services.site_finder.market_metrics import (
|
|||
_monthly_rate,
|
||||
_months_of_supply,
|
||||
_overstock_index,
|
||||
_price_sensitivity,
|
||||
_room_bucket,
|
||||
_sell_through_pct,
|
||||
compute_market_metrics,
|
||||
|
|
@ -458,9 +459,12 @@ class TestDistrictResolution:
|
|||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
m = compute_market_metrics(db, district="Кировский")
|
||||
|
||||
# резолвер вызван именно с админ-именем
|
||||
_patch_resolver.assert_called_once()
|
||||
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||
# Резолвер вызван с админ-именем. #1211: теперь зовётся ДВАЖДЫ — раз для
|
||||
# stock/sales фильтра (compute_market_metrics), раз для _price_sensitivity
|
||||
# → _elasticity_coef. Оба вызова — с одним и тем же admin-именем.
|
||||
assert _patch_resolver.called
|
||||
for call in _patch_resolver.call_args_list:
|
||||
assert call.args[1] == "Кировский"
|
||||
|
||||
# КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк).
|
||||
for call_idx in (0, 1): # stock + sales-window
|
||||
|
|
@ -501,6 +505,101 @@ class TestDistrictResolution:
|
|||
assert "Орджоникидзевский" not in p["districts"]
|
||||
|
||||
|
||||
class TestPriceSensitivityDistrictResolution:
|
||||
"""#1211 P2 silent-correctness: _price_sensitivity резолвит admin → МИКРО, а НЕ
|
||||
передаёт сырое admin-имя в _elasticity_coef.
|
||||
|
||||
Корень бага: ``objective_corpus_room_month.district`` — МИКРО-вокабуляр
|
||||
(Втузгородок, ЖБИ, …); фильтр по admin-имени ('Кировский') даёт 0 точек →
|
||||
регрессия всегда FALLBACK_ELASTICITY (silent-correctness — §9.2 district-level
|
||||
эластичность никогда не считалась в /analyze-пути; только 'Академический'
|
||||
случайно совпадал в обоих вокабулярах).
|
||||
"""
|
||||
|
||||
def test_admin_resolves_to_micros_in_elasticity_call(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
# Резолвер: 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде).
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Втузгородок", "ЖБИ"] if d == "Кировский" else None
|
||||
)
|
||||
db = MagicMock()
|
||||
with patch(_ELAST, return_value={"elasticity": -1.2, "source": "regression"}) as elast:
|
||||
value, source = _price_sensitivity(db, district="Кировский", window_months=6)
|
||||
|
||||
# резолвер вызван именно с admin-именем
|
||||
_patch_resolver.assert_called_once()
|
||||
assert _patch_resolver.call_args.args[1] == "Кировский"
|
||||
|
||||
# КРИТИЧНО: в _elasticity_coef ушёл список МИКРО, а НЕ сырое admin-имя.
|
||||
_args, kwargs = elast.call_args
|
||||
assert kwargs["districts"] == ["Втузгородок", "ЖБИ"]
|
||||
assert kwargs["district_name"] == "Кировский" # сохранён для logging
|
||||
# elasticity-окно минимум 24 мес даже при коротком window_months
|
||||
assert kwargs["elasticity_window_months"] == 24
|
||||
|
||||
# результат _elasticity_coef переброшен наружу
|
||||
assert value == -1.2
|
||||
assert source == "regression"
|
||||
|
||||
def test_resolver_none_passes_empty_list_ekb_wide(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
# Резолвер None ('не определён' / нет чистых алиасов) → передаём [] →
|
||||
# _elasticity_coef уходит в EKB-wide ветку (без district-фильтра).
|
||||
_patch_resolver.side_effect = lambda _db, _d: None
|
||||
db = MagicMock()
|
||||
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "regression"}) as elast:
|
||||
_price_sensitivity(db, district="не определён", window_months=6)
|
||||
|
||||
_args, kwargs = elast.call_args
|
||||
assert kwargs["districts"] == []
|
||||
|
||||
def test_none_district_skips_resolver_and_elasticity(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
# district=None → _price_sensitivity сразу возвращает (None, None), не зовёт ни
|
||||
# резолвер, ни _elasticity_coef.
|
||||
db = MagicMock()
|
||||
with patch(_ELAST) as elast:
|
||||
value, source = _price_sensitivity(db, district=None, window_months=6)
|
||||
assert value is None
|
||||
assert source is None
|
||||
assert not _patch_resolver.called
|
||||
assert not elast.called
|
||||
|
||||
def test_post_resolution_regression_returns_non_fallback(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
# Симулируем «после резолва n>=30 точек, регрессия валидна»: _elasticity_coef
|
||||
# возвращает source='regression' с конкретным slope — _price_sensitivity не
|
||||
# подменяет результат и не схлопывается в fallback.
|
||||
_patch_resolver.side_effect = lambda _db, d: (
|
||||
["Эльмаш"] if d == "Орджоникидзевский" else None
|
||||
)
|
||||
db = MagicMock()
|
||||
with patch(
|
||||
_ELAST, return_value={"elasticity": -0.87, "r2": 0.42, "n": 45, "source": "regression"}
|
||||
):
|
||||
value, source = _price_sensitivity(db, district="Орджоникидзевский", window_months=6)
|
||||
# источник 'regression' (не 'fallback'), значит admin→micro резолв реально дал
|
||||
# точки регрессии — это и есть фикс бага #1211.
|
||||
assert source == "regression"
|
||||
assert value == -0.87
|
||||
|
||||
def test_elasticity_exception_returns_none_pair_graceful(
|
||||
self, _patch_resolver: MagicMock
|
||||
) -> None:
|
||||
# Багу резолва ортогональна graceful-обёртка: если _elasticity_coef падает,
|
||||
# _price_sensitivity ловит и возвращает (None, None), а не крашит pipeline.
|
||||
_patch_resolver.side_effect = lambda _db, d: ["Втузгородок"]
|
||||
db = MagicMock()
|
||||
with patch(_ELAST, side_effect=RuntimeError("db down")):
|
||||
value, source = _price_sensitivity(db, district="Кировский", window_months=6)
|
||||
assert value is None
|
||||
assert source is None
|
||||
|
||||
|
||||
class TestComputeMarketMetricsThinData:
|
||||
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue