| """Минимальный пример применения модели hidden_pro вне ноутбука. |
| |
| Модель предсказывает согласие с поведенческой слабой разметкой |
| ("похоже ли объявление на объявление профессионального продавца по |
| поведенческим правилам"), а не факт профессиональной деятельности — |
| интерпретировать оценку следует именно в этом смысле (раздел 6.3; |
| раздел не входит в публикуемую часть). |
| |
| Рабочий порог: thr_p95 = 0.6020, соответствует precision 0.95 на |
| контрольной части (раздел 6, 04_06_threshold). Объявления с оценкой выше |
| порога стоит трактовать как "похожие на профессиональные" с высокой |
| точностью; порог не откалиброван под конкретный recall и подбирался |
| только под precision 0.95. |
| |
| Признаки остаточного фотоканала (img_count, unique_hashes, |
| valid_hash_count, missing_hash_count, cover_hash_missing) на результат |
| практически не влияют: их полное исключение из модели меняет AUC на |
| -0.0003 при разбросе между фолдами 0.0084 (раздел 7, ablation, |
| 04_07_ablation) — эффект неотличим от нуля на уровне всей выборки. Они |
| оставлены в модели, но переживать из-за их неточных значений не стоит; |
| правильнее подавать их как пропуск (см. ниже), чем подменять |
| произвольным числом. Для только что опубликованного объявления (как |
| example_listing ниже) фотоконвейер ещё не отработал, поэтому эти |
| признаки намеренно оставлены пропуском — как в реальном сценарии |
| применения модели сразу после публикации. |
| |
| Требуются файлы (все — рядом с этим скриптом): |
| - hidden_pro_catboost.cbm — модель CatBoost; |
| - hidden_pro_model_meta.json — метаданные (порог, параметры, метрики); |
| - segment_medians.json — медианы цены по сегментам «сделка × |
| тип недвижимости × город»; |
| - vocabularies.json — допустимые категории, порядок столбцов, |
| соответствие город→регион, метка пропуска |
| категориальных признаков, значения по |
| умолчанию для непубликуемых на момент |
| размещения признаков. |
| """ |
|
|
| import json |
| import re |
| from pathlib import Path |
|
|
| import numpy as np |
| from catboost import CatBoostClassifier, Pool |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| EXPECTED_SCORE = 0.844068 |
|
|
| |
| |
| |
| |
| |
| REFERENCE_FEATURES = { |
| "property_type": "apartment", |
| "deal": "sale", |
| "currency": "USD", |
| "is_negotiable": 1.0, |
| "city": "Бишкек", |
| "region": "chui", |
| "is_hide_house_number": 0.0, |
| "hide_phone": 0.0, |
| "hide_chat": None, |
| "is_vip": 1.0, |
| "is_ppv": 0.0, |
| "image_count": 8.0, |
| "district": "Асанбай мкр", |
| "area_m2": 68.0, |
| "condition": "Евроремонт", |
| "deal_terms": "Возможен обмен, Наличный расчет", |
| "params_count": 15.0, |
| "img_count": 8.0, |
| "valid_hash_count": None, |
| "unique_hashes": None, |
| "cover_hash_missing": "None", |
| "missing_hash_count": None, |
| "has_daily_views": 1.0, |
| "rooms_num": 3.0, |
| "price_to_seg_median": 2.1511627906976747, |
| "price_is_stub": 0.0, |
| "price_seg_small": 0.0, |
| "price_anomaly_low": 0.0, |
| "price_anomaly_high": 0.0, |
| "lifetime_bucket": "<1ч", |
| } |
|
|
| DISCLAIMER = ( |
| "объявление похоже на объявление профессионального продавца " |
| "по согласию со слабой разметкой — это не установление факта " |
| "профессиональной деятельности (раздел 6.3)." |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| example_listing = { |
| "price": 185000.0, |
| "currency": "USD", |
| "city": "Бишкек", |
| "property_type": "apartment", |
| "deal": "sale", |
| "area_m2": 68, |
| "rooms": "3 комнаты", |
| "image_count": 8, |
| "img_count": 8, |
| "district": "Асанбай мкр", |
| "condition": "Евроремонт", |
| "deal_terms": "Возможен обмен, Наличный расчет", |
| "is_negotiable": 1, |
| "is_vip": 1, |
| "is_ppv": 0, |
| "has_daily_views": 1, |
| "hide_phone": 0, |
| "hide_chat": None, |
| "is_hide_house_number": 0, |
| "params_count": 15, |
| } |
|
|
|
|
| def load_artifacts(models_dir=None): |
| """Загружает модель и справочники один раз. models_dir=None — папка |
| рядом с этим скриптом (прежнее поведение); иначе — любая директория, |
| куда, например, Space скачал файлы модели с Hub.""" |
| models_dir = Path(models_dir) if models_dir is not None else Path(__file__).resolve().parent |
|
|
| model = CatBoostClassifier() |
| model.load_model(str(models_dir / "hidden_pro_catboost.cbm")) |
|
|
| meta = json.loads((models_dir / "hidden_pro_model_meta.json").read_text(encoding="utf-8")) |
| segment_medians = json.loads((models_dir / "segment_medians.json").read_text(encoding="utf-8")) |
| vocab = json.loads((models_dir / "vocabularies.json").read_text(encoding="utf-8")) |
| return model, meta, segment_medians, vocab |
|
|
|
|
| def price_to_kgs(price, currency, usd_to_kgs_rate): |
| """Приводит цену объявления к сомам — так же, как в 04_01_price.""" |
| if price is None: |
| return None |
| if currency == "USD": |
| return price * usd_to_kgs_rate |
| return price |
|
|
|
|
| def to_num(v): |
| """Числовое поле, хранящееся в текстовом виде (area_m2, rooms) — |
| та же логика, что в 04_01_typing: первое число в строке.""" |
| if v is None: |
| return None |
| m = re.search(r"[\d.,]+", str(v).replace(" ", "")) |
| return float(m.group().replace(",", ".")) if m else None |
|
|
|
|
| def compute_price_features(price_kgs, deal, property_type, city, segment_medians): |
| """Воспроизводит признаки группы «цена» (04_01_price) по справочнику |
| segment_medians.json вместо обучающей выборки. Возвращает промежуточные |
| величины расчёта строками лога (второй элемент кортежа) — это часть |
| сквозной проверки (04_07_repro_check), а не только конечный результат.""" |
| params = segment_medians["параметры"] |
| stub_threshold = params["stub_threshold_kgs"] |
| min_segment = params["min_segment_size"] |
| anomaly_low = params["anomaly_low"] |
| anomaly_high = params["anomaly_high"] |
|
|
| log = [] |
|
|
| is_stub = int(price_kgs is not None and price_kgs <= stub_threshold) |
| log.append(f"Расчёт цены: цена в сомах = {price_kgs}") |
|
|
| segment = None |
| for rec in segment_medians["сегменты"]: |
| if (rec["deal"] == deal and rec["property_type"] == property_type |
| and rec["city"] == city): |
| segment = rec |
| break |
|
|
| ratio = None |
| seg_small = 0 |
| if price_kgs is not None and not is_stub: |
| if segment is None: |
| |
| |
| |
| |
| |
| log.append(f" ! сегмент {deal}/{property_type}/{city} не найден " |
| f"в segment_medians.json — price_to_seg_median " |
| f"останется пропуском (price_seg_small=1)") |
| seg_small = 1 |
| elif segment["n"] < min_segment: |
| log.append(f" сегмент {deal}/{property_type}/{city} найден, но " |
| f"n={segment['n']} < min_segment={min_segment} — " |
| f"price_to_seg_median останется пропуском " |
| f"(price_seg_small=1)") |
| seg_small = 1 |
| else: |
| log.append(f" сегмент {deal}/{property_type}/{city}: " |
| f"медиана={segment['median']}, n={segment['n']}") |
| ratio = price_kgs / segment["median"] |
| log.append(f" price_to_seg_median = {price_kgs} / " |
| f"{segment['median']} = {ratio}") |
| elif is_stub: |
| log.append(f" цена <= {stub_threshold} сом — заглушка " |
| f"(price_is_stub=1), price_to_seg_median не считается") |
|
|
| anomaly_low_flag = None if ratio is None else int(ratio < anomaly_low) |
| anomaly_high_flag = None if ratio is None else int(ratio > anomaly_high) |
|
|
| return { |
| "price_to_seg_median": ratio, |
| "price_is_stub": is_stub, |
| "price_seg_small": seg_small, |
| "price_anomaly_low": anomaly_low_flag, |
| "price_anomaly_high": anomaly_high_flag, |
| }, log |
|
|
|
|
| def build_feature_row(raw, meta, segment_medians, vocab): |
| """Собирает один словарь признаков в порядке feature_order. |
| |
| Принимает ИСХОДНЫЕ поля объявления (price, currency, city, area_m2, |
| rooms как текст и т.п.), а не готовые признаки — производные |
| признаки (price_to_seg_median и связанные, region, area_m2/rooms_num |
| из текста) считаются здесь же, чтобы проверка охватывала всю |
| цепочку, а не только применение уже готовой матрицы к модели. |
| |
| Возвращает (ordered_row, feature_order, cat_features, log, warnings): |
| log — диагностические сообщения расчёта цены, warnings — предупреждения |
| о признаках, которые потребитель обязан был передать сам.""" |
| missing_label = vocab["categorical_missing_label"] |
|
|
| usd_to_kgs_rate = segment_medians["параметры"]["usd_to_kgs_rate"] |
| price_kgs = price_to_kgs(raw.get("price"), raw.get("currency"), usd_to_kgs_rate) |
|
|
| |
| |
| |
| |
| |
| |
| region = vocab["city_to_region"].get(raw["city"], missing_label) |
|
|
| price_feats, log = compute_price_features( |
| price_kgs, raw["deal"], raw["property_type"], raw["city"], segment_medians) |
|
|
| |
| |
| |
| |
| area_m2 = to_num(raw.get("area_m2")) |
| AREA_LO, AREA_HI = 5, 2000 |
| if area_m2 is not None and not (AREA_LO <= area_m2 <= AREA_HI): |
| area_m2 = None |
| rooms_num = to_num(raw.get("rooms")) |
|
|
| row = dict(raw) |
| row["region"] = region |
| row["area_m2"] = area_m2 |
| row["rooms_num"] = rooms_num |
| row.update(price_feats) |
|
|
| warnings = [] |
| |
| |
| |
| |
| |
| |
| if raw.get("img_count") is None: |
| warnings.append("! img_count не передан: реального значения по умолчанию " |
| "нет (в обучении ноль означал фактическое отсутствие " |
| "фотографий, а не неизвестность) — признак останется " |
| "пропуском.") |
|
|
| |
| |
| |
| |
| |
| |
| for feat_name, spec in vocab["defaults"].items(): |
| row.setdefault(feat_name, spec["значение"]) |
|
|
| feature_order = vocab["feature_order"] |
| cat_features_names = set(vocab["cat_features"]) |
| ordered_row = [] |
| for col in feature_order: |
| val = row.get(col) |
| if col in cat_features_names: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| val = missing_label if val is None else str(val) |
| else: |
| val = np.nan if val is None else float(val) |
| ordered_row.append(val) |
|
|
| return ordered_row, feature_order, list(cat_features_names & set(feature_order)), log, warnings |
|
|
|
|
| def validate_categories(ordered_row, feature_order, cat_features, categories): |
| """Проверяет, что значения категориальных признаков собранной строки |
| входят в словарь, на котором обучена модель (vocabularies["categories"]). |
| |
| Несовпадение — не ошибка сборки: CatBoost не поднимет исключение, а |
| молча отнесёт неизвестное значение к отдельной ветви. Но это значит, |
| что предсказание для такого признака не подкреплено обучающими |
| примерами, и об этом стоит знать явно, а не узнавать постфактум.""" |
| problems = [] |
| for col, val in zip(feature_order, ordered_row): |
| if col not in cat_features: |
| continue |
| allowed = categories.get(col) |
| if allowed is not None and val not in allowed: |
| problems.append((col, val, allowed[:5])) |
| return problems |
|
|
|
|
| def _is_missing(v): |
| return v is None or (isinstance(v, float) and np.isnan(v)) |
|
|
|
|
| def compare_with_reference(ordered_row, feature_order, reference): |
| """Построчно сравнивает собранные признаки с эталонными |
| (REFERENCE_FEATURES). Печатает только расхождения — совпавшие |
| признаки в вывод не идут, иначе он тонет в шуме на фоне 30 строк. |
| |
| Числовые значения сравниваются с допуском 1e-9, строковые — точным |
| равенством; None и NaN считаются совпадающими между собой.""" |
| got_by_col = dict(zip(feature_order, ordered_row)) |
| mismatches = [] |
| for col, expected in reference.items(): |
| got = got_by_col.get(col) |
|
|
| if _is_missing(expected) and _is_missing(got): |
| continue |
|
|
| if isinstance(expected, str) or isinstance(got, str): |
| match = (got == expected) |
| else: |
| try: |
| match = abs(float(got) - float(expected)) < 1e-9 |
| except (TypeError, ValueError): |
| match = got == expected |
|
|
| if not match: |
| mismatches.append((col, expected, got)) |
|
|
| if mismatches: |
| print("\nПофакторное сравнение с эталоном (только расхождения):") |
| print(f" {'признак':<22}{'эталон':<22}{'получено':<22}") |
| for col, expected, got in mismatches: |
| print(f" {col:<22}{str(expected):<22}{str(got):<22} <-- расхождение") |
| else: |
| print("\nПофакторное сравнение с эталоном: расхождений нет " |
| "(при этом итоговая оценка не совпала — проверьте округление " |
| "или порядок cat_features_idx).") |
| return mismatches |
|
|
|
|
| def score_listing(raw, artifacts): |
| """Считает оценку модели для одного объявления raw (исходные поля, |
| не готовые признаки). Ничего не печатает — результат предназначен |
| для print_report() или для отображения в веб-форме. |
| |
| artifacts — кортеж (model, meta, segment_medians, vocab) из |
| load_artifacts(), передаётся снаружи, чтобы не перечитывать файлы |
| с диска на каждый вызов.""" |
| model, meta, segment_medians, vocab = artifacts |
|
|
| ordered_row, feature_order, cat_features, log, warnings = build_feature_row( |
| raw, meta, segment_medians, vocab) |
| problems = validate_categories(ordered_row, feature_order, set(vocab["cat_features"]), |
| vocab["categories"]) |
| cat_features_idx = [feature_order.index(c) for c in vocab["cat_features"]] |
|
|
| pool = Pool([ordered_row], cat_features=cat_features_idx) |
| score = float(model.predict_proba(pool)[0, 1]) |
|
|
| thr_info = meta["порог_thr_p95"] |
| thr = thr_info["значение"] |
| above_threshold = score > thr |
|
|
| return { |
| "score": score, |
| "threshold": thr, |
| "threshold_precision": thr_info["precision"], |
| "threshold_recall": thr_info["recall"], |
| "above_threshold": above_threshold, |
| "verdict_label": "Выше порога" if above_threshold else "Ниже порога", |
| "disclaimer": DISCLAIMER, |
| "ordered_row": ordered_row, |
| "feature_order": feature_order, |
| "cat_features": cat_features, |
| "problems": problems, |
| "warnings": warnings, |
| "log": log, |
| } |
|
|
|
|
| def print_report(result): |
| """Печатает в консоль то же, что раньше печатал score_listing — |
| порядок сообщений сохранён: лог сборки цены, предупреждения, затем |
| оценка и вердикт.""" |
| for line in result["log"]: |
| print(line) |
|
|
| for line in result["warnings"]: |
| print(line) |
|
|
| if result["problems"]: |
| print("Предупреждение: значения вне обучающего словаря категорий:") |
| for col, val, sample in result["problems"]: |
| print(f" {col}: {val!r} — не входит в обучающие категории " |
| f"(первые допустимые: {sample})") |
|
|
| print(f"Оценка модели: {result['score']:.4f}") |
| print(f"Рабочий порог: {result['threshold']:.4f} (precision {result['threshold_precision']}, " |
| f"recall {result['threshold_recall']} на контрольной части)") |
| print(f"{result['verdict_label']}:", result["disclaimer"]) |
|
|
|
|
| def check_reproducibility(result): |
| """Сверяет оценку result со сквозным эталоном (EXPECTED_SCORE, |
| REFERENCE_FEATURES). Критерий: расхождение < 1e-6 — цепочка от |
| справочников до оценки воспроизводима; больше — см. комментарий к |
| EXPECTED_SCORE в начале файла про вероятные причины.""" |
| if EXPECTED_SCORE is None: |
| return |
|
|
| diff = abs(result["score"] - EXPECTED_SCORE) |
| print(f"\nЭталонная оценка (вымышленное объявление): {EXPECTED_SCORE:.6f}") |
| print(f"Расхождение: {diff:.10f}") |
| if diff < 1e-6: |
| print("Совпадает с эталоном — цепочка воспроизводима.") |
| else: |
| print("! Расхождение превышает 1e-6.") |
| compare_with_reference(result["ordered_row"], result["feature_order"], REFERENCE_FEATURES) |
|
|
|
|
| if __name__ == "__main__": |
| artifacts = load_artifacts() |
| result = score_listing(example_listing, artifacts) |
| print_report(result) |
| check_reproducibility(result) |
|
|