fix(tradein/estimator): кросс-source дедуп — устойчивый адресный ключ street+house (#2265)
This commit is contained in:
parent
bbe596747d
commit
a08e263c60
2 changed files with 323 additions and 50 deletions
|
|
@ -4537,53 +4537,149 @@ def _apply_corridor_clamp(
|
|||
return new_ppm2, new_price, new_low, new_high, True
|
||||
|
||||
|
||||
# ── #2087 H4: кросс-source физический дедуп аналогов ─────────────────────────
|
||||
# ── #2087 H4 / #2265: кросс-source физический дедуп аналогов ──────────────────
|
||||
# Radius-дедуп (_RN_DUP_WINDOW) ловит только повторы ВНУТРИ одного source
|
||||
# (source, source_id). Один физический лот, кросс-запостенный на avito+cian+
|
||||
# domklik, имеет РАЗНЫЕ source → переживает radius-дедуп → считается несколько
|
||||
# раз → раздувает n_analogs И cv (→ шире коридор), может смещать медиану.
|
||||
# _dedup_cross_source схлопывает такие дубли по физическому ключу ДО агрегации.
|
||||
#
|
||||
# #2265: адресный fallback-ключ ужесточён. Раньше building = lower + схлопнутая
|
||||
# пунктуация ВСЕГО адреса → три source-формата одного дома дают три разных
|
||||
# building-компонента и один физлот считается тремя. Прод-пример (оценка
|
||||
# d5fc3841): один лот 33.6 м²/3 этаж/4.0М на ул. Сыромолотова показан как 3
|
||||
# аналога, т.к. cadnum пуст у всех троих, а адреса форматированы по-разному:
|
||||
# cian «р-н Кировский, мкр. ЖБИ, улица Сыромолотова, …»
|
||||
# domklik «Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова…»
|
||||
# yandex «Екатеринбург, улица Сыромолотова, 11»
|
||||
# domklik building_cadastral_number ВСЕГДА пуст (0/6296), yandex/avito/cian —
|
||||
# 17/29/33% → cadnum-ключ между источниками тоже не совпадает.
|
||||
#
|
||||
# Фикс: из адреса извлекаем нормализованный street_token (тип улицы + город +
|
||||
# район + «Россия» выброшены) и строим композит (street_token, floor, area,
|
||||
# price). house_no СОЗНАТЕЛЬНО не входит в equality-ключ — он присутствует
|
||||
# непоследовательно (yandex несёт номер, cian/domklik часто нет), включение
|
||||
# помешало бы слить именно тот кросс-пост, ради которого дедуп существует.
|
||||
# Разные физлоты остаются разными через floor/area/price (требование «не
|
||||
# over-merge»). Дедуп union-find по ЛЮБОМУ совпавшему ключу (cadnum-композит ИЛИ
|
||||
# street-композит) → закрывает и «у одного cadnum есть, у другого нет, адрес
|
||||
# совпадает». Два РАЗНЫХ кадастра (авторитетный сигнал разных зданий) НЕ
|
||||
# сливаются, даже если street-композит совпал.
|
||||
_DEDUP_PRICE_BUCKET_RUB = 100_000
|
||||
_ADDR_NORM_RE = re.compile(r"[^0-9a-zа-яё]+", flags=re.IGNORECASE)
|
||||
|
||||
# Типы улиц (для отсечения от названия). District-маркеры (мкр/м-н/квартал/
|
||||
# район) СЮДА НЕ входят — иначе «мкр. ЖБИ, улица Сыромолотова» матчилось бы на
|
||||
# «мкр ЖБИ» вместо реальной улицы; они убираются как шум в _ADDR_NOISE_WORDS.
|
||||
_STREET_TYPE_WORDS = (
|
||||
"улица",
|
||||
"ул",
|
||||
"переулок",
|
||||
"пер",
|
||||
"проспект",
|
||||
"просп",
|
||||
"пр-кт",
|
||||
"пр-т",
|
||||
"пр",
|
||||
"бульвар",
|
||||
"б-р",
|
||||
"бул",
|
||||
"проезд",
|
||||
"шоссе",
|
||||
"ш",
|
||||
"набережная",
|
||||
"наб",
|
||||
"площадь",
|
||||
"пл",
|
||||
"аллея",
|
||||
"тупик",
|
||||
"тракт",
|
||||
"линия",
|
||||
)
|
||||
# Длинные типы раньше коротких, чтобы «улица» матчилось раньше «ул».
|
||||
_STREET_TYPE_ALT = "|".join(re.escape(w) for w in sorted(_STREET_TYPE_WORDS, key=len, reverse=True))
|
||||
# «<тип>[.] <название>»: название = 1-3 буквенных слова, до запятой/цифры.
|
||||
_STREET_NAME_RE = re.compile(
|
||||
rf"(?:^|[,\s])(?:{_STREET_TYPE_ALT})\.?\s+([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){{0,2}})",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»).
|
||||
_HOUSE_MARKER_WORDS = frozenset(
|
||||
{"дом", "д", "корпус", "корп", "к", "строение", "стр", "литера", "лит", "владение", "вл"}
|
||||
)
|
||||
# Шум-токены (город/страна/район/микрорайон) на случай, если попали в название.
|
||||
_ADDR_NOISE_WORDS = frozenset(
|
||||
{
|
||||
"россия",
|
||||
"рф",
|
||||
"екатеринбург",
|
||||
"г",
|
||||
"город",
|
||||
"область",
|
||||
"обл",
|
||||
"район",
|
||||
"р-н",
|
||||
"мкр",
|
||||
"м-н",
|
||||
"микрорайон",
|
||||
"квартал",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _norm_addr_for_dedup(addr: str | None) -> str:
|
||||
"""Нормализация адреса для физического ключа: lower + схлопнутая пунктуация.
|
||||
def _extract_street_token(addr: str | None) -> str:
|
||||
"""Нормализованный уличный токен для дедуп-ключа (#2265).
|
||||
|
||||
Кросс-source адреса форматируются по-разному («ул. Ленина, д.5» vs
|
||||
«Ленина 5») — нормализуем к сравнимому виду. Пустой/None → "".
|
||||
Из адреса произвольного source-формата вытаскивает название улицы без типа
|
||||
(улица/пер/пр…), города, района и «Россия». Не распознан / пусто → "".
|
||||
"""
|
||||
if not addr:
|
||||
return ""
|
||||
return _ADDR_NORM_RE.sub(" ", addr.strip().lower()).strip()
|
||||
m = _STREET_NAME_RE.search(addr.strip().lower())
|
||||
if not m:
|
||||
return ""
|
||||
words = [
|
||||
w
|
||||
for w in m.group(1).split()
|
||||
if w and w not in _HOUSE_MARKER_WORDS and w not in _ADDR_NOISE_WORDS
|
||||
]
|
||||
return " ".join(words).strip()
|
||||
|
||||
|
||||
def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None:
|
||||
"""Физический ключ лота (building, floor, area_bucket, price_bucket).
|
||||
def _lot_dedup_components(
|
||||
lot: dict[str, Any],
|
||||
) -> tuple[str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]:
|
||||
"""(cadnum, cadnum-композит, street-композит) лота для union-find дедупа.
|
||||
|
||||
None, если нет данных для ключа (нет площади/цены/building) — такой лот
|
||||
НЕ дедупим (оставляем как уникальный, чтобы не сливать неоднозначное).
|
||||
building = building_cadastral_number (надёжнее) ИЛИ нормализованный address.
|
||||
area_bucket = round(area_m2) (~±0.5 м² — поглощает округление 66.9 vs 67.0).
|
||||
price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB): у настоящего
|
||||
кросс-поста цена идентична (тот же продавец), допуск лишь гасит округление.
|
||||
Каждый композит = (building, floor, area_bucket, price_bucket) или None,
|
||||
если нет соответствующего сигнала. area_bucket = round(area_m2) (~±0.5 м² —
|
||||
гасит 66.9 vs 67.0), price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB)
|
||||
(у настоящего кросс-поста цена идентична, допуск лишь гасит округление).
|
||||
Нет площади/цены → оба None (лот уникален, не дедупится).
|
||||
"""
|
||||
area = lot.get("area_m2")
|
||||
price = lot.get("price_rub")
|
||||
if not area or not price:
|
||||
return None
|
||||
return "", None, None
|
||||
floor = lot.get("floor")
|
||||
area_b = round(float(area))
|
||||
price_b = round(float(price) / _DEDUP_PRICE_BUCKET_RUB)
|
||||
cad = lot.get("building_cadastral_number")
|
||||
building = (
|
||||
str(cad).strip() if cad and str(cad).strip() else _norm_addr_for_dedup(lot.get("address"))
|
||||
)
|
||||
if not building:
|
||||
return None
|
||||
return (
|
||||
building,
|
||||
lot.get("floor"),
|
||||
round(float(area)),
|
||||
round(float(price) / _DEDUP_PRICE_BUCKET_RUB),
|
||||
)
|
||||
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
|
||||
street = _extract_street_token(lot.get("address"))
|
||||
street_key = (street, floor, area_b, price_b) if street else None
|
||||
return cad_s, cad_key, street_key
|
||||
|
||||
|
||||
def _phys_dedup_key(lot: dict[str, Any]) -> tuple[str, Any, int, int] | None:
|
||||
"""Первичный физический ключ (building, floor, area_bucket, price_bucket).
|
||||
|
||||
building = cadnum (надёжнее) ИЛИ street_token (#2265). None, если нет
|
||||
площади/цены или не из чего построить building. Сохраняет 4-кортежную форму
|
||||
(canonical-ключ; union-find в _dedup_cross_source использует оба композита).
|
||||
"""
|
||||
_cad_s, cad_key, street_key = _lot_dedup_components(lot)
|
||||
return cad_key or street_key
|
||||
|
||||
|
||||
def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
|
||||
|
|
@ -4599,36 +4695,88 @@ def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
|
|||
|
||||
|
||||
def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Схлопывает кросс-source дубли одного физического лота (#2087 H4).
|
||||
"""Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265).
|
||||
|
||||
Из группы с общим физическим ключом (_phys_dedup_key) оставляет ОДНОГО
|
||||
представителя (свежайший scraped_at), НЕ суммирует. Порядок вывода — по
|
||||
первому появлению группы (сохраняет relevance-сортировку из _fetch_analogs
|
||||
для top-N UI). Представитель несёт свой source → n_analogs/median/cv/
|
||||
source_counts/sources_used считаются по физическим лотам («лот считается
|
||||
один раз»; source кросс-поста, не ставший представителем, выпадает из
|
||||
выборки — по нему нет независимой ценовой точки). Флаг OFF → no-op
|
||||
(байт-идентичный проход). Лоты без ключа (нет площади/цены/building) не
|
||||
дедупятся — остаются уникальными.
|
||||
Union-find по физическим композитам: два лота — дубли, если совпал ЛЮБОЙ
|
||||
ключ — cadnum-композит ИЛИ street-композит (_lot_dedup_components). Из группы
|
||||
оставляет ОДНОГО представителя (свежайший scraped_at), НЕ суммирует. Порядок
|
||||
вывода — по первому появлению группы (сохраняет relevance-сортировку из
|
||||
_fetch_analogs для top-N UI). Представитель несёт свой source → n_analogs/
|
||||
median/cv/source_counts/sources_used считаются по физическим лотам («лот
|
||||
считается один раз»; source кросс-поста, не ставший представителем, выпадает
|
||||
из выборки — по нему нет независимой ценовой точки).
|
||||
|
||||
#2265: cadnum-композит обрабатывается первым (авторитетный), затем street-
|
||||
композит — но union по street НЕ сливает два РАЗНЫХ непустых кадастра
|
||||
(разные здания). Флаг OFF → no-op (байт-идентичный проход). Лоты без
|
||||
площади/цены не дедупятся — остаются уникальными.
|
||||
"""
|
||||
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
|
||||
return lots
|
||||
|
||||
groups: dict[Any, list[dict[str, Any]]] = {}
|
||||
order: list[Any] = []
|
||||
for lot in lots:
|
||||
key: Any = _phys_dedup_key(lot)
|
||||
if key is None:
|
||||
key = ("__nodup__", id(lot)) # неоднозначный → уникальный псевдоключ
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
order.append(key)
|
||||
groups[key].append(lot)
|
||||
n = len(lots)
|
||||
parent = list(range(n))
|
||||
comps = [_lot_dedup_components(lot) for lot in lots]
|
||||
# cadnum группы (root → кадастр «» если группа без/со смешанным кадастром).
|
||||
root_cad = [c[0] for c in comps]
|
||||
|
||||
def find(x: int) -> int:
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]] # path-halving
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
def union(a: int, b: int) -> None:
|
||||
ra, rb = find(a), find(b)
|
||||
if ra == rb:
|
||||
return
|
||||
ca, cb = root_cad[ra], root_cad[rb]
|
||||
if ca and cb and ca != cb:
|
||||
return # разные кадастры → разные здания, не сливаем
|
||||
merged = ca or cb
|
||||
# меньший индекс — корень: сохраняет first-seen порядок групп.
|
||||
if ra < rb:
|
||||
parent[rb] = ra
|
||||
root_cad[ra] = merged
|
||||
else:
|
||||
parent[ra] = rb
|
||||
root_cad[rb] = merged
|
||||
|
||||
# 1) cadnum-композит — авторитетный (тот же кадастр = то же здание).
|
||||
cad_owner: dict[tuple[str, Any, int, int], int] = {}
|
||||
for i, (_cad_s, cad_key, _street_key) in enumerate(comps):
|
||||
if cad_key is None:
|
||||
continue
|
||||
prev = cad_owner.get(cad_key)
|
||||
if prev is None:
|
||||
cad_owner[cad_key] = i
|
||||
else:
|
||||
union(prev, i)
|
||||
|
||||
# 2) street-композит — union при отсутствии cadnum-конфликта.
|
||||
street_owner: dict[tuple[str, Any, int, int], int] = {}
|
||||
for i, (_cad_s, _cad_key, street_key) in enumerate(comps):
|
||||
if street_key is None:
|
||||
continue
|
||||
prev = street_owner.get(street_key)
|
||||
if prev is None:
|
||||
street_owner[street_key] = i
|
||||
else:
|
||||
union(prev, i)
|
||||
|
||||
groups: dict[int, list[dict[str, Any]]] = {}
|
||||
order: list[int] = []
|
||||
for i, lot in enumerate(lots):
|
||||
root = find(i)
|
||||
if root not in groups:
|
||||
groups[root] = []
|
||||
order.append(root)
|
||||
groups[root].append(lot)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
collapsed = 0
|
||||
for key in order:
|
||||
group = groups[key]
|
||||
for root in order:
|
||||
group = groups[root]
|
||||
if len(group) == 1:
|
||||
result.append(group[0])
|
||||
else:
|
||||
|
|
@ -4636,7 +4784,7 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
collapsed += len(group) - 1
|
||||
if collapsed:
|
||||
logger.info(
|
||||
"cross-source dedup #2087 H4: %d lots → %d unique (-%d dups)",
|
||||
"cross-source dedup #2087/#2265: %d lots → %d unique (-%d dups)",
|
||||
len(lots),
|
||||
len(result),
|
||||
collapsed,
|
||||
|
|
|
|||
|
|
@ -179,3 +179,128 @@ def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) ->
|
|||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# #2265 — устойчивый адресный street-токен (кросс-source форматы)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_extract_street_token_across_source_formats() -> None:
|
||||
# Три РЕАЛЬНЫХ прод-формата одного дома (ул. Сыромолотова) → один токен.
|
||||
cian = estimator._extract_street_token("р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11")
|
||||
domklik = estimator._extract_street_token("Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова")
|
||||
yandex = estimator._extract_street_token("Екатеринбург, улица Сыромолотова, 11")
|
||||
assert cian == domklik == yandex == "сыромолотова"
|
||||
|
||||
|
||||
def test_extract_street_token_strips_house_and_noise() -> None:
|
||||
assert estimator._extract_street_token("Екатеринбург, ул. Ленина, д. 5") == "ленина"
|
||||
assert estimator._extract_street_token("проспект Космонавтов 15") == "космонавтов"
|
||||
assert estimator._extract_street_token("пер. Банковский, 8а") == "банковский"
|
||||
# Нет типа улицы → распознать нельзя → пусто (безопасный fallback, лот уникален).
|
||||
assert estimator._extract_street_token("Екатеринбург, ЖК Дружба") == ""
|
||||
assert estimator._extract_street_token("") == ""
|
||||
assert estimator._extract_street_token(None) == ""
|
||||
|
||||
|
||||
def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан
|
||||
# как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
address="р-н Кировский, мкр. ЖБИ, улица Сыромолотова, 11",
|
||||
area=33.6,
|
||||
price=4_000_000,
|
||||
floor=3,
|
||||
cad=None,
|
||||
),
|
||||
_lot(
|
||||
source="domklik",
|
||||
address="Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова",
|
||||
area=33.6,
|
||||
price=4_000_000,
|
||||
floor=3,
|
||||
cad=None,
|
||||
),
|
||||
_lot(
|
||||
source="yandex",
|
||||
address="Екатеринбург, улица Сыромолотова, 11",
|
||||
area=33.6,
|
||||
price=4_000_000,
|
||||
floor=3,
|
||||
cad=None,
|
||||
),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 1 # один физлот, не три
|
||||
|
||||
|
||||
def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# У одного cadnum есть, у другого нет, адрес совпадает → сливаются по
|
||||
# street-композиту (#2265: union по любому совпавшему ключу).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
address="улица Сыромолотова, 11",
|
||||
area=33.6,
|
||||
price=4_000_000,
|
||||
floor=3,
|
||||
cad="66:41:0601011:1234",
|
||||
),
|
||||
_lot(
|
||||
source="domklik",
|
||||
address="ЖБИ м-н, улица Сыромолотова",
|
||||
area=33.6,
|
||||
price=4_000_000,
|
||||
floor=3,
|
||||
cad=None,
|
||||
),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
|
||||
# кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
address="улица Сыромолотова, 11",
|
||||
area=33.6,
|
||||
price=4_000_000,
|
||||
floor=3,
|
||||
cad="66:41:0601011:1234",
|
||||
),
|
||||
_lot(
|
||||
source="avito",
|
||||
address="улица Сыромолотова, 13",
|
||||
area=33.6,
|
||||
price=4_000_000,
|
||||
floor=3,
|
||||
cad="66:41:0601011:9999",
|
||||
),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_same_street_different_metrics_stay_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
|
||||
# разными (защита от over-merge при отсутствии cadnum).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
||||
_lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 2
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue