# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # fairvalue.py : framework d'estimation de la JUSTE VALEUR locative (fair value) # # Moteur générique Groupe-KA « estimation de juste valeur » : les # spécificités logement vivent dans CONFIG (segments, caractéristiques, # seuils) — décliner sur immo-ka (biens en vente) ou auto-ka (véhicules) # = fournir une autre CONFIG et une autre requête de chargement. # # Trois niveaux, du plus robuste au plus fin : # N1 référence par segment de marché (ville[/quartier] × chambres), # médiane + p25/p75, volume minimal sinon repli au niveau supérieur ; # N2 ajustements multiplicatifs par caractéristiques (meublé, chauffage # inclus, stationnement…) estimés SUR LES DONNÉES elles-mêmes ; # N3 comparables kNN géographiques (grille spatiale, pondération # gaussienne par la distance). # Combinaison + couloir de sécurité autour du segment + indice de confiance. # AUCUNE donnée inventée : segment trop rare => confiance faible / pas de # verdict, jamais d'estimation à l'aveugle. # ----------------------------------------------------------------------------- from __future__ import annotations import math import re import time from statistics import median MODEL_VERSION = "fv-logement-1.0" CONFIG = { # volume minimal d'annonces pour qu'un segment fasse référence "min_segment": 8, # comparables (niveau 3) "knn_k": 12, "knn_min": 4, "knn_radius_m": 3000.0, "knn_bandwidth_m": 800.0, # pondération gaussienne exp(-(d/bw)^2) # combinaison : poids du kNN quand il est disponible "knn_weight": 0.6, # couloir de sécurité autour de la référence du segment "corridor": (0.70, 1.30), # x p25 … x p75 # classification (écart relatif au marché) "seuil_sous": -0.08, "seuil_sur": 0.08, # au-delà : donnée probablement corrompue (prix à la semaine, coquille…) # -> pas de verdict plutôt qu'un verdict trompeur "verdict_dev_bounds": (-0.50, 1.00), # ajustement superficie : (aire/aire_type)^elasticité, borné "area_elasticity": 0.35, "area_adj_bounds": (0.80, 1.30), # caractéristiques ajustables (chemin json_extract dans details, ou champ) "features": { "meuble": ("field", "furnished"), "chauffage": ("details", "$.inclusions.heating"), "electricite": ("details", "$.inclusions.electricity"), "stationnement": ("details", "$.parking.available"), "climatisation": ("details", "$.ac"), "balcon": ("details", "$.balcony"), "ascenseur": ("details", "$.elevator"), "piscine": ("details", "$.pool"), "lave_vaisselle": ("details", "$.appliances.dishwasher"), }, # bornes de sanité des coefficients appris (un coef hors bornes = bruit) "coef_bounds": (0.85, 1.35), "coef_min_pairs": 60, # nb min d'annonces avec ET sans la carac. } # --------------------------------------------------------------- utilitaires # location de CHAMBRE / colocation : segment à part — comparer une chambre # au loyer d'un logement entier fabriquerait de fausses « bonnes affaires » _CHAMBRE = re.compile( r"(chambre\s+(à|a)\s+louer|chambre\s+dans|chambre\s+disponible|" r"chambre\s+meubl|colocation|\bcoloc\b|room\s+for\s+rent|" r"room\s+in\b|roommate|chambre\s+(à|a)\s+partager)", re.IGNORECASE) # prix affiché non mensuel (semaine/jour/nuit) : l'écart serait trompeur _NON_MENSUEL = re.compile( r"(/\s*sem|par\s+sem|semaine|hebdo|/\s*jour|par\s+jour|/\s*nuit|" r"par\s+nuit|/\s*wk|/\s*week\b|weekly|/\s*night|nightly|/\s*day\b)", re.IGNORECASE) def _bed_group(bedrooms) -> str: """Groupe de chambres : 0 (studio), 1, 2, 3, 4+ — clé de segment.""" if bedrooms is None: return "?" b = int(round(float(bedrooms))) return "4+" if b >= 4 else str(max(0, b)) def _pct(sorted_vals: list[float], q: float) -> float: if not sorted_vals: return 0.0 i = q * (len(sorted_vals) - 1) lo, hi = int(math.floor(i)), int(math.ceil(i)) return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (i - lo) def _dist_m(lat1, lng1, lat2, lng2) -> float: """Distance approx. en mètres (équirectangulaire — suffisant à < 10 km).""" kx = 111320.0 * math.cos(math.radians((lat1 + lat2) / 2)) return math.hypot((lat1 - lat2) * 110540.0, (lng1 - lng2) * kx) def _ensure_tables(con) -> None: con.executescript(""" CREATE TABLE IF NOT EXISTS fairvalue ( uid TEXT PRIMARY KEY, -- listings.uid fv REAL, -- juste valeur estimée ($/mois) fv_low REAL, -- fourchette basse fv_high REAL, -- fourchette haute deviation REAL, -- (prix - fv) / fv verdict TEXT, -- sous | marche | sur | NULL confidence TEXT, -- fort | moyen | faible method TEXT, -- segment | segment+ajust | knn+segment comps INTEGER, -- nb de comparables kNN retenus segment TEXT, -- clé du segment de référence model_version TEXT, computed_at REAL ); CREATE TABLE IF NOT EXISTS fv_segments ( -- historisation des références ts REAL, level INTEGER, -- 1=ville+quartier+ch, 2=ville+ch, 3=ch, 4=global key TEXT, n INTEGER, p25 REAL, p50 REAL, p75 REAL, area_p50 REAL ); CREATE INDEX IF NOT EXISTS idx_fv_segments_ts ON fv_segments(ts); """) con.commit() # ------------------------------------------------------------- jeu de données def _load(con) -> list[dict]: """Jeu de référence : annonces publiées (qualité validée), canoniques, au prix plausible. Les annonces en quarantaine sont EXCLUES.""" feats = CONFIG["features"] cols = ", ".join( f"json_extract(details, '{path}') AS f_{name}" for name, (kind, path) in feats.items() if kind == "details") rows = con.execute(f""" SELECT uid, source, city, sector, unit_type, bedrooms, price, area_sqft, lat, lng, furnished, price_label, title, json_extract(details, '$.price_from') AS price_from, {cols} FROM listings WHERE active=1 AND published=1 AND dup_of IS NULL AND price IS NOT NULL AND price BETWEEN 175 AND 20000 """).fetchall() out = [] for r in rows: d = dict(r) if ((d.get("unit_type") or "").strip().lower() in ("chambre", "chambres") or _CHAMBRE.search(d.get("title") or "")): d["bed"] = "ch" # segment « chambre / colocation » else: d["bed"] = _bed_group(d["bedrooms"]) # prix à la semaine/nuit rangé comme mensuel : EXCLU du jeu de # référence (fausserait les médianes) et jamais de verdict d["non_monthly"] = bool( _NON_MENSUEL.search(d.get("price_label") or "") or _NON_MENSUEL.search(d.get("title") or "")) if not d["non_monthly"]: out.append(d) return out def _feature_value(item: dict, name: str): kind, path = CONFIG["features"][name] if kind == "field": return item.get(path) return item.get(f"f_{name}") # ------------------------------------------------- niveau 1 : segments marché def _build_segments(items: list[dict]) -> dict[str, dict]: """Références par segment avec échelle de repli : L1 ville|quartier|ch → L2 ville|ch → L3 ch (province) → L4 global.""" buckets: dict[tuple[int, str], list[dict]] = {} for it in items: keys = [] if it["city"] and it["sector"] and it["bed"] != "?": keys.append((1, f"{it['city']}|{it['sector']}|{it['bed']}")) if it["city"] and it["bed"] != "?": keys.append((2, f"{it['city']}|{it['bed']}")) if it["bed"] != "?": keys.append((3, it["bed"])) keys.append((4, "global")) for k in keys: buckets.setdefault(k, []).append(it) refs: dict[str, dict] = {} n_min = CONFIG["min_segment"] for (level, key), members in buckets.items(): if len(members) < n_min and level != 4: continue prices = sorted(m["price"] for m in members) areas = sorted(m["area_sqft"] for m in members if m["area_sqft"]) refs[f"{level}|{key}"] = { "level": level, "key": key, "n": len(members), "p25": _pct(prices, 0.25), "p50": _pct(prices, 0.50), "p75": _pct(prices, 0.75), "area_p50": median(areas) if areas else None, } return refs def _segment_for(it: dict, refs: dict[str, dict]) -> dict | None: """Référence la plus fine disponible pour une annonce (repli en échelle).""" candidates = [] if it["city"] and it["sector"] and it["bed"] != "?": candidates.append(f"1|{it['city']}|{it['sector']}|{it['bed']}") if it["city"] and it["bed"] != "?": candidates.append(f"2|{it['city']}|{it['bed']}") if it["bed"] != "?": candidates.append(f"3|{it['bed']}") candidates.append("4|global") for c in candidates: if c in refs: return refs[c] return None # ------------------------------------- niveau 2 : coefficients par carac. def _learn_coefficients(items: list[dict], refs: dict) -> dict[str, float]: """Coefficient multiplicatif de chaque caractéristique, appris des données : médiane de (prix / p50 du segment) des annonces AVEC la caractéristique, divisée par celle des annonces SANS. Borné (coef_bounds) et ignoré si l'échantillon est trop mince — aucun coefficient inventé.""" coefs: dict[str, tuple[float, float]] = {} lo, hi = CONFIG["coef_bounds"] for name in CONFIG["features"]: with_r, without_r = [], [] for it in items: ref = _segment_for(it, refs) if ref is None or ref["level"] >= 4 or ref["p50"] <= 0: continue v = _feature_value(it, name) if v is None: continue ratio = it["price"] / ref["p50"] (with_r if v else without_r).append(ratio) if (len(with_r) >= CONFIG["coef_min_pairs"] and len(without_r) >= CONFIG["coef_min_pairs"]): c = median(with_r) / max(median(without_r), 1e-9) c = min(hi, max(lo, c)) # part des annonces (au statut connu) qui ONT la caractéristique : # sert à centrer l'ajustement — le p50 du segment reflète déjà le # mélange avec/sans, appliquer c brut gonflerait toutes les # estimations (biais systématique vers « sous-évalué ») share = len(with_r) / (len(with_r) + len(without_r)) coefs[name] = (c, share) return coefs def _adjusted(it: dict, ref: dict, coefs: dict[str, float]) -> float: """Niveau 2 : référence du segment ajustée aux caractéristiques.""" fv = ref["p50"] for name, (coef, share) in coefs.items(): v = _feature_value(it, name) if v is None: continue # statut inconnu = effet moyen (neutre) norm = share * coef + (1.0 - share) # effet moyen déjà dans le p50 fv *= (coef if v else 1.0) / norm if it["area_sqft"] and ref.get("area_p50"): lo, hi = CONFIG["area_adj_bounds"] adj = (it["area_sqft"] / ref["area_p50"]) ** CONFIG["area_elasticity"] fv *= min(hi, max(lo, adj)) return fv # -------------------------------------------- niveau 3 : comparables kNN géo def _build_grid(items: list[dict]) -> dict[tuple[int, int, str], list[dict]]: grid: dict[tuple[int, int, str], list[dict]] = {} for it in items: if it["lat"] is None or it["lng"] is None or it["bed"] == "?": continue cell = (int(it["lat"] * 50), int(it["lng"] * 50), it["bed"]) # ~2 km grid.setdefault(cell, []).append(it) return grid def _knn(it: dict, grid: dict) -> tuple[float | None, int]: if it["lat"] is None or it["lng"] is None or it["bed"] == "?": return None, 0 ci, cj = int(it["lat"] * 50), int(it["lng"] * 50) cands = [] for di in (-1, 0, 1): for dj in (-1, 0, 1): cands.extend(grid.get((ci + di, cj + dj, it["bed"]), ())) radius, bw = CONFIG["knn_radius_m"], CONFIG["knn_bandwidth_m"] scored = [] for c in cands: if c["uid"] == it["uid"]: continue d = _dist_m(it["lat"], it["lng"], c["lat"], c["lng"]) if d > radius: continue w = math.exp(-((d / bw) ** 2)) if it["unit_type"] and c["unit_type"] and it["unit_type"] == c["unit_type"]: w *= 1.3 # même type d'unité = plus comparable scored.append((w, c["price"], d)) if len(scored) < CONFIG["knn_min"]: return None, len(scored) scored.sort(key=lambda x: -x[0]) top = scored[:CONFIG["knn_k"]] # médiane PONDÉRÉE des loyers comparables (la moyenne serait tirée vers le # haut par l'asymétrie des loyers) by_price = sorted(top, key=lambda x: x[1]) half = sum(w for w, _, _ in top) / 2 acc = 0.0 fv = by_price[-1][1] for w, price, _ in by_price: acc += w if acc >= half: fv = price break return fv, len(top) # --------------------------------------------------------- calcul d'ensemble def compute_all(verbose: bool = True) -> dict: """(Re)calcule la fair value de toutes les annonces publiées. Appelé à chaque cycle de synchronisation (ingest.watch) et via `python run.py fairvalue`. Historise les références de segments. """ from . import db con = db.connect() _ensure_tables(con) t0 = time.time() items = _load(con) refs = _build_segments(items) coefs = _learn_coefficients(items, refs) grid = _build_grid(items) now = time.time() stats = {"annonces": len(items), "segments": len(refs), "coefs": coefs, "sous": 0, "marche": 0, "sur": 0, "sans_verdict": 0} rows = [] for it in items: ref = _segment_for(it, refs) if ref is None: continue fv_seg = _adjusted(it, ref, coefs) fv_knn, n_comps = _knn(it, grid) if fv_knn is not None: w = CONFIG["knn_weight"] fv = w * fv_knn + (1 - w) * fv_seg method = "knn+segment" else: fv = fv_seg method = "segment+ajust" if coefs else "segment" # couloir de sécurité autour du segment (jamais d'estimation absurde) c_lo, c_hi = CONFIG["corridor"] fv = min(max(fv, ref["p25"] * c_lo), ref["p75"] * c_hi) # fourchette : dispersion relative du segment appliquée à l'estimation lo = fv * (ref["p25"] / ref["p50"]) if ref["p50"] else fv * 0.85 hi = fv * (ref["p75"] / ref["p50"]) if ref["p50"] else fv * 1.15 # divergence forte entre méthodes -> fourchette élargie, confiance # abaissée (les deux estimateurs ne racontent pas la même histoire) divergent = (fv_knn is not None and fv_seg > 0 and abs(fv_knn - fv_seg) / fv_seg > 0.25) if divergent: lo, hi = min(lo, min(fv_knn, fv_seg)), max(hi, max(fv_knn, fv_seg)) if (n_comps >= 8 and ref["level"] <= 2 and not divergent): confidence = "fort" elif n_comps >= CONFIG["knn_min"] or (ref["level"] <= 2 and ref["n"] >= 15): confidence = "moyen" else: confidence = "faible" deviation = (it["price"] - fv) / fv if fv > 0 else None # prudence : pas de verdict si confiance faible, prix ambigu # (« à partir de » = plancher) ou écart hors du plausible (donnée # douteuse : coquille de prix, périodicité mal lue…) b_lo, b_hi = CONFIG["verdict_dev_bounds"] verdict = None if (deviation is not None and confidence != "faible" and not it["price_from"] and b_lo < deviation < b_hi): if deviation <= CONFIG["seuil_sous"]: verdict = "sous" elif deviation >= CONFIG["seuil_sur"]: verdict = "sur" else: verdict = "marche" stats[verdict] += 1 else: stats["sans_verdict"] += 1 rows.append((it["uid"], round(fv), round(lo), round(hi), round(deviation, 4) if deviation is not None else None, verdict, confidence, method, n_comps, f"{ref['level']}|{ref['key']}", MODEL_VERSION, now)) con.execute("DELETE FROM fairvalue") con.executemany( "INSERT INTO fairvalue (uid, fv, fv_low, fv_high, deviation, verdict," " confidence, method, comps, segment, model_version, computed_at)" " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", rows) # historisation des références (évolution des loyers par segment) con.executemany( "INSERT INTO fv_segments (ts, level, key, n, p25, p50, p75, area_p50)" " VALUES (?,?,?,?,?,?,?,?)", [(now, r["level"], r["key"], r["n"], round(r["p25"]), round(r["p50"]), round(r["p75"]), r["area_p50"]) for r in refs.values() if r["level"] <= 2]) con.commit() con.close() stats["seconds"] = round(time.time() - t0, 1) stats["estimees"] = len(rows) if verbose: c = {k: round(v[0], 3) for k, v in coefs.items()} print(f"[rent-ka] fairvalue: {stats['estimees']} estimées " f"({stats['segments']} segments) en {stats['seconds']}s — " f"sous {stats['sous']} / marché {stats['marche']} / " f"sur {stats['sur']} / sans verdict {stats['sans_verdict']}") print(f"[rent-ka] fairvalue coefs: {c}") return stats # ------------------------------------------------- explication d'une annonce def explain(uid: str) -> dict | None: """Détail d'une estimation pour la fiche (analyse de prix) : fair value, fourchette, confiance, distribution du segment (mini-graphique).""" from . import db con = db.connect() _ensure_tables(con) row = con.execute("SELECT * FROM fairvalue WHERE uid=?", (uid,)).fetchone() if row is None: con.close() return None d = dict(row) # distribution des loyers du segment de référence (pour l'histogramme) level, key = d["segment"].split("|", 1) parts = key.split("|") sql = ("SELECT price FROM listings WHERE active=1 AND published=1" " AND dup_of IS NULL AND price BETWEEN 175 AND 20000") args: list = [] if level == "1": sql += " AND city=? AND sector=?" args += parts[0:2] bed = parts[2] elif level == "2": sql += " AND city=?" args.append(parts[0]) bed = parts[1] else: bed = parts[0] if parts[0] != "global" else None prices = [r["price"] for r in con.execute(sql, args)] if bed is not None: beds = {r["uid"]: r["bedrooms"] for r in con.execute( "SELECT uid, bedrooms FROM listings WHERE active=1 AND published=1")} # re-filtrer par groupe de chambres via une requête directe (plus sûr) sql2 = sql + (" AND ((?='0' AND (bedrooms IS NOT NULL AND CAST(ROUND(bedrooms) AS INT)<=0))" " OR (?='4+' AND CAST(ROUND(bedrooms) AS INT)>=4)" " OR (CAST(ROUND(bedrooms) AS INT) = CAST(? AS INT) AND ? NOT IN ('0','4+')))") prices = [r["price"] for r in con.execute(sql2, args + [bed, bed, bed, bed])] con.close() prices.sort() # histogramme en 24 classes bornées p1–p99 (queues écrasées sinon) hist = [] if len(prices) >= 8: lo, hi = _pct(prices, 0.01), _pct(prices, 0.99) if hi > lo: nbins = 24 counts = [0] * nbins for p in prices: i = int((min(max(p, lo), hi) - lo) / (hi - lo) * (nbins - 1)) counts[i] += 1 hist = [{"x0": round(lo + (hi - lo) * i / nbins), "x1": round(lo + (hi - lo) * (i + 1) / nbins), "n": c} for i, c in enumerate(counts)] d["segment_n"] = len(prices) d["histogram"] = hist return d