Бэктест оценщика умеет любой регион, а не только Свердловскую область #3516
1 changed files with 117 additions and 22 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue