бэктест: выборка вразброс — ORDER BY id DESC садится на несколько улиц #3289
2 changed files with 211 additions and 5 deletions
|
|
@ -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
|
# Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi
|
||||||
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
|
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
|
||||||
# is applied in Python for byte-for-byte fidelity with the estimator.
|
# 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).
|
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||||
|
|
||||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
``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
|
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
|
||||||
slug — see the ``--city`` CLI help for the naming decision.
|
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:
|
if city is None:
|
||||||
return _SAMPLE_SQL
|
return _SAMPLE_SQL
|
||||||
return text(
|
return text(
|
||||||
|
|
@ -1235,7 +1319,13 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float
|
||||||
|
|
||||||
|
|
||||||
def _load_sample(
|
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]:
|
) -> list[DealSample]:
|
||||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||||
|
|
||||||
|
|
@ -1259,7 +1349,9 @@ def _load_sample(
|
||||||
}
|
}
|
||||||
if city is not None:
|
if city is not None:
|
||||||
params["city"] = city
|
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] = []
|
out: list[DealSample] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
|
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,
|
rooms_tolerance: int,
|
||||||
holdout_split: bool = False,
|
holdout_split: bool = False,
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
|
scattered: bool = False,
|
||||||
|
seed: str = "mera",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the full read-only backtest and return a metrics dict.
|
"""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
|
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||||
(byte-identical to the pre-oblast-D behaviour).
|
(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)
|
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||||
|
|
||||||
matched_rows: list[tuple[float, float, int]] = []
|
matched_rows: list[tuple[float, float, int]] = []
|
||||||
|
|
@ -2118,6 +2212,8 @@ def run_backtest_full(
|
||||||
dump_fixture: str | None = None,
|
dump_fixture: str | None = None,
|
||||||
resolve_house_id: bool = False,
|
resolve_house_id: bool = False,
|
||||||
city: str | None = None,
|
city: str | None = None,
|
||||||
|
scattered: bool = False,
|
||||||
|
seed: str = "mera",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
"""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.
|
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||||
"""
|
"""
|
||||||
est = _import_estimator_full()
|
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(
|
logger.info(
|
||||||
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
"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",
|
default="2025-06-01",
|
||||||
help="Only deals with deal_date >= this ISO date (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(
|
p.add_argument(
|
||||||
"--city",
|
"--city",
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -2458,6 +2572,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
dump_fixture=args.dump_fixture,
|
dump_fixture=args.dump_fixture,
|
||||||
resolve_house_id=args.resolve_house_id,
|
resolve_house_id=args.resolve_house_id,
|
||||||
city=args.city,
|
city=args.city,
|
||||||
|
scattered=(args.spread == "scattered"),
|
||||||
|
seed=args.seed,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
metrics = run_backtest(
|
metrics = run_backtest(
|
||||||
|
|
@ -2468,6 +2584,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
rooms_tolerance=args.rooms_tolerance,
|
rooms_tolerance=args.rooms_tolerance,
|
||||||
holdout_split=args.holdout_split,
|
holdout_split=args.holdout_split,
|
||||||
city=args.city,
|
city=args.city,
|
||||||
|
scattered=(args.spread == "scattered"),
|
||||||
|
seed=args.seed,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
88
tradein-mvp/backend/tests/test_backtest_sampling_spread.py
Normal file
88
tradein-mvp/backend/tests/test_backtest_sampling_spread.py
Normal 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 вразброс, условие
|
||||||
Loading…
Add table
Reference in a new issue