Keras in Python, Rebuilding My 2020 Notebook (Step by Step)

Keras is a Python library for building neural networks, and since TensorFlow 2.16 it installs with TensorFlow. Using Keras in Python comes down to a few calls. You stack layers with keras.Sequential, pick a loss with compile, train with fit, score the result with evaluate, and save the whole model to one .keras file.

In eight steps, I build one that predicts the median house value of California districts from eight numbers, like income and house age. On districts it never saw, it misses by $35,351 on average. Plain linear regression on the same split misses by $53,330.

I first wrote a model like this in November 2020. I was teaching myself machine learning on the side, following Pierian Data’s TensorFlow 2.0 course, and I saved my notes as a notebook in this GitHub repo. I used it as a reference for some of my projects after that.

The repo is called Keras-Model-Predicting-House-Prices. The notebook inside predicts the price of a gem, from fake data that came with the course. This time the houses are real.

Every code block below runs top to bottom on Python 3.13.8, TensorFlow 2.21.0, Keras 3.15.1 and scikit-learn 1.9.1, and the output shown is what it printed. I ran it three times, once in a brand new virtual environment, and got the same numbers every time. Your last digits may differ on other hardware.

What is Keras, and how do Keras and TensorFlow fit together?

Keras is a high-level library, which means you describe the network in a few lines and Keras takes care of the details. A neural network is a stack of layers that turns input numbers into a prediction. It learns by nudging its internal numbers, called weights, a little after every batch of examples.

If that’s new to you, my introduction to neural networks covers the theory first.

Keras doesn’t do the heavy math itself. It hands that job to a backend, and Keras 3 can run on JAX, TensorFlow or PyTorch. The Keras getting started guide shows how to switch with the KERAS_BACKEND environment variable. With nothing set, my install used TensorFlow.

Here’s the part that trips people up when they search for TensorFlow and Keras. Older code imports it as from tensorflow.keras import ..., also called tf.keras. Since TensorFlow 2.16, installing TensorFlow installs Keras 3 as well.

And tf.keras now points at that same Keras 3. In my install, tf.keras reported version 3.15.1, the same as import keras. So the code below just writes import keras. If you want to see the TensorFlow layer underneath, my post on creating tensors in TensorFlow starts there.

What do you need before you start?

You need three things.

  • Python 3.10 to 3.13. TensorFlow 2.21 only ships packages for those versions.
  • A terminal. I ran every command on a Mac.
  • Basic Python. I explain the machine learning parts as they come up.

TensorFlow’s install page lists Python 3.9 to 3.12 near the top and then says 2.21 supports 3.10 to 3.13 further down. Trust the second one. It’s the one that matches what pip can actually download.

You don’t need a GPU. The whole script, training included, finished in under 30 seconds on my Mac’s CPU.

The eight steps at a glance

Steps 1 to 4 get the data ready. Steps 5 to 8 build, train, test and save the model.

  1. Install Keras in Python in a virtual environment.
  2. Load the California housing data and spot its price ceiling.
  3. Split the data three ways for training, validation and testing.
  4. Normalize the features with a Keras layer.
  5. Build the model with keras.Sequential.
  6. Compile and train it with early stopping.
  7. Test it against linear regression.
  8. Predict and save the model.

Step 1. Install Keras in Python

Installing Keras in Python takes one pip command, but make a virtual environment first. That’s a private folder of packages for one project, so nothing here clashes with the rest of your Python setup.

python3.13 -m venv .venv
source .venv/bin/activate
pip install tensorflow scikit-learn pandas

pip install tensorflow pulls in Keras 3 for you. scikit-learn brings the dataset and the linear regression baseline, and pandas holds the table.

Step 2. Load the California housing data

We’re predicting the median house value of California districts, using scikit-learn’s California Housing dataset. It comes from the 1990 U.S. census, with one row per census block group, a small area that typically holds 600 to 3,000 people. The scikit-learn docs call these districts, so I will too.

Each row has eight inputs, called features. They are median income, median house age, average rooms and bedrooms per household, population, average household size, and the district’s latitude and longitude.

The target, the number we want to predict, is the district’s median house value in hundreds of thousands of dollars. So 2.07 means $207,000, in 1990 prices.

import keras
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.model_selection import train_test_split

keras.utils.set_random_seed(42)

housing = fetch_california_housing(as_frame=True)
X = housing.data
y = housing.target

print(X.shape)
print(list(X.columns))
print(y.describe().round(2))
(20640, 8)
['MedInc', 'HouseAge', 'AveRooms', 'AveBedrms', 'Population', 'AveOccup', 'Latitude', 'Longitude']
count    20640.00
mean         2.07
std          1.15
min          0.15
25%          1.20
50%          1.80
75%          2.65
max          5.00
Name: MedHouseVal, dtype: float64

set_random_seed(42) fixes the random parts, like the starting weights, so you get the same model every run. as_frame=True gives you pandas tables instead of bare arrays. If pandas is new to you, my pandas tutorial for beginners covers the basics.

That’s 20,640 districts and 8 features. The median district is worth 1.80, or $180,000.

Why do 965 districts share one price?

Look at the max in that output. It prints as 5.00, which hides something.

capped = (y == y.max()).sum()
print(f"top value {y.max()}, districts at the top {capped}")
print(y.value_counts().head(3))
top value 5.00001, districts at the top 965
MedHouseVal
5.00001    965
1.37500    122
1.62500    117
Name: count, dtype: int64

965 districts share the exact same top value, 5.00001, or $500,001. The next most common value shows up only 122 times. That’s not 965 neighborhoods landing on the same price by luck. It looks like a ceiling on the data, and it comes back in Step 7.

Step 3. Split the data three ways

A model needs three piles of data. The training set is what it learns from. The validation set is a practice exam it takes after every pass, and the test set is the final exam it sees once, at the very end.

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X_train, y_train, test_size=0.25, random_state=42)

X_train, X_val, X_test = X_train.to_numpy(), X_val.to_numpy(), X_test.to_numpy()
y_train, y_val, y_test = y_train.to_numpy(), y_val.to_numpy(), y_test.to_numpy()
print(len(X_train), len(X_val), len(X_test))
12384 4128 4128

The first train_test_split holds back 20% for the test set. The second takes 25% of what’s left for validation, which is 20% of the whole. That leaves 60% for training. It’s the same split my KNN tutorial uses, with one extra cut for validation.

The last two lines turn the pandas tables into NumPy arrays. Keras works with arrays, and Step 4 doesn’t accept a DataFrame at all. Pass one to adapt and you get a TypeError about strings.

Step 4. Normalize the features with a Keras layer

The features sit on wildly different scales. Population runs into the thousands, while bedrooms per household hovers around 1. Neural networks usually train more smoothly when every input is on a similar scale.

So we normalize them. Normalizing shifts and scales each column so it averages 0 with a standard deviation of 1. Keras has a layer for that.

norm = keras.layers.Normalization()
norm.adapt(X_train)

for name, mean in zip(housing.feature_names, norm.mean.numpy()[0]):
    print(f"{name:10} {mean:8.2f}")
MedInc         3.87
HouseAge      28.59
AveRooms       5.41
AveBedrms      1.09
Population  1427.29
AveOccup       3.03
Latitude      35.64
Longitude   -119.58

adapt measures the mean and variance of each column. It only ever sees the training set. If it peeked at the test set, the final exam would leak into the studying, which is called data leakage.

My 2020 notes had the right comment on this exact line, “We only run it on the training set. To prevent data leakage from test set.” Past me got that part right.

Why a layer, and not scikit-learn’s scaler? Because the layer becomes part of the model. The Normalization docs say adapt stores the mean and variance as the layer’s weights. So when you save the model, the scaling goes with it, and you can feed it raw numbers later.

Step 5. Build the model

Building a model with Keras in Python means listing its layers in order.

model = keras.Sequential([
    keras.Input(shape=(8,)),
    norm,
    keras.layers.Dense(64, activation="relu"),
    keras.layers.Dense(64, activation="relu"),
    keras.layers.Dense(1),
])
model.summary()
Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ normalization (Normalization)   │ (None, 8)              │            17 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 64)             │           576 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 64)             │         4,160 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_2 (Dense)                 │ (None, 1)              │            65 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 4,818 (18.82 KB)
 Trainable params: 4,801 (18.75 KB)
 Non-trainable params: 17 (72.00 B)

Here’s what each line does.

  • keras.Input(shape=(8,)) says every example has 8 numbers.
  • norm is the Normalization layer from Step 4, now the first thing every input passes through.
  • Dense(64, activation="relu") is a layer of 64 neurons. Dense means each neuron connects to every value from the layer before.
  • relu is the activation function. It passes positive numbers through and turns negatives into zero. That bend is what lets the network learn curves instead of only straight lines.
  • Dense(1) is the output, one neuron for one predicted value. It has no activation, so the output isn’t bent or clipped. That’s the usual setup for predicting a number.

The summary counts the parameters. The first Dense layer has 576 because each of its 64 neurons gets a weight for each of the 8 inputs, plus one bias, so 8 times 64 plus 64.

The 17 non-trainable ones belong to the Normalization layer, 8 means, 8 variances and a count. Training never changes them.

I picked 64 neurons. It’s a common starting size, and it’s small enough to train in seconds on a CPU.

Step 6. Compile and train it

Training a Keras model takes two calls. compile sets how the model learns, and fit runs the learning on your training data.

model.compile(optimizer="adam", loss="mse", metrics=["mae"])
  • optimizer="adam" is the algorithm that adjusts the weights after each batch. Adam is a common default.
  • loss="mse" is mean squared error, the average of the squared misses. Training tries to make this number small.
  • metrics=["mae"] adds mean absolute error, the average miss in the target’s own units. It’s easier to read than MSE, since 0.35 here means $35,000.

Then fit does the training.

early_stop = keras.callbacks.EarlyStopping(
    monitor="val_mae", patience=10, restore_best_weights=True
)
history = model.fit(
    X_train, y_train,
    validation_data=(X_val, y_val),
    epochs=200,
    batch_size=32,
    callbacks=[early_stop],
    verbose=0,
)
print("epochs run", len(history.history["loss"]))
print("best epoch", early_stop.best_epoch + 1)
epochs run 79
best epoch 69

An epoch is one full pass over the training data. With batch_size=32, the model updates its weights after every 32 districts.

EarlyStopping checks the validation MAE after every epoch. If it doesn’t improve for 10 epochs in a row, which is what patience=10 means, training stops. restore_best_weights=True then puts back the weights from the best epoch, as the EarlyStopping docs describe.

I allowed up to 200 epochs. Training stopped at 79, and the best epoch was 69. best_epoch counts from zero, hence the + 1.

Average miss by epoch, training vs validation Mean absolute error in dollars for each of 79 epochs. Training error falls from about $63k to $32k. Validation error falls from about $46k and bottoms out at $35.6k in epoch 69, where early stopping restores the weights. Training stops at epoch 79. $30k $40k $50k $60k 1 20 40 60 79 Epoch Best epoch 69 weights restored here Validation Training
Mean absolute error per epoch on the training set (dashed) and the validation set (solid). Validation error stopped improving after epoch 69, so training ended at epoch 79 and Keras put the epoch 69 weights back.
Rendered with Python from the run's output.

The dashed training line keeps sinking slowly. The solid validation line flattens out and wobbles. That gap is the start of overfitting, where a model memorizes its training data instead of learning patterns that carry over. My overfitting vs underfitting post goes deeper on that gap.

My 2020 notebook had none of this. It trained for a fixed 250 epochs with no validation set and hoped for the best.

Step 7. Test it against linear regression

Now the final exam. evaluate scores the model on the 4,128 test districts it has never seen.

test_mse, test_mae = model.evaluate(X_test, y_test, verbose=0)
print(f"test MAE {test_mae:.3f}, about ${test_mae * 100_000:,.0f}")
test MAE 0.354, about $35,351

evaluate returns the loss and then each metric, in the order you compiled them. The average miss on districts the model never saw is $35,351.

Does the neural network beat linear regression?

Yes, on this data, and by a lot. On the same 4,128 test districts, the network’s average miss was $35,351 and linear regression’s was $53,330.

A number like $35,351 means little on its own, which is why the baseline matters. I trained scikit-learn’s LinearRegression on the same training set and scored both models on the same test set.

nn_pred = model.predict(X_test, verbose=0).ravel()

linear = LinearRegression().fit(X_train, y_train)
lin_pred = linear.predict(X_test)

for name, pred in [("linear regression", lin_pred), ("neural network", nn_pred)]:
    mae = mean_absolute_error(y_test, pred)
    print(f"{name:18} MAE ${mae * 100_000:,.0f}  R2 {r2_score(y_test, pred):.3f}")
linear regression  MAE $53,330  R2 0.571
neural network     MAE $35,351  R2 0.792
Model Average miss (MAE) R-squared
Linear regression $53,330 0.571
Keras neural network $35,351 0.792

That’s about $18,000 less per district, and the network explains about 79% of the spread in prices against 57% for the straight line.

R-squared is the share of the spread in house values a model explains, where 1.0 would be perfect. I walk through it in my linear regression in Python post.

One difference in setup is worth saying. Both models learned from the same 12,384 districts, but the network also used the validation set to decide when to stop.

Why the gap? A straight line gives each feature one fixed effect everywhere. The network’s ReLU layers can bend, so income and location can matter differently in different places.

That’s the point of a baseline. It turns “is the neural network worth it?” into two numbers you can compare.

Predicted vs actual median house value 300 test districts. Most dots sit near the diagonal line where the prediction equals the actual value. 13 of them sit in a vertical column at the top value of $500,001, with predictions spread mostly below it. $0k $0k $100k $100k $200k $200k $300k $300k $400k $400k $500k $500k Actual median value Predicted Perfect prediction 13 districts at the top value every one exactly $500,001
A fixed random sample of 300 of the 4,128 test districts. Dots on the dashed line were predicted exactly. Squares are districts at the dataset's top value, exactly $500,001.
Rendered with Python from the run's output.

The squares are districts at the $500,001 top value. 13 of the 300 sampled districts sit there, and the network guessed lower for 12 of them. Whatever those districts are really worth, the data only says $500,001.

Step 8. Predict and save the model

Call model.predict on new rows to get predictions, and model.save to write the whole model to one .keras file. keras.models.load_model brings it back.

There’s no single house in this data. Each row is a district, so a prediction is a district’s median value. Here’s the first district in the test set.

district = X_test[:1]
print(dict(zip(housing.feature_names, district[0].round(2).tolist())))

pred = model.predict(district, verbose=0)[0, 0]
print(f"predicted ${pred * 100_000:,.0f}, actual ${y_test[0] * 100_000:,.0f}")
{'MedInc': 1.68, 'HouseAge': 25.0, 'AveRooms': 4.19, 'AveBedrms': 1.02, 'Population': 1392.0, 'AveOccup': 3.88, 'Latitude': 36.06, 'Longitude': -119.01}
predicted $46,242, actual $47,700

The model says $46,242, and the real median was $47,700. X_test[:1] keeps the 2D shape, one row of eight columns, which is what predict expects.

Notice there’s no scaling step. The raw numbers go straight in, because the Normalization layer is inside the model.

Saving takes one line, and loading takes one more.

model.save("house_prices.keras")

loaded = keras.models.load_model("house_prices.keras")
print(f"loaded model predicts ${loaded.predict(district, verbose=0)[0, 0] * 100_000:,.0f}")
loaded model predicts $46,242

The Keras saving guide describes a .keras file as a zip archive that holds the model’s configuration and its weights. The loaded copy predicts the same $46,242, scaling included.

What changed since my 2020 notebook?

The short version is that the scaling moved inside the model, a validation set now decides when to stop, the save format is .keras, and the data is real.

2020 notebook This post
Data 1,000 rows of fake gem prices from the course 20,640 California districts from the 1990 census
Import tensorflow.keras keras
Scaling MinMaxScaler, outside the model Normalization layer, inside the model
Model 3 hidden layers of 4 ReLU neurons, rmsprop 2 hidden layers of 64 ReLU neurons, adam
Training length Fixed 250 epochs Early stopping, stopped at 79 (best 69)
Validation set None 20% of the data
Save format .h5, with a legacy warning .keras
Loads in Keras 3.15.1 Only with compile=False Yes

Here’s the 2020 repo as it stands today. The README credits the course and says the data isn’t real.

The Keras-Model-Predicting-House-Prices repo on GitHub in dark mode, with a Data folder, the notebook and a README that says the data is not real and comes from the Pierian Data Inc. TensorFlow 2.0 course

I reran the 2020 notebook on the same versions as this post. It failed on the first line that reads data. The notebook opens fake_reg.csv, but the repo stores the file as Data/fakeData.csv.

The commit history shows how that happened. I uploaded the CSV as fake_reg.csv in November 2020, then moved and renamed it a month later, on December 20. Nothing after that touched the notebook.

GitHub commit history for the Keras-Model-Predicting-House-Prices repo in dark mode, with December 20, 2020 commits that move fake_reg.csv into Data and rename it fakeData.csv, below README updates from January 2021 and above the first uploads from November 2020

Both screenshots show my real public repo, captured on October 12, 2026.

So it doesn’t run from a fresh clone. After I copied the file to the expected name, the old from tensorflow.keras imports still worked, and the model trained.

Its model had three hidden layers of 4 ReLU neurons each, trained with the rmsprop optimizer for a fixed 250 epochs on data scaled by scikit-learn’s MinMaxScaler. Then it saved the model as myPriceModel.h5. Saving printed a warning that the HDF5 format “is considered legacy,” and loading it back failed with this error.

ValueError: Could not deserialize 'keras.metrics.mse' because it is not a KerasSaveable subclass

Passing compile=False to load_model got the old file to load. If you have .h5 files lying around, that’s the first thing to try.

The bigger change is where the scaling lives. In 2020 the scaler sat outside the model, so every prediction needed a scaler.transform call first. Forget it, and the model quietly predicts from the wrong numbers.

That happened to me for real. My part of the BDA 594 group project in my Big Data Analytics master’s at SDSU, where we ranked NFL special teams players, reused this template almost line for line. Same layers, same 250 epochs, same .h5 save.

The copy also lost one line. It fit the scaler and scaled the test set, but it never ran X_train = scaler.transform(X_train). So that model trained on raw numbers and was tested on scaled ones. The notebook is in my grad projects repo.

With a Normalization layer inside the model, there is no separate step to forget.

What should you remember about Keras in Python?

  • pip install tensorflow installs Keras 3 as well. Write import keras, and use Python 3.10 to 3.13.
  • Split into training, validation and test sets before anything learns from the data.
  • Put a Normalization layer inside the model and adapt it on the training set only.
  • End a regression model with Dense(1) and no activation. Compile with mse loss and track mae.
  • Use EarlyStopping with restore_best_weights=True instead of guessing the epoch count.
  • Train a linear regression baseline. Here the network cut the average miss from $53,330 to $35,351.
  • Save to .keras. An old .h5 file may need compile=False to load.

What’s the oldest notebook in your GitHub, and does it still run?