aboutsummaryrefslogtreecommitdiffstats
path: root/app/models.js
diff options
context:
space:
mode:
Diffstat (limited to 'app/models.js')
-rw-r--r--app/models.js157
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));
}
}