Data Preparation and Visualization¶
85 min Real data Needs: Pandas, Plotting, Probability
You have a file of 7043 telephone customers and a column saying which of them left. This page is what you do between opening that file and handing a table to a model.
Why this matters¶
You already know the commands. dropna, astype, groupby, histplot. What
you have not done is decide which ones to run, in what order, and why, on a
dataset nobody has cleaned for you.
That decision sequence is the job. Two analysts given the same file will produce different tables, and the difference is judgment rather than syntax. This page is about the judgment, and the churn data is here to make it concrete.
Exploratory data analysis¶
EDA is a name for the loop you run when you meet a dataset.
It is not a checklist you complete. Every answer raises the next question: the target is imbalanced, so which metric will you use; total charges is text, so why; eleven values are missing, so what do those eleven rows have in common.
Start with the three commands you already know.
import pandas as pd
df = pd.read_csv("Telco-Customer-Churn.csv")
print(df.shape)
df.info()
df.describe()
df.describe(include="object")
describe on its own only reports numeric columns, which on this file is three
of twenty-one. Pass include="object" and you get counts, unique values and the
most frequent value for the rest. Two commands, and you already know the shape
of the problem.
Missing data, and what it means¶
Finding missing values is a line of code. Deciding what they mean is the work, and this dataset has a perfect example.
Zero missing values. Except TotalCharges has dtype object, which for a
column of money is suspicious. Eleven of its entries are a single space
character, which pandas read as text rather than as missing.
df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce")
print(df["TotalCharges"].isna().sum())
Now look at what those eleven rows have in common.
Every one of them has tenure equal to zero. These are customers who signed up
this month and have not been billed yet. The value is not unknown, it is zero.
Always ask why before you ask how
Fill those eleven with the column median, around 1400 euro, and you have invented two years of billing history for customers who joined last week. Every model you build afterwards learns from that invention.
The three options are drop, replace and impute, and they are not interchangeable:
Drop the rows when they are few and you cannot justify a value. Eleven out of 7043 is nothing, so this is defensible here.
Replace with a value you can justify from the meaning of the data. Filling these with 0 is correct, because zero is what a customer with no billing cycle has been charged.
Impute with a statistic, or with a model, when the value genuinely exists but was not recorded. Median for skewed columns, mean for symmetric ones, and a flag column either way.
Here, replacing with 0 is right and imputing is wrong, and no amount of looking
at the isna count would have told you that.
Types¶
TotalCharges was text because of eleven spaces. That is the common case: one
bad value drags a whole column to object and every numeric operation you try
afterwards either fails or silently does something strange.
SeniorCitizen has the opposite problem. It is stored as 0 and 1, so pandas
reports it as an integer and describe cheerfully tells you the mean senior
citizen is 0.16. It is a category wearing a number's clothes.
The test is not what the values look like. It is whether arithmetic on the column means anything.
Duplicates and columns to drop¶
Two different questions. The first asks whether an entire row repeats. The second asks whether the identifier repeats, which would mean the same customer appears twice with different details, and that is a data collection problem rather than a copy and paste one.
Then drop what cannot help.
Identifiers. customerID is unique for every row. A model given it can
memorise the training set perfectly and learn nothing that transfers. Drop it.
Constant columns. If every row has the same value, the column carries no information.
Columns that leak the answer. Anything recorded at the same time as the
outcome, or after it. There is no such column in this file, but in a real churn
dataset a field like cancellation_reason would be one, and it would give you a
model with 99 percent accuracy and no use whatsoever.
Look at the target first¶
Before any feature, plot the thing you are predicting.
import seaborn as sns
print(df["Churn"].value_counts(normalize=True))
sns.countplot(data=df, x="Churn")
About one customer in four left. That single number changes several later decisions:
- A model that predicts "No" for everybody scores 73.5 percent accuracy. That is your baseline, and anything you build must beat it before it is interesting.
- Accuracy is the wrong headline metric here, for the reason you saw on the Probability page.
- Your splits need to be stratified, which is the last section of this page.
ML connection
You met this as class imbalance in Working with Data and as base rates in Probability. This is the same idea for the third time, now with a number attached to a real dataset. If a later page quotes you an accuracy for this problem, 73.5 percent is the number to compare it against.
Distributions¶
Categorical features first, because two thirds of this file is categorical.
categorical = df.select_dtypes(include=["object", "category"]).columns
fig, axes = plt.subplots(4, 4, figsize=(18, 14))
for ax, col in zip(axes.flatten(), categorical):
sns.countplot(data=df, x=col, ax=ax)
ax.tick_params(axis="x", rotation=30)
fig.tight_layout()
What you are looking for: categories with almost no members, which will be unreliable no matter what the model does, and values that should be the same category but are spelled differently.
This dataset has a nice example. Several service columns contain No internet
service as well as No. Those are two different statements and it is your
choice whether they stay separate.
Then the continuous ones. There are only three: tenure, MonthlyCharges,
TotalCharges.
for ax, col in zip(axes, ["tenure", "MonthlyCharges", "TotalCharges"]):
sns.histplot(data=df, x=col, bins=30, ax=ax)
tenure is U-shaped: many customers are brand new, many have been there for
years, fewer in between. That shape is a finding, not noise, and it is the
argument for the binning section later.
Relationships with the target¶
A distribution tells you what a column looks like. What you need is whether it relates to churn.
With three numeric columns this is a small heatmap, and it says one useful thing:
tenure and TotalCharges are strongly correlated, because total charges is
roughly tenure times monthly charges. Two columns carrying the same information
is worth knowing before you feed both to a model.
For categorical features against churn, proportions beat counts.
normalize="index" is what makes this readable. Raw counts would mostly tell
you that month-to-month is the largest group. Percentages tell you that its churn
rate is fifteen times the two year rate, which is the actual finding.
For numeric features, compare the distributions across the two groups.
Churners have visibly shorter tenure. You can see it. The next section is about whether you can defend it.
Is the pattern real?¶
Your eyes will find patterns in noise. A statistical test asks whether the pattern you are looking at could plausibly have arisen by chance.
Chi-square, for two categorical variables¶
Build a table of counts and compare it with the counts you would expect if the two variables were unrelated.
from scipy.stats import chi2_contingency
table = pd.crosstab(df["Contract"], df["Churn"])
chi2, p, dof, expected = chi2_contingency(table)
print(chi2, p)
A small p-value means the observed counts would be unlikely if contract type and churn were independent.
Welch's t-test, for a numeric variable across two groups¶
from scipy.stats import ttest_ind
churned = df.loc[df["Churn"] == "Yes", "tenure"]
stayed = df.loc[df["Churn"] == "No", "tenure"]
stat, p = ttest_ind(churned, stayed, equal_var=False)
print(stat, p)
equal_var=False is what makes it Welch's version rather than the standard one.
The standard test assumes both groups have the same spread, which here they do
not: there are far more stayers than churners and their tenure is spread
differently. Welch's does not assume it, costs nothing, and is the safer default
in almost every real comparison.
Significant does not mean important
With 7043 rows almost everything comes back significant, because a large sample can detect a tiny difference reliably. A p-value answers "is this difference real". It does not answer "is this difference big enough to care about".
Always look at the effect alongside the p-value. A two month difference in average tenure with a p-value of 0.001 is real and probably useful. A difference of 0.3 months with the same p-value is real and useless.
Making better features¶
Two moves, and both are about helping a model express something it otherwise could not.
Binning¶
df["tenure_band"] = pd.cut(
df["tenure"],
bins=[-1, 12, 24, 48, 100],
labels=["new", "growing", "established", "loyal"],
)
Binning throws away information deliberately. That is worth it when the effect is a step rather than a slope: the difference between month 2 and month 14 is enormous here, while the difference between month 50 and month 62 is small. A linear model cannot say that about a raw tenure column, and it can about bands.
Use pd.cut for boundaries you choose and pd.qcut for equal sized groups.
Choosing your own is usually better, because the boundaries then mean something
to the business.
New columns¶
df["avg_monthly"] = df["TotalCharges"] / df["tenure"].replace(0, 1)
df["services"] = (df[service_cols] == "Yes").sum(axis=1)
The second one is the interesting kind. Counting how many services a customer has turns sixteen separate columns into one number with an obvious meaning, and a model can use it directly.
Every feature you create should have a sentence attached explaining what it means. If you cannot write the sentence, you are generating columns rather than engineering features.
Encoding¶
Models take numbers. Most of this dataset is text.
df = pd.get_dummies(df, columns=["InternetService", "Contract", "PaymentMethod"], drop_first=True)
df["Churn"] = (df["Churn"] == "Yes").astype(int)
For binary columns like Partner, a simple 0 and 1 is fine and there is no
ordering to invent. For unordered categories with more than two values, one-hot
is the default. For genuinely ordered categories, such as your tenure_band,
ordinal encoding preserves information that one-hot throws away.
drop_first=True removes one column per feature. The information is still there,
because "not DSL and not Fiber" means None, and it avoids a redundancy that
upsets linear models.
Scaling¶
tenure runs to 72 and TotalCharges runs past 8000. Any model that measures
distance, or that uses gradient descent, treats those numbers as if they were on
the same scale. They are not.
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
Standardisation subtracts the mean and divides by the standard deviation, giving a column centred on zero. Min-max squeezes into a 0 to 1 range, which preserves shape but is more affected by outliers.
Which models care:
| Model | Needs scaling? |
|---|---|
| Linear and logistic regression | Yes, especially with regularisation |
| Anything distance based, such as k-nearest neighbours | Yes, critically |
| Neural networks | Yes, training is much slower without it |
| Decision trees and random forests | No, thresholds are unaffected |
Fit on the training data only
Look at the two lines above. fit_transform on the training set,
transform on the test set. The scaler learns the mean and standard
deviation from training data alone.
Call fit on the whole dataset and the test set's statistics have leaked
into your training. Your score comes out better than it should, and the
model performs worse than promised on anything new. This is the single most
common way a beginner produces a result that is not real, and the next page
is largely about it.
Splitting¶
from sklearn.model_selection import train_test_split
X = df.drop(columns=["Churn"])
y = df["Churn"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42
)
print(y_train.mean(), y_test.mean())
stratify=y keeps the 73 to 27 balance in both halves. Without it the split is
random, and with a minority class the smaller set can drift several percentage
points, which makes your test score measure a slightly different problem from
the one you trained on.
random_state makes the split reproducible, so that a change in your score
comes from a change you made rather than from a different shuffle.
Split before you scale, before you impute, and before anything else that learns from the data.
The whole thing in one screen¶
df = pd.read_csv("Telco-Customer-Churn.csv")
df["TotalCharges"] = pd.to_numeric(df["TotalCharges"], errors="coerce").fillna(0)
df["SeniorCitizen"] = df["SeniorCitizen"].astype("category")
df = df.drop_duplicates().drop(columns=["customerID"])
df["tenure_band"] = pd.cut(df["tenure"], [-1, 12, 24, 48, 100], labels=["new", "growing", "established", "loyal"])
df["Churn"] = (df["Churn"] == "Yes").astype(int)
X = pd.get_dummies(df.drop(columns=["Churn"]), drop_first=True)
y = df["Churn"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=42)
scaler = StandardScaler().fit(X_train)
X_train, X_test = scaler.transform(X_train), scaler.transform(X_test)
Eleven lines. Every one of them is a decision somebody could disagree with, which is why the last step of preparation is writing them down.
Keep a decisions file
One short note per choice: what you did, and why. Filled TotalCharges
blanks with 0 because all eleven have tenure 0. Dropped customerID as an
identifier. Binned tenure at 12, 24 and 48 months because those are contract
boundaries.
In three months you will not remember, and neither will the person reviewing your work. This is the same habit as the data statement from your first project.
When it goes wrong¶
describe() shows only three columns
It reports numeric columns by default. Use df.describe(include="object")
or include="all" for the rest.
A money column has dtype object
Something in it is not a number. pd.to_numeric(col, errors="coerce") then
isna() shows you exactly which rows, and those rows usually explain
themselves.
isna().sum() says zero but values are clearly missing
Empty strings, single spaces and the words unknown or n/a are not missing
values as far as pandas is concerned. Pass na_values to read_csv or
convert afterwards.
My test accuracy is suspiciously high
Check for an identifier column, for a feature recorded after the outcome, and for a scaler fitted before the split. All three produce excellent scores that mean nothing.
Every feature is statistically significant
With 7043 rows that is expected. Look at the size of the difference, not only the p-value.
get_dummies produced hundreds of columns
One of your categorical columns has very many distinct values, often an
identifier that should have been dropped. Check df[col].nunique() before
encoding.
Check yourself¶
Use the churn file for all of these.
- Report the shape, the dtype of every column, and which columns are categorical wearing numeric clothes.
- Convert
TotalChargesproperly, show that exactly eleven values become missing, and demonstrate what those rows have in common. - Justify in one sentence why 0 is the right fill for those rows and the median is not.
- Produce the churn rate by
Contractas proportions, and state the baseline accuracy any model must beat. - Run a chi-square test for two categorical features and a Welch's t-test for
MonthlyCharges. Report both the p-value and the size of the difference. - Bin tenure into four bands of your own choosing and defend the boundaries.
- Encode, scale and split, in the correct order, and prove the class balance is preserved in both halves.
- Write your decisions file. Aim for one line per decision.
Quick reference¶
| Task | Code |
|---|---|
| Full summary | df.describe(include="all") |
| Force numeric | pd.to_numeric(col, errors="coerce") |
| Category from number | df["SeniorCitizen"].astype("category") |
| Missing map | sns.heatmap(df.isna(), cbar=False) |
| Duplicate rows, duplicate ids | df.duplicated().sum(), df["id"].duplicated().sum() |
| Target balance | df["Churn"].value_counts(normalize=True) |
| Rates by category | pd.crosstab(df["a"], df["b"], normalize="index") |
| Chi-square | chi2_contingency(pd.crosstab(a, b)) |
| Welch t-test | ttest_ind(g1, g2, equal_var=False) |
| Bin by boundaries | pd.cut(col, bins=[...], labels=[...]) |
| Bin by quantiles | pd.qcut(col, q=4) |
| One-hot | pd.get_dummies(df, columns=[...], drop_first=True) |
| Scale | StandardScaler().fit(X_train) then .transform(X_test) |
| Stratified split | train_test_split(X, y, stratify=y, random_state=42) |
Questions to sit with¶
- Eleven rows had a blank total charge and you decided it meant zero. What evidence supported that, and what would have changed your mind?
- A model that predicts nobody churns is 73.5 percent accurate. Is that model useless, and does your answer depend on what the company does with the prediction?
- Binning tenure throws away information on purpose. What exactly did you give up, and what did you buy with it?
- Every choice on this page could have gone differently. If two analysts produce different churn rates from the same file, is one of them wrong?
- Scaling must be fitted on training data only. Why is fitting on everything so tempting, and what does that tell you about how leakage usually happens?
Next¶
You have a clean, encoded, scaled, split dataset. The next page is about the ways that process quietly goes wrong, and how to build it so it cannot.
Worth your time: the scikit-learn preprocessing guide is the reference for the last three sections, and the Telco churn dataset page has community notebooks worth reading after you have formed your own view.