Compare commits
No commits in common. "d20b844561bfa35a15e8a967b19104a323a60880" and "078f10284130074da61737c1261e889757382de5" have entirely different histories.
d20b844561
...
078f102841
2 changed files with 5 additions and 211 deletions
|
|
@ -1129,88 +1129,6 @@ _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.
|
||||||
|
|
@ -1245,7 +1163,7 @@ _CITY_PPM2_BAND_SQL = text(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sample_sql(city: str | None, scattered: bool = False) -> Any:
|
def _sample_sql(city: str | None) -> 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
|
||||||
|
|
@ -1259,8 +1177,6 @@ def _sample_sql(city: str | None, scattered: bool = False) -> 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(
|
||||||
|
|
@ -1319,13 +1235,7 @@ def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float
|
||||||
|
|
||||||
|
|
||||||
def _load_sample(
|
def _load_sample(
|
||||||
db: Session,
|
db: Session, *, sample: int, since: str, city: str | None = None
|
||||||
*,
|
|
||||||
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].
|
||||||
|
|
||||||
|
|
@ -1349,9 +1259,7 @@ def _load_sample(
|
||||||
}
|
}
|
||||||
if city is not None:
|
if city is not None:
|
||||||
params["city"] = city
|
params["city"] = city
|
||||||
if scattered:
|
rows = db.execute(_sample_sql(city), params).mappings().all()
|
||||||
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:
|
||||||
|
|
@ -2117,8 +2025,6 @@ 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.
|
||||||
|
|
||||||
|
|
@ -2138,7 +2044,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, scattered=scattered, seed=seed)
|
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||||
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]] = []
|
||||||
|
|
@ -2212,8 +2118,6 @@ 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).
|
||||||
|
|
||||||
|
|
@ -2246,7 +2150,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, scattered=scattered, seed=seed)
|
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
@ -2414,24 +2318,6 @@ 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,
|
||||||
|
|
@ -2572,8 +2458,6 @@ 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(
|
||||||
|
|
@ -2584,8 +2468,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"""Выборка бэктеста: режим «вразброс» и неприкосновенность умолчания.
|
|
||||||
|
|
||||||
ЗАЧЕМ. `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