Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# fairvalue.py : framework d'estimation de la JUSTE VALEUR locative (fair value)5#6# Moteur générique Groupe-KA « estimation de juste valeur » : les7# spécificités logement vivent dans CONFIG (segments, caractéristiques,8# seuils) — décliner sur immo-ka (biens en vente) ou auto-ka (véhicules)9# = fournir une autre CONFIG et une autre requête de chargement.10#11# Trois niveaux, du plus robuste au plus fin :12# N1 référence par segment de marché (ville[/quartier] × chambres),13# médiane + p25/p75, volume minimal sinon repli au niveau supérieur ;14# N2 ajustements multiplicatifs par caractéristiques (meublé, chauffage15# inclus, stationnement…) estimés SUR LES DONNÉES elles-mêmes ;16# N3 comparables kNN géographiques (grille spatiale, pondération17# gaussienne par la distance).18# Combinaison + couloir de sécurité autour du segment + indice de confiance.19# AUCUNE donnée inventée : segment trop rare => confiance faible / pas de20# verdict, jamais d'estimation à l'aveugle.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import math25import re26import time27from statistics import median2829MODEL_VERSION = "fv-logement-1.0"3031CONFIG = {32 # volume minimal d'annonces pour qu'un segment fasse référence33 "min_segment": 8,34 # comparables (niveau 3)35 "knn_k": 12,36 "knn_min": 4,37 "knn_radius_m": 3000.0,38 "knn_bandwidth_m": 800.0, # pondération gaussienne exp(-(d/bw)^2)39 # combinaison : poids du kNN quand il est disponible40 "knn_weight": 0.6,41 # couloir de sécurité autour de la référence du segment42 "corridor": (0.70, 1.30), # x p25 … x p7543 # classification (écart relatif au marché)44 "seuil_sous": -0.08,45 "seuil_sur": 0.08,46 # au-delà : donnée probablement corrompue (prix à la semaine, coquille…)47 # -> pas de verdict plutôt qu'un verdict trompeur48 "verdict_dev_bounds": (-0.50, 1.00),49 # ajustement superficie : (aire/aire_type)^elasticité, borné50 "area_elasticity": 0.35,51 "area_adj_bounds": (0.80, 1.30),52 # caractéristiques ajustables (chemin json_extract dans details, ou champ)53 "features": {54 "meuble": ("field", "furnished"),55 "chauffage": ("details", "$.inclusions.heating"),56 "electricite": ("details", "$.inclusions.electricity"),57 "stationnement": ("details", "$.parking.available"),58 "climatisation": ("details", "$.ac"),59 "balcon": ("details", "$.balcony"),60 "ascenseur": ("details", "$.elevator"),61 "piscine": ("details", "$.pool"),62 "lave_vaisselle": ("details", "$.appliances.dishwasher"),63 },64 # bornes de sanité des coefficients appris (un coef hors bornes = bruit)65 "coef_bounds": (0.85, 1.35),66 "coef_min_pairs": 60, # nb min d'annonces avec ET sans la carac.67}686970# --------------------------------------------------------------- utilitaires71# location de CHAMBRE / colocation : segment à part — comparer une chambre72# au loyer d'un logement entier fabriquerait de fausses « bonnes affaires »73_CHAMBRE = re.compile(74 r"(chambre\s+(à|a)\s+louer|chambre\s+dans|chambre\s+disponible|"75 r"chambre\s+meubl|colocation|\bcoloc\b|room\s+for\s+rent|"76 r"room\s+in\b|roommate|chambre\s+(à|a)\s+partager)", re.IGNORECASE)7778# prix affiché non mensuel (semaine/jour/nuit) : l'écart serait trompeur79_NON_MENSUEL = re.compile(80 r"(/\s*sem|par\s+sem|semaine|hebdo|/\s*jour|par\s+jour|/\s*nuit|"81 r"par\s+nuit|/\s*wk|/\s*week\b|weekly|/\s*night|nightly|/\s*day\b)",82 re.IGNORECASE)838485def _bed_group(bedrooms) -> str:86 """Groupe de chambres : 0 (studio), 1, 2, 3, 4+ — clé de segment."""87 if bedrooms is None:88 return "?"89 b = int(round(float(bedrooms)))90 return "4+" if b >= 4 else str(max(0, b))919293def _pct(sorted_vals: list[float], q: float) -> float:94 if not sorted_vals:95 return 0.096 i = q * (len(sorted_vals) - 1)97 lo, hi = int(math.floor(i)), int(math.ceil(i))98 return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (i - lo)99100101def _dist_m(lat1, lng1, lat2, lng2) -> float:102 """Distance approx. en mètres (équirectangulaire — suffisant à < 10 km)."""103 kx = 111320.0 * math.cos(math.radians((lat1 + lat2) / 2))104 return math.hypot((lat1 - lat2) * 110540.0, (lng1 - lng2) * kx)105106107def _ensure_tables(con) -> None:108 con.executescript("""109 CREATE TABLE IF NOT EXISTS fairvalue (110 uid TEXT PRIMARY KEY, -- listings.uid111 fv REAL, -- juste valeur estimée ($/mois)112 fv_low REAL, -- fourchette basse113 fv_high REAL, -- fourchette haute114 deviation REAL, -- (prix - fv) / fv115 verdict TEXT, -- sous | marche | sur | NULL116 confidence TEXT, -- fort | moyen | faible117 method TEXT, -- segment | segment+ajust | knn+segment118 comps INTEGER, -- nb de comparables kNN retenus119 segment TEXT, -- clé du segment de référence120 model_version TEXT,121 computed_at REAL122 );123 CREATE TABLE IF NOT EXISTS fv_segments ( -- historisation des références124 ts REAL,125 level INTEGER, -- 1=ville+quartier+ch, 2=ville+ch, 3=ch, 4=global126 key TEXT,127 n INTEGER,128 p25 REAL, p50 REAL, p75 REAL,129 area_p50 REAL130 );131 CREATE INDEX IF NOT EXISTS idx_fv_segments_ts ON fv_segments(ts);132 """)133 con.commit()134135136# ------------------------------------------------------------- jeu de données137def _load(con) -> list[dict]:138 """Jeu de référence : annonces publiées (qualité validée), canoniques,139 au prix plausible. Les annonces en quarantaine sont EXCLUES."""140 feats = CONFIG["features"]141 cols = ", ".join(142 f"json_extract(details, '{path}') AS f_{name}"143 for name, (kind, path) in feats.items() if kind == "details")144 rows = con.execute(f"""145 SELECT uid, source, city, sector, unit_type, bedrooms, price,146 area_sqft, lat, lng, furnished, price_label, title,147 json_extract(details, '$.price_from') AS price_from, {cols}148 FROM listings149 WHERE active=1 AND published=1 AND dup_of IS NULL150 AND price IS NOT NULL AND price BETWEEN 175 AND 20000151 """).fetchall()152 out = []153 for r in rows:154 d = dict(r)155 if ((d.get("unit_type") or "").strip().lower() in ("chambre", "chambres")156 or _CHAMBRE.search(d.get("title") or "")):157 d["bed"] = "ch" # segment « chambre / colocation »158 else:159 d["bed"] = _bed_group(d["bedrooms"])160 # prix à la semaine/nuit rangé comme mensuel : EXCLU du jeu de161 # référence (fausserait les médianes) et jamais de verdict162 d["non_monthly"] = bool(163 _NON_MENSUEL.search(d.get("price_label") or "")164 or _NON_MENSUEL.search(d.get("title") or ""))165 if not d["non_monthly"]:166 out.append(d)167 return out168169170def _feature_value(item: dict, name: str):171 kind, path = CONFIG["features"][name]172 if kind == "field":173 return item.get(path)174 return item.get(f"f_{name}")175176177# ------------------------------------------------- niveau 1 : segments marché178def _build_segments(items: list[dict]) -> dict[str, dict]:179 """Références par segment avec échelle de repli :180 L1 ville|quartier|ch → L2 ville|ch → L3 ch (province) → L4 global."""181 buckets: dict[tuple[int, str], list[dict]] = {}182 for it in items:183 keys = []184 if it["city"] and it["sector"] and it["bed"] != "?":185 keys.append((1, f"{it['city']}|{it['sector']}|{it['bed']}"))186 if it["city"] and it["bed"] != "?":187 keys.append((2, f"{it['city']}|{it['bed']}"))188 if it["bed"] != "?":189 keys.append((3, it["bed"]))190 keys.append((4, "global"))191 for k in keys:192 buckets.setdefault(k, []).append(it)193 refs: dict[str, dict] = {}194 n_min = CONFIG["min_segment"]195 for (level, key), members in buckets.items():196 if len(members) < n_min and level != 4:197 continue198 prices = sorted(m["price"] for m in members)199 areas = sorted(m["area_sqft"] for m in members if m["area_sqft"])200 refs[f"{level}|{key}"] = {201 "level": level, "key": key, "n": len(members),202 "p25": _pct(prices, 0.25), "p50": _pct(prices, 0.50),203 "p75": _pct(prices, 0.75),204 "area_p50": median(areas) if areas else None,205 }206 return refs207208209def _segment_for(it: dict, refs: dict[str, dict]) -> dict | None:210 """Référence la plus fine disponible pour une annonce (repli en échelle)."""211 candidates = []212 if it["city"] and it["sector"] and it["bed"] != "?":213 candidates.append(f"1|{it['city']}|{it['sector']}|{it['bed']}")214 if it["city"] and it["bed"] != "?":215 candidates.append(f"2|{it['city']}|{it['bed']}")216 if it["bed"] != "?":217 candidates.append(f"3|{it['bed']}")218 candidates.append("4|global")219 for c in candidates:220 if c in refs:221 return refs[c]222 return None223224225# ------------------------------------- niveau 2 : coefficients par carac.226def _learn_coefficients(items: list[dict], refs: dict) -> dict[str, float]:227 """Coefficient multiplicatif de chaque caractéristique, appris des données :228 médiane de (prix / p50 du segment) des annonces AVEC la caractéristique,229 divisée par celle des annonces SANS. Borné (coef_bounds) et ignoré si230 l'échantillon est trop mince — aucun coefficient inventé."""231 coefs: dict[str, tuple[float, float]] = {}232 lo, hi = CONFIG["coef_bounds"]233 for name in CONFIG["features"]:234 with_r, without_r = [], []235 for it in items:236 ref = _segment_for(it, refs)237 if ref is None or ref["level"] >= 4 or ref["p50"] <= 0:238 continue239 v = _feature_value(it, name)240 if v is None:241 continue242 ratio = it["price"] / ref["p50"]243 (with_r if v else without_r).append(ratio)244 if (len(with_r) >= CONFIG["coef_min_pairs"]245 and len(without_r) >= CONFIG["coef_min_pairs"]):246 c = median(with_r) / max(median(without_r), 1e-9)247 c = min(hi, max(lo, c))248 # part des annonces (au statut connu) qui ONT la caractéristique :249 # sert à centrer l'ajustement — le p50 du segment reflète déjà le250 # mélange avec/sans, appliquer c brut gonflerait toutes les251 # estimations (biais systématique vers « sous-évalué »)252 share = len(with_r) / (len(with_r) + len(without_r))253 coefs[name] = (c, share)254 return coefs255256257def _adjusted(it: dict, ref: dict, coefs: dict[str, float]) -> float:258 """Niveau 2 : référence du segment ajustée aux caractéristiques."""259 fv = ref["p50"]260 for name, (coef, share) in coefs.items():261 v = _feature_value(it, name)262 if v is None:263 continue # statut inconnu = effet moyen (neutre)264 norm = share * coef + (1.0 - share) # effet moyen déjà dans le p50265 fv *= (coef if v else 1.0) / norm266 if it["area_sqft"] and ref.get("area_p50"):267 lo, hi = CONFIG["area_adj_bounds"]268 adj = (it["area_sqft"] / ref["area_p50"]) ** CONFIG["area_elasticity"]269 fv *= min(hi, max(lo, adj))270 return fv271272273# -------------------------------------------- niveau 3 : comparables kNN géo274def _build_grid(items: list[dict]) -> dict[tuple[int, int, str], list[dict]]:275 grid: dict[tuple[int, int, str], list[dict]] = {}276 for it in items:277 if it["lat"] is None or it["lng"] is None or it["bed"] == "?":278 continue279 cell = (int(it["lat"] * 50), int(it["lng"] * 50), it["bed"]) # ~2 km280 grid.setdefault(cell, []).append(it)281 return grid282283284def _knn(it: dict, grid: dict) -> tuple[float | None, int]:285 if it["lat"] is None or it["lng"] is None or it["bed"] == "?":286 return None, 0287 ci, cj = int(it["lat"] * 50), int(it["lng"] * 50)288 cands = []289 for di in (-1, 0, 1):290 for dj in (-1, 0, 1):291 cands.extend(grid.get((ci + di, cj + dj, it["bed"]), ()))292 radius, bw = CONFIG["knn_radius_m"], CONFIG["knn_bandwidth_m"]293 scored = []294 for c in cands:295 if c["uid"] == it["uid"]:296 continue297 d = _dist_m(it["lat"], it["lng"], c["lat"], c["lng"])298 if d > radius:299 continue300 w = math.exp(-((d / bw) ** 2))301 if it["unit_type"] and c["unit_type"] and it["unit_type"] == c["unit_type"]:302 w *= 1.3 # même type d'unité = plus comparable303 scored.append((w, c["price"], d))304 if len(scored) < CONFIG["knn_min"]:305 return None, len(scored)306 scored.sort(key=lambda x: -x[0])307 top = scored[:CONFIG["knn_k"]]308 # médiane PONDÉRÉE des loyers comparables (la moyenne serait tirée vers le309 # haut par l'asymétrie des loyers)310 by_price = sorted(top, key=lambda x: x[1])311 half = sum(w for w, _, _ in top) / 2312 acc = 0.0313 fv = by_price[-1][1]314 for w, price, _ in by_price:315 acc += w316 if acc >= half:317 fv = price318 break319 return fv, len(top)320321322# --------------------------------------------------------- calcul d'ensemble323def compute_all(verbose: bool = True) -> dict:324 """(Re)calcule la fair value de toutes les annonces publiées.325326 Appelé à chaque cycle de synchronisation (ingest.watch) et via327 `python run.py fairvalue`. Historise les références de segments.328 """329 from . import db330 con = db.connect()331 _ensure_tables(con)332 t0 = time.time()333334 items = _load(con)335 refs = _build_segments(items)336 coefs = _learn_coefficients(items, refs)337 grid = _build_grid(items)338339 now = time.time()340 stats = {"annonces": len(items), "segments": len(refs), "coefs": coefs,341 "sous": 0, "marche": 0, "sur": 0, "sans_verdict": 0}342 rows = []343 for it in items:344 ref = _segment_for(it, refs)345 if ref is None:346 continue347 fv_seg = _adjusted(it, ref, coefs)348 fv_knn, n_comps = _knn(it, grid)349 if fv_knn is not None:350 w = CONFIG["knn_weight"]351 fv = w * fv_knn + (1 - w) * fv_seg352 method = "knn+segment"353 else:354 fv = fv_seg355 method = "segment+ajust" if coefs else "segment"356 # couloir de sécurité autour du segment (jamais d'estimation absurde)357 c_lo, c_hi = CONFIG["corridor"]358 fv = min(max(fv, ref["p25"] * c_lo), ref["p75"] * c_hi)359360 # fourchette : dispersion relative du segment appliquée à l'estimation361 lo = fv * (ref["p25"] / ref["p50"]) if ref["p50"] else fv * 0.85362 hi = fv * (ref["p75"] / ref["p50"]) if ref["p50"] else fv * 1.15363364 # divergence forte entre méthodes -> fourchette élargie, confiance365 # abaissée (les deux estimateurs ne racontent pas la même histoire)366 divergent = (fv_knn is not None and fv_seg > 0367 and abs(fv_knn - fv_seg) / fv_seg > 0.25)368 if divergent:369 lo, hi = min(lo, min(fv_knn, fv_seg)), max(hi, max(fv_knn, fv_seg))370371 if (n_comps >= 8 and ref["level"] <= 2 and not divergent):372 confidence = "fort"373 elif n_comps >= CONFIG["knn_min"] or (ref["level"] <= 2 and ref["n"] >= 15):374 confidence = "moyen"375 else:376 confidence = "faible"377378 deviation = (it["price"] - fv) / fv if fv > 0 else None379 # prudence : pas de verdict si confiance faible, prix ambigu380 # (« à partir de » = plancher) ou écart hors du plausible (donnée381 # douteuse : coquille de prix, périodicité mal lue…)382 b_lo, b_hi = CONFIG["verdict_dev_bounds"]383 verdict = None384 if (deviation is not None and confidence != "faible"385 and not it["price_from"] and b_lo < deviation < b_hi):386 if deviation <= CONFIG["seuil_sous"]:387 verdict = "sous"388 elif deviation >= CONFIG["seuil_sur"]:389 verdict = "sur"390 else:391 verdict = "marche"392 stats[verdict] += 1393 else:394 stats["sans_verdict"] += 1395396 rows.append((it["uid"], round(fv), round(lo), round(hi),397 round(deviation, 4) if deviation is not None else None,398 verdict, confidence, method, n_comps,399 f"{ref['level']}|{ref['key']}", MODEL_VERSION, now))400401 con.execute("DELETE FROM fairvalue")402 con.executemany(403 "INSERT INTO fairvalue (uid, fv, fv_low, fv_high, deviation, verdict,"404 " confidence, method, comps, segment, model_version, computed_at)"405 " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", rows)406 # historisation des références (évolution des loyers par segment)407 con.executemany(408 "INSERT INTO fv_segments (ts, level, key, n, p25, p50, p75, area_p50)"409 " VALUES (?,?,?,?,?,?,?,?)",410 [(now, r["level"], r["key"], r["n"], round(r["p25"]), round(r["p50"]),411 round(r["p75"]), r["area_p50"]) for r in refs.values()412 if r["level"] <= 2])413 con.commit()414 con.close()415 stats["seconds"] = round(time.time() - t0, 1)416 stats["estimees"] = len(rows)417 if verbose:418 c = {k: round(v[0], 3) for k, v in coefs.items()}419 print(f"[rent-ka] fairvalue: {stats['estimees']} estimées "420 f"({stats['segments']} segments) en {stats['seconds']}s — "421 f"sous {stats['sous']} / marché {stats['marche']} / "422 f"sur {stats['sur']} / sans verdict {stats['sans_verdict']}")423 print(f"[rent-ka] fairvalue coefs: {c}")424 return stats425426427# ------------------------------------------------- explication d'une annonce428def explain(uid: str) -> dict | None:429 """Détail d'une estimation pour la fiche (analyse de prix) : fair value,430 fourchette, confiance, distribution du segment (mini-graphique)."""431 from . import db432 con = db.connect()433 _ensure_tables(con)434 row = con.execute("SELECT * FROM fairvalue WHERE uid=?", (uid,)).fetchone()435 if row is None:436 con.close()437 return None438 d = dict(row)439 # distribution des loyers du segment de référence (pour l'histogramme)440 level, key = d["segment"].split("|", 1)441 parts = key.split("|")442 sql = ("SELECT price FROM listings WHERE active=1 AND published=1"443 " AND dup_of IS NULL AND price BETWEEN 175 AND 20000")444 args: list = []445 if level == "1":446 sql += " AND city=? AND sector=?"447 args += parts[0:2]448 bed = parts[2]449 elif level == "2":450 sql += " AND city=?"451 args.append(parts[0])452 bed = parts[1]453 else:454 bed = parts[0] if parts[0] != "global" else None455 prices = [r["price"] for r in con.execute(sql, args)]456 if bed is not None:457 beds = {r["uid"]: r["bedrooms"] for r in con.execute(458 "SELECT uid, bedrooms FROM listings WHERE active=1 AND published=1")}459 # re-filtrer par groupe de chambres via une requête directe (plus sûr)460 sql2 = sql + (" AND ((?='0' AND (bedrooms IS NOT NULL AND CAST(ROUND(bedrooms) AS INT)<=0))"461 " OR (?='4+' AND CAST(ROUND(bedrooms) AS INT)>=4)"462 " OR (CAST(ROUND(bedrooms) AS INT) = CAST(? AS INT) AND ? NOT IN ('0','4+')))")463 prices = [r["price"] for r in con.execute(sql2, args + [bed, bed, bed, bed])]464 con.close()465 prices.sort()466 # histogramme en 24 classes bornées p1–p99 (queues écrasées sinon)467 hist = []468 if len(prices) >= 8:469 lo, hi = _pct(prices, 0.01), _pct(prices, 0.99)470 if hi > lo:471 nbins = 24472 counts = [0] * nbins473 for p in prices:474 i = int((min(max(p, lo), hi) - lo) / (hi - lo) * (nbins - 1))475 counts[i] += 1476 hist = [{"x0": round(lo + (hi - lo) * i / nbins),477 "x1": round(lo + (hi - lo) * (i + 1) / nbins),478 "n": c} for i, c in enumerate(counts)]479 d["segment_n"] = len(prices)480 d["histogram"] = hist481 return d482