// UQO Éval — modélisation hédonique : de l'échantillon à la matrice de design. import { allMunicipalities } from "../municipalites"; import type { Obs } from "./data"; import { zeros, type Mat } from "./linalg"; import { LIMITS, type HedonicSpec, type VarKey } from "./spec"; export type ColKind = "intercept" | "cont" | "dummy" | "fe_time" | "fe_geo" | "trend"; export interface Column { name: string; // identifiant stable (ex. ln_aire, type=condo, t=2024-T2, g=Gatineau) label: { fr: string; en: string }; kind: ColKind; var?: VarKey | "age2"; level?: string; base?: string; // niveau de référence (dummies, effets fixes) log?: boolean; n?: number; // effectif du niveau (dummies) } export interface Design { X: Mat; y: Float64Array; cols: Column[]; obs: Obs[]; dropped: { reason: { fr: string; en: string }; n: number }[]; clusters: Int32Array; // identifiant de grappe (zone géo) par observation nClusters: number; timeLevels: { level: string; n: number; base: boolean }[]; geoLevels: { level: string; n: number; base: boolean }[]; medians: Record; // médianes des variables continues brutes (prédicteur) contVars: { var: VarKey | "age2"; col: number; log: boolean }[]; center: { lat: number; lng: number }; } const LABELS: Record = { aire: { fr: "Aire d'étages (m²)", en: "Floor area (m²)" }, terrain: { fr: "Terrain (m²)", en: "Lot (m²)" }, age: { fr: "Âge (ans)", en: "Age (years)" }, age2: { fr: "Âge² (÷100)", en: "Age² (÷100)" }, nb_logements: { fr: "Nb logements", en: "Dwellings" }, nb_etages: { fr: "Nb étages", en: "Storeys" }, dist_centre: { fr: "Distance au centre (km)", en: "Distance to centre (km)" }, valeur_role: { fr: "Valeur au rôle ($)", en: "Assessed value ($)" }, bedrooms: { fr: "Chambres", en: "Bedrooms" }, bathrooms: { fr: "Salles de bain", en: "Bathrooms" }, type: { fr: "Type", en: "Type" }, genre: { fr: "Genre", en: "Style" }, lien: { fr: "Lien", en: "Link" }, }; const CONT_VARS: VarKey[] = ["aire", "terrain", "age", "nb_logements", "nb_etages", "dist_centre", "valeur_role", "bedrooms", "bathrooms"]; const CAT_VARS: VarKey[] = ["type", "genre", "lien"]; function contValue(o: Obs, v: VarKey, center: Map): number | null { switch (v) { case "aire": return o.aire; case "terrain": return o.terrain; case "age": return o.annee ? o.year - o.annee : null; case "nb_logements": return o.nb_logements != null && o.nb_logements > 0 ? o.nb_logements : null; case "nb_etages": return o.nb_etages != null && o.nb_etages > 0 ? o.nb_etages : null; case "dist_centre": { const c = o.muni ? center.get(o.muni.toLowerCase()) : undefined; if (!c) return null; const dLat = (o.lat - c.lat) * 111; const dLng = (o.lng - c.lng) * 111 * Math.cos((c.lat * Math.PI) / 180); return Math.max(0.05, Math.sqrt(dLat * dLat + dLng * dLng)); } case "valeur_role": return o.valeur_role; case "bedrooms": return o.bedrooms != null && o.bedrooms >= 0 ? o.bedrooms : null; case "bathrooms": return o.bathrooms != null && o.bathrooms >= 0 ? o.bathrooms : null; default: return null; } } function catValue(o: Obs, v: VarKey): string { const raw = v === "type" ? o.type : v === "genre" ? o.genre : o.lien; return raw && raw.trim() ? raw.trim() : "inconnu"; } function geoKey(o: Obs, spec: HedonicSpec): string { switch (spec.fe.geo) { case "muni": return o.muni ?? "?"; case "arrond": return o.arrond ? `${o.muni ?? "?"} · ${o.arrond}` : (o.muni ?? "?"); case "voisinage": return o.voisinage ? `${o.muni ?? "?"} · UV ${o.voisinage}` : `${o.muni ?? "?"} · UV ?`; case "grid1": case "grid2": case "grid5": { const km = spec.fe.geo === "grid1" ? 1 : spec.fe.geo === "grid2" ? 2 : 5; const dLat = km / 111; const dLng = km / (111 * Math.cos((o.lat * Math.PI) / 180)); const clat = (Math.floor(o.lat / dLat) + 0.5) * dLat; const clng = (Math.floor(o.lng / dLng) + 0.5) * dLng; return `${km} km @ ${clat.toFixed(3)}, ${clng.toFixed(3)}`; } default: return o.muni ?? "?"; } } function timeKey(o: Obs, spec: HedonicSpec): string { return spec.fe.time === "year" ? String(o.year) : spec.fe.time === "quarter" ? o.quarter : o.month; } /** Niveaux d'une variable catégorielle : base = plus fréquent, niveaux rares → « autre », plafond. */ function levelize(values: string[], maxLevels: number, minCount: number, otherLabel: string) { const counts = new Map(); for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1); const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]); const keep = new Set(sorted.filter(([, n], i) => n >= minCount && i < maxLevels - 1).map(([l]) => l)); const mapped = values.map((v) => (keep.has(v) ? v : otherLabel)); const c2 = new Map(); for (const v of mapped) c2.set(v, (c2.get(v) ?? 0) + 1); const levels = [...c2.entries()].sort((a, b) => b[1] - a[1]); return { mapped, levels: levels.map(([level, n], i) => ({ level, n, base: i === 0 })), base: levels[0]?.[0] ?? otherLabel }; } export function buildDesign(obs0: Obs[], spec: HedonicSpec): Design { const dropped: Design["dropped"] = []; const centerMap = new Map(); for (const m of allMunicipalities()) centerMap.set(m.nom.toLowerCase(), { lat: m.lat, lng: m.lng }); const vars = spec.vars.filter((v, i, a) => a.indexOf(v) === i); const cont = CONT_VARS.filter((v) => vars.includes(v)); const cats = CAT_VARS.filter((v) => vars.includes(v)); const logSet = new Set(spec.logVars.filter((v) => cont.includes(v))); const useAge2 = spec.ageSquared && cont.includes("age"); const useTrend = vars.includes("trend_xy"); // 1. observations complètes pour les variables continues retenues let obs = obs0; for (const v of cont) { const before = obs.length; obs = obs.filter((o) => { const x = contValue(o, v, centerMap); return x != null && Number.isFinite(x) && (!logSet.has(v) || x > 0); }); if (before - obs.length) dropped.push({ reason: { fr: `${LABELS[v].fr} manquante`, en: `${LABELS[v].en} missing` }, n: before - obs.length }); } const n = obs.length; if (n < LIMITS.minN) throw new Error(`échantillon trop petit après nettoyage (${n} < ${LIMITS.minN}) — élargissez la zone, la période ou retirez des variables`); // 2. colonnes const cols: Column[] = [{ name: "const", label: { fr: "Constante", en: "Intercept" }, kind: "intercept" }]; const contVars: Design["contVars"] = []; const medians: Record = {}; for (const v of cont) { const lg = logSet.has(v); contVars.push({ var: v, col: cols.length, log: lg }); cols.push({ name: lg ? `ln_${v}` : v, label: lg ? { fr: `ln ${LABELS[v].fr}`, en: `ln ${LABELS[v].en}` } : LABELS[v], kind: "cont", var: v, log: lg }); const vals = obs.map((o) => contValue(o, v, centerMap)!).sort((a, b) => a - b); medians[v] = vals[Math.floor(vals.length / 2)]; } if (useAge2) { contVars.push({ var: "age2", col: cols.length, log: false }); cols.push({ name: "age2", label: LABELS.age2, kind: "cont", var: "age2" }); } const meanLat = obs.reduce((s, o) => s + o.lat, 0) / n; const meanLng = obs.reduce((s, o) => s + o.lng, 0) / n; if (useTrend) { for (const [name, fr, en] of [ ["trend_x", "Tendance est-ouest (×10 km)", "East-west trend (×10 km)"], ["trend_y", "Tendance nord-sud (×10 km)", "North-south trend (×10 km)"], ["trend_x2", "Tendance x²", "Trend x²"], ["trend_y2", "Tendance y²", "Trend y²"], ["trend_xy", "Tendance x·y", "Trend x·y"], ] as const) cols.push({ name, label: { fr, en }, kind: "trend", var: "trend_xy" }); } const catLevels: { v: VarKey; mapped: string[]; base: string; first: number }[] = []; for (const v of cats) { const lv = levelize(obs.map((o) => catValue(o, v)), 12, Math.max(5, Math.round(n * 0.005)), "autre"); const first = cols.length; for (const l of lv.levels) if (!l.base) cols.push({ name: `${v}=${l.level}`, label: { fr: `${LABELS[v].fr} : ${l.level}`, en: `${LABELS[v].en}: ${l.level}` }, kind: "dummy", var: v, level: l.level, base: lv.base, n: l.n }); catLevels.push({ v, mapped: lv.mapped, base: lv.base, first }); } let timeLevels: Design["timeLevels"] = []; let timeMapped: string[] = []; let timeBase = ""; if (spec.fe.time !== "none") { const lv = levelize(obs.map((o) => timeKey(o, spec)), 80, 1, "autre"); // base temporelle = période la plus ancienne (indice = 100 au départ) const chrono = [...lv.levels].sort((a, b) => a.level.localeCompare(b.level)); timeBase = chrono[0].level; timeLevels = chrono.map((l) => ({ ...l, base: l.level === timeBase })); timeMapped = lv.mapped; for (const l of timeLevels) if (!l.base) cols.push({ name: `t=${l.level}`, label: { fr: `Période ${l.level}`, en: `Period ${l.level}` }, kind: "fe_time", level: l.level, base: timeBase, n: l.n }); } let geoLevels: Design["geoLevels"] = []; let geoMapped: string[] = obs.map((o) => o.muni ?? "?"); let geoBase = ""; if (spec.fe.geo !== "none") { const lv = levelize(obs.map((o) => geoKey(o, spec)), LIMITS.maxGeoLevels, 3, "autres zones"); geoLevels = lv.levels; geoMapped = lv.mapped; geoBase = lv.base; for (const l of geoLevels) if (!l.base) cols.push({ name: `g=${l.level}`, label: { fr: `Zone ${l.level}`, en: `Zone ${l.level}` }, kind: "fe_geo", level: l.level, base: geoBase, n: l.n }); } if (cols.length > LIMITS.maxCols) throw new Error(`trop de colonnes (${cols.length} > ${LIMITS.maxCols}) — réduisez la granularité des effets fixes`); // 3. remplissage const k = cols.length; const X = zeros(n, k); const y = new Float64Array(n); const colIndex = new Map(cols.map((c, i) => [c.name, i])); for (let i = 0; i < n; i++) { const o = obs[i]; const off = i * k; X.a[off] = 1; for (const cv of contVars) { if (cv.var === "age2") { const a = contValue(o, "age", centerMap)!; X.a[off + cv.col] = (a * a) / 100; } else { const x = contValue(o, cv.var, centerMap)!; X.a[off + cv.col] = cv.log ? Math.log(x) : x; } } if (useTrend) { const dx = ((o.lng - meanLng) * 111 * Math.cos((meanLat * Math.PI) / 180)) / 10; const dy = ((o.lat - meanLat) * 111) / 10; X.a[off + colIndex.get("trend_x")!] = dx; X.a[off + colIndex.get("trend_y")!] = dy; X.a[off + colIndex.get("trend_x2")!] = dx * dx; X.a[off + colIndex.get("trend_y2")!] = dy * dy; X.a[off + colIndex.get("trend_xy")!] = dx * dy; } for (const cl of catLevels) { const lvl = cl.mapped[i]; if (lvl !== cl.base) { const j = colIndex.get(`${cl.v}=${lvl}`); if (j != null) X.a[off + j] = 1; } } if (timeMapped.length && timeMapped[i] !== timeBase) { const j = colIndex.get(`t=${timeMapped[i]}`); if (j != null) X.a[off + j] = 1; } if (spec.fe.geo !== "none" && geoMapped[i] !== geoBase) { const j = colIndex.get(`g=${geoMapped[i]}`); if (j != null) X.a[off + j] = 1; } y[i] = spec.depvar === "lnprice" ? Math.log(o.price) : o.price; } // 4. grappes (zone géo ; sinon municipalité) const clusterIds = new Map(); const clusters = new Int32Array(n); for (let i = 0; i < n; i++) { const key = geoMapped[i]; if (!clusterIds.has(key)) clusterIds.set(key, clusterIds.size); clusters[i] = clusterIds.get(key)!; } return { X, y, cols, obs, dropped, clusters, nClusters: clusterIds.size, timeLevels, geoLevels, medians, contVars, center: { lat: meanLat, lng: meanLng } }; }