| import json |
| import numpy as np |
| import pandas as pd |
| import joblib |
| from flask import Flask, request, jsonify |
|
|
| app = Flask(__name__) |
| model = joblib.load("model.joblib") |
|
|
| 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'] |
|
|
| 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}] |
|
|
| 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}] |
| 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}] |
| 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} |
|
|
|
|
| @app.route("/predict", methods=["POST"]) |
| def predict(): |
| d = request.get_json(force=True) |
| town_name = d.get("town", "").strip() |
| ti = next((t for t in TOWNS if t["town"] == town_name), TOWNS[0]) |
| area = float(d.get("area", 80)) |
| rooms = float(d.get("rooms", 3)) |
| premium = int(any([d.get("attika"), d.get("loft"), d.get("seesicht"), |
| d.get("luxurios"), d.get("pool"), d.get("exklusiv")])) |
| row = { |
| "rooms": rooms, "area": area, |
| "pop_dens": ti["pop_dens"], "frg_pct": ti["frg_pct"], |
| "tax_income": ti["tax_income"], |
| "room_per_m2": rooms / area if area else 0, |
| "luxurious": int(d.get("luxurios", 0)), |
| "temporary": int(d.get("temporary", 0)), |
| "furnished": int(d.get("furnished", 0)), |
| "area_cat_ecoded": 0 if area < 50 else (1 if area < 100 else 2), |
| "zurich_city": 1 if "rich" in town_name and ti["pop_dens"] > 2000 else 0, |
| "(ATTIKA)": int(d.get("attika", 0)), |
| "(LOFT)": int(d.get("loft", 0)), |
| "(SEESICHT)":int(d.get("seesicht", 0)), |
| "(LUXURIÖS)":int(d.get("luxurios", 0)), |
| "(POOL)": int(d.get("pool", 0)), |
| "(EXKLUSIV)":int(d.get("exklusiv", 0)), |
| "log_pop_dens": np.log1p(ti["pop_dens"]), |
| "log_tax_income": np.log1p(ti["tax_income"]), |
| "area_per_room": area / rooms if rooms else 0, |
| "has_premium_keyword": premium, |
| } |
| price = float(model.predict(pd.DataFrame([row])[FEATURE_COLS])[0]) |
| price = max(500, round(price / 10) * 10) |
| return jsonify({"prediction": price, |
| "low": round(price * 0.88 / 10) * 10, |
| "high": round(price * 1.12 / 10) * 10, |
| "sqm": round(price / area)}) |
|
|
|
|
| @app.route("/") |
| def index(): |
| return HTML |
|
|
|
|
| HTML = """<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1"> |
| <title>ZH Rent Predictor</title> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> |
| <style> |
| *{box-sizing:border-box;margin:0;padding:0} |
| body{font-family:'Inter',sans-serif;font-size:14px;background:#f0f2f5;color:#111827;min-height:100vh} |
| .bar{background:#1e3a5f;color:#fff;padding:13px 20px;font-size:15px;font-weight:600;display:flex;align-items:center;gap:10px} |
| .bar small{font-weight:400;opacity:.5;font-size:12px} |
| .wrap{max-width:960px;margin:20px auto;padding:0 14px;display:grid;grid-template-columns:290px 1fr;gap:14px;align-items:start} |
| @media(max-width:680px){.wrap{grid-template-columns:1fr}} |
| .card{background:#fff;border:1px solid #e0e4eb;border-radius:6px;padding:16px;margin-bottom:12px} |
| .card:last-child{margin-bottom:0} |
| .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} |
| .f{margin-bottom:11px}.f:last-child{margin-bottom:0} |
| .fl{display:flex;justify-content:space-between;font-size:13px;color:#374151;font-weight:500;margin-bottom:5px} |
| .fl b{color:#1e3a5f} |
| 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} |
| select:focus,input[type=text]:focus{border-color:#1e3a5f} |
| input[type=range]{width:100%;accent-color:#1e3a5f;cursor:pointer} |
| .grid2{display:grid;grid-template-columns:1fr 1fr;gap:6px} |
| .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} |
| .chk:hover{border-color:#1e3a5f}.chk.on{border-color:#1e3a5f;background:#eff4ff} |
| .chk input{accent-color:#1e3a5f} |
| .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} |
| .btn:hover{background:#162e4d}.btn:disabled{opacity:.6;cursor:wait} |
| .hero{background:#1e3a5f;color:#fff;border-radius:6px;padding:20px 22px;margin-bottom:12px;min-height:120px;display:flex;align-items:center} |
| .ph{color:rgba(255,255,255,.4);font-size:13px} |
| .ri{width:100%} |
| .rl{font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.07em;margin-bottom:5px} |
| .rp{font-size:40px;font-weight:700;letter-spacing:-.02em;line-height:1;margin-bottom:3px} |
| .rp span{font-size:18px;font-weight:400;opacity:.6;margin-right:3px} |
| .rd{font-size:12px;opacity:.45;margin-bottom:14px} |
| .rb{display:grid;grid-template-columns:repeat(3,1fr);gap:8px} |
| .rbi{background:rgba(255,255,255,.08);border-radius:4px;padding:8px 10px} |
| .rbi-l{font-size:10px;opacity:.45;text-transform:uppercase;letter-spacing:.06em} |
| .rbi-v{font-size:13px;font-weight:600;margin-top:2px} |
| .sg{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px} |
| .st{background:#fff;border:1px solid #e0e4eb;border-radius:6px;padding:12px 14px} |
| .stl{font-size:11px;color:#6b7280;margin-bottom:3px} |
| .stv{font-size:17px;font-weight:700;color:#111827} |
| .sts{font-size:11px;color:#9ca3af;margin-top:1px} |
| table{width:100%;border-collapse:collapse;font-size:12px} |
| th{padding:7px 10px;background:#f3f4f6;color:#6b7280;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.05em;text-align:left} |
| td{padding:7px 10px;border-bottom:1px solid #f3f4f6;color:#374151} |
| tr:last-child td{border:none} |
| tr.best td{font-weight:600;background:#f0f4ff} |
| tr.best td:first-child{border-left:2px solid #1e3a5f} |
| .tag{background:#1e3a5f;color:#fff;font-size:10px;padding:2px 6px;border-radius:3px;margin-left:4px;font-weight:500} |
| .fi{display:flex;flex-direction:column;gap:7px} |
| .fir{display:flex;align-items:center;gap:8px;font-size:12px} |
| .fin{width:125px;flex-shrink:0;color:#6b7280} |
| .fit{flex:1;background:#f3f4f6;border-radius:2px;height:5px} |
| .fib{height:5px;background:#1e3a5f;border-radius:2px} |
| .fip{width:32px;text-align:right;font-size:11px;color:#9ca3af} |
| .note{font-size:11px;color:#9ca3af;margin-top:10px;line-height:1.5} |
| </style> |
| </head> |
| <body> |
| <div class="bar"> |
| <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> |
| ZH Apartment Rent Predictor <small>AI Applications HS24</small> |
| </div> |
| <div class="wrap"> |
| <div> |
| <div class="card"> |
| <h3>Location</h3> |
| <input type="text" id="tsearch" placeholder="Search municipality..."> |
| <br><br> |
| <select id="tsel"></select> |
| </div> |
| <div class="card"> |
| <h3>Apartment</h3> |
| <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> |
| <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> |
| </div> |
| <div class="card"> |
| <h3>Features</h3> |
| <div class="grid2"> |
| <label class="chk"><input type="checkbox" id="attika"> 🏠 Attika</label> |
| <label class="chk"><input type="checkbox" id="loft"> 🏭 Loft</label> |
| <label class="chk"><input type="checkbox" id="seesicht"> 🏔️ Lake view</label> |
| <label class="chk"><input type="checkbox" id="exklusiv"> 💎 Exclusive</label> |
| <label class="chk"><input type="checkbox" id="pool"> 🏊 Pool</label> |
| <label class="chk"><input type="checkbox" id="furnished"> 🛋️ Furnished</label> |
| <label class="chk"><input type="checkbox" id="temporary"> ⏱️ Temporary</label> |
| <label class="chk"><input type="checkbox" id="luxurios"> 👑 Luxurious</label> |
| </div> |
| </div> |
| <button class="btn" id="pbtn" type="button">Predict Rent</button> |
| </div> |
| <div> |
| <div class="hero"> |
| <div class="ph" id="ph">← fill in the details and click predict</div> |
| <div class="ri" id="ri" style="display:none"> |
| <div class="rl">Estimated monthly rent</div> |
| <div class="rp"><span>CHF</span><span id="pnum">0</span></div> |
| <div class="rd" id="rdesc"></div> |
| <div class="rb"> |
| <div class="rbi"><div class="rbi-l">Low</div><div class="rbi-v" id="rlow">—</div></div> |
| <div class="rbi"><div class="rbi-l">High</div><div class="rbi-v" id="rhigh">—</div></div> |
| <div class="rbi"><div class="rbi-l">Per m²</div><div class="rbi-v" id="rsqm">—</div></div> |
| </div> |
| </div> |
| </div> |
| <div class="sg"> |
| <div class="st"><div class="stl">Canton median</div><div class="stv">CHF 2'370</div><div class="sts">804 listings</div></div> |
| <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> |
| <div class="st"><div class="stl">Avg. tax income</div><div class="stv" id="st2">—</div><div class="sts">municipal avg.</div></div> |
| <div class="st"><div class="stl">Pop. density</div><div class="stv" id="sp">—</div><div class="sts">per km²</div></div> |
| </div> |
| <div class="card"> |
| <h3>Model comparison — 5-fold CV</h3> |
| <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> |
| <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> |
| </div> |
| <div class="card"><h3>Feature importance</h3><div class="fi" id="fi"></div></div> |
| </div> |
| </div> |
| <script> |
| (function(){ |
| 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}]; |
| 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}]; |
| 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}]; |
| 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}; |
| 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"}; |
| |
| function bsel(q){ |
| var s=document.getElementById("tsel"),cur=s.value; |
| s.innerHTML="";q=(q||"").toLowerCase(); |
| TOWNS.forEach(function(t){ |
| if(q&&t.town.toLowerCase().indexOf(q)===-1)return; |
| var o=document.createElement("option"); |
| o.value=t.town;o.textContent=t.town; |
| if(t.town===cur)o.selected=true; |
| s.appendChild(o); |
| }); |
| if(!s.value&&s.options.length)s.options[0].selected=true; |
| ustats(); |
| } |
| |
| function ustats(){ |
| var town=document.getElementById("tsel").value,t=null; |
| for(var i=0;i<TOWNS.length;i++)if(TOWNS[i].town===town){t=TOWNS[i];break;} |
| if(!t)return; |
| document.getElementById("sm").textContent="CHF "+Math.round(t.median_price).toLocaleString("de-CH"); |
| document.getElementById("sc").textContent=(t.count||"")+" listings"; |
| document.getElementById("st2").textContent="CHF "+Math.round(t.tax_income).toLocaleString("de-CH"); |
| document.getElementById("sp").textContent=Math.round(t.pop_dens).toLocaleString("de-CH"); |
| } |
| |
| document.getElementById("tsearch").addEventListener("input",function(){bsel(this.value);}); |
| document.getElementById("tsel").addEventListener("change",ustats); |
| document.getElementById("rooms").addEventListener("input",function(){document.getElementById("rv").textContent=parseFloat(this.value).toFixed(1);}); |
| document.getElementById("area").addEventListener("input",function(){document.getElementById("av").textContent=this.value;}); |
| |
| document.querySelectorAll(".chk").forEach(function(l){ |
| l.addEventListener("click",function(e){ |
| var cb=l.querySelector("input");cb.checked=!cb.checked; |
| l.classList.toggle("on",cb.checked);e.preventDefault(); |
| }); |
| }); |
| |
| document.getElementById("pbtn").addEventListener("click",function(){ |
| var btn=this;btn.disabled=true;btn.textContent="Predicting..."; |
| var payload={ |
| town:document.getElementById("tsel").value, |
| rooms:parseFloat(document.getElementById("rooms").value), |
| area:parseFloat(document.getElementById("area").value), |
| attika:document.getElementById("attika").checked?1:0, |
| loft:document.getElementById("loft").checked?1:0, |
| seesicht:document.getElementById("seesicht").checked?1:0, |
| exklusiv:document.getElementById("exklusiv").checked?1:0, |
| pool:document.getElementById("pool").checked?1:0, |
| furnished:document.getElementById("furnished").checked?1:0, |
| temporary:document.getElementById("temporary").checked?1:0, |
| luxurios:document.getElementById("luxurios").checked?1:0 |
| }; |
| var xhr=new XMLHttpRequest(); |
| xhr.open("POST","/predict",true); |
| xhr.setRequestHeader("Content-Type","application/json"); |
| xhr.onreadystatechange=function(){ |
| if(xhr.readyState!==4)return; |
| btn.disabled=false;btn.textContent="Predict Rent"; |
| if(xhr.status!==200){alert("Error: "+xhr.responseText);return;} |
| var d=JSON.parse(xhr.responseText); |
| document.getElementById("ph").style.display="none"; |
| var ri=document.getElementById("ri");ri.style.display="block"; |
| anim("pnum",d.prediction); |
| document.getElementById("rlow").textContent="CHF "+d.low.toLocaleString("de-CH"); |
| document.getElementById("rhigh").textContent="CHF "+d.high.toLocaleString("de-CH"); |
| document.getElementById("rsqm").textContent=d.sqm+" CHF"; |
| document.getElementById("rdesc").textContent=payload.town+" · "+payload.rooms+" rooms · "+payload.area+" m²"; |
| }; |
| xhr.send(JSON.stringify(payload)); |
| }); |
| |
| function anim(id,to){ |
| var el=document.getElementById(id),s=performance.now(),dur=450; |
| (function step(now){ |
| var p=Math.min((now-s)/dur,1),e=1-Math.pow(1-p,2); |
| el.textContent=Math.round(to*e).toLocaleString("de-CH"); |
| if(p<1)requestAnimationFrame(step); |
| })(s); |
| } |
| |
| function btbl(){ |
| var rows=ITER1.map(function(r){return{it:"1",r:r};}).concat(ITER2.map(function(r){return{it:"2",r:r};})); |
| var bR2=Math.max.apply(null,rows.map(function(x){return x.r.R2;})); |
| document.getElementById("tb").innerHTML=rows.map(function(x){ |
| var b=x.r.R2===bR2; |
| 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>'; |
| }).join(""); |
| } |
| |
| function bfi(){ |
| var e=Object.entries(FI),mx=Math.max.apply(null,e.map(function(x){return x[1];})); |
| document.getElementById("fi").innerHTML=e.map(function(x){ |
| 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>'; |
| }).join(""); |
| } |
| |
| bsel();btbl();bfi(); |
| })(); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| if __name__ == "__main__": |
| app.run(host="0.0.0.0", port=7860, debug=False) |
|
|