diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index e0fe8ef4..b2e39d47 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -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"]) diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index e7b389d1..d277aaba 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -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 } diff --git a/tradein-mvp/backend/tests/fixtures/backtest_full_fixture.json.gz b/tradein-mvp/backend/tests/fixtures/backtest_full_fixture.json.gz index dff67155..7fb8078e 100644 Binary files a/tradein-mvp/backend/tests/fixtures/backtest_full_fixture.json.gz and b/tradein-mvp/backend/tests/fixtures/backtest_full_fixture.json.gz differ