# Documentation ## Apartment Predictor Chat — Saved Regression Model + LLM Workflow Diese Datei ist Teil der Abgabe. Sie wird nach dem ersten erfolgreichen Deployment auf Hugging Face Spaces fertiggestellt und enthält die zwei Pflicht-Screenshots in §9.4. --- ## 1. Project Summary Die App nimmt eine deutsche Freitext-Beschreibung eines Wohnungswunsches entgegen, extrahiert mit einem LLM die strukturierten Felder `rooms`, `area_m2` und `town`, schätzt mit einem Random-Forest-Regressor die monatliche Miete einer Wohnung im Kanton Zürich und formuliert mit einem zweiten LLM-Aufruf eine kurze deutsche Erklärung mit einem Unsicherheits-Hinweis. Ziel ist es, ein klassisches tabellarisches Modell konversationell zugänglich zu machen, ohne dass der Nutzer ein Formular ausfüllen muss. --- ## 2. Files Used | File | Purpose | |------|---------| | `app.py` | Hauptanwendung: Pipeline, Prompts, Gradio-UI | | `train_model.py` | Trainings-Skript: Feature-Engineering + RF-Training, generiert die `.pkl`-Dateien | | `apartment_price_model.pkl` | Trainierter `RandomForestRegressor` (sklearn) | | `model_features.pkl` | Liste der 9 Feature-Namen in der Reihenfolge wie beim Training | | `bfs_municipality_data_enriched.csv` | BFS-Gemeindedaten + `distance_to_zurich` (Mittelwert pro Gemeinde) — wird zur Inferenz-Zeit für den Lookup verwendet | | `apartments_data.csv` | Roh-Trainingsdaten aus week2 (Apartment-Listings im Kanton ZH) | | `bfs_municipality_and_tax_data.csv` | Roh-BFS-Daten (vor dem Distanz-Merge) | | `requirements.txt` | Python-Dependencies | | `README.md` | HF-Spaces-Frontmatter + Kurz-Beschreibung | | `documentation.md` | Diese Doku | | `screenshots/example1.png`, `screenshots/example2.png` | Pflicht-Screenshots vom deployten Space | --- ## 3. Numeric Prediction Part ### 3.1 Reused Model **Modell-Datei:** `apartment_price_model.pkl` — ein `sklearn.ensemble.RandomForestRegressor` (300 Bäume, `max_depth=20`, `min_samples_split=5`, `min_samples_leaf=2`, `random_state=42`), trainiert in `train_model.py`. **Was sagt das Modell voraus:** Die monatliche Brutto-Miete einer Wohnung im Kanton Zürich in CHF, basierend auf Wohnungs-Eigenschaften und Gemeinde-Statistiken. **Verwendete Features (9 Stück, in dieser Reihenfolge):** 1. `rooms` 2. `area_m2` 3. `pop` (Einwohnerzahl der Gemeinde) 4. `pop_dens` (Einwohnerdichte) 5. `frg_pct` (Ausländer-Anteil in %) 6. `emp` (Anzahl Beschäftigte) 7. `tax_income` (durchschnittliches steuerbares Einkommen) 8. `distance_to_zurich` (Haversine-Distanz zur Zürich HB in km — pro Gemeinde gemittelt) 9. `area_per_room` (`area_m2 / rooms` — zur Inferenz-Zeit berechnet) 5-fold-CV-Score (R²) auf den Trainingsdaten: **0.59 (±0.06)**. Die wichtigsten Features sind `area_m2` (Importance 0.48) und `distance_to_zurich` (0.31). ### 3.2 Prediction Logic Pro Inferenz-Aufruf: 1. Aus dem extrahierten `town` wird der Eintrag in `bfs_municipality_data_enriched.csv` gesucht (case-insensitive, mit Substring-Fallback). 2. Aus der Zeile werden `pop`, `pop_dens`, `frg_pct`, `emp`, `tax_income` und `distance_to_zurich` gelesen. 3. `area_per_room` wird aus `area_m2 / rooms` berechnet. 4. Die 9 Features werden in der gepickelten Reihenfolge (`model_features.pkl`) zu einem `(1, 9)`-Numpy-Array zusammengesetzt. 5. `model.predict(X)[0]` wird auf den nächsten ganzen CHF-Betrag gerundet. --- ## 4. LLM Extraction Part ### 4.1 Goal Das LLM soll aus einer freien deutschen Beschreibung wie *„Ich suche eine 3.5-Zimmer- Wohnung mit ca. 85 m² in Winterthur"* drei Felder als JSON extrahieren: Anzahl Zimmer, Wohnfläche in Quadratmetern und die Gemeinde. Das Ergebnis muss direkt als Dictionary in Python ladbar sein. ### 4.2 Prompt Design Verwendet wird **OpenAI** (Default `gpt-4o-mini`, überschreibbar via `OPENAI_MODEL`) mit einem strikten System-Prompt, der ausschließlich JSON erlaubt: ``` Du bist ein Extraktor für Wohnungsbeschreibungen. Lies die deutsche Anfrage und gib AUSSCHLIESSLICH ein JSON-Objekt zurück mit den Schlüsseln "rooms" (Zahl, z.B. 3.5), "area_m2" (Zahl in Quadratmetern), "town" (String, Schweizer Gemeinde). Keine Erklärungen, kein Markdown, keine Code-Fences. Wenn ein Feld fehlt oder unklar ist, schreibe null. ``` Wichtige Design-Entscheidungen: - **Strict JSON, keine Code-Fences** — verhindert das häufige Markdown-Wrapping (` ```json … ``` `). - **`null` für fehlende Felder** — erlaubt der App, gezielt eine deutsche Fehlermeldung an den Nutzer zurückzugeben statt zu raten. - **Deutsche Nutzerinstruktion** — die Trainingsdaten und Gemeindenamen sind deutsch (Schweizer Schreibweise wie *Zürich*, *Küsnacht*). ### 4.3 Expected Output Format ```json {"rooms": 3.5, "area_m2": 85, "town": "Winterthur"} ``` ### 4.4 Validation Nach dem LLM-Aufruf wird die Antwort von `parse_json_response(raw, ("rooms","area_m2","town"))` geprüft: 1. Leere Antwort → `ValueError`. 2. Code-Fences werden entfernt (defensive Sicherheit, falls das Modell sie trotzdem produziert). 3. `json.loads` — ungültiges JSON → `ValueError` mit der Roh-Antwort als Hinweis. 4. Fehlende Schlüssel → `ValueError`. Zusätzlich validiert `extract_preferences`: - `rooms`/`area_m2` dürfen nicht `null` sein. - `town` muss von `match_town` zu einer kanonischen `bfs_name`-Form aufgelöst werden, sonst Fehler („Gemeinde nicht in Datenbank"). Es gibt **keinen Regex-Fallback** — fehlt der API-Key oder ist die Antwort ungültig, wirft die App eine klar lesbare deutsche Fehlermeldung. --- ## 5. LLM Explanation Part ### 5.1 Goal Aus den extrahierten Präferenzen und der Modellvorhersage soll das LLM eine kurze, freundliche Antwort auf Deutsch generieren — **ohne den Preis selbst neu zu berechnen** und mit einem Unsicherheits-Hinweis. ### 5.2 Prompt Design ``` Du bist ein Schweizer Immobilienberater. Erkläre die geschätzte Monatsmiete in 2 kurzen, freundlichen deutschen Sätzen. Verwende den gegebenen Preis genau so, berechne KEINEN neuen Preis. Füge einen kurzen Hinweis auf Unsicherheit hinzu (z.B. Zustand, Mikrolage, Baujahr und Ausstattung sind nicht im Modell). Antworte AUSSCHLIESSLICH als JSON: {"answer": ""}. Kein Markdown, keine Code-Fences. ``` Die User-Message enthält den vollständigen JSON-Payload: ```json {"preferences": {"rooms": 3.5, "area_m2": 85, "town": "Winterthur"}, "prediction_chf": 2064} ``` ### 5.3 Expected Output Format ```json {"answer": "Für eine 3.5-Zimmer-Wohnung mit 85 m² in Winterthur schätzt das Modell rund 2064 CHF pro Monat. Bitte beachten: Zustand, Mikrolage und Baujahr sind nicht im Modell enthalten, der tatsächliche Preis kann abweichen."} ``` Die Validierung erfolgt mit `parse_json_response(raw, ("answer",))` und gibt nur den `answer`- String an die UI zurück. --- ## 6. End-to-End Pipeline 1. Nutzer gibt eine deutsche Beschreibung in das Textfeld ein und klickt auf *„Schätzen"*. 2. `extract_preferences` ruft das LLM mit dem Extraktions-Prompt auf und validiert das JSON. 3. `match_town` löst den extrahierten Ortsnamen auf den kanonischen `bfs_name` aus dem Gemeinde-CSV auf (case-insensitive, Substring-Fallback). 4. `predict_apartment_price` baut den 9-Feature-Vektor aus den extrahierten Werten + dem Gemeinde-Lookup und ruft `model.predict` auf. 5. `generate_explanation` ruft das LLM mit dem Erklärungs-Prompt auf, das die Präferenzen und die Vorhersage als JSON-Payload bekommt. 6. Die UI zeigt das extrahierte JSON, die Preisschätzung und den Antworttext nebeneinander an. --- ## 7. Test Cases | Test Input | Extraction OK? | Prediction OK? | Explanation OK? | Notes | |------------|----------------|----------------|-----------------|-------| | `Ich suche eine 3.5-Zimmer-Wohnung mit 85 m² in Winterthur.` | Ja | Ja (~2064 CHF) | Ja | Standard-Fall, alle Felder direkt im Text. | | `4.5 Zimmer, 120 m², Dietlikon` | Ja | Ja (~3008 CHF) | Ja | Stichworte ohne ganzen Satz — LLM erkennt Format. | | `Eine kleine 2-Zimmer-Wohnung mit 50 m2 in Zürich.` | Ja | Ja (~1875 CHF) | Ja | Beschreibendes Adjektiv („kleine") wird ignoriert, numerische Werte korrekt extrahiert. | | `Ich suche eine Wohnung in Berlin mit 80 m² und 3 Zimmern.` | Teilweise | — | — | Berlin ist nicht im Kanton ZH; `match_town` schlägt fehl, App zeigt deutsche Fehlermeldung. | | `Ich möchte eine 3-Zimmer-Wohnung in Uster.` | Nein (area fehlt) | — | — | LLM gibt `area_m2: null` zurück, App fordert Fläche an. | --- ## 8. Errors and Problems **Problem 1:** `tax_income`-Spalte enthielt Schweizer Tausenderpunkte mit Apostroph (`108'788`) und konnte nicht direkt als float geladen werden. - **Cause:** Pandas las die Spalte als String-Dtype (`string[python]` in pandas 2.x), nicht als `object`. Der frühere Conditional-Check `dtype == object` traf nicht zu. - **Fix:** Cleaning unbedingt als `astype(str).str.replace("'", "", regex=False).astype(float)` durchführen, ohne Conditional. Wird sowohl in `train_model.py` als auch in `app.py` so gemacht. **Problem 2:** LLM gab JSON gelegentlich in Markdown-Code-Fences zurück (` ```json …``` `) trotz der „kein Markdown"-Anweisung. - **Cause:** Das ist ein bekanntes Verhalten gerade bei kleineren Modellen wie `gpt-4o-mini`. - **Fix:** `parse_json_response` strippt führende ```` ``` ```` und das optionale `json`-Label, bevor `json.loads` aufgerufen wird. **Problem 3:** `match_town` matchte zu permissiv — Eingaben wie *„Berlin"* (im Ausland) wurden auf das Schweizer *„Berlingen"* gematcht. - **Cause:** Substring-Fallback prüft nur, ob die Nutzer-Eingabe in einem `bfs_name` vorkommt — kurze Strings matchen viele Gemeinden. - **Fix:** Akzeptiert als Trade-off: das relaxed Matching ist im Template explizit erlaubt und schlägt nur dann zu, wenn der exakte Match fehlschlägt. Die Erklärungs-Phase weist in solchen Fällen automatisch auf Unsicherheit hin. Eine engere Heuristik (z.B. Levenshtein- Distanz oder Gewichtung nach Treffergüte) wäre eine sinnvolle Erweiterung. --- ## 9. Deployment Notes ### 9.1 Files included Auf den HF Space werden folgende Dateien gepusht: - `app.py` - `requirements.txt` - `apartment_price_model.pkl` - `model_features.pkl` - `bfs_municipality_data_enriched.csv` - `README.md` (mit HF-Frontmatter) `train_model.py`, die Roh-CSVs (`apartments_data.csv`, `bfs_municipality_and_tax_data.csv`) und `documentation.md` bleiben im Git-Repo der Schule, müssen aber nicht im Space liegen, da der Space das Modell nur lädt (nicht neu trainiert). ### 9.2 Secrets / Environment Variables | Name | Pflicht | Default | |------|---------|---------| | `OPENAI_API_KEY` | Ja | — | | `OPENAI_MODEL` | Nein | `gpt-4o-mini` | In den HF-Space-Settings unter *Settings → Variables and secrets → New secret* hinzufügen. ### 9.3 Deployment Result App-URL: **https://huggingface.co/spaces/dubattim/apartment-predictor-chat** Initial-Build schlug fehl, weil HF Spaces standardmäßig Python 3.13 verwendet und das von Gradio 5.0.0 transitiv genutzte `pydub`-Paket auf das in 3.13 entfernte `audioop`-Modul angewiesen ist. Behoben durch Bumpen von `sdk_version` im README auf `5.49.1` (neuere Gradio-Versionen brauchen `pydub` für die Standard-Komponenten nicht mehr) und Hinzufügen von `audioop-lts` als 3.13- Backport in `requirements.txt`. Nach dem zweiten Push baute der Space sauber durch und antwortet auf alle drei Test-Prompts wie erwartet. ### 9.4 Screenshots ![Beispiel 1: 3.5-Zimmer Winterthur](screenshots/example1.png) *Beispiel 1:* Eingabe „Ich suche eine 3.5-Zimmer-Wohnung mit 85 m² in Winterthur." — das LLM extrahiert `{"rooms": 3.5, "area_m2": 85, "town": "Winterthur"}`, das Modell schätzt **2064 CHF** und das LLM formuliert eine deutsche Antwort inklusive Unsicherheits-Hinweis (Zustand, Mikrolage, Baujahr, Ausstattung). ![Beispiel 2: 4.5-Zimmer Dietlikon](screenshots/example2.png) *Beispiel 2:* Eingabe „4.5 Zimmer, 120 m², Dietlikon" (Stichwort-Format ohne ganzen Satz) — das LLM erkennt das Format trotzdem und extrahiert `{"rooms": 4.5, "area_m2": 120, "town": "Dietlikon"}`. Die Schätzung ergibt **3008 CHF** und die Erklärung weist erneut auf nicht modellierte Faktoren hin. --- ## 10. Reflection Die Kombination aus Regression-Modell und LLM funktioniert gut, weil jedes der beiden Systeme das tut, was es am besten kann: das LLM versteht freie Sprache, das Modell rechnet auf Tabellen-Features. Die größte Fragilität liegt im LLM-Layer — wenn das Modell „Berlin" mit einer Schweizer Gemeinde verwechselt oder ein Feld als String statt Zahl zurückgibt, fängt die Validierung das zwar ab, aber der Nutzer bekommt eine wenig hilfreiche Fehlermeldung. Deutsche Eingabe ist hier zentral, weil die Schweizer Gemeindenamen (mit Umlauten und spezifischer Schreibweise) auf Deutsch reagieren — *„Zurich"* würde z.B. bei strikter Substring-Suche nicht *„Zürich"* matchen. Verbessern würde ich als nächstes: a) bessere Fuzzy-Matching-Heuristik mit Levenshtein, b) zusätzliche Eingabe-Felder im Modell wie Baujahr/Zustand/Stockwerk, c) ein optionales Validierungs-Retry beim LLM, falls das JSON ungültig zurückkommt. --- ## 11. Responsible Use Note Die Schätzung ist nur ein grober Richtwert auf Basis weniger Features (Zimmer, Fläche, Gemeinde-Statistiken). Reale Mietpreise hängen zusätzlich vom Zustand der Wohnung, Mikrolage, Baujahr, Ausstattung und Marktlage ab — Faktoren, die das Modell nicht kennt. Außerdem kann das LLM beim Extrahieren der Felder Fehler machen (falscher Ort, falsch interpretierte Zahl); deshalb zeigt die App das extrahierte JSON immer transparent an, damit Nutzer:innen die Eingabe vor der Schätzung kontrollieren können.