A property team needs an estimate it can defend. That means seeing why the model landed where it did, and knowing when not to trust it.
AlloyTower was a three-week internship project to build that for a property platform. The brief covered data validation, an automated valuation model, a comparables search and an app that people outside the data team could use.
Data
The source file had 2,090 sales and 28 columns. Before any modelling I wrote a data-quality report. It logged 11 findings and ranked each one by severity.
Severity
Finding
What I did
Critical
Latitude and longitude are random. Only 3% of points fall in their stated state.
Dropped both columns
Critical
ZIP codes are randomly generated
Dropped the column
High
6.4% of rows have implausible price for their size
Dropped 134 rows below $50 per sqft
High
Sale prices show no real trend over time
Dropped date features
High
Physical attributes carry weak signal
Built two models and set expectations
Medium
Assessed value correlates 0.99 with sale price
Built one model with it and one without
Medium
Some columns are derived from the target
Dropped the derived price-per-sqft, annual-tax and building-age columns
Four more findings were low-severity or informational, such as file-encoding problems and a duplicate key. After cleaning, 1,956 rows remained.
The charts below are computed from the raw file, so each finding can be checked.
Where properties labelled California actually sitlabelled CAother states, sample of 500
Only 6 of 129 properties labelled California fall inside the state's bounding box. The same check finds 10 of 148 for TX, 1 of 146 for FL, 4 of 124 for NY.
Show as table (first 25 points)
longitude
latitude
Group
-144
31.2
labelled CA
-126
37.1
labelled CA
-81.3
26.7
labelled CA
-76.9
21.5
labelled CA
-130
21.5
labelled CA
-88.2
26.3
labelled CA
-150
39.7
labelled CA
-137
43.3
labelled CA
-144
23.2
labelled CA
-124
30.8
labelled CA
-133
29.6
labelled CA
-130
47.1
labelled CA
-84.3
23.9
labelled CA
-95.7
29.3
labelled CA
-86.2
21.9
labelled CA
-151
41.6
labelled CA
-129
37.8
labelled CA
-103
23.4
labelled CA
-85.6
44.8
labelled CA
-90.3
35.6
labelled CA
-95.4
37.9
labelled CA
-98.9
39.3
labelled CA
-115
41.8
labelled CA
-83.6
26.3
labelled CA
-127
43.2
labelled CA
Price per square foot across all sales
1340
349
423100
260
181200
115
104300
99
67400
65
45500
55
37600
32
30700
29
17800
19
16900
13
01000
0
Cut-off at $50 per sqft. 134 sales fall below it and were dropped.
US$ per sqft, in bands of 50 (the last band includes everything above 1,050)
Show as table
US$ per sqft, in bands of 50 (the last band includes everything above 1,050)
Value
0
134
50
349
100
423
150
260
200
181
250
115
300
104
350
99
400
67
450
65
500
45
550
55
600
37
650
32
700
30
750
29
800
17
850
19
900
16
950
13
1000
0
1050
0
Median sale price by year, US$ thousands
595k2016
643k2017
572k2018
600k2019
604k2020
613k2021
588k2022
638k2023
646k2024
636k2025
Real US prices rose by roughly 40% over this decade. Here the median barely moves, so the sale dates carry no market signal and the models ignore time.
Show as table
Group
Value
2016
595k
2017
643k
2018
572k
2019
600k
2020
604k
2021
613k
2022
588k
2023
638k
2024
646k
2025
636k
What correlates with sale price, 1,956 cleaned rowsPearson r, from −1 to 1
−101
Assessed value0.99
Living area (sqft)0.22
Lot size0.13
Year built-0.01
Bathrooms0.00
Bedrooms0.01
Assessed value tracks price almost perfectly. Size, the strongest physical signal, manages only 0.22, and bedrooms and bathrooms are close to zero.
Show as table
Feature
r
Assessed value
0.990
Living area (sqft)
0.221
Lot size
0.128
Year built
-0.010
Bathrooms
0.004
Bedrooms
0.014
Approach
I trained two LightGBM models on the log of the sale price. Location is target-encoded at state, county and city level. Three engineered features capture sqft per bedroom, location times size, and age worked out from the year built.
Model A uses the county’s assessed value when one exists.
Model B uses only the property’s own facts, for new builds and anything without an assessment.
The service picks the model from the request. If an assessed value is sent, Model A answers. Otherwise Model B does.
How a valuation request flows
Console or API clientproperty details
FastAPI servicevalidates every field
Routerassessed value given?yes → Model Ano → Model B
SHAP TreeExplainerper-feature contributions
Responseestimate, range and reasons
Every estimate comes back with its SHAP contributions, so a reviewer sees which features pushed the value up or down. A separate endpoint returns the five most similar sold properties.
use_model_a = raw.get("assessed_value") is not None
bundle = self.bundle_a if use_model_a else self.bundle_b
Evaluation
Both models were scored with 5-fold cross-validation and compared against simple baselines. These include the overall mean, the mean per state, and a linear regression.
Mean absolute percentage error, 5-fold CVlower is better
Model A · with assessment8.0%
Model B · fundamentals only44.9%
The gap between the two models is the main finding. Most of Model A's accuracy comes from the assessment itself, not from learned valuation skill.
Show as table
Item
Value
Note
Model A · with assessment
8.0%
R² 0.98
Model B · fundamentals only
44.9%
R² 0.55
Model B is the honest test of what the other columns can predict. Its 45% error says more about this dataset than about the method. In real US data, size correlates 0.6 to 0.8 with price. Here it correlates 0.22.
What didn’t work
The physical attributes never carried much signal. Bedrooms and bathrooms correlate with price at close to zero, so location dominates both models.
Limitations
The coordinates and sale dates in the source are synthetic, so location comes only from state, county and city, and the model ignores time.
1,956 rows is small for a valuation model. Estimates for rare states and for very large or very small homes are less certain.
Model A mostly echoes the assessed value. It is useful when an assessment exists, but it is not evidence of independent valuation skill.