micromlp: A From-Scratch Neural Net That Predicts Housing Prices
I built micromlp: a single file of Python, no dependencies, no PyTorch. It downloads a real dataset, builds a 2-layer MLP, implements automatic differentiation from scratch, trains with gradient descent, and makes predictions. The task: the California housing dataset from chapter 2 of Hands-On Machine Learning — predict a district’s median house value from its census stats.
This is inspired by Karpathy’s microgpt. I’ve used PyTorch for years. I’d never actually written backprop by hand. There’s a difference between knowing .backward() exists and knowing what it does when you call it — I found it worth closing that gap once, on a toy, instead of taking it on faith forever.
The four pieces
Same shape as any neural net project, just shrunk down to where you can hold the whole thing in your head:
- Autodiff. A
Valueclass that wraps a number, remembers what operation produced it and from which inputs, and can walk that graph backward to compute every gradient in one pass. - The model. A
Neuron, aLayer, anMLP— Value objects wired together. Two layers, sixteen hidden units, one linear output. - The data. 20,640 California census districts, one row per district, downloaded once and cached locally.
- The task. Sixteen features per district, one target:
median_house_value.
I already wrote about the autodiff part in more depth in Partial Derivatives, Reverse-Mode Autodiff — same idea, same six-line backward(). Here it’s wrapped in a Value class instead of loose functions, so it composes into a network:
class Value:
__slots__ = ('data', 'grad', '_children', '_local_grads')
def __init__(self, data, children=(), local_grads=()):
self.data = data # scalar value of this node, from the forward pass
self.grad = 0.0 # d(loss)/d(this node), filled in by the backward pass
self._children = children # nodes that feed into this one
self._local_grads = local_grads # d(this node)/d(each child)
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data + other.data, (self, other), (1, 1))
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
return Value(self.data * other.data, (self, other), (other.data, self.data))
def __pow__(self, other):
return Value(self.data**other, (self,), (other * self.data**(other - 1),))
def exp(self):
e = math.exp(self.data)
return Value(e, (self,), (e,))
def log(self):
return Value(math.log(self.data), (self,), (1 / self.data,))
def tanh(self):
t = math.tanh(self.data)
return Value(t, (self,), (1 - t * t,))
def relu(self):
return Value(max(0, self.data), (self,), (float(self.data > 0),))
def __neg__(self): return self * -1
def __radd__(self, other): return self + other
def __sub__(self, other): return self + (-other)
def __rsub__(self, other): return other + (-self)
def __rmul__(self, other): return self * other
def __truediv__(self, other): return self * other**-1
def __rtruediv__(self, other): return other * self**-1
def backward(self):
topo, visited = [], set()
def build(v):
if v not in visited:
visited.add(v)
for child in v._children:
build(child)
topo.append(v)
build(self)
self.grad = 1.0
for v in reversed(topo):
for child, local_grad in zip(v._children, v._local_grads):
child.grad += local_grad * v.grad
The model is Value objects wired into neurons:
class Neuron:
def __init__(self, nin, nonlin=True):
self.w = [Value(random.uniform(-1, 1) / nin**0.5) for _ in range(nin)]
self.b = Value(0.0)
self.nonlin = nonlin
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh() if self.nonlin else act
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout, **kwargs):
self.neurons = [Neuron(nin, **kwargs) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [
Layer(sizes[i], sizes[i + 1], nonlin=(i != len(nouts) - 1))
for i in range(len(nouts))
]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]
The last layer is built with nonlin=False, so it skips the tanh — the output is a raw number, not squashed into [-1, 1]. That’s the difference between this and a classifier: house prices aren’t a yes/no.
That’s the whole engine. No tensors, no vectorization, one Python object per scalar. Slow, and that’s fine — the point was to see the machinery, not to compete with PyTorch.
The data
HOUSING_URL = "https://raw.githubusercontent.com/ageron/handson-ml2/master/datasets/housing/housing.csv"
CACHE_PATH = os.path.join(os.path.dirname(__file__), "housing.csv")
def fetch_housing(url=HOUSING_URL, cache_path=CACHE_PATH):
if not os.path.exists(cache_path):
with urllib.request.urlopen(url, timeout=20) as resp:
raw = resp.read()
with open(cache_path, "wb") as f:
f.write(raw)
print(f"downloaded housing.csv to {cache_path}")
else:
print(f"loaded housing.csv from cache: {cache_path}")
with open(cache_path, newline="") as f:
rows = list(csv.DictReader(f))
print(f"{len(rows)} rows")
return rows
This is the dataset Géron uses to teach the whole ML workflow in Hands-On ML: one row per California census block group, eight numeric columns (location, age, room counts, income) plus one categorical column (ocean_proximity), and a target — median_house_value. It has two well-known warts, both worth handling on purpose instead of by accident:
total_bedrooms is missing on about 1% of rows. I impute it with the median, computed from the training split only — if you compute it from the whole dataset, information about the test rows leaks into a statistic the model ends up trained against.
def clean_rows(rows):
"""total_bedrooms has ~1% missing values in this dataset. Hands-On ML's
fix is to impute with the median, computed from training data only so
the test set can't leak into a statistic the model is fit against."""
bedrooms = [float(r["total_bedrooms"]) for r in rows if r["total_bedrooms"]]
bedrooms.sort()
median_bedrooms = bedrooms[len(bedrooms) // 2]
for r in rows:
if not r["total_bedrooms"]:
r["total_bedrooms"] = median_bedrooms
return median_bedrooms
And median_house_value is capped: every district actually worth more than $500,000 just got recorded as 500001.0. Training on that teaches the model a lie about the top of the market, so I drop those rows instead of pretending they’re real data points.
The features
Eight raw columns, five one-hot columns for ocean_proximity, and three ratios that Hands-On ML calls out specifically as more informative than the raw counts they’re built from — total_rooms alone doesn’t mean much without knowing how many households it’s spread across:
NUMERIC_COLUMNS = [
"longitude", "latitude", "housing_median_age", "total_rooms",
"total_bedrooms", "population", "households", "median_income",
]
OCEAN_CATEGORIES = ["<1H OCEAN", "INLAND", "ISLAND", "NEAR BAY", "NEAR OCEAN"]
ENGINEERED_COLUMNS = ["rooms_per_household", "bedrooms_per_room", "population_per_household"]
FEATURE_NAMES = NUMERIC_COLUMNS + ENGINEERED_COLUMNS + [f"ocean_{c}" for c in OCEAN_CATEGORIES]
def extract_features(row):
total_rooms = float(row["total_rooms"])
total_bedrooms = float(row["total_bedrooms"])
households = float(row["households"])
population = float(row["population"])
features = {name: float(row[name]) for name in NUMERIC_COLUMNS}
features["total_bedrooms"] = total_bedrooms
features["rooms_per_household"] = total_rooms / households
features["bedrooms_per_room"] = total_bedrooms / total_rooms
features["population_per_household"] = population / households
for cat in OCEAN_CATEGORIES:
features[f"ocean_{cat}"] = float(row["ocean_proximity"] == cat)
return features
Sixteen features in, min-max normalized against training-set statistics — same normalize() I’d use for any of these, nothing dataset-specific about it.
Loss and evaluation
This is regression, not classification, so the loss is mean squared error and the output is a raw number, not a probability:
PRICE_SCALE = 100_000.0 # train on median_house_value / 100k, so targets sit near 1-5
def mse_loss(pred, target):
return (pred - target) ** 2
def rmse(model, X, y):
se = sum((model(x).data - target) ** 2 for x, target in zip(X, y))
return (se / len(y)) ** 0.5 * PRICE_SCALE
Scaling the target down by 100,000 before training isn’t cosmetic — it keeps the loss and the gradients in a range where tanh hidden units and a normal learning rate behave, instead of the network spending its first hundred iterations just learning that outputs should be six digits.
Training
def train(Xtrain, ytrain, Xtest, ytest, hidden=16, iters=400, batch_size=256, lr=0.05):
random.seed(1)
model = MLP(len(FEATURE_NAMES), [hidden, 1])
for it in range(iters):
batch_idx = [random.randrange(len(Xtrain)) for _ in range(batch_size)]
loss = Value(0.0)
for i in batch_idx:
pred = model(Xtrain[i])
loss = loss + mse_loss(pred, ytrain[i])
loss = loss * (1.0 / batch_size)
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.grad
if it % 50 == 0 or it == iters - 1:
print(f"iter {it:4d} train loss {loss.data:.4f} test RMSE ${rmse(model, Xtest, ytest):,.0f}")
return model
Plain mini-batch SGD, no momentum, no learning-rate schedule. median_income alone is famously well-correlated with median_house_value in this dataset, so unlike a weak-signal problem, this one doesn’t need aggressive learning rates or careful balancing to move — it just needs to run.
Running it
$ python3 micromlp.py
downloaded housing.csv to housing.csv
20640 rows
train examples: 15740, test examples: 3935
baseline (always predict the train-set mean): RMSE $95,605
iter 0 train loss 5.1530 test RMSE $173,384
iter 50 train loss 0.6881 test RMSE $79,907
iter 100 train loss 0.6123 test RMSE $75,151
iter 150 train loss 0.5459 test RMSE $71,276
iter 200 train loss 0.4978 test RMSE $68,384
iter 250 train loss 0.5186 test RMSE $65,821
iter 300 train loss 0.4303 test RMSE $63,916
iter 350 train loss 0.3888 test RMSE $62,943
iter 399 train loss 0.4760 test RMSE $62,591
sample predictions on held-out districts:
predicted $169,687 actual $113,300
predicted $215,223 actual $223,800
predicted $195,557 actual $172,100
predicted $125,612 actual $87,900
predicted $193,666 actual $240,300
predicted $204,689 actual $81,300
predicted $154,692 actual $67,500
predicted $220,351 actual $141,900
Test RMSE drops from $173K at initialization to $62.6K after 400 iterations, well under the $95.6K you’d get by just guessing the training mean for every district. That’s a clean, monotonic curve — a much easier story than a rare-event classifier, because the signal here is real and dense: every one of the 15,740 training examples pulls the gradient somewhere useful, instead of 96% of them agreeing on “predict no.”
The sample predictions still miss by a lot on individual districts — $204,689 predicted against $81,300 actual is a bad guess by any measure. A 2-layer, 16-hidden-unit network trained for 400 iterations on 16 features was never going to nail every outlier; it’s finding the broad shape of the relationship (income and location predict price), not the local noise.
What I’d do differently
Sixteen hidden units and 400 iterations is enough to beat the baseline meaningfully, not enough to compete with a tuned gradient-boosted tree, which is what actually wins this dataset in practice. Géron’s own book gets RMSE down near $50K with random forests. The gap between micromlp’s $62.6K and that isn’t a bug — it’s the cost of a from-scratch autodiff engine that processes one scalar at a time. A next version could add more capacity, more iterations, or feature crosses (latitude × longitude captures neighborhood effects that neither coordinate does alone) — I haven’t done any of that. The point of this project was the pipeline, not the leaderboard.
Full code: gist.github.com/soasme/micromlp.py. Delete housing.csv to re-download; the source data doesn’t change, so the numbers above should reproduce exactly.