fix(mera/backtest): свежая фикстура не реплеилась, а разрез по сегментам врал
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m58s

Три связанные правки в харнесе бэктеста.

1. replay_fixture жёстко пинил estimate_dedup_analogs_enabled = False. Это верно
   только для фикстуры, захваченной до #2173, когда дедуп был выключен по
   умолчанию. Любая свежая фикстура при этом становилась нереплеибельной: с
   dedup=True на захвате часть сделок уходит под sufficiency-гейт с
   median_ppm2<=0, ratio_resolver для них не зовётся, а реплей с dedup=False их
   оценивает и падает на «control flow diverged from capture». Теперь значение
   берётся из settings_at_capture; фикстуры без этого поля ведут себя как раньше.

2. Разрез per_segment группирует ошибки по SOLD руб/м2 — по величине, которую
   движок и предсказывает. Корзины отобраны по шуму исхода, отсюда регрессия к
   среднему: даже несмещённый предиктор выглядит завышающим в дешёвых корзинах и
   занижающим в дорогих. На контрольном предикторе без перекоса по построению
   (13074 сделки ЕКБ) этот разрез даёт бизнес -17.04% и элит -30.06% — больше,
   чем наблюдавшийся перекос движка. По этому артефакту уже один раз
   откалибровали estimate_segment_multipliers (#2255). Блок оставлен для
   сравнимости с историей прогонов, но помечен предупреждением в докстринге и в
   печатном выводе.

3. Добавлен per_area_bucket — разрез по площади, независимой от исхода, с
   границами asking_to_sold_ratios (30/44/62/85). Промах конкретного бакета ratio
   виден сразу. Блок аддитивный: area_rows=None у старых вызывающих даёт пустой
   словарь, форма ответа не меняется.

Фикстура заменена на свежую (1600 сделок ЕКБ с 2025-06, 1269 оценённых, захват
после #2620) — прежняя от 2026-07-04 старше перехода ratio на area-бакеты и
показывала точность конфигурации, которой в проде уже нет. Baseline перезахвачен:
n 269 -> 1269, MAPE 12.63 -> 13.90, bias -3.74 -> -0.78, покрытие 86.13%.
Сдвиг метрик — смена измерительной выборки, а не регресс движка.

В каверзы модуля добавлен пункт (d): предсказание идёт из центроида улицы, а не
из адреса дома — в открытом датасете Росреестра нет номера дома, кадастры все
вида 66:41:0000000:0, и 65971 сделка садится в 3137 координат.
This commit is contained in:
bot-backend 2026-08-29 23:05:15 +03:00
parent 2b62d279fe
commit 9516d95277
3 changed files with 220 additions and 84 deletions

View file

@ -69,6 +69,16 @@ CAVEATS (read these before trusting the numbers)
(c) ДКП TRUE MARKET a registered ДКП price is what the parties declared
to rosreestr; it can diverge from the genuine transaction price (tax
optimisation, related-party sales, etc.).
(d) ПРЕДСКАЗАНИЕ ИДЁТ ИЗ ЦЕНТРОИДА УЛИЦЫ, НЕ ИЗ АДРЕСА ДОМА (#3251). Открытый
датасет Росреестра НЕ содержит номера дома: `deals.address` выглядит как
«Екатеринбург, Краснолесья», кадастры приходят как `66:41:0000000:0`
(пригодных 0 из 108 623), а геокод сажает 65 971 сделку за 2025+ всего в
3 137 различных координат. Значит аналоги ищутся в радиусе от ЦЕНТРА УЛИЦЫ,
а `--resolve-house-id` резолвит дом лишь для ~40% выборки. Это раздувает
MAPE и бьёт по классам с редкой застройкой сильнее прочих: часть перекоса
по 4+ комнатам цена такой привязки, а не ошибка модели. Любой замер, где
сделка связывается с КОНКРЕТНЫМ зданием по геометрии (материал стен,
этажность, цена собственного дома), этим скомпрометирован.
PERFORMANCE
-----------
@ -541,6 +551,24 @@ def _segment_label(ppm2: float) -> str:
def _segment_metrics(rows: list[tuple[float, float]]) -> dict[str, dict[str, Any]]:
"""Per-price-segment signed-error summary, bucketed by the SOLD ₽/m².
#3251 — ЧИТАТЬ ПЕРЕД ЛЮБОЙ КАЛИБРОВКОЙ ПО ЭТОМУ РАЗРЕЗУ. Корзина задаётся
величиной, которую движок и предсказывает, то есть ИСХОДОМ. Это отбирает корзины
по шуму исхода и порождает регрессию к среднему: даже идеально несмещённый
предиктор выглядит завышающим в дешёвых корзинах и занижающим в дорогих.
Монотонный спуск «чем дороже сегмент, тем сильнее занижаем» подпись артефакта,
а НЕ находка.
Контрольный опыт (13 074 сделки ЕКБ, 2026-08-29): предиктор без сегментного
перекоса по построению даёт в этих корзинах эконом +22.28%, бизнес 17.04%,
элит 30.06% БОЛЬШЕ, чем наблюдавшийся перекос движка. На этом артефакте уже
один раз откалибровали `estimate_segment_multipliers` (#2255, флаг с тех пор
выключен, см. предупреждение в config.py).
Блок оставлен для сравнимости с историей прогонов. Для калибровки пользуйся
`per_area_bucket` ниже там ось (площадь) независима от исхода. Настоящий
ценовой бэнд требует независимого прокси (медиана объявлений собственного дома,
охват 74.6%), которого в офлайн-фикстуре нет.
Each input row is ``(pred_ppm2, sold_ppm2)``. We bucket by the segment of the
SOLD price (ground truth), compute signed_error_pct = 100*(pred-sold)/sold,
and run `_errors_summary` per band. Rows with sold<=0 are dropped (can't
@ -635,11 +663,41 @@ def _calibration_metrics(
return out
def _area_bucket_metrics(
rows: list[tuple[float, float, float]],
) -> dict[str, dict[str, Any]]:
"""#3251: ошибка по AREA-БАКЕТАМ — ось независима от исхода.
Каждая строка ``(expected_sold_ppm2, sold_ppm2, area_m2)``. Бакеты те же, что
у `asking_to_sold_ratios` (границы 30/44/62/85, см. `app.tasks.asking_to_sold_ratio
.area_bucket`), поэтому разрез напрямую показывает, какой бакет ratio промахивается.
В отличие от `per_segment` здесь НЕТ регрессии к среднему: площадь известна на
входе и не зависит от того, что вышло. Чистая функция, без БД.
"""
edges = ((30.0, "0 <30"), (44.0, "1 30-44"), (62.0, "2 44-62"), (85.0, "3 62-85"))
def label_for(area: float) -> str:
for upper, label in edges:
if area < upper:
return label
return "4 >=85"
order = [lbl for _, lbl in edges] + ["4 >=85"]
by: dict[str, list[float]] = {lbl: [] for lbl in order}
for pred, sold, area in rows:
if sold <= 0 or area <= 0:
continue
by[label_for(area)].append(100.0 * (pred - sold) / sold)
return {lbl: _errors_summary(by[lbl]) for lbl in order}
def _expected_sold_metrics(
rows: list[tuple[float, float, int]],
*,
n_no_prediction: int = 0,
per_rooms_no_prediction: dict[int, int] | None = None,
area_rows: list[tuple[float, float, float]] | None = None,
) -> dict[str, Any]:
"""expected_sold error block: overall + per-rooms (via _compute_metrics) + per-segment.
@ -654,6 +712,9 @@ def _expected_sold_metrics(
per_rooms_no_analogs=per_rooms_no_prediction,
)
m["per_segment"] = _segment_metrics([(pred, sold) for pred, sold, _ in rows])
# #3251: независимая ось. area_rows=None (старые вызывающие) → блок пустой,
# форма ответа не ломается.
m["per_area_bucket"] = _area_bucket_metrics(area_rows or [])
return m
@ -688,12 +749,14 @@ def _compute_full_metrics(
cov_by_conf: dict[str, list[tuple[float, float, float]]] = {b: [] for b in conf_order}
sharp_rows: list[tuple[float, float, float]] = [] # (point, range_low, range_high)
calib_rows: list[tuple[str, float | None, bool | None]] = []
es_area_rows: list[tuple[float, float, float]] = [] # #3251 (pred, sold, area)
for p in predictions:
signed: float | None = None
if p.expected_sold_ppm2 is not None and p.sold_ppm2 > 0:
signed = 100.0 * (p.expected_sold_ppm2 - p.sold_ppm2) / p.sold_ppm2
es_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.rooms))
es_area_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.area_m2))
covered: bool | None = None
if p.range_low is not None and p.range_high is not None:
@ -712,6 +775,7 @@ def _compute_full_metrics(
es_rows,
n_no_prediction=n_no_prediction,
per_rooms_no_prediction=per_rooms_no_prediction,
area_rows=es_area_rows,
),
"range_coverage": {
"overall": _range_coverage(cov_rows),
@ -882,6 +946,9 @@ def _render_full_table(metrics: dict[str, Any]) -> str:
lines.extend(_render_metrics_block("[EXPECTED_SOLD] per-rooms", es, no_col="no_pred"))
lines.append("")
lines.extend(_render_segment_block(es["per_segment"]))
if es.get("per_area_bucket"):
lines.append("")
lines.extend(_render_area_bucket_block(es["per_area_bucket"]))
conf_order = metrics.get("confidence_order") or list(CONFIDENCE_BUCKETS)
lines.append("")
@ -909,6 +976,9 @@ def _render_segment_block(per_segment: dict[str, Any]) -> list[str]:
header = f" {'segment':<10} {'n':>5} {'bias%':>8} {'MAPE%':>8} {'p25%':>8} {'p75%':>8}"
out: list[str] = [
"[EXPECTED_SOLD] per price-segment (band by SOLD ₽/m²):",
" ⚠ корзина задана ИСХОДОМ → регрессия к среднему. Монотонный спуск здесь",
" появляется и у несмещённого предиктора. НЕ калибровать по этому блоку",
" (#3251) — бери per area-bucket ниже.",
header,
" " + "-" * (len(header) - 2),
]
@ -923,6 +993,25 @@ def _render_segment_block(per_segment: dict[str, Any]) -> list[str]:
return out
def _render_area_bucket_block(per_area: dict[str, Any]) -> list[str]:
"""#3251: таблица ошибки по area-бакетам (ось независима от исхода)."""
header = f" {'area bucket':<12} {'n':>5} {'bias%':>8} {'MAPE%':>8} {'p25%':>8} {'p75%':>8}"
out: list[str] = [
"[EXPECTED_SOLD] per area-bucket (ось НЕЗАВИСИМА от исхода — калибруй здесь):",
" бакеты = границы asking_to_sold_ratios (30/44/62/85), промах видно",
" сразу по конкретному бакету ratio.",
header,
" " + "-" * (len(header) - 2),
]
for label, m in per_area.items():
out.append(
f" {label:<12} {m.get('n', 0):>5} "
f"{_fmt_pct(m.get('median_bias_pct')):>8} {_fmt_pct(m.get('mape_pct')):>8} "
f"{_fmt_pct(m.get('p25_pct')):>8} {_fmt_pct(m.get('p75_pct')):>8}"
)
return out
# --------------------------------------------------------------------------- #
# #2255 --calibrate-segments — per-segment multiplier proposal (PRINT-ONLY)
# --------------------------------------------------------------------------- #
@ -1575,8 +1664,18 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
# вызывается, а реплей с dedup=False их оценивает и упирается в
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
# False — для старых фикстур без settings_at_capture поведение прежнее).
_dedup_capture = bool(
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
)
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
m.settings.estimate_dedup_analogs_enabled = False
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
try:
for rec in deals:
kw = dict(rec["kwargs"])

View file

@ -1,22 +1,22 @@
{
"calibration": {
"high": {
"coverage_pct": null,
"mape_pct": null,
"n": 0,
"n_covered": 0
"coverage_pct": 50.0,
"mape_pct": 27.87,
"n": 8,
"n_covered": 4
},
"low": {
"coverage_pct": 82.09,
"mape_pct": 12.67,
"n": 276,
"n_covered": 220
"coverage_pct": 86.68,
"mape_pct": 13.76,
"n": 1562,
"n_covered": 1067
},
"medium": {
"coverage_pct": 100.0,
"mape_pct": 6.99,
"n": 1,
"n_covered": 1
"coverage_pct": 73.33,
"mape_pct": 17.91,
"n": 30,
"n_covered": 22
}
},
"confidence_order": [
@ -26,130 +26,167 @@
],
"expected_sold": {
"overall": {
"mape_pct": 12.63,
"median_bias_pct": -3.74,
"n": 269,
"mape_pct": 13.9,
"median_bias_pct": -0.78,
"n": 1269,
"n_no_analogs": 0,
"p25_pct": -15.17,
"p75_pct": 8.67
"p25_pct": -13.39,
"p75_pct": 14.46
},
"per_area_bucket": {
"0 <30": {
"mape_pct": 14.55,
"median_bias_pct": 7.59,
"n": 161,
"p25_pct": -4.16,
"p75_pct": 29.4
},
"1 30-44": {
"mape_pct": 13.12,
"median_bias_pct": -0.51,
"n": 503,
"p25_pct": -11.98,
"p75_pct": 14.88
},
"2 44-62": {
"mape_pct": 12.99,
"median_bias_pct": -2.82,
"n": 379,
"p25_pct": -14.44,
"p75_pct": 10.26
},
"3 62-85": {
"mape_pct": 15.32,
"median_bias_pct": -5.33,
"n": 179,
"p25_pct": -18.99,
"p75_pct": 7.16
},
"4 >=85": {
"mape_pct": 23.21,
"median_bias_pct": -21.36,
"n": 47,
"p25_pct": -29.97,
"p75_pct": 6.3
}
},
"per_rooms": {
"0": {
"label": "студия",
"mape_pct": 16.96,
"median_bias_pct": 16.96,
"n": 35,
"mape_pct": 14.55,
"median_bias_pct": 7.59,
"n": 161,
"n_no_analogs": 0,
"p25_pct": 1.5,
"p75_pct": 38.82
"p25_pct": -4.16,
"p75_pct": 29.4
},
"1": {
"label": "1к",
"mape_pct": 11.06,
"median_bias_pct": -4.61,
"n": 93,
"mape_pct": 13.12,
"median_bias_pct": -0.51,
"n": 503,
"n_no_analogs": 0,
"p25_pct": -14.43,
"p75_pct": 8.15
"p25_pct": -11.98,
"p75_pct": 14.88
},
"2": {
"label": "2к",
"mape_pct": 17.39,
"median_bias_pct": -11.71,
"n": 74,
"mape_pct": 12.99,
"median_bias_pct": -2.82,
"n": 379,
"n_no_analogs": 0,
"p25_pct": -23.07,
"p75_pct": -0.36
"p25_pct": -14.44,
"p75_pct": 10.26
},
"3": {
"label": "3к",
"mape_pct": 7.79,
"median_bias_pct": -4.05,
"n": 43,
"mape_pct": 15.32,
"median_bias_pct": -5.33,
"n": 179,
"n_no_analogs": 0,
"p25_pct": -10.26,
"p75_pct": 3.82
"p25_pct": -18.99,
"p75_pct": 7.16
},
"4": {
"label": "4+",
"mape_pct": 16.38,
"median_bias_pct": -1.32,
"n": 24,
"mape_pct": 23.21,
"median_bias_pct": -21.36,
"n": 47,
"n_no_analogs": 0,
"p25_pct": -14.91,
"p75_pct": 15.41
"p25_pct": -29.97,
"p75_pct": 6.3
}
},
"per_segment": {
"бизнес": {
"mape_pct": 13.65,
"median_bias_pct": -10.54,
"n": 46,
"p25_pct": -27.15,
"p75_pct": -1.31
"mape_pct": 11.85,
"median_bias_pct": -9.98,
"n": 144,
"p25_pct": -20.69,
"p75_pct": -1.72
},
"комфорт": {
"mape_pct": 10.14,
"median_bias_pct": -5.01,
"n": 101,
"p25_pct": -15.49,
"p75_pct": 4.37
"mape_pct": 12.71,
"median_bias_pct": -2.39,
"n": 425,
"p25_pct": -16.15,
"p75_pct": 9.46
},
"премиум": {
"mape_pct": 68.92,
"median_bias_pct": -68.92,
"n": 1,
"p25_pct": -68.92,
"p75_pct": -68.92
"mape_pct": null,
"median_bias_pct": null,
"n": 0,
"p25_pct": null,
"p75_pct": null
},
"эконом": {
"mape_pct": 14.2,
"median_bias_pct": 3.33,
"n": 115,
"p25_pct": -8.69,
"p75_pct": 27.3
"mape_pct": 14.98,
"median_bias_pct": 2.46,
"n": 697,
"p25_pct": -9.54,
"p75_pct": 26.95
},
"элит": {
"mape_pct": 33.2,
"median_bias_pct": -33.2,
"n": 6,
"p25_pct": -42.75,
"p75_pct": -22.4
"mape_pct": 18.45,
"median_bias_pct": -18.45,
"n": 3,
"p25_pct": -40.32,
"p75_pct": -15.7
}
}
},
"headline": {
"ask_median_ppm2": 145883.6593586467,
"deal_median_ppm2": 125063.0,
"spread_pct": 16.65
"ask_median_ppm2": 136721.80831284,
"deal_median_ppm2": 115770.5,
"spread_pct": 18.1
},
"range_coverage": {
"overall": {
"coverage_pct": 82.16,
"n": 269,
"n_covered": 221
"coverage_pct": 86.13,
"n": 1269,
"n_covered": 1093
},
"per_confidence": {
"high": {
"coverage_pct": null,
"n": 0,
"n_covered": 0
"coverage_pct": 50.0,
"n": 8,
"n_covered": 4
},
"low": {
"coverage_pct": 82.09,
"n": 268,
"n_covered": 220
"coverage_pct": 86.68,
"n": 1231,
"n_covered": 1067
},
"medium": {
"coverage_pct": 100.0,
"n": 1,
"n_covered": 1
"coverage_pct": 73.33,
"n": 30,
"n_covered": 22
}
}
},
"sharpness": {
"median_rel_width": 0.743,
"n": 269
"n": 1269
},
"unrecorded_lookup_calls": 0
}