бэктест: выборка вразброс — ORDER BY id DESC садится на несколько улиц #3289

Merged
bot-backend merged 1 commit from feat/backtest-scattered-sampling into main 2026-08-31 09:14:42 +00:00
2 changed files with 211 additions and 5 deletions

View file

@ -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()

View file

@ -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 вразброс, условие