Research note

A more 'objective' value of a house in the UK (sans London)

As a country, we do tend to obsess over how downtrodden or genteel a neighbourhood, town or city is. Here, we try to quantify that obsession, to gauge the value of a house by its objectively measured 'affluence'. And as per the timeless national joke, London does its own thing

June 2026 · 10 min read

What listings sites don't tell you

Property sites are generous with the property itself: square footage, photos, floor plans, asking price, the lot. They're considerably shyer about everything around it. Rightmove will show you a map of nearby schools without the faintest hint of whether any of them are good; Zoopla's valuation tool leans almost entirely on what the house next door recently sold for. Neither tells someone moving to an unfamiliar area much about the deprivation, crime or income levels of the neighbourhood they're about to commit several hundred thousand pounds to, and a local forum mostly offers anecdote, which is not, strictly speaking, data.

This project's answer is a house-price model that takes the surrounding neighbourhood seriously as an input in its own right, rather than an afterthought bolted onto square footage and property type, and that actually measures how much of a house's price that neighbourhood explains.

The data: property records meet the census

England was the target, and not out of any lack of ambition. Scotland and Northern Ireland record small-area socioeconomic data under entirely different systems, so pooling all three would have meant reconciling incompatible geographies for comparatively little payoff, given that the great majority of the UK's internal relocators and new arrivals move within England anyway. Two datasets, joined at LSOA/MSOA level:

Land Registry doesn't record floor area, unhelpfully, so each sale was matched to its Energy Performance Certificate for that figure instead.

This was a lengthy process that consumed many afternoons.

The EPC register holds ~25 million certificates, and took distributed computing to match, with a bit of NLP to address inconsistent flat addressing rules, all just to add a floor area to some property sales.

The code and the merged dataset behind every table below are the dissertation's own

import pandas as pd
import numpy as np
from pathlib import Path
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.neural_network import MLPRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score

CODE_DIR = Path("../socioeconomichosueingvaluator")

df = pd.read_csv(CODE_DIR / "final_data_house_sales2025.csv", encoding="latin1")
df = df.drop(columns=["LSOA"])
df = pd.get_dummies(df, columns=["Type"], drop_first=True)   # Type_F, Type_S, Type_T vs. Detached baseline

X = df.drop(columns=["Paid Price", "Postcode", "Address"])
y = df["Paid Price"]

X_train, X_test, y_train, y_test, addr_train, addr_test, pc_train, pc_test = train_test_split(
    X, y, df["Address"], df["Postcode"],
    test_size=0.2, random_state=88,
)
print(f"{len(df):,} properties -> {len(X_train):,} train / {len(X_test):,} test")
X_train.head()
Output
16,997 properties -> 13,597 train / 3,400 test
Output
total_floor_area_m2 Employment Health Crime Income Education Type_F Type_S Type_T
15522 93.0 22570 20795 31483 56143 44.0 False True False
14135 89.0 3144 1974 11578 34493 43.0 False True False
13986 99.0 23461 24047 28964 55398 45.1 False False True
54 125.0 21077 20881 30192 66347 49.8 False True False
16779 85.0 13022 14454 22922 46384 42.5 False False False

Five models, one dataset

Every model below trains and tests on an 80/20 split. Two are linear: a straight line through the features, and the same line with a penalty added to stop the coefficients running wild with enthusiasm. Three are built to catch the kind of non-linear, interacting relationships a straight line simply can't:

def rmse(y_true, y_pred):
    return np.sqrt(mean_squared_error(y_true, y_pred))

scaler = StandardScaler().fit(X_train)
X_train_scaled = scaler.transform(X_train)
X_test_scaled = scaler.transform(X_test)

# Hyperparameters as tuned in the dissertation's own model.py
models = {
    "Linear Regression": (LinearRegression(), False),
    "Ridge Regression":  (Ridge(alpha=50), False),
    "Random Forest":     (RandomForestRegressor(n_estimators=100, max_depth=None,
                                                 min_samples_split=5, min_samples_leaf=2,
                                                 random_state=50), False),
    "Gradient Boosting": (GradientBoostingRegressor(n_estimators=300, learning_rate=0.1,
                                                     max_depth=4, min_samples_split=5,
                                                     min_samples_leaf=2, random_state=50), False),
    "Neural Network":    (MLPRegressor(hidden_layer_sizes=(100, 50), activation="relu",
                                        solver="adam", learning_rate_init=0.01, max_iter=500,
                                        random_state=50, alpha=0.001), True),   # scaled input
}

predictions, rows = {}, []
for name, (model, needs_scaling) in models.items():
    Xtr, Xte = (X_train_scaled, X_test_scaled) if needs_scaling else (X_train, X_test)
    model.fit(Xtr, y_train)
    y_pred = model.predict(Xte)
    predictions[name] = y_pred
    rows.append({"Model": name, "RMSE (£)": round(rmse(y_test, y_pred)),
                 "MAE (£)": round(mean_absolute_error(y_test, y_pred)),
                 "R2": round(r2_score(y_test, y_pred), 3)})

results = pd.DataFrame(rows).sort_values("RMSE (£)").reset_index(drop=True)
results
Output
Model RMSE (£) MAE (£) R2
0 Neural Network 167851 81403 0.743
1 Random Forest 170921 70244 0.733
2 Gradient Boosting 190435 71953 0.669
3 Linear Regression 204758 99517 0.617
4 Ridge Regression 204763 99548 0.617

Linear and Ridge Regression land in almost the same place, which isn't much of a surprise: Ridge is just Linear Regression with a penalty on large coefficients, and here that penalty barely lifts a finger. All three non-linear models beat both by a wide margin. Random Forest and the Neural Network come out close to tied: Random Forest wins on MAE (a lower typical error), the Neural Network wins on RMSE and R² (fewer really big misses), and Gradient Boosting trails a clear step behind both while still leaving the two linear models for dust. Random Forest is the one actually running the live web app, on the grounds that it wins on MAE.

That's the full model comparison, and also the last time this post treats the five as rivals. What's more interesting than which one wins is a mistake every single one of them makes.

Where the straight line breaks first: the opposite end of the country

Before getting to London, it's worth seeing what the linear models get wrong first, since it turns out to be a mirror image of what's coming. A straight line fitted to minimise overall error will always extrapolate past the point where the relationship it learned still makes any sense, and pushed far enough into deprived, low-income, high-crime territory, that extrapolation cheerfully crosses zero altogether:

linear_check = pd.DataFrame({
    "Address": addr_test.values,
    "Postcode": pc_test.values,
    "Actual (£)": y_test.values,
    "Linear predicted (£)": predictions["Linear Regression"],
}).sort_values("Linear predicted (£)").head(8).reset_index(drop=True)
linear_check
Output
Address Postcode Actual (£) Linear predicted (£)
0 FLAT 3 6 CHARLES STREET FY1 3HD 45000 -247282.664465
1 FLAT 4 6 CHARLES STREET FY1 3HD 45000 -247282.664465
2 8 PARKWOOD MEWS BB9 8TS 62500 -148764.884759
3 22 NITHSIDE FY4 4SA 155000 -97959.285636
4 49 BOARDMANS LANE WA9 1HH 48000 -82945.619041
5 11 GRAY GROVE L36 0TB 76500 -78221.444190
6 25 HAMMOON GROVE ST2 9DH 170000 -76146.129121
7 47 CAWTHORNE DRIVE HU4 7AS 90000 -69449.825820

Every one of Linear Regression's worst underpredictions is a cheap flat or terrace in the North of England: FY1 and FY4 are Blackpool; BB9 is Nelson, in Lancashire's old cotton-mill belt; WA9 is St Helens, a former glass-and-chemicals town in Merseyside; L36 is Huyton, on Liverpool's eastern edge; further down the list sit ST2 (Stoke-on-Trent) and HU4 (Hull). Blackpool is not, it turns out, a borderline case: on the 2025 Indices of Deprivation it's ranked the single most deprived local authority in England, first nationally on employment and health, second on crime (GOV.UK Indices of Deprivation 2025; Local Deprivation Explorer), which happens to be the exact dataset this model trained on.

The model isn't wrong to price that profile low; it's wrong about how low, because it's still drawing one straight line all the way from Kensington's income and school figures down to Blackpool's, and a line steep enough to fit the top of that range overshoots badly at the bottom. A model with no concept of a floor at zero will happily inform you that a house is worth minus a quarter of a million pounds, if that's what the slope demands at that end of the data. Rather humorously, it does suggest that the regression models consider these parts of England so undesirable you should only move to them if you were paid to do so, though on a more serious note, it is a nonsense suggestion. The other 3 models do not have such folly, although a problem of a similar ilk (but different magnitude) does arise

The mistake every model makes, in the same handful of postcodes

Moving from a straight line to Random Forest, Gradient Boosting or a Neural Network does fix that specific problem: none of the three ever predicts a negative price, since tree splits and a learned network function aren't forced through one global slope the way a straight line is. But all three independently land on a different failure, in exactly the same postcodes, and this time it's the opposite end of the country entirely:

def most_underpredicted(name, n=8):
    resid = predictions[name] - y_test.values
    idx = np.argsort(resid)[:n]
    return set(addr_test.values[idx])

# addresses appearing in every non-linear model's own worst-underpredicted list
shared = most_underpredicted("Random Forest") & most_underpredicted("Gradient Boosting") & most_underpredicted("Neural Network")

rows = []
for addr in shared:
    mask = addr_test.values == addr
    row = {"Address": addr, "Postcode": pc_test.values[mask][0], "Actual (£)": int(y_test.values[mask][0])}
    for name in ["Random Forest", "Gradient Boosting", "Neural Network"]:
        row[name] = round(predictions[name][mask][0])
    rows.append(row)

shared_underpredicted = pd.DataFrame(rows).sort_values("Actual (£)", ascending=False).reset_index(drop=True)
shared_underpredicted
Output
Address Postcode Actual (£) Random Forest Gradient Boosting Neural Network
0 73 MARLBOROUGH PLACE NW8 0PT 8600000 2695406 3779351 3451837
1 28 KENSINGTON PARK ROAD W11 3BU 5511000 3213029 3698170 3853500
2 9 BROOKFIELD PARK NW5 1ES 3100000 1281923 1388151 1135717
3 5 WYKEHAM ROAD NW4 2TB 3000000 1234995 1088400 1044618
4 14 TOWNSHEND ROAD TW9 1XH 2540000 1327235 1265582 1154771
5 1A CORNWALL MEWS SOUTH SW7 4RX 2250000 710833 1162371 818972
6 65 GROVE PARK SE5 8LF 2175000 1452981 1270562 976489

Every one of these addresses sits in inner or well-to-do outer London: NW8 is St John's Wood, W11 is Notting Hill, SW7 is South Kensington, TW9 is Richmond upon Thames. All three flexible models independently put it among their own worst underpredictions, without ever once being told to go looking for London specifically. 73 Marlborough Place, NW8, sold for £8.6 million; every model's best guess, built from that property's actual crime, income, education, employment and health figures, lands somewhere between £2.7 and £3.8 million, which is a multi-million-pound gap that has nothing to do with the models being unsophisticated, since these are precisely the models built to catch this kind of non-linear, location-driven effect.

There's a perfectly mechanical reason Random Forest in particular does this: it predicts by averaging a lot of decision trees, each trained on a bootstrap sample, which structurally drags extreme, rare values back toward the mean of whatever similar-looking properties it's actually seen plenty of. But that only pushes the real question back a step: why is a handful of London addresses so rare and so extreme, relative to everywhere else with a similar income, school and crime profile, that every flexible model quietly files them under the same kind of outlier?

worst = shared_underpredicted.iloc[0]
gap = worst["Actual (£)"] - worst["Random Forest"]
income_coefficient = 7.51   # £ of house price per £1 of local income, from the linear model above

print(f"{worst['Address']}: actual £{worst['Actual (£)']:,} vs. Random Forest's £{worst['Random Forest']:,}")
print(f"Shortfall: £{gap:,.0f} — equivalent to {gap / income_coefficient:,.0f} separate £1-of-local-income increases")
Output
73 MARLBOROUGH PLACE: actual £8,600,000 vs. Random Forest's £2,695,406
Shortfall: £5,904,594 — equivalent to 786,231 separate £1-of-local-income increases

Random Forest's shortfall on that one property alone works out to the equivalent of over three-quarters of a million separate £1-of-local-income increases, all stacked on a single street. Income, schools, crime and employment explain a genuinely large share of what a house is worth almost everywhere in England, and then this one handful of addresses carries a premium built out of none of those things, at a scale no amount of model sophistication ever quite closes.

London isn't an expensive version of England. It's a different market.

In December 2025, the average house in England sold for £292,000 (GOV.UK: UK House Price Index, England, December 2025). London's own average, from the same release two months earlier, was £547,000, nearly double the England-wide figure. That gap isn't simply "London vs. everywhere else," either; it holds up inside London too. Kensington and Chelsea, the most expensive local authority in the December 2025 release, averaged £1,178,497. Barking and Dagenham, London's cheapest borough, averaged £353,512, which is still above the England-wide average of £292,000, but somewhat less of an arbitrarily large number. For comparison, Kingston upon Hull (one of the cheapest areas in England, and the same HU postcode area that showed up among Linear Regression's worst underpredictions above) averaged £131,323 in the same release, roughly a ninefold gap between Hull and Kensington and Chelsea.

That scale of gap, England's cheapest and most expensive local authorities sitting roughly nine times apart, is exactly what sends a straight line into negative numbers at Blackpool's end of the distribution, and exactly what three structurally different flexible models still can't close at the other end, no matter how clever they get. Indeed, the magnitude in errors seen when evaluating the models can mostly be explained by how wrong they are about London.

The dissertation arrives at the same conclusion from the results themselves, rather than assuming it going in: every model, however flexible, systematically underprices the same prime London properties, and the fix isn't a sixth algorithm, however tempting that always feels. It's treating London as its own market, not a pricier tail of the national one. That could mean adding distance to central London as its own feature, or simply giving the capital a model of its own and letting the rest of England get on with things separately. Sophistication bought real accuracy almost everywhere else in the country. In St John's Wood, Notting Hill and South Kensington, all it bought was a smaller wrong answer.

The Random Forest model itself is live at socioeconomichousevaluationweb.uk: enter an LSOA code, property type and floor area, or just click the map, for an actual prediction from the model this whole post has been about. Though if you're to move to St John's Wood, it's probably not your best tool.