Бэктест оценщика умеет любой регион, а не только Свердловскую область #3516

Merged
lekss361 merged 1 commit from feat/backtest-estimator-region into main 2026-09-13 11:03:32 +00:00

View file

@ -134,6 +134,10 @@ USAGE
# oblast D: per-city validation (exact deals.city name, not a slug): # oblast D: per-city validation (exact deals.city name, not a slug):
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300 python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
# #3520 любой регион (choices come from app.services.regions.REGIONS):
# Москва (region_code=77, 212 937 ДКП, 69 138 за последние 12 мес.):
python -m scripts.backtest_estimator --region 77 --since 2024-09-01 --sample 300
""" """
from __future__ import annotations from __future__ import annotations
@ -156,6 +160,15 @@ from typing import Any
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.regions import REGIONS, region_for_point
# Регион по умолчанию — Свердловская обл., прежнее (единственное до #3520)
# поведение скрипта. Список допустимых значений — РЕЕСТР REGIONS
# (app/services/regions.py), а не отдельный литерал: регион появляется в
# реестре один раз и автоматически становится доступен и здесь, и в
# msk_raw_import, и в самом эстиматоре.
DEFAULT_BACKTEST_REGION_CODE = 66
def _import_estimator() -> tuple[Any, Any]: def _import_estimator() -> tuple[Any, Any]:
"""Lazy import of the estimator's pure funcs (_filter_outliers, _percentile). """Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).
@ -1153,6 +1166,7 @@ _SAMPLE_SQL = text(
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1204,6 +1218,7 @@ _SAMPLE_SQL_SCATTERED = text(
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1233,6 +1248,7 @@ _SAMPLE_SQL_SCATTERED_CITY = text(
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1271,10 +1287,9 @@ _CANDIDATES_SQL = text(
# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city) # _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city)
# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see # since migration 298 — #3051 "Москва"). Looked up only when --city is set (see
# _resolve_city_ppm2_band); the default (city=None) path never issues this # _resolve_city_ppm2_band); the default (city=None) path never issues this
# query. region_code defaults to 66 (Свердловская обл.) — this script has no # query. region_code (#3520 "любой регион") is threaded from the CLI --region
# region CLI flag yet, so every call is scoped to the oblast, matching every # flag end-to-end now (default 66, Свердловская обл. — unchanged behaviour for
# existing --city invocation (byte-identical to the pre-298 unscoped lookup, # every pre-existing invocation, which never passed --region).
# which only ever saw region-66 rows since the table was oblast-only before).
_CITY_PPM2_BAND_SQL = text( _CITY_PPM2_BAND_SQL = text(
""" """
SELECT ppm2_min, ppm2_max SELECT ppm2_min, ppm2_max
@ -1288,10 +1303,13 @@ _CITY_PPM2_BAND_SQL = text(
def _sample_sql(city: str | None, scattered: bool = False) -> 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 every
before this change literal identity, not just equal text so the time literal identity, not just equal text (asserted by
default CLI invocation (and the frozen EKB regression gate, which never ``test_sample_sql_city_none_returns_original_object``) so callers that
calls this path at all) see byte-identical SQL. cache/compare the query object see a stable reference. All four SQL
variants (unscoped/scattered × city/no-city) carry a ``region_code``
predicate (#3520 "любой регион") bound from ``_load_sample``'s
``region_code`` param, default 66.
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
scopes the sample to that ``deals.city`` value populated oblast-wide by scopes the sample to that ``deals.city`` value populated oblast-wide by
@ -1321,6 +1339,7 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any:
house_type house_type
FROM deals FROM deals
WHERE source = 'rosreestr' WHERE source = 'rosreestr'
AND region_code = CAST(:region_code AS int)
AND geom IS NOT NULL AND geom IS NOT NULL
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric) AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
AND rooms IS NOT NULL AND rooms IS NOT NULL
@ -1348,9 +1367,9 @@ def _resolve_city_ppm2_band(
mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB
error the globals; read-only best-effort, never raises. error the globals; read-only best-effort, never raises.
``region_code`` defaults to 66 (Свердловская обл.) this script has no ``region_code`` defaults to 66 (Свердловская обл.) for backward-compat
region CLI flag yet (out of scope, #3051 sub-PR B); every existing direct callers; ``_load_sample`` (#3520) always passes the actual
--city caller keeps its byte-identical lookup. ``--region`` value through explicitly.
""" """
if city is None: if city is None:
return float(PPM2_MIN), float(PPM2_MAX) return float(PPM2_MIN), float(PPM2_MAX)
@ -1376,22 +1395,32 @@ def _load_sample(
city: str | None = None, city: str | None = None,
scattered: bool = False, scattered: bool = False,
seed: str = "mera", seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> list[DealSample]: ) -> list[DealSample]:
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]. """Run the held-out ДКП deal sampling SELECT → list[DealSample].
``region_code`` (#3520 "любой регион", default 66 — Свердловская обл.)
scopes the sample to one ``deals.region_code`` value REQUIRED once
``deals`` carries more than one region (212 937 Москва + 113 351
Московская обл. rows alongside 108 623 ЕКБ), otherwise the "unscoped"
query would silently mix regions together. Must be a key of
``app.services.regions.REGIONS`` validated by ``main()``/``_parse_args``
(CLI ``choices``) so this function only ever sees a supported code.
``city`` (oblast D, default None) scopes the sample to one ``deals.city`` ``city`` (oblast D, default None) scopes the sample to one ``deals.city``
value via ``_sample_sql`` and sources the PPM2 sanity band from value via ``_sample_sql`` and sources the PPM2 sanity band from
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``, ``deal_city_price_bands`` for that (region_code, city) pair
falls back to the module globals). Default None is byte-identical to the (``_resolve_city_ppm2_band``, falls back to the module globals). Default
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals, None is byte-identical to the pre-oblast-D behaviour: same SQL object,
no extra query. same PPM2_MIN/PPM2_MAX globals, no extra query.
""" """
if city is None: if city is None:
ppm2_min: float = PPM2_MIN ppm2_min: float = PPM2_MIN
ppm2_max: float = PPM2_MAX ppm2_max: float = PPM2_MAX
else: else:
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city) ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city, region_code=region_code)
params: dict[str, Any] = { params: dict[str, Any] = {
"region_code": region_code,
"ppm2_min": ppm2_min, "ppm2_min": ppm2_min,
"ppm2_max": ppm2_max, "ppm2_max": ppm2_max,
"since": since, "since": since,
@ -1947,8 +1976,21 @@ def _predict_full_spine(
# harness measures the SAME corridor prod actually computes today, not the # harness measures the SAME corridor prod actually computes today, not the
# pre-C2 unscoped behaviour — else the backtest validates stale semantics. # pre-C2 unscoped behaviour — else the backtest validates stale semantics.
target_city = m._resolve_target_city(deal.address) target_city = m._resolve_target_city(deal.address)
# #3520 "любой регион": mirror estimate_quality's OWN region_code resolution
# (estimator.py ~4868-4870, region_for_point(geo.lat, geo.lon) with a
# DEFAULT_REGION_CODE fallback for points outside every registered bbox) —
# without this, _fetch_dkp_corridor's default (region 66) would scope a
# Москва/Московская обл. deal's corridor query to the WRONG region and
# silently return no rows (or, worse, an unrelated same-named street in 66).
target_region = region_for_point(deal.lat, deal.lon)
target_region_code = target_region.code if target_region else DEFAULT_BACKTEST_REGION_CODE
dkp_raw = m._fetch_dkp_corridor( dkp_raw = m._fetch_dkp_corridor(
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city db,
address=deal.address,
rooms=deal.rooms,
area=deal.area_m2,
city=target_city,
region_code=target_region_code,
) )
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like # #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
# estimate_quality — no-area / no-address → ([], None) instead of an # estimate_quality — no-area / no-address → ([], None) instead of an
@ -2169,6 +2211,7 @@ def run_backtest(
city: str | None = None, city: str | None = None,
scattered: bool = False, scattered: bool = False,
seed: str = "mera", seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> 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.
@ -2184,12 +2227,29 @@ def run_backtest(
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True`` out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
to fit on even-id deals and evaluate on the odd-id half for an honest number. to fit on even-id deals and evaluate on the odd-id half for an honest number.
``region_code`` (#3520, default 66) scopes the deal sample to one
``deals.region_code`` see ``_load_sample``.
``city`` (oblast D, default None) scopes the deal sample to one ``city`` (oblast D, default None) scopes the deal sample to one
``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(
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city) db,
sample=sample,
since=since,
city=city,
scattered=scattered,
seed=seed,
region_code=region_code,
)
logger.info(
"loaded sample: %d ДКП deals (since=%s, city=%s, region=%d)",
len(deals),
since,
city,
region_code,
)
matched_rows: list[tuple[float, float, int]] = [] matched_rows: list[tuple[float, float, int]] = []
matched_ids: list[int] = [] matched_ids: list[int] = []
@ -2264,6 +2324,7 @@ def run_backtest_full(
city: str | None = None, city: str | None = None,
scattered: bool = False, scattered: bool = False,
seed: str = "mera", seed: str = "mera",
region_code: int = DEFAULT_BACKTEST_REGION_CODE,
) -> 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).
@ -2289,6 +2350,14 @@ def run_backtest_full(
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is ``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
attached to the returned metrics. Default False byte-identical prior output. attached to the returned metrics. Default False byte-identical prior output.
``region_code`` (#3520, default 66) scopes the deal sample to one
``deals.region_code`` see ``_load_sample``. Independently of this flag,
``_predict_full_spine`` ALWAYS resolves each deal's OWN region from its
lat/lon via ``regions.region_for_point`` (mirroring prod's
``estimate_quality`` region resolution) before calling
``_fetch_dkp_corridor``, so the ДКП corridor is scoped correctly even if a
sample somehow mixed regions.
``city`` (oblast D, default None) scopes the deal sample to one ``city`` (oblast D, default None) scopes the deal sample to one
``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). Independently of this flag, (byte-identical to the pre-oblast-D behaviour). Independently of this flag,
@ -2296,9 +2365,21 @@ 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,
scattered=scattered,
seed=seed,
region_code=region_code,
)
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, region=%d) [full spine]",
len(deals),
since,
city,
region_code,
) )
predictions: list[Prediction] = [] predictions: list[Prediction] = []
@ -2482,6 +2563,17 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два " help="Соль для --spread scattered. Тот же seed даёт ТУ ЖЕ выборку: два "
"прогона подряд отличаются только правкой, а не составом выборки.", "прогона подряд отличаются только правкой, а не составом выборки.",
) )
p.add_argument(
"--region",
type=int,
default=DEFAULT_BACKTEST_REGION_CODE,
choices=sorted(REGIONS),
help="#3520 'любой регион': deals.region_code to backtest against (default "
f"{DEFAULT_BACKTEST_REGION_CODE} — Свердловская обл., unchanged behaviour). "
"Supported values come from the app.services.regions.REGIONS registry — "
+ ", ".join(f"{code} ({REGIONS[code].name})" for code in sorted(REGIONS))
+ ". An unsupported code is rejected by argparse before any DB call.",
)
p.add_argument( p.add_argument(
"--city", "--city",
default=None, default=None,
@ -2599,9 +2691,10 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit("--calibrate-segments is only supported with --engine full") raise SystemExit("--calibrate-segments is only supported with --engine full")
logger.info( logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm " "backtest start: engine=%s region=%d sample=%d since=%s radius=%dm "
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s", "rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
args.engine, args.engine,
args.region,
args.sample, args.sample,
args.since, args.since,
args.radius, args.radius,
@ -2624,6 +2717,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city, city=args.city,
scattered=(args.spread == "scattered"), scattered=(args.spread == "scattered"),
seed=args.seed, seed=args.seed,
region_code=args.region,
) )
else: else:
metrics = run_backtest( metrics = run_backtest(
@ -2636,6 +2730,7 @@ def main(argv: list[str] | None = None) -> int:
city=args.city, city=args.city,
scattered=(args.spread == "scattered"), scattered=(args.spread == "scattered"),
seed=args.seed, seed=args.seed,
region_code=args.region,
) )
finally: finally:
db.close() db.close()