diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-16 08:17:40 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-11-16 08:17:40 +0200 |
| commit | b9eb804ae4f47974ad99025892dd477169809de1 (patch) | |
| tree | 10fff1f78ca9633191bd6a2c433b73ef12f5a801 | |
| parent | 02fedba64c93258044945b3d1ac5d1ecb6ab64c3 (diff) | |
| download | housing-b9eb804ae4f47974ad99025892dd477169809de1.tar.zst | |
Add hedonic pricing model
| -rw-r--r-- | app/models.js | 157 |
1 files changed, 67 insertions, 90 deletions
diff --git a/app/models.js b/app/models.js index 6ad5636..95f18fd 100644 --- a/app/models.js +++ b/app/models.js @@ -1045,117 +1045,94 @@ export class Weights { export class ScoringEngine { /** + * Hedonic Pricing Model + * PV = exp( β0 + Σ βi * Xi ) + * Score = 100 * PV / ListingPrice * @param {House} house * @param {Weights} weights - * @returns {number} 0–100 */ static calculate(house, weights) { - let score = 0; - let totalWeight = 0; - - // Price scoring (lower is better) - if (weights.price > 0) { - const priceScore = Math.max(0, 1 - house.price / 2_000_000); // Normalize to 2M - score += priceScore * weights.price; - totalWeight += weights.price; - } + // ---------------------------- + // 1. Collect feature values Xi + // ---------------------------- - // Construction year scoring (newer is better) - if (weights.constructionYear > 0 && house.constructionYear > 0) { - const currentYear = new Date().getFullYear(); - const yearScore = (house.constructionYear - 1900) / (currentYear - 1900); - score += yearScore * weights.constructionYear; - totalWeight += weights.constructionYear; - } + const X = {}; - // Living area scoring (bigger is better) - if (weights.livingArea > 0) { - const areaScore = Math.min(1, house.livingArea / 200); - score += areaScore * weights.livingArea; - totalWeight += weights.livingArea; - } + // Normalized price per sqm (lower is better → negative effect) + X.price = house.pricePerSqm > 0 ? -Math.log(house.pricePerSqm) : 0; - // Market distance scoring (closer is better) - if (weights.distanceMarket > 0 && house.geospatial.marketDistance > 0) { - const marketScore = Math.max(0, 1 - house.geospatial.marketDistance / 5000); - score += marketScore * weights.distanceMarket; - totalWeight += weights.distanceMarket; - } + // Construction year (newer better) + X.constructionYear = + house.constructionYear > 0 + ? (house.constructionYear - 1900) / (new Date().getFullYear() - 1900) + : 0; - // School distance scoring (closer is better) - if (weights.distanceSchool > 0 && house.geospatial.schoolDistance > 0) { - const schoolScore = Math.max(0, 1 - house.geospatial.schoolDistance / 5000); - score += schoolScore * weights.distanceSchool; - totalWeight += weights.distanceSchool; - } + // Living area (log-size effect typical in hedonic models) + X.livingArea = house.livingArea > 0 ? Math.log(house.livingArea) : 0; + + // Distances (closer better → negative sign) + X.distanceMarket = + house.geospatial?.marketDistance > 0 ? -Math.log(1 + house.geospatial.marketDistance) : 0; + + X.distanceSchool = + house.geospatial?.schoolDistance > 0 ? -Math.log(1 + house.geospatial.schoolDistance) : 0; - // Statistical area factors + X.distanceTrain = + house.geospatial?.distanceTrain > 0 ? -Math.log(1 + house.geospatial.distanceTrain) : 0; + + X.distanceLightRail = + house.geospatial?.distanceLightRail > 0 + ? -Math.log(1 + house.geospatial.distanceLightRail) + : 0; + + X.distanceTram = + house.geospatial?.distanceTram > 0 ? -Math.log(1 + house.geospatial.distanceTram) : 0; + + // Statistical area variables if (house.statisticalArea) { const sa = house.statisticalArea; - // Foreign speakers (lower is generally better) - if (weights.foreignSpeakers > 0) { - const foreignScore = Math.max(0, 1 - sa.foreignSpeakers); - score += foreignScore * weights.foreignSpeakers; - totalWeight += weights.foreignSpeakers; - } + X.foreignSpeakers = sa.foreignSpeakers != null ? -sa.foreignSpeakers : 0; - // Unemployment rate (lower is better) - if (weights.unemploymentRate > 0) { - const unemploymentScore = Math.max(0, 1 - sa.unemploymentRate / 100); - score += unemploymentScore * weights.unemploymentRate; - totalWeight += weights.unemploymentRate; - } + X.unemploymentRate = sa.unemploymentRate != null ? -(sa.unemploymentRate / 100) : 0; - // Average income (higher is better) - if (weights.averageIncome > 0) { - const incomeScore = Math.min(1, sa.averageIncome / 100000); - score += incomeScore * weights.averageIncome; - totalWeight += weights.averageIncome; - } + X.averageIncome = sa.averageIncome != null ? Math.log(sa.averageIncome) : 0; - // Higher education (higher is better) - if (weights.higherEducation > 0) { - const educationScore = sa.higherEducation; - score += educationScore * weights.higherEducation; - totalWeight += weights.higherEducation; - } + X.higherEducation = sa.higherEducation != null ? sa.higherEducation : 0; + } else { + X.foreignSpeakers = 0; + X.unemploymentRate = 0; + X.averageIncome = 0; + X.higherEducation = 0; } - // Transit distances (closer is better, but not too close) - if (weights.distanceTrain > 0 && house.geospatial.distanceTrain > 0) { - const trainScore = ScoringEngine.calculateTransitScore(house.geospatial.distanceTrain); - score += trainScore * weights.distanceTrain; - totalWeight += weights.distanceTrain; - } + // ---------------------------- + // 2. Compute hedonic log-value + // ---------------------------- - if (weights.distanceLightRail > 0 && house.geospatial.distanceLightRail > 0) { - const lightRailScore = ScoringEngine.calculateTransitScore( - house.geospatial.distanceLightRail, - ); - score += lightRailScore * weights.distanceLightRail; - totalWeight += weights.distanceLightRail; - } + let logPV = 0; - if (weights.distanceTram > 0 && house.geospatial.distanceTram > 0) { - const tramScore = ScoringEngine.calculateTransitScore(house.geospatial.distanceTram); - score += tramScore * weights.distanceTram; - totalWeight += weights.distanceTram; + for (const [key, value] of Object.entries(X)) { + const β = weights[key]; + if (typeof β === "number" && β !== 0) { + logPV += β * value; + } } - return totalWeight > 0 ? (score / totalWeight) * 100 : 0; - } + // ---------------------------- + // 3. Convert to perceived value + // ---------------------------- - /** - * Calculate transit distance score (optimal around 500m-1000m) - * @param {number} distance in meters - * @returns {number} score 0-1 - */ - static calculateTransitScore(distance) { - if (distance < 100) return 0.3; // Too close - noise and congestion - if (distance < 500) return 0.8; // Good walking distance - if (distance < 1000) return 1.0; // Optimal - if (distance < 2000) return 0.6; // Acceptable - return 0.2; // Too far + const PV = Math.exp(logPV); + + // ---------------------------- + // 4. Compare with listing price + // ---------------------------- + + if (!house.price || house.price <= 0) return 0; + + const desirability = (PV / house.price) * 100; + + return Math.max(0, Math.min(100, desirability)); } } |
