Upload 5 files
Browse files- Dockerfile +7 -0
- README.md +43 -8
- app.py +295 -0
- model.joblib +3 -0
- requirements.txt +5 -0
Dockerfile
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY . .
|
| 6 |
+
EXPOSE 7860
|
| 7 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,12 +1,47 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
| 8 |
-
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ZH Apartment Rent Predictor
|
| 3 |
+
emoji: 🏠
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# ZH Apartment Rent Predictor
|
| 12 |
+
|
| 13 |
+
Predicts monthly rent for apartments in the Canton of Zurich. Built for AI Applications HS24.
|
| 14 |
+
|
| 15 |
+
## Data
|
| 16 |
+
- 804 real listings from the canton of Zurich
|
| 17 |
+
- Enriched with BFS municipal data (population density, tax income, foreign resident share)
|
| 18 |
+
- 59 municipalities covered
|
| 19 |
+
|
| 20 |
+
## Preprocessing
|
| 21 |
+
1. Merged listing data with BFS municipal statistics on `bfs_number`
|
| 22 |
+
2. Extracted binary keyword flags from descriptions (Attika, Loft, Seesicht, Luxuriös, Pool, Exklusiv)
|
| 23 |
+
3. Area categorised into 3 buckets (< 50 m², 50–99 m², 100+ m²)
|
| 24 |
+
4. Added `zurich_city` flag for city of Zurich listings
|
| 25 |
+
5. Log-transformed `pop_dens` and `tax_income` to reduce skew
|
| 26 |
+
6. Derived `room_per_m2` and `area_per_room` ratios
|
| 27 |
+
7. Added `has_premium_keyword` (new feature) — 1 if any luxury keyword present
|
| 28 |
+
8. All numeric features scaled with `RobustScaler`
|
| 29 |
+
|
| 30 |
+
## Models & Results (5-fold CV)
|
| 31 |
+
|
| 32 |
+
| Iteration | Model | MAE | RMSE | R² |
|
| 33 |
+
|---|---|---|---|---|
|
| 34 |
+
| 1 | Ridge (α=10) | 446 | 671 | 0.607 |
|
| 35 |
+
| 1 | Lasso (α=10) | 448 | 678 | 0.599 |
|
| 36 |
+
| 2 | Random Forest | 439 | 659 | 0.618 |
|
| 37 |
+
| **2** | **Gradient Boosting** ✓ | **422** | **641** | **0.638** |
|
| 38 |
+
|
| 39 |
+
Final model: `GradientBoostingRegressor(n_estimators=400, learning_rate=0.04, max_depth=5, subsample=0.8)`
|
| 40 |
+
|
| 41 |
+
## New Feature
|
| 42 |
+
`has_premium_keyword` — binary flag consolidating six sparse keyword columns into one stable signal. Ranks in the top 10 most important features.
|
| 43 |
+
|
| 44 |
+
## Files
|
| 45 |
+
- `app.py` — Flask app + HTML (all data baked in, no extra JSON files needed)
|
| 46 |
+
- `model.joblib` — trained model
|
| 47 |
+
- `Dockerfile` / `requirements.txt` — deployment config
|
app.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import joblib
|
| 5 |
+
from flask import Flask, request, jsonify
|
| 6 |
+
|
| 7 |
+
app = Flask(__name__)
|
| 8 |
+
model = joblib.load("model.joblib")
|
| 9 |
+
|
| 10 |
+
FEATURE_COLS = ['rooms', 'area', 'pop_dens', 'frg_pct', 'tax_income', 'room_per_m2', 'luxurious', 'temporary', 'furnished', 'area_cat_ecoded', 'zurich_city', '(ATTIKA)', '(LOFT)', '(SEESICHT)', '(LUXURIÖS)', '(POOL)', '(EXKLUSIV)', 'log_pop_dens', 'log_tax_income', 'area_per_room', 'has_premium_keyword']
|
| 11 |
+
|
| 12 |
+
TOWNS = [{"town": "Adlikon b. Regensdorf", "median_price": 2272.5, "count": 6, "lat": 47.451836903889976, "lon": 8.464237054189047, "tax_income": 73522.0, "pop_dens": 1268.1258549932, "frg_pct": 35.5717367853}, {"town": "Adliswil", "median_price": 2085.0, "count": 7, "lat": 47.31021772112165, "lon": 8.522809846060616, "tax_income": 89522.0, "pop_dens": 2438.0952380952, "frg_pct": 37.7850506757}, {"town": "Affoltern am Albis", "median_price": 2089.0, "count": 7, "lat": 47.278944287981304, "lon": 8.45288862500872, "tax_income": 72583.0, "pop_dens": 1161.7563739377, "frg_pct": 28.7003169959}, {"town": "Andelfingen", "median_price": 1740.0, "count": 3, "lat": 47.594041188557945, "lon": 8.679091453552246, "tax_income": 88960.0, "pop_dens": 333.3827893175, "frg_pct": 13.3511348465}, {"town": "Bassersdorf", "median_price": 2927.5, "count": 4, "lat": 47.43820285797119, "lon": 8.633100032806396, "tax_income": 79477.0, "pop_dens": 1309.4130675526, "frg_pct": 24.949255751}, {"town": "Br\u00fcttisellen", "median_price": 2130.0, "count": 3, "lat": 47.42356745402018, "lon": 8.633115450541178, "tax_income": 84772.0, "pop_dens": 1005.9343434343, "frg_pct": 27.325216518099996}, {"town": "B\u00fclach", "median_price": 2150.0, "count": 15, "lat": 47.519343566894534, "lon": 8.5398281733195, "tax_income": 78194.0, "pop_dens": 1328.2784338098, "frg_pct": 28.0320044919}, {"town": "Dielsdorf", "median_price": 1825.0, "count": 5, "lat": 47.479800415039065, "lon": 8.454447555541993, "tax_income": 75326.0, "pop_dens": 1016.3543441227, "frg_pct": 31.847133757999995}, {"town": "Dietikon", "median_price": 2143.5, "count": 28, "lat": 47.40354074750628, "lon": 8.404910496303014, "tax_income": 71348.0, "pop_dens": 2143.7092144887497, "frg_pct": 35.57692254225}, {"town": "Dietlikon", "median_price": 2940.0, "count": 10, "lat": 47.42664413452148, "lon": 8.620266914367676, "tax_income": 84564.5, "pop_dens": 2234.7808319589503, "frg_pct": 28.390703560650003}, {"town": "D\u00fcbendorf", "median_price": 2230.0, "count": 11, "lat": 47.40156243064187, "lon": 8.612197962674228, "tax_income": 79563.0, "pop_dens": 2151.4684287812, "frg_pct": 35.9451250725}, {"town": "Eglisau", "median_price": 2660.0, "count": 3, "lat": 47.575480143229164, "lon": 8.517463684082031, "tax_income": 87906.0, "pop_dens": 589.293598234, "frg_pct": 24.143097958399995}, {"town": "Elsau", "median_price": 2525.0, "count": 4, "lat": 47.504170417785645, "lon": 8.802351474761963, "tax_income": 79356.0, "pop_dens": 451.9206939281, "frg_pct": 17.3293117631}, {"town": "Embrach", "median_price": 2400.0, "count": 4, "lat": 47.509860038757324, "lon": 8.595427751541138, "tax_income": 73330.0, "pop_dens": 741.811023622, "frg_pct": 28.0437320879}, {"town": "Fehraltorf", "median_price": 1740.0, "count": 5, "lat": 47.38572616577149, "lon": 8.757724952697753, "tax_income": 80449.0, "pop_dens": 684.7940865892, "frg_pct": 18.2266769468}, {"town": "Feuerthalen", "median_price": 1490.0, "count": 5, "lat": 47.69002685546875, "lon": 8.644001007080078, "tax_income": 76356.0, "pop_dens": 1457.2580645161, "frg_pct": 22.4958494743}, {"town": "F\u00e4llanden", "median_price": 2850.0, "count": 3, "lat": 47.374595642089844, "lon": 8.637135823567709, "tax_income": 90510.0, "pop_dens": 1361.9122257053, "frg_pct": 24.2145241109}, {"town": "Glattbrugg", "median_price": 1386.0, "count": 6, "lat": 47.434794108072914, "lon": 8.567777792612711, "tax_income": 71979.0, "pop_dens": 3759.2128801431004, "frg_pct": 44.9129151994}, {"town": "Gossau ZH", "median_price": 1875.0, "count": 10, "lat": 47.30550994873047, "lon": 8.767629241943359, "tax_income": 80977.0, "pop_dens": 562.9791894852, "frg_pct": 14.5719844358}, {"town": "Greifensee", "median_price": 1860.0, "count": 3, "lat": 47.369075775146484, "lon": 8.683858553568522, "tax_income": 89901.0, "pop_dens": 2372.2466960352, "frg_pct": 20.1671309192}, {"town": "Hedingen", "median_price": 2620.0, "count": 4, "lat": 47.29815101623535, "lon": 8.450382471084595, "tax_income": 94456.0, "pop_dens": 578.5604900459, "frg_pct": 16.4107993647}, {"town": "Hinwil", "median_price": 2267.5, "count": 4, "lat": 47.30254364013672, "lon": 8.83609652519226, "tax_income": 72124.0, "pop_dens": 506.1490125673, "frg_pct": 17.105613195}, {"town": "Hombrechtikon", "median_price": 1812.0, "count": 8, "lat": 47.251842975616455, "lon": 8.775048613548279, "tax_income": 79414.0, "pop_dens": 720.4433497537, "frg_pct": 20.6723646724}, {"town": "Kloten", "median_price": 2354.0, "count": 26, "lat": 47.445955129770134, "lon": 8.58423034961407, "tax_income": 71103.5, "pop_dens": 2401.9209185355, "frg_pct": 39.175285220149995}, {"town": "Langnau am Albis", "median_price": 3100.0, "count": 3, "lat": 47.287497202555336, "lon": 8.529908498128256, "tax_income": 96048.0, "pop_dens": 891.2341407151001, "frg_pct": 27.7856865536}, {"town": "Lufingen", "median_price": 2790.0, "count": 3, "lat": 47.48583094278971, "lon": 8.591005643208822, "tax_income": 94684.0, "pop_dens": 466.60231660229994, "frg_pct": 19.1146048821}, {"town": "Meilen", "median_price": 2500.0, "count": 3, "lat": 47.27062861124674, "lon": 8.64123821258545, "tax_income": 139797.0, "pop_dens": 1200.5862646566, "frg_pct": 23.1880013952}, {"town": "M\u00e4nnedorf", "median_price": 2500.0, "count": 3, "lat": 47.256217956542976, "lon": 8.689555168151855, "tax_income": 103577.0, "pop_dens": 2381.3417190776, "frg_pct": 20.3803151686}, {"town": "Neerach", "median_price": 2240.0, "count": 4, "lat": 47.51153087615967, "lon": 8.47110652923584, "tax_income": 110502.0, "pop_dens": 525.6622516556, "frg_pct": 13.3228346457}, {"town": "Niederglatt ZH", "median_price": 1690.0, "count": 3, "lat": 47.492113749186196, "lon": 8.501464207967123, "tax_income": 74085.0, "pop_dens": 1386.9444444444, "frg_pct": 26.056479070700004}, {"town": "Niederhasli", "median_price": 2070.0, "count": 5, "lat": 47.48185348510742, "lon": 8.495015335083007, "tax_income": 72919.0, "pop_dens": 826.9911504425002, "frg_pct": 25.7998929909}, {"town": "Niederweningen", "median_price": 2700.0, "count": 5, "lat": 47.508734130859374, "lon": 8.37294273376465, "tax_income": 86604.0, "pop_dens": 448.61313868609994, "frg_pct": 17.0517409697}, {"town": "Oberengstringen", "median_price": 1839.5, "count": 8, "lat": 47.407087326049805, "lon": 8.464881896972656, "tax_income": 81948.0, "pop_dens": 3956.4740716056, "frg_pct": 33.2520893901}, {"town": "Oberglatt ZH", "median_price": 2010.0, "count": 5, "lat": 47.47789306640625, "lon": 8.518560981750488, "tax_income": 65147.0, "pop_dens": 876.1212121212, "frg_pct": 37.1748754842}, {"town": "Oetwil am See", "median_price": 1917.5, "count": 8, "lat": 47.27392053604126, "lon": 8.720972299575806, "tax_income": 70794.0, "pop_dens": 792.1440261866, "frg_pct": 29.2561983471}, {"town": "Oetwil an der Limmat", "median_price": 1740.0, "count": 5, "lat": 47.425846099853516, "lon": 8.40000286102295, "tax_income": 65532.0, "pop_dens": 2970.6638115632, "frg_pct": 46.2264830967}, {"town": "Opfikon", "median_price": 2418.0, "count": 5, "lat": 47.427667236328126, "lon": 8.566677856445313, "tax_income": 71979.0, "pop_dens": 3759.2128801431, "frg_pct": 44.9129151994}, {"town": "Pfaffhausen", "median_price": 2448.0, "count": 6, "lat": 47.36277389526367, "lon": 8.620045026143393, "tax_income": 103450.0, "pop_dens": 1035.3230729068, "frg_pct": 22.85907711085}, {"town": "Pfungen", "median_price": 1730.0, "count": 7, "lat": 47.51750564575195, "lon": 8.639920507158552, "tax_income": 71575.0, "pop_dens": 781.5631262525, "frg_pct": 24.8461538462}, {"town": "Pf\u00e4ffikon ZH", "median_price": 1700.0, "count": 5, "lat": 47.36850814819336, "lon": 8.782468223571778, "tax_income": 80310.0, "pop_dens": 623.4751409534, "frg_pct": 19.0891154226}, {"town": "Richterswil", "median_price": 2067.0, "count": 5, "lat": 47.20718002319336, "lon": 8.705493354797364, "tax_income": 90081.0, "pop_dens": 1810.3585657371, "frg_pct": 19.4762323944}, {"town": "Russikon", "median_price": 2571.0, "count": 3, "lat": 47.394126892089844, "lon": 8.774710337320963, "tax_income": 91275.0, "pop_dens": 308.7139845397, "frg_pct": 13.316640109300002}, {"town": "R\u00fcti ZH", "median_price": 1470.0, "count": 6, "lat": 47.258008321126304, "lon": 8.85670264561971, "tax_income": 66676.0, "pop_dens": 1221.2723658052, "frg_pct": 24.8412827609}, {"town": "Schlatt ZH", "median_price": 2700.0, "count": 3, "lat": 47.46413803100586, "lon": 8.845952033996582, "tax_income": 71604.0, "pop_dens": 87.9598662207, "frg_pct": 7.9847908745}, {"town": "Schlieren", "median_price": 2227.5, "count": 14, "lat": 47.400221143450054, "lon": 8.442373888833183, "tax_income": 65678.0, "pop_dens": 2851.4415781487, "frg_pct": 46.1178223618}, {"town": "Schwerzenbach", "median_price": 2940.0, "count": 3, "lat": 47.38499450683594, "lon": 8.64946715037028, "tax_income": 79405.0, "pop_dens": 1973.2824427481, "frg_pct": 27.1373307544}, {"town": "St\u00e4fa", "median_price": 3725.0, "count": 6, "lat": 47.24422264099121, "lon": 8.727231820424398, "tax_income": 103206.0, "pop_dens": 1713.3876600698998, "frg_pct": 19.3232776192}, {"town": "Thalwil", "median_price": 1900.0, "count": 5, "lat": 47.28451690673828, "lon": 8.573451232910156, "tax_income": 113194.0, "pop_dens": 3298.0, "frg_pct": 29.0313688737}, {"town": "Unterengstringen", "median_price": 2624.0, "count": 3, "lat": 47.410074869791664, "lon": 8.444167137145996, "tax_income": 96737.0, "pop_dens": 1177.8443113773, "frg_pct": 25.826131164199996}, {"town": "Uster", "median_price": 1875.0, "count": 18, "lat": 47.349968168470596, "lon": 8.719801743825277, "tax_income": 79839.0, "pop_dens": 1228.7469287469, "frg_pct": 23.0096837775}, {"town": "Volketswil", "median_price": 2490.0, "count": 7, "lat": 47.38727460588728, "lon": 8.66897201538086, "tax_income": 82135.0, "pop_dens": 1327.6353276353, "frg_pct": 24.597639485}, {"town": "Wald ZH", "median_price": 1610.0, "count": 4, "lat": 47.276848793029785, "lon": 8.911797046661377, "tax_income": 66453.0, "pop_dens": 399.525128611, "frg_pct": 26.5649762282}, {"town": "Wallisellen", "median_price": 3315.0, "count": 4, "lat": 47.41270351409912, "lon": 8.59876537322998, "tax_income": 86973.0, "pop_dens": 2623.2087227414, "frg_pct": 31.0135977674}, {"town": "Wetzikon ZH", "median_price": 2094.5, "count": 20, "lat": 47.32592258453369, "lon": 8.802443408966065, "tax_income": 68951.0, "pop_dens": 1486.6151100535, "frg_pct": 25.994397759100003}, {"town": "Winterthur", "median_price": 2167.0, "count": 96, "lat": 47.50012377897898, "lon": 8.735338846842447, "tax_income": 70966.0, "pop_dens": 1662.5973262818, "frg_pct": 24.535003932}, {"town": "Zollikerberg", "median_price": 2590.0, "count": 3, "lat": 47.34665171305338, "lon": 8.599929491678873, "tax_income": 171602.0, "pop_dens": 1664.5859872612, "frg_pct": 26.0580087243}, {"town": "Zollikon", "median_price": 4950.0, "count": 3, "lat": 47.343475341796875, "lon": 8.57410717010498, "tax_income": 171602.0, "pop_dens": 1664.5859872612, "frg_pct": 26.0580087243}, {"town": "Zweidlen", "median_price": 2090.0, "count": 4, "lat": 47.56949996948242, "lon": 8.4723961353302, "tax_income": 74507.0, "pop_dens": 424.1463414634, "frg_pct": 24.5351734713}, {"town": "Z\u00fcrich", "median_price": 2877.5, "count": 262, "lat": 47.38310058244312, "lon": 8.53003583427604, "tax_income": 85446.0, "pop_dens": 5229.091582652058, "frg_pct": 32.4584678868}]
|
| 13 |
+
|
| 14 |
+
ITER1 = [{"model": "Ridge (\u03b1=10)", "MAE": 445.8, "RMSE": 671.2, "R2": 0.607}, {"model": "Lasso (\u03b1=10)", "MAE": 448.3, "RMSE": 677.7, "R2": 0.5994}]
|
| 15 |
+
ITER2 = [{"model": "RandomForest (n=300, depth=12, min_leaf=4)", "MAE": 438.7, "RMSE": 659.4, "R2": 0.6183}, {"model": "GradientBoosting (n=400, lr=0.04, depth=5, sub=0.8)", "MAE": 422.1, "RMSE": 641.0, "R2": 0.6377}]
|
| 16 |
+
FI = {"area": 0.45728226097807934, "zurich_city": 0.11430298224257547, "pop_dens": 0.0780246036087376, "log_pop_dens": 0.054507714070176565, "rooms": 0.049764240815849056, "room_per_m2": 0.0436824204066582, "area_per_room": 0.04151442713929889, "tax_income": 0.03480695081719218, "log_tax_income": 0.029919650085885676, "has_premium_keyword": 0.027519254096562112}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@app.route("/predict", methods=["POST"])
|
| 20 |
+
def predict():
|
| 21 |
+
d = request.get_json(force=True)
|
| 22 |
+
town_name = d.get("town", "").strip()
|
| 23 |
+
ti = next((t for t in TOWNS if t["town"] == town_name), TOWNS[0])
|
| 24 |
+
area = float(d.get("area", 80))
|
| 25 |
+
rooms = float(d.get("rooms", 3))
|
| 26 |
+
premium = int(any([d.get("attika"), d.get("loft"), d.get("seesicht"),
|
| 27 |
+
d.get("luxurios"), d.get("pool"), d.get("exklusiv")]))
|
| 28 |
+
row = {
|
| 29 |
+
"rooms": rooms, "area": area,
|
| 30 |
+
"pop_dens": ti["pop_dens"], "frg_pct": ti["frg_pct"],
|
| 31 |
+
"tax_income": ti["tax_income"],
|
| 32 |
+
"room_per_m2": rooms / area if area else 0,
|
| 33 |
+
"luxurious": int(d.get("luxurios", 0)),
|
| 34 |
+
"temporary": int(d.get("temporary", 0)),
|
| 35 |
+
"furnished": int(d.get("furnished", 0)),
|
| 36 |
+
"area_cat_ecoded": 0 if area < 50 else (1 if area < 100 else 2),
|
| 37 |
+
"zurich_city": 1 if "rich" in town_name and ti["pop_dens"] > 2000 else 0,
|
| 38 |
+
"(ATTIKA)": int(d.get("attika", 0)),
|
| 39 |
+
"(LOFT)": int(d.get("loft", 0)),
|
| 40 |
+
"(SEESICHT)":int(d.get("seesicht", 0)),
|
| 41 |
+
"(LUXURIÖS)":int(d.get("luxurios", 0)),
|
| 42 |
+
"(POOL)": int(d.get("pool", 0)),
|
| 43 |
+
"(EXKLUSIV)":int(d.get("exklusiv", 0)),
|
| 44 |
+
"log_pop_dens": np.log1p(ti["pop_dens"]),
|
| 45 |
+
"log_tax_income": np.log1p(ti["tax_income"]),
|
| 46 |
+
"area_per_room": area / rooms if rooms else 0,
|
| 47 |
+
"has_premium_keyword": premium,
|
| 48 |
+
}
|
| 49 |
+
price = float(model.predict(pd.DataFrame([row])[FEATURE_COLS])[0])
|
| 50 |
+
price = max(500, round(price / 10) * 10)
|
| 51 |
+
return jsonify({"prediction": price,
|
| 52 |
+
"low": round(price * 0.88 / 10) * 10,
|
| 53 |
+
"high": round(price * 1.12 / 10) * 10,
|
| 54 |
+
"sqm": round(price / area)})
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.route("/")
|
| 58 |
+
def index():
|
| 59 |
+
return HTML
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
HTML = """<!DOCTYPE html>
|
| 63 |
+
<html lang="en">
|
| 64 |
+
<head>
|
| 65 |
+
<meta charset="UTF-8">
|
| 66 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 67 |
+
<title>ZH Rent Predictor</title>
|
| 68 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
| 69 |
+
<style>
|
| 70 |
+
*{box-sizing:border-box;margin:0;padding:0}
|
| 71 |
+
body{font-family:'Inter',sans-serif;font-size:14px;background:#f0f2f5;color:#111827;min-height:100vh}
|
| 72 |
+
.bar{background:#1e3a5f;color:#fff;padding:13px 20px;font-size:15px;font-weight:600;display:flex;align-items:center;gap:10px}
|
| 73 |
+
.bar small{font-weight:400;opacity:.5;font-size:12px}
|
| 74 |
+
.wrap{max-width:960px;margin:20px auto;padding:0 14px;display:grid;grid-template-columns:290px 1fr;gap:14px;align-items:start}
|
| 75 |
+
@media(max-width:680px){.wrap{grid-template-columns:1fr}}
|
| 76 |
+
.card{background:#fff;border:1px solid #e0e4eb;border-radius:6px;padding:16px;margin-bottom:12px}
|
| 77 |
+
.card:last-child{margin-bottom:0}
|
| 78 |
+
.card h3{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:#6b7280;margin-bottom:12px;padding-bottom:9px;border-bottom:1px solid #f3f4f6}
|
| 79 |
+
.f{margin-bottom:11px}.f:last-child{margin-bottom:0}
|
| 80 |
+
.fl{display:flex;justify-content:space-between;font-size:13px;color:#374151;font-weight:500;margin-bottom:5px}
|
| 81 |
+
.fl b{color:#1e3a5f}
|
| 82 |
+
select,input[type=text]{width:100%;padding:7px 9px;border:1px solid #d1d5db;border-radius:4px;font:13px 'Inter',sans-serif;color:#111827;background:#fff;outline:none}
|
| 83 |
+
select:focus,input[type=text]:focus{border-color:#1e3a5f}
|
| 84 |
+
input[type=range]{width:100%;accent-color:#1e3a5f;cursor:pointer}
|
| 85 |
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
| 86 |
+
.chk{display:flex;align-items:center;gap:6px;font-size:12px;color:#374151;padding:6px 8px;border:1px solid #e5e7eb;border-radius:4px;background:#f9fafb;cursor:pointer;user-select:none;transition:border-color .12s}
|
| 87 |
+
.chk:hover{border-color:#1e3a5f}.chk.on{border-color:#1e3a5f;background:#eff4ff}
|
| 88 |
+
.chk input{accent-color:#1e3a5f}
|
| 89 |
+
.btn{width:100%;padding:10px;background:#1e3a5f;color:#fff;border:none;border-radius:4px;font:600 14px 'Inter',sans-serif;cursor:pointer;margin-top:4px}
|
| 90 |
+
.btn:hover{background:#162e4d}.btn:disabled{opacity:.6;cursor:wait}
|
| 91 |
+
.hero{background:#1e3a5f;color:#fff;border-radius:6px;padding:20px 22px;margin-bottom:12px;min-height:120px;display:flex;align-items:center}
|
| 92 |
+
.ph{color:rgba(255,255,255,.4);font-size:13px}
|
| 93 |
+
.ri{width:100%}
|
| 94 |
+
.rl{font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.07em;margin-bottom:5px}
|
| 95 |
+
.rp{font-size:40px;font-weight:700;letter-spacing:-.02em;line-height:1;margin-bottom:3px}
|
| 96 |
+
.rp span{font-size:18px;font-weight:400;opacity:.6;margin-right:3px}
|
| 97 |
+
.rd{font-size:12px;opacity:.45;margin-bottom:14px}
|
| 98 |
+
.rb{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
| 99 |
+
.rbi{background:rgba(255,255,255,.08);border-radius:4px;padding:8px 10px}
|
| 100 |
+
.rbi-l{font-size:10px;opacity:.45;text-transform:uppercase;letter-spacing:.06em}
|
| 101 |
+
.rbi-v{font-size:13px;font-weight:600;margin-top:2px}
|
| 102 |
+
.sg{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}
|
| 103 |
+
.st{background:#fff;border:1px solid #e0e4eb;border-radius:6px;padding:12px 14px}
|
| 104 |
+
.stl{font-size:11px;color:#6b7280;margin-bottom:3px}
|
| 105 |
+
.stv{font-size:17px;font-weight:700;color:#111827}
|
| 106 |
+
.sts{font-size:11px;color:#9ca3af;margin-top:1px}
|
| 107 |
+
table{width:100%;border-collapse:collapse;font-size:12px}
|
| 108 |
+
th{padding:7px 10px;background:#f3f4f6;color:#6b7280;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em;text-align:left}
|
| 109 |
+
td{padding:7px 10px;border-bottom:1px solid #f3f4f6;color:#374151}
|
| 110 |
+
tr:last-child td{border:none}
|
| 111 |
+
tr.best td{font-weight:600;background:#f0f4ff}
|
| 112 |
+
tr.best td:first-child{border-left:2px solid #1e3a5f}
|
| 113 |
+
.tag{background:#1e3a5f;color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;margin-left:4px;font-weight:500}
|
| 114 |
+
.fi{display:flex;flex-direction:column;gap:7px}
|
| 115 |
+
.fir{display:flex;align-items:center;gap:8px;font-size:12px}
|
| 116 |
+
.fin{width:125px;flex-shrink:0;color:#6b7280}
|
| 117 |
+
.fit{flex:1;background:#f3f4f6;border-radius:2px;height:5px}
|
| 118 |
+
.fib{height:5px;background:#1e3a5f;border-radius:2px}
|
| 119 |
+
.fip{width:32px;text-align:right;font-size:11px;color:#9ca3af}
|
| 120 |
+
.note{font-size:11px;color:#9ca3af;margin-top:10px;line-height:1.5}
|
| 121 |
+
</style>
|
| 122 |
+
</head>
|
| 123 |
+
<body>
|
| 124 |
+
<div class="bar">
|
| 125 |
+
<svg width="18" height="18" fill="none" viewBox="0 0 24 24"><path d="M3 9.5L12 3l9 6.5V21H3z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/><rect x="9" y="14" width="6" height="7" rx="1" stroke="#fff" stroke-width="1.5"/></svg>
|
| 126 |
+
ZH Apartment Rent Predictor <small>AI Applications HS24</small>
|
| 127 |
+
</div>
|
| 128 |
+
<div class="wrap">
|
| 129 |
+
<div>
|
| 130 |
+
<div class="card">
|
| 131 |
+
<h3>Location</h3>
|
| 132 |
+
<input type="text" id="tsearch" placeholder="Search municipality...">
|
| 133 |
+
<br><br>
|
| 134 |
+
<select id="tsel"></select>
|
| 135 |
+
</div>
|
| 136 |
+
<div class="card">
|
| 137 |
+
<h3>Apartment</h3>
|
| 138 |
+
<div class="f"><div class="fl">Rooms <b id="rv">3.0</b></div><input type="range" id="rooms" min="1" max="7.5" step="0.5" value="3"></div>
|
| 139 |
+
<div class="f"><div class="fl">Area (m²) <b id="av">80</b></div><input type="range" id="area" min="20" max="280" step="5" value="80"></div>
|
| 140 |
+
</div>
|
| 141 |
+
<div class="card">
|
| 142 |
+
<h3>Features</h3>
|
| 143 |
+
<div class="grid2">
|
| 144 |
+
<label class="chk"><input type="checkbox" id="attika"> 🏠 Attika</label>
|
| 145 |
+
<label class="chk"><input type="checkbox" id="loft"> 🏭 Loft</label>
|
| 146 |
+
<label class="chk"><input type="checkbox" id="seesicht"> 🏔️ Lake view</label>
|
| 147 |
+
<label class="chk"><input type="checkbox" id="exklusiv"> 💎 Exclusive</label>
|
| 148 |
+
<label class="chk"><input type="checkbox" id="pool"> 🏊 Pool</label>
|
| 149 |
+
<label class="chk"><input type="checkbox" id="furnished"> 🛋️ Furnished</label>
|
| 150 |
+
<label class="chk"><input type="checkbox" id="temporary"> ⏱️ Temporary</label>
|
| 151 |
+
<label class="chk"><input type="checkbox" id="luxurios"> 👑 Luxurious</label>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
<button class="btn" id="pbtn" type="button">Predict Rent</button>
|
| 155 |
+
</div>
|
| 156 |
+
<div>
|
| 157 |
+
<div class="hero">
|
| 158 |
+
<div class="ph" id="ph">← fill in the details and click predict</div>
|
| 159 |
+
<div class="ri" id="ri" style="display:none">
|
| 160 |
+
<div class="rl">Estimated monthly rent</div>
|
| 161 |
+
<div class="rp"><span>CHF</span><span id="pnum">0</span></div>
|
| 162 |
+
<div class="rd" id="rdesc"></div>
|
| 163 |
+
<div class="rb">
|
| 164 |
+
<div class="rbi"><div class="rbi-l">Low</div><div class="rbi-v" id="rlow">—</div></div>
|
| 165 |
+
<div class="rbi"><div class="rbi-l">High</div><div class="rbi-v" id="rhigh">—</div></div>
|
| 166 |
+
<div class="rbi"><div class="rbi-l">Per m²</div><div class="rbi-v" id="rsqm">—</div></div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
<div class="sg">
|
| 171 |
+
<div class="st"><div class="stl">Canton median</div><div class="stv">CHF 2'370</div><div class="sts">804 listings</div></div>
|
| 172 |
+
<div class="st"><div class="stl">Town median</div><div class="stv" id="sm">—</div><div class="sts" id="sc">select a town</div></div>
|
| 173 |
+
<div class="st"><div class="stl">Avg. tax income</div><div class="stv" id="st2">—</div><div class="sts">municipal avg.</div></div>
|
| 174 |
+
<div class="st"><div class="stl">Pop. density</div><div class="stv" id="sp">—</div><div class="sts">per km²</div></div>
|
| 175 |
+
</div>
|
| 176 |
+
<div class="card">
|
| 177 |
+
<h3>Model comparison — 5-fold CV</h3>
|
| 178 |
+
<table><thead><tr><th>Iter.</th><th>Model</th><th>MAE</th><th>RMSE</th><th>R²</th></tr></thead><tbody id="tb"></tbody></table>
|
| 179 |
+
<p class="note">Selected: Gradient Boosting (iter. 2). New feature added: <code>has_premium_keyword</code> — 1 if listing mentions Attika, Loft, Seesicht, Pool etc.</p>
|
| 180 |
+
</div>
|
| 181 |
+
<div class="card"><h3>Feature importance</h3><div class="fi" id="fi"></div></div>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
<script>
|
| 185 |
+
(function(){
|
| 186 |
+
var TOWNS=[{"town": "Adlikon b. Regensdorf", "median_price": 2272.5, "count": 6, "lat": 47.451836903889976, "lon": 8.464237054189047, "tax_income": 73522.0, "pop_dens": 1268.1258549932, "frg_pct": 35.5717367853}, {"town": "Adliswil", "median_price": 2085.0, "count": 7, "lat": 47.31021772112165, "lon": 8.522809846060616, "tax_income": 89522.0, "pop_dens": 2438.0952380952, "frg_pct": 37.7850506757}, {"town": "Affoltern am Albis", "median_price": 2089.0, "count": 7, "lat": 47.278944287981304, "lon": 8.45288862500872, "tax_income": 72583.0, "pop_dens": 1161.7563739377, "frg_pct": 28.7003169959}, {"town": "Andelfingen", "median_price": 1740.0, "count": 3, "lat": 47.594041188557945, "lon": 8.679091453552246, "tax_income": 88960.0, "pop_dens": 333.3827893175, "frg_pct": 13.3511348465}, {"town": "Bassersdorf", "median_price": 2927.5, "count": 4, "lat": 47.43820285797119, "lon": 8.633100032806396, "tax_income": 79477.0, "pop_dens": 1309.4130675526, "frg_pct": 24.949255751}, {"town": "Br\u00fcttisellen", "median_price": 2130.0, "count": 3, "lat": 47.42356745402018, "lon": 8.633115450541178, "tax_income": 84772.0, "pop_dens": 1005.9343434343, "frg_pct": 27.325216518099996}, {"town": "B\u00fclach", "median_price": 2150.0, "count": 15, "lat": 47.519343566894534, "lon": 8.5398281733195, "tax_income": 78194.0, "pop_dens": 1328.2784338098, "frg_pct": 28.0320044919}, {"town": "Dielsdorf", "median_price": 1825.0, "count": 5, "lat": 47.479800415039065, "lon": 8.454447555541993, "tax_income": 75326.0, "pop_dens": 1016.3543441227, "frg_pct": 31.847133757999995}, {"town": "Dietikon", "median_price": 2143.5, "count": 28, "lat": 47.40354074750628, "lon": 8.404910496303014, "tax_income": 71348.0, "pop_dens": 2143.7092144887497, "frg_pct": 35.57692254225}, {"town": "Dietlikon", "median_price": 2940.0, "count": 10, "lat": 47.42664413452148, "lon": 8.620266914367676, "tax_income": 84564.5, "pop_dens": 2234.7808319589503, "frg_pct": 28.390703560650003}, {"town": "D\u00fcbendorf", "median_price": 2230.0, "count": 11, "lat": 47.40156243064187, "lon": 8.612197962674228, "tax_income": 79563.0, "pop_dens": 2151.4684287812, "frg_pct": 35.9451250725}, {"town": "Eglisau", "median_price": 2660.0, "count": 3, "lat": 47.575480143229164, "lon": 8.517463684082031, "tax_income": 87906.0, "pop_dens": 589.293598234, "frg_pct": 24.143097958399995}, {"town": "Elsau", "median_price": 2525.0, "count": 4, "lat": 47.504170417785645, "lon": 8.802351474761963, "tax_income": 79356.0, "pop_dens": 451.9206939281, "frg_pct": 17.3293117631}, {"town": "Embrach", "median_price": 2400.0, "count": 4, "lat": 47.509860038757324, "lon": 8.595427751541138, "tax_income": 73330.0, "pop_dens": 741.811023622, "frg_pct": 28.0437320879}, {"town": "Fehraltorf", "median_price": 1740.0, "count": 5, "lat": 47.38572616577149, "lon": 8.757724952697753, "tax_income": 80449.0, "pop_dens": 684.7940865892, "frg_pct": 18.2266769468}, {"town": "Feuerthalen", "median_price": 1490.0, "count": 5, "lat": 47.69002685546875, "lon": 8.644001007080078, "tax_income": 76356.0, "pop_dens": 1457.2580645161, "frg_pct": 22.4958494743}, {"town": "F\u00e4llanden", "median_price": 2850.0, "count": 3, "lat": 47.374595642089844, "lon": 8.637135823567709, "tax_income": 90510.0, "pop_dens": 1361.9122257053, "frg_pct": 24.2145241109}, {"town": "Glattbrugg", "median_price": 1386.0, "count": 6, "lat": 47.434794108072914, "lon": 8.567777792612711, "tax_income": 71979.0, "pop_dens": 3759.2128801431004, "frg_pct": 44.9129151994}, {"town": "Gossau ZH", "median_price": 1875.0, "count": 10, "lat": 47.30550994873047, "lon": 8.767629241943359, "tax_income": 80977.0, "pop_dens": 562.9791894852, "frg_pct": 14.5719844358}, {"town": "Greifensee", "median_price": 1860.0, "count": 3, "lat": 47.369075775146484, "lon": 8.683858553568522, "tax_income": 89901.0, "pop_dens": 2372.2466960352, "frg_pct": 20.1671309192}, {"town": "Hedingen", "median_price": 2620.0, "count": 4, "lat": 47.29815101623535, "lon": 8.450382471084595, "tax_income": 94456.0, "pop_dens": 578.5604900459, "frg_pct": 16.4107993647}, {"town": "Hinwil", "median_price": 2267.5, "count": 4, "lat": 47.30254364013672, "lon": 8.83609652519226, "tax_income": 72124.0, "pop_dens": 506.1490125673, "frg_pct": 17.105613195}, {"town": "Hombrechtikon", "median_price": 1812.0, "count": 8, "lat": 47.251842975616455, "lon": 8.775048613548279, "tax_income": 79414.0, "pop_dens": 720.4433497537, "frg_pct": 20.6723646724}, {"town": "Kloten", "median_price": 2354.0, "count": 26, "lat": 47.445955129770134, "lon": 8.58423034961407, "tax_income": 71103.5, "pop_dens": 2401.9209185355, "frg_pct": 39.175285220149995}, {"town": "Langnau am Albis", "median_price": 3100.0, "count": 3, "lat": 47.287497202555336, "lon": 8.529908498128256, "tax_income": 96048.0, "pop_dens": 891.2341407151001, "frg_pct": 27.7856865536}, {"town": "Lufingen", "median_price": 2790.0, "count": 3, "lat": 47.48583094278971, "lon": 8.591005643208822, "tax_income": 94684.0, "pop_dens": 466.60231660229994, "frg_pct": 19.1146048821}, {"town": "Meilen", "median_price": 2500.0, "count": 3, "lat": 47.27062861124674, "lon": 8.64123821258545, "tax_income": 139797.0, "pop_dens": 1200.5862646566, "frg_pct": 23.1880013952}, {"town": "M\u00e4nnedorf", "median_price": 2500.0, "count": 3, "lat": 47.256217956542976, "lon": 8.689555168151855, "tax_income": 103577.0, "pop_dens": 2381.3417190776, "frg_pct": 20.3803151686}, {"town": "Neerach", "median_price": 2240.0, "count": 4, "lat": 47.51153087615967, "lon": 8.47110652923584, "tax_income": 110502.0, "pop_dens": 525.6622516556, "frg_pct": 13.3228346457}, {"town": "Niederglatt ZH", "median_price": 1690.0, "count": 3, "lat": 47.492113749186196, "lon": 8.501464207967123, "tax_income": 74085.0, "pop_dens": 1386.9444444444, "frg_pct": 26.056479070700004}, {"town": "Niederhasli", "median_price": 2070.0, "count": 5, "lat": 47.48185348510742, "lon": 8.495015335083007, "tax_income": 72919.0, "pop_dens": 826.9911504425002, "frg_pct": 25.7998929909}, {"town": "Niederweningen", "median_price": 2700.0, "count": 5, "lat": 47.508734130859374, "lon": 8.37294273376465, "tax_income": 86604.0, "pop_dens": 448.61313868609994, "frg_pct": 17.0517409697}, {"town": "Oberengstringen", "median_price": 1839.5, "count": 8, "lat": 47.407087326049805, "lon": 8.464881896972656, "tax_income": 81948.0, "pop_dens": 3956.4740716056, "frg_pct": 33.2520893901}, {"town": "Oberglatt ZH", "median_price": 2010.0, "count": 5, "lat": 47.47789306640625, "lon": 8.518560981750488, "tax_income": 65147.0, "pop_dens": 876.1212121212, "frg_pct": 37.1748754842}, {"town": "Oetwil am See", "median_price": 1917.5, "count": 8, "lat": 47.27392053604126, "lon": 8.720972299575806, "tax_income": 70794.0, "pop_dens": 792.1440261866, "frg_pct": 29.2561983471}, {"town": "Oetwil an der Limmat", "median_price": 1740.0, "count": 5, "lat": 47.425846099853516, "lon": 8.40000286102295, "tax_income": 65532.0, "pop_dens": 2970.6638115632, "frg_pct": 46.2264830967}, {"town": "Opfikon", "median_price": 2418.0, "count": 5, "lat": 47.427667236328126, "lon": 8.566677856445313, "tax_income": 71979.0, "pop_dens": 3759.2128801431, "frg_pct": 44.9129151994}, {"town": "Pfaffhausen", "median_price": 2448.0, "count": 6, "lat": 47.36277389526367, "lon": 8.620045026143393, "tax_income": 103450.0, "pop_dens": 1035.3230729068, "frg_pct": 22.85907711085}, {"town": "Pfungen", "median_price": 1730.0, "count": 7, "lat": 47.51750564575195, "lon": 8.639920507158552, "tax_income": 71575.0, "pop_dens": 781.5631262525, "frg_pct": 24.8461538462}, {"town": "Pf\u00e4ffikon ZH", "median_price": 1700.0, "count": 5, "lat": 47.36850814819336, "lon": 8.782468223571778, "tax_income": 80310.0, "pop_dens": 623.4751409534, "frg_pct": 19.0891154226}, {"town": "Richterswil", "median_price": 2067.0, "count": 5, "lat": 47.20718002319336, "lon": 8.705493354797364, "tax_income": 90081.0, "pop_dens": 1810.3585657371, "frg_pct": 19.4762323944}, {"town": "Russikon", "median_price": 2571.0, "count": 3, "lat": 47.394126892089844, "lon": 8.774710337320963, "tax_income": 91275.0, "pop_dens": 308.7139845397, "frg_pct": 13.316640109300002}, {"town": "R\u00fcti ZH", "median_price": 1470.0, "count": 6, "lat": 47.258008321126304, "lon": 8.85670264561971, "tax_income": 66676.0, "pop_dens": 1221.2723658052, "frg_pct": 24.8412827609}, {"town": "Schlatt ZH", "median_price": 2700.0, "count": 3, "lat": 47.46413803100586, "lon": 8.845952033996582, "tax_income": 71604.0, "pop_dens": 87.9598662207, "frg_pct": 7.9847908745}, {"town": "Schlieren", "median_price": 2227.5, "count": 14, "lat": 47.400221143450054, "lon": 8.442373888833183, "tax_income": 65678.0, "pop_dens": 2851.4415781487, "frg_pct": 46.1178223618}, {"town": "Schwerzenbach", "median_price": 2940.0, "count": 3, "lat": 47.38499450683594, "lon": 8.64946715037028, "tax_income": 79405.0, "pop_dens": 1973.2824427481, "frg_pct": 27.1373307544}, {"town": "St\u00e4fa", "median_price": 3725.0, "count": 6, "lat": 47.24422264099121, "lon": 8.727231820424398, "tax_income": 103206.0, "pop_dens": 1713.3876600698998, "frg_pct": 19.3232776192}, {"town": "Thalwil", "median_price": 1900.0, "count": 5, "lat": 47.28451690673828, "lon": 8.573451232910156, "tax_income": 113194.0, "pop_dens": 3298.0, "frg_pct": 29.0313688737}, {"town": "Unterengstringen", "median_price": 2624.0, "count": 3, "lat": 47.410074869791664, "lon": 8.444167137145996, "tax_income": 96737.0, "pop_dens": 1177.8443113773, "frg_pct": 25.826131164199996}, {"town": "Uster", "median_price": 1875.0, "count": 18, "lat": 47.349968168470596, "lon": 8.719801743825277, "tax_income": 79839.0, "pop_dens": 1228.7469287469, "frg_pct": 23.0096837775}, {"town": "Volketswil", "median_price": 2490.0, "count": 7, "lat": 47.38727460588728, "lon": 8.66897201538086, "tax_income": 82135.0, "pop_dens": 1327.6353276353, "frg_pct": 24.597639485}, {"town": "Wald ZH", "median_price": 1610.0, "count": 4, "lat": 47.276848793029785, "lon": 8.911797046661377, "tax_income": 66453.0, "pop_dens": 399.525128611, "frg_pct": 26.5649762282}, {"town": "Wallisellen", "median_price": 3315.0, "count": 4, "lat": 47.41270351409912, "lon": 8.59876537322998, "tax_income": 86973.0, "pop_dens": 2623.2087227414, "frg_pct": 31.0135977674}, {"town": "Wetzikon ZH", "median_price": 2094.5, "count": 20, "lat": 47.32592258453369, "lon": 8.802443408966065, "tax_income": 68951.0, "pop_dens": 1486.6151100535, "frg_pct": 25.994397759100003}, {"town": "Winterthur", "median_price": 2167.0, "count": 96, "lat": 47.50012377897898, "lon": 8.735338846842447, "tax_income": 70966.0, "pop_dens": 1662.5973262818, "frg_pct": 24.535003932}, {"town": "Zollikerberg", "median_price": 2590.0, "count": 3, "lat": 47.34665171305338, "lon": 8.599929491678873, "tax_income": 171602.0, "pop_dens": 1664.5859872612, "frg_pct": 26.0580087243}, {"town": "Zollikon", "median_price": 4950.0, "count": 3, "lat": 47.343475341796875, "lon": 8.57410717010498, "tax_income": 171602.0, "pop_dens": 1664.5859872612, "frg_pct": 26.0580087243}, {"town": "Zweidlen", "median_price": 2090.0, "count": 4, "lat": 47.56949996948242, "lon": 8.4723961353302, "tax_income": 74507.0, "pop_dens": 424.1463414634, "frg_pct": 24.5351734713}, {"town": "Z\u00fcrich", "median_price": 2877.5, "count": 262, "lat": 47.38310058244312, "lon": 8.53003583427604, "tax_income": 85446.0, "pop_dens": 5229.091582652058, "frg_pct": 32.4584678868}];
|
| 187 |
+
var ITER1=[{"model": "Ridge (\u03b1=10)", "MAE": 445.8, "RMSE": 671.2, "R2": 0.607}, {"model": "Lasso (\u03b1=10)", "MAE": 448.3, "RMSE": 677.7, "R2": 0.5994}];
|
| 188 |
+
var ITER2=[{"model": "RandomForest (n=300, depth=12, min_leaf=4)", "MAE": 438.7, "RMSE": 659.4, "R2": 0.6183}, {"model": "GradientBoosting (n=400, lr=0.04, depth=5, sub=0.8)", "MAE": 422.1, "RMSE": 641.0, "R2": 0.6377}];
|
| 189 |
+
var FI={"area": 0.45728226097807934, "zurich_city": 0.11430298224257547, "pop_dens": 0.0780246036087376, "log_pop_dens": 0.054507714070176565, "rooms": 0.049764240815849056, "room_per_m2": 0.0436824204066582, "area_per_room": 0.04151442713929889, "tax_income": 0.03480695081719218, "log_tax_income": 0.029919650085885676, "has_premium_keyword": 0.027519254096562112};
|
| 190 |
+
var FN={area:"area (m²)",zurich_city:"zurich city",pop_dens:"pop. density",log_pop_dens:"log(pop dens)",rooms:"rooms",room_per_m2:"rooms/m²",area_per_room:"m²/room",tax_income:"tax income",log_tax_income:"log(tax inc.)",has_premium_keyword:"premium kw"};
|
| 191 |
+
|
| 192 |
+
function bsel(q){
|
| 193 |
+
var s=document.getElementById("tsel"),cur=s.value;
|
| 194 |
+
s.innerHTML="";q=(q||"").toLowerCase();
|
| 195 |
+
TOWNS.forEach(function(t){
|
| 196 |
+
if(q&&t.town.toLowerCase().indexOf(q)===-1)return;
|
| 197 |
+
var o=document.createElement("option");
|
| 198 |
+
o.value=t.town;o.textContent=t.town;
|
| 199 |
+
if(t.town===cur)o.selected=true;
|
| 200 |
+
s.appendChild(o);
|
| 201 |
+
});
|
| 202 |
+
if(!s.value&&s.options.length)s.options[0].selected=true;
|
| 203 |
+
ustats();
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function ustats(){
|
| 207 |
+
var town=document.getElementById("tsel").value,t=null;
|
| 208 |
+
for(var i=0;i<TOWNS.length;i++)if(TOWNS[i].town===town){t=TOWNS[i];break;}
|
| 209 |
+
if(!t)return;
|
| 210 |
+
document.getElementById("sm").textContent="CHF "+Math.round(t.median_price).toLocaleString("de-CH");
|
| 211 |
+
document.getElementById("sc").textContent=(t.count||"")+" listings";
|
| 212 |
+
document.getElementById("st2").textContent="CHF "+Math.round(t.tax_income).toLocaleString("de-CH");
|
| 213 |
+
document.getElementById("sp").textContent=Math.round(t.pop_dens).toLocaleString("de-CH");
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
document.getElementById("tsearch").addEventListener("input",function(){bsel(this.value);});
|
| 217 |
+
document.getElementById("tsel").addEventListener("change",ustats);
|
| 218 |
+
document.getElementById("rooms").addEventListener("input",function(){document.getElementById("rv").textContent=parseFloat(this.value).toFixed(1);});
|
| 219 |
+
document.getElementById("area").addEventListener("input",function(){document.getElementById("av").textContent=this.value;});
|
| 220 |
+
|
| 221 |
+
document.querySelectorAll(".chk").forEach(function(l){
|
| 222 |
+
l.addEventListener("click",function(e){
|
| 223 |
+
var cb=l.querySelector("input");cb.checked=!cb.checked;
|
| 224 |
+
l.classList.toggle("on",cb.checked);e.preventDefault();
|
| 225 |
+
});
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
document.getElementById("pbtn").addEventListener("click",function(){
|
| 229 |
+
var btn=this;btn.disabled=true;btn.textContent="Predicting...";
|
| 230 |
+
var payload={
|
| 231 |
+
town:document.getElementById("tsel").value,
|
| 232 |
+
rooms:parseFloat(document.getElementById("rooms").value),
|
| 233 |
+
area:parseFloat(document.getElementById("area").value),
|
| 234 |
+
attika:document.getElementById("attika").checked?1:0,
|
| 235 |
+
loft:document.getElementById("loft").checked?1:0,
|
| 236 |
+
seesicht:document.getElementById("seesicht").checked?1:0,
|
| 237 |
+
exklusiv:document.getElementById("exklusiv").checked?1:0,
|
| 238 |
+
pool:document.getElementById("pool").checked?1:0,
|
| 239 |
+
furnished:document.getElementById("furnished").checked?1:0,
|
| 240 |
+
temporary:document.getElementById("temporary").checked?1:0,
|
| 241 |
+
luxurios:document.getElementById("luxurios").checked?1:0
|
| 242 |
+
};
|
| 243 |
+
var xhr=new XMLHttpRequest();
|
| 244 |
+
xhr.open("POST","/predict",true);
|
| 245 |
+
xhr.setRequestHeader("Content-Type","application/json");
|
| 246 |
+
xhr.onreadystatechange=function(){
|
| 247 |
+
if(xhr.readyState!==4)return;
|
| 248 |
+
btn.disabled=false;btn.textContent="Predict Rent";
|
| 249 |
+
if(xhr.status!==200){alert("Error: "+xhr.responseText);return;}
|
| 250 |
+
var d=JSON.parse(xhr.responseText);
|
| 251 |
+
document.getElementById("ph").style.display="none";
|
| 252 |
+
var ri=document.getElementById("ri");ri.style.display="block";
|
| 253 |
+
anim("pnum",d.prediction);
|
| 254 |
+
document.getElementById("rlow").textContent="CHF "+d.low.toLocaleString("de-CH");
|
| 255 |
+
document.getElementById("rhigh").textContent="CHF "+d.high.toLocaleString("de-CH");
|
| 256 |
+
document.getElementById("rsqm").textContent=d.sqm+" CHF";
|
| 257 |
+
document.getElementById("rdesc").textContent=payload.town+" · "+payload.rooms+" rooms · "+payload.area+" m²";
|
| 258 |
+
};
|
| 259 |
+
xhr.send(JSON.stringify(payload));
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
function anim(id,to){
|
| 263 |
+
var el=document.getElementById(id),s=performance.now(),dur=450;
|
| 264 |
+
(function step(now){
|
| 265 |
+
var p=Math.min((now-s)/dur,1),e=1-Math.pow(1-p,2);
|
| 266 |
+
el.textContent=Math.round(to*e).toLocaleString("de-CH");
|
| 267 |
+
if(p<1)requestAnimationFrame(step);
|
| 268 |
+
})(s);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
function btbl(){
|
| 272 |
+
var rows=ITER1.map(function(r){return{it:"1",r:r};}).concat(ITER2.map(function(r){return{it:"2",r:r};}));
|
| 273 |
+
var bR2=Math.max.apply(null,rows.map(function(x){return x.r.R2;}));
|
| 274 |
+
document.getElementById("tb").innerHTML=rows.map(function(x){
|
| 275 |
+
var b=x.r.R2===bR2;
|
| 276 |
+
return'<tr class="'+(b?"best":"")+'"><td>'+x.it+'</td><td>'+x.r.model+(b?'<span class="tag">✓</span>':"")+'</td><td>'+x.r.MAE+'</td><td>'+x.r.RMSE+'</td><td>'+x.r.R2.toFixed(4)+'</td></tr>';
|
| 277 |
+
}).join("");
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
function bfi(){
|
| 281 |
+
var e=Object.entries(FI),mx=Math.max.apply(null,e.map(function(x){return x[1];}));
|
| 282 |
+
document.getElementById("fi").innerHTML=e.map(function(x){
|
| 283 |
+
return'<div class="fir"><div class="fin">'+(FN[x[0]]||x[0])+'</div><div class="fit"><div class="fib" style="width:'+(x[1]/mx*100).toFixed(1)+'%"></div></div><div class="fip">'+(x[1]*100).toFixed(1)+'%</div></div>';
|
| 284 |
+
}).join("");
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
bsel();btbl();bfi();
|
| 288 |
+
})();
|
| 289 |
+
</script>
|
| 290 |
+
</body>
|
| 291 |
+
</html>"""
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
if __name__ == "__main__":
|
| 295 |
+
app.run(host="0.0.0.0", port=7860, debug=False)
|
model.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cdd0bd40d3d6123b43005bba6f2bf9ec22a02c7b7b2d1f054e7846137b6f459c
|
| 3 |
+
size 1628803
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
scikit-learn==1.8.0
|
| 3 |
+
pandas
|
| 4 |
+
numpy==2.4.2
|
| 5 |
+
joblib
|