Companion to “From Idea to Prototype in an Afternoon: Scaffolded, AI-Assisted Rapid Prototyping in Visual Analytics.” This appendix documents every step that turned a public, coordinate-free listings dump into the geocoded table used by the SoftSky demonstrator, so the result can be audited and reproduced.
The pipeline is deterministic given the same dataset snapshot and the same OpenStreetMap (OSM) extract. Coordinates are derived by geocoding street addresses, not read from the file. Two short scripts do the work: one cleans, one geocodes; both are listed in full below.
The raw data is the public Kaggle dataset Apartment rental offers in Germany (immo_data.csv), scraped from the ImmoScout24 portal across 2018–19, comprising 268,850 listings nationwide and released under CC BY-NC-SA. We take the subset regio2 == "Leipzig" → 13,723 offers, the single largest city in the dataset.
Its spatial fields are administrative and textual only: regio1/2/3 (state / city / quarter), geo_bln, geo_krs, geo_plz (5-digit postal code, present for 100% of rows), and street / streetPlain / houseNumber (a street is given for ~73.6% of the German rows; the remainder read "no_information"). Rent and size fields (baseRent, serviceCharge, totalRent, livingSpace, noRooms, …) carry the usual portal noise: out-of-range values, blanks, and re-posted duplicates.
The idea under test is spatial, so the demonstrator needed a city whose addresses geocode well. Among large German cities in the dataset, Leipzig combined the largest raw count with the most complete street information, which translates directly into geocoding precision: after processing, 90.4% of Leipzig offers resolve to an exact house-number point (see §6). A city with sparser street fields would have pushed more listings down to coarse postal-code centroids, weakening the spatial signal the technique relies on. Leipzig was therefore chosen on data quality, not convenience.
Because positions are derived, each row also carries a geoLevel flag recording how precisely it was located — house, street, or plz — so downstream users can weight or filter by spatial certainty.
Cleaning applies seven deterministic steps. Plausibility bounds remove physically impossible values; out-of-range secondary fields are set to missing rather than dropped; exact duplicates (re-posts of the same unit) are collapsed.
geo_plz.totalRent = baseRent + serviceCharge where totalRent is missing.baseRent ∈ [50, 10000], livingSpace ∈ [10, 400], noRooms ∈ [1, 10], totalRent ≤ 15000.serviceCharge outside [0, 2000], yearConstructed outside [1850, 2025], floor outside [−2, 60].hasKitchen, balcony, cellar, lift, garden, newlyConst) to 0/1.(geo_plz, baseRent, totalRent, livingSpace, noRooms, typeOfFlat, floor).pricePerSqm = baseRent / livingSpace.| Stage | Rows | Removed |
|---|---|---|
Raw subset (regio2 == "Leipzig") | 13,723 | — |
| After plausibility filter | 13,717 | 6 |
| After de-duplication | 12,849 | 868 |
| After geocoding (4 unlocatable dropped) | 12,845 | 4 |
import pandas as pd, numpy as np
df = pd.read_csv("immo_data.csv", low_memory=False)
# 1. subset Leipzig -> 13,723 rows
df = df[df["regio2"] == "Leipzig"].copy()
# 2. valid 5-digit postal code
df = df[df["geo_plz"].astype(str).str.fullmatch(r"\d{5}")]
# 3. derive totalRent where missing
df["totalRent"] = df["totalRent"].fillna(
df["baseRent"] + df["serviceCharge"].fillna(0))
# 4. plausibility filter -> 13,717 rows
keep = (df.baseRent.between(50, 10000) & df.livingSpace.between(10, 400)
& df.noRooms.between(1, 10) & (df.totalRent <= 15000))
df = df[keep].copy()
# 5. range-clip secondary fields to missing (keep the row)
df.loc[~df.serviceCharge.between(0, 2000), "serviceCharge"] = np.nan
df.loc[~df.yearConstructed.between(1850, 2025),"yearConstructed"] = np.nan
df.loc[~df.floor.between(-2, 60), "floor"] = np.nan
# 6. booleans -> 0/1
for c in ["hasKitchen","balcony","cellar","lift","garden","newlyConst"]:
df[c] = (df[c].map({True:1, False:0, "True":1, "False":0})
.fillna(0).astype(int))
# 7. drop exact duplicates -> 12,849 rows
df = df.drop_duplicates(subset=["geo_plz","baseRent","totalRent",
"livingSpace","noRooms","typeOfFlat","floor"])
# derive price per square metre
df["pricePerSqm"] = (df.baseRent / df.livingSpace).round(2)
df.to_csv("leipzig_clean.csv", index=False) # feeds the geocoder
To respect OSM usage policy, addresses are resolved with a single bulk Overpass query over the Leipzig administrative boundary, not thousands of per-address Nominatim calls. The query returned 81,448 address elements, normalized into a lookup of 73,878 unique house-number addresses, 2,937 street centroids, and 37 postal-code centroids.
User-Agent header (returning HTTP 406). Setting a real User-Agent identifying the research use fixes it. This single header is the most common reason a first geocoding attempt fails silently.Each listing is matched after normalizing both sides — HTML-entity decoding (so Hauptstraße → Hauptstraße), ß/umlaut folding, straße/str. collapse, and house-number whitespace stripping — with a graded fallback. Where the exact number is not found, the row falls back to the street centroid, then to the postal-code centroid.
| Level | Meaning | Listings | Share |
|---|---|---|---|
house | Exact address point | 11,615 | 90.4% |
street | Street known, number unmatched | 179 | 1.4% |
plz | No usable street → postal-code centroid | 1,051 | 8.2% |
| dropped | Unlocatable | 4 | 0.0% |
| Geocoded total | 12,845 | 100% | |
House-number points are kept at their exact coordinates. Only postal-code-centroid points receive a small separating jitter (~15 m) at display time in the demonstrator so co-located markers do not overlap; the stored coordinates remain the unjittered centroids.
import requests, html, re, pandas as pd
from collections import defaultdict
OVERPASS = "https://overpass-api.de/api/interpreter"
HEADERS = {"User-Agent": "SoftSky-research/1.0 (contact: you@example.org)"} # 406 fix
# --- 1. one bulk query over the Leipzig admin boundary (admin_level 6) ---
Q = """
[out:json][timeout:300];
area["name"="Leipzig"]["admin_level"="6"]->.a;
( node["addr:housenumber"](area.a);
way ["addr:housenumber"](area.a); );
out center tags;
"""
els = requests.post(OVERPASS, data={"data": Q}, headers=HEADERS).json()["elements"]
# --- 2. normalisation (apply identically to OSM and to listings) ---
def nstreet(s):
s = html.unescape(str(s)).lower()
s = (s.replace("ä","ae").replace("ö","oe")
.replace("ü","ue").replace("ß","ss"))
s = re.sub(r"stra(ss|ße)e?\b", "str", s)
s = re.sub(r"str\.?", "str", s)
return re.sub(r"\s+", " ", s).strip()
def nnum(n): return re.sub(r"\s+", "", html.unescape(str(n))).lower()
# --- 3. build lookups: exact address, street centroid, plz centroid ---
addr, street_pts, plz_pts = {}, defaultdict(list), defaultdict(list)
for e in els:
t = e.get("tags", {})
lat = e.get("lat") or e.get("center", {}).get("lat")
lon = e.get("lon") or e.get("center", {}).get("lon")
if lat is None: continue
st, nu, pz = t.get("addr:street"), t.get("addr:housenumber"), t.get("addr:postcode")
if st and nu:
addr[(nstreet(st), nnum(nu))] = (lat, lon)
street_pts[nstreet(st)].append((lat, lon))
if pz: plz_pts[str(pz)].append((lat, lon))
def centroid(pts):
return (sum(p[0] for p in pts)/len(pts),
sum(p[1] for p in pts)/len(pts))
street_c = {k: centroid(v) for k,v in street_pts.items()}
plz_c = {k: centroid(v) for k,v in plz_pts.items()}
# --- 4. graded match per listing, recording geoLevel ---
df = pd.read_csv("leipzig_clean.csv", low_memory=False)
lat, lng, lvl = [], [], []
for _, r in df.iterrows():
st, nu, pz = nstreet(r.get("street","")), nnum(r.get("houseNumber","")), str(r.geo_plz)
if (st, nu) in addr: p, L = addr[(st, nu)], "house"
elif st in street_c: p, L = street_c[st], "street"
elif pz in plz_c: p, L = plz_c[pz], "plz"
else: p, L = (None, None), None
lat.append(p[0]); lng.append(p[1]); lvl.append(L)
df["lat"], df["lng"], df["geoLevel"] = lat, lng, lvl
df = df.dropna(subset=["lat","lng"]) # drop 4 unlocatable -> 12,845
df.to_csv("leipzig_clean_geo.csv", index=False)
The normalization above is the reference behavior; minor regex differences will not change which rows match at house level, only a handful of borderline street spellings.
leipzig_clean_geo.csv holds 12,845 rows in 27 columns: the original 24 retained fields plus the derived pricePerSqm and the geocoded lat, lng, and geoLevel. Coordinates are WGS-84.
| Property | Value |
|---|---|
| Distinct coordinate points | 6,376 |
| Bounding box (lat) | 51.261 – 51.423 |
| Bounding box (lng) | 12.248 – 12.521 |
| Centroid | ≈ (51.34, 12.37) |
| Median base rent | €460 |
| Median living space | 66 m² |
regio1 / regio2 | Sachsen / Leipzig |
| Column | Description |
|---|---|
regio1 | Federal state (here Sachsen) |
regio2 | City / district (Leipzig) |
regio3 | Quarter / locality (e.g. Lindenau) |
geo_plz | 5-digit postal code |
baseRent | Cold rent, €/month |
serviceCharge | Ancillary costs, €/month (blanked if outside [0,2000]) |
totalRent | Warm rent, €/month (derived where missing) |
livingSpace | Living area, m² |
noRooms | Number of rooms |
floor | Floor of the unit (blanked if outside [−2,60]) |
numberOfFloors | Floors in the building |
yearConstructed | Construction year (blanked if outside [1850,2025]) |
typeOfFlat | Apartment type (e.g. roof_storey) |
condition | Unit / building condition |
interiorQual | Interior quality |
heatingType | Heating system |
hasKitchen | Fitted kitchen (0/1) |
balcony | Balcony (0/1) |
cellar | Cellar (0/1) |
lift | Elevator (0/1) |
garden | Garden (0/1) |
newlyConst | Newly constructed (0/1) |
date | Scrape period (e.g. Oct19) |
pricePerSqm | Derived: baseRent / livingSpace, €/m² |
lat | Latitude (WGS-84), geocoded |
lng | Longitude (WGS-84), geocoded |
geoLevel | Geocoding precision: house | street | plz |
# Python 3.11; only two third-party packages are needed
pip install pandas requests
# 1) obtain immo_data.csv from Kaggle (login required), place it here
# https://www.kaggle.com/datasets/corrieaar/apartment-rental-offers-in-germany
python clean_leipzig.py # immo_data.csv -> leipzig_clean.csv
python geocode_overpass.py # leipzig_clean.csv -> leipzig_clean_geo.csv
immo_data.csv yields identical counts (13,723 → 13,717 → 12,849).house-level share can only rise; record the query date for exact reproduction.User-Agent or Overpass returns HTTP 406 and no data.Geocoding accuracy. 90.4% of rows are exact address points; the remaining ~9.6% sit at street or postal-code centroids and are coarser by design. The geoLevel flag makes this explicit so analyses can exclude or down-weight imprecise rows.
Temporal scope. Listings are from 2018–19 and reflect that market, not current rents. The OSM extract is contemporaneous with processing, not with the listings.
Selection & portal bias. The data is one portal's online offers, not a census of the housing stock; price and composition are biased toward what was advertised on ImmoScout24.
Privacy & license. Records are public, aggregated property advertisements with no personal identifiers. Processing adds none. Reuse is bound by CC BY-NC-SA (attribution, non-commercial, share-alike); OSM-derived coordinates are © OpenStreetMap contributors under the ODbL.