gendesign/backend/app/services/forecasting/regression.py
bot-backend 14f3ef2019
All checks were successful
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m20s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-17 17:13:38 +00:00

816 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§9.6 distributed-lag регрессия спроса района на ключевую ставку (Almon / ADL).
Forgejo #978 (#951-B), §9.6 «lagged key_rate → demand». DoD требует *настоящую*
distributed-lag модель отклика месячного спроса района на key_rate при лагах 0..6,
а НЕ текущий single-lag OLS (`rate_sensitivity.best_lag` берёт ОДИН лучший лаг) и
НЕ unconstrained free-lags (7 коллинеарных лагов Δrate → раздутые дисперсии,
скачущие знаки — оценка непригодна на коротком месячном ряде).
ПОДХОД — Almon polynomial distributed lag (Almon 1965; Stock & Watson, DL):
накладываем НИЗКОСТЕПЕННОЙ полином на 7 лаговых коэффициентов β_0..β_6,
β_j = Σ_{p=0..d} γ_p · j^p (d = _ALMON_DEGREE, по умолчанию 2),
и оцениваем d+1 параметр γ (а не 7 свободных β) через OLS на Almon-преобразованных
регрессорах z_p[t] = Σ_j j^p · x[tj]. Это резко снижает коллинеарность (3 гладких
параметра вместо 7 шумных) — стандартный приём для коротких лаговых рядов. Per-lag
β_j реконструируем обратно из γ. Альтернатива ADL(p,q) задокументирована, но Almon
выбран: он напрямую даёт ФОРМУ отклика по лагам, нужную для фразы §9.6.
HAC (Newey-West) стандартные ошибки — РУЧНОЙ numpy, БЕЗ statsmodels (тяжёлая
зависимость + пересборка prod-образа; ручной NW ~30 строк). Δln-остатки на
месячном ряде автокоррелированы (перекрытие лаговых окон + инерция спроса) →
обычные OLS-SE занижены. NW-ковариация = взвешенная сумма автоковариаций остатков
с окном Бартлетта; bandwidth L = floor(4·(n/100)^(2/9)) (Newey-West 1994 rule).
ВЫХОД на район: `{best_lag_months, coef, r2, n, ...}` (см. DistributedLagFit).
• best_lag_months — лаг с пиком |β_j| из ОЦЕНЁННОЙ Almon-формы (момент сильнейшего
отклика; для фразы §9.6 «через Y мес»).
• coef — long-run (кумулятивный) мультипликатор Σ_j β_j на Δln: суммарный
%-эффект от удержания ставки на +1 п.п. (а не отклик одного месяца). Документ:
coef = долгосрочный мультипликатор; per_lag_coef несёт всю форму.
GATE (зеркало analytics_queries._elasticity_coef, L1826-1852: n≥30 ∧ R²≥0.1 ∧
slope<0 иначе fallback). Адаптация — gate смотрит на ЗНАК long-run β (ЦБ ↑ставку →
спрос ↓ → Σβ<0); n<30 ИЛИ R²<0.1 → degrade (source='fallback', claim не делаем).
Дух forecasting-модулей: PURE/детерминированно, graceful-on-thin-data, без LLM.
ADVISORY: §9.6-стек советующий (как rate_sensitivity). Модуль самостоятелен —
ПОДКЛЮЧЕНИЕ к §9.6-консьюмеру отложено (точка интеграции — в docstring
compute_district_rate_regression), чтобы не задеть рабочий best_lag-путь
(product_scoring / demand_normalization / demand_supply_forecast зовут
compute_rate_sensitivity). Зеркалит дисциплину #979 (ship module + tests + note).
psycopg v3 / SQLAlchemy text: bind ВСЕГДА через CAST(:x AS type) — НИКОГДА :x::type.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import date
from typing import Any, Literal
import numpy as np
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
from app.services.forecasting.sales_series import (
SegmentSpec,
build_sales_series,
log_diff,
)
logger = logging.getLogger(__name__)
# ── Named-константы ───────────────────────────────────────────────────────────
# Глубина ряда по умолчанию (месяцев назад) — зеркалит _DEFAULT_MONTHS_BACK
# rate_sensitivity / macro_series (48 ≈ 4 года): §9.6 join-ит demand↔macro
# месяц-в-месяц, окна одной длины.
_DEFAULT_MONTHS_BACK: int = 48
# Максимальный лаг key_rate (мес). 0..6 — полугодовое окно отклика спроса на
# смену ставки (ипотека/сделки оформляются месяцами; полугодовой хвост ловит
# долгий эффект). Совпадает с верхней границей _LAGS rate_sensitivity (там {0,1,2,3,6}).
_MAX_LAG: int = 6
# Степень полинома Алмона над 7 лаговыми коэффициентами. 2 (квадратичная) —
# стандартный минимум, дающий «горб» (рост→пик→спад) отклика: реакция спроса
# нарастает, достигает максимума через несколько месяцев, затухает. deg<7
# (=число лагов) — суть Алмона: 3 гладких параметра вместо 7 шумных коллинеарных.
_ALMON_DEGREE: int = 2
# GATE-пороги (зеркало _elasticity_coef L1856): n≥30 строк ∧ R²≥0.1 ∧ верный знак
# (для DL — long-run Σβ<0). Здесь одна «строка» = один Δln-МЕСЯЦ с полным набором
# лагов; на 48-мес окне их ≤ ~41 (минус _MAX_LAG на разогрев лагов и дыры).
_MIN_OBS: int = 30
_MIN_R2: float = 0.1
# Минимум наблюдений, ниже которого Almon-OLS вообще не пытаемся (нужно > числа
# параметров d+1 с запасом на остаточные степени свободы для R²/HAC). 8 ≈ дух
# rate_sensitivity._MIN_OBS — но это лишь «можно ли фитить», НЕ gate-порог для
# claim (тот — _MIN_OBS=30 выше).
_MIN_FIT_OBS: int = 8
# Текст §9.6 (НЕ LLM) — шаблон фразы из оценённой лаговой формы.
_PHRASE_TEMPLATE: str = (
"При росте ключевой ставки на 1 п.п. спрос снижается в среднем на {x}% "
"(пик эффекта через {y} мес.)."
)
_PHRASE_INSUFFICIENT: str = (
"недостаточно данных для distributed-lag оценки чувствительности к ставке"
)
# Survivorship-FREE помесячный агрегат сделок (зеркало rate_sensitivity._SOURCE_A);
# Literal — чтобы build_sales_series принял его как SalesSource без приведения.
_SOURCE_A: Literal["corpus_room_month"] = "corpus_room_month"
@dataclass(frozen=True)
class DistributedLagFit:
"""Результат Almon distributed-lag регрессии Δln(demand) ~ Δrate[0..K].
Детерминированный. Числовые поля = None при недостатке данных / провале gate
(никогда 0-как-заглушка). `phrase` ВСЕГДА заполнена. ADVISORY до подключения.
coef — LONG-RUN (кумулятивный) мультипликатор Σ_j β_j на Δln: суммарный
%-эффект (в exp-масштабе через x_pct) от удержания ставки на +1 п.п. Полную
ФОРМУ отклика по лагам несёт per_lag_coef; пик |β_j| → best_lag_months.
"""
segment: dict[str, str | None]
best_lag_months: int | None # лаг пика |β_j| оценённой формы (момент сильнейшего отклика)
coef: float | None # long-run Σ_j β_j на Δln (кумулятивный мультипликатор)
x_pct: float | None # 100·(exp(coef)1): %-эффект на +1 п.п. (NEGATIVE при ↓)
r2: float | None # R² distributed-lag регрессии
n: int # число использованных наблюдений (полных Δln-месяцев с лагами)
per_lag_coef: tuple[float, ...] | None # β_0..β_K из Almon-формы (вся форма отклика)
hac_se: tuple[float, ...] | None # Newey-West SE для β_0..β_K (ручной NW)
hac_bandwidth: int | None # окно Бартлетта L, на котором считались HAC SE
almon_degree: int # степень полинома Алмона
source: str # 'regression' (gate пройден) | 'fallback' (degrade)
phrase: str
def as_dict(self) -> dict[str, Any]:
return {
"segment": dict(self.segment),
"best_lag_months": self.best_lag_months,
"coef": _round_or_none(self.coef, 4),
"x_pct": _round_or_none(self.x_pct, 1),
"r2": _round_or_none(self.r2, 4),
"n": self.n,
"per_lag_coef": (
[round(c, 4) for c in self.per_lag_coef] if self.per_lag_coef is not None else None
),
"hac_se": ([round(s, 4) for s in self.hac_se] if self.hac_se is not None else None),
"hac_bandwidth": self.hac_bandwidth,
"almon_degree": self.almon_degree,
"source": self.source,
"phrase": self.phrase,
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# ──────────────────────────────────────────────────────────────────────────────
# Pure-логика — без БД, полностью юнит-тестируемо (numpy на синтетике с известным лагом).
# ──────────────────────────────────────────────────────────────────────────────
def _build_lag_matrix(
x: list[float | None], y: list[float | None], *, max_lag: int
) -> tuple[np.ndarray, np.ndarray] | None:
"""Собрать матрицу лагов регрессора и выровненный y, дропнув неполные строки.
Для каждого месяца t строим вектор регрессоров [x[t], x[t1], …, x[tmax_lag]]
и спариваем с y[t]. Строку используем ТОЛЬКО если y[t] и ВСЕ max_lag+1 лаговых
значений конечны (None/NaN/Inf в любом лаге → строку дропаем: distributed-lag
требует полный лаговый профиль, частичный сместил бы оценку). PURE, без БД.
Args:
x: регрессор по месяцам (обычно Δrate), None-дыры ок.
y: зависимая (Δln(demand)) по тем же месяцам, None-дыры ок.
max_lag: максимальный лаг (включительно) — матрица имеет max_lag+1 столбец.
Returns:
(X, yv): X формы (n, max_lag+1) [lag0..lagK], yv формы (n,). None если
ни одной полной строки (n=0).
"""
n_months = min(len(x), len(y))
rows: list[list[float]] = []
ys: list[float] = []
for t in range(max_lag, n_months):
yv = y[t]
if yv is None:
continue
yf = float(yv)
if not math.isfinite(yf):
continue
lagvec: list[float] = []
ok = True
for j in range(max_lag + 1):
xv = x[t - j]
if xv is None:
ok = False
break
xf = float(xv)
if not math.isfinite(xf):
ok = False
break
lagvec.append(xf)
if not ok:
continue
rows.append(lagvec)
ys.append(yf)
if not rows:
return None
return np.asarray(rows, dtype=float), np.asarray(ys, dtype=float)
def _almon_basis(max_lag: int, degree: int) -> np.ndarray:
"""Матрица Almon-весов W формы (max_lag+1, degree+1): W[j, p] = j^p.
β_j = Σ_p γ_p · j^p = (W @ γ)[j]. Преобразование регрессоров: если X — матрица
лагов (n, max_lag+1), то Almon-регрессоры Z = X @ W (n, degree+1), и OLS y~Z
даёт γ; per-lag β = W @ γ. j^0 столбец = 1 (intercept полинома → β-уровень).
PURE.
"""
lags = np.arange(max_lag + 1, dtype=float)
return np.vander(lags, N=degree + 1, increasing=True)
def _ols_lstsq(z: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
"""OLS y ~ [1, Z] через numpy lstsq → (coef_with_intercept, residuals). PURE.
Добавляем столбец-константу (свободный член регрессии — НЕ Almon-уровень).
Возвращает None при недостатке наблюдений (n ≤ #параметров → нет степеней
свободы) или вырожденной (rank-deficient) матрице плана.
Returns:
(coef, resid): coef[0] = intercept, coef[1:] = γ; resid = y ŷ. None если
фит невозможен.
"""
n = z.shape[0]
design = np.column_stack([np.ones(n), z])
k = design.shape[1]
if n <= k:
return None
# rank-проверка: коллинеарный план → оценка γ не определена однозначно.
if np.linalg.matrix_rank(design) < k:
return None
coef, _res, _rank, _sv = np.linalg.lstsq(design, y, rcond=None)
resid = y - design @ coef
return coef, resid
def _r2(y: np.ndarray, resid: np.ndarray) -> float | None:
"""R² = 1 SS_res/SS_tot. None при нулевой дисперсии y (R² не определён). PURE."""
ss_res = float(np.sum(resid**2))
ss_tot = float(np.sum((y - float(np.mean(y))) ** 2))
if ss_tot == 0.0:
return None
return 1.0 - ss_res / ss_tot
def newey_west_bandwidth(n: int) -> int:
"""Окно Бартлетта L для Newey-West по правилу floor(4·(n/100)^(2/9)).
Newey-West (1994) automatic bandwidth: растёт с n, но медленно. Нижняя
граница 1 (нужна хотя бы lag-1 автоковариация, иначе HAC == обычная OLS).
PURE.
"""
if n <= 1:
return 0
bw: int = math.floor(4.0 * (n / 100.0) ** (2.0 / 9.0))
return max(1, bw)
def newey_west_cov(x_design: np.ndarray, resid: np.ndarray, *, bandwidth: int) -> np.ndarray:
"""HAC (Newey-West) ковариация оценок OLS — РУЧНОЙ numpy, БЕЗ statsmodels.
Heteroskedasticity-and-autocorrelation-consistent ковариация:
V = (X'X)^{-1} · S · (X'X)^{-1}, S = Σ_0 + Σ_{l=1..L} w_l (Σ_l + Σ_l'),
где Σ_0 = Σ_t u_t² x_t x_t' (мясо White/HC0), Σ_l = Σ_t u_t u_{tl} x_t x_{tl}'
(lag-l автоковариация моментов), w_l = 1 l/(L+1) — вес Бартлетта (гарантирует
положительную полуопределённость S). u_t — остатки, x_t — строка плана.
Δln-остатки месячного спроса автокоррелированы (перекрытие лаговых окон +
инерция) → обычные OLS-SE занижены; NW их корректирует. PURE, без БД.
Args:
x_design: матрица плана (n, k) — та же [1, Z], что в _ols_lstsq.
resid: остатки OLS (n,).
bandwidth: окно Бартлетта L (≥0); 0 → только Σ_0 (== White HC0).
Returns:
Ковариационная матрица (k, k). При вырожденной X'X — псевдообратная
(graceful, не crash).
"""
n = x_design.shape[0]
xtx = x_design.T @ x_design
try:
xtx_inv = np.linalg.inv(xtx)
except np.linalg.LinAlgError: # вырожденная X'X → псевдообратная (graceful)
xtx_inv = np.linalg.pinv(xtx)
u = resid.reshape(-1, 1)
ux = x_design * u # (n, k): строка t = u_t · x_t
# Σ_0 — «мясо» White (HC0).
s = ux.T @ ux
l_bw = max(0, min(bandwidth, n - 1)) # эффективное окно Бартлетта (L)
for lag in range(1, l_bw + 1):
w = 1.0 - lag / (l_bw + 1.0) # вес Бартлетта
gamma = ux[lag:].T @ ux[:-lag] # Σ_t u_t u_{t-l} x_t x_{t-l}'
s = s + w * (gamma + gamma.T)
cov: np.ndarray = xtx_inv @ s @ xtx_inv
return cov
def _peak_lag(per_lag: np.ndarray) -> int:
"""Лаг с максимальным |β_j| — момент сильнейшего отклика спроса. PURE.
При нескольких равных максимумах берём ПЕРВЫЙ (наименьший лаг — самый ранний
пик). Для фразы §9.6 «через Y мес».
"""
return int(np.argmax(np.abs(per_lag)))
def _x_pct_from_coef(coef: float) -> float:
"""Long-run β на Δln → %-эффект на +1 п.п. ставки: 100·(exp(β)1). PURE.
β<0 → отрицательный % (спрос падает). exp т.к. Y=Δln (мультипликативный
масштаб). Зеркало rate_sensitivity._x_pct_from_beta.
"""
return 100.0 * (math.exp(coef) - 1.0)
def fit_almon_dl(
x: list[float | None],
y: list[float | None],
*,
max_lag: int = _MAX_LAG,
degree: int = _ALMON_DEGREE,
min_obs: int = _MIN_OBS,
min_r2: float = _MIN_R2,
) -> dict[str, Any] | None:
"""Almon polynomial distributed-lag фит Δln(demand) ~ Δrate[0..max_lag]. PURE.
Шаги:
1. Собрать матрицу лагов X (n, max_lag+1) + выровненный y, дропнув строки с
неполным лаговым профилем (_build_lag_matrix).
2. Almon-преобразование Z = X @ W (W[j,p]=j^p) → degree+1 регрессоров вместо
max_lag+1 коллинеарных. OLS y~[1,Z] (lstsq) → intercept + γ.
3. Реконструкция per-lag β = W @ γ; long-run Σβ; R²; пик-лаг.
4. HAC (Newey-West) ковариация на плане [1,Z] → SE для γ; SE для per-lag β
через delta-method (J = [0|W], V_β = J·V_γ·J').
Возвращает dict (см. ключи ниже) либо None если фит невозможен (n < запас по
степеням свободы / вырожденный план / нулевая дисперсия y). GATE (n≥min_obs ∧
R²≥min_r2 ∧ Σβ<0) здесь НЕ применяется — это делает оркестратор (чтобы pure-фит
был переиспользуем и для диагностики «почти прошёл»).
Args:
x: регрессор по месяцам (Δrate), None-дыры ок.
y: зависимая (Δln(demand)) по тем же месяцам, None-дыры ок.
max_lag: макс. лаг (включительно).
degree: степень полинома Алмона (< max_lag+1).
min_obs / min_r2: пороги для УДОБСТВА вызывающего (возвращаются в dict как
gate_n_ok / gate_r2_ok), сам фит ими не отсекается.
Returns:
dict с ключами: per_lag_coef (tuple), long_run_coef (float), best_lag (int),
r2 (float|None), n (int), hac_se (tuple), hac_bandwidth (int),
intercept (float), gate_n_ok (bool), gate_r2_ok (bool), gate_sign_ok (bool).
None если фит математически невозможен.
"""
if degree >= max_lag + 1:
# Полином не должен иметь параметров ≥ числа лагов — иначе это не
# ограничение (вырождается в free-lags). Защита от мисконфига.
logger.warning("fit_almon_dl: degree=%d >= max_lag+1=%d — refusing", degree, max_lag + 1)
return None
built = _build_lag_matrix(x, y, max_lag=max_lag)
if built is None:
return None
x_lags, yv = built
n = int(x_lags.shape[0])
if n < _MIN_FIT_OBS:
return None
# Нулевая дисперсия зависимой → R²/наклоны не определены.
if float(np.var(yv)) == 0.0:
return None
w = _almon_basis(max_lag, degree) # (max_lag+1, degree+1)
z = x_lags @ w # (n, degree+1) — Almon-регрессоры
fit = _ols_lstsq(z, yv)
if fit is None:
return None
coef, resid = fit # coef = [intercept, γ_0..γ_d]
intercept = float(coef[0])
gamma = coef[1:]
per_lag = w @ gamma # (max_lag+1,) — реконструированные β_j
long_run = float(np.sum(per_lag))
r2 = _r2(yv, resid)
best_lag = _peak_lag(per_lag)
# HAC (Newey-West) на ТОМ ЖЕ плане [1, Z].
design = np.column_stack([np.ones(n), z])
bw = newey_west_bandwidth(n)
cov = newey_west_cov(design, resid, bandwidth=bw)
# SE per-lag β через delta-method: β = W·γ = J·coef, J = [0_col | W] (intercept
# не входит в β). V_β = J·cov·J'; диагональ ≥0 → sqrt (отрицательные FP-края → 0).
j = np.column_stack([np.zeros((max_lag + 1, 1)), w]) # (max_lag+1, degree+2)
cov_beta = j @ cov @ j.T
var_beta = np.clip(np.diag(cov_beta), a_min=0.0, a_max=None)
hac_se = tuple(float(s) for s in np.sqrt(var_beta))
gate_n_ok = n >= min_obs
gate_r2_ok = r2 is not None and r2 >= min_r2
gate_sign_ok = long_run < 0.0
return {
"per_lag_coef": tuple(float(c) for c in per_lag),
"long_run_coef": long_run,
"best_lag": best_lag,
"r2": r2,
"n": n,
"hac_se": hac_se,
"hac_bandwidth": bw,
"intercept": intercept,
"gate_n_ok": gate_n_ok,
"gate_r2_ok": gate_r2_ok,
"gate_sign_ok": gate_sign_ok,
}
def _build_phrase(*, x_pct: float | None, best_lag: int | None, gated: bool) -> str:
"""Фраза §9.6 (НЕ LLM) из оценённой лаговой формы. PURE.
gate провален / нет valid эффекта → «недостаточно данных…». Иначе:
«при росте ставки +1 п.п. спрос снижается на X% (пик через Y мес)». X —
положительная МАГНИТУДА %-эффекта (long-run), Y — пик-лаг.
"""
if not gated or x_pct is None or best_lag is None:
return _PHRASE_INSUFFICIENT
return _PHRASE_TEMPLATE.format(x=round(abs(x_pct), 1), y=best_lag)
def _insufficient(segment: dict[str, str | None], *, n: int = 0) -> DistributedLagFit:
"""Граничный результат «недостаточно данных» (fallback, фраза-заглушка). PURE."""
return DistributedLagFit(
segment=segment,
best_lag_months=None,
coef=None,
x_pct=None,
r2=None,
n=n,
per_lag_coef=None,
hac_se=None,
hac_bandwidth=None,
almon_degree=_ALMON_DEGREE,
source="fallback",
phrase=_PHRASE_INSUFFICIENT,
)
def build_fit_result(
x: list[float | None],
y: list[float | None],
*,
segment: dict[str, str | None],
max_lag: int = _MAX_LAG,
degree: int = _ALMON_DEGREE,
min_obs: int = _MIN_OBS,
min_r2: float = _MIN_R2,
) -> DistributedLagFit:
"""Прогнать Almon-DL фит и обернуть в DistributedLagFit с gate-деградацией. PURE.
GATE (зеркало _elasticity_coef): n≥min_obs ∧ R²≥min_r2 ∧ long-run Σβ<0 →
source='regression' (claim). Иначе → degrade: source='fallback', фраза
«недостаточно данных», но per_lag_coef/r2/n СОХРАНЯЕМ для диагностики (как
_elasticity_coef возвращает r2/n в fallback). НЕ crash на тонких данных.
Это чистая обёртка (без БД) — тестируется на синтетике с известным лагом.
"""
fit = fit_almon_dl(x, y, max_lag=max_lag, degree=degree, min_obs=min_obs, min_r2=min_r2)
if fit is None:
return _insufficient(segment)
n = int(fit["n"])
gated = bool(fit["gate_n_ok"] and fit["gate_r2_ok"] and fit["gate_sign_ok"])
long_run = float(fit["long_run_coef"])
r2 = fit["r2"]
per_lag = tuple(fit["per_lag_coef"])
hac_se = tuple(fit["hac_se"])
best_lag = int(fit["best_lag"])
if not gated:
# Degrade: сохраняем числа для диагностики, но source='fallback' и фраза-заглушка.
logger.info(
"regression: gate failed (segment=%s n=%d r2=%s long_run=%.4f "
"n_ok=%s r2_ok=%s sign_ok=%s) → fallback",
segment,
n,
None if r2 is None else round(r2, 4),
long_run,
fit["gate_n_ok"],
fit["gate_r2_ok"],
fit["gate_sign_ok"],
)
return DistributedLagFit(
segment=segment,
best_lag_months=None,
coef=None,
x_pct=None,
r2=_round_or_none(r2, 4),
n=n,
per_lag_coef=per_lag,
hac_se=hac_se,
hac_bandwidth=int(fit["hac_bandwidth"]),
almon_degree=degree,
source="fallback",
phrase=_PHRASE_INSUFFICIENT,
)
x_pct = _x_pct_from_coef(long_run)
phrase = _build_phrase(x_pct=x_pct, best_lag=best_lag, gated=True)
logger.info(
"regression(OK): segment=%s long_run=%.4f x_pct=%.1f best_lag=%d r2=%.4f n=%d bw=%d",
segment,
long_run,
x_pct,
best_lag,
r2 if r2 is not None else float("nan"),
n,
int(fit["hac_bandwidth"]),
)
return DistributedLagFit(
segment=segment,
best_lag_months=best_lag,
coef=long_run,
x_pct=x_pct,
r2=r2,
n=n,
per_lag_coef=per_lag,
hac_se=hac_se,
hac_bandwidth=int(fit["hac_bandwidth"]),
almon_degree=degree,
source="regression",
phrase=phrase,
)
# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────
def _align_demand_deltas(
sales_months: list[date], sales_units: list[int], macro_months: list[date]
) -> list[float | None]:
"""Выровнять Δln(units) спроса по сетке макро-месяцев (общая ось X↔Y).
Зеркало rate_sensitivity._align_sales_deltas: log_diff даёт Δln по сетке
ПРОДАЖ, перекладываем на macro_months (месяц без продаж → None), чтобы пары
(Δrate[tL], Δln[t]) были month-в-month и лаговая матрица строилась по единой
временной оси. PURE.
NB (#979 дух): дессзонивание ПЕРЕД log_diff здесь НЕ применяется — та же
оговорка, что в rate_sensitivity._align_sales_deltas (на коротком ряде ratio-
to-mean фактор смещает восстановленный лаг). Отложено той же задачей.
"""
deltas = log_diff(sales_units)
by_month = dict(zip(sales_months, deltas, strict=False))
return [by_month.get(m) for m in macro_months]
def compute_district_rate_regression(
db: Session,
*,
district: str,
obj_class: str | None = None,
months_back: int = _DEFAULT_MONTHS_BACK,
max_lag: int = _MAX_LAG,
degree: int = _ALMON_DEGREE,
) -> DistributedLagFit:
"""§9.6 Almon distributed-lag регрессия месячного спроса РАЙОНА на key_rate.
Constrained DL (Almon, deg `degree`) Δln(demand_district) ~ Δkey_rate при лагах
0..max_lag, с реконструкцией per-lag β и HAC (Newey-West) SE. GATE зеркалит
_elasticity_coef (n≥30 ∧ R²≥0.1 ∧ long-run Σβ<0 иначе fallback). ДЕТЕРМИНИРОВАНО.
Данные:
• key_rate — get_monthly_macro (PR2), Δ первой разностью (_delta) → X-ось.
• спрос района — build_sales_series Source A (objective_corpus_room_month,
survivorship-FREE помесячный агрегат сделок), Δln (log_diff) → Y-ось,
выровненная на сетку макро (_align_demand_deltas).
Graceful-on-thin-data: пустой/тонкий ряд / провал gate → source='fallback',
фраза «недостаточно данных…», НЕ crash (дух forecasting-модулей).
ADVISORY + НЕ ПОДКЛЮЧЕНО (отложенная интеграция, #978 Part B):
Точка интеграции в §9.6 — там же, где сейчас зовётся compute_rate_sensitivity
(product_scoring._build → mortgage_sensitivity; demand_normalization;
demand_supply_forecast explain-фраза). Подключение ОТЛОЖЕНО, чтобы не задеть
рабочий single-lag best_lag-путь (риск регресса в трёх консьюмерах). Зеркалит
дисциплину #979: ship module + tests + note integration point. §9.6-стек
advisory в любом случае.
Args:
db: SQLAlchemy sync Session.
district: район ЕКБ (Source A column `district`).
obj_class: класс ЖК (None → агрегат по району); регистр нормализуется в SQL.
months_back: глубина ряда (по умолчанию 48).
max_lag / degree: окно лагов и степень Алмона.
Returns:
DistributedLagFit (всегда; фраза заполнена даже при нехватке данных).
"""
segment: dict[str, str | None] = {"district": district, "obj_class": obj_class}
macro = get_monthly_macro(db, months_back=months_back)
rate_deltas = _delta([m.key_rate for m in macro])
macro_months = [m.month for m in macro]
spec = SegmentSpec(obj_class=obj_class, district=district)
sales = build_sales_series(db, spec=spec, source=_SOURCE_A, months_back=months_back)
demand_deltas = _align_demand_deltas(sales.months, sales.units, macro_months)
return build_fit_result(
rate_deltas,
demand_deltas,
segment=segment,
max_lag=max_lag,
degree=degree,
)
# ──────────────────────────────────────────────────────────────────────────────
# §9.6 production adapter — wraps the validated Almon-ADL estimator behind the
# RateSensitivity contract the three §9.6 consumers already speak.
# ──────────────────────────────────────────────────────────────────────────────
#
# WHY this exists: the out-of-sample backtest (backend/scripts/backtest_rate_
# sensitivity.py) found the single-best-lag OLS (`rate_sensitivity.compute_rate_
# sensitivity`) directionally NOISE OOS (hit-rate 0.148 on Source B EKB-wide,
# worse than coin-flip), while this Almon distributed-lag estimator is strictly
# less noisy on every tier (0.407 Source B / 0.60 survivorship-free Source A).
# So the rate-regime discount (§9.4 demand_normalization), the mortgage_sensitivity
# score (§14.2 product_scoring) and the explain-phrase (§9.8 demand_supply_forecast)
# are repointed onto this estimator. REVERSIBLE: this adapter is the single seam —
# reverting the 3 call-site imports restores the old path. compute_rate_sensitivity
# stays as-is (still used by the backtest + its own tests).
def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity:
"""Low-confidence RateSensitivity with no usable β/x_pct (graceful degrade).
Used when there is no district to fit the district×class regression on
(spec.district is None): demand_normalization then degrades to a neutral
norm=1.0 (applied=False) and product_scoring's mortgage_sensitivity takes the
low-confidence path — honest, not invented. PURE.
"""
return RateSensitivity(
segment=segment,
x_pct=None,
y_lag_months=None,
z_area_floor=None,
most_sensitive_bucket=None,
beta=None,
r2=None,
n_obs=0,
shrinkage_weight=0.0,
confounded=False,
confidence="low",
phrase=_PHRASE_INSUFFICIENT,
)
def _fit_to_sensitivity(
fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
) -> RateSensitivity:
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
Field mapping (see compute_rate_regime_sensitivity docstring for the rationale):
• beta ← fit.coef (LONG-RUN Σβ — see beta-semantics note below)
• x_pct ← fit.x_pct
• y_lag_months ← fit.best_lag_months
• phrase ← fit.phrase
• r2 / n_obs ← fit.r2 / fit.n
• confidence ← 'regression'"medium" (gated-OK but advisory-grade) |
'fallback'"low"
• confounded ← passed in by the caller (computed from the ACTUAL fit window
via is_confounded_window — #1636). The §9.6 production path
OR-aggregates this with §9.5 macro_coefficient.confounded in
demand_supply_forecast._series_confounded → шок-фактор (#1222).
The 48-мес regression window overlaps the 2024-07-01 shock long
after the 12-мес macro window stops doing so, so hardcoding
False here silently dropped the shock signal on this channel.
Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
no analogue in a district×class distributed-lag fit (no room×area bucketing here)
→ None / sensible defaults. PURE.
BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
Σ_j β_j on Δln — the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
a single-lag slope. That is exactly the quantity demand_normalization wants for
a future-regime discount (exp(β·Δrate) over a sustained Δrate), and it stays
clamped to [0.5, 1.2] downstream so a large coef saturates rather than blows up.
"""
confidence: Confidence = "medium" if fit.source == "regression" else "low"
return RateSensitivity(
segment=segment,
x_pct=fit.x_pct,
y_lag_months=fit.best_lag_months,
z_area_floor=None,
most_sensitive_bucket=None,
beta=fit.coef,
r2=fit.r2,
n_obs=fit.n,
shrinkage_weight=0.0,
confounded=confounded,
confidence=confidence,
phrase=fit.phrase,
)
@cached(
lambda db, *, spec, months_back=_DEFAULT_MONTHS_BACK: (spec, months_back),
label="compute_rate_regime_sensitivity",
)
def compute_rate_regime_sensitivity(
db: Session,
*,
spec: SegmentSpec,
months_back: int = _DEFAULT_MONTHS_BACK,
) -> RateSensitivity:
"""§9.6 rate sensitivity for a market segment via the Almon-ADL estimator.
Thin adapter over `compute_district_rate_regression` (the validated #978
distributed-lag model) that returns the existing `RateSensitivity` dataclass so
the three §9.6 consumers (demand_normalization / product_scoring /
demand_supply_forecast) use it with NO body changes beyond the call site.
Replaces the OOS-noisy single-best-lag `compute_rate_sensitivity` in production.
Confidence is capped at "medium" even on a gate-passing fit: the §9.6 stack is
advisory until the engine is fully validated, so we never advertise "high".
Graceful degradation (NEVER crashes):
• spec.district is None → no district to fit the district×class regression on
→ low-confidence result with beta=None / x_pct=None and the insufficient-
data phrase (demand_normalization → neutral, product_scoring → low). We do
NOT call compute_district_rate_regression with district=None (it requires a
str).
• compute_district_rate_regression is already graceful (returns a 'fallback'
DistributedLagFit on thin/failed data), but we still wrap it defensively and
degrade to the insufficient result on any unexpected error.
Args:
db: SQLAlchemy sync Session.
spec: target segment; `district` (and optionally `obj_class`) drive the fit.
months_back: series depth (defaults to _DEFAULT_MONTHS_BACK).
Returns:
RateSensitivity (always; phrase populated even when data is insufficient).
"""
segment = spec.as_dict()
if spec.district is None:
logger.info(
"rate_regime_sensitivity: spec.district is None (segment=%s) → "
"insufficient (district×class regression needs a district)",
segment,
)
return _insufficient_sensitivity(segment)
try:
fit = compute_district_rate_regression(
db,
district=spec.district,
obj_class=spec.obj_class,
months_back=months_back,
)
except Exception:
# compute_district_rate_regression is graceful by contract; this guard only
# catches truly unexpected failures so the §9.6 consumers never crash on the
# rate channel. Log with traceback (never swallow silently), then degrade.
logger.exception(
"rate_regime_sensitivity: district regression raised (segment=%s) → "
"degrading to insufficient",
segment,
)
return _insufficient_sensitivity(segment)
# #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
# over the same macro grid as compute_district_rate_regression (get_monthly_macro,
# months_back); re-reading it here is a cache hit (same args). The 48-мес window
# crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
# is exactly the channel that was silently never raising the shock flag (#1222).
confounded = _macro_window_confounded(db, months_back=months_back)
return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)
def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
"""True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).
Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
(PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
использует compute_district_rate_regression → cache-hit, без лишнего запроса.
Пустая сетка / сбой → False (нет окна — нечего конфаундить), НЕ crash.
"""
try:
months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
except Exception:
logger.exception(
"rate_regime_sensitivity: macro window read for confounded-flag failed "
"(months_back=%d) → treating as not confounded",
months_back,
)
return False
if not months:
return False
return is_confounded_window(min(months), max(months))