TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
1// UQO Éval — modélisation hédonique : de l'échantillon à la matrice de design.2import { allMunicipalities } from "../municipalites";3import type { Obs } from "./data";4import { zeros, type Mat } from "./linalg";5import { LIMITS, type HedonicSpec, type VarKey } from "./spec";67export type ColKind = "intercept" | "cont" | "dummy" | "fe_time" | "fe_geo" | "trend";89export interface Column {10 name: string; // identifiant stable (ex. ln_aire, type=condo, t=2024-T2, g=Gatineau)11 label: { fr: string; en: string };12 kind: ColKind;13 var?: VarKey | "age2";14 level?: string;15 base?: string; // niveau de référence (dummies, effets fixes)16 log?: boolean;17 n?: number; // effectif du niveau (dummies)18}1920export interface Design {21 X: Mat;22 y: Float64Array;23 cols: Column[];24 obs: Obs[];25 dropped: { reason: { fr: string; en: string }; n: number }[];26 clusters: Int32Array; // identifiant de grappe (zone géo) par observation27 nClusters: number;28 timeLevels: { level: string; n: number; base: boolean }[];29 geoLevels: { level: string; n: number; base: boolean }[];30 medians: Record<string, number>; // médianes des variables continues brutes (prédicteur)31 contVars: { var: VarKey | "age2"; col: number; log: boolean }[];32 center: { lat: number; lng: number };33}3435const LABELS: Record<string, { fr: string; en: string }> = {36 aire: { fr: "Aire d'étages (m²)", en: "Floor area (m²)" },37 terrain: { fr: "Terrain (m²)", en: "Lot (m²)" },38 age: { fr: "Âge (ans)", en: "Age (years)" },39 age2: { fr: "Âge² (÷100)", en: "Age² (÷100)" },40 nb_logements: { fr: "Nb logements", en: "Dwellings" },41 nb_etages: { fr: "Nb étages", en: "Storeys" },42 dist_centre: { fr: "Distance au centre (km)", en: "Distance to centre (km)" },43 valeur_role: { fr: "Valeur au rôle ($)", en: "Assessed value ($)" },44 bedrooms: { fr: "Chambres", en: "Bedrooms" },45 bathrooms: { fr: "Salles de bain", en: "Bathrooms" },46 type: { fr: "Type", en: "Type" },47 genre: { fr: "Genre", en: "Style" },48 lien: { fr: "Lien", en: "Link" },49};5051const CONT_VARS: VarKey[] = ["aire", "terrain", "age", "nb_logements", "nb_etages", "dist_centre", "valeur_role", "bedrooms", "bathrooms"];52const CAT_VARS: VarKey[] = ["type", "genre", "lien"];5354function contValue(o: Obs, v: VarKey, center: Map<string, { lat: number; lng: number }>): number | null {55 switch (v) {56 case "aire":57 return o.aire;58 case "terrain":59 return o.terrain;60 case "age":61 return o.annee ? o.year - o.annee : null;62 case "nb_logements":63 return o.nb_logements != null && o.nb_logements > 0 ? o.nb_logements : null;64 case "nb_etages":65 return o.nb_etages != null && o.nb_etages > 0 ? o.nb_etages : null;66 case "dist_centre": {67 const c = o.muni ? center.get(o.muni.toLowerCase()) : undefined;68 if (!c) return null;69 const dLat = (o.lat - c.lat) * 111;70 const dLng = (o.lng - c.lng) * 111 * Math.cos((c.lat * Math.PI) / 180);71 return Math.max(0.05, Math.sqrt(dLat * dLat + dLng * dLng));72 }73 case "valeur_role":74 return o.valeur_role;75 case "bedrooms":76 return o.bedrooms != null && o.bedrooms >= 0 ? o.bedrooms : null;77 case "bathrooms":78 return o.bathrooms != null && o.bathrooms >= 0 ? o.bathrooms : null;79 default:80 return null;81 }82}8384function catValue(o: Obs, v: VarKey): string {85 const raw = v === "type" ? o.type : v === "genre" ? o.genre : o.lien;86 return raw && raw.trim() ? raw.trim() : "inconnu";87}8889function geoKey(o: Obs, spec: HedonicSpec): string {90 switch (spec.fe.geo) {91 case "muni":92 return o.muni ?? "?";93 case "arrond":94 return o.arrond ? `${o.muni ?? "?"} · ${o.arrond}` : (o.muni ?? "?");95 case "voisinage":96 return o.voisinage ? `${o.muni ?? "?"} · UV ${o.voisinage}` : `${o.muni ?? "?"} · UV ?`;97 case "grid1":98 case "grid2":99 case "grid5": {100 const km = spec.fe.geo === "grid1" ? 1 : spec.fe.geo === "grid2" ? 2 : 5;101 const dLat = km / 111;102 const dLng = km / (111 * Math.cos((o.lat * Math.PI) / 180));103 const clat = (Math.floor(o.lat / dLat) + 0.5) * dLat;104 const clng = (Math.floor(o.lng / dLng) + 0.5) * dLng;105 return `${km} km @ ${clat.toFixed(3)}, ${clng.toFixed(3)}`;106 }107 default:108 return o.muni ?? "?";109 }110}111112function timeKey(o: Obs, spec: HedonicSpec): string {113 return spec.fe.time === "year" ? String(o.year) : spec.fe.time === "quarter" ? o.quarter : o.month;114}115116/** Niveaux d'une variable catégorielle : base = plus fréquent, niveaux rares → « autre », plafond. */117function levelize(values: string[], maxLevels: number, minCount: number, otherLabel: string) {118 const counts = new Map<string, number>();119 for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);120 const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);121 const keep = new Set(sorted.filter(([, n], i) => n >= minCount && i < maxLevels - 1).map(([l]) => l));122 const mapped = values.map((v) => (keep.has(v) ? v : otherLabel));123 const c2 = new Map<string, number>();124 for (const v of mapped) c2.set(v, (c2.get(v) ?? 0) + 1);125 const levels = [...c2.entries()].sort((a, b) => b[1] - a[1]);126 return { mapped, levels: levels.map(([level, n], i) => ({ level, n, base: i === 0 })), base: levels[0]?.[0] ?? otherLabel };127}128129export function buildDesign(obs0: Obs[], spec: HedonicSpec): Design {130 const dropped: Design["dropped"] = [];131 const centerMap = new Map<string, { lat: number; lng: number }>();132 for (const m of allMunicipalities()) centerMap.set(m.nom.toLowerCase(), { lat: m.lat, lng: m.lng });133134 const vars = spec.vars.filter((v, i, a) => a.indexOf(v) === i);135 const cont = CONT_VARS.filter((v) => vars.includes(v));136 const cats = CAT_VARS.filter((v) => vars.includes(v));137 const logSet = new Set(spec.logVars.filter((v) => cont.includes(v)));138 const useAge2 = spec.ageSquared && cont.includes("age");139 const useTrend = vars.includes("trend_xy");140141 // 1. observations complètes pour les variables continues retenues142 let obs = obs0;143 for (const v of cont) {144 const before = obs.length;145 obs = obs.filter((o) => {146 const x = contValue(o, v, centerMap);147 return x != null && Number.isFinite(x) && (!logSet.has(v) || x > 0);148 });149 if (before - obs.length) dropped.push({ reason: { fr: `${LABELS[v].fr} manquante`, en: `${LABELS[v].en} missing` }, n: before - obs.length });150 }151 const n = obs.length;152 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`);153154 // 2. colonnes155 const cols: Column[] = [{ name: "const", label: { fr: "Constante", en: "Intercept" }, kind: "intercept" }];156 const contVars: Design["contVars"] = [];157 const medians: Record<string, number> = {};158 for (const v of cont) {159 const lg = logSet.has(v);160 contVars.push({ var: v, col: cols.length, log: lg });161 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 });162 const vals = obs.map((o) => contValue(o, v, centerMap)!).sort((a, b) => a - b);163 medians[v] = vals[Math.floor(vals.length / 2)];164 }165 if (useAge2) {166 contVars.push({ var: "age2", col: cols.length, log: false });167 cols.push({ name: "age2", label: LABELS.age2, kind: "cont", var: "age2" });168 }169 const meanLat = obs.reduce((s, o) => s + o.lat, 0) / n;170 const meanLng = obs.reduce((s, o) => s + o.lng, 0) / n;171 if (useTrend) {172 for (const [name, fr, en] of [173 ["trend_x", "Tendance est-ouest (×10 km)", "East-west trend (×10 km)"],174 ["trend_y", "Tendance nord-sud (×10 km)", "North-south trend (×10 km)"],175 ["trend_x2", "Tendance x²", "Trend x²"],176 ["trend_y2", "Tendance y²", "Trend y²"],177 ["trend_xy", "Tendance x·y", "Trend x·y"],178 ] as const)179 cols.push({ name, label: { fr, en }, kind: "trend", var: "trend_xy" });180 }181 const catLevels: { v: VarKey; mapped: string[]; base: string; first: number }[] = [];182 for (const v of cats) {183 const lv = levelize(obs.map((o) => catValue(o, v)), 12, Math.max(5, Math.round(n * 0.005)), "autre");184 const first = cols.length;185 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 });186 catLevels.push({ v, mapped: lv.mapped, base: lv.base, first });187 }188 let timeLevels: Design["timeLevels"] = [];189 let timeMapped: string[] = [];190 let timeBase = "";191 if (spec.fe.time !== "none") {192 const lv = levelize(obs.map((o) => timeKey(o, spec)), 80, 1, "autre");193 // base temporelle = période la plus ancienne (indice = 100 au départ)194 const chrono = [...lv.levels].sort((a, b) => a.level.localeCompare(b.level));195 timeBase = chrono[0].level;196 timeLevels = chrono.map((l) => ({ ...l, base: l.level === timeBase }));197 timeMapped = lv.mapped;198 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 });199 }200 let geoLevels: Design["geoLevels"] = [];201 let geoMapped: string[] = obs.map((o) => o.muni ?? "?");202 let geoBase = "";203 if (spec.fe.geo !== "none") {204 const lv = levelize(obs.map((o) => geoKey(o, spec)), LIMITS.maxGeoLevels, 3, "autres zones");205 geoLevels = lv.levels;206 geoMapped = lv.mapped;207 geoBase = lv.base;208 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 });209 }210 if (cols.length > LIMITS.maxCols) throw new Error(`trop de colonnes (${cols.length} > ${LIMITS.maxCols}) — réduisez la granularité des effets fixes`);211212 // 3. remplissage213 const k = cols.length;214 const X = zeros(n, k);215 const y = new Float64Array(n);216 const colIndex = new Map(cols.map((c, i) => [c.name, i]));217 for (let i = 0; i < n; i++) {218 const o = obs[i];219 const off = i * k;220 X.a[off] = 1;221 for (const cv of contVars) {222 if (cv.var === "age2") {223 const a = contValue(o, "age", centerMap)!;224 X.a[off + cv.col] = (a * a) / 100;225 } else {226 const x = contValue(o, cv.var, centerMap)!;227 X.a[off + cv.col] = cv.log ? Math.log(x) : x;228 }229 }230 if (useTrend) {231 const dx = ((o.lng - meanLng) * 111 * Math.cos((meanLat * Math.PI) / 180)) / 10;232 const dy = ((o.lat - meanLat) * 111) / 10;233 X.a[off + colIndex.get("trend_x")!] = dx;234 X.a[off + colIndex.get("trend_y")!] = dy;235 X.a[off + colIndex.get("trend_x2")!] = dx * dx;236 X.a[off + colIndex.get("trend_y2")!] = dy * dy;237 X.a[off + colIndex.get("trend_xy")!] = dx * dy;238 }239 for (const cl of catLevels) {240 const lvl = cl.mapped[i];241 if (lvl !== cl.base) {242 const j = colIndex.get(`${cl.v}=${lvl}`);243 if (j != null) X.a[off + j] = 1;244 }245 }246 if (timeMapped.length && timeMapped[i] !== timeBase) {247 const j = colIndex.get(`t=${timeMapped[i]}`);248 if (j != null) X.a[off + j] = 1;249 }250 if (spec.fe.geo !== "none" && geoMapped[i] !== geoBase) {251 const j = colIndex.get(`g=${geoMapped[i]}`);252 if (j != null) X.a[off + j] = 1;253 }254 y[i] = spec.depvar === "lnprice" ? Math.log(o.price) : o.price;255 }256257 // 4. grappes (zone géo ; sinon municipalité)258 const clusterIds = new Map<string, number>();259 const clusters = new Int32Array(n);260 for (let i = 0; i < n; i++) {261 const key = geoMapped[i];262 if (!clusterIds.has(key)) clusterIds.set(key, clusterIds.size);263 clusters[i] = clusterIds.get(key)!;264 }265266 return { X, y, cols, obs, dropped, clusters, nClusters: clusterIds.size, timeLevels, geoLevels, medians, contVars, center: { lat: meanLat, lng: meanLng } };267}268