SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
7.3 KB · 104 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Property Vector (§119-123, 154-155) : représentation canonique textuelle4 * déterministe + vecteur numérique déterministe (one-hot des catégories +5 * numériques normalisés). Modèle « vrai-prix-technical-v1 » : aucun appel6 * externe, reproductible ; l'interface `Embedder` permet de brancher plus tard7 * un modèle d'embedding réel. Module PUR.8 */9import type { MergedFacts } from "./merge";1011export const EMBEDDING_MODEL = "vrai-prix-technical-v1";1213export interface Embedder { model: string; embed(canonicalText: string, facts: MergedFacts): Promise<number[]> | number[] }1415const CATS = {16  buildingType: ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile", "other"],17  quality: ["economy", "standard", "superior", "prestige"],18  basement: ["none", "crawl", "unfinished", "partial", "finished", "walkout"],19  garage: ["none", "attached", "detached", "integrated", "carport"],20  roof: ["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"],21  roofGeometry: ["gable", "hip", "flat", "mansard", "complex"],22  windows: ["pvc", "hybrid", "aluminum", "wood"],23  heating: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "furnace_oil", "hydronic", "geothermal", "wood"],24  foundation: ["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"],25  structure: ["wood_frame", "steel", "concrete", "log", "masonry"],26  siding: ["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel"],27  flooring: ["hardwood", "engineered", "vinyl_plank", "laminate", "ceramic", "carpet"],28  pool: ["none", "above_ground", "inground"],29  condition: ["poor", "below_average", "average", "good", "very_good", "excellent", "renovated", "new"],30} as const;31const CONDITION_GROUPS = ["structure", "roof", "exterior", "windows", "interior", "kitchen", "bathroom", "mechanical", "electrical", "site"];3233/** Dimension fixe du vecteur (documentée) : 8+4×3+6+5+5+5+4+8+5+5+8+6+3 = 80 catégoriels + 12 numériques + 10 conditions = 102. */34export const EMBEDDING_DIMENSION = 8 + 4 * 3 + 6 + 5 + 5 + 5 + 4 + 8 + 5 + 5 + 8 + 6 + 3 + 12 + 10;3536function oneHot(list: readonly string[], v: string | null | undefined): number[] {37  return list.map((x) => (x === v ? 1 : 0));38}39function shares(list: readonly string[], m: Record<string, number | undefined> | null): number[] {40  return list.map((x) => Math.max(0, Math.min(1, m?.[x] ?? 0)));41}4243export function canonicalText(m: MergedFacts): string {44  const t = m.buildingType.value.replace(/_/g, " ");45  const parts = [46    `${t.charAt(0).toUpperCase()}${t.slice(1)}.`,47    m.yearBuilt.value ? `${m.yearBuilt.value}.` : "Year unknown.",48    `${Math.round(m.grossFloorAreaSqft.value)} sqft.`,49    `${Math.round(m.stories.value)} ${m.stories.value > 1 ? "stories" : "story"}.`,50    `${m.structure.value.replace(/_/g, " ")}.`,51    `${m.foundation.value.replace(/_/g, " ")} foundation.`,52    `${Object.entries(m.siding.value).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)).map(([k, v]) => `${k.replace(/_/g, " ")} ${Math.round((v ?? 0) * 100)}%`).join(", ")} exterior.`,53    `${m.roof.value.replace(/_/g, " ")} ${m.roofGeometry.value} roof.`,54    m.garage.value.type === "none" ? "No garage." : `${m.garage.value.type} ${m.garage.value.spaces}-car garage.`,55    `${m.basement.value} basement${m.basementFinishedPct.value ? ` ${Math.round(m.basementFinishedPct.value * 100)}% finished` : ""}.`,56    `${m.kitchenQuality.value} kitchen.`,57    `${m.bathrooms.value} bathrooms${m.powderRooms.value ? `, ${m.powderRooms.value} powder rooms` : ""}.`,58    `${m.bathroomQuality.value} bathrooms quality.`,59    Object.keys(m.flooring.value ?? {}).length ? `${Object.entries(m.flooring.value).sort((a, b) => b[1] - a[1]).map(([k]) => k.replace(/_/g, " ")).join(" and ")} floors.` : "",60    `${m.heating.value.replace(/_/g, " ")}${m.hasAirConditioning.value ? ", air conditioning" : ""}${m.hasAirExchanger.value ? ", air exchanger" : ""}.`,61    `${m.windows.value} windows.`,62    m.deckSqft.value ? `Deck ${Math.round(m.deckSqft.value)} sqft.` : "",63    m.pool.value !== "none" ? `${m.pool.value.replace(/_/g, " ")} pool.` : "",64    `Overall quality ${m.quality.value}.`,65    Object.keys(m.conditions).length ? `Condition ${Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(", ")}.` : "",66  ];67  return parts.filter(Boolean).join("\n");68}6970export function technicalVector(m: MergedFacts): number[] {71  const condIdx = (g: string) => { const c = m.conditions[g]; const i = c ? CATS.condition.indexOf(c as (typeof CATS.condition)[number]) : -1; return i < 0 ? 0.5 : i / (CATS.condition.length - 1); };72  const v = [73    ...oneHot(CATS.buildingType, m.buildingType.value),74    ...oneHot(CATS.quality, m.quality.value), ...oneHot(CATS.quality, m.kitchenQuality.value), ...oneHot(CATS.quality, m.bathroomQuality.value),75    ...oneHot(CATS.basement, m.basement.value), ...oneHot(CATS.garage, m.garage.value.type), ...oneHot(CATS.roof, m.roof.value), ...oneHot(CATS.roofGeometry, m.roofGeometry.value),76    ...oneHot(CATS.windows, m.windows.value), ...oneHot(CATS.heating, m.heating.value), ...oneHot(CATS.foundation, m.foundation.value), ...oneHot(CATS.structure, m.structure.value),77    ...shares(CATS.siding, m.siding.value as Record<string, number | undefined>), ...shares(CATS.flooring, m.flooring.value), ...oneHot(CATS.pool, m.pool.value),78    // numériques normalisés79    Math.min(1, Math.log10(Math.max(200, m.grossFloorAreaSqft.value)) / 4.5), Math.min(1, m.stories.value / 4), Math.min(1, ((new Date().getFullYear() - (m.yearBuilt.value ?? 1980)) || 0) / 120),80    Math.min(1, m.bathrooms.value / 5), Math.min(1, m.powderRooms.value / 3), Math.min(1, (m.bedrooms.value ?? 3) / 6), Math.min(1, m.deckSqft.value / 1000), Math.min(1, m.drivewaySqft.value / 2000),81    m.hasAirConditioning.value ? 1 : 0, m.hasAirExchanger.value ? 1 : 0, Math.min(1, m.basementFinishedPct.value), Math.min(1, m.garage.value.spaces / 3),82    ...CONDITION_GROUPS.map(condIdx),83  ];84  if (v.length !== EMBEDDING_DIMENSION) throw new Error(`dimension du vecteur ${v.length} ≠ ${EMBEDDING_DIMENSION}`);85  return v.map((x) => Math.round(x * 10000) / 10000);86}8788export const technicalEmbedder: Embedder = { model: EMBEDDING_MODEL, embed: (_t, facts) => technicalVector(facts) };8990export function cosine(a: number[], b: number[]): number {91  let dot = 0, na = 0, nb = 0;92  for (let i = 0; i < Math.min(a.length, b.length); i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }93  return na && nb ? dot / Math.sqrt(na * nb) : 0;94}9596/** Vecteur de structure de coût (§201) : parts normalisées par grande famille. */97export function costProfileVector(categories: { category: string; adjusted: number }[]): Record<string, number> {98  const fam: Record<string, string[]> = { foundation: ["site", "excavation", "foundation"], structure: ["structure"], envelope: ["roofing", "envelope", "openings", "insulation", "finishes_ext"], finish: ["interior", "kitchen", "bathroom", "basement"], mechanical: ["plumbing", "hvac", "electrical"], exterior: ["garage", "exterior"] };99  const total = categories.reduce((s, c) => s + c.adjusted, 0) || 1;100  const out: Record<string, number> = {};101  for (const [k, cats] of Object.entries(fam)) out[k] = Math.round((1000 * categories.filter((c) => cats.includes(c.category)).reduce((s, c) => s + c.adjusted, 0)) / total) / 1000;102  return out;103}104