fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки #2624

Merged
lekss361 merged 1 commit from fix/tradein-implausible-build-year into main 2026-08-02 11:22:23 +00:00
Owner

Проблема

house_metadata (OSM Overpass + cad_buildings/кадастр, приоритетный источник) и TradeInEstimateInput.year_built (payload, схема допускает ge=1800) отдают year_built без валидации. На проде встретился 1829 — попал в хедонический year+area фактор (_price_from_inputs, #2002), тот экстраполировал regression fit далеко за пределы обучающей выборки и упёрся в нижний кламп estimate_hedonic_factor_min=0.75, порезав выкупную цену на фиксированные −25% без физического смысла.

Фикс

Единая точка входа _sanitize_build_year() (app/services/estimator.py) вызывается в estimate_quality() сразу после того как target_year определён (из payload либо house_meta), ДО cohort-фильтра / _fetch_analogs house-match scoring / хедонического фактора. Год вне [1917, текущий_год+3] логируется на WARNING (house_id + адрес) и заменяется на None — трактуется как «неизвестен», а НЕ клампится к границе. None уже был честным нейтральным входом для хедоники (year-term = 0, эквивалент year=2000) — теперь мусорный год ведёт себя так же, вместо уверенной −25% поправки.

Диапазон: нижняя граница 1917 — массовая многоквартирная застройка в РФ/СССР началась после революции, что раньше почти гарантированно ошибка источника (OSM/кадастр иногда путают год постройки дома с годом основания места на тех же координатах). Верхняя граница текущий+3 — допуск на цели trade-in со строящимся домом (год сдачи по ДДУ известен заранее, но не на десятилетия вперёд).

Механизм хедоники и её коэффициенты НЕ тронуты — только валидация входа.

Вывод по диапазону валидности хедоники (п.3 задачи)

COHORTS в estimator.py (cohort hard-filter для Tier 0 аналогов) вообще не определяет когорту раньше 1955 (khrushchev: 1955-1969 — самая ранняя). На проде (read-only, deals — таблица-источник ДКП-сделок в tradein БД): из 94834 сделок с известным годом лишь 1611 (1.7%) относятся к домам < 1950, а < 1917 — всего 138 (0.15%), причём эти 138 включают заведомый мусор (min year в таблице = 1800). Это означает: даже если исходный held-out-фит на n=2366 сделок (см. комментарий #2002 в config.py) брал репрезентативную подвыборку этой популяции, эффективная обучающая поддержка для домов старше ~1955 практически отсутствует. Применение фактора к 1829 (или любому году < ~1950) — не интерполяция, а неконтролируемая экстраполяция экспоненты. estimate_hedonic_factor_min=0.75 в этой зоне не защита, а маскировка выхода за диапазон под видом уверенной −25% поправки — этот PR меняет это на честное «не знаем» (None) для однозначно неправдоподобных лет; годы 1917-1954 формально проходят guard (не мусор), но остаются известной зоной тонкой обучающей поддержки — это отдельный calibration follow-up, не трогается в этом PR (не входит в scope: «не трогать саму хедонику»).

Замеры на проде (read-only, НЕ писал)

house_metadata: 49 строк, 10 с годом, 1 неправдоподобная (year=1829, единственная — совпадает с описанием бага).

listings (per-analog год, НЕ участвует в этом фиксе — отдельный вход, target_year не трогает): 88953 строк, 55325 с годом, 138 неправдоподобных (year < 1917: 1010/1072/1695/1866 по 1 разу; year > 2029: 2030×108, 2031×26 — вероятно легитимные ожидаемые сроки сдачи строящихся домов, порог +3 года не покрывает; listings.year_built не фидит в target_year/хедонику, только в per-analog house-match scoring — вне scope этого PR).

trade_in_estimates (сохранённые результаты оценок — реальные прошлые прогоны с этим багом): 608 строк с годом, 6 неправдоподобных (min=1800, max=2026) — соответствует «два прогона аудита + два admin-теста» из описания задачи плюс ещё несколько.

Воспроизведённый кейс: цена до/после (год=1829, area=50м², ppm2=100000₽, ratio=0.85)

до фикса (year=1829 прямо в хедонику) после фикса (year→None)
хедонический фактор 0.7500 (raw 0.348, клампится в пол) 0.9876 (около нейтрали, ~year=2000)
expected_sold_price 3 187 500 ₽ 4 197 217 ₽
Δ vs asking×ratio (4 250 000 ₽) −25.00% −1.24% (только area-term, ожидаемо)

Год 1960 (валидная хрущёвка, cohort khrushchev) — фактор 0.7738 (НЕ на клампе), поведение байт-в-байт как до фикса — guard не трогает легитимные старые дома.

Тесты

tests/test_estimator_implausible_year.py — 9 тестов, каждый падает на pre-fix коде. Falsification (git stash реализации, тесты оставлены):

# ДО фикса (estimator.py застэшен, только новый тест-файл):
9 failed in 1.28s   — AttributeError: module 'app.services.estimator' has no attribute '_sanitize_build_year' / 'MIN_PLAUSIBLE_BUILD_YEAR'

# git stash pop → ПОСЛЕ фикса:
9 passed in 0.90s

Сценарии: 1829→None (не режет цену, test_price_not_cut_after_sanitizing_1829), 1960→1960 (не меняется, test_year_1960_hedonic_unaffected_by_guard), текущий+10→None (test_future_year_beyond_lead_dropped), текущий+3 (граница)→сохраняется (test_near_future_year_within_lead_kept), None→None (test_none_year_unchanged), плюс граничные 1917/1916 (test_boundary_year_*) и WARNING-лог с house_id (test_implausible_low_year_dropped_to_none).

Полный pytest -q --deselect "tests/test_search_api.py::test_search_cache_hit" (известный pre-existing fail, не трогал): 3090 passed, 9 skipped, 1 deselected — ноль новых фейлов.

Test plan

  • ruff check — чисто
  • Falsification: 9/9 failed pre-fix → 9/9 passed post-fix
  • Полный pytest suite — 3090 passed / 0 unexpected failures
  • Прод-замеры (read-only SQL) в PR body выше

Refs: не привязан к конкретному issue — найдено сквозным прогоном оценок по проду (Mera-audit 2026-08-02).

## Проблема `house_metadata` (OSM Overpass + `cad_buildings`/кадастр, приоритетный источник) и `TradeInEstimateInput.year_built` (payload, схема допускает `ge=1800`) отдают `year_built` без валидации. На проде встретился **1829** — попал в хедонический year+area фактор (`_price_from_inputs`, #2002), тот экстраполировал regression fit далеко за пределы обучающей выборки и упёрся в нижний кламп `estimate_hedonic_factor_min=0.75`, порезав выкупную цену на фиксированные −25% без физического смысла. ## Фикс Единая точка входа `_sanitize_build_year()` (`app/services/estimator.py`) вызывается в `estimate_quality()` сразу после того как `target_year` определён (из payload либо `house_meta`), ДО cohort-фильтра / `_fetch_analogs` house-match scoring / хедонического фактора. Год вне `[1917, текущий_год+3]` логируется на WARNING (house_id + адрес) и заменяется на `None` — трактуется как «неизвестен», а НЕ клампится к границе. `None` уже был честным нейтральным входом для хедоники (year-term = 0, эквивалент year=2000) — теперь мусорный год ведёт себя так же, вместо уверенной −25% поправки. **Диапазон:** нижняя граница 1917 — массовая многоквартирная застройка в РФ/СССР началась после революции, что раньше почти гарантированно ошибка источника (OSM/кадастр иногда путают год постройки дома с годом основания места на тех же координатах). Верхняя граница текущий+3 — допуск на цели trade-in со строящимся домом (год сдачи по ДДУ известен заранее, но не на десятилетия вперёд). Механизм хедоники и её коэффициенты НЕ тронуты — только валидация входа. ## Вывод по диапазону валидности хедоники (п.3 задачи) `COHORTS` в `estimator.py` (cohort hard-filter для Tier 0 аналогов) вообще не определяет когорту раньше 1955 (`khrushchev: 1955-1969` — самая ранняя). На проде (read-only, `deals` — таблица-источник ДКП-сделок в `tradein` БД): из 94834 сделок с известным годом лишь 1611 (1.7%) относятся к домам < 1950, а < 1917 — всего 138 (0.15%), причём эти 138 включают заведомый мусор (min year в таблице = 1800). Это означает: даже если исходный held-out-фит на n=2366 сделок (см. комментарий `#2002` в `config.py`) брал репрезентативную подвыборку этой популяции, эффективная обучающая поддержка для домов старше ~1955 практически отсутствует. Применение фактора к 1829 (или любому году < ~1950) — не интерполяция, а неконтролируемая экстраполяция экспоненты. **`estimate_hedonic_factor_min=0.75` в этой зоне не защита, а маскировка выхода за диапазон под видом уверенной −25% поправки** — этот PR меняет это на честное «не знаем» (None) для однозначно неправдоподобных лет; годы 1917-1954 формально проходят guard (не мусор), но остаются известной зоной тонкой обучающей поддержки — это отдельный calibration follow-up, не трогается в этом PR (не входит в scope: «не трогать саму хедонику»). ## Замеры на проде (read-only, НЕ писал) `house_metadata`: 49 строк, 10 с годом, **1 неправдоподобная** (year=1829, единственная — совпадает с описанием бага). `listings` (per-analog год, НЕ участвует в этом фиксе — отдельный вход, `target_year` не трогает): 88953 строк, 55325 с годом, 138 неправдоподобных (year < 1917: 1010/1072/1695/1866 по 1 разу; year > 2029: 2030×108, 2031×26 — вероятно легитимные ожидаемые сроки сдачи строящихся домов, порог +3 года не покрывает; `listings.year_built` не фидит в `target_year`/хедонику, только в per-analog house-match scoring — вне scope этого PR). `trade_in_estimates` (сохранённые результаты оценок — реальные прошлые прогоны с этим багом): 608 строк с годом, **6 неправдоподобных** (min=1800, max=2026) — соответствует «два прогона аудита + два admin-теста» из описания задачи плюс ещё несколько. ## Воспроизведённый кейс: цена до/после (год=1829, area=50м², ppm2=100000₽, ratio=0.85) | | до фикса (year=1829 прямо в хедонику) | после фикса (year→None) | |---|---|---| | хедонический фактор | 0.7500 (raw 0.348, клампится в пол) | 0.9876 (около нейтрали, ~year=2000) | | expected_sold_price | 3 187 500 ₽ | 4 197 217 ₽ | | Δ vs asking×ratio (4 250 000 ₽) | **−25.00%** | −1.24% (только area-term, ожидаемо) | Год 1960 (валидная хрущёвка, cohort `khrushchev`) — фактор 0.7738 (НЕ на клампе), поведение байт-в-байт как до фикса — guard не трогает легитимные старые дома. ## Тесты `tests/test_estimator_implausible_year.py` — 9 тестов, каждый падает на pre-fix коде. Falsification (`git stash` реализации, тесты оставлены): ``` # ДО фикса (estimator.py застэшен, только новый тест-файл): 9 failed in 1.28s — AttributeError: module 'app.services.estimator' has no attribute '_sanitize_build_year' / 'MIN_PLAUSIBLE_BUILD_YEAR' # git stash pop → ПОСЛЕ фикса: 9 passed in 0.90s ``` Сценарии: 1829→None (не режет цену, `test_price_not_cut_after_sanitizing_1829`), 1960→1960 (не меняется, `test_year_1960_hedonic_unaffected_by_guard`), текущий+10→None (`test_future_year_beyond_lead_dropped`), текущий+3 (граница)→сохраняется (`test_near_future_year_within_lead_kept`), None→None (`test_none_year_unchanged`), плюс граничные 1917/1916 (`test_boundary_year_*`) и WARNING-лог с house_id (`test_implausible_low_year_dropped_to_none`). Полный `pytest -q --deselect "tests/test_search_api.py::test_search_cache_hit"` (известный pre-existing fail, не трогал): **3090 passed, 9 skipped, 1 deselected** — ноль новых фейлов. ## Test plan - [x] `ruff check` — чисто - [x] Falsification: 9/9 failed pre-fix → 9/9 passed post-fix - [x] Полный pytest suite — 3090 passed / 0 unexpected failures - [x] Прод-замеры (read-only SQL) в PR body выше Refs: не привязан к конкретному issue — найдено сквозным прогоном оценок по проду (Mera-audit 2026-08-02).
lekss361 added 1 commit 2026-08-02 11:06:31 +00:00
fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m40s
86afbf9e6f
house_metadata (OSM/кадастр) отдаёт year_built без валидации; на проде
встретился 1829, который клампился в нижний пол хедонического фактора
(estimate_hedonic_factor_min=0.75) и резал выкупную цену на фикс. -25%
без физического смысла — модель никогда не видела осмысленного объёма
домов старше 1955 (см. COHORTS). Год вне [1917, текущий+3] теперь
трактуется как отсутствующий (None, нейтральный year-term) вместо
клампа, с WARNING-логом по house_id/адресу.

Единая точка входа _sanitize_build_year() в estimate_quality() перед
cohort-фильтром, house-match scoring и хедоническим фактором — покрывает
оба источника года (payload.year_built и house_meta.year_built).
lekss361 merged commit 4b3d8b4cca into main 2026-08-02 11:22:23 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2624
No description provided.