At the end of the previous module we promised to put statistics to work on real, imperfect data, and here we deliver on that promise. MercaFresh's datasets — like those of any real company — arrive with duplicates, data-entry errors, impossible values and categories spelled three different ways. No algorithm from module 4 can compensate for corrupt input data: if we train the churn model on duplicated customers or negative ages, it will learn patterns that don't exist. In this lesson you'll learn how to audit a dataset, detect its problems and fix them systematically with pandas. According to most practitioners, this is the phase where an ML project spends the most time: doing it well makes the difference between a useful model and a misleading one.
Contents
- Why real-world data is dirty
- The MercaFresh customer dataset
- Initial audit: know the terrain
- Duplicates: exact and logical
- Data-entry errors and impossible values
- Text and category inconsistencies
- Incorrect data types
- Outliers: detecting them and deciding what to do
Why real-world data is dirty
Tutorial datasets come clean; company datasets don't. The causes are always similar:
- Manual entry: a MercaFresh call-center agent types "BCN" one day and "Barcelona" the next.
- System integration: the website, the mobile app and the call center store data in different formats (dates
2025-03-14vs14/03/2025). - Software bugs: a bug recorded order amounts as €0 for two weeks.
- Historical migrations: when the CRM was replaced in 2023, some customers were imported twice.
- Sentinel values: someone used
-1or999to mean "unknown", and now they look like real data.
The practical consequence is summed up by the classic phrase garbage in, garbage out: a model trained on garbage produces garbage predictions, but with the appearance of mathematical precision. That's why cleaning is not a formality — it's part of the analysis.
flowchart LR
A[Raw data] --> B[Audit]
B --> C[Duplicates]
C --> D[Impossible values]
D --> E[Text and categories]
E --> F[Data types]
F --> G[Outliers]
G --> H[Clean dataset]
This is the order we'll follow: first understand, then fix, from the most obvious to the most subtle.
The MercaFresh customer dataset
We'll work with a fictional but realistic extract of the customer table, exported from the CRM for the churn project. Let's build it in code so you can reproduce everything that follows:
import pandas as pd
import numpy as np
data = {
"customer_id": [101, 102, 102, 104, 105, 106, 107, 108, 109, 110],
"name": ["Ana Ruiz", "Luis Gil", "Luis Gil", "Marta Vega", "Joan Pons",
"Sara Mora", "Pau Serra", "Eva Lima", "Leo Cano", "Iris Bou"],
"age": [34, 29, 29, -5, 41, 38, 127, 45, 31, 27],
"city": ["Barcelona", "BCN", "BCN", " barcelona ", "Valencia",
"VALENCIA", "Madrid", "madrid", "Sevilla", "Barcelona"],
"total_spend": ["1250.50", "890.00", "890.00", "0", "2100.75",
"310.20", "15400.00", "670.40", "0", "980.10"],
"signup_date": ["2023-05-10", "2024-01-15", "2024-01-15", "2023-11-02",
"2022-07-30", "2027-03-01", "2021-02-14", "2023-09-19",
"2024-06-05", "2023-12-28"],
"num_orders": [18, 12, 12, 0, 25, 4, 210, 9, 0, 14],
}
df = pd.DataFrame(data)
print(df)Even at a glance you can sense trouble: customer 102 appears twice, there's an age of -5 and another of 127, the city "Barcelona" is written four different ways, the spend is stored as text and there's a signup date in 2027 (in the future!). In a 10-row dataset you spot these instantly; in one with 200,000 rows you need a method.
Initial audit: know the terrain
Before touching anything, you need an X-ray of the dataset. Three pandas functions cover 80% of the initial audit:
info() answers three questions: how many rows are there? what type is each column? how many non-null values does each one have? Here we discover the first silent problem: total_spend is of type object (text), not numeric. An object where you expected numbers is a red flag.
describe() gives us the mean, standard deviation, minimum, maximum and quartiles — exactly the statistics from module 2 (lesson 02-01). Its great value in cleaning is checking min and max: a minimum age of -5 and a maximum of 127 need no further analysis to know something is wrong.
# 3) Cardinality: how many distinct values each column has
print(df.nunique())
print(df["city"].unique())nunique() counts distinct values per column. If we know MercaFresh operates in 4 cities but city has 7 unique values, there are spelling inconsistencies. unique() shows them to us: ['Barcelona' 'BCN' ' barcelona ' 'Valencia' 'VALENCIA' 'Madrid' 'madrid' 'Sevilla'].
| Function | Question it answers | Problem it uncovers |
|---|---|---|
info() |
What types, and how many nulls? | Wrong types, incomplete columns |
describe() |
What ranges do the numbers take? | Impossible values, strange scales |
nunique() / unique() |
How many distinct categories? | Categories duplicated by spelling |
Duplicates: exact and logical
Exact duplicates
An exact duplicate is a row identical to another in every column. At MercaFresh, customer 102 (Luis Gil) was imported twice during the CRM migration:
# How many exact duplicate rows are there?
print(df.duplicated().sum()) # 1
# View them (keep=False shows ALL copies, not just the second one)
print(df[df.duplicated(keep=False)])
# Remove them, keeping the first occurrence
df = df.drop_duplicates()
print(len(df)) # 9 rowsKey points about drop_duplicates:
keep="first"(the default) keeps the first copy;keep="last", the last one;keep=Falseremoves all of them.- It returns a new DataFrame: you have to reassign it (
df = df.drop_duplicates()).
Why does this matter for the churn model? A duplicated customer weighs twice as much in training: the model will "see" their patterns twice and skew its conclusions toward that profile.
Logical duplicates
More dangerous are logical duplicates: rows that represent the same entity but are not byte-for-byte identical. For example, the same customer signed up twice with their spending split across both records, or the same order logged by both the website and the app with different ids. To hunt them down, you look for duplication only in the columns that define identity:
# Are there repeated customer ids even if the other columns differ?
print(df.duplicated(subset=["customer_id"]).sum())
# Or identity by name + signup date
suspects = df[df.duplicated(subset=["name", "signup_date"], keep=False)]With logical duplicates, deleting blindly is risky: sometimes the right move is to merge (add up the orders from both records) instead of removing. It's a business decision, not just a technical one: always ask what each row represents.
Data-entry errors and impossible values
An impossible value is one that violates the rules of the real world or of the business. The technique is to define explicit validity rules and check how many rows break them:
# Rule 1: age must be between 18 and 100 (MercaFresh requires customers to be adults)
invalid_ages = df[(df["age"] < 18) | (df["age"] > 100)]
print(invalid_ages[["customer_id", "age"]])
# 104 -> -5 (sign error or typo)
# 107 -> 127 (did they type 12 and 7 together? is it 27?)
# Rule 2: a customer with orders must have spend > 0
# (first we'll convert total_spend to a number; see the data types section)
# Rule 3: the signup date cannot be in the future
dates = pd.to_datetime(df["signup_date"])
future_dates = df[dates > pd.Timestamp("2026-08-24")]
print(future_dates[["customer_id", "signup_date"]]) # 106 -> 2027-03-01And what do we do with them? There are three options, in order of preference:
- Correct, if we know the error: an age of -5 with a flipped sign could be 5 (impossible, a minor) or a typo; if the original CRM has the good value, recover it from there.
- Mark as missing (
np.nan), if we can't know the real value: it's honest to admit we don't know, and in the next lesson we'll see how to handle those nulls. - Delete the row, only if the whole record is unrecoverable and there are few such cases.
# Option 2 applied: turn impossible values into NaN
df.loc[(df["age"] < 18) | (df["age"] > 100), "age"] = np.nan
df.loc[pd.to_datetime(df["signup_date"]) > pd.Timestamp("2026-08-24"),
"signup_date"] = np.nanNote the pattern df.loc[condition, column] = value: it's the safe way in pandas to modify a subset (it avoids the infamous SettingWithCopyWarning).
The €0 amounts deserve separate reflection: a total_spend of 0 with num_orders of 0 can be legitimate (a registered customer who never bought — highly relevant for churn!), while a spend of 0 with 12 orders is an error. The same figure can be valid data or an error depending on context: validate combinations of columns, not columns in isolation.
Text and category inconsistencies
The city column is the perfect example: "Barcelona", "BCN" and " barcelona " are the same city to a human, but three different categories to pandas and to any model. If we don't fix it, the upcoming one-hot encoding (lesson 03-04) will create three columns where there should be one.
Normalization follows a three-step recipe using the str.* string methods:
# Step 1: remove stray whitespace at the start and end
df["city"] = df["city"].str.strip()
# Step 2: unify upper/lower case
df["city"] = df["city"].str.lower()
print(df["city"].unique())
# ['barcelona' 'bcn' 'valencia' 'madrid' 'sevilla']
# Step 3: map aliases and abbreviations to a canonical value
aliases = {"bcn": "barcelona", "vlc": "valencia", "mad": "madrid"}
df["city"] = df["city"].replace(aliases)
# Final cosmetic touch: capitalize
df["city"] = df["city"].str.title()
print(df["city"].value_counts())
# Barcelona 3
# Madrid 2
# Valencia 2
# Sevilla 1An important detail: str.strip() and str.lower() transform the text character by character, while replace() substitutes whole values using a dictionary. For substitutions inside the text (for example, removing the periods from "S.L.") there is str.replace("...", "..."), which is a different method — confusing the two is a classic mistake.
To discover aliases you didn't know about, value_counts() is your friend: it sorts categories by frequency, and the rare variants show up at the bottom of the list with counts of 1 or 2.
Incorrect data types
A number stored as text is an invisible problem until it blows up: you can't compute means, sorting is alphabetical ("15400" < "890" because "1" < "8") and describe() ignores it.
# total_spend arrived as text: convert it to float
df["total_spend"] = df["total_spend"].astype(float)
# If there were unconvertible values ("N/A", "error"), astype would fail.
# to_numeric with errors="coerce" turns them into NaN instead of breaking:
df["total_spend"] = pd.to_numeric(df["total_spend"], errors="coerce")
# Dates stored as text are converted with to_datetime
df["signup_date"] = pd.to_datetime(df["signup_date"], errors="coerce")
print(df.dtypes)| Situation | Tool | Behavior on errors |
|---|---|---|
| Clean numeric text | astype(float) / astype(int) |
Raises an exception |
| Numeric text with garbage | pd.to_numeric(..., errors="coerce") |
Turns garbage into NaN |
| Dates as text | pd.to_datetime(..., errors="coerce") |
Turns garbage into NaT |
| Ambiguous date format | pd.to_datetime(..., format="%d/%m/%Y") |
Enforces the given format |
The format parameter deserves attention: "14/03/2025" could be March 14 or (in the American convention) an error. Specifying format="%d/%m/%Y" removes the ambiguity. Converting dates to real datetime also unlocks operations we'll use in lesson 03-03: extracting the month, computing tenure, subtracting dates.
Outliers: detecting them and deciding what to do
You already know outliers from module 2: in 02-01 you saw them poke out of the boxplots of order amounts, and in 02-02 you learned that a high z-score signals an oddity. Now we apply them as cleaning tools on total_spend.
Detection with z-score
spend = df["total_spend"].dropna()
z = (spend - spend.mean()) / spend.std()
print(df.loc[z.abs() > 3, ["customer_id", "total_spend"]])A reminder from 02-02: if the data were normal, only 0.3% of values would fall beyond |z| > 3. Customer 107, with €15,400 in spending and 210 orders, is off the charts. The z-score has a weakness: the mean and standard deviation it uses are contaminated by the outliers themselves, so in small or heavily skewed samples it can fail.
Detection with IQR
The interquartile range method (02-01) is more robust because the quartiles barely flinch at extreme values:
q1 = spend.quantile(0.25)
q3 = spend.quantile(0.75)
iqr = q3 - q1
lower_bound = q1 - 1.5 * iqr
upper_bound = q3 + 1.5 * iqr
outliers = df[(df["total_spend"] < lower_bound) | (df["total_spend"] > upper_bound)]
print(outliers[["customer_id", "total_spend"]])It's exactly the rule that draws the "whiskers" of the boxplot you already know.
The decision: error or reality?
Here is the nuance that separates mechanical cleaning from good cleaning: an outlier is not necessarily an error. Customer 107 could be a restaurant that buys from MercaFresh daily — an extremely valuable customer, not corrupt data. The options:
| Situation | Recommended action |
|---|---|
| Clearly erroneous outlier (age 127) | Correct or convert to NaN |
| Real but atypical outlier (restaurant customer) | Keep; perhaps flag it with an is_business column |
| Real outlier that distorts the model | Cap it (capping/winsorizing: clip at the 99th percentile) or transform the variable (lesson 03-03) |
| Many structural outliers | Detecting them is itself a business goal (anomalies, module 5) |
# Example of capping at the 99th percentile
p99 = df["total_spend"].quantile(0.99)
df["total_spend"] = df["total_spend"].clip(upper=p99)The golden rule: never remove an outlier without being able to explain why. Document every decision — your future self (and the churn model) will thank you.
Common Mistakes and Tips
- Cleaning without auditing first. Running
drop_duplicates"just in case" before understanding the data can delete legitimate records. Firstinfo(),describe(),nunique(); then act. - Forgetting to reassign.
df.drop_duplicates()withoutdf =changes nothing: most pandas operations return a copy. - Confusing
replace()withstr.replace(). The first substitutes whole values in the Series; the second, substrings inside each text. - Automatically removing outliers with |z| > 3. It's a detector, not a judge: every outlier deserves a diagnosis (error or exceptional customer?).
- Validating columns in isolation. A spend of 0 may or may not be valid depending on
num_orders: powerful validity rules cross columns. - Not documenting the changes. Record the number of rows removed and the rules applied; in a real project you'll be held accountable for every discarded record.
- Tip: write the cleaning as a reproducible function (
def clean_customers(df): ...) instead of loose notebook cells. When next month's export arrives, you'll apply it in seconds.
Exercises
Exercise 1
With the original df DataFrame from the lesson (before cleaning), write the code that: (a) counts the exact duplicates, (b) removes them keeping the first occurrence, and (c) checks whether any repeated customer_id remains.
Exercise 2
The MercaFresh team hands you this column from a new export: ["Madrid", "MAD", " madrid", "Màdrid", "madriz"]. Normalize it so that every value ends up as "Madrid". Hint: you'll need strip, lower and an alias dictionary that includes the typos.
Exercise 3
The orders table has an order_amount column with these values: [22.5, 19.0, 24.3, 21.7, 480.0, 23.1, 20.9, 18.4]. Detect outliers with the IQR method and reason (in a comment) whether the detected value should be removed, kept or investigated, knowing that MercaFresh also serves small restaurants.
Solutions
Exercise 1
# (a) Count exact duplicates
print(df.duplicated().sum()) # 1
# (b) Remove them, keeping the first occurrence
df = df.drop_duplicates(keep="first")
# (c) Any repeated ids left? (logical duplicates)
print(df.duplicated(subset=["customer_id"]).sum()) # 0 in this caseIf (c) returned more than 0, you would inspect those rows with keep=False and decide whether to merge or remove.
Exercise 2
s = pd.Series(["Madrid", "MAD", " madrid", "Màdrid", "madriz"])
s = s.str.strip().str.lower() # 'madrid', 'mad', 'madrid', 'màdrid', 'madriz'
aliases = {"mad": "madrid", "màdrid": "madrid", "madriz": "madrid"}
s = s.replace(aliases)
s = s.str.title()
print(s.unique()) # ['Madrid']Typos (madriz) can't be fixed with general rules: you have to discover them with value_counts() and add them to the alias dictionary one by one.
Exercise 3
amounts = pd.Series([22.5, 19.0, 24.3, 21.7, 480.0, 23.1, 20.9, 18.4])
q1, q3 = amounts.quantile(0.25), amounts.quantile(0.75)
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
print(amounts[(amounts < lower) | (amounts > upper)]) # 480.0
# Reasoning: €480 is a clear statistical outlier, but MercaFresh serves
# restaurants: it could be a legitimate wholesale order. Before removing it,
# investigate: does the customer have a history of large orders? Do they
# match a business-type customer? If legitimate, keep it (and perhaps flag
# it with a 'wholesale_order' column); remove it only if confirmed an error.Conclusion
In this lesson you've learned to turn a chaotic dataset into a trustworthy one: audit with info(), describe() and nunique(); remove exact duplicates and diagnose logical ones with drop_duplicates; define validity rules to hunt down impossible values; unify categories with str.strip/lower and replace; fix types with astype, to_numeric and to_datetime; and detect outliers with z-score and IQR, knowing that detecting them is not sentencing them. The central idea: cleaning is analysis, not paperwork — every correction is a decision you must be able to explain.
You'll have noticed that several times the honest solution was to turn a suspicious value into NaN: we've been deliberately accumulating gaps. In the next lesson we tackle exactly that: MercaFresh's missing data, why it's missing (which is not always random) and the strategies for dropping or imputing it without deceiving the model.
Machine Learning Course
Module 1: Introduction to Machine Learning
- What is Machine Learning?
- History and evolution of Machine Learning
- Types of Machine Learning
- Applications of Machine Learning
- The Machine Learning project workflow
Module 2: Foundations of Statistics and Probability
- Basic statistics concepts
- Probability distributions
- Correlation and covariance
- Statistical inference
- Bayes' theorem
Module 3: Data Preprocessing
- Data cleaning
- Handling missing data
- Data transformation
- Encoding categorical variables
- Normalization and standardization
- Feature engineering
Module 4: Supervised Machine Learning Algorithms
- Linear regression
- Logistic regression
- Decision trees
- Support Vector Machines (SVM)
- K-Nearest Neighbors (K-NN)
- Naive Bayes
- Neural networks
Module 5: Unsupervised Machine Learning Algorithms
- Clustering: K-means
- Hierarchical clustering
- Principal Component Analysis (PCA)
- DBSCAN clustering
- Data visualization with t-SNE and UMAP
Module 6: Model Evaluation and Validation
- Data splitting: training, validation and test
- Evaluation metrics
- Cross-validation
- ROC curve and AUC
- Overfitting and underfitting
Module 7: Advanced Techniques and Optimization
- Regularization: Ridge, Lasso and Elastic Net
- Ensemble Learning
- Gradient Boosting
- Deep neural networks (Deep Learning)
- Hyperparameter optimization
Module 8: Model Implementation and Deployment
- Popular frameworks and libraries
- Deploying models to production
- Model maintenance and monitoring
- Ethical and privacy considerations
Module 9: Hands-On Projects
- Project 1: Housing price prediction
- Project 2: Image classification
- Project 3: Sentiment analysis on social media
- Project 4: Fraud detection
- Project 5: Customer segmentation
