From 884a6b9cfe45623b9819cef573dfbac5859f4c3a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 31 Aug 2026 11:28:11 +0500 Subject: [PATCH] =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82:=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=D1=80=D0=B0=D0=B7=D0=B1=D1=80=D0=BE=D1=81?= =?UTF-8?q?=20=E2=80=94=20ORDER=20BY=20id=20DESC=20=D1=81=D0=B0=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=83=D0=BB=D0=B8=D1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ORDER BY id DESC берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится пачками по домам: соседние id это один дом и одна улица. Замер на проде 31.08.2026 по Екатеринбургу, выборка 200: последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы случайные 200 : 129 разных улиц, максимум 6 с одной На этой выборке стоит витрина лэндинга: 20 строк с ЧЕТЫРЁХ улиц, 16 из них с двух. Владелец заметил это как «почти все сделки из одного района». ЧТО ЗАМЕР ПОКАЗАЛ, а что нет. Пять прогонов по 200 сделок: recent MAPE 15.18% покрытие 88.75% scattered mera MAPE 14.96% покрытие 83.13% scattered alpha MAPE 15.97% покрытие 88.34% scattered bravo MAPE 13.69% покрытие 83.85% scattered charlie MAPE 14.36% покрытие 86.23% Заголовочная точность УСТОЯЛА — опубликованные 14,5% лежат внутри разброса представительной выборки. Кластеризация её не раздувала. А покрытие коридором опубликовано как 88% — это верх диапазона: при пересборке выборки величина гуляет 83-88 при медиане около 86. Публикуется лучший прогон из пяти как единственный. Правка самих чисел лэндинга — отдельным заходом. УМОЛЧАНИЕ НЕ ТРОНУТО. Докстринг _sample_sql обещает дефолтному пути побайтово тот же SQL ради замороженного регресс-гейта; смена умолчания молча обнулила бы сравнимость всей истории замеров. Режим выбирается флагом --spread. Порядок по md5(id||seed), а не random(): нужен ВОСПРОИЗВОДИМЫЙ порядок, иначе два прогона отличаются и из-за правки, и из-за состава выборки, и разделить вклады нечем. Тест держит обе стороны и проверен фальсификацией: сделать вразброс умолчанием — падает identity-проверка (сравнение текстов её бы пропустило), заменить md5 на random() — падает проверка воспроизводимости. --- .../backend/scripts/backtest_estimator.py | 128 +++++++++++++++++- .../tests/test_backtest_sampling_spread.py | 88 ++++++++++++ 2 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_backtest_sampling_spread.py diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index b2e39d47..41a5b555 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1129,6 +1129,88 @@ _SAMPLE_SQL = text( """ ) +# ── ВЫБОРКА ВРАЗБРОС (--spread scattered) ──────────────────────────────────── +# +# ЗАЧЕМ. `ORDER BY id DESC` выше — это последние ВСТАВЛЕННЫЕ строки, а Росреестр +# грузится пачками по домам: соседние id это один дом и одна улица. Замер на +# проде 31.08.2026 по Екатеринбургу, выборка 200: +# +# последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы +# случайные 200 : 129 разных улиц, максимум 6 с одной +# +# То есть заголовочная точность и вся витрина лэндинга считались по выборке +# втрое беднее случайной. Ошибка при этом не «шумит», а СМЕЩЕНА: пара новостроек +# ведёт себя иначе, чем город. +# +# ПОЧЕМУ НЕ СТАЛО УМОЛЧАНИЕМ. Замороженный регресс-гейт сравнивает прогоны между +# собой и требует от дефолтного пути побайтово того же SQL (см. докстринг +# `_sample_sql`). Менять умолчание значило бы обнулить сравнимость всей истории +# замеров. Режим выбирается явно. +# +# ПОЧЕМУ md5, А НЕ random(). `random()` даёт разную выборку на каждом прогоне — +# два замера подряд отличались бы и из-за правки, и из-за выборки, и разделить +# эти вклады было бы нечем. md5(id || seed) — псевдослучайный, но +# ВОСПРОИЗВОДИМЫЙ порядок: тот же seed → та же выборка. +_SAMPLE_SQL_SCATTERED = text( + """ + SELECT + id, + ST_X(geom::geometry) AS lon, + ST_Y(geom::geometry) AS lat, + rooms, + price_per_m2 AS sold_ppm2, + price_rub, + deal_date, + area_m2, + address, + floor, + total_floors, + year_built, + house_type + FROM deals + WHERE source = 'rosreestr' + AND geom IS NOT NULL + AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) + AND rooms IS NOT NULL + AND area_m2 IS NOT NULL + AND area_m2 > 0 + AND deal_date >= CAST(:since AS date) + ORDER BY md5(CAST(id AS text) || CAST(:seed AS text)) + LIMIT CAST(:sample AS integer) + """ +) + +_SAMPLE_SQL_SCATTERED_CITY = text( + """ + SELECT + id, + ST_X(geom::geometry) AS lon, + ST_Y(geom::geometry) AS lat, + rooms, + price_per_m2 AS sold_ppm2, + price_rub, + deal_date, + area_m2, + address, + floor, + total_floors, + year_built, + house_type + FROM deals + WHERE source = 'rosreestr' + AND geom IS NOT NULL + AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) + AND rooms IS NOT NULL + AND area_m2 IS NOT NULL + AND area_m2 > 0 + AND deal_date >= CAST(:since AS date) + AND city = CAST(:city AS text) + ORDER BY md5(CAST(id AS text) || CAST(:seed AS text)) + LIMIT CAST(:sample AS integer) + """ +) + + # Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi # (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers # is applied in Python for byte-for-byte fidelity with the estimator. @@ -1163,7 +1245,7 @@ _CITY_PPM2_BAND_SQL = text( ) -def _sample_sql(city: str | None) -> Any: +def _sample_sql(city: str | None, scattered: bool = False) -> Any: """ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D). ``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used @@ -1177,6 +1259,8 @@ def _sample_sql(city: str | None) -> Any: Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a slug — see the ``--city`` CLI help for the naming decision. """ + if scattered: + return _SAMPLE_SQL_SCATTERED_CITY if city is not None else _SAMPLE_SQL_SCATTERED if city is None: return _SAMPLE_SQL return text( @@ -1235,7 +1319,13 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float def _load_sample( - db: Session, *, sample: int, since: str, city: str | None = None + db: Session, + *, + sample: int, + since: str, + city: str | None = None, + scattered: bool = False, + seed: str = "mera", ) -> list[DealSample]: """Run the held-out ДКП deal sampling SELECT → list[DealSample]. @@ -1259,7 +1349,9 @@ def _load_sample( } if city is not None: params["city"] = city - rows = db.execute(_sample_sql(city), params).mappings().all() + if scattered: + params["seed"] = seed + rows = db.execute(_sample_sql(city, scattered), params).mappings().all() out: list[DealSample] = [] for r in rows: if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None: @@ -2025,6 +2117,8 @@ def run_backtest( rooms_tolerance: int, holdout_split: bool = False, city: str | None = None, + scattered: bool = False, + seed: str = "mera", ) -> dict[str, Any]: """Drive the full read-only backtest and return a metrics dict. @@ -2044,7 +2138,7 @@ def run_backtest( ``deals.city`` value — see ``_load_sample``. Default None is unscoped (byte-identical to the pre-oblast-D behaviour). """ - deals = _load_sample(db, sample=sample, since=since, city=city) + deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed) logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city) matched_rows: list[tuple[float, float, int]] = [] @@ -2118,6 +2212,8 @@ def run_backtest_full( dump_fixture: str | None = None, resolve_house_id: bool = False, city: str | None = None, + scattered: bool = False, + seed: str = "mera", ) -> dict[str, Any]: """Drive the FULL-spine read-only backtest and return a metrics dict (#1966). @@ -2150,7 +2246,7 @@ def run_backtest_full( ``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring. """ est = _import_estimator_full() - deals = _load_sample(db, sample=sample, since=since, city=city) + deals = _load_sample(db, sample=sample, since=since, city=city, scattered=scattered, seed=seed) logger.info( "loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city ) @@ -2318,6 +2414,24 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: default="2025-06-01", help="Only deals with deal_date >= this ISO date (default 2025-06-01).", ) + p.add_argument( + "--spread", + choices=("recent", "scattered"), + default="recent", + help="Как берётся выборка сделок. 'recent' (умолчание) — ORDER BY id DESC, " + "т.е. последние ВСТАВЛЕННЫЕ строки: Росреестр грузится пачками по домам, " + "поэтому такая выборка садится на несколько улиц (замер 31.08.2026 по ЕКБ: " + "38 улиц, до 22 сделок с одной). 'scattered' — воспроизводимый " + "псевдослучайный порядок по md5(id||seed): 129 улиц, максимум 6 с одной. " + "Умолчание оставлено 'recent' ради сравнимости с историей замеров — " + "замороженный регресс-гейт требует от дефолтного пути того же SQL.", + ) + p.add_argument( + "--seed", + default="mera", + help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два " + "прогона подряд отличаются только правкой, а не составом выборки.", + ) p.add_argument( "--city", default=None, @@ -2458,6 +2572,8 @@ def main(argv: list[str] | None = None) -> int: dump_fixture=args.dump_fixture, resolve_house_id=args.resolve_house_id, city=args.city, + scattered=(args.spread == "scattered"), + seed=args.seed, ) else: metrics = run_backtest( @@ -2468,6 +2584,8 @@ def main(argv: list[str] | None = None) -> int: rooms_tolerance=args.rooms_tolerance, holdout_split=args.holdout_split, city=args.city, + scattered=(args.spread == "scattered"), + seed=args.seed, ) finally: db.close() diff --git a/tradein-mvp/backend/tests/test_backtest_sampling_spread.py b/tradein-mvp/backend/tests/test_backtest_sampling_spread.py new file mode 100644 index 00000000..b4cbeea0 --- /dev/null +++ b/tradein-mvp/backend/tests/test_backtest_sampling_spread.py @@ -0,0 +1,88 @@ +"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания. + +ЗАЧЕМ. `ORDER BY id DESC` берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр +грузится пачками по домам: соседние id — это один дом и одна улица. Замер на +проде 31.08.2026 по Екатеринбургу, выборка 200: + + последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы + случайные 200 : 129 разных улиц, максимум 6 с одной + +Выборка втрое беднее случайной — а на ней стояла витрина лэндинга (20 строк с +ЧЕТЫРЁХ улиц, 16 из них с двух). + +ЧТО ЭТОТ ТЕСТ ДЕРЖИТ. Две вещи, и вторая не менее важна первой: + +1. Режим «вразброс» действительно меняет порядок и воспроизводим по seed. +2. УМОЛЧАНИЕ НЕ ТРОНУТО — дефолтный путь обязан отдавать ТОТ ЖЕ объект SQL. + Замороженный регресс-гейт сравнивает прогоны между собой; сменить умолчание + значило бы молча обнулить сравнимость всей истории замеров. +""" + +from __future__ import annotations + +import re + +from scripts.backtest_estimator import ( + _SAMPLE_SQL, + _SAMPLE_SQL_SCATTERED, + _SAMPLE_SQL_SCATTERED_CITY, + _sample_sql, +) + + +def _sql(obj) -> str: + return str(obj.text if hasattr(obj, "text") else obj) + + +class TestУмолчаниеНеТронуто: + def test_дефолтный_путь_отдаёт_тот_же_объект(self): + # Именно identity, а не равенство текста: докстринг `_sample_sql` + # обещает побайтовую неизменность, и «текст совпал» это обещание не + # проверяет — объект мог быть пересобран с другими bind-параметрами. + assert _sample_sql(None) is _SAMPLE_SQL + assert _sample_sql(None, scattered=False) is _SAMPLE_SQL + + def test_умолчание_сортирует_по_id_и_не_знает_про_seed(self): + sql = _sql(_SAMPLE_SQL) + assert "ORDER BY id DESC" in sql + assert ":seed" not in sql, "в умолчание протёк параметр режима вразброс" + + +class TestРежимВразброс: + def test_выбирается_явно_и_меняет_порядок(self): + assert _sample_sql(None, scattered=True) is _SAMPLE_SQL_SCATTERED + assert _sample_sql("Екатеринбург", scattered=True) is _SAMPLE_SQL_SCATTERED_CITY + + def test_порядок_воспроизводим_а_не_случаен(self): + # random() дал бы разную выборку на каждом прогоне, и два замера + # подряд отличались бы и из-за правки, и из-за состава выборки — + # разделить вклады было бы нечем. + for obj in (_SAMPLE_SQL_SCATTERED, _SAMPLE_SQL_SCATTERED_CITY): + sql = _sql(obj) + assert "md5(" in sql, "порядок обязан считаться от id и seed" + assert ":seed" in sql + assert re.search(r"\brandom\s*\(", sql) is None, ( + "random() делает выборку неповторимой между прогонами" + ) + assert "ORDER BY id DESC" not in sql + + def test_городской_вариант_фильтрует_город_а_общий_нет(self): + assert "city = CAST(:city AS text)" in _sql(_SAMPLE_SQL_SCATTERED_CITY) + assert "city = CAST(:city AS text)" not in _sql(_SAMPLE_SQL_SCATTERED) + + def test_оба_режима_отбирают_по_одним_и_тем_же_условиям(self): + # Иначе разница между режимами объяснялась бы не порядком, а другим + # набором сделок — и сравнение двух прогонов ничего бы не значило. + общие = [ + "source = 'rosreestr'", + "geom IS NOT NULL", + "price_per_m2 BETWEEN", + "rooms IS NOT NULL", + "area_m2 > 0", + "deal_date >= CAST(:since AS date)", + ] + база = _sql(_SAMPLE_SQL) + вразброс = _sql(_SAMPLE_SQL_SCATTERED) + for условие in общие: + assert условие in база, условие + assert условие in вразброс, условие -- 2.45.3