Spaces:
Sleeping
Sleeping
| # 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": "<Text>"}. | |
| 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:* 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:* 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. | |