EDA workflow
Every previous chapter taught a tool. This one teaches the order you use them in.
Exploratory Data Analysis is the part of the job nobody writes a ticket for and everybody skips when they are in a hurry — and it is where almost every serious mistake gets caught. A model trained on a column that is 60% missing, a report averaging a value that has two populations inside it, a "record month" that is really a duplicated import: all of these are found in twenty minutes of EDA and all of them survive to production without it.
What EDA is for
EDA has three jobs, and they are worth separating because students tend to only do the third.
| Job | The question | What it protects you from |
|---|---|---|
| 1. Understand the data | What is one row? What does each column mean? What are the units? | Answering the wrong question perfectly |
| 2. Check the data | What is missing, duplicated, impossible or mis-typed? | Confidently reporting a number that is simply wrong |
| 3. Find the signal | What relationships, groups and trends are actually in here? | Missing the finding you were paid to find |
EDA is what a doctor does before prescribing: temperature, pulse, blood pressure, listen to the chest. None of it is the treatment. All of it decides which treatment is safe. A data scientist who skips EDA and goes straight to modelling is prescribing without examining the patient.
You are not looking for the answer yet. You are looking for reasons your answer would be wrong. That reframing changes what people actually do — they start hunting for NULLs and duplicates instead of rushing to a correlation.
The six-step checklist
Give students a fixed order. Under pressure, a checklist beats intuition.
The order is not arbitrary. Step 2 changes how you read step 3; step 3 tells you which pairs in step 4 are worth looking at; step 6 comes last because you cannot tell an outlier from a typo until you know what normal looks like.
Step 1 — shape and types
import sqlite3 import pandas as pd import numpy as np import seaborn as sns import matplotlib.pyplot as plt sns.set_theme(style="whitegrid") penguins = sns.load_dataset("penguins") print(penguins.shape) # (344, 7) — rows, columns penguins.head() # what does one row actually look like? penguins.info() # dtypes + non-null counts, in one view penguins.describe() # numeric summary penguins.describe(include="object") # categorical summary
(344, 7) <class 'pandas.core.frame.DataFrame'> RangeIndex: 344 entries, 0 to 343 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 species 344 non-null object 1 island 344 non-null object 2 bill_length_mm 342 non-null float64 3 bill_depth_mm 342 non-null float64 4 flipper_length_mm 342 non-null float64 5 body_mass_g 342 non-null float64 6 sex 333 non-null object dtypes: float64(4), object(3) memory usage: 18.9+ KB
info() is the highest-value single call in pandas: it gives you the row count, every column name, every dtype and the non-null count all at once. Read it before anything else.
Before any statistics, say the sentence out loud: "one row is one penguin", "one row is one enrollment", "one row is one product per day". If you cannot finish that sentence, you do not understand the table yet and every average you compute will be over the wrong denominator.
This is exactly where a JOIN bites you: after joining, one row may no longer be one student — it may be one student per enrollment. That is Chapter 5's fan-out, seen from the other side.
Three that matter constantly:
- Dates as
object— nothing time-based works untilpd.to_datetime(). - Numbers as
object— usually a stray"N/A", a thousands separator or a currency symbol.pd.to_numeric(col, errors="coerce")turns the junk into NaN so you can count it. - Integers as
float64— that is SQLNULLarriving asNaN(Chapter 12). Not a bug, but a signal that the column has missing values.
Step 2 — missing data
# how much is missing, per column, as a count and a percentage miss = pd.DataFrame({ "missing": penguins.isna().sum(), "pct": (penguins.isna().mean() * 100).round(1) }).sort_values("missing", ascending=False) print(miss) # which rows are incomplete? penguins[penguins.isna().any(axis=1)]
missing pct sex 11 3.2 bill_depth_mm 2 0.6 bill_length_mm 2 0.6 flipper_length_mm 2 0.6 body_mass_g 2 0.6 island 0 0.0 species 0 0.0
Then ask the question that matters more than the count: is it missing at random? If the missing rows are concentrated in one group, dropping them silently biases everything downstream.
# are the missing values spread evenly across species?
penguins.assign(missing_sex=penguins.sex.isna()) \
.groupby("species")["missing_sex"].mean().round(3)
df.dropna() removes every row with any missing value. On a wide table that can delete most of your data to fix one bad column — and it does it silently. Always print the shape before and after:
python run
before = len(df)
df = df.dropna(subset=["body_mass_g"]) # only the column you actually need
print(f"dropped {before - len(df)} of {before} rows")
Filling is equally a decision. fillna(0) on a grade turns "not marked yet" into "scored zero" — the same trap as COALESCE(grade, 0) in Chapter 3. Fill with the mean, the median, a group median, or leave it NULL — but choose, and write down why.
Step 3 — one variable at a time
Before any relationship, look at each column alone. For a numeric column you want the shape, the centre, the spread and the tails:
fig, axes = plt.subplots(1, 3, figsize=(15, 4)) sns.histplot(data=penguins, x="body_mass_g", kde=True, ax=axes[0]) sns.boxplot(data=penguins, y="body_mass_g", ax=axes[1]) sns.ecdfplot(data=penguins, x="body_mass_g", ax=axes[2]) plt.tight_layout()
For a categorical column you want the levels and their balance:
penguins["species"].value_counts() penguins["species"].value_counts(normalize=True).round(3) # as proportions sns.countplot(data=penguins, x="species", hue="species", legend=False)
Four things to write down for every numeric column: is it symmetric or skewed, is it unimodal or multimodal, is the range plausible, and how many distinct values does it have (a "numeric" column with 3 distinct values is really a category).
On a right-skewed column (income, price, session length) the mean is pulled above the median by the long tail. Reporting the mean salary of a company where the founder is included is technically correct and practically dishonest. When mean and median diverge noticeably, say so in the report and prefer the median.
Step 4 — two variables
There are only three combinations, and each has a standard chart.
| Pair | Chart | Statistic | Chapter |
|---|---|---|---|
| numeric × numeric | scatterplot, regplot, jointplot |
Correlation | 15, 19 |
| numeric × categorical | boxplot, violinplot, barplot |
Group means/medians | 17 |
| categorical × categorical | countplot with hue, heatmap of crosstab |
Cross-tabulation | 17, 18 |
# numeric x numeric sns.scatterplot(data=penguins, x="flipper_length_mm", y="body_mass_g", hue="species") # numeric x categorical sns.boxplot(data=penguins, x="species", y="body_mass_g", hue="species", legend=False) # categorical x categorical — proportions, not raw counts ct = pd.crosstab(penguins["island"], penguins["species"], normalize="index") sns.heatmap(ct, annot=True, fmt=".2f", cmap="Blues")
A raw count heatmap of island × species mostly shows which island has more penguins. normalize="index" turns each row into proportions, which is what "what is the species mix on this island?" actually asks. Choosing between "index", "columns" and "all" is the analytical question.
Step 5 — many variables at once
# correlation matrix — numeric_only is required on pandas 2+ corr = penguins.corr(numeric_only=True) sns.heatmap(corr, annot=True, fmt=".2f", cmap="coolwarm", center=0, vmin=-1, vmax=1) # every numeric pair at once, coloured by group sns.pairplot(penguins, hue="species", diag_kind="kde", corner=True)
A pairplot with hue is often the single highest-information chart in an entire analysis. On the penguins data it shows in one glance that the three species separate almost perfectly on bill and flipper measurements — which is the whole finding.
A relationship that holds overall can reverse inside every subgroup. In the penguins data, bill length and bill depth look negatively correlated overall — but within each species the relationship is clearly positive. The overall correlation is an artefact of Gentoo penguins having long, shallow bills.
Any conclusion drawn from the pooled scatter would be exactly backwards. Always colour by the obvious grouping variable before believing a correlation.
Step 6 — outliers and data quality
An outlier is not automatically an error. Sort them into three buckets:
| Kind | Example | What to do |
|---|---|---|
| Impossible | Age 200; negative price; date in 2154 | Fix or remove — it is a data-entry or unit bug |
| Sentinel value | -999, 0 for "unknown", 1900-01-01 |
Convert to NaN before it poisons the mean |
| Genuinely extreme | One customer worth 40% of revenue | Keep it — it is often the finding |
# the standard 1.5 x IQR rule — the same rule the boxplot whiskers use q1, q3 = penguins["body_mass_g"].quantile([0.25, 0.75]) iqr = q3 - q1 lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr outliers = penguins[(penguins.body_mass_g < lo) | (penguins.body_mass_g > hi)] print(f"{len(outliers)} outliers outside [{lo:.0f}, {hi:.0f}]") # sentinel hunt: suspiciously round or repeated values penguins["body_mass_g"].value_counts().head(10)
0 outliers outside [1750, 6550]
Removing points because they are inconvenient is fabrication. Remove a value only when you can say why it is wrong — a unit error, a test record, an impossible date. If it is merely extreme, keep it and consider a log scale, a robust statistic (median, IQR) or a note in the report. "I dropped the top 1%" is a sentence that must appear in your methodology if it happened at all.
The rest of the data-quality sweep is quick and worth doing every time:
df.duplicated().sum() # exact duplicate rows
df.duplicated(subset=["student_id", "course_id"]).sum() # business-key duplicates
df["city"].str.strip().str.lower().nunique() # 'Tashkent ' vs 'tashkent' vs 'Tashkent'
df.nunique() # constant columns (1) or ID-like columns (== len(df))
Which plot for which question The table to put on the classroom wall.
| Your question | Data | Plot |
|---|---|---|
| How is this spread out? | 1 numeric | histplot, kdeplot, ecdfplot |
| How many of each? | 1 categorical | countplot |
| Do these two move together? | 2 numeric | scatterplot, jointplot |
| Is there a trend? | 2 numeric | regplot, lmplot |
| How does it change over time? | time + numeric | lineplot |
| Does it differ between groups? | category + numeric | boxplot, violinplot |
| What is the average per group? | category + numeric | barplot (a mean, not a count) |
| How do two categories interact? | 2 categorical | heatmap of a crosstab |
| What relates to what, overall? | many numeric | correlation heatmap, pairplot |
| Does the pattern hold in every subgroup? | anything + category | col= faceting, FacetGrid |
| Where are the extreme values? | 1 numeric | boxplot |
A full worked EDA
Twenty minutes on the titanic dataset, following the checklist end to end. This is the version to run live in class.
Steps 1–2: shape, types, missingness
titanic = sns.load_dataset("titanic") print(titanic.shape) # (891, 15) — one row is one passenger titanic.info() pd.DataFrame({"missing": titanic.isna().sum(), "pct": (titanic.isna().mean()*100).round(1)}) \ .query("missing > 0").sort_values("missing", ascending=False)
(891, 15) <class 'pandas.core.frame.DataFrame'> RangeIndex: 891 entries, 0 to 890 Data columns (total 15 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 survived 891 non-null int64 1 pclass 891 non-null int64 2 sex 891 non-null object 3 age 714 non-null float64 4 sibsp 891 non-null int64 5 parch 891 non-null int64 6 fare 891 non-null float64 7 embarked 889 non-null object 8 class 891 non-null category 9 who 891 non-null object 10 adult_male 891 non-null bool 11 deck 203 non-null category 12 embark_town 889 non-null object 13 alive 891 non-null object 14 alone 891 non-null bool dtypes: bool(2), category(2), float64(2), int64(4), object(5) memory usage: 80.7+ KB
columnmissingpctwhat to do
deck68877.2Too sparse to use as-is — drop it, or treat "unknown" as its own level
age17719.9Substantial. Do not drop the rows — impute, or analyse with and without
embarked / embark_town20.2Negligible — safe to drop those two rows
Note immediately that 891 − 177 = 714: any statement about age is based on 714 passengers, not 891. That sentence belongs in the report.
Step 3: one variable at a time
fig, axes = plt.subplots(1, 3, figsize=(15, 4)) sns.histplot(data=titanic, x="age", bins=30, kde=True, ax=axes[0]) sns.countplot(data=titanic, x="pclass", hue="pclass", legend=False, ax=axes[1]) sns.histplot(data=titanic, x="fare", bins=40, ax=axes[2]) axes[2].set_title("fare is heavily right-skewed") plt.tight_layout() titanic["survived"].value_counts(normalize=True).round(3) # ~38% survived
Age is roughly symmetric with a bump of young children. Fare is severely right-skewed — a few very expensive tickets — so its mean is far above its median, and any "average fare" needs care.
Step 4: two variables — the actual question
fig, axes = plt.subplots(1, 3, figsize=(15, 4)) sns.barplot(data=titanic, x="sex", y="survived", hue="sex", legend=False, ax=axes[0]) sns.barplot(data=titanic, x="pclass", y="survived", hue="pclass", legend=False, ax=axes[1]) sns.boxplot(data=titanic, x="survived", y="age", hue="survived", legend=False, ax=axes[2]) axes[0].set_title("survival rate by sex") axes[1].set_title("survival rate by class") plt.tight_layout()
survived is coded 0/1, so its mean is the survival rate — 0.74 means 74% survived. A barplot of a 0/1 column is a proportion chart, and the error bars are a genuine confidence interval on that proportion. This is one of the most useful tricks in the whole guide.
Step 5: many variables, and the interaction
# does class change the effect of sex? (it does — a lot) sns.catplot(data=titanic, x="pclass", y="survived", hue="sex", kind="bar", height=4, aspect=1.3) # the same thing as a grid of numbers pd.crosstab(titanic["sex"], titanic["pclass"], values=titanic["survived"], aggfunc="mean").round(2)
The finding: survival is dominated by sex, and modulated by class. Women in first class survived at a far higher rate than women in third; men in third class fared worst of all. A single-variable summary would have missed the interaction entirely — which is the whole reason step 5 exists.
Step 6: outliers and quality
titanic[titanic["fare"] > 300][["pclass", "fare", "survived"]] # real, not errors
(titanic["fare"] == 0).sum() # 15 zero fares — crew or staff? investigate
titanic.duplicated().sum()
The £512 fares are genuine first-class tickets, so they stay. The zero fares are the interesting anomaly — worth a footnote, and exactly the kind of thing that only surfaces because you looked.
EDA anti-patterns
| Anti-pattern | Why it hurts | Do instead |
|---|---|---|
| Straight to modelling | Garbage in, confident garbage out | Run the six steps first — it is twenty minutes |
df.dropna() as reflex |
Can delete most of the data silently and bias what remains | subset= the column you need; print rows lost |
Reporting a mean with no n and no spread |
An average of 3 values looks identical to an average of 3,000 | Always show count and a spread measure |
Never using hue |
Simpson's paradox reverses your conclusion | Colour by the obvious grouping variable |
| Deleting inconvenient outliers | Fabrication | Remove only what you can prove is wrong; document it |
| Plotting before looking at dtypes | Dates as strings sort alphabetically; numbers as text do not aggregate | info() first, every time |
| Trusting a correlation without a scatter | Anscombe (Chapter 19) | Plot the pair before quoting r |
| Pulling the whole table into pandas | Slow, or out of memory | Aggregate in SQL first (Chapter 12) |
| Not writing anything down | You will not remember why you dropped those rows | Keep a findings list as you go |
A numbered list, written during the EDA, one line per observation: "1. age missing for 20% of passengers; 2. fare is right-skewed, use median; 3. 15 fares are exactly 0 — unexplained; 4. survival depends on sex and class, and they interact."
That list is the deliverable. The charts are how you found it, but nobody reads twenty charts — they read four sentences.
Summary
- EDA has three jobs: understand the data, check the data, find the signal. Most people only do the third.
- Work the checklist in order: shape & types → missing → one variable → two variables → many variables → outliers.
- Always answer "what is one row?" first. After a JOIN the answer may have changed — that is fan-out from Chapter 5.
info()is the single highest-value call in pandas: rows, dtypes and non-null counts at once.- Missingness is a finding, not a chore. Measure how much, check whether it is random, and treat
dropna()/fillna()as decisions you document. - Report the median when a column is skewed, and never a mean without its n.
- Always colour by the obvious group — Simpson's paradox can reverse a pooled correlation completely.
- Sort outliers into impossible, sentinel and genuinely extreme. Only the first two get removed, and only with a reason.
- A
barplotof a 0/1 column is a proportion chart — one of the most useful tricks here. - The deliverable is the findings list, not the charts.
Next: Chapter 23 collects every Seaborn gotcha into one revision catalogue and closes Part II.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.