fix(tradein/estimator): displayed analog cards match n_analogs + cross-source dedup (#2518)
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Failing after 54s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Some checks failed
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m16s
Deploy Trade-In / test (push) Successful in 4m49s
Deploy Trade-In / deploy (push) Failing after 54s
Deploy Trade-In / build-backend (push) Successful in 1m9s
This commit is contained in:
parent
2c5b30f8c9
commit
8b7af92ee3
3 changed files with 391 additions and 33 deletions
|
|
@ -1959,7 +1959,14 @@ def _compute_same_building_anchor(
|
||||||
= k·fsd. confidence-банд по fsd.
|
= k·fsd. confidence-банд по fsd.
|
||||||
|
|
||||||
Returns dict {anchor_ppm2, anchor_sold_ppm2, fsd, confidence, n, cv,
|
Returns dict {anchor_ppm2, anchor_sold_ppm2, fsd, confidence, n, cv,
|
||||||
comp_min_ppm2, used_uplift, haircut} или None если комплов нет.
|
comp_min_ppm2, used_uplift, haircut, comps} или None если комплов нет.
|
||||||
|
"comps" — ФИНАЛЬНЫЙ (пост-MAD-clip, оба прохода) список выживших comp-словарей,
|
||||||
|
len(comps) == n. Это ЕДИНСТВЕННЫЙ источник правды для того, какие именно
|
||||||
|
комплы сформировали anchor_ppm2/n — caller обязан строить UI-карточки
|
||||||
|
(anchor_comps_used) из НЕГО, а не из исходного (пред-clip) списка, иначе
|
||||||
|
MAD-clip-отсечённый ценовой выброс всё ещё показывается пользователю, а
|
||||||
|
n_analogs (=n) расходится с фактическим числом карточек (display-consistency
|
||||||
|
fix, см. estimate_quality #2527).
|
||||||
"""
|
"""
|
||||||
if not comps:
|
if not comps:
|
||||||
return None
|
return None
|
||||||
|
|
@ -2130,6 +2137,8 @@ def _compute_same_building_anchor(
|
||||||
"comp_max_ppm2": max(ppm2),
|
"comp_max_ppm2": max(ppm2),
|
||||||
"used_uplift": used_uplift,
|
"used_uplift": used_uplift,
|
||||||
"haircut": haircut,
|
"haircut": haircut,
|
||||||
|
# финальный пост-MAD-clip пул, len(comps) == n (см. docstring выше).
|
||||||
|
"comps": comps,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2329,9 +2338,10 @@ def _price_from_inputs(
|
||||||
# Когда anchor сработал, anchor_comps_used (и n_analogs=anchor["n"]) брались
|
# Когда anchor сработал, anchor_comps_used (и n_analogs=anchor["n"]) брались
|
||||||
# из недедупленного пула → тот же физлот с разных source (пустой cadnum,
|
# из недедупленного пула → тот же физлот с разных source (пустой cadnum,
|
||||||
# street-композит совпал) считался/показывался дважды. Дедупим ЗДЕСЬ, ДО
|
# street-композит совпал) считался/показывался дважды. Дедупим ЗДЕСЬ, ДО
|
||||||
# _compute_same_building_anchor и ДО anchor_comps_used=anchor_comps ниже —
|
# _compute_same_building_anchor — она сама дальше MAD-клипит anchor_comps и
|
||||||
# оба места читают этот же (теперь задедупленный) список, так что n_analogs
|
# возвращает финальный переживший пул как anchor["comps"] (display-consistency
|
||||||
# и показанные top-N analogs остаются согласованы автоматически.
|
# fix, см. anchor_comps_used=anchor["comps"] ниже) — n_analogs и показанные
|
||||||
|
# top-N analogs остаются согласованы автоматически по построению.
|
||||||
anchor_comps = _dedup_cross_source(anchor_comps)
|
anchor_comps = _dedup_cross_source(anchor_comps)
|
||||||
|
|
||||||
# 3. Outlier filter
|
# 3. Outlier filter
|
||||||
|
|
@ -2482,7 +2492,15 @@ def _price_from_inputs(
|
||||||
|
|
||||||
if anchor is not None:
|
if anchor is not None:
|
||||||
# #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы.
|
# #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы.
|
||||||
anchor_comps_used = anchor_comps
|
# display-consistency fix: anchor["comps"] — ФИНАЛЬНЫЙ пост-MAD-clip пул
|
||||||
|
# (len == anchor["n"] == n_analogs ниже), НЕ исходный anchor_comps —
|
||||||
|
# иначе clip-отсечённый ценовой выброс (напр. кросс-пост ×4.5 дороже
|
||||||
|
# остальных) оставался бы среди показанных карточек, а count карточек
|
||||||
|
# расходился бы с заявленным n_analogs.
|
||||||
|
# .get() fallback — defensive: тестовые/внешние доубли _compute_same_
|
||||||
|
# building_anchor могут не нести "comps" (старый контракт); деградируем
|
||||||
|
# к пред-clip списку, а не падаем KeyError.
|
||||||
|
anchor_comps_used = anchor.get("comps") or anchor_comps
|
||||||
est_ppm2 = anchor["anchor_ppm2"]
|
est_ppm2 = anchor["anchor_ppm2"]
|
||||||
# PREMIUM GUARDRAIL (hard).
|
# PREMIUM GUARDRAIL (hard).
|
||||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
|
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
|
||||||
|
|
@ -3586,16 +3604,29 @@ async def estimate_quality(
|
||||||
# #694: когда same-building якорь сработал, headline построен на комплах того
|
# #694: когда same-building якорь сработал, headline построен на комплах того
|
||||||
# же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean
|
# же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean
|
||||||
# (другие/дешевле/пусто → premium headline «не подтверждён» аналогами).
|
# (другие/дешевле/пусто → premium headline «не подтверждён» аналогами).
|
||||||
|
# display-consistency fix: anchor_comps_used уже = anchor["comps"] (финальный
|
||||||
|
# пост-MAD-clip пул, len == n_analogs) — count согласован по построению.
|
||||||
|
# _dedup_display_lots ловит остаточные кросс-source дубли (ценовой дрейф между
|
||||||
|
# площадками), которые price_bucket-строгий статистический _dedup_cross_source
|
||||||
|
# мог пропустить — display-only, n_analogs/median/cv не трогает.
|
||||||
if anchor_tier is not None and anchor_comps_used:
|
if anchor_tier is not None and anchor_comps_used:
|
||||||
analogs_lots = [_anchor_comp_to_analog(c) for c in anchor_comps_used[:10]]
|
display_pool = _dedup_display_lots(anchor_comps_used)
|
||||||
|
analogs_lots = [_anchor_comp_to_analog(c) for c in display_pool[:10]]
|
||||||
# #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/
|
# #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/
|
||||||
# days_on_market) считаем по ПОКАЗАННЫМ комплам, а не по радиусной выборке —
|
# days_on_market) считаем по ПОКАЗАННЫМ комплам, а не по радиусной выборке —
|
||||||
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
|
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
|
||||||
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
|
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
|
||||||
metadata_lots = anchor_comps_used
|
metadata_lots = display_pool
|
||||||
else:
|
else:
|
||||||
analogs_lots = [_listing_to_analog(lot) for lot in listings_clean[:10]]
|
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
|
||||||
metadata_lots = listings_clean
|
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
|
||||||
|
# listings_clean может содержать записи с price_per_m2=None (outlier-фильтр
|
||||||
|
# их сохраняет — «нечего судить»), которые раздували карточки сверх
|
||||||
|
# заявленного N. _dedup_display_lots — см. ветку anchor выше.
|
||||||
|
priced_clean = [lot for lot in listings_clean if lot.get("price_per_m2")]
|
||||||
|
display_pool = _dedup_display_lots(priced_clean)
|
||||||
|
analogs_lots = [_listing_to_analog(lot) for lot in display_pool[:10]]
|
||||||
|
metadata_lots = display_pool
|
||||||
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
|
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
|
||||||
# #2087 (M1): единый канонический sources_used + source_counts. ОБА считаем по
|
# #2087 (M1): единый канонический sources_used + source_counts. ОБА считаем по
|
||||||
# persisted analogs_lots (top-N, ровно то, что уходит в колонку analogs и
|
# persisted analogs_lots (top-N, ровно то, что уходит в колонку analogs и
|
||||||
|
|
@ -5459,6 +5490,8 @@ def _extract_street_token(addr: str | None) -> str:
|
||||||
|
|
||||||
def _lot_dedup_components(
|
def _lot_dedup_components(
|
||||||
lot: dict[str, Any],
|
lot: dict[str, Any],
|
||||||
|
*,
|
||||||
|
include_price: bool = True,
|
||||||
) -> tuple[str, str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]:
|
) -> tuple[str, str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]:
|
||||||
"""(cadnum, house_no, cadnum-композит, street-композит) лота для union-find.
|
"""(cadnum, house_no, cadnum-композит, street-композит) лота для union-find.
|
||||||
|
|
||||||
|
|
@ -5467,15 +5500,23 @@ def _lot_dedup_components(
|
||||||
гасит 66.9 vs 67.0), price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB)
|
гасит 66.9 vs 67.0), price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB)
|
||||||
(у настоящего кросс-поста цена идентична, допуск лишь гасит округление).
|
(у настоящего кросс-поста цена идентична, допуск лишь гасит округление).
|
||||||
house_no — extra-сигнал для street-union guard (не входит в equality-ключ).
|
house_no — extra-сигнал для street-union guard (не входит в equality-ключ).
|
||||||
Нет площади/цены → композиты None (лот уникален, не дедупится).
|
Нет площади (или, при include_price=True, нет цены) → композиты None (лот
|
||||||
|
уникален, не дедупится).
|
||||||
|
|
||||||
|
include_price=False (display-only дедуп, см. _dedup_display_lots) отключает
|
||||||
|
price_bucket-компонент композита (фиксирован в 0, цена не требуется) —
|
||||||
|
кросс-постинг одного физлота с ценовым дрейфом между площадками (частый
|
||||||
|
случай: перевыставили дешевле/дороже) всё равно схлопывается в один
|
||||||
|
building+floor+area_bucket ключ. НЕ используется в статистическом пайплайне
|
||||||
|
(_dedup_cross_source, влияет на n_analogs/median/cv) — только для UI-карточек.
|
||||||
"""
|
"""
|
||||||
area = lot.get("area_m2")
|
area = lot.get("area_m2")
|
||||||
price = lot.get("price_rub")
|
price = lot.get("price_rub")
|
||||||
if not area or not price:
|
if not area or (include_price and not price):
|
||||||
return "", "", None, None
|
return "", "", None, None
|
||||||
floor = lot.get("floor")
|
floor = lot.get("floor")
|
||||||
area_b = round(float(area))
|
area_b = round(float(area))
|
||||||
price_b = round(float(price) / _DEDUP_PRICE_BUCKET_RUB)
|
price_b = round(float(price) / _DEDUP_PRICE_BUCKET_RUB) if include_price and price else 0
|
||||||
cad = lot.get("building_cadastral_number")
|
cad = lot.get("building_cadastral_number")
|
||||||
cad_s = str(cad).strip() if cad and str(cad).strip() else ""
|
cad_s = str(cad).strip() if cad and str(cad).strip() else ""
|
||||||
cad_key = (cad_s, floor, area_b, price_b) if cad_s else None
|
cad_key = (cad_s, floor, area_b, price_b) if cad_s else None
|
||||||
|
|
@ -5507,31 +5548,36 @@ def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
|
||||||
return (sa_ord, has_photo, lot.get("source") or "", lot.get("source_url") or "")
|
return (sa_ord, has_photo, lot.get("source") or "", lot.get("source_url") or "")
|
||||||
|
|
||||||
|
|
||||||
def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _union_find_phys_dedup(
|
||||||
"""Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265).
|
lots: list[dict[str, Any]], *, include_price: bool
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Общий union-find движок для _dedup_cross_source / _dedup_display_lots.
|
||||||
|
|
||||||
Union-find по физическим композитам: два лота — дубли, если совпал ЛЮБОЙ
|
Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265):
|
||||||
ключ — cadnum-композит ИЛИ street-композит (_lot_dedup_components). Из группы
|
два лота — дубли, если совпал ЛЮБОЙ ключ — cadnum-композит ИЛИ street-композит
|
||||||
оставляет ОДНОГО представителя (свежайший scraped_at), НЕ суммирует. Порядок
|
(_lot_dedup_components). Из группы оставляет ОДНОГО представителя (свежайший
|
||||||
вывода — по первому появлению группы (сохраняет relevance-сортировку из
|
scraped_at), НЕ суммирует. Порядок вывода — по первому появлению группы
|
||||||
_fetch_analogs для top-N UI). Представитель несёт свой source → n_analogs/
|
(сохраняет relevance-сортировку из _fetch_analogs для top-N UI).
|
||||||
median/cv/source_counts/sources_used считаются по физическим лотам («лот
|
|
||||||
считается один раз»; source кросс-поста, не ставший представителем, выпадает
|
|
||||||
из выборки — по нему нет независимой ценовой точки).
|
|
||||||
|
|
||||||
#2265: cadnum-композит обрабатывается первым (авторитетный), затем street-
|
#2265: cadnum-композит обрабатывается первым (авторитетный), затем street-
|
||||||
композит — но union по street НЕ сливает два РАЗНЫХ непустых кадастра
|
композит — но union по street НЕ сливает два РАЗНЫХ непустых кадастра
|
||||||
(разные здания) И два РАЗНЫХ извлечённых номера дома (equal-or-missing =
|
(разные здания) И два РАЗНЫХ извлечённых номера дома (equal-or-missing =
|
||||||
сливаем, both-present-different = нет). Оба guard'а — на уровне корня группы,
|
сливаем, both-present-different = нет). Оба guard'а — на уровне корня группы,
|
||||||
устойчивы к порядку. Флаг OFF → no-op (байт-идентичный проход). Лоты без
|
устойчивы к порядку. Лоты без площади (или, при include_price=True, без цены)
|
||||||
площади/цены не дедупятся — остаются уникальными.
|
не дедупятся — остаются уникальными.
|
||||||
"""
|
|
||||||
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
|
|
||||||
return lots
|
|
||||||
|
|
||||||
|
include_price=True — статистический ключ (влияет на n_analogs/median/cv,
|
||||||
|
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
|
||||||
|
_dedup_cross_source). include_price=False — display-only ключ без
|
||||||
|
price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами
|
||||||
|
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
|
||||||
|
пары для merge (require_diff_source) — без цены единственный оставшийся
|
||||||
|
сигнал «это правда репост», а не два разных юнита в одном доме/этаже/
|
||||||
|
area_bucket, случайно совпавших по округлённой площади.
|
||||||
|
"""
|
||||||
n = len(lots)
|
n = len(lots)
|
||||||
parent = list(range(n))
|
parent = list(range(n))
|
||||||
comps = [_lot_dedup_components(lot) for lot in lots]
|
comps = [_lot_dedup_components(lot, include_price=include_price) for lot in lots]
|
||||||
# cadnum/house группы (root → «» если группа без/со смешанным значением).
|
# cadnum/house группы (root → «» если группа без/со смешанным значением).
|
||||||
root_cad = [c[0] for c in comps]
|
root_cad = [c[0] for c in comps]
|
||||||
root_house = [c[1] for c in comps]
|
root_house = [c[1] for c in comps]
|
||||||
|
|
@ -5542,10 +5588,20 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
x = parent[x]
|
x = parent[x]
|
||||||
return x
|
return x
|
||||||
|
|
||||||
|
# display-only (include_price=False): без price_bucket в ключе два РАЗНЫХ
|
||||||
|
# реальных юнита в одном доме/этаже/area_bucket (обычный случай — соседние
|
||||||
|
# квартиры на площадке) стали бы неотличимы от кросс-поста ОДНОГО юнита.
|
||||||
|
# Требуем разный source для merge — реальный кросс-пост живёт на РАЗНЫХ
|
||||||
|
# площадках (avito/cian/yandex/domklik); одинаковый source + одинаковый
|
||||||
|
# физключ, но разная цена → разные юниты (или дефект данных), не схлопываем.
|
||||||
|
require_diff_source = not include_price
|
||||||
|
|
||||||
def union(a: int, b: int, guard_house: bool) -> None:
|
def union(a: int, b: int, guard_house: bool) -> None:
|
||||||
ra, rb = find(a), find(b)
|
ra, rb = find(a), find(b)
|
||||||
if ra == rb:
|
if ra == rb:
|
||||||
return
|
return
|
||||||
|
if require_diff_source and lots[a].get("source") == lots[b].get("source"):
|
||||||
|
return # display-only: тот же source → не кросс-пост, не сливаем
|
||||||
ca, cb = root_cad[ra], root_cad[rb]
|
ca, cb = root_cad[ra], root_cad[rb]
|
||||||
if ca and cb and ca != cb:
|
if ca and cb and ca != cb:
|
||||||
return # разные кадастры → разные здания, не сливаем
|
return # разные кадастры → разные здания, не сливаем
|
||||||
|
|
@ -5620,7 +5676,8 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
collapsed += len(group) - 1
|
collapsed += len(group) - 1
|
||||||
if collapsed:
|
if collapsed:
|
||||||
logger.info(
|
logger.info(
|
||||||
"cross-source dedup #2087/#2265: %d lots → %d unique (-%d dups)",
|
"phys dedup (include_price=%s): %d lots → %d unique (-%d dups)",
|
||||||
|
include_price,
|
||||||
len(lots),
|
len(lots),
|
||||||
len(result),
|
len(result),
|
||||||
collapsed,
|
collapsed,
|
||||||
|
|
@ -5628,6 +5685,43 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
|
||||||
|
median/cv/source_counts/sources_used (представитель несёт свой source → лот
|
||||||
|
считается один раз; source кросс-поста, не ставший представителем, выпадает
|
||||||
|
из выборки — по нему нет независимой ценовой точки). Флаг
|
||||||
|
estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно
|
||||||
|
для frozen backtest regression gate). Ключ включает price_bucket
|
||||||
|
(_lot_dedup_components include_price=True по умолчанию) — см.
|
||||||
|
_union_find_phys_dedup.
|
||||||
|
"""
|
||||||
|
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
|
||||||
|
return lots
|
||||||
|
return _union_find_phys_dedup(lots, include_price=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Дедуп ОТОБРАЖАЕМЫХ карточек-аналогов по физическому ключу БЕЗ price_bucket.
|
||||||
|
|
||||||
|
Display-consistency fix (2026-07): применяется к финальному пулу, из которого
|
||||||
|
строятся analogs_lots в estimate_quality — ПОСЛЕ MAD-clip/anchor-фильтрации и
|
||||||
|
ПОСЛЕ статистического _dedup_cross_source, но независимо от него. Ловит
|
||||||
|
остаточные кросс-source дубли одного физлота, которые price_bucket-строгий
|
||||||
|
_dedup_cross_source пропустил из-за ценового дрейфа между площадками (частый
|
||||||
|
случай: перевыставили на другой площадке дешевле/дороже) — без такого
|
||||||
|
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
|
||||||
|
|
||||||
|
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
|
||||||
|
статистикой (median/n_analogs), этот — только честностью UI-карточек и
|
||||||
|
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
|
||||||
|
n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены
|
||||||
|
из отдельного (не обязательно совпадающего по итоговой длине) пула.
|
||||||
|
"""
|
||||||
|
if len(lots) < 2:
|
||||||
|
return lots
|
||||||
|
return _union_find_phys_dedup(lots, include_price=False)
|
||||||
|
|
||||||
|
|
||||||
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs
|
||||||
|
и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей
|
||||||
|
одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»).
|
||||||
|
|
||||||
|
Два независимых root cause (см. PR):
|
||||||
|
|
||||||
|
1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len
|
||||||
|
выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip)
|
||||||
|
anchor_comps — клип-отсечённый ценовой выброс оставался видимым в UI, даже
|
||||||
|
когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь =
|
||||||
|
anchor["comps"] (тот же пост-clip пул, что дал n).
|
||||||
|
|
||||||
|
2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения
|
||||||
|
ЦЕНОВОГО бакета (price_bucket) — реальный кросс-пост одного физлота на разных
|
||||||
|
площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/
|
||||||
|
дороже), который может увести пару в разные бакеты → дубль выживает в
|
||||||
|
популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) — тот же
|
||||||
|
физический ключ (building/floor/area), но БЕЗ price_bucket — ловит именно эти
|
||||||
|
остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая
|
||||||
|
n_analogs/median/cv (frozen backtest regression gate не задет — см.
|
||||||
|
tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает).
|
||||||
|
|
||||||
|
Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая
|
||||||
|
ЦЕЛИКОМ является ценовым выбросом, — «Гоголя 18» из QA-смоука): MAD-clip убирает
|
||||||
|
ОБЕ копии из anchor["comps"] → они пропадают из карточек естественным образом
|
||||||
|
(fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов.
|
||||||
|
|
||||||
|
Test B покрывает менее тривиальный случай — дубль-пара НЕ является ценовым
|
||||||
|
выбросом (обычная цена, просто с небольшим дрейфом между площадками) и
|
||||||
|
переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only
|
||||||
|
дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) — тест
|
||||||
|
документирует именно это, а не искусственное равенство.
|
||||||
|
|
||||||
|
Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из
|
||||||
|
test_same_building_anchor.py (anchor_tier="A" → same-building anchor ветка, где
|
||||||
|
живёт MAD-clip).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from app.services import estimator
|
||||||
|
|
||||||
|
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
_h = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_h)
|
||||||
|
|
||||||
|
|
||||||
|
def _comp(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
address: str,
|
||||||
|
ppm2: float,
|
||||||
|
area_m2: float = 146.2,
|
||||||
|
rooms: int = 4,
|
||||||
|
scraped_at: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Минимальный same-building comp-dict (без floor/total_floors → нейтральный
|
||||||
|
floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"source_url": f"https://{source}.example/offer/{address}/{ppm2}",
|
||||||
|
"address": address,
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"rooms": rooms,
|
||||||
|
"price_per_m2": ppm2,
|
||||||
|
"price_rub": ppm2 * area_m2,
|
||||||
|
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||||
|
"photo_urls": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None:
|
||||||
|
"""5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source
|
||||||
|
дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы,
|
||||||
|
как «Гоголя 18» в QA-смоуке) → MAD-clip убирает ОБЕ копии из
|
||||||
|
anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из
|
||||||
|
исходного anchor_comps), выброс не просачивается в UI — количество карточек
|
||||||
|
равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках.
|
||||||
|
"""
|
||||||
|
clean = [
|
||||||
|
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||||
|
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||||
|
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||||
|
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||||
|
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0),
|
||||||
|
]
|
||||||
|
# Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком
|
||||||
|
# ценовой выброс, как реальный прод-кейс «Гоголя 18»).
|
||||||
|
outlier_dup = [
|
||||||
|
_comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0),
|
||||||
|
_comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A")
|
||||||
|
|
||||||
|
# Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту
|
||||||
|
# часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых).
|
||||||
|
assert est.n_analogs == 5
|
||||||
|
|
||||||
|
# Главный инвариант display-consistency: показанные карточки == n_analogs.
|
||||||
|
assert len(est.analogs) == est.n_analogs == 5
|
||||||
|
|
||||||
|
# Ни одна из копий выброса не попала в карточки.
|
||||||
|
shown_addresses = {a.address for a in est.analogs}
|
||||||
|
assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses
|
||||||
|
shown_ppm2 = {a.price_per_m2 for a in est.analogs}
|
||||||
|
assert 900_000 not in shown_ppm2
|
||||||
|
assert 920_000 not in shown_ppm2
|
||||||
|
|
||||||
|
# Нет дублей адресов среди карточек.
|
||||||
|
assert len(shown_addresses) == len(est.analogs)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip,
|
||||||
|
# ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её
|
||||||
|
# пропускает из-за ценового дрейфа, бакет-straddle).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
|
||||||
|
"""4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф
|
||||||
|
2-25к, «Пушкина 9»-паттерн из QA-смоука) — НЕ выброс, переживает MAD-clip как
|
||||||
|
2 отдельные записи (n_analogs их считает по отдельности, статистика не
|
||||||
|
тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ
|
||||||
|
карточку — карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а
|
||||||
|
следствие того, что стат-пайплайн и display-дедуп используют разные ключи
|
||||||
|
по дизайну — см. docstring _dedup_display_lots). Инвариант, который держит
|
||||||
|
этот тест: адрес дубля встречается в карточках РОВНО один раз.
|
||||||
|
"""
|
||||||
|
clean = [
|
||||||
|
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||||
|
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||||
|
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||||
|
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||||
|
]
|
||||||
|
# Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% —
|
||||||
|
# достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.)
|
||||||
|
# развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах
|
||||||
|
# MAD-clip диапазона остальных 4).
|
||||||
|
dup_a = _comp(
|
||||||
|
source="yandex",
|
||||||
|
address="Екатеринбург, ул. Пушкина, 9",
|
||||||
|
ppm2=219_000.0,
|
||||||
|
area_m2=142.0,
|
||||||
|
scraped_at=datetime(2026, 6, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
dup_b = _comp(
|
||||||
|
source="domklik",
|
||||||
|
address="Екатеринбург, ул. Пушкина, 9",
|
||||||
|
ppm2=222_500.0,
|
||||||
|
area_m2=142.0,
|
||||||
|
scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель
|
||||||
|
)
|
||||||
|
|
||||||
|
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
|
||||||
|
# иначе тест проверял бы не то, что заявлено в docstring.
|
||||||
|
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
||||||
|
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
|
||||||
|
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
|
||||||
|
|
||||||
|
est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A")
|
||||||
|
|
||||||
|
# Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не
|
||||||
|
# выброс) — n_analogs = 6.
|
||||||
|
assert est.n_analogs == 6
|
||||||
|
|
||||||
|
# display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs.
|
||||||
|
assert len(est.analogs) == 5
|
||||||
|
assert len(est.analogs) < est.n_analogs
|
||||||
|
|
||||||
|
# Инвариант: адрес дубля встречается РОВНО один раз среди карточек.
|
||||||
|
shown_addresses = [a.address for a in est.analogs]
|
||||||
|
assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1
|
||||||
|
# Представитель — свежайший scraped_at (domklik, 222_500).
|
||||||
|
pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9")
|
||||||
|
assert pushkina_card.price_per_m2 == 222_500
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Unit-level: _dedup_display_lots — прямые проверки поведения helper'а
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _lot(
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
address: str = "ул. Ленина, 5",
|
||||||
|
area: float = 60.0,
|
||||||
|
price: float = 12_000_000.0,
|
||||||
|
floor: int | None = 5,
|
||||||
|
scraped_at: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"address": address,
|
||||||
|
"area_m2": area,
|
||||||
|
"price_rub": price,
|
||||||
|
"price_per_m2": price / area if area else 0,
|
||||||
|
"floor": floor,
|
||||||
|
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||||
|
"photo_urls": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None:
|
||||||
|
# Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат-
|
||||||
|
# дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует
|
||||||
|
# цену вовсе.
|
||||||
|
lots = [
|
||||||
|
_lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
|
||||||
|
_lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)),
|
||||||
|
]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]["source"] == "cian" # свежайший scraped_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_same_source_pair_not_merged() -> None:
|
||||||
|
# Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост
|
||||||
|
# (либо два разных реальных юнита, случайно совпавших по округлённой
|
||||||
|
# площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source
|
||||||
|
# guard).
|
||||||
|
lots = [
|
||||||
|
_lot(source="cian", price=12_000_000.0),
|
||||||
|
_lot(source="cian", price=13_000_000.0),
|
||||||
|
]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_different_addresses_stay_distinct() -> None:
|
||||||
|
lots = [
|
||||||
|
_lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0),
|
||||||
|
_lot(source="cian", address="ул. Мира, 3", price=12_000_000.0),
|
||||||
|
]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert len(out) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_display_lots_noop_under_two_lots() -> None:
|
||||||
|
lots = [_lot(source="avito")]
|
||||||
|
out = estimator._dedup_display_lots(lots)
|
||||||
|
assert out is lots
|
||||||
|
|
@ -595,6 +595,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
|
||||||
payload = _make_payload_qi()
|
payload = _make_payload_qi()
|
||||||
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
|
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
|
||||||
|
|
||||||
|
fake_comps = [
|
||||||
|
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
|
||||||
|
for _ in range(3)
|
||||||
|
]
|
||||||
fake_anchor = {
|
fake_anchor = {
|
||||||
"anchor_ppm2": _BASE_PPM2 * 1.5,
|
"anchor_ppm2": _BASE_PPM2 * 1.5,
|
||||||
"anchor_sold_ppm2": _BASE_PPM2 * 1.4,
|
"anchor_sold_ppm2": _BASE_PPM2 * 1.4,
|
||||||
|
|
@ -606,11 +610,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
|
||||||
"comp_max_ppm2": _BASE_PPM2 * 1.7,
|
"comp_max_ppm2": _BASE_PPM2 * 1.7,
|
||||||
"used_uplift": False,
|
"used_uplift": False,
|
||||||
"haircut": 0.05,
|
"haircut": 0.05,
|
||||||
|
# display-consistency fix: contract now requires "comps" — the final
|
||||||
|
# post-MAD-clip survivors (see _compute_same_building_anchor docstring).
|
||||||
|
"comps": fake_comps,
|
||||||
}
|
}
|
||||||
fake_comps = [
|
|
||||||
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
|
|
||||||
for _ in range(3)
|
|
||||||
]
|
|
||||||
|
|
||||||
analogs_no_cadnum = [
|
analogs_no_cadnum = [
|
||||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue