Merge pull request 'бэктест: выборка вразброс — ORDER BY id DESC садится на несколько улиц' (#3289) from feat/backtest-scattered-sampling into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled
This commit is contained in:
commit
d20b844561
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
|
||||
# (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()
|
||||
|
|
|
|||
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