Chapter 03 · Section II · 30 min read
Predicting house rents in Kathmandu
A complete worked example. Scrape, clean, and engineer features from a small dataset of Kathmandu rental listings; train a linear regression; honestly evaluate it. The kind of project a builder can finish in a weekend and a landlord can actually use.
Now we use linear regression on a problem you genuinely could deploy: predicting Kathmandu rents from listing features. The data is small (300 listings is plenty), the features are intuitive, and the result is a model that — given a flat’s size, bedrooms, area, and a few other characteristics — predicts the rent within roughly Rs 4,000–7,000. By the end of this section you will have a working regression pipeline you can adapt to any local prediction problem in your own neighbourhood.
The dataset
Real Kathmandu rental data comes from a few sources:
- Hamrobazar listings. Public, scrapeable (politely; recall Course 02 Chapter 2 Section 3). Roughly 300-1,000 active rental listings at any given time.
- OnlineKhabar real estate, Khojibetha, ImeKhotnu — similar scrape-able directories.
- Facebook Marketplace — much harder to scrape; do not try.
- Your own neighbourhood. Ask landlords directly; collect 50-100 listings with the rent, area, size, bedrooms.
For this section we’ll assume you have a CSV at data/raw/kathmandu_rentals.csv with roughly these columns:
listing_id, rent_npr, size_sqft, bedrooms, bathrooms, area, parking,
furnished, floor, building_type, date_posted, source_url
A small but real warning: rent listings have asking prices, not agreed prices. The actual rent a tenant pays is often 5-15% lower. The model will predict the listing price; communicate that caveat clearly.
Loading and a first look
import pandas as pd
df = pd.read_csv("data/raw/kathmandu_rentals.csv")
print(df.shape)
print(df.dtypes)
print()
print(df["rent_npr"].describe())
print()
print(df["area"].value_counts().head(10))
You should see roughly 300 rows, 11 columns, mean rent around Rs 35,000–45,000, and a long tail toward higher rents. The area column will show Thamel, Baluwatar, Sanepa, Lalitpur, Patan, etc. — the “most desirable” areas have the most listings.
Step 1 — Clean
Use everything from Course 02 Chapter 3:
import re
# Normalise area names
df["area"] = df["area"].str.strip().str.title()
area_aliases = {
"Kupondol": "Kupondole",
"Lalitpur Sub-Metro": "Lalitpur",
"Patan Dhoka": "Patan",
}
df["area"] = df["area"].replace(area_aliases)
# Drop rows with missing essentials
df = df.dropna(subset=["rent_npr", "size_sqft", "area"])
# Bound implausible rents (data entry errors)
df = df[df["rent_npr"].between(5000, 500000)]
df = df[df["size_sqft"].between(100, 5000)]
print("After cleaning:", df.shape)
You will lose 5-10% of rows to cleaning. That is normal; better to lose them than to feed garbage to the model.
Step 2 — Engineer features
A few derived features that domain intuition suggests will help:
# Size in tens of sqft (more interpretable coefficients later)
df["size_100sqft"] = df["size_sqft"] / 100
# Rooms per 100sqft — small flats with many rooms are crowded
df["rooms_per_100sqft"] = df["bedrooms"] / df["size_100sqft"]
# Is this in a high-rent area?
high_rent_areas = ["Baluwatar", "Lazimpat", "Bhatbhateni", "Sanepa", "Naxal"]
df["is_high_rent_area"] = df["area"].isin(high_rent_areas).astype(int)
# Is this a top-floor flat? (floor 4+)
df["is_top_floor"] = (df["floor"] >= 4).astype(int)
# Days since posting — older listings often have stale prices
df["date_posted"] = pd.to_datetime(df["date_posted"])
df["days_since_posted"] = (pd.Timestamp.now() - df["date_posted"]).dt.days
Domain features like these are where regression models earn their reputation. The model on its own does not know that Baluwatar is expensive; you teach it by adding the is_high_rent_area feature.
Step 3 — Split and pipeline
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import Pipeline
numeric = [
"size_100sqft", "bedrooms", "bathrooms",
"rooms_per_100sqft", "is_top_floor", "is_high_rent_area",
"days_since_posted",
]
categorical = ["area", "building_type", "furnished", "parking"]
X = df[numeric + categorical]
y = df["rent_npr"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42,
)
model = Pipeline([
("preprocess", ColumnTransformer([
("num", StandardScaler(), numeric),
("cat", OneHotEncoder(handle_unknown="ignore"), categorical),
])),
("regressor", LinearRegression()),
])
model.fit(X_train, y_train)
Same shape as every model so far. The pipeline handles scaling and encoding without leakage.
Step 4 — Evaluate
import numpy as np
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
predictions = model.predict(X_test)
mae = mean_absolute_error(y_test, predictions)
rmse = np.sqrt(mean_squared_error(y_test, predictions))
r2 = r2_score(y_test, predictions)
print(f"MAE: Rs {mae:,.0f}")
print(f"RMSE: Rs {rmse:,.0f}")
print(f"R²: {r2:.3f}")
Output (illustrative; yours will vary):
MAE: Rs 4,832
RMSE: Rs 7,150
R²: 0.72
The model’s typical prediction error on test listings is about Rs 4,800. That is good enough to be useful — to a landlord setting a listing price, “Rs 4,800 off” is well within the negotiation range. The R² of 0.72 says the model explains 72% of the variance in rents from features alone. The remaining 28% is everything we didn’t capture: building age, view, neighbourhood specifics, landlord reputation, time-of-year effects, and irreducible noise.
Step 5 — Read the coefficients
preprocess = model.named_steps["preprocess"]
regressor = model.named_steps["regressor"]
feature_names = preprocess.get_feature_names_out()
# Top contributors by absolute weight
weights_with_names = list(zip(feature_names, regressor.coef_))
weights_with_names.sort(key=lambda x: abs(x[1]), reverse=True)
print("Top coefficients:")
for name, w in weights_with_names[:15]:
print(f" {name:45s} {w:+12.0f}")
print(f" {'intercept':45s} {regressor.intercept_:+12.0f}")
You’ll see something like:
num__size_100sqft +6,420
cat__area_Baluwatar +5,180
cat__area_Lazimpat +4,920
num__is_high_rent_area +3,840
cat__furnished_Fully furnished +3,500
cat__area_Kalanki -3,800
num__bedrooms +2,150
...
Read this. Each additional 100 sqft adds Rs 6,420 on average. Baluwatar is a +Rs 5,180 premium over the average. Kalanki is a -Rs 3,800 discount. Fully furnished adds Rs 3,500. These are reasonable numbers for Kathmandu rentals — the model has learned the local market.
A prediction for one new flat
new_flat = pd.DataFrame([{
"size_sqft": 850, "bedrooms": 2, "bathrooms": 1,
"area": "Sanepa", "parking": "Available",
"furnished": "Semi-furnished", "floor": 3,
"building_type": "Apartment",
"date_posted": pd.Timestamp.now(),
}])
# Recompute derived features (same as training)
new_flat["size_100sqft"] = new_flat["size_sqft"] / 100
new_flat["rooms_per_100sqft"] = new_flat["bedrooms"] / new_flat["size_100sqft"]
new_flat["is_high_rent_area"] = new_flat["area"].isin(["Baluwatar", "Lazimpat", "Bhatbhateni", "Sanepa", "Naxal"]).astype(int)
new_flat["is_top_floor"] = (new_flat["floor"] >= 4).astype(int)
new_flat["days_since_posted"] = 0
predicted_rent = model.predict(new_flat[numeric + categorical])[0]
print(f"Predicted rent: Rs {predicted_rent:,.0f}")
The model will print something like Predicted rent: Rs 47,300. Use it. Trust it within ±Rs 5,000. Show the predicted vs actual on the test set to anyone questioning it.
A residual plot — catching where the model is bad
A useful regression habit: plot actual against predicted, and actual - predicted (the residual) against predicted. The residual plot should be a featureless cloud of points around y=0. Any structure suggests the model is systematically wrong for some inputs:
import matplotlib.pyplot as plt
residuals = y_test - predictions
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].scatter(predictions, y_test, alpha=0.5)
axes[0].plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], "k--")
axes[0].set_xlabel("Predicted rent (NPR)")
axes[0].set_ylabel("Actual rent (NPR)")
axes[0].set_title("Actual vs Predicted")
axes[1].scatter(predictions, residuals, alpha=0.5)
axes[1].axhline(0, color="k", linestyle="--")
axes[1].set_xlabel("Predicted rent (NPR)")
axes[1].set_ylabel("Residual (NPR)")
axes[1].set_title("Residuals")
plt.show()
If the residual plot has a clear curve, your relationship is non-linear and a linear model is missing it. If it has a fan shape (residuals get larger for larger predictions), the model is heteroscedastic and you might want to predict log(rent) instead. We get to both in the next section.
Check your understanding
Quick check
—A teammate trains a rent prediction model on listings from Lalitpur. They want to deploy it to predict rents in Bhaktapur. What's the most important caveat?
Quick check
—Looking at the residual plot, you notice that large predicted rents (>Rs 80,000) have large positive residuals — i.e. actual rents tend to be much higher than predicted. What does this suggest about your linear model?
What comes next
Linear regression gets you a long way. But sometimes the relationship between features and target is genuinely curved or interactive, and no amount of feature engineering with a linear model will fit it. The next section meets two non-linear regressors — KNeighborsRegressor and DecisionTreeRegressor — and shows you how to recognise when a straight line just isn’t enough.