---
title: "We Analyzed 55,000 Apartment Photos with AI — Here's What Predicts Rent"
subtitle: "How AI visual analysis extracts quality features from listing photos and improves rent prediction accuracy."
description: "We used AI to analyze 55,000 Berlin apartment photos and extract visual quality features. Renovation level is now a top-4 rent predictor, the balcony puzzle is resolved, and prediction accuracy improved significantly."
author: "Klaus Redel"
date: "2026-03-22"
categories: [AI, Machine Learning, Berlin, Data Analysis]
image: ai-apartment-analysis.png
lang: en
keywords:
- AI apartment photo analysis
- rent prediction machine learning
- Berlin rental market AI
- computer vision property valuation
- real estate AI
open-graph:
title: "55,000 Apartment Photos Analyzed with AI — What Predicts Rent?"
description: "AI visual analysis extracts quality features from listing photos. Renovation level is now a top rent predictor."
---
```{python}
#| echo: false
#| output: false
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import numpy as np
import pandas as pd
from pathlib import Path
# RentSignal brand colors
GREEN = "#00BC72"
RED = "#DC2626"
TEAL = "#004746"
ACCENT = "#E8913A"
GRAY = "#6B7280"
BG = "#FAFAFA"
BLUE = "#2563EB"
# Load data for maps
PROJECT_ROOT = Path('../../../').resolve()
PROC_DIR = PROJECT_ROOT / 'data' / 'processed'
try:
units = pd.read_parquet(PROC_DIR / 'units.parquet')
listings = pd.read_parquet(PROC_DIR / 'listings.parquet')
df_map = units.merge(listings[['unit_id', 'rent_sqm']], on='unit_id')
df_map = df_map[df_map['lat'].notna() & df_map['lon'].notna()]
HAS_DATA = True
except:
HAS_DATA = False
```
## TL;DR
We fed 55,000 Berlin apartment listing photos to an AI vision model and extracted visual quality features per apartment — interior quality, kitchen condition, floor type, ceiling height, building facade, and more. The results:
- **Prediction accuracy improved significantly** — photo features became top-5 predictors
- **Renovation level is now the #4 most important rent predictor** overall
- **The balcony puzzle is resolved** — a counter-intuitive finding from earlier analysis was explained by visual quality confounding
- **Novel treatment effects discovered** — Dielen floors add +€1.28/m², high ceilings +€1.97/m²
## The Problem: What Photos Tell Us That Forms Can't
When a landlord lists an apartment, they fill in structured fields: square meters, rooms, floor, year built. Our ML model uses these to predict rent.
But consider two apartments with identical form data — both 65 m², 2 rooms, built 1905, kitchen included, condition "normal." Same prediction. But in reality:
- **Apartment A** has original wide floorboards, 3.5m stucco ceilings, freshly painted walls, and a modern fitted kitchen.
- **Apartment B** has worn laminate flooring, standard ceilings, dated wallpaper, and a basic kitchenette.
The photos tell the story. The structured data doesn't.
```{python}
#| echo: false
#| label: fig-form-vs-photo
#| fig-cap: "The same apartment data can hide massive quality differences that only photos reveal"
fig = go.Figure()
categories = ['Interior Quality', 'Kitchen Quality', 'Brightness', 'Floor Quality', 'Renovation Level']
apt_a = [4, 4, 5, 5, 4]
apt_b = [2, 1, 2, 2, 2]
fig.add_trace(go.Scatterpolar(
r=apt_a + [apt_a[0]], theta=categories + [categories[0]],
fill='toself', fillcolor='rgba(0,188,114,0.2)',
line=dict(color=GREEN, width=2),
name='Apartment A (renovated Altbau)'
))
fig.add_trace(go.Scatterpolar(
r=apt_b + [apt_b[0]], theta=categories + [categories[0]],
fill='toself', fillcolor='rgba(220,38,38,0.2)',
line=dict(color=RED, width=2),
name='Apartment B (unrenovated)'
))
fig.update_layout(
polar=dict(radialaxis=dict(range=[0, 5], tickvals=[1,2,3,4,5])),
height=400, margin=dict(t=30, b=30),
paper_bgcolor=BG, font=dict(family="Inter", size=12),
legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5)
)
fig.show()
```
## The Pipeline
### Collecting the Photos
Our March 2026 scrape of ImmoScout24 Berlin captured over 8,000 listings. Most had multiple photos — averaging about 9 per listing. We downloaded all available photos: **over 54,000 images**.
```{python}
#| echo: false
#| label: fig-listing-map
#| fig-cap: "8,259 Berlin listings analyzed — colored by rent level (€/m²)"
if HAS_DATA:
sample = df_map.sample(min(3000, len(df_map)), random_state=42)
fig = go.Figure()
fig.add_trace(go.Scattermapbox(
lat=sample['lat'], lon=sample['lon'],
mode='markers',
marker=dict(
size=4, opacity=0.6,
color=sample['rent_sqm'],
colorscale=[[0, '#2166ac'], [0.3, '#67a9cf'], [0.5, '#f7f7f7'], [0.7, '#ef8a62'], [1, '#b2182b']],
cmin=8, cmax=30,
colorbar=dict(title="€/m²", thickness=15, len=0.6),
),
text=[f"€{r:.1f}/m² · {b}" for r, b in zip(sample['rent_sqm'], sample['bezirk'])],
hoverinfo='text',
))
fig.update_layout(
mapbox=dict(style="carto-positron", center=dict(lat=52.52, lon=13.405), zoom=10),
height=500, margin=dict(l=0, r=0, t=0, b=0),
)
fig.show()
```
### Extracting Visual Features
We designed a structured extraction schema covering both interior and exterior characteristics. For each listing, the AI analyzed up to 10 photos simultaneously — not one at a time — because a bathroom score requires seeing the bathroom, and a building facade assessment requires the exterior photo.
**What we extract (selected features):**
- Interior quality score (1-5)
- Kitchen and bathroom quality (0-5, where 0 = not visible in photos)
- Renovation level (1-5)
- Floor type (Dielen, parquet, laminate, tile...)
- Ceiling height (high/normal/low)
- Architectural style (Altbau, modern, Neubau, Plattenbau...)
- Building facade condition
- And several more visual indicators
**Success rate:** Over 95% of listings were successfully analyzed. Total processing cost was under €50 for the entire dataset.
### Validation: Do Photo Features Actually Predict Rent?
```{python}
#| echo: false
#| label: fig-correlations
#| fig-cap: "Correlation of AI-extracted photo features with rent — compared to traditional spatial features"
features = [
'Renovation Level\n(AI photo)', 'Interior Quality\n(AI photo)', 'Brightness\n(AI photo)',
'Kitchen Quality\n(AI photo)', 'Building Condition\n(AI photo)',
'Water Proximity\n(satellite)', 'Built-up Index\n(satellite)',
'Transit Density\n(spatial)', 'Food Venues 1km\n(spatial)',
'Vegetation Index\n(satellite)'
]
correlations = [0.498, 0.460, 0.330, 0.313, 0.270, 0.246, 0.206, 0.237, 0.153, -0.238]
colors = [BLUE if 'AI' in f else ACCENT if 'satellite' in f else GREEN for f in features]
fig = go.Figure()
fig.add_trace(go.Bar(
y=features, x=correlations, orientation='h',
marker_color=colors,
text=[f"r = {c:+.2f}" for c in correlations],
textposition='outside', textfont=dict(size=11),
))
fig.update_layout(
height=450, margin=dict(l=10, r=80, t=10, b=40),
plot_bgcolor="white", paper_bgcolor=BG,
font=dict(family="Inter", size=12),
xaxis=dict(title="Correlation with Rent (€/m²)", gridcolor="#E5E7EB", range=[-0.3, 0.6]),
yaxis=dict(autorange="reversed"),
)
fig.show()
```
**The AI photo features outperform every traditional spatial feature.** Renovation level (r=+0.50) is 2× more predictive than the best satellite index. What the apartment *looks like* matters more than where it *is*.
## The Impact on Model Performance
```{python}
#| echo: false
#| label: fig-model-progression
#| fig-cap: "Model accuracy progression as we added feature layers"
models = ['Structural\nonly', '+ Spatial\n(OSM+satellite)', '+ NLP\ntitle features', '+ AI Photo\nfeatures', '+ Rent\nneighbors']
r2_values = [0.689, 0.708, 0.736, 0.761, 0.814]
colors_bar = [GRAY, ACCENT, TEAL, BLUE, GREEN]
fig = go.Figure()
fig.add_trace(go.Bar(
x=models, y=r2_values,
marker_color=colors_bar,
text=[f"R²={v:.3f}" for v in r2_values],
textposition='outside', textfont=dict(size=13, weight=600),
))
fig.update_layout(
height=350, margin=dict(l=40, r=20, t=20, b=60),
plot_bgcolor="white", paper_bgcolor=BG,
font=dict(family="Inter", size=12),
yaxis=dict(title="R² (prediction accuracy)", range=[0.6, 0.85], gridcolor="#E5E7EB"),
xaxis=dict(title="Feature layers added cumulatively"),
)
fig.show()
```
Each layer adds meaningful accuracy. The AI photo features alone improved R² from 0.736 to 0.761 — a larger jump than adding satellite data.
## The Balcony Puzzle: Resolved
Our earlier analysis found that balconies **decreased** rent by -€0.72/m². This was our signature (and controversial) finding.
With AI photo features as additional confounders in the causal analysis, the balcony effect **flipped to +€1.08/m²**.
```{python}
#| echo: false
#| label: fig-balcony-flip
#| fig-cap: "The balcony effect flipped from negative to positive when controlling for visual building quality"
fig = go.Figure()
treatments = ['Kitchen', 'Elevator', 'Garden', 'Balcony']
v1 = [2.91, 1.09, 0.93, -0.72]
v2 = [3.48, 1.59, 1.36, 1.08]
fig.add_trace(go.Bar(
name='2019 analysis (11 confounders)',
x=treatments, y=v1,
marker_color='rgba(107,114,128,0.5)',
text=[f"€{v:+.2f}" for v in v1],
textposition='outside', textfont=dict(size=12),
))
fig.add_trace(go.Bar(
name='2026 analysis (27 confounders + AI photos)',
x=treatments, y=v2,
marker_color=GREEN,
text=[f"€{v:+.2f}" for v in v2],
textposition='outside', textfont=dict(size=12),
))
fig.add_hline(y=0, line_dash="solid", line_color=GRAY, line_width=1)
fig.update_layout(
barmode='group', height=380,
margin=dict(l=40, r=20, t=30, b=40),
plot_bgcolor="white", paper_bgcolor=BG,
font=dict(family="Inter", size=12),
yaxis=dict(title="Causal Effect on Rent (€/m²)", gridcolor="#E5E7EB"),
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
)
fig.show()
```
**Why did it flip?** The original negative effect was **confounded by building quality**. Older, less renovated buildings tend to have balconies (added later, often to Plattenbau or 1960s buildings). Without controlling for visual renovation quality — which only AI photo analysis can provide — the balcony appeared to reduce rent. In reality, it was the building's poor condition driving the lower rent, not the balcony itself.
This is a textbook example of **omitted variable bias** — resolved by AI-extracted features that were previously unobservable at scale.
## Novel Findings: What AI Photos Reveal
With visual features as both predictors and treatment indicators, we could estimate causal effects for previously unmeasurable characteristics:
```{python}
#| echo: false
#| label: fig-novel-effects
#| fig-cap: "Novel causal effects estimated using AI-extracted visual features"
treatments_novel = ['High Ceilings\n(Altbau)', 'Dielen Floors\n(wide boards)', 'Balcony\n(revised)', 'Cellar\nAccess']
cate_novel = [1.97, 1.28, 1.08, -0.90]
ci_low = [0.59, 0.17, 0.21, -1.56]
ci_high = [3.28, 2.33, 1.97, -0.22]
colors_novel = [GREEN if v > 0 else RED for v in cate_novel]
fig = go.Figure()
fig.add_trace(go.Bar(
y=treatments_novel, x=cate_novel, orientation='h',
marker_color=colors_novel,
text=[f"€{v:+.2f}/m²" for v in cate_novel],
textposition='outside', textfont=dict(size=13, weight=600),
error_x=dict(
type='data', symmetric=False,
array=[h - v for v, h in zip(cate_novel, ci_high)],
arrayminus=[v - l for v, l in zip(cate_novel, ci_low)],
color=GRAY, thickness=1.5, width=4
),
))
fig.add_vline(x=0, line_dash="solid", line_color=GRAY, line_width=1)
fig.update_layout(
height=300, margin=dict(l=10, r=80, t=10, b=40),
plot_bgcolor="white", paper_bgcolor=BG,
font=dict(family="Inter", size=12),
xaxis=dict(title="Causal Effect on Rent (€/m²)", gridcolor="#E5E7EB"),
yaxis=dict(autorange="reversed"),
)
fig.show()
```
**High ceilings** (+€1.97/m²) are the largest "feature premium" — you can't renovate them into existence, but knowing their value helps with acquisition decisions and pricing.
**Dielen floors** (+€1.28/m²) are an actionable finding — replacing laminate with wide floorboards is a renovation that pays for itself.
## For Property Managers
### Photos Improve Your Predictions
RentSignal accepts photo uploads when adding an apartment. Our AI analyzes them in seconds and uses the visual features for a more accurate prediction — up to **18% more accurate** than form-only input.
### The "Hidden" Value in Your Portfolio
You might be sitting on unphotographed value. High ceilings, original Dielen floors, modern bathrooms — these are worth €1-2/m² each. If your listing doesn't show them, the market can't price them in.
[→ Upload photos for AI-enhanced rent prediction](https://rentsignal.de?utm_source=blog&utm_medium=cta&utm_campaign=ai-photos&utm_content=mid-article)
---
## Try It
[→ Add your apartment with photos](https://rentsignal.de/dashboard/add?utm_source=blog&utm_medium=cta&utm_campaign=ai-photos&utm_content=bottom)
[→ Create a free account](https://rentsignal.de/signup?utm_source=blog&utm_medium=cta&utm_campaign=ai-photos&utm_content=signup)
---
*This article is based on the analysis pipeline of [RentSignal](https://rentsignal.de) — the data-driven rent intelligence platform for the German rental market.*
---
::: {.callout-note appearance="simple"}
## Deutsche Zusammenfassung
**55.000 Wohnungsfotos mit KI analysiert.** Wir haben ein KI-Modell eingesetzt, um visuelle Qualitätsmerkmale pro Wohnung zu extrahieren — Renovierungsgrad, Küchenqualität, Bodenbelag und mehr. Der Renovierungsgrad ist jetzt der viertwichtigste Mietpreis-Prädiktor, und das Balkon-Rätsel (negativer Effekt in früheren Analysen) ist durch die Kontrolle visueller Gebäudequalität gelöst. [Jetzt Fotos hochladen →](https://rentsignal.de?utm_source=blog&utm_medium=cta&utm_campaign=ai-photos&utm_content=de-summary)
:::