Merge pull request 'Бэктест оценщика берёт тип дома из справочника домов, как боевая оценка — штраф за тип дома впервые участвует в замере' (#3571) from fix/backtest-house-type 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
a6a1b7cdfc
2 changed files with 136 additions and 4 deletions
|
|
@ -1674,12 +1674,16 @@ class _HouseIdResolution:
|
|||
total: int = 0 # deals processed in the full-engine path under the flag
|
||||
resolved: int = 0 # deals where match_house_readonly returned an id
|
||||
imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired
|
||||
# #2862: сделкам, у которых тип дома подставлен из `houses` (deals.house_type пуст
|
||||
# у всех строк). Ноль здесь = штраф за тип дома в прогоне не участвовал вовсе.
|
||||
house_type_from_houses: int = 0
|
||||
|
||||
def as_json(self) -> dict[str, int]:
|
||||
return {
|
||||
"resolved": self.resolved,
|
||||
"total": self.total,
|
||||
"imv_reachable": self.imv_reachable,
|
||||
"house_type_from_houses": self.house_type_from_houses,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2550,6 +2554,18 @@ def run_backtest_full(
|
|||
target_house_id = _resolve_house_id_for_deal(db, deal)
|
||||
if target_house_id is not None:
|
||||
resolution.resolved += 1
|
||||
# #2862: боевой estimate_quality (#3234) берёт тип дома из `houses`, когда
|
||||
# его нет в форме — по резолвленному house_id, иначе ближайший дом в 60 м.
|
||||
# deals.house_type пуст у всех сделок, так что без этого штраф за тип дома
|
||||
# в прогоне не срабатывал ни разу. Только тип: год и этажность из `houses`
|
||||
# сдвинули бы метрики по другим признакам и смешали бы замер.
|
||||
if deal.house_type is None:
|
||||
facts = est.m._lookup_house_facts(
|
||||
db, target_house_id=target_house_id, lat=deal.lat, lon=deal.lon
|
||||
)
|
||||
if facts is not None and facts.house_type is not None:
|
||||
deal = dataclasses.replace(deal, house_type=facts.house_type)
|
||||
resolution.house_type_from_houses += 1
|
||||
try:
|
||||
pr = _predict_full_spine(
|
||||
db,
|
||||
|
|
@ -2616,10 +2632,11 @@ def run_backtest_full(
|
|||
if resolution is not None:
|
||||
metrics["house_id_resolution"] = resolution.as_json()
|
||||
logger.info(
|
||||
"house_id resolution: resolved=%d/%d imv_reachable=%d",
|
||||
"house_id resolution: resolved=%d/%d imv_reachable=%d house_type_from_houses=%d",
|
||||
resolution.resolved,
|
||||
resolution.total,
|
||||
resolution.imv_reachable,
|
||||
resolution.house_type_from_houses,
|
||||
)
|
||||
|
||||
if dump_fixture is not None and capture is not None:
|
||||
|
|
|
|||
|
|
@ -519,13 +519,23 @@ def test_argparse_resolve_house_id_override() -> None:
|
|||
|
||||
|
||||
def test_house_id_resolution_as_json_shape() -> None:
|
||||
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2)
|
||||
assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2}
|
||||
res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2, house_type_from_houses=3)
|
||||
assert res.as_json() == {
|
||||
"resolved": 4,
|
||||
"total": 10,
|
||||
"imv_reachable": 2,
|
||||
"house_type_from_houses": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_house_id_resolution_defaults_zero() -> None:
|
||||
res = bt._HouseIdResolution()
|
||||
assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0}
|
||||
assert res.as_json() == {
|
||||
"resolved": 0,
|
||||
"total": 0,
|
||||
"imv_reachable": 0,
|
||||
"house_type_from_houses": 0,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -1006,3 +1016,108 @@ def test_predict_full_spine_passes_resolved_city_to_corridor(
|
|||
assert captured.get("called") is True
|
||||
assert captured.get("city") == "нижний тагил"
|
||||
assert pred is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #2862: тип дома из `houses` доезжает до отбора аналогов, как в estimate_quality.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _run_full_with_house_facts(
|
||||
monkeypatch: pytest.MonkeyPatch, *, resolve_house_id: bool
|
||||
) -> tuple[dict[int, set[object]], list[dict[str, object]], dict[str, object]]:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services import estimator as est_mod
|
||||
|
||||
def _deal(deal_id: int, house_type: str | None) -> object:
|
||||
return bt.DealSample(
|
||||
id=deal_id,
|
||||
lon=60.6,
|
||||
lat=56.8,
|
||||
rooms=2,
|
||||
sold_ppm2=100_000.0,
|
||||
deal_date=None,
|
||||
area_m2=50.0,
|
||||
address="Екатеринбург, ул. Ленина, 5",
|
||||
floor=3,
|
||||
year_built=None,
|
||||
house_type=house_type,
|
||||
)
|
||||
|
||||
# Сделка 1 — как все сделки на проде (house_type пуст); сделка 2 — тип уже есть.
|
||||
monkeypatch.setattr(bt, "_load_sample", lambda *a, **kw: [_deal(1, None), _deal(2, "brick")])
|
||||
monkeypatch.setattr(bt, "_resolve_house_id_for_deal", lambda _db, _deal: 77)
|
||||
|
||||
lookups: list[dict[str, object]] = []
|
||||
|
||||
def _fake_lookup(_db: object, **kw: object) -> object:
|
||||
lookups.append(kw)
|
||||
return est_mod._HouseFacts(
|
||||
house_id=77, total_floors=16, year_built=1985, house_type="panel", material_walls=None
|
||||
)
|
||||
|
||||
seen: dict[int, set[object]] = {}
|
||||
current: dict[str, int] = {}
|
||||
|
||||
def _fake_fetch_analogs(_db: object, **kw: object) -> tuple[list[object], bool, str]:
|
||||
seen.setdefault(current["id"], set()).add(kw["house_type"])
|
||||
return [], False, "W"
|
||||
|
||||
def _fake_price_from_inputs(**_kw: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
median_price=5_000_000.0,
|
||||
median_ppm2=100_000.0,
|
||||
confidence="low",
|
||||
anchor_tier=None,
|
||||
expected_sold_per_m2=95_000.0,
|
||||
expected_sold_price=4_750_000.0,
|
||||
expected_sold_range_low=4_000_000.0,
|
||||
expected_sold_range_high=5_500_000.0,
|
||||
)
|
||||
|
||||
real_predict = bt._predict_full_spine
|
||||
|
||||
def _tracking_predict(db: object, deal: object, est: object, **kw: object) -> object:
|
||||
current["id"] = deal.id # type: ignore[attr-defined]
|
||||
return real_predict(db, deal, est, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(est_mod, "_lookup_house_facts", _fake_lookup)
|
||||
monkeypatch.setattr(est_mod, "_fetch_analogs", _fake_fetch_analogs)
|
||||
monkeypatch.setattr(est_mod, "_resolve_target_city", lambda _addr: None)
|
||||
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
|
||||
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
|
||||
monkeypatch.setattr(bt, "_predict_full_spine", _tracking_predict)
|
||||
|
||||
metrics = bt.run_backtest_full(
|
||||
None, # type: ignore[arg-type]
|
||||
sample=2,
|
||||
since="2025-06-01",
|
||||
resolve_house_id=resolve_house_id,
|
||||
)
|
||||
return seen, lookups, metrics
|
||||
|
||||
|
||||
def test_full_backtest_takes_house_type_from_houses_like_prod(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=True)
|
||||
|
||||
# Пустой тип сделки заменён типом дома — именно он уходит в каждый отбор аналогов.
|
||||
assert seen[1] == {"panel"}
|
||||
# Свой тип сделки главнее справочника, как форма главнее houses на проде.
|
||||
assert seen[2] == {"brick"}
|
||||
assert lookups == [{"target_house_id": 77, "lat": 56.8, "lon": 60.6}]
|
||||
assert metrics["house_id_resolution"]["house_type_from_houses"] == 1 # type: ignore[index]
|
||||
|
||||
|
||||
def test_full_backtest_without_flag_keeps_house_type_untouched(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=False)
|
||||
|
||||
assert seen[1] == {None}
|
||||
assert lookups == []
|
||||
assert "house_id_resolution" not in metrics
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue