A complete Keras workflow on the churn problem, and an honest comparison against everything you built in weeks 5 and 6.
Preprocessing belongs to scikit-learn
Keras has preprocessing layers, but a ColumnTransformer is clearer for tabular data and it is what the rest of your pipeline already speaks. Fit it on training data, and keep it. You will need it at prediction time.
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore',
sparse_output=False))]), CAT),
])
X, y = df[NUM + CAT], df['churned']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
Xt = prep.fit_transform(X_tr).astype('float32')
Xv = prep.transform(X_te).astype('float32')
yt = y_tr.to_numpy().astype('float32')
yv = y_te.to_numpy().astype('float32')
print('features after encoding: %d' % Xt.shape[1])
print('train %s test %s' % (Xt.shape, Xv.shape))
print('positive rate: train %.3f, test %.3f' % (yt.mean(), yv.mean()))
features after encoding: 15
train (2250, 15) test (750, 15)
positive rate: train 0.268, test 0.268
Build, train, evaluate
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore',
sparse_output=False))]), CAT),
])
X, y = df[NUM + CAT], df['churned']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
Xt = prep.fit_transform(X_tr).astype('float32')
Xv = prep.transform(X_te).astype('float32')
yt = y_tr.to_numpy().astype('float32')
yv = y_te.to_numpy().astype('float32')
from sklearn.metrics import (roc_auc_score, average_precision_score,
brier_score_loss)
tf.random.set_seed(42)
model = tf.keras.Sequential([
tf.keras.layers.Input(shape=(Xt.shape[1],)),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.3),
tf.keras.layers.Dense(32, activation='relu'),
tf.keras.layers.Dropout(0.3),
tf.keras.layers.Dense(1, activation='sigmoid'),
])
model.compile(optimizer=tf.keras.optimizers.Adam(0.003),
loss='binary_crossentropy',
metrics=[tf.keras.metrics.AUC(name='auc')])
callbacks = [
tf.keras.callbacks.EarlyStopping(monitor='val_auc', mode='max',
patience=20, restore_best_weights=True),
tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5,
patience=8, min_lr=1e-5),
]
h = model.fit(Xt, yt, epochs=300, batch_size=64, validation_split=0.2,
callbacks=callbacks, verbose=0)
proba = model.predict(Xv, verbose=0).ravel()
print('epochs run %d' % len(h.history['loss']))
print('parameters %d' % model.count_params())
print('test ROC AUC %.4f' % roc_auc_score(yv, proba))
print('average precision %.4f' % average_precision_score(yv, proba))
print('Brier score %.4f' % brier_score_loss(yv, proba))
epochs run 22
parameters 3137
test ROC AUC 0.8152
average precision 0.5824
Brier score 0.1490
Against the alternatives
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore',
sparse_output=False))]), CAT),
])
X, y = df[NUM + CAT], df['churned']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
Xt = prep.fit_transform(X_tr).astype('float32')
Xv = prep.transform(X_te).astype('float32')
yt = y_tr.to_numpy().astype('float32')
yv = y_te.to_numpy().astype('float32')
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import (roc_auc_score, average_precision_score,
brier_score_loss)
import time
print('%-16s %10s %14s %10s %10s'
% ('model', 'ROC AUC', 'avg precision', 'Brier', 'seconds'))
for name, clf in [('logistic', LogisticRegression(max_iter=1000, random_state=42)),
('boosting', HistGradientBoostingClassifier(random_state=42))]:
t = time.perf_counter()
clf.fit(Xt, yt)
el = time.perf_counter() - t
pr = clf.predict_proba(Xv)[:, 1]
print('%-16s %10.4f %14.4f %10.4f %10.2f'
% (name, roc_auc_score(yv, pr), average_precision_score(yv, pr),
brier_score_loss(yv, pr), el))
tf.random.set_seed(42)
net = tf.keras.Sequential([
tf.keras.layers.Input(shape=(Xt.shape[1],)),
tf.keras.layers.Dense(64, activation='relu'),
tf.keras.layers.Dropout(0.3),
tf.keras.layers.Dense(32, activation='relu'),
tf.keras.layers.Dense(1, activation='sigmoid'),
])
net.compile(optimizer=tf.keras.optimizers.Adam(0.003), loss='binary_crossentropy')
t = time.perf_counter()
net.fit(Xt, yt, epochs=100, batch_size=64, validation_split=0.2,
callbacks=[tf.keras.callbacks.EarlyStopping(patience=15,
restore_best_weights=True)],
verbose=0)
el = time.perf_counter() - t
pr = net.predict(Xv, verbose=0).ravel()
print('%-16s %10.4f %14.4f %10.4f %10.2f'
% ('neural net', roc_auc_score(yv, pr), average_precision_score(yv, pr),
brier_score_loss(yv, pr), el))
model ROC AUC avg precision Brier seconds
logistic 0.8174 0.5993 0.1475 0.02
boosting 0.7796 0.5259 0.1691 2.44
neural net 0.8135 0.5949 0.1491 3.26
Read the last column before you celebrate
Logistic regression fits in a fraction of a second and matches everything else on this data. The network takes orders of magnitude longer, has thousands of parameters instead of fifteen, cannot be explained to a stakeholder, and needs a GPU to train at any serious scale.
None of that means networks are bad. It means tabular data with three thousand rows is not where they win. Weeks 12 and 13 show what they are actually for.
Saving a model properly
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
import pandas as pd
df = pd.read_csv('customers.csv').drop_duplicates().copy()
df['contract'] = df['contract'].str.strip().str.title()
df['total_charges'] = pd.to_numeric(df['total_charges'], errors='coerce')
NUM = ['tenure_months', 'support_calls', 'monthly_charges']
CAT = ['contract', 'internet_service', 'payment_method', 'has_dependents']
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.model_selection import train_test_split
prep = ColumnTransformer([
('num', Pipeline([('i', SimpleImputer(strategy='median')),
('s', StandardScaler())]), NUM),
('cat', Pipeline([('i', SimpleImputer(strategy='most_frequent')),
('o', OneHotEncoder(handle_unknown='ignore',
sparse_output=False))]), CAT),
])
X, y = df[NUM + CAT], df['churned']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
Xt = prep.fit_transform(X_tr).astype('float32')
Xv = prep.transform(X_te).astype('float32')
yt = y_tr.to_numpy().astype('float32')
yv = y_te.to_numpy().astype('float32')
import joblib
import os
import numpy as np
tf.random.set_seed(42)
model = tf.keras.Sequential([
tf.keras.layers.Input(shape=(Xt.shape[1],)),
tf.keras.layers.Dense(32, activation='relu'),
tf.keras.layers.Dense(1, activation='sigmoid'),
])
model.compile(optimizer='adam', loss='binary_crossentropy')
model.fit(Xt, yt, epochs=15, batch_size=64, verbose=0)
model.save('churn_net.keras')
joblib.dump(prep, 'churn_prep.joblib')
loaded = tf.keras.models.load_model('churn_net.keras')
loaded_prep = joblib.load('churn_prep.joblib')
fresh = loaded_prep.transform(X_te.head(5)).astype('float32')
print('reloaded predictions %s' % loaded.predict(fresh, verbose=0).ravel().round(4))
print('original predictions %s' % model.predict(Xv[:5], verbose=0).ravel().round(4))
print('\nfiles: %s' % sorted(f for f in os.listdir('.')
if f.startswith('churn_')))
reloaded predictions [0.6116 0.1354 0.328 0.1717 0.5191]
original predictions [0.6116 0.1354 0.328 0.1717 0.5191]
files: ['churn_by_contract.png', 'churn_net.keras', 'churn_prep.joblib']
The preprocessor is half the model
Save the network alone and you have a function that takes fifteen mystery numbers. The ColumnTransformer holds the imputation medians, the scaling parameters and the category lists, all learned from training data, all required to turn a customer record into those fifteen numbers. Losing it means retraining. Week 15 makes this a single versioned artefact.
Your assignment
Take the network above and make it deliberately too small, one hidden layer of 4 units, then deliberately too large, 512 units in three layers. Record the training loss, validation loss and test AUC for each. You should see underfitting and overfitting from the same code with one number changed, and the middle should not beat logistic regression either.
Day 7 takeaway
The full workflow is: scikit-learn preprocessing, a modest network, Adam, dropout, early stopping on the metric you care about, then evaluate on discrimination and calibration. Save the preprocessor with the model. And compare against logistic regression every time, on tabular data of this size it will hold its own.