Spaces:
Configuration error
Configuration error
Upload 6 files
Browse files- README.md +63 -8
- app.py +319 -0
- app_student.py +319 -0
- bfs_municipality_and_tax_data.csv +0 -0
- documentation.md +204 -0
- requirements.txt +5 -0
README.md
CHANGED
|
@@ -1,13 +1,68 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Apartment
|
| 3 |
+
emoji: 🏠
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.13.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Apartment Predictor (Numeric Model + LLM)
|
| 13 |
+
|
| 14 |
+
This Space demonstrates the Week 2 AI Applications pattern:
|
| 15 |
+
|
| 16 |
+
- natural language apartment wishes
|
| 17 |
+
- structured extraction (`rooms`, `area_m2`, `town`)
|
| 18 |
+
- reuse of an existing pickled random forest model
|
| 19 |
+
- LLM explanation of the result
|
| 20 |
+
|
| 21 |
+
Because the data is Swiss, students should write prompts in German so town names like `Zürich` match the dataset more reliably.
|
| 22 |
+
|
| 23 |
+
## Student workflow
|
| 24 |
+
|
| 25 |
+
- Build logic in notebook (`week2/ai_applications_exercise2.ipynb`)
|
| 26 |
+
- Reuse the provided saved model file `random_forest_regression.pkl`
|
| 27 |
+
- Implement TODOs in `app_student.py` (any LLM provider is allowed)
|
| 28 |
+
- Promote finished code to `app.py` for deployment
|
| 29 |
+
- Deploy the app to Hugging Face Spaces
|
| 30 |
+
- Complete `documentation.md`
|
| 31 |
+
|
| 32 |
+
## What To Submit
|
| 33 |
+
|
| 34 |
+
Your submission for this exercise should include:
|
| 35 |
+
|
| 36 |
+
- a working deployed app on Hugging Face Spaces
|
| 37 |
+
- your finished code files
|
| 38 |
+
- a completed `documentation.md`
|
| 39 |
+
|
| 40 |
+
In `documentation.md`, document what you built, how your prompts work, how you tested the app, and what happened during deployment.
|
| 41 |
+
|
| 42 |
+
You must also include **2 screenshots** from your app:
|
| 43 |
+
|
| 44 |
+
- 2 different example inputs
|
| 45 |
+
- visible extracted JSON
|
| 46 |
+
- visible prediction
|
| 47 |
+
- visible final explanation text
|
| 48 |
+
|
| 49 |
+
## LLM policy in this exercise
|
| 50 |
+
|
| 51 |
+
- LLM usage is mandatory.
|
| 52 |
+
- No fallback path is allowed for extraction/explanation.
|
| 53 |
+
- Errors should stay visible so issues can be debugged.
|
| 54 |
+
|
| 55 |
+
## Reference solution details
|
| 56 |
+
|
| 57 |
+
- `app.py` is an OpenAI-based reference implementation.
|
| 58 |
+
- It expects `OPENAI_API_KEY` (and optional `OPENAI_MODEL`).
|
| 59 |
+
|
| 60 |
+
## Required files
|
| 61 |
+
|
| 62 |
+
- `app.py`
|
| 63 |
+
- `app_student.py`
|
| 64 |
+
- `requirements.txt`
|
| 65 |
+
- `random_forest_regression.pkl`
|
| 66 |
+
- `bfs_municipality_and_tax_data.csv`
|
| 67 |
+
|
| 68 |
+
See `NOTEBOOK_TO_APP.md` for the transfer checklist.
|
app.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import pickle
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from openai import OpenAI
|
| 10 |
+
|
| 11 |
+
MODEL_PATH = Path("random_forest_regression.pkl")
|
| 12 |
+
DATA_PATH = Path("bfs_municipality_and_tax_data.csv")
|
| 13 |
+
|
| 14 |
+
# Hugging Face Secrets / local environment variables
|
| 15 |
+
# Required secret on Hugging Face: OPENAI_API_KEY
|
| 16 |
+
# Optional variable/secret: OPENAI_MODEL, e.g. gpt-4.1-mini
|
| 17 |
+
LLM_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
| 18 |
+
LLM_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_model_and_data():
|
| 22 |
+
if not MODEL_PATH.exists():
|
| 23 |
+
raise FileNotFoundError(
|
| 24 |
+
f"Missing model file: {MODEL_PATH}. Upload random_forest_regression.pkl to the same folder as app.py."
|
| 25 |
+
)
|
| 26 |
+
if not DATA_PATH.exists():
|
| 27 |
+
raise FileNotFoundError(
|
| 28 |
+
f"Missing data file: {DATA_PATH}. Upload bfs_municipality_and_tax_data.csv to the same folder as app.py."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
with open(MODEL_PATH, "rb") as model_file:
|
| 32 |
+
loaded_model = pickle.load(model_file)
|
| 33 |
+
|
| 34 |
+
bfs_data = pd.read_csv(DATA_PATH, sep=",", encoding="utf-8")
|
| 35 |
+
bfs_data["tax_income"] = (
|
| 36 |
+
bfs_data["tax_income"].astype(str).str.replace("'", "", regex=False).astype(float)
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
return loaded_model, bfs_data
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
model, df_bfs_data = load_model_and_data()
|
| 43 |
+
|
| 44 |
+
town_to_row = {
|
| 45 |
+
str(row["bfs_name"]).strip().lower(): row
|
| 46 |
+
for _, row in df_bfs_data.iterrows()
|
| 47 |
+
}
|
| 48 |
+
valid_towns = list(df_bfs_data["bfs_name"].sort_values().unique())
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def match_town(user_town: str):
|
| 52 |
+
"""Match user town text to the canonical bfs_name in the CSV."""
|
| 53 |
+
if not user_town:
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
query = str(user_town).strip().lower()
|
| 57 |
+
if not query:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
# 1) Exact lower-case match
|
| 61 |
+
if query in town_to_row:
|
| 62 |
+
return town_to_row[query]["bfs_name"]
|
| 63 |
+
|
| 64 |
+
# 2) Relaxed contains matching
|
| 65 |
+
matches = []
|
| 66 |
+
for town in valid_towns:
|
| 67 |
+
town_lower = str(town).strip().lower()
|
| 68 |
+
if query == town_lower:
|
| 69 |
+
return town
|
| 70 |
+
if query in town_lower or town_lower in query:
|
| 71 |
+
matches.append(town)
|
| 72 |
+
|
| 73 |
+
# Avoid silently choosing between ambiguous towns
|
| 74 |
+
if len(matches) == 1:
|
| 75 |
+
return matches[0]
|
| 76 |
+
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def call_llm_json(system_prompt: str, user_prompt: str) -> str:
|
| 81 |
+
"""Call OpenAI and require a JSON object response."""
|
| 82 |
+
if not LLM_API_KEY:
|
| 83 |
+
raise RuntimeError(
|
| 84 |
+
"OPENAI_API_KEY is missing. Add it in Hugging Face under Settings -> Variables and secrets."
|
| 85 |
+
)
|
| 86 |
+
if not LLM_MODEL:
|
| 87 |
+
raise RuntimeError("OPENAI_MODEL is missing. Example value: gpt-4.1-mini")
|
| 88 |
+
|
| 89 |
+
client = OpenAI(api_key=LLM_API_KEY)
|
| 90 |
+
|
| 91 |
+
response = client.responses.create(
|
| 92 |
+
model=LLM_MODEL,
|
| 93 |
+
input=[
|
| 94 |
+
{"role": "system", "content": system_prompt},
|
| 95 |
+
{"role": "user", "content": user_prompt},
|
| 96 |
+
],
|
| 97 |
+
text={"format": {"type": "json_object"}},
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return response.output_text
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def parse_json_response(raw: str, required_keys: tuple[str, ...]) -> dict:
|
| 104 |
+
cleaned = (raw or "").strip()
|
| 105 |
+
|
| 106 |
+
if not cleaned:
|
| 107 |
+
raise ValueError("LLM returned an empty response instead of JSON.")
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
parsed = json.loads(cleaned)
|
| 111 |
+
except json.JSONDecodeError as exc:
|
| 112 |
+
raise ValueError(
|
| 113 |
+
f"LLM did not return valid JSON. Received: {cleaned[:300]}"
|
| 114 |
+
) from exc
|
| 115 |
+
|
| 116 |
+
missing_keys = [key for key in required_keys if key not in parsed]
|
| 117 |
+
if missing_keys:
|
| 118 |
+
raise ValueError(
|
| 119 |
+
f"LLM JSON is missing required keys: {', '.join(missing_keys)}."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return parsed
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def extract_preferences(user_text: str) -> dict:
|
| 126 |
+
"""Extract rooms, area_m2 and town from German free text using an LLM."""
|
| 127 |
+
if not user_text or not user_text.strip():
|
| 128 |
+
raise ValueError(
|
| 129 |
+
"Bitte gib einen Wohnungswunsch mit Zimmeranzahl, Fläche und Ort ein."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
system_prompt = """
|
| 133 |
+
Du bist ein Extraktionssystem für Schweizer Wohnungswünsche.
|
| 134 |
+
|
| 135 |
+
Extrahiere aus dem Nutzereingabetext exakt diese Informationen:
|
| 136 |
+
- rooms: Anzahl Zimmer als Zahl, zum Beispiel 3.5
|
| 137 |
+
- area_m2: Wohnfläche in Quadratmetern als Zahl, zum Beispiel 85
|
| 138 |
+
- town: Schweizer Gemeinde / Ort als String, zum Beispiel "Winterthur"
|
| 139 |
+
|
| 140 |
+
Antworte ausschliesslich mit gültigem JSON.
|
| 141 |
+
Keine Markdown-Codeblöcke.
|
| 142 |
+
Keine Erklärungen.
|
| 143 |
+
Keine zusätzlichen Schlüssel.
|
| 144 |
+
Wenn ein Wert fehlt, verwende null.
|
| 145 |
+
|
| 146 |
+
Erwartetes Format:
|
| 147 |
+
{"rooms": 3.5, "area_m2": 85, "town": "Winterthur"}
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
user_prompt = f"""
|
| 151 |
+
Extrahiere rooms, area_m2 und town aus folgendem deutschen Wohnungswunsch:
|
| 152 |
+
|
| 153 |
+
{user_text}
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
raw = call_llm_json(system_prompt, user_prompt)
|
| 157 |
+
parsed = parse_json_response(raw, required_keys=("rooms", "area_m2", "town"))
|
| 158 |
+
|
| 159 |
+
rooms = parsed["rooms"]
|
| 160 |
+
area_m2 = parsed["area_m2"]
|
| 161 |
+
town = parsed["town"]
|
| 162 |
+
|
| 163 |
+
if rooms is None or area_m2 is None or town is None:
|
| 164 |
+
raise ValueError("Bitte gib Zimmeranzahl, Wohnfläche in m2 und Ort an.")
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
rooms = float(rooms)
|
| 168 |
+
area_m2 = float(area_m2)
|
| 169 |
+
except (TypeError, ValueError) as exc:
|
| 170 |
+
raise ValueError(
|
| 171 |
+
"Zimmeranzahl und Wohnfläche müssen als Zahlen erkannt werden."
|
| 172 |
+
) from exc
|
| 173 |
+
|
| 174 |
+
if rooms <= 0:
|
| 175 |
+
raise ValueError("Die Zimmeranzahl muss grösser als 0 sein.")
|
| 176 |
+
if area_m2 <= 0:
|
| 177 |
+
raise ValueError("Die Wohnfläche muss grösser als 0 sein.")
|
| 178 |
+
|
| 179 |
+
canonical_town = match_town(town)
|
| 180 |
+
if canonical_town is None:
|
| 181 |
+
raise ValueError(
|
| 182 |
+
f"Ort '{town}' wurde nicht eindeutig in den BFS-Daten gefunden. "
|
| 183 |
+
"Bitte verwende einen gültigen Schweizer Gemeindenamen aus dem Datensatz."
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
"rooms": rooms,
|
| 188 |
+
"area_m2": area_m2,
|
| 189 |
+
"town": canonical_town,
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def predict_apartment_price(rooms: float, area_m2: float, town: str) -> float:
|
| 194 |
+
"""Predict monthly rent with exactly these model features:
|
| 195 |
+
[rooms, area_m2, pop, pop_dens, frg_pct, emp, tax_income]
|
| 196 |
+
"""
|
| 197 |
+
canonical_town = match_town(town)
|
| 198 |
+
if canonical_town is None:
|
| 199 |
+
raise ValueError(f"Ort '{town}' wurde nicht gefunden.")
|
| 200 |
+
|
| 201 |
+
row = town_to_row[canonical_town.lower()]
|
| 202 |
+
|
| 203 |
+
features = np.array(
|
| 204 |
+
[[
|
| 205 |
+
float(rooms),
|
| 206 |
+
float(area_m2),
|
| 207 |
+
float(row["pop"]),
|
| 208 |
+
float(row["pop_dens"]),
|
| 209 |
+
float(row["frg_pct"]),
|
| 210 |
+
float(row["emp"]),
|
| 211 |
+
float(row["tax_income"]),
|
| 212 |
+
]]
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
prediction = model.predict(features)[0]
|
| 216 |
+
return round(float(prediction), 2)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def generate_explanation(preferences: dict, prediction: float) -> str:
|
| 220 |
+
"""Generate a concise German explanation with one uncertainty note using an LLM."""
|
| 221 |
+
system_prompt = """
|
| 222 |
+
Du bist ein hilfreicher Assistent für Wohnungsmietpreise in der Schweiz.
|
| 223 |
+
|
| 224 |
+
Deine Aufgabe:
|
| 225 |
+
Erkläre die gegebene Modellschätzung kurz und verständlich auf Deutsch.
|
| 226 |
+
|
| 227 |
+
Wichtige Regeln:
|
| 228 |
+
- Verwende die angegebene Prediction.
|
| 229 |
+
- Berechne keinen neuen Preis.
|
| 230 |
+
- Erfinde keine zusätzlichen Daten.
|
| 231 |
+
- Erwähne Zimmeranzahl, Fläche und Ort.
|
| 232 |
+
- Füge einen kurzen Unsicherheitshinweis hinzu.
|
| 233 |
+
- Sage, dass es sich um eine Schätzung handelt.
|
| 234 |
+
- Antworte ausschliesslich mit gültigem JSON.
|
| 235 |
+
- Verwende exakt den Schlüssel "answer".
|
| 236 |
+
|
| 237 |
+
Erwartetes Format:
|
| 238 |
+
{"answer": "Für eine 3.5-Zimmer-Wohnung mit 85 m2 in Winterthur schätzt das Modell die Monatsmiete auf rund 2800 CHF. Die Schätzung ist unsicher, weil Faktoren wie Zustand, Mikrolage und Ausstattung nicht direkt berücksichtigt werden."}
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
user_prompt = f"""
|
| 242 |
+
Wohnungsdaten:
|
| 243 |
+
{json.dumps(preferences, ensure_ascii=False)}
|
| 244 |
+
|
| 245 |
+
Modellschätzung der monatlichen Miete in CHF:
|
| 246 |
+
{prediction}
|
| 247 |
+
|
| 248 |
+
Formuliere eine kurze deutsche Antwort.
|
| 249 |
+
"""
|
| 250 |
+
|
| 251 |
+
raw = call_llm_json(system_prompt, user_prompt)
|
| 252 |
+
parsed = parse_json_response(raw, required_keys=("answer",))
|
| 253 |
+
|
| 254 |
+
answer = parsed["answer"]
|
| 255 |
+
if not answer:
|
| 256 |
+
raise ValueError("LLM explanation returned an empty answer.")
|
| 257 |
+
|
| 258 |
+
return str(answer)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def run_pipeline(user_text: str):
|
| 262 |
+
"""End-to-end app pipeline."""
|
| 263 |
+
try:
|
| 264 |
+
preferences = extract_preferences(user_text)
|
| 265 |
+
prediction = predict_apartment_price(
|
| 266 |
+
rooms=preferences["rooms"],
|
| 267 |
+
area_m2=preferences["area_m2"],
|
| 268 |
+
town=preferences["town"],
|
| 269 |
+
)
|
| 270 |
+
final_answer = generate_explanation(
|
| 271 |
+
preferences=preferences,
|
| 272 |
+
prediction=prediction,
|
| 273 |
+
)
|
| 274 |
+
return preferences, prediction, final_answer
|
| 275 |
+
|
| 276 |
+
except Exception as exc:
|
| 277 |
+
return (
|
| 278 |
+
{
|
| 279 |
+
"error": str(exc),
|
| 280 |
+
"hint": "Bitte gib Zimmeranzahl, Wohnfläche in m2 und einen Schweizer Ort an.",
|
| 281 |
+
},
|
| 282 |
+
None,
|
| 283 |
+
f"Fehler: {exc}",
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
with gr.Blocks(title="Apartment Wishes -> Prediction") as demo:
|
| 288 |
+
gr.Markdown(
|
| 289 |
+
"""
|
| 290 |
+
# Apartment Predictor
|
| 291 |
+
|
| 292 |
+
Beschreibe den Wohnungswunsch bitte auf Deutsch.
|
| 293 |
+
|
| 294 |
+
Beispiel:
|
| 295 |
+
**"Ich suche eine 3.5-Zimmer-Wohnung mit etwa 85 m2 in Winterthur."**
|
| 296 |
+
"""
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
user_text = gr.Textbox(
|
| 300 |
+
label="Wohnungswunsch",
|
| 301 |
+
lines=4,
|
| 302 |
+
placeholder="Beschreibe Zimmer, Fläche in m2 und Ort auf Deutsch...",
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
submit = gr.Button("Schätzen")
|
| 306 |
+
|
| 307 |
+
extracted = gr.JSON(label="Extrahierte Eingaben")
|
| 308 |
+
price = gr.Number(label="Geschätzte Monatsmiete (CHF)")
|
| 309 |
+
response = gr.Textbox(label="Antwort", lines=6)
|
| 310 |
+
|
| 311 |
+
submit.click(
|
| 312 |
+
fn=run_pipeline,
|
| 313 |
+
inputs=[user_text],
|
| 314 |
+
outputs=[extracted, price, response],
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
if __name__ == "__main__":
|
| 319 |
+
demo.launch()
|
app_student.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import pickle
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from openai import OpenAI
|
| 10 |
+
|
| 11 |
+
MODEL_PATH = Path("random_forest_regression.pkl")
|
| 12 |
+
DATA_PATH = Path("bfs_municipality_and_tax_data.csv")
|
| 13 |
+
|
| 14 |
+
# Hugging Face Secrets / local environment variables
|
| 15 |
+
# Required secret on Hugging Face: OPENAI_API_KEY
|
| 16 |
+
# Optional variable/secret: OPENAI_MODEL, e.g. gpt-4.1-mini
|
| 17 |
+
LLM_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
| 18 |
+
LLM_MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_model_and_data():
|
| 22 |
+
if not MODEL_PATH.exists():
|
| 23 |
+
raise FileNotFoundError(
|
| 24 |
+
f"Missing model file: {MODEL_PATH}. Upload random_forest_regression.pkl to the same folder as app.py."
|
| 25 |
+
)
|
| 26 |
+
if not DATA_PATH.exists():
|
| 27 |
+
raise FileNotFoundError(
|
| 28 |
+
f"Missing data file: {DATA_PATH}. Upload bfs_municipality_and_tax_data.csv to the same folder as app.py."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
with open(MODEL_PATH, "rb") as model_file:
|
| 32 |
+
loaded_model = pickle.load(model_file)
|
| 33 |
+
|
| 34 |
+
bfs_data = pd.read_csv(DATA_PATH, sep=",", encoding="utf-8")
|
| 35 |
+
bfs_data["tax_income"] = (
|
| 36 |
+
bfs_data["tax_income"].astype(str).str.replace("'", "", regex=False).astype(float)
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
return loaded_model, bfs_data
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
model, df_bfs_data = load_model_and_data()
|
| 43 |
+
|
| 44 |
+
town_to_row = {
|
| 45 |
+
str(row["bfs_name"]).strip().lower(): row
|
| 46 |
+
for _, row in df_bfs_data.iterrows()
|
| 47 |
+
}
|
| 48 |
+
valid_towns = list(df_bfs_data["bfs_name"].sort_values().unique())
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def match_town(user_town: str):
|
| 52 |
+
"""Match user town text to the canonical bfs_name in the CSV."""
|
| 53 |
+
if not user_town:
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
query = str(user_town).strip().lower()
|
| 57 |
+
if not query:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
# 1) Exact lower-case match
|
| 61 |
+
if query in town_to_row:
|
| 62 |
+
return town_to_row[query]["bfs_name"]
|
| 63 |
+
|
| 64 |
+
# 2) Relaxed contains matching
|
| 65 |
+
matches = []
|
| 66 |
+
for town in valid_towns:
|
| 67 |
+
town_lower = str(town).strip().lower()
|
| 68 |
+
if query == town_lower:
|
| 69 |
+
return town
|
| 70 |
+
if query in town_lower or town_lower in query:
|
| 71 |
+
matches.append(town)
|
| 72 |
+
|
| 73 |
+
# Avoid silently choosing between ambiguous towns
|
| 74 |
+
if len(matches) == 1:
|
| 75 |
+
return matches[0]
|
| 76 |
+
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def call_llm_json(system_prompt: str, user_prompt: str) -> str:
|
| 81 |
+
"""Call OpenAI and require a JSON object response."""
|
| 82 |
+
if not LLM_API_KEY:
|
| 83 |
+
raise RuntimeError(
|
| 84 |
+
"OPENAI_API_KEY is missing. Add it in Hugging Face under Settings -> Variables and secrets."
|
| 85 |
+
)
|
| 86 |
+
if not LLM_MODEL:
|
| 87 |
+
raise RuntimeError("OPENAI_MODEL is missing. Example value: gpt-4.1-mini")
|
| 88 |
+
|
| 89 |
+
client = OpenAI(api_key=LLM_API_KEY)
|
| 90 |
+
|
| 91 |
+
response = client.responses.create(
|
| 92 |
+
model=LLM_MODEL,
|
| 93 |
+
input=[
|
| 94 |
+
{"role": "system", "content": system_prompt},
|
| 95 |
+
{"role": "user", "content": user_prompt},
|
| 96 |
+
],
|
| 97 |
+
text={"format": {"type": "json_object"}},
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
return response.output_text
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def parse_json_response(raw: str, required_keys: tuple[str, ...]) -> dict:
|
| 104 |
+
cleaned = (raw or "").strip()
|
| 105 |
+
|
| 106 |
+
if not cleaned:
|
| 107 |
+
raise ValueError("LLM returned an empty response instead of JSON.")
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
parsed = json.loads(cleaned)
|
| 111 |
+
except json.JSONDecodeError as exc:
|
| 112 |
+
raise ValueError(
|
| 113 |
+
f"LLM did not return valid JSON. Received: {cleaned[:300]}"
|
| 114 |
+
) from exc
|
| 115 |
+
|
| 116 |
+
missing_keys = [key for key in required_keys if key not in parsed]
|
| 117 |
+
if missing_keys:
|
| 118 |
+
raise ValueError(
|
| 119 |
+
f"LLM JSON is missing required keys: {', '.join(missing_keys)}."
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return parsed
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def extract_preferences(user_text: str) -> dict:
|
| 126 |
+
"""Extract rooms, area_m2 and town from German free text using an LLM."""
|
| 127 |
+
if not user_text or not user_text.strip():
|
| 128 |
+
raise ValueError(
|
| 129 |
+
"Bitte gib einen Wohnungswunsch mit Zimmeranzahl, Fläche und Ort ein."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
system_prompt = """
|
| 133 |
+
Du bist ein Extraktionssystem für Schweizer Wohnungswünsche.
|
| 134 |
+
|
| 135 |
+
Extrahiere aus dem Nutzereingabetext exakt diese Informationen:
|
| 136 |
+
- rooms: Anzahl Zimmer als Zahl, zum Beispiel 3.5
|
| 137 |
+
- area_m2: Wohnfläche in Quadratmetern als Zahl, zum Beispiel 85
|
| 138 |
+
- town: Schweizer Gemeinde / Ort als String, zum Beispiel "Winterthur"
|
| 139 |
+
|
| 140 |
+
Antworte ausschliesslich mit gültigem JSON.
|
| 141 |
+
Keine Markdown-Codeblöcke.
|
| 142 |
+
Keine Erklärungen.
|
| 143 |
+
Keine zusätzlichen Schlüssel.
|
| 144 |
+
Wenn ein Wert fehlt, verwende null.
|
| 145 |
+
|
| 146 |
+
Erwartetes Format:
|
| 147 |
+
{"rooms": 3.5, "area_m2": 85, "town": "Winterthur"}
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
user_prompt = f"""
|
| 151 |
+
Extrahiere rooms, area_m2 und town aus folgendem deutschen Wohnungswunsch:
|
| 152 |
+
|
| 153 |
+
{user_text}
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
raw = call_llm_json(system_prompt, user_prompt)
|
| 157 |
+
parsed = parse_json_response(raw, required_keys=("rooms", "area_m2", "town"))
|
| 158 |
+
|
| 159 |
+
rooms = parsed["rooms"]
|
| 160 |
+
area_m2 = parsed["area_m2"]
|
| 161 |
+
town = parsed["town"]
|
| 162 |
+
|
| 163 |
+
if rooms is None or area_m2 is None or town is None:
|
| 164 |
+
raise ValueError("Bitte gib Zimmeranzahl, Wohnfläche in m2 und Ort an.")
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
rooms = float(rooms)
|
| 168 |
+
area_m2 = float(area_m2)
|
| 169 |
+
except (TypeError, ValueError) as exc:
|
| 170 |
+
raise ValueError(
|
| 171 |
+
"Zimmeranzahl und Wohnfläche müssen als Zahlen erkannt werden."
|
| 172 |
+
) from exc
|
| 173 |
+
|
| 174 |
+
if rooms <= 0:
|
| 175 |
+
raise ValueError("Die Zimmeranzahl muss grösser als 0 sein.")
|
| 176 |
+
if area_m2 <= 0:
|
| 177 |
+
raise ValueError("Die Wohnfläche muss grösser als 0 sein.")
|
| 178 |
+
|
| 179 |
+
canonical_town = match_town(town)
|
| 180 |
+
if canonical_town is None:
|
| 181 |
+
raise ValueError(
|
| 182 |
+
f"Ort '{town}' wurde nicht eindeutig in den BFS-Daten gefunden. "
|
| 183 |
+
"Bitte verwende einen gültigen Schweizer Gemeindenamen aus dem Datensatz."
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
return {
|
| 187 |
+
"rooms": rooms,
|
| 188 |
+
"area_m2": area_m2,
|
| 189 |
+
"town": canonical_town,
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def predict_apartment_price(rooms: float, area_m2: float, town: str) -> float:
|
| 194 |
+
"""Predict monthly rent with exactly these model features:
|
| 195 |
+
[rooms, area_m2, pop, pop_dens, frg_pct, emp, tax_income]
|
| 196 |
+
"""
|
| 197 |
+
canonical_town = match_town(town)
|
| 198 |
+
if canonical_town is None:
|
| 199 |
+
raise ValueError(f"Ort '{town}' wurde nicht gefunden.")
|
| 200 |
+
|
| 201 |
+
row = town_to_row[canonical_town.lower()]
|
| 202 |
+
|
| 203 |
+
features = np.array(
|
| 204 |
+
[[
|
| 205 |
+
float(rooms),
|
| 206 |
+
float(area_m2),
|
| 207 |
+
float(row["pop"]),
|
| 208 |
+
float(row["pop_dens"]),
|
| 209 |
+
float(row["frg_pct"]),
|
| 210 |
+
float(row["emp"]),
|
| 211 |
+
float(row["tax_income"]),
|
| 212 |
+
]]
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
prediction = model.predict(features)[0]
|
| 216 |
+
return round(float(prediction), 2)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def generate_explanation(preferences: dict, prediction: float) -> str:
|
| 220 |
+
"""Generate a concise German explanation with one uncertainty note using an LLM."""
|
| 221 |
+
system_prompt = """
|
| 222 |
+
Du bist ein hilfreicher Assistent für Wohnungsmietpreise in der Schweiz.
|
| 223 |
+
|
| 224 |
+
Deine Aufgabe:
|
| 225 |
+
Erkläre die gegebene Modellschätzung kurz und verständlich auf Deutsch.
|
| 226 |
+
|
| 227 |
+
Wichtige Regeln:
|
| 228 |
+
- Verwende die angegebene Prediction.
|
| 229 |
+
- Berechne keinen neuen Preis.
|
| 230 |
+
- Erfinde keine zusätzlichen Daten.
|
| 231 |
+
- Erwähne Zimmeranzahl, Fläche und Ort.
|
| 232 |
+
- Füge einen kurzen Unsicherheitshinweis hinzu.
|
| 233 |
+
- Sage, dass es sich um eine Schätzung handelt.
|
| 234 |
+
- Antworte ausschliesslich mit gültigem JSON.
|
| 235 |
+
- Verwende exakt den Schlüssel "answer".
|
| 236 |
+
|
| 237 |
+
Erwartetes Format:
|
| 238 |
+
{"answer": "Für eine 3.5-Zimmer-Wohnung mit 85 m2 in Winterthur schätzt das Modell die Monatsmiete auf rund 2800 CHF. Die Schätzung ist unsicher, weil Faktoren wie Zustand, Mikrolage und Ausstattung nicht direkt berücksichtigt werden."}
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
user_prompt = f"""
|
| 242 |
+
Wohnungsdaten:
|
| 243 |
+
{json.dumps(preferences, ensure_ascii=False)}
|
| 244 |
+
|
| 245 |
+
Modellschätzung der monatlichen Miete in CHF:
|
| 246 |
+
{prediction}
|
| 247 |
+
|
| 248 |
+
Formuliere eine kurze deutsche Antwort.
|
| 249 |
+
"""
|
| 250 |
+
|
| 251 |
+
raw = call_llm_json(system_prompt, user_prompt)
|
| 252 |
+
parsed = parse_json_response(raw, required_keys=("answer",))
|
| 253 |
+
|
| 254 |
+
answer = parsed["answer"]
|
| 255 |
+
if not answer:
|
| 256 |
+
raise ValueError("LLM explanation returned an empty answer.")
|
| 257 |
+
|
| 258 |
+
return str(answer)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def run_pipeline(user_text: str):
|
| 262 |
+
"""End-to-end app pipeline."""
|
| 263 |
+
try:
|
| 264 |
+
preferences = extract_preferences(user_text)
|
| 265 |
+
prediction = predict_apartment_price(
|
| 266 |
+
rooms=preferences["rooms"],
|
| 267 |
+
area_m2=preferences["area_m2"],
|
| 268 |
+
town=preferences["town"],
|
| 269 |
+
)
|
| 270 |
+
final_answer = generate_explanation(
|
| 271 |
+
preferences=preferences,
|
| 272 |
+
prediction=prediction,
|
| 273 |
+
)
|
| 274 |
+
return preferences, prediction, final_answer
|
| 275 |
+
|
| 276 |
+
except Exception as exc:
|
| 277 |
+
return (
|
| 278 |
+
{
|
| 279 |
+
"error": str(exc),
|
| 280 |
+
"hint": "Bitte gib Zimmeranzahl, Wohnfläche in m2 und einen Schweizer Ort an.",
|
| 281 |
+
},
|
| 282 |
+
None,
|
| 283 |
+
f"Fehler: {exc}",
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
with gr.Blocks(title="Apartment Wishes -> Prediction") as demo:
|
| 288 |
+
gr.Markdown(
|
| 289 |
+
"""
|
| 290 |
+
# Apartment Predictor
|
| 291 |
+
|
| 292 |
+
Beschreibe den Wohnungswunsch bitte auf Deutsch.
|
| 293 |
+
|
| 294 |
+
Beispiel:
|
| 295 |
+
**"Ich suche eine 3.5-Zimmer-Wohnung mit etwa 85 m2 in Winterthur."**
|
| 296 |
+
"""
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
user_text = gr.Textbox(
|
| 300 |
+
label="Wohnungswunsch",
|
| 301 |
+
lines=4,
|
| 302 |
+
placeholder="Beschreibe Zimmer, Fläche in m2 und Ort auf Deutsch...",
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
submit = gr.Button("Schätzen")
|
| 306 |
+
|
| 307 |
+
extracted = gr.JSON(label="Extrahierte Eingaben")
|
| 308 |
+
price = gr.Number(label="Geschätzte Monatsmiete (CHF)")
|
| 309 |
+
response = gr.Textbox(label="Antwort", lines=6)
|
| 310 |
+
|
| 311 |
+
submit.click(
|
| 312 |
+
fn=run_pipeline,
|
| 313 |
+
inputs=[user_text],
|
| 314 |
+
outputs=[extracted, price, response],
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
if __name__ == "__main__":
|
| 319 |
+
demo.launch()
|
bfs_municipality_and_tax_data.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
documentation.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Documentation
|
| 2 |
+
## Week 2: Apartment Predictor (Saved Regression Model + LLM Workflow)
|
| 3 |
+
|
| 4 |
+
This file documents the apartment prediction application that combines a saved regression model with an LLM workflow.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 1. Project Summary
|
| 9 |
+
|
| 10 |
+
The app accepts a German free-text apartment request, for example: "Ich suche eine 3.5-Zimmer-Wohnung mit 85 m2 in Winterthur." An LLM extracts the structured parameters `rooms`, `area_m2`, and `town` from the text. The app then uses a saved random forest regression model to estimate the monthly rent in CHF. A second LLM step turns the numeric prediction into a short German explanation with an uncertainty note.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 2. Files Used
|
| 15 |
+
|
| 16 |
+
| File | Purpose |
|
| 17 |
+
|------|---------|
|
| 18 |
+
| `ai_applications_exercise2.ipynb` | Notebook work and testing during the exercise |
|
| 19 |
+
| `app_student.py` | Student implementation with completed TODOs |
|
| 20 |
+
| `app.py` | Final deployable Gradio app for Hugging Face Spaces |
|
| 21 |
+
| `random_forest_regression.pkl` | Saved regression model used for numeric prediction |
|
| 22 |
+
| `bfs_municipality_and_tax_data.csv` | Municipality features used for prediction |
|
| 23 |
+
| `requirements.txt` | Python dependencies for the Space |
|
| 24 |
+
| `README.md` | Hugging Face Space configuration and project summary |
|
| 25 |
+
| `documentation.md` | Written documentation for the submission |
|
| 26 |
+
| `screenshot1.png` | First screenshot from the running app |
|
| 27 |
+
| `screenshot2.png` | Second screenshot from the running app |
|
| 28 |
+
|
| 29 |
+
---
|
| 30 |
+
|
| 31 |
+
## 3. Numeric Prediction Part
|
| 32 |
+
|
| 33 |
+
### 3.1 Reused Model
|
| 34 |
+
|
| 35 |
+
**Which saved model did you use?**
|
| 36 |
+
`random_forest_regression.pkl`
|
| 37 |
+
|
| 38 |
+
**What does the model predict?**
|
| 39 |
+
The model predicts the estimated monthly rent of an apartment in CHF. The prediction is based on apartment size information and municipality-level data.
|
| 40 |
+
|
| 41 |
+
**Which input features are used for prediction?**
|
| 42 |
+
|
| 43 |
+
The model input uses the seven features in this exact order:
|
| 44 |
+
|
| 45 |
+
1. `rooms`
|
| 46 |
+
2. `area_m2`
|
| 47 |
+
3. `pop`
|
| 48 |
+
4. `pop_dens`
|
| 49 |
+
5. `frg_pct`
|
| 50 |
+
6. `emp`
|
| 51 |
+
7. `tax_income`
|
| 52 |
+
|
| 53 |
+
### 3.2 Prediction Logic
|
| 54 |
+
|
| 55 |
+
The user provides `rooms`, `area_m2`, and `town` in natural language. The LLM extracts these values as JSON. Python then matches the extracted town name to the canonical `bfs_name` in `bfs_municipality_and_tax_data.csv`. The app reads the municipality values `pop`, `pop_dens`, `frg_pct`, `emp`, and `tax_income` from the matching CSV row. These values are combined with `rooms` and `area_m2` into a NumPy array and passed to the saved regression model.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## 4. LLM Extraction Part
|
| 60 |
+
|
| 61 |
+
### 4.1 Goal
|
| 62 |
+
|
| 63 |
+
The first LLM step extracts structured model inputs from a German apartment request. It must return the number of rooms, the apartment area in square meters, and the town name.
|
| 64 |
+
|
| 65 |
+
### 4.2 Prompt Design
|
| 66 |
+
|
| 67 |
+
The extraction prompt tells the LLM that it is an extraction system for Swiss apartment wishes. The prompt requires strict JSON and explicitly lists the required keys: `rooms`, `area_m2`, and `town`. It also tells the model not to return Markdown, explanations, or additional keys. If a value is missing, the LLM is instructed to use `null`.
|
| 68 |
+
|
| 69 |
+
### 4.3 Expected Output Format
|
| 70 |
+
|
| 71 |
+
```json
|
| 72 |
+
{"rooms": 3.5, "area_m2": 85, "town": "Winterthur"}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 4.4 Validation
|
| 76 |
+
|
| 77 |
+
The app validates the LLM output with `parse_json_response`. This function checks that the response is not empty, that it is valid JSON, and that all required keys are present. The app also converts `rooms` and `area_m2` to numbers and checks that they are greater than zero. The extracted town is matched against the BFS municipality data before the prediction is made.
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## 5. LLM Explanation Part
|
| 82 |
+
|
| 83 |
+
### 5.1 Goal
|
| 84 |
+
|
| 85 |
+
The second LLM step generates a short natural-language answer in German. It explains the model prediction to the user. The LLM should not calculate a new price; it should use the prediction value that was produced by the regression model.
|
| 86 |
+
|
| 87 |
+
### 5.2 Prompt Design
|
| 88 |
+
|
| 89 |
+
The explanation prompt includes the structured preferences and the predicted rent. The prompt instructs the LLM to write in German, mention the number of rooms, area, and town, and include a short uncertainty note. The answer is also required to be valid JSON with the key `answer`.
|
| 90 |
+
|
| 91 |
+
### 5.3 Expected Output Format
|
| 92 |
+
|
| 93 |
+
```json
|
| 94 |
+
{"answer": "Für eine 3.5-Zimmer-Wohnung mit 85 m2 in Winterthur schätzt das Modell die Monatsmiete auf rund 2800 CHF. Die Schätzung ist unsicher, weil Faktoren wie Zustand, Mikrolage und Ausstattung nicht direkt berücksichtigt werden."}
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## 6. End-to-End Pipeline
|
| 100 |
+
|
| 101 |
+
1. The user enters a German apartment request.
|
| 102 |
+
2. The LLM extracts `rooms`, `area_m2`, and `town` as JSON.
|
| 103 |
+
3. Python validates the JSON and converts the numeric values.
|
| 104 |
+
4. Python matches the town name to the BFS municipality data.
|
| 105 |
+
5. The app builds the seven-feature model input.
|
| 106 |
+
6. The saved random forest regression model predicts the monthly rent.
|
| 107 |
+
7. The second LLM step generates a short German explanation.
|
| 108 |
+
8. The Gradio app returns the extracted JSON, the predicted rent, and the final explanation text.
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## 7. Test Cases
|
| 113 |
+
|
| 114 |
+
| Test Input | Extracted Output Correct? | Prediction Returned? | Explanation Returned? | Notes |
|
| 115 |
+
|------------|----------------------------|----------------------|-----------------------|-------|
|
| 116 |
+
| `Ich suche eine 3.5-Zimmer-Wohnung mit 85 m2 in Winterthur.` | Yes | Yes | Yes | The app extracted the apartment size and town and returned a German explanation. |
|
| 117 |
+
| `Wie viel kostet ungefähr eine 2-Zimmer-Wohnung mit 55 Quadratmetern in Zürich?` | Yes | Yes | Yes | The LLM extracted `rooms`, `area_m2`, and `town`; the model returned a rent estimate. |
|
| 118 |
+
| `Ich interessiere mich für eine 4.5 Zimmer Wohnung mit 110 m2 in Bern.` | Yes | Yes | Yes | The app handled another Swiss city and produced the full output pipeline. |
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## 8. Errors and Problems
|
| 123 |
+
|
| 124 |
+
**Problem:** The app failed locally with `FileNotFoundError: random_forest_regression.pkl`.
|
| 125 |
+
**Cause:** The saved model file was not in the same folder as `app.py`.
|
| 126 |
+
**Fix:** I added `random_forest_regression.pkl` to the project folder before running and deploying the app.
|
| 127 |
+
|
| 128 |
+
**Problem:** The LLM could return invalid or incomplete JSON.
|
| 129 |
+
**Cause:** LLM output is not guaranteed unless the prompt and API response format are strict.
|
| 130 |
+
**Fix:** The app uses JSON mode and validates the response with `parse_json_response`.
|
| 131 |
+
|
| 132 |
+
**Problem:** Invalid or ambiguous town names may not match the BFS CSV.
|
| 133 |
+
**Cause:** The model requires municipality-level features from the CSV.
|
| 134 |
+
**Fix:** The app first tries exact town matching and then relaxed contains matching. Ambiguous or missing towns return a visible error.
|
| 135 |
+
|
| 136 |
+
**Problem:** The Space may fail if the API key is missing.
|
| 137 |
+
**Cause:** The LLM steps require an OpenAI API key.
|
| 138 |
+
**Fix:** I added `OPENAI_API_KEY` as a Hugging Face Space Secret.
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## 9. Deployment Notes
|
| 143 |
+
|
| 144 |
+
### 9.1 Files included
|
| 145 |
+
|
| 146 |
+
The Hugging Face Space contains:
|
| 147 |
+
|
| 148 |
+
- `app.py`
|
| 149 |
+
- `app_student.py`
|
| 150 |
+
- `README.md`
|
| 151 |
+
- `requirements.txt`
|
| 152 |
+
- `documentation.md`
|
| 153 |
+
- `bfs_municipality_and_tax_data.csv`
|
| 154 |
+
- `random_forest_regression.pkl`
|
| 155 |
+
- `screenshot1.png`
|
| 156 |
+
- `screenshot2.png`
|
| 157 |
+
|
| 158 |
+
### 9.2 Secrets / Environment Variables
|
| 159 |
+
|
| 160 |
+
The app requires this Hugging Face Secret:
|
| 161 |
+
|
| 162 |
+
- `OPENAI_API_KEY`
|
| 163 |
+
|
| 164 |
+
Optional:
|
| 165 |
+
|
| 166 |
+
- `OPENAI_MODEL`, for example `gpt-4.1-mini`
|
| 167 |
+
|
| 168 |
+
### 9.3 Deployment Result
|
| 169 |
+
|
| 170 |
+
The Space was deployed as a Gradio application. After uploading all required files and setting the OpenAI API key, the app started successfully and returned the extracted JSON, the predicted rent, and the final German explanation.
|
| 171 |
+
|
| 172 |
+
### 9.4 Screenshots
|
| 173 |
+
|
| 174 |
+
Add the two screenshots from the running Hugging Face Space here after deployment:
|
| 175 |
+
|
| 176 |
+
```md
|
| 177 |
+

|
| 178 |
+
|
| 179 |
+
In this example, the app extracted the apartment request as JSON, predicted the monthly rent, and generated a German explanation.
|
| 180 |
+
|
| 181 |
+

|
| 182 |
+
|
| 183 |
+
In this example, a different German apartment request was used. The screenshot shows the extracted JSON, predicted rent, and final answer.
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+

|
| 187 |
+
|
| 188 |
+
In this example, the app extracted the apartment request as JSON, predicted the monthly rent, and generated a German explanation.
|
| 189 |
+
|
| 190 |
+

|
| 191 |
+
|
| 192 |
+
In this example, a different German apartment request was used. The screenshot shows the extracted JSON, predicted rent, and final answer.
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## 10. Reflection
|
| 197 |
+
|
| 198 |
+
The combination of a regression model and an LLM worked well because the LLM made the app easier to use with natural German input. The numeric prediction stayed inside the saved model, while the LLM handled text extraction and explanation. The system is still fragile because wrong town names, missing values, or invalid JSON can stop the pipeline. German input is important because the dataset contains Swiss municipality names and the expected users describe apartments in German. In a future version, I would add more apartment features such as floor, building condition, balcony, public transport access, and exact location.
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## 11. Responsible Use Note
|
| 203 |
+
|
| 204 |
+
The predicted rent is only an estimate and should not be treated as a guaranteed market price. The model uses a limited set of structured features and does not directly include factors such as apartment condition, exact address, renovation status, balcony, view, or micro-location. The LLM may also extract values incorrectly, so the visible JSON output should always be checked. The app is useful for learning and experimentation, but not for final financial or rental decisions.
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai==2.33.0
|
| 2 |
+
scikit-learn==1.8.0
|
| 3 |
+
numpy
|
| 4 |
+
pandas
|
| 5 |
+
gradio
|