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,
|
district_name: str,
|
||||||
target_class: str | None,
|
target_class: str | None,
|
||||||
elasticity_window_months: int = 24,
|
elasticity_window_months: int = 24,
|
||||||
|
districts: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on
|
"""Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on
|
||||||
objective_corpus_room_month observations for the same район+class.
|
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.
|
price_avg → deals_total_avg_price_thousand_rub_per_m2.
|
||||||
LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope
|
LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope
|
||||||
не зависит от единиц (аддитивный сдвиг в LN пространстве).
|
не зависит от единиц (аддитивный сдвиг в 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 ""
|
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
|
||||||
params: dict[str, Any] = {
|
params: dict[str, Any] = {
|
||||||
|
|
@ -1823,6 +1839,18 @@ def _elasticity_coef(
|
||||||
if target_class:
|
if target_class:
|
||||||
params["cls"] = target_class
|
params["cls"] = target_class
|
||||||
_ = region_code # retained for backward compat; objective data covers EKB only
|
_ = 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 = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -1832,9 +1860,9 @@ def _elasticity_coef(
|
||||||
LN(crm.deals_total_count::float8) AS y,
|
LN(crm.deals_total_count::float8) AS y,
|
||||||
LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x
|
LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x
|
||||||
FROM objective_corpus_room_month crm
|
FROM objective_corpus_room_month crm
|
||||||
WHERE crm.district = :dn
|
WHERE crm.deals_total_count > 0
|
||||||
|
{where_district}
|
||||||
{where_class}
|
{where_class}
|
||||||
AND crm.deals_total_count > 0
|
|
||||||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
|
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
|
||||||
AND crm.report_month >= NOW() - (:ew || ' months')::interval
|
AND crm.report_month >= NOW() - (:ew || ' months')::interval
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -528,9 +528,19 @@ def _price_sensitivity(
|
||||||
Требует district (регрессия по району). Без district → None (нечего фитить).
|
Требует district (регрессия по району). Без district → None (нечего фитить).
|
||||||
elasticity-окно отдельно от velocity-окна: регрессии нужно больше истории,
|
elasticity-окно отдельно от velocity-окна: регрессии нужно больше истории,
|
||||||
поэтому минимум 24 мес (как в recommend_mix).
|
поэтому минимум 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:
|
if not district:
|
||||||
return None, None
|
return None, None
|
||||||
|
micros = resolve_objective_districts(db, district)
|
||||||
elasticity_window = max(window_months, 24)
|
elasticity_window = max(window_months, 24)
|
||||||
try:
|
try:
|
||||||
elast = _elasticity_coef(
|
elast = _elasticity_coef(
|
||||||
|
|
@ -539,8 +549,13 @@ def _price_sensitivity(
|
||||||
district_name=district,
|
district_name=district,
|
||||||
target_class=None,
|
target_class=None,
|
||||||
elasticity_window_months=elasticity_window,
|
elasticity_window_months=elasticity_window,
|
||||||
|
districts=micros if micros is not None else [],
|
||||||
)
|
)
|
||||||
except Exception:
|
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 None, None
|
||||||
return float(elast["elasticity"]), str(elast["source"])
|
return float(elast["elasticity"]), str(elast["source"])
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from app.services.site_finder.market_metrics import (
|
||||||
_monthly_rate,
|
_monthly_rate,
|
||||||
_months_of_supply,
|
_months_of_supply,
|
||||||
_overstock_index,
|
_overstock_index,
|
||||||
|
_price_sensitivity,
|
||||||
_room_bucket,
|
_room_bucket,
|
||||||
_sell_through_pct,
|
_sell_through_pct,
|
||||||
compute_market_metrics,
|
compute_market_metrics,
|
||||||
|
|
@ -458,9 +459,12 @@ class TestDistrictResolution:
|
||||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||||
m = compute_market_metrics(db, district="Кировский")
|
m = compute_market_metrics(db, district="Кировский")
|
||||||
|
|
||||||
# резолвер вызван именно с админ-именем
|
# Резолвер вызван с админ-именем. #1211: теперь зовётся ДВАЖДЫ — раз для
|
||||||
_patch_resolver.assert_called_once()
|
# stock/sales фильтра (compute_market_metrics), раз для _price_sensitivity
|
||||||
assert _patch_resolver.call_args.args[1] == "Кировский"
|
# → _elasticity_coef. Оба вызова — с одним и тем же admin-именем.
|
||||||
|
assert _patch_resolver.called
|
||||||
|
for call in _patch_resolver.call_args_list:
|
||||||
|
assert call.args[1] == "Кировский"
|
||||||
|
|
||||||
# КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк).
|
# КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк).
|
||||||
for call_idx in (0, 1): # stock + sales-window
|
for call_idx in (0, 1): # stock + sales-window
|
||||||
|
|
@ -501,6 +505,101 @@ class TestDistrictResolution:
|
||||||
assert "Орджоникидзевский" not in p["districts"]
|
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:
|
class TestComputeMarketMetricsThinData:
|
||||||
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash."""
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue