Data Preprocessing¶
85 min Real data The capstone of this group
Everything in this trimester so far has been one stage of a single process. This page puts the stages in order, names them properly, and covers the two things that turn a working notebook into a pipeline somebody else can trust.
Why this matters¶
The next module is about models, and you will discover quickly that the model is rarely the interesting part. Choosing between a random forest and a logistic regression might move your score a couple of points. Fixing how your data was prepared moves it far more, and it is the difference between a result that holds up and one that quietly does not.
This page is also your project brief. The questions at the end are the ones you answer about your own dataset, in writing, before you build anything.
The whole pipeline¶
Two boxes are marked because they are the ones people get wrong.
Store the raw data and never edit it. Your cleaning script reads the raw file and writes a new one. When you discover in week three that a decision was wrong, you change the script and rerun it. If you edited the original, you cannot.
Split before you transform. Everything that learns something from the data, a scaler, an imputer, an encoder, must learn it from the training set alone. This has a name and it gets its own section.
The vocabulary, untangled¶
These words appear in job adverts as if they were interchangeable. They are not, and knowing the difference is worth something in an interview.
Data engineering is the widest. Moving data from where it is to where you need it, and keeping it there: pipelines, storage, scheduling, reliability. It is a job title as well as an activity.
Data wrangling, also called munging, is getting a messy dataset into a usable shape. It covers the next two.
Data cleansing fixes what is wrong. Wrong types, duplicates, impossible values, missing entries. You did this on the churn file.
Data transformation changes the form without changing the meaning. Encoding, scaling, reshaping, aggregating.
Feature engineering adds what was not there. A ratio, a count, a band, a flag. This is the part with the most room for skill, because it is where domain knowledge enters.
EDA is not a stage at all. It runs alongside every one of the others, whenever you want to know what you are actually holding.
One sentence that separates them
Cleansing fixes errors. Transformation changes form. Feature engineering creates information that was implicit in the data and is now explicit.
Where your data comes from¶
The first questions in your brief are about sources, and they matter more than they look.
One source or several? Combining sources means joining them, and every join is a chance to silently lose or duplicate rows. You saw this in the Pandas page: check the row count before and after, every time.
Do the sources agree? Two systems will spell the same city three ways, store dates in two formats, and disagree about the same customer's details. Decide which one wins, and write that decision down.
How fresh is each one? Joining a table refreshed daily to one refreshed quarterly means three months of your rows are wrong in a way nothing will warn you about.
merged = customers.merge(usage, on="customer_id", how="left", validate="one_to_one")
print(len(customers), len(merged), merged["usage_minutes"].isna().sum())
validate raises if the relationship is not what you claimed, which is far
better than discovering it three steps later.
Formats and storage¶
Where you keep data is part of the design, not an afterthought.
| Format | Good for | Watch out for |
|---|---|---|
| CSV | Sharing, reading by eye, universal | Loses dtypes, slow, no compression |
| JSON | Nested structures, API responses | Awkward as a table, large |
| Parquet | Working storage for anything sizeable | Not human readable |
| SQL | Data that is queried and updated | Needs a server, needs SQL |
df.to_parquet("data/processed/churn_clean.parquet")
df = pd.read_parquet("data/processed/churn_clean.parquet")
Parquet keeps your dtypes, compresses well and loads far faster. After the
TotalCharges episode, a format that remembers a column was numeric is worth
having.
A layout worth adopting now:
data/
raw/ never edited, exactly as collected
interim/ partly processed, safe to delete
processed/ model ready, produced by a script
With data/raw/ in .gitignore if the files are large, and a note in the README
saying where they came from.
Hypotheses before you look¶
Your brief asks whether you have preexisting hypotheses. Write them down before you start exploring, and the reason is not tidiness.
If you explore first and form hypotheses afterwards, you will find patterns, because with enough columns some will look interesting by chance. You met this on the Probability page. Twenty features tested at the usual threshold gives you about one false finding for free.
H1: month-to-month customers churn more than contract customers
H2: churn is highest in the first year and falls after that
H3: customers with more services churn less
Three sentences, written first, and then tested. Anything else you find during exploration is still worth having, but label it as exploratory rather than confirmed, because it was not a prediction.
Sparse, dense, missing and extreme¶
Sparse data is mostly zeros or mostly empty. One-hot encoding a column with two hundred categories produces a sparse matrix, and that is normal. Text data is extremely sparse. If your matrix is mostly zeros, some models handle it gracefully and others fall over.
Missing you have covered twice. The rule that carries forward: ask why before you ask how, and record what you chose.
Outliers are new, and the mistake is treating them as noise by default.
q1, q3 = df["MonthlyCharges"].quantile([0.25, 0.75])
iqr = q3 - q1
low, high = q1 - 1.5 * iqr, q3 + 1.5 * iqr
outliers = df[(df["MonthlyCharges"] < low) | (df["MonthlyCharges"] > high)]
print(len(outliers))
That rule is the box plot whiskers you already know. What to do with what it finds depends entirely on which of three things you are looking at.
An error. An age of 200, a negative price. Fix it or remove it.
A rare but real value. A genuinely enormous customer. Keep it. It is the kind of case your model most needs to handle, and deleting it is choosing to be wrong about the customers who matter most.
A different population. Business accounts mixed in with domestic ones. The right answer is usually a separate model, or a column saying which is which.
Deleting outliers improves your metrics and may ruin your model
Removing the hard cases always makes the score go up. That is exactly why it is tempting, and exactly why it needs justifying in writing every time.
Splitting, properly¶
You know stratified splitting. There are two more cases, and using the wrong one is leakage that your test score will not reveal.
Random is fine when rows are independent of each other.
Stratified preserves class balance, which matters whenever one class is rare.
By time is required whenever you will predict the future. Train on the past, test on what came after. Split a time series randomly and your model learns from next month to predict this month, which it will never be able to do in production.
from sklearn.model_selection import TimeSeriesSplit
for train_idx, test_idx in TimeSeriesSplit(n_splits=5).split(X):
pass
By group is required when several rows belong to the same entity. If one customer has twelve monthly records and six land in train and six in test, your model has effectively seen the test customers.
from sklearn.model_selection import GroupShuffleSplit
splitter = GroupShuffleSplit(test_size=0.2, random_state=42)
train_idx, test_idx = next(splitter.split(X, y, groups=df["customer_id"]))
Ask one question of every dataset: could a row in my test set have been influenced by a row in my training set? If yes, a random split is wrong.
Leakage¶
This is the most important idea on the page.
Leakage is any information reaching your training process that would not be available when the model is used for real. It produces excellent scores and useless models, and it is hard to spot precisely because nothing goes wrong until it matters.
Four common sources:
Fitting a transformer before splitting. The scaler learns the mean of the whole dataset, including the test set.
A feature recorded after the outcome. cancellation_reason predicts churn
perfectly and is only filled in for people who already left.
A proxy for the answer. Not the label itself, but something created because of it. An internal risk flag set by the team who also decided the outcome.
Time travel. Any feature computed from data that did not exist yet at the moment you are predicting.
The test that catches most of it
For every feature, ask: would I have this value, with this content, at the moment I need to make the prediction?
If the answer is no, or if you have to think about it, it is a leak.
The other signal is a model that is too good. If your accuracy jumps to 99 percent on a hard problem, do not celebrate. Go and find out which column did it.
Making leakage structurally impossible¶
You can remember to fit on the training set only. You will forget once, and once is enough. The better answer is to build the preprocessing so that forgetting is not possible.
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
numeric = ["tenure", "MonthlyCharges", "TotalCharges"]
categorical = ["Contract", "InternetService", "PaymentMethod"]
numeric_steps = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_steps = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("encode", OneHotEncoder(handle_unknown="ignore")),
])
preprocess = ColumnTransformer([
("num", numeric_steps, numeric),
("cat", categorical_steps, categorical),
])
model = Pipeline([
("prep", preprocess),
("clf", LogisticRegression(max_iter=1000)),
])
model.fit(X_train, y_train)
print(model.score(X_test, y_test))
The whole thing is one object. fit learns every statistic from the training
data, and score applies those same statistics to the test data. There is no
line you could forget.
It also makes cross-validation correct for free, because the pipeline is refitted inside every fold.
from sklearn.model_selection import cross_val_score
scores = cross_val_score(model, X_train, y_train, cv=5, scoring="roc_auc")
print(scores.mean(), scores.std())
handle_unknown="ignore" matters. A category that appears only in the test set
would otherwise crash the encoder, and in production it would crash your service.
ML connection
In the next module you will compare several models. Doing that fairly means every one of them sees identically prepared data, which is exactly what a pipeline guarantees. Swap the last step and everything before it stays fixed.
Bias¶
Your brief asks how you will ensure the dataset is unbiased. The honest answer is that you cannot, so the real question is which biases you have and whether you have looked.
Sampling bias. Who is in your data and who is not. A churn model built on customers who answered a survey describes people who answer surveys.
Historical bias. The data faithfully records a world that was unfair. Nothing is wrong with the collection and the model still learns the unfairness.
Measurement bias. Your proxy is not the thing you care about. Using support tickets as a measure of dissatisfaction measures who complains.
Aggregation bias. One model for groups that behave differently. It ends up fitting the largest group and serving the others badly.
The practical check is to stop looking at the average.
Do this for every group you can identify, on the data before modelling and on the predictions afterwards. You are looking for groups that are small, groups whose rates are very different, and groups your model gets wrong more often.
This is the fourth time bias has appeared in this handbook. That is deliberate. It is not a topic you cover once.
Reproducibility¶
If you cannot rerun it and get the same answer, you do not have a result, you have an anecdote.
Seed everything. random_state=42 on every split and every model.
Script it. A notebook where cells were run out of order is not reproducible
even by you. When something works, move it into a .py file that runs top to
bottom.
Version the data, not just the code. At minimum, a filename with a date and a line in your README saying where it came from and when.
Write a data card. Half a page: source, collection date, row and column counts, known problems, every cleaning decision, who is represented and who is not.
That last one is the data statement from your first project, grown up. It is also the document that makes your portfolio project look professional rather than academic, because it is what a real team would require.
Your project brief¶
These are the questions to answer in writing, before you build. Most of them have a section above them.
| Question | Where to look |
|---|---|
| What are my sources, and do I need to combine them? | Where your data comes from |
| What format is it in, and what will I store it as? | Formats and storage |
| What features exist, and how will I explore them? | EDA, and the previous page |
| What do I expect to find, and how will I test it? | Hypotheses before you look |
| Is it sparse or dense? What about missing values and outliers? | Sparse, dense, missing and extreme |
| How will I split it? | Splitting, properly |
| How will I check for bias? | Bias |
| Which features do I expect to use? | Feature engineering, previous page |
| What types am I handling, and how will I transform them? | Encoding and scaling, previous page |
| Where and how will I store the result? | Formats and storage, Reproducibility |
Answer them in a document, not in your head. The answers will change as you work, and the changes are worth recording too.
When it goes wrong¶
My cross-validation score is much better than my test score
Something leaked into the cross-validation. The usual cause is preprocessing done before the split rather than inside a pipeline.
Accuracy is 99 percent on a hard problem
Look for a leaking feature. Print the feature importances or coefficients, find the one doing all the work, and ask whether you would have it at prediction time.
ValueError: Found unknown categories at prediction time
A category appeared that was not in the training data. Use
handle_unknown="ignore" in the encoder, and expect this to happen in
production.
The model works in the notebook and fails on new data
Either the new data has a different shape, which a pipeline prevents, or you transformed the training data in a way you cannot reproduce, which a pipeline also prevents.
Rerunning gives a different answer
An unseeded split, an unseeded model, or a notebook run out of order. Set
random_state everywhere and rerun from a clean kernel.
My joined table has more rows than either input
The key is not unique on one side. Check with duplicated().sum() before
merging, and use validate= to make pandas complain.
Check yourself¶
Use the churn data, or your own project data if you have it.
- Write the three-folder data layout and move a dataset into it. Nothing should
ever write to
raw/. - Write three hypotheses about your data before exploring it. Then test them and report which survived.
- Find the outliers in a numeric column with the IQR rule. Classify each one as an error, a rare real value, or a different population, and say what you did.
- Build a full
ColumnTransformerandPipelinefor your dataset and fit it in one call. - Deliberately introduce leakage by fitting a scaler before splitting. Record both scores and explain the gap.
- Report your target rate for every subgroup you can identify. Name the smallest group and say what that means for any model you build.
- Write the data card. Half a page, and no longer.
Quick reference¶
| Task | Code |
|---|---|
| Safe join | a.merge(b, on="k", how="left", validate="one_to_one") |
| Fast typed storage | df.to_parquet(path), pd.read_parquet(path) |
| Outlier bounds | q1 - 1.5 * iqr, q3 + 1.5 * iqr |
| Stratified split | train_test_split(X, y, stratify=y, random_state=42) |
| Time split | TimeSeriesSplit(n_splits=5) |
| Group split | GroupShuffleSplit(test_size=0.2) |
| Column routing | ColumnTransformer([("num", steps, cols), ...]) |
| Whole pipeline | Pipeline([("prep", preprocess), ("clf", model)]) |
| Honest validation | cross_val_score(pipeline, X_train, y_train, cv=5) |
| Unseen categories | OneHotEncoder(handle_unknown="ignore") |
| Subgroup rates | df.groupby("col")["target"].mean() |
Questions to sit with¶
- Leakage produces a better score and a worse model. What does that say about using the score as your only guide to whether the work is going well?
- You can remember to fit on the training set only, or you can use a pipeline so that forgetting is impossible. Where else in your work would that distinction be worth applying?
- Deleting outliers raises your accuracy. Under what circumstances is that an improvement, and how would you tell the difference?
- You cannot make a dataset unbiased. Given that, what is the honest thing to put in a report, and what would you refuse to claim?
- Somebody else has your code, your data and your seed. Can they reproduce your result exactly? If not, what is missing, and does it matter?
Next¶
That completes Real data, and with it the data half of the trimester. You can collect it, clean it, understand it, prepare it and store it so somebody else can rerun your work.
The next module is where a model finally sees the table you built. Almost everything that will go wrong there is something on this page.
Worth your time: the scikit-learn pipeline guide is the reference for the pipeline section, and Datasheets for Datasets is the paper that introduced the idea of the data card.